From 4759ca141afe0cdf87e73412271be47e9df19559 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 09:16:47 +0100 Subject: [PATCH 01/17] docs: add health monitoring dashboard design --- ...9-18-health-monitoring-dashboard-design.md | 279 ++++++++++++++++++ 1 file changed, 279 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-health-monitoring-dashboard-design.md diff --git a/docs/superpowers/specs/2026-09-18-health-monitoring-dashboard-design.md b/docs/superpowers/specs/2026-09-18-health-monitoring-dashboard-design.md new file mode 100644 index 0000000..ddad7c1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-health-monitoring-dashboard-design.md @@ -0,0 +1,279 @@ +# Standalone ASP.NET Health Monitoring Dashboard + +## Status + +Design approved in conversation; implementation has not started. + +## Goal + +Build a standalone ASP.NET Core monitoring application that polls ASP.NET health-check endpoints, presents current and historical service health, stores health history in SQL Server, and provides an extensible foundation for alerting. + +The first version is intended for a trusted internal network and does not include authentication or authorization. + +## Product decisions + +- Application type: standalone ASP.NET Core application. +- UI: Blazor Server. +- Persistence: SQL Server through EF Core. +- Architecture: modular monolith. +- Service configuration: managed through the dashboard and persisted in SQL Server. +- Service onboarding: idempotent REST self-registration API. +- Polling: configurable per service. +- Retention: configurable per service. +- Authentication: explicitly deferred from the first version. +- Alerting: extensible foundation; concrete notification channels are deferred. + +## User experience + +### Overview + +The overview is optimized for operational scanning and follows the approved visual direction: + +- Summary counters for Healthy, Degraded, Unhealthy, and open incidents. +- Service list showing service name, environment, endpoint, current status, response time, uptime, and last check. +- Clear status colors and text labels; status must not rely on color alone. +- Navigation from a service row to its detail page. +- Controls for adding and configuring monitored services. + +### Service detail + +The detail page shows: + +- Current status and last-check information. +- Current incident duration, when applicable. +- Uptime for selectable time ranges. +- Average response time and response-time history. +- Status timeline for the selected range. +- Dependency/check results including status, duration, description, exception, and data. +- Raw ASP.NET health response JSON for troubleshooting. +- Service configuration and status-policy editing. + +## Architecture + +The application is a modular monolith with these boundaries: + +1. **Service Registry** + - Owns monitored-service configuration and lifecycle. + - Supports dashboard editing and self-registration updates. + +2. **Registration API** + - Provides idempotent service self-registration. + - Returns whether registration created, updated, or acknowledged an existing service. + +3. **Polling Worker** + - Schedules checks independently per enabled service. + - Applies per-service interval and timeout settings. + - Uses an HTTP client with cancellation and bounded execution. + +4. **Health Normalizer** + - Parses standard ASP.NET health responses. + - Preserves the original response while deriving dashboard status. + +5. **History Store** + - Persists observations and dependency-level results. + - Applies per-service retention cleanup. + +6. **Incident Calculator** + - Maintains current service snapshots. + - Opens, extends, changes severity within, and closes incidents. + +7. **Dashboard Query Layer** + - Provides read models for overview cards, service details, timelines, metrics, and raw responses. + +8. **Alerting Adapter** + - Receives incident state changes through an interface. + - Initially supports a no-op or logging implementation. + +9. **Blazor Server UI** + - Provides overview, detail, configuration, registration, and history screens. + +The runtime flow is: + +```text +Service startup + -> idempotent registration API + -> service configuration in SQL Server + +Polling worker + -> health endpoint + -> normalize response + -> store observation + -> update current snapshot + -> open/close/update incident + -> dispatch alert event + +Blazor UI + -> query current state and history + -> display overview, details, and configuration +``` + +## Service configuration + +The service record will include: + +- Stable `serviceId`. +- Display name. +- Environment. +- Optional group and description. +- Health URL. +- Enabled state. +- Polling interval. +- Request timeout. +- Retention period. +- Status policy. +- Registration metadata such as version, host, and last registration time. + +Configuration is stored in SQL Server. JSON is used for REST request/response payloads and optional application-level defaults only. + +Dashboard changes are authoritative for dashboard-owned fields. Self-registration may update mutable runtime metadata and endpoint information, but must not unintentionally overwrite dashboard-owned settings such as retention, alerting, or status policy unless the API contract explicitly allows that field. + +## Status policy + +The normalized dashboard status is configurable per service. + +Default behavior: + +- Healthy: all reported checks are healthy. +- Degraded: the endpoint responds and a non-critical dependency is non-healthy. +- Unhealthy: the endpoint is unreachable, times out, returns invalid health JSON, or a critical check fails. +- Unknown: no successful observation has ever been received. + +Per-service policy supports: + +- `unhealthyDependencyBehavior`, defaulting to `Degraded`. +- A list of `criticalChecks`. +- Optional critical tags if tags are available in the health response contract. +- `endpointFailureStatus`, defaulting to `Unhealthy`. + +The raw ASP.NET aggregate status and the derived dashboard status are both stored. + +## Data model + +### MonitoredService + +Stores service identity and configuration. `serviceId` is unique. + +### HealthObservation + +Stores one poll result: + +- Service ID. +- Observation timestamp. +- Normalized status. +- ASP.NET aggregate status. +- HTTP status code. +- Response duration. +- Raw response JSON when available. +- Error or timeout details when the request fails. + +### HealthCheckResult + +Stores one dependency/check result associated with an observation: + +- Check name. +- Status. +- Description. +- Duration. +- Exception/error details. +- Serialized `data` fields. + +### ServiceIncident + +Stores an outage/degradation interval: + +- Service ID. +- Start and end timestamps. +- Current/open or recovered state. +- Incident severity. +- Duration. +- First and last observation references. + +A Degraded/Unhealthy transition opens an incident. Continued non-healthy observations extend it. A severity change is recorded within the incident. A Healthy observation closes it and records recovery time. + +### ServiceStatusSnapshot + +Stores one current-state row per service for fast overview queries: + +- Current normalized status. +- Last observation time. +- Last response duration. +- Current incident ID. +- Last error summary. + +## REST API + +### `POST /api/services/register` + +Creates or updates a monitored service using `serviceId` as the idempotency key. + +- New service: `201 Created`. +- Existing service acknowledged or updated: `200 OK`. +- Duplicate requests never create duplicate records. +- Mutable registration metadata may be updated. +- Protected dashboard-owned configuration must not be overwritten accidentally. + +### `GET /api/services` + +Lists registered services and current status summaries. + +### `GET /api/services/{serviceId}` + +Returns service configuration, status, and summary metrics. + +### `PUT /api/services/{serviceId}` + +Updates dashboard-managed configuration and status policy. + +### `DELETE /api/services/{serviceId}` + +Soft-deletes or archives a service while preserving its history. + +## Polling and retention + +The polling worker maintains an independent schedule for each enabled service. Polls use the configured timeout and record failures as observations rather than silently dropping them. + +Retention cleanup runs per service using its configured retention period. Cleanup must preserve aggregate incident records and any references required for audit/history views, even when detailed observations are removed. + +## Incidents and alerting + +The incident calculator must handle: + +- Healthy to Degraded. +- Healthy to Unhealthy. +- Continued non-healthy checks. +- Degraded to Unhealthy and Unhealthy to Degraded severity changes. +- Non-healthy to Healthy recovery. +- Timeouts and connection failures. +- Invalid health responses. + +Alerting is represented by an interface such as `IAlertDispatcher`. The first version can use a no-op or logging dispatcher. Future adapters may support email, Teams, Slack, webhooks, or other channels. + +Future per-service alert settings may include enabled state, trigger status, delay before alerting, repeat interval, recovery notification, and destination/channel. + +## Failure handling + +The monitor distinguishes HTTP failures, timeouts, connection/DNS failures, invalid JSON, valid Degraded/Unhealthy responses, and persistence failures. + +A failed poll creates an observation with timestamp, elapsed time, and failure detail. It does not erase the last successful raw response. The current snapshot applies the service policy to determine whether the failure is Unhealthy or Unknown. + +The monitoring application exposes its own health endpoint, including SQL Server connectivity, so it can be monitored externally. + +## Verification strategy + +- Unit tests for status normalization and configurable critical checks. +- Unit tests for idempotent registration and protected configuration fields. +- Unit tests for incident creation, continuation, severity changes, and recovery. +- SQL Server integration tests for persistence, queries, and retention. +- API tests for register, duplicate register, update, list, detail, and archive flows. +- Blazor component tests for overview, detail, dependency, and configuration views. +- Polling-worker tests using mocked HTTP and controlled time. +- Application health-check tests covering SQL Server availability. + +## Deferred scope + +- Authentication and authorization. +- Concrete email, Teams, Slack, or webhook delivery. +- Advanced alert routing and escalation policies. +- Distributed collectors or queue-based event ingestion. +- Multi-tenant isolation. +- High-volume telemetry aggregation beyond the SQL Server history model. From 21b3dcfb23bc3df50773e28fb5c1db231eefe0d8 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 09:36:29 +0100 Subject: [PATCH 02/17] docs: add health monitoring implementation plan --- .gitignore | 2 + ...lth-monitoring-dashboard-implementation.md | 331 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 docs/superpowers/plans/2026-09-18-health-monitoring-dashboard-implementation.md diff --git a/.gitignore b/.gitignore index 8937902..3c3e7d7 100644 --- a/.gitignore +++ b/.gitignore @@ -357,3 +357,5 @@ MigrationBackup/ /TransactionProcessing.MerchantPos/merchant.db-shm /TransactionProcessing.MerchantPos/merchant.db-wal /TransactionProcessor.SystemSetupTool/Properties/launchSettings.json +# Codex implementation worktrees +.worktrees/ diff --git a/docs/superpowers/plans/2026-09-18-health-monitoring-dashboard-implementation.md b/docs/superpowers/plans/2026-09-18-health-monitoring-dashboard-implementation.md new file mode 100644 index 0000000..6103a87 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-health-monitoring-dashboard-implementation.md @@ -0,0 +1,331 @@ +# Standalone Health Monitoring Dashboard Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a standalone .NET 10 Blazor Server monitoring application backed by SQL Server that registers services, polls ASP.NET health endpoints, stores normalized health history, calculates incidents, and presents operational dashboards. + +**Architecture:** Create a new `HealthMonitoring` modular-monolith solution in this repository. Keep domain/status logic, persistence, polling, REST endpoints, and Blazor UI in focused folders with interfaces between them. Use EF Core SQL Server for configuration/history and a hosted worker for per-service polling. + +**Tech Stack:** .NET 10, ASP.NET Core Blazor Server, EF Core 10 SQL Server, xUnit 2.9, `Microsoft.NET.Test.Sdk`, SQL Server integration tests where available. + +**Spec:** `docs/superpowers/specs/2026-09-18-health-monitoring-dashboard-design.md` + +## Global Constraints + +- Standalone ASP.NET Core application. +- Blazor Server UI. +- SQL Server persistence through EF Core. +- Per-service polling interval and retention. +- Idempotent registration by stable `serviceId`. +- No authentication or authorization in the first version. +- Default non-critical dependency failure is Degraded; critical checks can make a service Unhealthy. +- Preserve raw ASP.NET responses and dependency-level details. +- Failed polls are persisted as observations and do not erase the last successful raw response. +- Concrete notification channels are deferred; alerting uses an interface and logging/no-op implementation. + +--- + +### Task 1: Create the standalone solution and test harness + +**Files:** +- Create: `HealthMonitoring/HealthMonitoring.sln` +- Create: `HealthMonitoring/HealthMonitoring.csproj` +- Create: `HealthMonitoring.Tests/HealthMonitoring.Tests.csproj` +- Create: `HealthMonitoring.Tests/Usings.cs` +- Create: `HealthMonitoring/appsettings.json` +- Create: `HealthMonitoring/appsettings.Development.json` +- Create: `HealthMonitoring/Program.cs` +- Modify: `README.md` + +**Interfaces:** +- Produces a buildable ASP.NET Core host and an xUnit test project that later tasks reference. + +- [ ] **Step 1: Write a host smoke test** + +Create `HealthMonitoring.Tests/HostSmokeTests.cs` with a test that asserts the new project assembly can be loaded and that the test project references the application project. + +- [ ] **Step 2: Run the test to verify the scaffold is absent** + +Run `dotnet test HealthMonitoring.Tests/HealthMonitoring.Tests.csproj --no-restore`. +Expected: fail because the new project files do not exist. + +- [ ] **Step 3: Create the project files** + +Use `Microsoft.NET.Sdk.Web`, target `net10.0`, enable nullable and implicit usings, and reference `Microsoft.EntityFrameworkCore.SqlServer` version `10.0.12`, `Microsoft.EntityFrameworkCore.Design` version `10.0.12` with private assets, and `Microsoft.AspNetCore.Components.Web` through the shared framework. The test project targets `net10.0`, references the application project, `Microsoft.NET.Test.Sdk` `17.12.0`, `xunit` `2.9.2`, and `xunit.runner.visualstudio` `2.8.2`. + +Add a minimal `Program.cs` that creates a web application, maps a root placeholder endpoint, and runs. Add SQL Server configuration with a development connection string placeholder using `ConnectionStrings:HealthMonitoring` without embedding credentials. + +- [ ] **Step 4: Run the smoke test and build** + +Run `dotnet test HealthMonitoring.Tests/HealthMonitoring.Tests.csproj` and `dotnet build HealthMonitoring/HealthMonitoring.csproj`. +Expected: PASS and a clean build. + +- [ ] **Step 5: Update repository documentation** + +Add the new application to the root README with local build/run commands and the required `ConnectionStrings:HealthMonitoring` setting. + +- [ ] **Step 6: Commit the scaffold** + +Run `git add HealthMonitoring HealthMonitoring.Tests README.md` and commit with `feat: scaffold health monitoring application`. + +### Task 2: Add domain models and status normalization + +**Files:** +- Create: `HealthMonitoring/Domain/HealthStatus.cs` +- Create: `HealthMonitoring/Domain/StatusPolicy.cs` +- Create: `HealthMonitoring/Domain/MonitoredService.cs` +- Create: `HealthMonitoring/Domain/HealthObservation.cs` +- Create: `HealthMonitoring/Domain/HealthCheckResult.cs` +- Create: `HealthMonitoring/Domain/ServiceIncident.cs` +- Create: `HealthMonitoring/Domain/ServiceStatusSnapshot.cs` +- Create: `HealthMonitoring/Domain/HealthResponseModels.cs` +- Create: `HealthMonitoring/Domain/IHealthStatusNormalizer.cs` +- Create: `HealthMonitoring/Domain/HealthStatusNormalizer.cs` +- Test: `HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs` + +**Interfaces:** +- `HealthStatusNormalizer.Normalize(HealthResponseEnvelope response, HttpStatusCode? httpStatusCode, TimeSpan duration)` returns `NormalizedHealthResult` containing normalized status, aggregate status, error, and check results. +- `StatusPolicy` contains `UnhealthyDependencyBehavior`, `CriticalChecks`, `CriticalTags`, and `EndpointFailureStatus`. + +- [ ] **Step 1: Write failing normalizer tests** + +Cover: all checks Healthy => Healthy; non-critical Unhealthy check with a responding endpoint => Degraded by default; named critical check failure => Unhealthy; HTTP/request failure => configured endpoint-failure status; malformed response => Unhealthy with error; empty/no successful response => Unknown at snapshot initialization. + +- [ ] **Step 2: Run the focused tests** + +Run `dotnet test HealthMonitoring.Tests --filter FullyQualifiedName~HealthStatusNormalizerTests`. +Expected: fail because domain types and normalizer do not exist. + +- [ ] **Step 3: Implement the domain records and normalizer** + +Model ASP.NET health JSON with `status` and `entries` containing `status`, `description`, `duration`, `exception`, and `data`. Match critical checks case-insensitively. Preserve unrecognized data as `JsonElement` or serialized JSON. Keep the normalizer independent of EF and HTTP clients. + +- [ ] **Step 4: Run focused and full tests** + +Run the focused filter, then `dotnet test HealthMonitoring.Tests`. +Expected: PASS. + +- [ ] **Step 5: Commit the domain slice** + +Run `git add HealthMonitoring/Domain HealthMonitoring.Tests/Domain` and commit with `feat: add configurable health status normalization`. + +### Task 3: Implement EF Core persistence and migrations + +**Files:** +- Create: `HealthMonitoring/Persistence/HealthMonitoringDbContext.cs` +- Create: `HealthMonitoring/Persistence/EntityConfigurations.cs` +- Create: `HealthMonitoring/Persistence/HealthMonitoringRepository.cs` +- Create: `HealthMonitoring/Persistence/IHealthMonitoringRepository.cs` +- Create: `HealthMonitoring/Persistence/RetentionCleanupService.cs` +- Create: `HealthMonitoring/Migrations/` generated migration files +- Create: `HealthMonitoring.Tests/Persistence/RepositoryTests.cs` +- Create: `HealthMonitoring.Tests/Persistence/SqlServerTestDatabase.cs` +- Modify: `HealthMonitoring/Program.cs` + +**Interfaces:** +- `IHealthMonitoringRepository` supports service upsert/get/list/archive, observation append, current snapshot upsert, incident open/update/close, history queries, and retention cleanup. +- `HealthMonitoringDbContext` exposes `MonitoredServices`, `HealthObservations`, `HealthCheckResults`, `ServiceIncidents`, and `ServiceStatusSnapshots`. + +- [ ] **Step 1: Write repository tests** + +Test unique `serviceId` behavior, observation plus dependency persistence, current snapshot replacement, incident persistence, archive behavior, and queries ordered newest first. Use a SQL Server test connection from `HealthMonitoring__TestConnectionString`; if unavailable, the tests must be clearly skipped rather than silently using SQLite because SQL Server behavior is required. + +- [ ] **Step 2: Run tests to verify they fail** + +Run `dotnet test HealthMonitoring.Tests --filter FullyQualifiedName~RepositoryTests`. +Expected: fail because the context/repository do not exist. + +- [ ] **Step 3: Implement the EF model and repository** + +Use SQL Server-compatible keys, indexes, row timestamps, JSON stored as `nvarchar(max)`, unique service identity, indexes on service/timestamp and incident state, and soft archive fields. Use UTC timestamps consistently. Configure cascade behavior so deleting a service is not required for history preservation. + +- [ ] **Step 4: Add and apply the initial migration** + +Run `dotnet ef migrations add InitialHealthMonitoring --project HealthMonitoring/HealthMonitoring.csproj --startup-project HealthMonitoring/HealthMonitoring.csproj --output-dir Migrations` and apply it only when a configured SQL Server connection is available. + +- [ ] **Step 5: Run persistence tests and build** + +Run the repository test filter and `dotnet build HealthMonitoring/HealthMonitoring.csproj`. +Expected: PASS with SQL Server integration tests executed when configured. + +- [ ] **Step 6: Commit persistence** + +Run `git add HealthMonitoring/Persistence HealthMonitoring/Migrations HealthMonitoring.Tests/Persistence HealthMonitoring/Program.cs` and commit with `feat: add sql server health monitoring persistence`. + +### Task 4: Add idempotent registration and configuration APIs + +**Files:** +- Create: `HealthMonitoring/Api/ServiceRegistrationContracts.cs` +- Create: `HealthMonitoring/Api/ServiceRegistrationEndpoints.cs` +- Create: `HealthMonitoring/Api/ServiceQueryEndpoints.cs` +- Create: `HealthMonitoring/Api/ServiceConfigurationValidator.cs` +- Test: `HealthMonitoring.Tests/Api/ServiceRegistrationApiTests.cs` +- Modify: `HealthMonitoring/Program.cs` + +**Interfaces:** +- `POST /api/services/register` accepts `serviceId`, name, environment, health URL, interval, timeout, retention, policy, and runtime metadata. +- `GET /api/services`, `GET /api/services/{serviceId}`, `PUT /api/services/{serviceId}`, and `DELETE /api/services/{serviceId}` provide configuration and summaries. + +- [ ] **Step 1: Write API tests** + +Cover new registration returns `201`, repeated registration returns `200` and leaves one service, mutable metadata updates, dashboard-owned policy/retention is protected unless explicitly supplied by the dashboard API, invalid URLs/intervals are rejected, list/detail work, and delete archives rather than removing history. + +- [ ] **Step 2: Run API tests to verify they fail** + +Run `dotnet test HealthMonitoring.Tests --filter FullyQualifiedName~ServiceRegistrationApiTests`. +Expected: fail because endpoints and contracts do not exist. + +- [ ] **Step 3: Implement contracts, validation, and endpoints** + +Use minimal APIs with typed request/response records. Enforce stable non-empty `serviceId`, absolute HTTP/HTTPS health URLs, positive interval/timeout/retention values, and bounded timeout values. Make the database unique constraint the final duplicate guard and translate duplicate races into an acknowledged/upserted success response. + +- [ ] **Step 4: Run API tests and build** + +Run the focused tests, then `dotnet test HealthMonitoring.Tests` and `dotnet build HealthMonitoring/HealthMonitoring.csproj`. +Expected: PASS. + +- [ ] **Step 5: Commit the API slice** + +Run `git add HealthMonitoring/Api HealthMonitoring.Tests/Api HealthMonitoring/Program.cs` and commit with `feat: add idempotent service registration api`. + +### Task 5: Implement polling, normalization persistence, and incident calculation + +**Files:** +- Create: `HealthMonitoring/Monitoring/IHealthEndpointClient.cs` +- Create: `HealthMonitoring/Monitoring/HealthEndpointClient.cs` +- Create: `HealthMonitoring/Monitoring/IIncidentCalculator.cs` +- Create: `HealthMonitoring/Monitoring/IncidentCalculator.cs` +- Create: `HealthMonitoring/Monitoring/HealthPollingWorker.cs` +- Create: `HealthMonitoring/Monitoring/Alerting.cs` +- Test: `HealthMonitoring.Tests/Monitoring/IncidentCalculatorTests.cs` +- Test: `HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs` +- Test: `HealthMonitoring.Tests/Monitoring/HealthPollingWorkerTests.cs` +- Modify: `HealthMonitoring/Program.cs` + +**Interfaces:** +- `IHealthEndpointClient.CheckAsync(MonitoredService service, CancellationToken cancellationToken)` returns an observation candidate containing HTTP result, parsed response, raw JSON, duration, and failure details. +- `IIncidentCalculator.ApplyAsync(HealthObservation observation, CancellationToken cancellationToken)` updates snapshot and incident state. +- `IAlertDispatcher.DispatchAsync(AlertEvent alertEvent, CancellationToken cancellationToken)` receives incident transitions. + +- [ ] **Step 1: Write incident tests** + +Cover Healthy->Degraded opens an incident, continued Degraded keeps one incident, Degraded->Unhealthy changes severity without opening a second incident, Unhealthy->Healthy closes and records duration, and failed polls create observations with error details. + +- [ ] **Step 2: Run incident tests to verify they fail** + +Run `dotnet test HealthMonitoring.Tests --filter FullyQualifiedName~IncidentCalculatorTests`. +Expected: fail because the calculator does not exist. + +- [ ] **Step 3: Implement incident calculation** + +Load the current snapshot, compare normalized status, create/update/close one incident, update first/last observation references, and persist the new snapshot in one repository operation. Use an injected clock abstraction so duration tests are deterministic. + +- [ ] **Step 4: Write HTTP client and worker tests** + +Test valid ASP.NET JSON, non-success HTTP status with valid JSON, timeout, connection exception, malformed JSON, and cancellation. Test that the worker schedules enabled services using each service interval and does not poll archived/disabled services. + +- [ ] **Step 5: Run the new tests to verify they fail** + +Run `dotnet test HealthMonitoring.Tests --filter FullyQualifiedName~Monitoring`. +Expected: fail because the HTTP client and worker do not exist. + +- [ ] **Step 6: Implement client, worker, and alerting adapter** + +Use `IHttpClientFactory`, per-request timeout cancellation, UTC timestamps, the status normalizer, repository persistence, and a logging/no-op alert dispatcher. Re-read enabled services between scheduling cycles so dashboard changes take effect without restarting. + +- [ ] **Step 7: Run monitoring tests and build** + +Run the monitoring test filter, all tests, and build. Expected: PASS. + +- [ ] **Step 8: Commit monitoring** + +Run `git add HealthMonitoring/Monitoring HealthMonitoring.Tests/Monitoring HealthMonitoring/Program.cs` and commit with `feat: add health polling and incident tracking`. + +### Task 6: Add dashboard query services and Blazor Server UI + +**Files:** +- Create: `HealthMonitoring/Reporting/DashboardModels.cs` +- Create: `HealthMonitoring/Reporting/DashboardQueryService.cs` +- Create: `HealthMonitoring/Components/App.razor` +- Create: `HealthMonitoring/Components/Routes.razor` +- Create: `HealthMonitoring/Components/Layout/MainLayout.razor` +- Create: `HealthMonitoring/Components/Pages/Overview.razor` +- Create: `HealthMonitoring/Components/Pages/ServiceDetail.razor` +- Create: `HealthMonitoring/Components/Pages/ServiceEdit.razor` +- Create: `HealthMonitoring/Components/Pages/ServiceRegister.razor` +- Create: `HealthMonitoring/wwwroot/app.css` +- Test: `HealthMonitoring.Tests/Reporting/DashboardQueryServiceTests.cs` +- Modify: `HealthMonitoring/Program.cs` + +**Interfaces:** +- `IDashboardQueryService.GetOverviewAsync(...)` returns counters and service rows. +- `IDashboardQueryService.GetServiceDetailAsync(serviceId, range, ...)` returns current status, uptime, response metrics, timeline, incidents, checks, and raw response. + +- [ ] **Step 1: Write query-service tests** + +Cover overview counts, current snapshot selection, uptime percentage, response average, timeline ordering, incident duration, dependency result retrieval, and missing service behavior. + +- [ ] **Step 2: Run query tests to verify they fail** + +Run `dotnet test HealthMonitoring.Tests --filter FullyQualifiedName~DashboardQueryServiceTests`. +Expected: fail because query models/service do not exist. + +- [ ] **Step 3: Implement query models and service** + +Query snapshots for the overview, calculate uptime from persisted observations for the selected window, calculate average response only from successful observations, and return dependency details and raw JSON for the selected/latest observation. + +- [ ] **Step 4: Implement the Blazor pages** + +Build the approved visual direction: overview counters, service table, detail metrics, status timeline, dependency table, raw JSON disclosure, and configuration forms. Use text plus color for statuses, responsive layout, and explicit empty/error states. Use normal Blazor navigation and `IDashboardQueryService`; do not duplicate persistence logic in components. + +- [ ] **Step 5: Run tests and build** + +Run all tests and build the application. Expected: PASS. + +- [ ] **Step 6: Commit the UI slice** + +Run `git add HealthMonitoring/Reporting HealthMonitoring/Components HealthMonitoring/wwwroot HealthMonitoring.Tests/Reporting HealthMonitoring/Program.cs` and commit with `feat: add health monitoring dashboard ui`. + +### Task 7: Add retention cleanup, application health, and deployment documentation + +**Files:** +- Create: `HealthMonitoring/Monitoring/RetentionCleanupWorker.cs` +- Create: `HealthMonitoring/Health/MonitoringHealthChecks.cs` +- Create: `HealthMonitoring.Tests/Health/MonitoringHealthCheckTests.cs` +- Create: `HealthMonitoring/Dockerfile` +- Create: `HealthMonitoring/docker-compose.sqlserver.yml` +- Modify: `HealthMonitoring/Program.cs` +- Modify: `HealthMonitoring/appsettings.json` +- Modify: `README.md` + +- [ ] **Step 1: Write cleanup and application-health tests** + +Test that cleanup uses each service retention period and preserves incidents, and that the application health check reports SQL Server failure as Unhealthy. + +- [ ] **Step 2: Run tests to verify they fail** + +Run `dotnet test HealthMonitoring.Tests --filter FullyQualifiedName~Health`. +Expected: fail because cleanup worker and health check do not exist. + +- [ ] **Step 3: Implement cleanup and application health** + +Run cleanup as a hosted service on a bounded cadence, delete only detailed observations/check results older than each service retention period, and map SQL Server connectivity to the application health endpoint. + +- [ ] **Step 4: Add deployment files and documentation** + +Document SQL Server connection setup, migrations, local development, registration request examples, and Docker Compose usage without committing credentials. Add a Dockerfile that runs the published application. + +- [ ] **Step 5: Run full verification** + +Run `dotnet test HealthMonitoring.Tests`, `dotnet build HealthMonitoring/HealthMonitoring.csproj`, and `git diff --check`. Start the app against a configured development SQL Server and verify the overview, registration endpoint, health endpoint, and one poll cycle. + +- [ ] **Step 6: Commit the operational slice** + +Run `git add HealthMonitoring README.md` and commit with `feat: add monitoring retention and deployment support`. + +## Plan self-review + +- Spec coverage: architecture, UI, SQL Server persistence, service registration, idempotency, per-service polling/retention, status policy, incidents, raw responses, dependency details, failure handling, alerting abstraction, application health, and tests are covered by Tasks 1-7. +- Placeholder scan: no implementation step depends on an unspecified function or file; deferred alert channels and authentication are explicitly scoped out rather than left as implementation placeholders. +- Type consistency: `IHealthStatusNormalizer`, `IHealthMonitoringRepository`, `IHealthEndpointClient`, `IIncidentCalculator`, `IAlertDispatcher`, and `IDashboardQueryService` are defined before the tasks that consume them. +- Repository fit: the new app is isolated under `HealthMonitoring` and does not modify existing service runtime code. From 8f86994e9733bcaf4f558645b2fbb87571c4278c Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 09:51:47 +0100 Subject: [PATCH 03/17] feat: add standalone health monitoring dashboard --- .../Api/ServiceConfigurationValidatorTests.cs | 45 ++++++ .../Domain/HealthStatusNormalizerTests.cs | 61 ++++++++ .../HealthMonitoring.Tests.csproj | 19 +++ HealthMonitoring.Tests/HostSmokeTests.cs | 10 ++ .../Monitoring/HealthEndpointClientTests.cs | 52 +++++++ .../Monitoring/IncidentCalculatorTests.cs | 42 ++++++ .../Persistence/RepositoryTests.cs | 97 ++++++++++++ HealthMonitoring.Tests/Usings.cs | 1 + .../Api/ServiceConfigurationValidator.cs | 40 +++++ .../Api/ServiceRegistrationContracts.cs | 34 +++++ .../Api/ServiceRegistrationEndpoints.cs | 64 ++++++++ HealthMonitoring/Components/App.razor | 14 ++ .../Components/Layout/MainLayout.razor | 13 ++ .../Components/Pages/Overview.razor | 38 +++++ .../Components/Pages/ServiceDetail.razor | 25 ++++ .../Components/Pages/ServiceRegister.razor | 28 ++++ HealthMonitoring/Components/Routes.razor | 8 + .../Components/Shared/StatCard.razor | 2 + .../Components/Shared/StatusBadge.razor | 2 + HealthMonitoring/Components/_Imports.razor | 10 ++ HealthMonitoring/Dockerfile | 10 ++ HealthMonitoring/Domain/HealthCheckResult.cs | 13 ++ HealthMonitoring/Domain/HealthObservation.cs | 15 ++ .../Domain/HealthResponseModels.cs | 29 ++++ HealthMonitoring/Domain/HealthStatus.cs | 9 ++ .../Domain/HealthStatusNormalizer.cs | 69 +++++++++ .../Domain/IHealthStatusNormalizer.cs | 18 +++ HealthMonitoring/Domain/MonitoredService.cs | 24 +++ HealthMonitoring/Domain/ServiceIncident.cs | 14 ++ .../Domain/ServiceStatusSnapshot.cs | 12 ++ HealthMonitoring/Domain/StatusPolicy.cs | 14 ++ .../Health/MonitoringHealthChecks.cs | 22 +++ HealthMonitoring/HealthMonitoring.csproj | 15 ++ HealthMonitoring/HealthMonitoring.sln | 25 ++++ .../Monitoring/HealthEndpointClient.cs | 86 +++++++++++ .../Monitoring/HealthPollingWorker.cs | 86 +++++++++++ .../Monitoring/IncidentCalculator.cs | 75 ++++++++++ .../Monitoring/RetentionCleanupWorker.cs | 30 ++++ .../Persistence/HealthMonitoringDbContext.cs | 67 +++++++++ .../Persistence/HealthMonitoringRepository.cs | 138 ++++++++++++++++++ .../IHealthMonitoringRepository.cs | 19 +++ HealthMonitoring/Program.cs | 45 ++++++ HealthMonitoring/Reporting/DashboardModels.cs | 7 + .../Reporting/DashboardQueryService.cs | 50 +++++++ HealthMonitoring/appsettings.json | 11 ++ HealthMonitoring/docker-compose.sqlserver.yml | 17 +++ HealthMonitoring/wwwroot/app.css | 20 +++ README.md | 31 ++++ 48 files changed, 1576 insertions(+) create mode 100644 HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs create mode 100644 HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs create mode 100644 HealthMonitoring.Tests/HealthMonitoring.Tests.csproj create mode 100644 HealthMonitoring.Tests/HostSmokeTests.cs create mode 100644 HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs create mode 100644 HealthMonitoring.Tests/Monitoring/IncidentCalculatorTests.cs create mode 100644 HealthMonitoring.Tests/Persistence/RepositoryTests.cs create mode 100644 HealthMonitoring.Tests/Usings.cs create mode 100644 HealthMonitoring/Api/ServiceConfigurationValidator.cs create mode 100644 HealthMonitoring/Api/ServiceRegistrationContracts.cs create mode 100644 HealthMonitoring/Api/ServiceRegistrationEndpoints.cs create mode 100644 HealthMonitoring/Components/App.razor create mode 100644 HealthMonitoring/Components/Layout/MainLayout.razor create mode 100644 HealthMonitoring/Components/Pages/Overview.razor create mode 100644 HealthMonitoring/Components/Pages/ServiceDetail.razor create mode 100644 HealthMonitoring/Components/Pages/ServiceRegister.razor create mode 100644 HealthMonitoring/Components/Routes.razor create mode 100644 HealthMonitoring/Components/Shared/StatCard.razor create mode 100644 HealthMonitoring/Components/Shared/StatusBadge.razor create mode 100644 HealthMonitoring/Components/_Imports.razor create mode 100644 HealthMonitoring/Dockerfile create mode 100644 HealthMonitoring/Domain/HealthCheckResult.cs create mode 100644 HealthMonitoring/Domain/HealthObservation.cs create mode 100644 HealthMonitoring/Domain/HealthResponseModels.cs create mode 100644 HealthMonitoring/Domain/HealthStatus.cs create mode 100644 HealthMonitoring/Domain/HealthStatusNormalizer.cs create mode 100644 HealthMonitoring/Domain/IHealthStatusNormalizer.cs create mode 100644 HealthMonitoring/Domain/MonitoredService.cs create mode 100644 HealthMonitoring/Domain/ServiceIncident.cs create mode 100644 HealthMonitoring/Domain/ServiceStatusSnapshot.cs create mode 100644 HealthMonitoring/Domain/StatusPolicy.cs create mode 100644 HealthMonitoring/Health/MonitoringHealthChecks.cs create mode 100644 HealthMonitoring/HealthMonitoring.csproj create mode 100644 HealthMonitoring/HealthMonitoring.sln create mode 100644 HealthMonitoring/Monitoring/HealthEndpointClient.cs create mode 100644 HealthMonitoring/Monitoring/HealthPollingWorker.cs create mode 100644 HealthMonitoring/Monitoring/IncidentCalculator.cs create mode 100644 HealthMonitoring/Monitoring/RetentionCleanupWorker.cs create mode 100644 HealthMonitoring/Persistence/HealthMonitoringDbContext.cs create mode 100644 HealthMonitoring/Persistence/HealthMonitoringRepository.cs create mode 100644 HealthMonitoring/Persistence/IHealthMonitoringRepository.cs create mode 100644 HealthMonitoring/Program.cs create mode 100644 HealthMonitoring/Reporting/DashboardModels.cs create mode 100644 HealthMonitoring/Reporting/DashboardQueryService.cs create mode 100644 HealthMonitoring/appsettings.json create mode 100644 HealthMonitoring/docker-compose.sqlserver.yml create mode 100644 HealthMonitoring/wwwroot/app.css diff --git a/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs b/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs new file mode 100644 index 0000000..d854ce6 --- /dev/null +++ b/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs @@ -0,0 +1,45 @@ +using HealthMonitoring.Api; + +namespace HealthMonitoring.Tests.Api; + +public sealed class ServiceConfigurationValidatorTests +{ + [Fact] + public void Rejects_non_http_urls_and_invalid_intervals() + { + var request = new ServiceRegistrationRequest("service", "Service", "Production", "ftp://service") + { + PollingIntervalSeconds = 3, + RequestTimeoutSeconds = 5, + RetentionDays = 0 + }; + + var errors = ServiceConfigurationValidator.Validate(request); + + Assert.Contains(errors, error => error.Contains("HTTP", StringComparison.Ordinal)); + Assert.Contains(errors, error => error.Contains("PollingInterval", StringComparison.Ordinal)); + Assert.Contains(errors, error => error.Contains("Retention", StringComparison.Ordinal)); + } + + [Fact] + public void Self_registration_preserves_dashboard_managed_settings_for_existing_service() + { + var existing = new HealthMonitoring.Domain.MonitoredService + { + Id = Guid.NewGuid(), + ServiceId = "service", + Name = "Service", + Environment = "Production", + HealthUrl = new Uri("https://old/health"), + PollingInterval = TimeSpan.FromMinutes(5), + RetentionPeriod = TimeSpan.FromDays(10) + }; + var request = new ServiceRegistrationRequest("service", "Updated", "Production", "https://new/health") { PollingIntervalSeconds = 30, RetentionDays = 1 }; + + var result = ServiceConfigurationValidator.ToService(request, existing, preserveManagedSettings: true); + + Assert.Equal(TimeSpan.FromMinutes(5), result.PollingInterval); + Assert.Equal(TimeSpan.FromDays(10), result.RetentionPeriod); + Assert.Equal(new Uri("https://new/health"), result.HealthUrl); + } +} diff --git a/HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs b/HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs new file mode 100644 index 0000000..a16f823 --- /dev/null +++ b/HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs @@ -0,0 +1,61 @@ +using System.Net; +using System.Text.Json; +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Tests.Domain; + +public sealed class HealthStatusNormalizerTests +{ + [Fact] + public void All_healthy_checks_produce_healthy_status() + { + var normalizer = new HealthStatusNormalizer(); + var response = Response("Healthy", ("database", "Healthy")); + + var result = normalizer.Normalize(response, HttpStatusCode.OK, TimeSpan.FromMilliseconds(4)); + + Assert.Equal(HealthStatus.Healthy, result.Status); + } + + [Fact] + public void Non_critical_unhealthy_check_produces_degraded_status_by_default() + { + var normalizer = new HealthStatusNormalizer(); + var response = Response("Unhealthy", ("cache", "Unhealthy")); + + var result = normalizer.Normalize(response, HttpStatusCode.ServiceUnavailable, TimeSpan.FromMilliseconds(9)); + + Assert.Equal(HealthStatus.Degraded, result.Status); + } + + [Fact] + public void Critical_check_failure_produces_unhealthy_status() + { + var normalizer = new HealthStatusNormalizer(); + var response = Response("Unhealthy", ("database", "Unhealthy")); + var policy = new StatusPolicy { CriticalChecks = ["database"] }; + + var result = normalizer.Normalize(response, HttpStatusCode.ServiceUnavailable, TimeSpan.FromMilliseconds(9), policy); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + } + + [Fact] + public void Invalid_response_produces_unhealthy_status_with_error() + { + var normalizer = new HealthStatusNormalizer(); + var result = normalizer.NormalizeInvalid(HttpStatusCode.InternalServerError, "not-json", TimeSpan.FromMilliseconds(2)); + + Assert.Equal(HealthStatus.Unhealthy, result.Status); + Assert.Contains("not-json", result.Error, StringComparison.Ordinal); + } + + private static HealthResponseEnvelope Response(string status, params (string Name, string Status)[] checks) + { + var entries = checks.ToDictionary( + check => check.Name, + check => new HealthResponseEntry(check.Status, check.Status, TimeSpan.FromMilliseconds(1), null, new Dictionary())); + + return new HealthResponseEnvelope(status, entries); + } +} diff --git a/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj b/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj new file mode 100644 index 0000000..a462d4d --- /dev/null +++ b/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj @@ -0,0 +1,19 @@ + + + net10.0 + enable + enable + false + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + diff --git a/HealthMonitoring.Tests/HostSmokeTests.cs b/HealthMonitoring.Tests/HostSmokeTests.cs new file mode 100644 index 0000000..83c3da4 --- /dev/null +++ b/HealthMonitoring.Tests/HostSmokeTests.cs @@ -0,0 +1,10 @@ +namespace HealthMonitoring.Tests; + +public sealed class HostSmokeTests +{ + [Fact] + public void Application_assembly_is_available() + { + Assert.NotNull(typeof(Program).Assembly); + } +} diff --git a/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs new file mode 100644 index 0000000..4fdec2a --- /dev/null +++ b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs @@ -0,0 +1,52 @@ +using System.Net; +using System.Net.Http; +using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; + +namespace HealthMonitoring.Tests.Monitoring; + +public sealed class HealthEndpointClientTests +{ + [Fact] + public async Task Parses_standard_aspnet_health_response() + { + var handler = new StubHandler(HttpStatusCode.OK, "{\"status\":\"Healthy\",\"entries\":{\"database\":{\"status\":\"Healthy\",\"duration\":\"00:00:00.004\",\"data\":{}}}}" ); + var client = new HealthEndpointClient(new HttpClient(handler), new HealthStatusNormalizer()); + var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Healthy, result.Normalized.Status); + Assert.Single(result.Normalized.Checks); + Assert.Equal(HttpStatusCode.OK, result.HttpStatusCode); + } + + [Fact] + public async Task Timeout_is_recorded_as_unhealthy_observation_candidate() + { + var handler = new StubHandler(new TaskCanceledException("timeout")); + var client = new HealthEndpointClient(new HttpClient(handler), new HealthStatusNormalizer()); + var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Normalized.Status); + Assert.Contains("timeout", result.Normalized.Error, StringComparison.OrdinalIgnoreCase); + } + + private sealed class StubHandler : HttpMessageHandler + { + private readonly HttpStatusCode? _status; + private readonly string? _body; + private readonly Exception? _exception; + + public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; } + public StubHandler(Exception exception) { _exception = exception; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + if (_exception is not null) return Task.FromException(_exception); + return Task.FromResult(new HttpResponseMessage(_status!.Value) { Content = new StringContent(_body!) }); + } + } +} diff --git a/HealthMonitoring.Tests/Monitoring/IncidentCalculatorTests.cs b/HealthMonitoring.Tests/Monitoring/IncidentCalculatorTests.cs new file mode 100644 index 0000000..c4c2582 --- /dev/null +++ b/HealthMonitoring.Tests/Monitoring/IncidentCalculatorTests.cs @@ -0,0 +1,42 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; + +namespace HealthMonitoring.Tests.Monitoring; + +public sealed class IncidentCalculatorTests +{ + [Fact] + public async Task Degraded_opens_and_healthy_closes_one_incident() + { + var repository = new InMemoryMonitoringRepository(); + var calculator = new IncidentCalculator(repository, new FixedClock(DateTimeOffset.Parse("2026-09-18T08:00:00Z")), new NullAlertDispatcher()); + var service = new MonitoredService { Id = Guid.NewGuid(), ServiceId = "service" }; + + await calculator.ApplyAsync(service, Observation(service.Id, HealthStatus.Degraded, DateTimeOffset.Parse("2026-09-18T08:00:00Z")), CancellationToken.None); + await calculator.ApplyAsync(service, Observation(service.Id, HealthStatus.Healthy, DateTimeOffset.Parse("2026-09-18T08:05:00Z")), CancellationToken.None); + + var incident = Assert.Single(repository.Incidents); + Assert.False(incident.IsOpen); + Assert.Equal(TimeSpan.FromMinutes(5), incident.Duration); + } + + private static HealthObservation Observation(Guid serviceId, HealthStatus status, DateTimeOffset at) => new() + { + Id = Guid.NewGuid(), MonitoredServiceId = serviceId, Status = status, ObservedAtUtc = at, AggregateStatus = status.ToString() + }; +} + +internal sealed class InMemoryMonitoringRepository : IMonitoringStateRepository +{ + public List Incidents { get; } = []; + public ServiceStatusSnapshot? Snapshot { get; private set; } + public ServiceIncident? OpenIncident => Incidents.SingleOrDefault(item => item.IsOpen); + public Task GetSnapshotAsync(Guid serviceId, CancellationToken cancellationToken) => Task.FromResult(Snapshot); + public Task SetSnapshotAsync(ServiceStatusSnapshot snapshot, CancellationToken cancellationToken) { Snapshot = snapshot; return Task.CompletedTask; } + public Task GetOpenIncidentAsync(Guid serviceId, CancellationToken cancellationToken) => Task.FromResult(OpenIncident); + public Task AddIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken) { Incidents.Add(incident); return Task.CompletedTask; } + public Task UpdateIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken) => Task.CompletedTask; +} + +internal sealed class FixedClock(DateTimeOffset now) : IClock { public DateTimeOffset UtcNow => now; } +internal sealed class NullAlertDispatcher : IAlertDispatcher { public Task DispatchAsync(AlertEvent alertEvent, CancellationToken cancellationToken) => Task.CompletedTask; } diff --git a/HealthMonitoring.Tests/Persistence/RepositoryTests.cs b/HealthMonitoring.Tests/Persistence/RepositoryTests.cs new file mode 100644 index 0000000..3640290 --- /dev/null +++ b/HealthMonitoring.Tests/Persistence/RepositoryTests.cs @@ -0,0 +1,97 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace HealthMonitoring.Tests.Persistence; + +public sealed class RepositoryTests +{ + [Fact] + public async Task Upsert_registration_is_idempotent_by_service_id() + { + await using var database = await SqlServerTestDatabase.TryCreateAsync(); + if (database is null) return; + var first = NewService("merchant-pos"); + var second = NewService("merchant-pos"); + second.Version = "2.0.0"; + + await database.Repository.UpsertServiceAsync(first, CancellationToken.None); + await database.Repository.UpsertServiceAsync(second, CancellationToken.None); + + var services = await database.Repository.ListServicesAsync(includeArchived: true, CancellationToken.None); + + Assert.Single(services); + Assert.Equal("2.0.0", services[0].Version); + } + + [Fact] + public async Task Observation_and_checks_are_persisted_and_read_newest_first() + { + await using var database = await SqlServerTestDatabase.TryCreateAsync(); + if (database is null) return; + var service = NewService("file-processor"); + await database.Repository.UpsertServiceAsync(service, CancellationToken.None); + var older = NewObservation(service.Id, DateTimeOffset.UtcNow.AddMinutes(-1), "old"); + var newer = NewObservation(service.Id, DateTimeOffset.UtcNow, "new"); + newer.Checks.Add(new HealthCheckResult { Name = "database", Status = HealthStatus.Healthy }); + + await database.Repository.AddObservationAsync(older, CancellationToken.None); + await database.Repository.AddObservationAsync(newer, CancellationToken.None); + + var observations = await database.Repository.GetObservationsAsync(service.Id, DateTimeOffset.UtcNow.AddHours(-1), DateTimeOffset.UtcNow.AddMinutes(1), CancellationToken.None); + + Assert.Equal(2, observations.Count); + Assert.Equal("new", observations[0].RawResponseJson); + Assert.Single(observations[0].Checks); + } + + private static MonitoredService NewService(string serviceId) => new() + { + Id = Guid.NewGuid(), + ServiceId = serviceId, + Name = serviceId, + Environment = "Test", + HealthUrl = new Uri("https://localhost/health") + }; + + private static HealthObservation NewObservation(Guid serviceId, DateTimeOffset timestamp, string raw) => new() + { + Id = Guid.NewGuid(), + MonitoredServiceId = serviceId, + ObservedAtUtc = timestamp, + Status = HealthStatus.Healthy, + AggregateStatus = "Healthy", + HttpStatusCode = 200, + RawResponseJson = raw + }; +} + +public sealed class SqlServerTestDatabase : IAsyncDisposable +{ + private readonly HealthMonitoringDbContext _context; + public IHealthMonitoringRepository Repository { get; } + + private SqlServerTestDatabase(HealthMonitoringDbContext context) + { + _context = context; + Repository = new HealthMonitoringRepository(context); + } + + public static async Task TryCreateAsync() + { + var connectionString = Environment.GetEnvironmentVariable("HealthMonitoring__TestConnectionString"); + if (string.IsNullOrWhiteSpace(connectionString)) + { + return null; + } + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + var context = new HealthMonitoringDbContext(options); + await context.Database.EnsureCreatedAsync(); + return new SqlServerTestDatabase(context); + } + + public ValueTask DisposeAsync() => _context.DisposeAsync(); +} diff --git a/HealthMonitoring.Tests/Usings.cs b/HealthMonitoring.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/HealthMonitoring.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit; diff --git a/HealthMonitoring/Api/ServiceConfigurationValidator.cs b/HealthMonitoring/Api/ServiceConfigurationValidator.cs new file mode 100644 index 0000000..ef748e0 --- /dev/null +++ b/HealthMonitoring/Api/ServiceConfigurationValidator.cs @@ -0,0 +1,40 @@ +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Api; + +public static class ServiceConfigurationValidator +{ + public static IReadOnlyList Validate(ServiceRegistrationRequest request) + { + var errors = new List(); + if (string.IsNullOrWhiteSpace(request.ServiceId)) errors.Add("ServiceId is required."); + if (string.IsNullOrWhiteSpace(request.Name)) errors.Add("Name is required."); + if (string.IsNullOrWhiteSpace(request.Environment)) errors.Add("Environment is required."); + if (!Uri.TryCreate(request.HealthUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) errors.Add("HealthUrl must be an absolute HTTP or HTTPS URL."); + if (request.PollingIntervalSeconds < 5) errors.Add("PollingIntervalSeconds must be at least 5."); + if (request.RequestTimeoutSeconds < 1 || request.RequestTimeoutSeconds > request.PollingIntervalSeconds) errors.Add("RequestTimeoutSeconds must be between 1 and the polling interval."); + if (request.RetentionDays < 1) errors.Add("RetentionDays must be positive."); + return errors; + } + + public static MonitoredService ToService(ServiceRegistrationRequest request, MonitoredService? existing = null, bool preserveManagedSettings = true) => new() + { + Id = existing?.Id ?? Guid.NewGuid(), + ServiceId = request.ServiceId, + Name = request.Name, + Environment = request.Environment, + Group = request.Group, + Description = request.Description, + HealthUrl = new Uri(request.HealthUrl), + IsEnabled = existing?.IsEnabled ?? true, + PollingInterval = preserveManagedSettings && existing is not null ? existing.PollingInterval : TimeSpan.FromSeconds(request.PollingIntervalSeconds), + RequestTimeout = preserveManagedSettings && existing is not null ? existing.RequestTimeout : TimeSpan.FromSeconds(request.RequestTimeoutSeconds), + RetentionPeriod = preserveManagedSettings && existing is not null ? existing.RetentionPeriod : TimeSpan.FromDays(request.RetentionDays), + StatusPolicy = preserveManagedSettings && existing is not null ? existing.StatusPolicy : request.StatusPolicy ?? new StatusPolicy(), + Version = request.Version, + Host = request.Host, + LastRegisteredAtUtc = DateTimeOffset.UtcNow, + CreatedAtUtc = existing?.CreatedAtUtc ?? DateTimeOffset.UtcNow, + UpdatedAtUtc = DateTimeOffset.UtcNow + }; +} diff --git a/HealthMonitoring/Api/ServiceRegistrationContracts.cs b/HealthMonitoring/Api/ServiceRegistrationContracts.cs new file mode 100644 index 0000000..91313f8 --- /dev/null +++ b/HealthMonitoring/Api/ServiceRegistrationContracts.cs @@ -0,0 +1,34 @@ +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Api; + +public sealed class ServiceRegistrationRequest +{ + public ServiceRegistrationRequest() { } + public ServiceRegistrationRequest(string serviceId, string name, string environment, string healthUrl) { ServiceId = serviceId; Name = name; Environment = environment; HealthUrl = healthUrl; } + public string ServiceId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Environment { get; set; } = string.Empty; + public string HealthUrl { get; set; } = string.Empty; + public int PollingIntervalSeconds { get; set; } = 60; + public int RequestTimeoutSeconds { get; set; } = 10; + public int RetentionDays { get; set; } = 365; + public StatusPolicy? StatusPolicy { get; set; } + public string? Group { get; set; } + public string? Description { get; set; } + public string? Version { get; set; } + public string? Host { get; set; } +} + +public sealed record ServiceRegistrationResponse(string ServiceId, string Action, MonitoredService Service); + +public sealed record ServiceSummary( + string ServiceId, + string Name, + string Environment, + string HealthUrl, + bool IsEnabled, + HealthStatus Status, + DateTimeOffset? LastObservedAtUtc, + TimeSpan? LastResponseDuration, + string? LastError); diff --git a/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs new file mode 100644 index 0000000..88f8f20 --- /dev/null +++ b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs @@ -0,0 +1,64 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; + +namespace HealthMonitoring.Api; + +public static class ServiceRegistrationEndpoints +{ + public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + var errors = ServiceConfigurationValidator.Validate(request); + if (errors.Count > 0) return Results.ValidationProblem(errors.ToDictionary(error => error, error => new[] { error })); + + var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken); + var service = ServiceConfigurationValidator.ToService(request, existing, preserveManagedSettings: true); + await repository.UpsertServiceAsync(service, cancellationToken); + var action = existing is null ? "created" : "acknowledged"; + return existing is null + ? Results.Created($"/api/services/{service.ServiceId}", new ServiceRegistrationResponse(service.ServiceId, action, service)) + : Results.Ok(new ServiceRegistrationResponse(service.ServiceId, action, service)); + }); + + endpoints.MapGet("/api/services", async (IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + var services = await repository.ListServicesAsync(false, cancellationToken); + var results = new List(); + foreach (var service in services) + { + var snapshot = await repository.GetSnapshotAsync(service.Id, cancellationToken); + results.Add(new ServiceSummary(service.ServiceId, service.Name, service.Environment, service.HealthUrl.ToString(), service.IsEnabled, snapshot?.Status ?? HealthStatus.Unknown, snapshot?.LastObservedAtUtc, snapshot?.LastResponseDuration, snapshot?.LastError)); + } + + return Results.Ok(results); + }); + + endpoints.MapGet("/api/services/{serviceId}", async (string serviceId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + var service = await repository.GetServiceAsync(serviceId, cancellationToken); + if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound(); + return Results.Ok(service); + }); + + endpoints.MapPut("/api/services/{serviceId}", async (string serviceId, ServiceRegistrationRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + if (!string.Equals(serviceId, request.ServiceId, StringComparison.OrdinalIgnoreCase)) return Results.BadRequest("Route serviceId must match request ServiceId."); + var errors = ServiceConfigurationValidator.Validate(request); + if (errors.Count > 0) return Results.ValidationProblem(errors.ToDictionary(error => error, error => new[] { error })); + var existing = await repository.GetServiceAsync(serviceId, cancellationToken); + if (existing is null) return Results.NotFound(); + var service = ServiceConfigurationValidator.ToService(request, existing, preserveManagedSettings: false); + await repository.UpsertServiceAsync(service, cancellationToken); + return Results.Ok(service); + }); + + endpoints.MapDelete("/api/services/{serviceId}", async (string serviceId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + await repository.ArchiveServiceAsync(serviceId, DateTimeOffset.UtcNow, cancellationToken); + return Results.NoContent(); + }); + + return endpoints; + } +} diff --git a/HealthMonitoring/Components/App.razor b/HealthMonitoring/Components/App.razor new file mode 100644 index 0000000..956083c --- /dev/null +++ b/HealthMonitoring/Components/App.razor @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/HealthMonitoring/Components/Layout/MainLayout.razor b/HealthMonitoring/Components/Layout/MainLayout.razor new file mode 100644 index 0000000..f7fe91d --- /dev/null +++ b/HealthMonitoring/Components/Layout/MainLayout.razor @@ -0,0 +1,13 @@ +@inherits LayoutComponentBase + +
+ +
@Body
+
diff --git a/HealthMonitoring/Components/Pages/Overview.razor b/HealthMonitoring/Components/Pages/Overview.razor new file mode 100644 index 0000000..5f0f4a6 --- /dev/null +++ b/HealthMonitoring/Components/Pages/Overview.razor @@ -0,0 +1,38 @@ +@page "/" +@inject IDashboardQueryService Dashboard + +HealthWatch · Overview +

OPERATIONS

Services

Live health and history for registered services.

Register service
+ +@if (_overview is null) +{ +

Loading dashboard…

+} +else +{ +
+ + + + +
+
+
ServiceStatusResponseUptime (24h)Last check
+ @foreach (var service in _overview.Services) + { + +
@service.Name@service.Environment
+
+
@(service.ResponseDuration?.TotalMilliseconds.ToString("0") ?? "—") ms
+
@service.UptimePercent.ToString("0.00")%
+
@(service.LastChecked?.ToLocalTime().ToString("dd MMM, HH:mm:ss") ?? "Never")
+
+ } + @if (_overview.Services.Count == 0) {

No services registered yet.

} +
+} + +@code { + private DashboardOverview? _overview; + protected override async Task OnInitializedAsync() => _overview = await Dashboard.GetOverviewAsync(TimeSpan.FromHours(24), CancellationToken.None); +} diff --git a/HealthMonitoring/Components/Pages/ServiceDetail.razor b/HealthMonitoring/Components/Pages/ServiceDetail.razor new file mode 100644 index 0000000..b86b76e --- /dev/null +++ b/HealthMonitoring/Components/Pages/ServiceDetail.razor @@ -0,0 +1,25 @@ +@page "/services/{ServiceId}" +@inject IDashboardQueryService Dashboard + +@(_model?.Service.Name ?? "Service") · HealthWatch +@if (_model is null) +{ +

Service not found.

+} +else +{ +

SERVICE DETAIL · @_model.Service.Environment

@_model.Service.Name

@_model.Service.HealthUrl

Back to overview
+
Last checked @( _model.Summary.LastChecked?.ToLocalTime().ToString("dd MMM, HH:mm:ss") ?? "never")
+
+

Status timeline

@foreach (var observation in _model.Observations.OrderBy(item => item.ObservedAtUtc)) { }
+

Dependency details

@if (_model.Checks.Count == 0) {

No dependency details have been returned.

} else {
CheckStatusDurationDetails
@foreach (var check in _model.Checks.GroupBy(item => item.Name).Select(group => group.OrderByDescending(item => item.HealthObservationId).First())) {
@check.Name
@check.Duration.TotalMilliseconds.ToString("0") ms
@(check.Description ?? check.Exception ?? "No detail")
}
}
+
Latest raw health response
@(_model.Observations.FirstOrDefault()?.RawResponseJson ?? "No response recorded.")
+} + +@code { + [Parameter] public string ServiceId { get; set; } = string.Empty; + private ServiceDetailModel? _model; + private string UptimeText => $"{_model?.Summary.UptimePercent:0.00}%"; + private string ResponseText => _model?.Summary.ResponseDuration is { } duration ? $"{duration.TotalMilliseconds:0} ms" : "—"; + protected override async Task OnInitializedAsync() => _model = await Dashboard.GetServiceDetailAsync(ServiceId, TimeSpan.FromHours(24), CancellationToken.None); +} diff --git a/HealthMonitoring/Components/Pages/ServiceRegister.razor b/HealthMonitoring/Components/Pages/ServiceRegister.razor new file mode 100644 index 0000000..c946054 --- /dev/null +++ b/HealthMonitoring/Components/Pages/ServiceRegister.razor @@ -0,0 +1,28 @@ +@page "/services/new" +@inject HttpClient Http +@inject NavigationManager Navigation + +Register service · HealthWatch +

CONFIGURATION

Register service

Add a service manually or use the self-registration API.

Cancel
+ + + + + +
+ @if (!string.IsNullOrWhiteSpace(_error)) {

@_error

} + +
+ +@code { + private ServiceRegistrationRequest _request = new("", "", "Production", "https://localhost/health"); + private bool _saving; + private string? _error; + private async Task RegisterAsync() + { + _saving = true; + try { var response = await Http.PostAsJsonAsync("/api/services/register", _request); if (response.IsSuccessStatusCode) Navigation.NavigateTo("/"); else _error = await response.Content.ReadAsStringAsync(); } + catch (Exception exception) { _error = exception.Message; } + finally { _saving = false; } + } +} diff --git a/HealthMonitoring/Components/Routes.razor b/HealthMonitoring/Components/Routes.razor new file mode 100644 index 0000000..eaecd3b --- /dev/null +++ b/HealthMonitoring/Components/Routes.razor @@ -0,0 +1,8 @@ + + + + + +

Page not found.

+
+
diff --git a/HealthMonitoring/Components/Shared/StatCard.razor b/HealthMonitoring/Components/Shared/StatCard.razor new file mode 100644 index 0000000..86e366c --- /dev/null +++ b/HealthMonitoring/Components/Shared/StatCard.razor @@ -0,0 +1,2 @@ +
@Label@Value
+@code { [Parameter] public string Label { get; set; } = string.Empty; [Parameter] public object? Value { get; set; } [Parameter] public string Class { get; set; } = string.Empty; } diff --git a/HealthMonitoring/Components/Shared/StatusBadge.razor b/HealthMonitoring/Components/Shared/StatusBadge.razor new file mode 100644 index 0000000..f42dc03 --- /dev/null +++ b/HealthMonitoring/Components/Shared/StatusBadge.razor @@ -0,0 +1,2 @@ +● @Status +@code { [Parameter] public HealthStatus Status { get; set; } } diff --git a/HealthMonitoring/Components/_Imports.razor b/HealthMonitoring/Components/_Imports.razor new file mode 100644 index 0000000..43048c2 --- /dev/null +++ b/HealthMonitoring/Components/_Imports.razor @@ -0,0 +1,10 @@ +@using System.Net.Http.Json +@using HealthMonitoring.Domain +@using HealthMonitoring.Reporting +@using HealthMonitoring.Components.Shared +@using HealthMonitoring.Api +@using Microsoft.AspNetCore.Components.Forms +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web diff --git a/HealthMonitoring/Dockerfile b/HealthMonitoring/Dockerfile new file mode 100644 index 0000000..74ac5cc --- /dev/null +++ b/HealthMonitoring/Dockerfile @@ -0,0 +1,10 @@ +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +WORKDIR /src +COPY . . +RUN dotnet publish HealthMonitoring.csproj -c Release -o /app/publish /p:UseAppHost=false + +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime +WORKDIR /app +COPY --from=build /app/publish . +EXPOSE 8080 +ENTRYPOINT ["dotnet", "HealthMonitoring.dll"] diff --git a/HealthMonitoring/Domain/HealthCheckResult.cs b/HealthMonitoring/Domain/HealthCheckResult.cs new file mode 100644 index 0000000..b4a9de0 --- /dev/null +++ b/HealthMonitoring/Domain/HealthCheckResult.cs @@ -0,0 +1,13 @@ +namespace HealthMonitoring.Domain; + +public sealed class HealthCheckResult +{ + public Guid Id { get; set; } + public Guid HealthObservationId { get; set; } + public string Name { get; set; } = string.Empty; + public HealthStatus Status { get; set; } + public string? Description { get; set; } + public TimeSpan Duration { get; set; } + public string? Exception { get; set; } + public string? DataJson { get; set; } +} diff --git a/HealthMonitoring/Domain/HealthObservation.cs b/HealthMonitoring/Domain/HealthObservation.cs new file mode 100644 index 0000000..b276c8a --- /dev/null +++ b/HealthMonitoring/Domain/HealthObservation.cs @@ -0,0 +1,15 @@ +namespace HealthMonitoring.Domain; + +public sealed class HealthObservation +{ + public Guid Id { get; set; } + public Guid MonitoredServiceId { get; set; } + public DateTimeOffset ObservedAtUtc { get; set; } + public HealthStatus Status { get; set; } + public string AggregateStatus { get; set; } = string.Empty; + public int? HttpStatusCode { get; set; } + public TimeSpan ResponseDuration { get; set; } + public string? RawResponseJson { get; set; } + public string? Error { get; set; } + public List Checks { get; set; } = []; +} diff --git a/HealthMonitoring/Domain/HealthResponseModels.cs b/HealthMonitoring/Domain/HealthResponseModels.cs new file mode 100644 index 0000000..6d65252 --- /dev/null +++ b/HealthMonitoring/Domain/HealthResponseModels.cs @@ -0,0 +1,29 @@ +using System.Text.Json; + +namespace HealthMonitoring.Domain; + +public sealed record HealthResponseEnvelope( + string Status, + IReadOnlyDictionary Entries); + +public sealed record HealthResponseEntry( + string Status, + string? Description, + TimeSpan Duration, + string? Exception, + IReadOnlyDictionary Data); + +public sealed record NormalizedHealthResult( + HealthStatus Status, + string AggregateStatus, + IReadOnlyList Checks, + string? Error = null, + string? RawResponse = null); + +public sealed record NormalizedCheckResult( + string Name, + HealthStatus Status, + string? Description, + TimeSpan Duration, + string? Exception, + IReadOnlyDictionary Data); diff --git a/HealthMonitoring/Domain/HealthStatus.cs b/HealthMonitoring/Domain/HealthStatus.cs new file mode 100644 index 0000000..9e16cb3 --- /dev/null +++ b/HealthMonitoring/Domain/HealthStatus.cs @@ -0,0 +1,9 @@ +namespace HealthMonitoring.Domain; + +public enum HealthStatus +{ + Unknown, + Healthy, + Degraded, + Unhealthy +} diff --git a/HealthMonitoring/Domain/HealthStatusNormalizer.cs b/HealthMonitoring/Domain/HealthStatusNormalizer.cs new file mode 100644 index 0000000..0dde495 --- /dev/null +++ b/HealthMonitoring/Domain/HealthStatusNormalizer.cs @@ -0,0 +1,69 @@ +using System.Net; + +namespace HealthMonitoring.Domain; + +public sealed class HealthStatusNormalizer : IHealthStatusNormalizer +{ + public NormalizedHealthResult Normalize( + HealthResponseEnvelope response, + HttpStatusCode? httpStatusCode, + TimeSpan duration, + StatusPolicy? policy = null) + { + policy ??= new StatusPolicy(); + var checks = response.Entries + .Select(entry => new NormalizedCheckResult( + entry.Key, + ParseStatus(entry.Value.Status), + entry.Value.Description, + entry.Value.Duration, + entry.Value.Exception, + entry.Value.Data)) + .ToArray(); + + var criticalChecks = policy.CriticalChecks.ToHashSet(StringComparer.OrdinalIgnoreCase); + var hasUnhealthy = checks.Any(check => check.Status == HealthStatus.Unhealthy); + var hasCriticalFailure = checks.Any(check => criticalChecks.Contains(check.Name) && check.Status != HealthStatus.Healthy); + var hasDegraded = checks.Any(check => check.Status == HealthStatus.Degraded); + var status = hasCriticalFailure || (policy.UnhealthyDependencyBehavior == UnhealthyDependencyBehavior.Unhealthy && hasUnhealthy) + ? HealthStatus.Unhealthy + : hasUnhealthy || hasDegraded + ? HealthStatus.Degraded + : HealthStatus.Healthy; + + return new NormalizedHealthResult(status, response.Status, checks); + } + + public NormalizedHealthResult NormalizeInvalid( + HttpStatusCode? httpStatusCode, + string rawResponse, + TimeSpan duration, + StatusPolicy? policy = null) + { + var status = string.Equals(policy?.EndpointFailureStatus, nameof(HealthStatus.Degraded), StringComparison.OrdinalIgnoreCase) + ? HealthStatus.Degraded + : HealthStatus.Unhealthy; + + return new NormalizedHealthResult( + status, + httpStatusCode?.ToString() ?? "NoResponse", + [], + $"Invalid health response: {rawResponse}", + rawResponse); + } + + private static HealthStatus ParseStatus(string status) + { + if (string.Equals(status, nameof(HealthStatus.Healthy), StringComparison.OrdinalIgnoreCase)) + { + return HealthStatus.Healthy; + } + + if (string.Equals(status, nameof(HealthStatus.Degraded), StringComparison.OrdinalIgnoreCase)) + { + return HealthStatus.Degraded; + } + + return HealthStatus.Unhealthy; + } +} diff --git a/HealthMonitoring/Domain/IHealthStatusNormalizer.cs b/HealthMonitoring/Domain/IHealthStatusNormalizer.cs new file mode 100644 index 0000000..9ce0b4d --- /dev/null +++ b/HealthMonitoring/Domain/IHealthStatusNormalizer.cs @@ -0,0 +1,18 @@ +using System.Net; + +namespace HealthMonitoring.Domain; + +public interface IHealthStatusNormalizer +{ + NormalizedHealthResult Normalize( + HealthResponseEnvelope response, + HttpStatusCode? httpStatusCode, + TimeSpan duration, + StatusPolicy? policy = null); + + NormalizedHealthResult NormalizeInvalid( + HttpStatusCode? httpStatusCode, + string rawResponse, + TimeSpan duration, + StatusPolicy? policy = null); +} diff --git a/HealthMonitoring/Domain/MonitoredService.cs b/HealthMonitoring/Domain/MonitoredService.cs new file mode 100644 index 0000000..93ae70a --- /dev/null +++ b/HealthMonitoring/Domain/MonitoredService.cs @@ -0,0 +1,24 @@ +namespace HealthMonitoring.Domain; + +public sealed class MonitoredService +{ + public Guid Id { get; set; } + public string ServiceId { get; set; } = string.Empty; + public string Name { get; set; } = string.Empty; + public string Environment { get; set; } = string.Empty; + public string? Group { get; set; } + public string? Description { get; set; } + public Uri HealthUrl { get; set; } = new("https://localhost"); + public bool IsEnabled { get; set; } = true; + public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(1); + public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10); + public TimeSpan RetentionPeriod { get; set; } = TimeSpan.FromDays(365); + public StatusPolicy StatusPolicy { get; set; } = new(); + public string StatusPolicyJson { get; set; } = "{}"; + public string? Version { get; set; } + public string? Host { get; set; } + public DateTimeOffset? LastRegisteredAtUtc { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset? ArchivedAtUtc { get; set; } +} diff --git a/HealthMonitoring/Domain/ServiceIncident.cs b/HealthMonitoring/Domain/ServiceIncident.cs new file mode 100644 index 0000000..8fdc491 --- /dev/null +++ b/HealthMonitoring/Domain/ServiceIncident.cs @@ -0,0 +1,14 @@ +namespace HealthMonitoring.Domain; + +public sealed class ServiceIncident +{ + public Guid Id { get; set; } + public Guid MonitoredServiceId { get; set; } + public DateTimeOffset StartedAtUtc { get; set; } + public DateTimeOffset? EndedAtUtc { get; set; } + public HealthStatus Severity { get; set; } + public Guid FirstObservationId { get; set; } + public Guid LastObservationId { get; set; } + public bool IsOpen => EndedAtUtc is null; + public TimeSpan? Duration => (EndedAtUtc ?? DateTimeOffset.UtcNow) - StartedAtUtc; +} diff --git a/HealthMonitoring/Domain/ServiceStatusSnapshot.cs b/HealthMonitoring/Domain/ServiceStatusSnapshot.cs new file mode 100644 index 0000000..9d15aa7 --- /dev/null +++ b/HealthMonitoring/Domain/ServiceStatusSnapshot.cs @@ -0,0 +1,12 @@ +namespace HealthMonitoring.Domain; + +public sealed class ServiceStatusSnapshot +{ + public Guid MonitoredServiceId { get; set; } + public HealthStatus Status { get; set; } = HealthStatus.Unknown; + public DateTimeOffset? LastObservedAtUtc { get; set; } + public TimeSpan? LastResponseDuration { get; set; } + public Guid? CurrentIncidentId { get; set; } + public string? LastError { get; set; } + public Guid? LastObservationId { get; set; } +} diff --git a/HealthMonitoring/Domain/StatusPolicy.cs b/HealthMonitoring/Domain/StatusPolicy.cs new file mode 100644 index 0000000..a65a7ea --- /dev/null +++ b/HealthMonitoring/Domain/StatusPolicy.cs @@ -0,0 +1,14 @@ +namespace HealthMonitoring.Domain; + +public enum UnhealthyDependencyBehavior +{ + Degraded, + Unhealthy +} + +public sealed class StatusPolicy +{ + public UnhealthyDependencyBehavior UnhealthyDependencyBehavior { get; init; } = UnhealthyDependencyBehavior.Degraded; + public IReadOnlyCollection CriticalChecks { get; init; } = []; + public string EndpointFailureStatus { get; init; } = nameof(HealthStatus.Unhealthy); +} diff --git a/HealthMonitoring/Health/MonitoringHealthChecks.cs b/HealthMonitoring/Health/MonitoringHealthChecks.cs new file mode 100644 index 0000000..6077c0f --- /dev/null +++ b/HealthMonitoring/Health/MonitoringHealthChecks.cs @@ -0,0 +1,22 @@ +using Microsoft.Extensions.Diagnostics.HealthChecks; + +namespace HealthMonitoring.Health; + +public sealed class SqlServerHealthCheck(IServiceScopeFactory scopeFactory) : IHealthCheck +{ + public async Task CheckHealthAsync(HealthCheckContext context, CancellationToken cancellationToken = default) + { + try + { + await using var scope = scopeFactory.CreateAsyncScope(); + var db = scope.ServiceProvider.GetRequiredService(); + return await db.Database.CanConnectAsync(cancellationToken) + ? HealthCheckResult.Healthy("SQL Server is reachable.") + : HealthCheckResult.Unhealthy("SQL Server is not reachable."); + } + catch (Exception exception) + { + return HealthCheckResult.Unhealthy("SQL Server health check failed.", exception); + } + } +} diff --git a/HealthMonitoring/HealthMonitoring.csproj b/HealthMonitoring/HealthMonitoring.csproj new file mode 100644 index 0000000..7bdd8a6 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.csproj @@ -0,0 +1,15 @@ + + + net10.0 + enable + enable + dotnet-HealthMonitoring + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + diff --git a/HealthMonitoring/HealthMonitoring.sln b/HealthMonitoring/HealthMonitoring.sln new file mode 100644 index 0000000..d72b291 --- /dev/null +++ b/HealthMonitoring/HealthMonitoring.sln @@ -0,0 +1,25 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 18 +VisualStudioVersion = 18.4.11626.88 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HealthMonitoring", "HealthMonitoring.csproj", "{7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HealthMonitoring.Tests", "..\HealthMonitoring.Tests\HealthMonitoring.Tests.csproj", "{2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|Any CPU.Build.0 = Release|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|Any CPU.Build.0 = Release|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/HealthMonitoring/Monitoring/HealthEndpointClient.cs b/HealthMonitoring/Monitoring/HealthEndpointClient.cs new file mode 100644 index 0000000..f987426 --- /dev/null +++ b/HealthMonitoring/Monitoring/HealthEndpointClient.cs @@ -0,0 +1,86 @@ +using System.Diagnostics; +using System.Net; +using System.Text.Json; +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Monitoring; + +public sealed record HealthEndpointResult( + NormalizedHealthResult Normalized, + HttpStatusCode? HttpStatusCode, + TimeSpan Duration, + string? RawResponse); + +public interface IHealthEndpointClient +{ + Task CheckAsync(MonitoredService service, CancellationToken cancellationToken); +} + +public sealed class HealthEndpointClient(HttpClient httpClient, IHealthStatusNormalizer normalizer) : IHealthEndpointClient +{ + public async Task CheckAsync(MonitoredService service, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + try + { + using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeout.CancelAfter(service.RequestTimeout); + using var response = await httpClient.GetAsync(service.HealthUrl, timeout.Token); + var raw = await response.Content.ReadAsStringAsync(timeout.Token); + stopwatch.Stop(); + try + { + var parsed = Parse(raw); + var normalized = normalizer.Normalize(parsed, response.StatusCode, stopwatch.Elapsed, service.StatusPolicy) with { RawResponse = raw }; + if (!response.IsSuccessStatusCode) + { + normalized = normalized with { Status = HealthStatus.Unhealthy, Error = $"Health endpoint returned {(int)response.StatusCode} {response.ReasonPhrase}." }; + } + + return new HealthEndpointResult(normalized, response.StatusCode, stopwatch.Elapsed, raw); + } + catch (JsonException) + { + var normalized = normalizer.NormalizeInvalid(response.StatusCode, raw, stopwatch.Elapsed, service.StatusPolicy); + return new HealthEndpointResult(normalized, response.StatusCode, stopwatch.Elapsed, raw); + } + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + stopwatch.Stop(); + var normalized = normalizer.NormalizeInvalid(null, "Request timeout.", stopwatch.Elapsed, service.StatusPolicy); + return new HealthEndpointResult(normalized, null, stopwatch.Elapsed, null); + } + catch (Exception exception) when (exception is HttpRequestException or TaskCanceledException) + { + stopwatch.Stop(); + var normalized = normalizer.NormalizeInvalid(null, exception.Message, stopwatch.Elapsed, service.StatusPolicy); + return new HealthEndpointResult(normalized, null, stopwatch.Elapsed, null); + } + } + + private static HealthResponseEnvelope Parse(string raw) + { + using var document = JsonDocument.Parse(raw); + var root = document.RootElement; + var status = root.GetProperty("status").GetString() ?? nameof(HealthStatus.Unhealthy); + var entries = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (root.TryGetProperty("entries", out var entriesElement)) + { + foreach (var property in entriesElement.EnumerateObject()) + { + var entry = property.Value; + var entryStatus = entry.TryGetProperty("status", out var statusElement) ? statusElement.GetString() ?? nameof(HealthStatus.Unhealthy) : nameof(HealthStatus.Unhealthy); + var description = entry.TryGetProperty("description", out var descriptionElement) ? descriptionElement.GetString() : null; + var duration = entry.TryGetProperty("duration", out var durationElement) && TimeSpan.TryParse(durationElement.GetString(), out var parsedDuration) ? parsedDuration : TimeSpan.Zero; + var exception = entry.TryGetProperty("exception", out var exceptionElement) ? exceptionElement.GetString() : null; + var data = entry.TryGetProperty("data", out var dataElement) + ? dataElement.EnumerateObject().ToDictionary(item => item.Name, item => item.Value.Clone(), StringComparer.OrdinalIgnoreCase) + : new Dictionary(); + entries[property.Name] = new HealthResponseEntry(entryStatus, description, duration, exception, data); + } + } + + return new HealthResponseEnvelope(status, entries); + } +} diff --git a/HealthMonitoring/Monitoring/HealthPollingWorker.cs b/HealthMonitoring/Monitoring/HealthPollingWorker.cs new file mode 100644 index 0000000..3f2debd --- /dev/null +++ b/HealthMonitoring/Monitoring/HealthPollingWorker.cs @@ -0,0 +1,86 @@ +using System.Text.Json; +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; + +namespace HealthMonitoring.Monitoring; + +public sealed class HealthPollingWorker( + IServiceScopeFactory scopeFactory, + ILogger logger) : BackgroundService +{ + private readonly Dictionary _nextPollAt = []; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await PollDueServicesAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + break; + } + catch (Exception exception) + { + logger.LogError(exception, "Health polling cycle failed."); + } + + await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken); + } + } + + private async Task PollDueServicesAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + var endpointClient = scope.ServiceProvider.GetRequiredService(); + var calculator = scope.ServiceProvider.GetRequiredService(); + var services = await repository.ListServicesAsync(false, cancellationToken); + var now = DateTimeOffset.UtcNow; + + foreach (var service in services.Where(item => item.IsEnabled && item.ArchivedAtUtc is null)) + { + if (_nextPollAt.TryGetValue(service.Id, out var nextPoll) && nextPoll > now) + { + continue; + } + + _nextPollAt[service.Id] = now.Add(service.PollingInterval); + var result = await endpointClient.CheckAsync(service, cancellationToken); + var observation = ToObservation(service, result, now); + await repository.AddObservationAsync(observation, cancellationToken); + await calculator.ApplyAsync(service, observation, cancellationToken); + } + + var activeIds = services.Select(service => service.Id).ToHashSet(); + foreach (var staleId in _nextPollAt.Keys.Where(id => !activeIds.Contains(id)).ToArray()) + { + _nextPollAt.Remove(staleId); + } + } + + private static HealthObservation ToObservation(MonitoredService service, HealthEndpointResult result, DateTimeOffset observedAtUtc) => new() + { + Id = Guid.NewGuid(), + MonitoredServiceId = service.Id, + ObservedAtUtc = observedAtUtc, + Status = result.Normalized.Status, + AggregateStatus = result.Normalized.AggregateStatus, + HttpStatusCode = (int?)result.HttpStatusCode, + ResponseDuration = result.Duration, + RawResponseJson = result.RawResponse, + Error = result.Normalized.Error, + Checks = result.Normalized.Checks.Select(check => new HealthCheckResult + { + Id = Guid.NewGuid(), + Name = check.Name, + Status = check.Status, + Description = check.Description, + Duration = check.Duration, + Exception = check.Exception, + DataJson = JsonSerializer.Serialize(check.Data) + }).ToList() + }; +} diff --git a/HealthMonitoring/Monitoring/IncidentCalculator.cs b/HealthMonitoring/Monitoring/IncidentCalculator.cs new file mode 100644 index 0000000..fa1ea4a --- /dev/null +++ b/HealthMonitoring/Monitoring/IncidentCalculator.cs @@ -0,0 +1,75 @@ +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Monitoring; + +public interface IMonitoringStateRepository +{ + Task GetSnapshotAsync(Guid serviceId, CancellationToken cancellationToken); + Task SetSnapshotAsync(ServiceStatusSnapshot snapshot, CancellationToken cancellationToken); + Task GetOpenIncidentAsync(Guid serviceId, CancellationToken cancellationToken); + Task AddIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken); + Task UpdateIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken); +} + +public interface IClock { DateTimeOffset UtcNow { get; } } +public sealed class SystemClock : IClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; } + +public sealed record AlertEvent(Guid ServiceId, HealthStatus PreviousStatus, HealthStatus CurrentStatus, Guid IncidentId, bool Recovered); +public interface IAlertDispatcher { Task DispatchAsync(AlertEvent alertEvent, CancellationToken cancellationToken); } +public sealed class LoggingAlertDispatcher(ILogger logger) : IAlertDispatcher +{ + public Task DispatchAsync(AlertEvent alertEvent, CancellationToken cancellationToken) + { + logger.LogInformation("Health incident transition for {ServiceId}: {PreviousStatus} -> {CurrentStatus}; recovered={Recovered}", alertEvent.ServiceId, alertEvent.PreviousStatus, alertEvent.CurrentStatus, alertEvent.Recovered); + return Task.CompletedTask; + } +} + +public sealed class IncidentCalculator(IMonitoringStateRepository repository, IClock clock, IAlertDispatcher alertDispatcher) +{ + public async Task ApplyAsync(MonitoredService service, HealthObservation observation, CancellationToken cancellationToken) + { + var snapshot = await repository.GetSnapshotAsync(service.Id, cancellationToken) ?? new ServiceStatusSnapshot { MonitoredServiceId = service.Id }; + var previousStatus = snapshot.Status; + var openIncident = await repository.GetOpenIncidentAsync(service.Id, cancellationToken); + + if (observation.Status is HealthStatus.Degraded or HealthStatus.Unhealthy) + { + if (openIncident is null) + { + openIncident = new ServiceIncident + { + Id = Guid.NewGuid(), + MonitoredServiceId = service.Id, + StartedAtUtc = observation.ObservedAtUtc == default ? clock.UtcNow : observation.ObservedAtUtc, + Severity = observation.Status, + FirstObservationId = observation.Id, + LastObservationId = observation.Id + }; + await repository.AddIncidentAsync(openIncident, cancellationToken); + await alertDispatcher.DispatchAsync(new AlertEvent(service.Id, previousStatus, observation.Status, openIncident.Id, false), cancellationToken); + } + else + { + openIncident.Severity = observation.Status > openIncident.Severity ? observation.Status : openIncident.Severity; + openIncident.LastObservationId = observation.Id; + await repository.UpdateIncidentAsync(openIncident, cancellationToken); + } + } + else if (observation.Status == HealthStatus.Healthy && openIncident is not null) + { + openIncident.EndedAtUtc = observation.ObservedAtUtc == default ? clock.UtcNow : observation.ObservedAtUtc; + openIncident.LastObservationId = observation.Id; + await repository.UpdateIncidentAsync(openIncident, cancellationToken); + await alertDispatcher.DispatchAsync(new AlertEvent(service.Id, previousStatus, observation.Status, openIncident.Id, true), cancellationToken); + } + + snapshot.Status = observation.Status; + snapshot.LastObservedAtUtc = observation.ObservedAtUtc; + snapshot.LastResponseDuration = observation.ResponseDuration; + snapshot.LastError = observation.Error; + snapshot.LastObservationId = observation.Id; + snapshot.CurrentIncidentId = openIncident?.IsOpen == true ? openIncident.Id : null; + await repository.SetSnapshotAsync(snapshot, cancellationToken); + } +} diff --git a/HealthMonitoring/Monitoring/RetentionCleanupWorker.cs b/HealthMonitoring/Monitoring/RetentionCleanupWorker.cs new file mode 100644 index 0000000..4249a72 --- /dev/null +++ b/HealthMonitoring/Monitoring/RetentionCleanupWorker.cs @@ -0,0 +1,30 @@ +using HealthMonitoring.Persistence; + +namespace HealthMonitoring.Monitoring; + +public sealed class RetentionCleanupWorker(IServiceScopeFactory scopeFactory, ILogger logger) : BackgroundService +{ + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + while (!stoppingToken.IsCancellationRequested) + { + try + { + await CleanupAsync(stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) { break; } + catch (Exception exception) { logger.LogError(exception, "Health history retention cleanup failed."); } + await Task.Delay(TimeSpan.FromHours(1), stoppingToken); + } + } + + private async Task CleanupAsync(CancellationToken cancellationToken) + { + await using var scope = scopeFactory.CreateAsyncScope(); + var repository = scope.ServiceProvider.GetRequiredService(); + foreach (var service in await repository.ListServicesAsync(true, cancellationToken)) + { + await repository.DeleteObservationsBeforeAsync(service.Id, DateTimeOffset.UtcNow.Subtract(service.RetentionPeriod), cancellationToken); + } + } +} diff --git a/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs b/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs new file mode 100644 index 0000000..8d154ca --- /dev/null +++ b/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs @@ -0,0 +1,67 @@ +using HealthMonitoring.Domain; +using Microsoft.EntityFrameworkCore; + +namespace HealthMonitoring.Persistence; + +public sealed class HealthMonitoringDbContext(DbContextOptions options) : DbContext(options) +{ + public DbSet MonitoredServices => Set(); + public DbSet HealthObservations => Set(); + public DbSet HealthCheckResults => Set(); + public DbSet ServiceIncidents => Set(); + public DbSet ServiceStatusSnapshots => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(service => service.Id); + entity.HasIndex(service => service.ServiceId).IsUnique(); + entity.Property(service => service.ServiceId).HasMaxLength(200).IsRequired(); + entity.Property(service => service.Name).HasMaxLength(300).IsRequired(); + entity.Property(service => service.Environment).HasMaxLength(100).IsRequired(); + entity.Property(service => service.HealthUrl).HasConversion().HasMaxLength(2048).IsRequired(); + entity.Property(service => service.StatusPolicyJson).HasColumnType("nvarchar(max)").IsRequired(); + entity.Ignore(service => service.StatusPolicy); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(observation => observation.Id); + entity.HasIndex(observation => new { observation.MonitoredServiceId, observation.ObservedAtUtc }); + entity.Property(observation => observation.Status).HasConversion().HasMaxLength(20); + entity.Property(observation => observation.AggregateStatus).HasMaxLength(50).IsRequired(); + entity.Property(observation => observation.RawResponseJson).HasColumnType("nvarchar(max)"); + entity.Property(observation => observation.Error).HasColumnType("nvarchar(max)"); + entity.HasMany(observation => observation.Checks) + .WithOne() + .HasForeignKey(checkResult => checkResult.HealthObservationId) + .OnDelete(DeleteBehavior.Cascade); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(checkResult => checkResult.Id); + entity.Property(checkResult => checkResult.Name).HasMaxLength(300).IsRequired(); + entity.Property(checkResult => checkResult.Status).HasConversion().HasMaxLength(20); + entity.Property(checkResult => checkResult.DataJson).HasColumnType("nvarchar(max)"); + entity.Property(checkResult => checkResult.Exception).HasColumnType("nvarchar(max)"); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(incident => incident.Id); + entity.HasIndex(incident => new { incident.MonitoredServiceId, incident.StartedAtUtc }); + entity.Property(incident => incident.Severity).HasConversion().HasMaxLength(20); + entity.Ignore(incident => incident.IsOpen); + entity.Ignore(incident => incident.Duration); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(snapshot => snapshot.MonitoredServiceId); + entity.Property(snapshot => snapshot.Status).HasConversion().HasMaxLength(20); + entity.Property(snapshot => snapshot.LastError).HasColumnType("nvarchar(max)"); + }); + } +} diff --git a/HealthMonitoring/Persistence/HealthMonitoringRepository.cs b/HealthMonitoring/Persistence/HealthMonitoringRepository.cs new file mode 100644 index 0000000..859d59e --- /dev/null +++ b/HealthMonitoring/Persistence/HealthMonitoringRepository.cs @@ -0,0 +1,138 @@ +using System.Text.Json; +using HealthMonitoring.Domain; +using Microsoft.EntityFrameworkCore; + +namespace HealthMonitoring.Persistence; + +public sealed class HealthMonitoringRepository(HealthMonitoringDbContext dbContext) : IHealthMonitoringRepository, HealthMonitoring.Monitoring.IMonitoringStateRepository +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task GetServiceAsync(string serviceId, CancellationToken cancellationToken) + { + var service = await dbContext.MonitoredServices.SingleOrDefaultAsync(item => item.ServiceId == serviceId, cancellationToken); + return HydratePolicy(service); + } + + public async Task> ListServicesAsync(bool includeArchived, CancellationToken cancellationToken) + { + var query = dbContext.MonitoredServices.AsNoTracking(); + if (!includeArchived) + { + query = query.Where(service => service.ArchivedAtUtc == null); + } + + var services = await query.OrderBy(service => service.Name).ToListAsync(cancellationToken); + return services.Select(HydratePolicy).ToArray()!; + } + + public async Task UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken) + { + service.StatusPolicyJson = JsonSerializer.Serialize(service.StatusPolicy, JsonOptions); + var existing = await dbContext.MonitoredServices.SingleOrDefaultAsync(item => item.ServiceId == service.ServiceId, cancellationToken); + if (existing is null) + { + dbContext.MonitoredServices.Add(service); + } + else + { + existing.Name = service.Name; + existing.Environment = service.Environment; + existing.Group = service.Group; + existing.Description = service.Description; + existing.HealthUrl = service.HealthUrl; + existing.IsEnabled = service.IsEnabled; + existing.PollingInterval = service.PollingInterval; + existing.RequestTimeout = service.RequestTimeout; + existing.RetentionPeriod = service.RetentionPeriod; + existing.StatusPolicyJson = service.StatusPolicyJson; + existing.Version = service.Version; + existing.Host = service.Host; + existing.LastRegisteredAtUtc = service.LastRegisteredAtUtc; + existing.UpdatedAtUtc = service.UpdatedAtUtc; + service = existing; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return HydratePolicy(service)!; + } + + public async Task ArchiveServiceAsync(string serviceId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken) + { + var service = await dbContext.MonitoredServices.SingleOrDefaultAsync(item => item.ServiceId == serviceId, cancellationToken); + if (service is null) + { + return; + } + + service.IsEnabled = false; + service.ArchivedAtUtc = archivedAtUtc; + service.UpdatedAtUtc = archivedAtUtc; + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task AddObservationAsync(HealthObservation observation, CancellationToken cancellationToken) + { + dbContext.HealthObservations.Add(observation); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task> GetObservationsAsync(Guid serviceId, DateTimeOffset fromUtc, DateTimeOffset toUtc, CancellationToken cancellationToken) => + await dbContext.HealthObservations + .AsNoTracking() + .Include(observation => observation.Checks) + .Where(observation => observation.MonitoredServiceId == serviceId && observation.ObservedAtUtc >= fromUtc && observation.ObservedAtUtc <= toUtc) + .OrderByDescending(observation => observation.ObservedAtUtc) + .ToListAsync(cancellationToken); + + public Task GetSnapshotAsync(Guid serviceId, CancellationToken cancellationToken) => + dbContext.ServiceStatusSnapshots.AsNoTracking().SingleOrDefaultAsync(snapshot => snapshot.MonitoredServiceId == serviceId, cancellationToken); + + public async Task UpsertSnapshotAsync(ServiceStatusSnapshot snapshot, CancellationToken cancellationToken) + { + var existing = await dbContext.ServiceStatusSnapshots.SingleOrDefaultAsync(item => item.MonitoredServiceId == snapshot.MonitoredServiceId, cancellationToken); + if (existing is null) + { + dbContext.ServiceStatusSnapshots.Add(snapshot); + } + else + { + dbContext.Entry(existing).CurrentValues.SetValues(snapshot); + } + + await dbContext.SaveChangesAsync(cancellationToken); + } + + public Task SetSnapshotAsync(ServiceStatusSnapshot snapshot, CancellationToken cancellationToken) => UpsertSnapshotAsync(snapshot, cancellationToken); + + public Task GetOpenIncidentAsync(Guid serviceId, CancellationToken cancellationToken) => + dbContext.ServiceIncidents.SingleOrDefaultAsync(incident => incident.MonitoredServiceId == serviceId && incident.EndedAtUtc == null, cancellationToken); + + public async Task AddIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken) + { + dbContext.ServiceIncidents.Add(incident); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task UpdateIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken) + { + dbContext.ServiceIncidents.Update(incident); + await dbContext.SaveChangesAsync(cancellationToken); + } + + public async Task DeleteObservationsBeforeAsync(Guid serviceId, DateTimeOffset cutoffUtc, CancellationToken cancellationToken) => + await dbContext.HealthObservations + .Where(observation => observation.MonitoredServiceId == serviceId && observation.ObservedAtUtc < cutoffUtc) + .ExecuteDeleteAsync(cancellationToken); + + private static MonitoredService? HydratePolicy(MonitoredService? service) + { + if (service is null) + { + return null; + } + + service.StatusPolicy = JsonSerializer.Deserialize(service.StatusPolicyJson, JsonOptions) ?? new StatusPolicy(); + return service; + } +} diff --git a/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs b/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs new file mode 100644 index 0000000..47753d4 --- /dev/null +++ b/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs @@ -0,0 +1,19 @@ +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Persistence; + +public interface IHealthMonitoringRepository +{ + Task GetServiceAsync(string serviceId, CancellationToken cancellationToken); + Task> ListServicesAsync(bool includeArchived, CancellationToken cancellationToken); + Task UpsertServiceAsync(MonitoredService service, CancellationToken cancellationToken); + Task ArchiveServiceAsync(string serviceId, DateTimeOffset archivedAtUtc, CancellationToken cancellationToken); + Task AddObservationAsync(HealthObservation observation, CancellationToken cancellationToken); + Task> GetObservationsAsync(Guid serviceId, DateTimeOffset fromUtc, DateTimeOffset toUtc, CancellationToken cancellationToken); + Task GetSnapshotAsync(Guid serviceId, CancellationToken cancellationToken); + Task UpsertSnapshotAsync(ServiceStatusSnapshot snapshot, CancellationToken cancellationToken); + Task GetOpenIncidentAsync(Guid serviceId, CancellationToken cancellationToken); + Task AddIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken); + Task UpdateIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken); + Task DeleteObservationsBeforeAsync(Guid serviceId, DateTimeOffset cutoffUtc, CancellationToken cancellationToken); +} diff --git a/HealthMonitoring/Program.cs b/HealthMonitoring/Program.cs new file mode 100644 index 0000000..86cfeaa --- /dev/null +++ b/HealthMonitoring/Program.cs @@ -0,0 +1,45 @@ +using HealthMonitoring.Api; +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; +using HealthMonitoring.Monitoring; +using HealthMonitoring.Reporting; +using HealthMonitoring.Components; +using HealthMonitoring.Health; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddDbContext(options => + options.UseSqlServer(builder.Configuration.GetConnectionString("HealthMonitoring"))); +builder.Services.AddScoped(); +builder.Services.AddSingleton(); +builder.Services.AddHttpClient(); +builder.Services.AddSingleton(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddHostedService(); +builder.Services.AddHostedService(); +builder.Services.AddHttpClient(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddScoped(); +builder.Services.AddHealthChecks().AddCheck("sql-server"); + +var app = builder.Build(); + +using (var scope = app.Services.CreateScope()) +{ + var connectionString = app.Configuration.GetConnectionString("HealthMonitoring"); + if (!string.IsNullOrWhiteSpace(connectionString)) + { + await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); + } +} + +app.MapServiceRegistrationEndpoints(); +app.MapHealthChecks("/health"); +app.UseStaticFiles(); +app.MapRazorComponents().AddInteractiveServerRenderMode(); + +app.Run(); + +public partial class Program; diff --git a/HealthMonitoring/Reporting/DashboardModels.cs b/HealthMonitoring/Reporting/DashboardModels.cs new file mode 100644 index 0000000..f3b220b --- /dev/null +++ b/HealthMonitoring/Reporting/DashboardModels.cs @@ -0,0 +1,7 @@ +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Reporting; + +public sealed record DashboardOverview(IReadOnlyList Services, int Healthy, int Degraded, int Unhealthy, int OpenIncidents); +public sealed record ServiceDashboardRow(string ServiceId, string Name, string Environment, HealthStatus Status, TimeSpan? ResponseDuration, double UptimePercent, DateTimeOffset? LastChecked, string? LastError); +public sealed record ServiceDetailModel(MonitoredService Service, ServiceDashboardRow Summary, IReadOnlyList Observations, IReadOnlyList Incidents, IReadOnlyList Checks); diff --git a/HealthMonitoring/Reporting/DashboardQueryService.cs b/HealthMonitoring/Reporting/DashboardQueryService.cs new file mode 100644 index 0000000..e6cfdb4 --- /dev/null +++ b/HealthMonitoring/Reporting/DashboardQueryService.cs @@ -0,0 +1,50 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace HealthMonitoring.Reporting; + +public interface IDashboardQueryService +{ + Task GetOverviewAsync(TimeSpan range, CancellationToken cancellationToken); + Task GetServiceDetailAsync(string serviceId, TimeSpan range, CancellationToken cancellationToken); +} + +public sealed class DashboardQueryService(HealthMonitoringDbContext dbContext) : IDashboardQueryService +{ + public async Task GetOverviewAsync(TimeSpan range, CancellationToken cancellationToken) + { + var services = await dbContext.MonitoredServices.AsNoTracking().Where(service => service.ArchivedAtUtc == null).ToListAsync(cancellationToken); + var from = DateTimeOffset.UtcNow.Subtract(range); + var rows = new List(); + foreach (var service in services) + { + var snapshot = await dbContext.ServiceStatusSnapshots.AsNoTracking().SingleOrDefaultAsync(item => item.MonitoredServiceId == service.Id, cancellationToken); + var observations = await dbContext.HealthObservations.AsNoTracking().Where(item => item.MonitoredServiceId == service.Id && item.ObservedAtUtc >= from).ToListAsync(cancellationToken); + rows.Add(ToRow(service, snapshot, observations)); + } + + var incidents = await dbContext.ServiceIncidents.AsNoTracking().CountAsync(incident => incident.EndedAtUtc == null, cancellationToken); + return new DashboardOverview(rows.OrderBy(row => row.Name).ToArray(), rows.Count(row => row.Status == HealthStatus.Healthy), rows.Count(row => row.Status == HealthStatus.Degraded), rows.Count(row => row.Status == HealthStatus.Unhealthy), incidents); + } + + public async Task GetServiceDetailAsync(string serviceId, TimeSpan range, CancellationToken cancellationToken) + { + var service = await dbContext.MonitoredServices.AsNoTracking().SingleOrDefaultAsync(item => item.ServiceId == serviceId && item.ArchivedAtUtc == null, cancellationToken); + if (service is null) return null; + var from = DateTimeOffset.UtcNow.Subtract(range); + var observations = await dbContext.HealthObservations.AsNoTracking().Include(item => item.Checks).Where(item => item.MonitoredServiceId == service.Id && item.ObservedAtUtc >= from).OrderByDescending(item => item.ObservedAtUtc).ToListAsync(cancellationToken); + var incidents = await dbContext.ServiceIncidents.AsNoTracking().Where(item => item.MonitoredServiceId == service.Id && (item.EndedAtUtc == null || item.EndedAtUtc >= from)).OrderByDescending(item => item.StartedAtUtc).ToListAsync(cancellationToken); + var snapshot = await dbContext.ServiceStatusSnapshots.AsNoTracking().SingleOrDefaultAsync(item => item.MonitoredServiceId == service.Id, cancellationToken); + var checks = observations.SelectMany(item => item.Checks).ToArray(); + return new ServiceDetailModel(service, ToRow(service, snapshot, observations), observations, incidents, checks); + } + + private static ServiceDashboardRow ToRow(MonitoredService service, ServiceStatusSnapshot? snapshot, IReadOnlyList observations) + { + var total = observations.Count; + var healthy = observations.Count(observation => observation.Status == HealthStatus.Healthy); + var uptime = total == 0 ? 0 : healthy * 100d / total; + return new ServiceDashboardRow(service.ServiceId, service.Name, service.Environment, snapshot?.Status ?? HealthStatus.Unknown, snapshot?.LastResponseDuration, uptime, snapshot?.LastObservedAtUtc, snapshot?.LastError); + } +} diff --git a/HealthMonitoring/appsettings.json b/HealthMonitoring/appsettings.json new file mode 100644 index 0000000..81e869f --- /dev/null +++ b/HealthMonitoring/appsettings.json @@ -0,0 +1,11 @@ +{ + "ConnectionStrings": { + "HealthMonitoring": "" + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/HealthMonitoring/docker-compose.sqlserver.yml b/HealthMonitoring/docker-compose.sqlserver.yml new file mode 100644 index 0000000..9aa0ee1 --- /dev/null +++ b/HealthMonitoring/docker-compose.sqlserver.yml @@ -0,0 +1,17 @@ +services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "ChangeThisStrongPassword!123" + ports: + - "1433:1433" + health-monitoring: + build: . + depends_on: + - sqlserver + environment: + ASPNETCORE_URLS: http://+:8080 + ConnectionStrings__HealthMonitoring: Server=sqlserver;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True + ports: + - "8080:8080" diff --git a/HealthMonitoring/wwwroot/app.css b/HealthMonitoring/wwwroot/app.css new file mode 100644 index 0000000..99f33b2 --- /dev/null +++ b/HealthMonitoring/wwwroot/app.css @@ -0,0 +1,20 @@ +:root { font-family: Inter, ui-sans-serif, system-ui, sans-serif; color: #17212b; background: #f3f6f7; } +* { box-sizing: border-box; } +body { margin: 0; } +a { color: inherit; text-decoration: none; } +.shell { display: flex; min-height: 100vh; } +.sidebar { width: 230px; background: #17212b; color: #d7e1e5; padding: 28px 18px; display: flex; flex-direction: column; } +.brand { color: #fff; font-size: 20px; font-weight: 800; margin: 0 12px 34px; } +nav { display: grid; gap: 6px; } nav a { padding: 11px 12px; border-radius: 8px; } nav a.active, nav a:hover { background: #263946; color: #fff; } +.sidebar-note { margin-top: auto; font-size: 12px; color: #91a3ad; line-height: 1.5; padding: 12px; } +.main-content { max-width: 1280px; width: 100%; padding: 42px 48px; margin: 0 auto; } +.page-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-bottom: 28px; } h1 { margin: 4px 0; font-size: 32px; } h2 { font-size: 18px; margin: 0 0 18px; } .eyebrow { color: #6d7e88; font-size: 11px; letter-spacing: .12em; font-weight: 800; margin: 0; } .muted { color: #71818a; } +.button { display: inline-block; border: 0; border-radius: 8px; background: #2563d8; color: #fff; padding: 11px 16px; font-weight: 700; cursor: pointer; } .button.secondary { background: #e1e8eb; color: #263641; } +.stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 14px; margin-bottom: 20px; } .stat-grid.three { grid-template-columns: repeat(3, 1fr); } +.stat-card { background: #fff; border-radius: 11px; padding: 18px; border-left: 4px solid #a7b2b8; box-shadow: 0 2px 10px #23323c0b; } .stat-card span { display: block; color: #71818a; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; } .stat-card strong { display: block; font-size: 27px; margin-top: 8px; } .stat-card.healthy { border-color: #39c77c; } .stat-card.degraded { border-color: #e2aa3c; } .stat-card.unhealthy { border-color: #e14d5c; } +.panel { background: #fff; border-radius: 12px; padding: 22px; box-shadow: 0 2px 10px #23323c0b; margin-bottom: 20px; } .table-heading, .service-row { display: grid; grid-template-columns: 2fr 1fr 1fr 1.2fr 1.4fr; gap: 14px; align-items: center; } .table-heading { padding: 0 12px 12px; color: #71818a; font-size: 11px; font-weight: 800; text-transform: uppercase; } .service-row { padding: 15px 12px; border-top: 1px solid #edf0f1; font-size: 13px; } .service-row:hover { background: #f8fafb; } .service-row small, .service-row strong { display: block; } .service-row small { color: #71818a; margin-top: 3px; } +.status { font-weight: 800; font-size: 13px; } .status.healthy { color: #1fa667; } .status.degraded { color: #b77b12; } .status.unhealthy { color: #d23b4b; } .status.unknown { color: #71818a; } .empty { color: #71818a; text-align: center; padding: 20px; } +.detail-status { display: flex; gap: 16px; align-items: center; margin: -12px 0 20px; color: #71818a; } .timeline { display: flex; gap: 2px; height: 28px; } .timeline-segment { flex: 1; border-radius: 4px; min-width: 3px; } .timeline-segment.healthy { background: #39c77c; } .timeline-segment.degraded { background: #e2aa3c; } .timeline-segment.unhealthy { background: #e14d5c; } .timeline-segment.unknown { background: #a7b2b8; } +.check-table .service-row { grid-template-columns: 1.5fr .8fr 1fr 2fr; } details summary { cursor: pointer; font-weight: 800; } pre { white-space: pre-wrap; overflow: auto; background: #17212b; color: #d7e1e5; padding: 16px; border-radius: 8px; } +.form { max-width: 760px; display: grid; gap: 16px; } label { display: grid; gap: 7px; font-size: 13px; font-weight: 700; } input { border: 1px solid #ccd7dc; border-radius: 7px; padding: 11px; font: inherit; } .form-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .error { color: #c52f40; } +@media (max-width: 900px) { .sidebar { width: 180px; } .main-content { padding: 28px 20px; } .stat-grid { grid-template-columns: repeat(2, 1fr); } .table-heading { display: none; } .service-row { grid-template-columns: 2fr 1fr 1fr; } .service-row > :nth-child(4), .service-row > :nth-child(5) { display: none; } } diff --git a/README.md b/README.md index 0e5330f..0039146 100644 --- a/README.md +++ b/README.md @@ -1 +1,32 @@ # SupportTools +## Health Monitoring + +The standalone `HealthMonitoring` application is an ASP.NET Core/Blazor Server monitor for ASP.NET health endpoints. It uses SQL Server for service configuration, health observations, incidents, and history. + +Run locally with: + +```powershell +dotnet run --project HealthMonitoring/HealthMonitoring.csproj +``` + +Set `ConnectionStrings:HealthMonitoring` (or `HealthMonitoring__ConnectionStrings__HealthMonitoring`) to a SQL Server connection string before enabling persistence and migrations. + +The application creates its schema on startup for the first version. A local SQL Server plus the monitor can be started with: + +```powershell +docker compose -f HealthMonitoring/docker-compose.sqlserver.yml up --build +``` + +Register a service with: + +```powershell +Invoke-RestMethod -Method Post -Uri http://localhost:8080/api/services/register -ContentType 'application/json' -Body (@{ + serviceId = 'merchant-pos-production' + name = 'Merchant POS' + environment = 'Production' + healthUrl = 'https://merchant-pos/health' + pollingIntervalSeconds = 60 + requestTimeoutSeconds = 10 + retentionDays = 365 +} | ConvertTo-Json) +``` From 7875ba023ed8deaca4adc684ce4bf8d63f839cb7 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:05:14 +0100 Subject: [PATCH 04/17] fix: register monitoring state repository --- ...ealthMonitoringServiceRegistrationTests.cs | 18 +++++++++++++++ ...thMonitoringServiceCollectionExtensions.cs | 23 +++++++++++++++++++ HealthMonitoring/Program.cs | 9 +------- 3 files changed, 42 insertions(+), 8 deletions(-) create mode 100644 HealthMonitoring.Tests/HealthMonitoringServiceRegistrationTests.cs create mode 100644 HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs diff --git a/HealthMonitoring.Tests/HealthMonitoringServiceRegistrationTests.cs b/HealthMonitoring.Tests/HealthMonitoringServiceRegistrationTests.cs new file mode 100644 index 0000000..601cab2 --- /dev/null +++ b/HealthMonitoring.Tests/HealthMonitoringServiceRegistrationTests.cs @@ -0,0 +1,18 @@ +using HealthMonitoring.Monitoring; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthMonitoring.Tests; + +public sealed class HealthMonitoringServiceRegistrationTests +{ + [Fact] + public void Monitoring_registration_exposes_the_state_repository_required_by_incident_calculator() + { + var services = new ServiceCollection(); + + services.AddHealthMonitoringServices(); + + Assert.Contains(services, descriptor => descriptor.ServiceType == typeof(IMonitoringStateRepository)); + Assert.Contains(services, descriptor => descriptor.ServiceType == typeof(IncidentCalculator)); + } +} diff --git a/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs b/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs new file mode 100644 index 0000000..4f95c6c --- /dev/null +++ b/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs @@ -0,0 +1,23 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthMonitoring.Monitoring; + +public static class HealthMonitoringServiceCollectionExtensions +{ + public static IServiceCollection AddHealthMonitoringServices(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddSingleton(); + services.AddHttpClient(); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); + services.AddHostedService(); + services.AddHostedService(); + return services; + } +} diff --git a/HealthMonitoring/Program.cs b/HealthMonitoring/Program.cs index 86cfeaa..abb892b 100644 --- a/HealthMonitoring/Program.cs +++ b/HealthMonitoring/Program.cs @@ -11,14 +11,7 @@ builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("HealthMonitoring"))); -builder.Services.AddScoped(); -builder.Services.AddSingleton(); -builder.Services.AddHttpClient(); -builder.Services.AddSingleton(); -builder.Services.AddScoped(); -builder.Services.AddScoped(); -builder.Services.AddHostedService(); -builder.Services.AddHostedService(); +builder.Services.AddHealthMonitoringServices(); builder.Services.AddHttpClient(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddScoped(); From 628ee724da41fa60dce2663fa0825910bc71eb56 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:13:52 +0100 Subject: [PATCH 05/17] feat: add health monitoring hosting configuration --- HealthMonitoring/HealthMonitoring.csproj | 3 +++ HealthMonitoring/Program.cs | 2 ++ HealthMonitoring/hosting.json | 4 ++++ 3 files changed, 9 insertions(+) create mode 100644 HealthMonitoring/hosting.json diff --git a/HealthMonitoring/HealthMonitoring.csproj b/HealthMonitoring/HealthMonitoring.csproj index 7bdd8a6..509c68b 100644 --- a/HealthMonitoring/HealthMonitoring.csproj +++ b/HealthMonitoring/HealthMonitoring.csproj @@ -6,6 +6,9 @@ dotnet-HealthMonitoring + + PreserveNewest + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/HealthMonitoring/Program.cs b/HealthMonitoring/Program.cs index abb892b..341d8ce 100644 --- a/HealthMonitoring/Program.cs +++ b/HealthMonitoring/Program.cs @@ -9,6 +9,8 @@ var builder = WebApplication.CreateBuilder(args); +builder.Configuration.AddJsonFile("hosting.json", optional: true, reloadOnChange: true); + builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("HealthMonitoring"))); builder.Services.AddHealthMonitoringServices(); diff --git a/HealthMonitoring/hosting.json b/HealthMonitoring/hosting.json new file mode 100644 index 0000000..0e6bf02 --- /dev/null +++ b/HealthMonitoring/hosting.json @@ -0,0 +1,4 @@ +{ + "Urls": "http://*:9620", + "AllowedHosts": "*" +} From 3fb7906be1011c6ed1567ded7f827fa7380a8930 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:17:01 +0100 Subject: [PATCH 06/17] config: add development sql connection --- HealthMonitoring/appsettings.Development.json | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 HealthMonitoring/appsettings.Development.json diff --git a/HealthMonitoring/appsettings.Development.json b/HealthMonitoring/appsettings.Development.json new file mode 100644 index 0000000..4ec7024 --- /dev/null +++ b/HealthMonitoring/appsettings.Development.json @@ -0,0 +1,5 @@ +{ + "ConnectionStrings": { + "HealthMonitoring": "Server=localhost;Database=HealthMonitoring;Trusted_Connection=True;TrustServerCertificate=True;" + } +} From c34e542eefce84bca2e4ecaf22d629968b83aa8e Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:21:22 +0100 Subject: [PATCH 07/17] fix: configure default health monitoring sql connection --- HealthMonitoring.Tests/ConfigurationTests.cs | 18 ++++++++++++++++++ HealthMonitoring/appsettings.json | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) create mode 100644 HealthMonitoring.Tests/ConfigurationTests.cs diff --git a/HealthMonitoring.Tests/ConfigurationTests.cs b/HealthMonitoring.Tests/ConfigurationTests.cs new file mode 100644 index 0000000..96fdf36 --- /dev/null +++ b/HealthMonitoring.Tests/ConfigurationTests.cs @@ -0,0 +1,18 @@ +using Microsoft.Extensions.Configuration; + +namespace HealthMonitoring.Tests; + +public sealed class ConfigurationTests +{ + [Fact] + public void Base_configuration_contains_the_development_sql_server_connection() + { + var repositoryRoot = Directory.GetParent(AppContext.BaseDirectory)!.Parent!.Parent!.Parent!.Parent!.FullName; + var configuration = new ConfigurationBuilder() + .SetBasePath(Path.Combine(repositoryRoot, "HealthMonitoring")) + .AddJsonFile("appsettings.json") + .Build(); + + Assert.Equal("Server=localhost;Database=HealthMonitoring;Trusted_Connection=True;TrustServerCertificate=True;", configuration.GetConnectionString("HealthMonitoring")); + } +} diff --git a/HealthMonitoring/appsettings.json b/HealthMonitoring/appsettings.json index 81e869f..6d8e60d 100644 --- a/HealthMonitoring/appsettings.json +++ b/HealthMonitoring/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "HealthMonitoring": "" + "HealthMonitoring": "Server=localhost;Database=HealthMonitoring;Trusted_Connection=True;TrustServerCertificate=True;" }, "Logging": { "LogLevel": { From 556ca81acb1b6445baa497b30461e2142a09a4f5 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:40:41 +0100 Subject: [PATCH 08/17] config: use sql authentication for health monitoring --- HealthMonitoring.Tests/ConfigurationTests.cs | 2 +- HealthMonitoring/appsettings.Development.json | 2 +- HealthMonitoring/appsettings.json | 2 +- README.md | 2 ++ 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/HealthMonitoring.Tests/ConfigurationTests.cs b/HealthMonitoring.Tests/ConfigurationTests.cs index 96fdf36..91ca454 100644 --- a/HealthMonitoring.Tests/ConfigurationTests.cs +++ b/HealthMonitoring.Tests/ConfigurationTests.cs @@ -13,6 +13,6 @@ public void Base_configuration_contains_the_development_sql_server_connection() .AddJsonFile("appsettings.json") .Build(); - Assert.Equal("Server=localhost;Database=HealthMonitoring;Trusted_Connection=True;TrustServerCertificate=True;", configuration.GetConnectionString("HealthMonitoring")); + Assert.Equal("Server=.;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True;Encrypt=False;", configuration.GetConnectionString("HealthMonitoring")); } } diff --git a/HealthMonitoring/appsettings.Development.json b/HealthMonitoring/appsettings.Development.json index 4ec7024..bde607e 100644 --- a/HealthMonitoring/appsettings.Development.json +++ b/HealthMonitoring/appsettings.Development.json @@ -1,5 +1,5 @@ { "ConnectionStrings": { - "HealthMonitoring": "Server=localhost;Database=HealthMonitoring;Trusted_Connection=True;TrustServerCertificate=True;" + "HealthMonitoring": "Server=.;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True;Encrypt=False;" } } diff --git a/HealthMonitoring/appsettings.json b/HealthMonitoring/appsettings.json index 6d8e60d..3b7aa8c 100644 --- a/HealthMonitoring/appsettings.json +++ b/HealthMonitoring/appsettings.json @@ -1,6 +1,6 @@ { "ConnectionStrings": { - "HealthMonitoring": "Server=localhost;Database=HealthMonitoring;Trusted_Connection=True;TrustServerCertificate=True;" + "HealthMonitoring": "Server=.;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True;Encrypt=False;" }, "Logging": { "LogLevel": { diff --git a/README.md b/README.md index 0039146..dfd8ff4 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,8 @@ dotnet run --project HealthMonitoring/HealthMonitoring.csproj Set `ConnectionStrings:HealthMonitoring` (or `HealthMonitoring__ConnectionStrings__HealthMonitoring`) to a SQL Server connection string before enabling persistence and migrations. +The checked-in local sample uses SQL authentication with user `sa` and the dummy password `ChangeThisStrongPassword!123`. Replace it with the actual local `sa` password, preferably through an environment variable or user secrets. + The application creates its schema on startup for the first version. A local SQL Server plus the monitor can be started with: ```powershell From 2824e457a18dde6a466eba9cb4eac52ef235cf87 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:45:54 +0100 Subject: [PATCH 09/17] config: add development launch profile --- HealthMonitoring/Properties/launchSettings.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 HealthMonitoring/Properties/launchSettings.json diff --git a/HealthMonitoring/Properties/launchSettings.json b/HealthMonitoring/Properties/launchSettings.json new file mode 100644 index 0000000..a9a8166 --- /dev/null +++ b/HealthMonitoring/Properties/launchSettings.json @@ -0,0 +1,12 @@ +{ + "profiles": { + "HealthMonitoring": { + "commandName": "Project", + "launchBrowser": true, + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "applicationUrl": "http://localhost:9620" + } + } +} From 50270ba6b4b388dc183e74c4d9fd6e034ecb16d5 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:47:58 +0100 Subject: [PATCH 10/17] fix: enable antiforgery middleware --- HealthMonitoring/Program.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/HealthMonitoring/Program.cs b/HealthMonitoring/Program.cs index 341d8ce..9b24e64 100644 --- a/HealthMonitoring/Program.cs +++ b/HealthMonitoring/Program.cs @@ -33,6 +33,7 @@ app.MapServiceRegistrationEndpoints(); app.MapHealthChecks("/health"); app.UseStaticFiles(); +app.UseAntiforgery(); app.MapRazorComponents().AddInteractiveServerRenderMode(); app.Run(); From 80657bba7280a8692c0c9c074d057e89866396ab Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 10:52:58 +0100 Subject: [PATCH 11/17] fix: use absolute registration api url --- HealthMonitoring/Components/Pages/ServiceRegister.razor | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/HealthMonitoring/Components/Pages/ServiceRegister.razor b/HealthMonitoring/Components/Pages/ServiceRegister.razor index c946054..5a0a69c 100644 --- a/HealthMonitoring/Components/Pages/ServiceRegister.razor +++ b/HealthMonitoring/Components/Pages/ServiceRegister.razor @@ -21,7 +21,7 @@ private async Task RegisterAsync() { _saving = true; - try { var response = await Http.PostAsJsonAsync("/api/services/register", _request); if (response.IsSuccessStatusCode) Navigation.NavigateTo("/"); else _error = await response.Content.ReadAsStringAsync(); } + try { var response = await Http.PostAsJsonAsync(new Uri(new Uri(Navigation.BaseUri), "api/services/register"), _request); if (response.IsSuccessStatusCode) Navigation.NavigateTo("/"); else _error = await response.Content.ReadAsStringAsync(); } catch (Exception exception) { _error = exception.Message; } finally { _saving = false; } } From e2c9a7b454e94407785764920d3dd03b6e8d0d84 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 18:49:44 +0100 Subject: [PATCH 12/17] finish the first cut --- .github/workflows/createrelease.yml | 6 + .github/workflows/pushtomain.yml | 22 ++ .../codex-20260918/content/overview.html | 41 ++++ .../codex-20260918/state/server-stopped | 1 + .../HealthMonitoring.Client.csproj | 16 ++ .../HealthMonitoringRegistrationClient.cs | 169 +++++++++++++++ HealthMonitoring.Client/README.md | 21 ++ .../Api/ServiceConfigurationValidatorTests.cs | 85 +++++++- ...HealthMonitoringRegistrationClientTests.cs | 133 ++++++++++++ .../Components/DashboardApiUriTests.cs | 14 ++ .../HealthMonitoring.Tests.csproj | 1 + .../Monitoring/HealthEndpointClientTests.cs | 10 +- .../Monitoring/InitializationStateTests.cs | 42 ++++ .../Monitoring/KurrentDbMonitorClientTests.cs | 40 ++++ .../Monitoring/SqlServerMonitorClientTests.cs | 39 ++++ .../Persistence/DependencyMappingTests.cs | 24 +++ .../Persistence/TimeSpanMappingTests.cs | 28 +++ .../Api/DependencyMappingContracts.cs | 10 + .../Api/DependencyMappingEndpoints.cs | 90 ++++++++ .../Api/InitializationEndpoints.cs | 27 +++ .../Api/ServiceConfigurationValidator.cs | 14 +- .../Api/ServiceRegistrationContracts.cs | 8 +- .../Api/ServiceRegistrationEndpoints.cs | 8 +- .../Components/DashboardApiUri.cs | 6 + .../Components/InitializationGate.razor | 28 +++ .../Components/Layout/MainLayout.razor | 17 +- .../Components/Pages/DependencyLinks.razor | 147 +++++++++++++ .../Pages/InitialisationOverview.razor | 56 +++++ .../Components/Pages/Initialise.razor | 70 +++++++ .../Components/Pages/Overview.razor | 4 +- .../Components/Pages/ServiceDetail.razor | 8 +- .../Components/Pages/ServiceRegister.razor | 16 +- HealthMonitoring/Components/Routes.razor | 8 +- HealthMonitoring/Components/_Imports.razor | 1 + HealthMonitoring/Domain/MonitorType.cs | 8 + HealthMonitoring/Domain/MonitoredService.cs | 6 + .../Domain/ServiceDependencyLink.cs | 11 + HealthMonitoring/HealthMonitoring.csproj | 5 + HealthMonitoring/HealthMonitoring.sln | 39 +++- .../Monitoring/HealthEndpointClient.cs | 9 +- ...thMonitoringServiceCollectionExtensions.cs | 8 +- .../Monitoring/HealthPollingWorker.cs | 9 +- .../Monitoring/InitializationService.cs | 107 ++++++++++ .../Monitoring/KurrentDbMonitorClient.cs | 73 +++++++ .../Monitoring/SqlServerMonitorClient.cs | 64 ++++++ .../Persistence/HealthMonitoringDbContext.cs | 33 +++ .../Persistence/HealthMonitoringRepository.cs | 39 ++++ .../HealthMonitoringSchemaInitializer.cs | 113 ++++++++++ .../IHealthMonitoringRepository.cs | 3 + HealthMonitoring/Program.cs | 13 +- HealthMonitoring/Reporting/DashboardModels.cs | 3 +- .../Reporting/DashboardQueryService.cs | 6 +- HealthMonitoring/appsettings.Development.json | 2 +- HealthMonitoring/appsettings.json | 6 +- HealthMonitoring/wwwroot/app.css | 7 +- ...09-18-dependency-mapping-implementation.md | 89 ++++++++ ...perational-support-shell-implementation.md | 196 ++++++++++++++++++ .../2026-09-18-dependency-mapping-design.md | 71 +++++++ ...-09-18-operational-support-shell-design.md | 82 ++++++++ 59 files changed, 2168 insertions(+), 44 deletions(-) create mode 100644 .superpowers/brainstorm/codex-20260918/content/overview.html create mode 100644 .superpowers/brainstorm/codex-20260918/state/server-stopped create mode 100644 HealthMonitoring.Client/HealthMonitoring.Client.csproj create mode 100644 HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs create mode 100644 HealthMonitoring.Client/README.md create mode 100644 HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs create mode 100644 HealthMonitoring.Tests/Components/DashboardApiUriTests.cs create mode 100644 HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs create mode 100644 HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs create mode 100644 HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs create mode 100644 HealthMonitoring.Tests/Persistence/DependencyMappingTests.cs create mode 100644 HealthMonitoring.Tests/Persistence/TimeSpanMappingTests.cs create mode 100644 HealthMonitoring/Api/DependencyMappingContracts.cs create mode 100644 HealthMonitoring/Api/DependencyMappingEndpoints.cs create mode 100644 HealthMonitoring/Api/InitializationEndpoints.cs create mode 100644 HealthMonitoring/Components/DashboardApiUri.cs create mode 100644 HealthMonitoring/Components/InitializationGate.razor create mode 100644 HealthMonitoring/Components/Pages/DependencyLinks.razor create mode 100644 HealthMonitoring/Components/Pages/InitialisationOverview.razor create mode 100644 HealthMonitoring/Components/Pages/Initialise.razor create mode 100644 HealthMonitoring/Domain/MonitorType.cs create mode 100644 HealthMonitoring/Domain/ServiceDependencyLink.cs create mode 100644 HealthMonitoring/Monitoring/InitializationService.cs create mode 100644 HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs create mode 100644 HealthMonitoring/Monitoring/SqlServerMonitorClient.cs create mode 100644 HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs create mode 100644 docs/superpowers/plans/2026-09-18-dependency-mapping-implementation.md create mode 100644 docs/superpowers/plans/2026-09-18-operational-support-shell-implementation.md create mode 100644 docs/superpowers/specs/2026-09-18-dependency-mapping-design.md create mode 100644 docs/superpowers/specs/2026-09-18-operational-support-shell-design.md diff --git a/.github/workflows/createrelease.yml b/.github/workflows/createrelease.yml index 791cf69..947fc9e 100644 --- a/.github/workflows/createrelease.yml +++ b/.github/workflows/createrelease.yml @@ -69,6 +69,12 @@ jobs: name: scheduledtasks path: ScheduledTasks + - name: Build and Publish Nuget Packages + if: ${{ github.event.release.prerelease == false }} + run: | + dotnet pack "HealthMonitoring.Client\HealthMonitoring.Client.csproj" /p:PackageVersion=${{ steps.get_version.outputs.VERSION }} --output Nugets + dotnet nuget push Nugets/HealthMonitoring.Client.${{ steps.get_version.outputs.VERSION }}.nupkg --api-key ${{ secrets.PRIVATEFEED_APIKEY }} --source ${{ secrets.PRIVATEFEED_URL }} --skip-duplicate + buildsqlagentjobdeploymenttool: name: "Build SQL Agent Job Deployment Tool" runs-on: windows-latest diff --git a/.github/workflows/pushtomain.yml b/.github/workflows/pushtomain.yml index da9b082..1193675 100644 --- a/.github/workflows/pushtomain.yml +++ b/.github/workflows/pushtomain.yml @@ -84,6 +84,28 @@ jobs: name: scheduledtasks path: ScheduledTasks + - name: 'Get Previous tag' + id: get-latest-tag + uses: actions-ecosystem/action-get-latest-tag@v1 + with: + semver_only: true + + - name: 'Bump Version' + id: bump-semver + uses: actions-ecosystem/action-bump-semver@v1 + with: + current_version: ${{ steps.get-latest-tag.outputs.tag }} + level: patch + + - name: Print Version + id: next_version + run: echo ::set-output name=VERSION::${{ steps.bump-semver.outputs.new_version }}.$GITHUB_RUN_NUMBER + + - name: Build and Publish Nuget Packages + run: | + dotnet pack "HealthMonitoring.Client\HealthMonitoring.Client.csproj" /p:PackageVersion=${{ steps.next_version.outputs.VERSION }} --output Nugets + dotnet nuget push Nugets/HealthMonitoring.Client.${{ steps.next_version.outputs.VERSION }}.nupkg --api-key ${{ secrets.PRIVATEFEED_APIKEY }} --source ${{ secrets.PRIVATEFEED_URL }} + buildsqlagentjobdeploymenttool: name: "Build SQL Agent Job Deployment Tool" runs-on: windows-latest diff --git a/.superpowers/brainstorm/codex-20260918/content/overview.html b/.superpowers/brainstorm/codex-20260918/content/overview.html new file mode 100644 index 0000000..6fbdf0f --- /dev/null +++ b/.superpowers/brainstorm/codex-20260918/content/overview.html @@ -0,0 +1,41 @@ +

Monitoring dashboard direction

+

Overview first, with a focused service detail view available from each card.

+ +
+
HealthWatch · Overview
+
+
+
Services
Last refresh: just now · 12 monitored
+
+
+
+
HEALTHY
9
+
DEGRADED
2
+
UNHEALTHY
1
+
OPEN INCIDENTS
1
+
+
+
+
SERVICE
STATUS
RESPONSE
UPTIME (30D)
LAST CHECK
+
+
Merchant POS
Production · /health
● Healthy
8 ms
99.99%
18 Sep, 08:14
+
Merchant File Processor
Production · /health
● Degraded
31 ms
99.82%
18 Sep, 08:14
+
Transaction API
Staging · /health
● Unhealthy
—
98.41%
Incident open · 6m
+
+
+
+ +
+

Service detail view

+
+
Merchant File Processor · Production
+
+
● Degraded
Last checked 18 Sep, 08:14:22 · 31 ms
+
UPTIME (30 DAYS)
99.82%
CURRENT INCIDENT
12m 18s
AVG RESPONSE (24H)
24.6 ms
+
STATUS TIMELINE · LAST 24 HOURS
08:00Now
+
DEPENDENCY DETAILS
CHECK
STATUS
DURATION
DETAIL
PrimaryDatabase
Healthy
4 ms
Connection successful
MessageBus
Degraded
26 ms
Consumer lag above threshold
+
+
+
+ +

The key question is whether this overview/detail split gives you the right operational emphasis: current state first, then dependency evidence and history.

diff --git a/.superpowers/brainstorm/codex-20260918/state/server-stopped b/.superpowers/brainstorm/codex-20260918/state/server-stopped new file mode 100644 index 0000000..344d421 --- /dev/null +++ b/.superpowers/brainstorm/codex-20260918/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1789733678007} diff --git a/HealthMonitoring.Client/HealthMonitoring.Client.csproj b/HealthMonitoring.Client/HealthMonitoring.Client.csproj new file mode 100644 index 0000000..bbed55a --- /dev/null +++ b/HealthMonitoring.Client/HealthMonitoring.Client.csproj @@ -0,0 +1,16 @@ + + + net10.0 + enable + enable + HealthMonitoring.Client + Reusable client for registering services and dependency mappings with HealthMonitoring. + README.md + + + + + + + + diff --git a/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs b/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs new file mode 100644 index 0000000..94c796f --- /dev/null +++ b/HealthMonitoring.Client/HealthMonitoringRegistrationClient.cs @@ -0,0 +1,169 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; + +namespace HealthMonitoring.Client; + +public sealed class HealthMonitoringRegistrationClientOptions +{ + public Uri MonitoringServerUrl { get; set; } = new("http://localhost:9620"); +} + +public sealed class ServiceRegistrationOptions +{ + public string ServiceId { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public Uri HealthUrl { get; init; } = new("http://localhost/health"); + public string MonitorType { get; init; } = "HttpHealthEndpoint"; + public string? ConnectionString { get; init; } + public bool IgnoreCertificateErrors { get; init; } + public TimeSpan PollingInterval { get; init; } = TimeSpan.FromSeconds(60); + public TimeSpan RequestTimeout { get; init; } = TimeSpan.FromSeconds(10); + public TimeSpan RetentionPeriod { get; init; } = TimeSpan.FromDays(365); + public string? Group { get; init; } + public string? Description { get; init; } + public string? Version { get; init; } + public string? Host { get; init; } + public object? StatusPolicy { get; init; } +} + +public sealed record DependencyMappingOptions(string DependencyName, string TargetServiceId); + +public sealed record ServiceRegistrationResult(string ServiceId, string Action, Guid MonitoredServiceId); + +public interface IHealthMonitoringRegistrationClient +{ + Task RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default); + Task SetDependencyMappingsAsync(string serviceId, IEnumerable mappings, CancellationToken cancellationToken = default); + Task RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable mappings, CancellationToken cancellationToken = default); +} + +public sealed class HealthMonitoringRegistrationClient(HttpClient httpClient) : IHealthMonitoringRegistrationClient +{ + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web); + + public async Task RegisterAsync(ServiceRegistrationOptions options, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(options); + ValidateServiceOptions(options); + + using var response = await httpClient.PostAsJsonAsync("api/services/register", new ServiceRegistrationPayload( + options.ServiceId, + options.Name, + options.HealthUrl.ToString(), + ParseMonitorType(options.MonitorType), + options.ConnectionString, + options.IgnoreCertificateErrors, + ToSeconds(options.PollingInterval, nameof(options.PollingInterval)), + ToSeconds(options.RequestTimeout, nameof(options.RequestTimeout)), + ToDays(options.RetentionPeriod, nameof(options.RetentionPeriod)), + options.StatusPolicy, + options.Group, + options.Description, + options.Version, + options.Host), JsonOptions, cancellationToken); + + await EnsureSuccessAsync(response, "register service", cancellationToken); + var result = await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new InvalidOperationException("The monitoring server returned an empty registration response."); + return new ServiceRegistrationResult(result.ServiceId, result.Action, result.Service.Id); + } + + public async Task SetDependencyMappingsAsync(string serviceId, IEnumerable mappings, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(serviceId)) throw new ArgumentException("A service ID is required.", nameof(serviceId)); + ArgumentNullException.ThrowIfNull(mappings); + + var existing = await GetDependencyLinksAsync(serviceId, cancellationToken); + foreach (var mapping in mappings) + { + if (string.IsNullOrWhiteSpace(mapping.DependencyName)) throw new ArgumentException("A dependency name is required.", nameof(mappings)); + if (string.IsNullOrWhiteSpace(mapping.TargetServiceId)) throw new ArgumentException("A target service ID is required.", nameof(mappings)); + + var target = await GetServiceAsync(mapping.TargetServiceId, cancellationToken); + var payload = new DependencyMappingPayload(mapping.DependencyName, target.Id); + var current = existing.FirstOrDefault(link => string.Equals(link.DependencyName.Trim(), mapping.DependencyName.Trim(), StringComparison.OrdinalIgnoreCase)); + using var response = current is null + ? await httpClient.PostAsJsonAsync($"api/services/{Encode(serviceId)}/dependency-links", payload, JsonOptions, cancellationToken) + : await httpClient.PutAsJsonAsync($"api/services/{Encode(serviceId)}/dependency-links/{current.Id}", payload, JsonOptions, cancellationToken); + await EnsureSuccessAsync(response, "save dependency mapping", cancellationToken); + } + } + + public async Task RegisterAndConfigureAsync(ServiceRegistrationOptions options, IEnumerable mappings, CancellationToken cancellationToken = default) + { + var result = await RegisterAsync(options, cancellationToken); + await SetDependencyMappingsAsync(options.ServiceId, mappings, cancellationToken); + return result; + } + + private async Task> GetDependencyLinksAsync(string serviceId, CancellationToken cancellationToken) + { + using var response = await httpClient.GetAsync($"api/services/{Encode(serviceId)}/dependency-links", cancellationToken); + await EnsureSuccessAsync(response, "read dependency mappings", cancellationToken); + return await response.Content.ReadFromJsonAsync>(JsonOptions, cancellationToken) ?? []; + } + + private async Task GetServiceAsync(string serviceId, CancellationToken cancellationToken) + { + using var response = await httpClient.GetAsync($"api/services/{Encode(serviceId)}", cancellationToken); + await EnsureSuccessAsync(response, $"read target service '{serviceId}'", cancellationToken); + return await response.Content.ReadFromJsonAsync(JsonOptions, cancellationToken) + ?? throw new InvalidOperationException("The monitoring server returned an empty service response."); + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, string operation, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) return; + var body = await response.Content.ReadAsStringAsync(cancellationToken); + throw new HttpRequestException($"Could not {operation}. Server returned {(int)response.StatusCode}: {body}", null, response.StatusCode); + } + + private static string Encode(string value) => Uri.EscapeDataString(value); + + private static int ParseMonitorType(string monitorType) => + string.Equals(monitorType, "KurrentDb", StringComparison.OrdinalIgnoreCase) ? 1 : + string.Equals(monitorType, "SqlServer", StringComparison.OrdinalIgnoreCase) ? 2 : 0; + + private static int ToSeconds(TimeSpan value, string name) + { + if (value <= TimeSpan.Zero || value.TotalSeconds > int.MaxValue) throw new ArgumentOutOfRangeException(name); + return (int)value.TotalSeconds; + } + + private static int ToDays(TimeSpan value, string name) + { + if (value <= TimeSpan.Zero || value.TotalDays > int.MaxValue) throw new ArgumentOutOfRangeException(name); + return (int)value.TotalDays; + } + + private static void ValidateServiceOptions(ServiceRegistrationOptions options) + { + if (string.IsNullOrWhiteSpace(options.ServiceId)) throw new ArgumentException("A service ID is required.", nameof(options)); + if (string.IsNullOrWhiteSpace(options.Name)) throw new ArgumentException("A service name is required.", nameof(options)); + if (!options.HealthUrl.IsAbsoluteUri) throw new ArgumentException("The health URL must be absolute.", nameof(options)); + } + + private sealed record ServiceRegistrationPayload(string ServiceId, string Name, string HealthUrl, int MonitorType, string? ConnectionString, bool IgnoreCertificateErrors, int PollingIntervalSeconds, int RequestTimeoutSeconds, int RetentionDays, object? StatusPolicy, string? Group, string? Description, string? Version, string? Host); + private sealed record ServiceRegistrationResponsePayload(string ServiceId, string Action, ServiceIdentityPayload Service); + private sealed record ServiceIdentityPayload(Guid Id, string ServiceId, string Name); + private sealed record DependencyMappingPayload(string DependencyName, Guid TargetMonitoredServiceId); + private sealed record DependencyLinkResponsePayload(Guid Id, string DependencyName, Guid TargetMonitoredServiceId, string TargetServiceId, string TargetServiceName); +} + +public static class HealthMonitoringRegistrationClientServiceCollectionExtensions +{ + public static IHttpClientBuilder AddHealthMonitoringRegistrationClient(this IServiceCollection services, Action configure) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configure); + var options = new HealthMonitoringRegistrationClientOptions(); + configure(options); + if (!options.MonitoringServerUrl.IsAbsoluteUri) throw new ArgumentException("The monitoring server URL must be absolute.", nameof(configure)); + + return services.AddHttpClient(client => + { + client.BaseAddress = options.MonitoringServerUrl; + }); + } +} diff --git a/HealthMonitoring.Client/README.md b/HealthMonitoring.Client/README.md new file mode 100644 index 0000000..be4816f --- /dev/null +++ b/HealthMonitoring.Client/README.md @@ -0,0 +1,21 @@ +# HealthMonitoring.Client + +Reusable .NET client for service self-registration and dependency mapping. + +```csharp +services.AddHealthMonitoringRegistrationClient(options => +{ + options.MonitoringServerUrl = new Uri("http://localhost:9620"); +}); + +var result = await client.RegisterAndConfigureAsync( + new ServiceRegistrationOptions + { + ServiceId = "payments-api", + Name = "Payments API", + HealthUrl = new Uri("https://payments-api/health") + }, + [new DependencyMappingOptions("Security Service", "security-service")]); +``` + +Registration is idempotent. Dependency mappings are resolved by `TargetServiceId` and are created or updated as needed. diff --git a/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs b/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs index d854ce6..2913745 100644 --- a/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs +++ b/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs @@ -1,4 +1,5 @@ using HealthMonitoring.Api; +using HealthMonitoring.Domain; namespace HealthMonitoring.Tests.Api; @@ -7,7 +8,7 @@ public sealed class ServiceConfigurationValidatorTests [Fact] public void Rejects_non_http_urls_and_invalid_intervals() { - var request = new ServiceRegistrationRequest("service", "Service", "Production", "ftp://service") + var request = new ServiceRegistrationRequest("service", "Service", "ftp://service") { PollingIntervalSeconds = 3, RequestTimeoutSeconds = 5, @@ -34,12 +35,90 @@ public void Self_registration_preserves_dashboard_managed_settings_for_existing_ PollingInterval = TimeSpan.FromMinutes(5), RetentionPeriod = TimeSpan.FromDays(10) }; - var request = new ServiceRegistrationRequest("service", "Updated", "Production", "https://new/health") { PollingIntervalSeconds = 30, RetentionDays = 1 }; + var request = new ServiceRegistrationRequest("service", "Updated", "https://new/health") { PollingIntervalSeconds = 30, RetentionDays = 1 }; - var result = ServiceConfigurationValidator.ToService(request, existing, preserveManagedSettings: true); + var result = ServiceConfigurationValidator.ToService(request, "Development", existing, preserveManagedSettings: true); Assert.Equal(TimeSpan.FromMinutes(5), result.PollingInterval); Assert.Equal(TimeSpan.FromDays(10), result.RetentionPeriod); Assert.Equal(new Uri("https://new/health"), result.HealthUrl); } + + [Fact] + public void Registration_contract_does_not_accept_environment_from_the_service() + { + Assert.DoesNotContain(typeof(ServiceRegistrationRequest).GetProperties(), property => property.Name == "Environment"); + } + + [Fact] + public void New_service_uses_the_dashboard_environment() + { + var request = new ServiceRegistrationRequest("service", "Service", "https://service/health"); + + var result = ServiceConfigurationValidator.ToService(request, "Production"); + + Assert.Equal("Production", result.Environment); + } + + [Fact] + public void Certificate_validation_setting_is_preserved_for_a_service() + { + var request = new ServiceRegistrationRequest("service", "Service", "https://service/health") + { + IgnoreCertificateErrors = true + }; + + var result = ServiceConfigurationValidator.ToService(request, "Development"); + + Assert.True(result.IgnoreCertificateErrors); + } + + [Fact] + public void Kurrent_db_registration_requires_a_connection_string_instead_of_a_health_url() + { + var request = new ServiceRegistrationRequest + { + ServiceId = "eventstore", + Name = "KurrentDB", + MonitorType = MonitorType.KurrentDb + }; + + var errors = ServiceConfigurationValidator.Validate(request); + + Assert.DoesNotContain(errors, error => error.Contains("HealthUrl", StringComparison.Ordinal)); + Assert.Contains(errors, error => error.Contains("ConnectionString", StringComparison.Ordinal)); + } + + [Fact] + public void Kurrent_db_registration_stores_the_connection_string() + { + var request = new ServiceRegistrationRequest + { + ServiceId = "eventstore", + Name = "KurrentDB", + MonitorType = MonitorType.KurrentDb, + ConnectionString = "esdb://admin:password@localhost:2113?tls=false" + }; + + var result = ServiceConfigurationValidator.ToService(request, "Development"); + + Assert.Equal(MonitorType.KurrentDb, result.MonitorType); + Assert.Equal(request.ConnectionString, result.ConnectionString); + } + + [Fact] + public void Sql_server_registration_requires_a_connection_string_instead_of_a_health_url() + { + var request = new ServiceRegistrationRequest + { + ServiceId = "orders-db", + Name = "Orders SQL Server", + MonitorType = MonitorType.SqlServer + }; + + var errors = ServiceConfigurationValidator.Validate(request); + + Assert.DoesNotContain(errors, error => error.Contains("HealthUrl", StringComparison.Ordinal)); + Assert.Contains(errors, error => error.Contains("ConnectionString", StringComparison.Ordinal)); + } } diff --git a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs new file mode 100644 index 0000000..7641910 --- /dev/null +++ b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs @@ -0,0 +1,133 @@ +using System.Net; +using System.Net.Http.Json; +using HealthMonitoring.Client; + +namespace HealthMonitoring.Tests.Client; + +public sealed class HealthMonitoringRegistrationClientTests +{ + [Fact] + public async Task RegisterAsync_posts_service_configuration_and_returns_acknowledgement() + { + var handler = new RecordingHandler(_ => new HttpResponseMessage(HttpStatusCode.Created) + { + Content = JsonContent.Create(new + { + serviceId = "payments-api", + action = "created", + service = new { id = Guid.Parse("11111111-1111-1111-1111-111111111111") } + }) + }); + var client = CreateClient(handler); + + var result = await client.RegisterAsync(new ServiceRegistrationOptions + { + ServiceId = "payments-api", + Name = "Payments API", + HealthUrl = new Uri("https://payments-api/health"), + PollingInterval = TimeSpan.FromSeconds(30), + RequestTimeout = TimeSpan.FromSeconds(8), + RetentionPeriod = TimeSpan.FromDays(90), + IgnoreCertificateErrors = true + }); + + Assert.Equal("created", result.Action); + Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), result.MonitoredServiceId); + Assert.Equal(HttpMethod.Post, handler.Request!.Method); + Assert.Equal("http://monitoring.local/api/services/register", handler.Request.RequestUri!.ToString()); + var payload = await handler.Request.Content!.ReadFromJsonAsync(); + Assert.Equal("payments-api", payload!.ServiceId); + Assert.Equal(0, payload.MonitorType); + Assert.Equal(30, payload.PollingIntervalSeconds); + Assert.True(payload.IgnoreCertificateErrors); + } + + [Fact] + public async Task SetDependencyMappingsAsync_resolves_target_service_and_upserts_mapping() + { + var targetId = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var handler = new QueueHandler( + JsonResponse(HttpStatusCode.OK, Array.Empty()), + JsonResponse(HttpStatusCode.OK, new { id = targetId, serviceId = "security-service", name = "Security Service" }), + JsonResponse(HttpStatusCode.Created, new { })); + var client = CreateClient(handler); + + await client.SetDependencyMappingsAsync("payments-api", new[] + { + new DependencyMappingOptions("Security Service", "security-service") + }); + + Assert.Collection(handler.Requests, + request => Assert.Equal("http://monitoring.local/api/services/payments-api/dependency-links", request.RequestUri!.ToString()), + request => Assert.Equal("http://monitoring.local/api/services/security-service", request.RequestUri!.ToString()), + request => Assert.Equal(HttpMethod.Post, request.Method)); + var payload = await handler.Requests[2].Content!.ReadFromJsonAsync(); + Assert.Equal("Security Service", payload!.DependencyName); + Assert.Equal(targetId, payload.TargetMonitoredServiceId); + } + + [Fact] + public async Task SetDependencyMappingsAsync_updates_existing_mapping_instead_of_creating_a_duplicate() + { + var linkId = Guid.Parse("33333333-3333-3333-3333-333333333333"); + var targetId = Guid.Parse("44444444-4444-4444-4444-444444444444"); + var handler = new QueueHandler( + JsonResponse(HttpStatusCode.OK, new[] { new { id = linkId, dependencyName = "Security Service" } }), + JsonResponse(HttpStatusCode.OK, new { id = targetId, serviceId = "security-service", name = "Security Service" }), + JsonResponse(HttpStatusCode.OK, new { })); + var client = CreateClient(handler); + + await client.SetDependencyMappingsAsync("payments-api", new[] + { + new DependencyMappingOptions("Security Service", "security-service") + }); + + Assert.Equal(HttpMethod.Put, handler.Requests[2].Method); + Assert.EndsWith($"/api/services/payments-api/dependency-links/{linkId}", handler.Requests[2].RequestUri!.ToString()); + } + + private static HealthMonitoringRegistrationClient CreateClient(HttpMessageHandler handler) => + new(new HttpClient(handler) { BaseAddress = new Uri("http://monitoring.local") }); + + private sealed class ServiceRegistrationPayload + { + public string ServiceId { get; set; } = string.Empty; + public int MonitorType { get; set; } + public int PollingIntervalSeconds { get; set; } + public bool IgnoreCertificateErrors { get; set; } + } + + private sealed class DependencyMappingPayload + { + public string DependencyName { get; set; } = string.Empty; + public Guid TargetMonitoredServiceId { get; set; } + } + + private sealed class RecordingHandler(Func responseFactory) : HttpMessageHandler + { + public HttpRequestMessage? Request { get; private set; } + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Request = request; + return Task.FromResult(responseFactory(request)); + } + } + + private sealed class QueueHandler(params HttpResponseMessage[] responses) : HttpMessageHandler + { + private int _index; + public List Requests { get; } = []; + + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Requests.Add(request); + return Task.FromResult(responses[_index++]); + } + } + + private static HttpResponseMessage JsonResponse(HttpStatusCode statusCode, object value) => new(statusCode) + { + Content = JsonContent.Create(value) + }; +} diff --git a/HealthMonitoring.Tests/Components/DashboardApiUriTests.cs b/HealthMonitoring.Tests/Components/DashboardApiUriTests.cs new file mode 100644 index 0000000..32622b2 --- /dev/null +++ b/HealthMonitoring.Tests/Components/DashboardApiUriTests.cs @@ -0,0 +1,14 @@ +using HealthMonitoring.Components; + +namespace HealthMonitoring.Tests.Components; + +public sealed class DashboardApiUriTests +{ + [Fact] + public void Builds_an_absolute_uri_from_the_dashboard_base_uri() + { + var result = DashboardApiUri.Create("http://localhost:9620/", "api/services/orders"); + + Assert.Equal("http://localhost:9620/api/services/orders", result.AbsoluteUri); + } +} diff --git a/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj b/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj index a462d4d..4d961cc 100644 --- a/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj +++ b/HealthMonitoring.Tests/HealthMonitoring.Tests.csproj @@ -15,5 +15,6 @@ + diff --git a/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs index 4fdec2a..7611d9c 100644 --- a/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs +++ b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs @@ -2,6 +2,7 @@ using System.Net.Http; using HealthMonitoring.Domain; using HealthMonitoring.Monitoring; +using Microsoft.Extensions.Http; namespace HealthMonitoring.Tests.Monitoring; @@ -11,7 +12,7 @@ public sealed class HealthEndpointClientTests public async Task Parses_standard_aspnet_health_response() { var handler = new StubHandler(HttpStatusCode.OK, "{\"status\":\"Healthy\",\"entries\":{\"database\":{\"status\":\"Healthy\",\"duration\":\"00:00:00.004\",\"data\":{}}}}" ); - var client = new HealthEndpointClient(new HttpClient(handler), new HealthStatusNormalizer()); + var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; var result = await client.CheckAsync(service, CancellationToken.None); @@ -25,7 +26,7 @@ public async Task Parses_standard_aspnet_health_response() public async Task Timeout_is_recorded_as_unhealthy_observation_candidate() { var handler = new StubHandler(new TaskCanceledException("timeout")); - var client = new HealthEndpointClient(new HttpClient(handler), new HealthStatusNormalizer()); + var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; var result = await client.CheckAsync(service, CancellationToken.None); @@ -49,4 +50,9 @@ protected override Task SendAsync(HttpRequestMessage reques return Task.FromResult(new HttpResponseMessage(_status!.Value) { Content = new StringContent(_body!) }); } } + + private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory + { + public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + } } diff --git a/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs b/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs new file mode 100644 index 0000000..bed61c6 --- /dev/null +++ b/HealthMonitoring.Tests/Monitoring/InitializationStateTests.cs @@ -0,0 +1,42 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; + +namespace HealthMonitoring.Tests.Monitoring; + +public sealed class InitializationStateTests +{ + [Fact] + public void Base_initialization_is_complete_only_when_both_required_monitor_types_exist() + { + var services = new[] + { + new MonitoredService { MonitorType = MonitorType.SqlServer }, + new MonitoredService { MonitorType = MonitorType.KurrentDb } + }; + + Assert.True(InitializationStateCalculator.IsComplete(services)); + Assert.False(InitializationStateCalculator.IsComplete(services.Take(1))); + } + + [Fact] + public void Archived_monitors_do_not_complete_initialization() + { + var services = new[] + { + new MonitoredService { MonitorType = MonitorType.SqlServer }, + new MonitoredService { MonitorType = MonitorType.KurrentDb, ArchivedAtUtc = DateTimeOffset.UtcNow } + }; + + Assert.False(InitializationStateCalculator.IsComplete(services)); + } + + [Fact] + public void Each_base_monitor_requirement_is_evaluated_independently() + { + var services = new[] { new MonitoredService { MonitorType = MonitorType.SqlServer } }; + + Assert.True(InitializationStateCalculator.IsComplete(services, requireSqlServer: true, requireKurrentDb: false)); + Assert.False(InitializationStateCalculator.IsComplete(services, requireSqlServer: false, requireKurrentDb: true)); + Assert.True(InitializationStateCalculator.IsComplete([], requireSqlServer: false, requireKurrentDb: false)); + } +} diff --git a/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs b/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs new file mode 100644 index 0000000..5a48db6 --- /dev/null +++ b/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs @@ -0,0 +1,40 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; + +namespace HealthMonitoring.Tests.Monitoring; + +public sealed class KurrentDbMonitorClientTests +{ + [Fact] + public async Task Successful_probe_returns_healthy_kurrentdb_observation() + { + var client = new KurrentDbMonitorClient(new StubProbe()); + var service = new MonitoredService { MonitorType = MonitorType.KurrentDb }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Healthy, result.Normalized.Status); + Assert.Single(result.Normalized.Checks); + Assert.Equal("KurrentDB", result.Normalized.Checks[0].Name); + Assert.Contains("KurrentDB", result.RawResponse, StringComparison.Ordinal); + } + + [Fact] + public async Task Failed_probe_returns_unhealthy_with_the_failure_detail() + { + var client = new KurrentDbMonitorClient(new StubProbe(new InvalidOperationException("connection refused"))); + var service = new MonitoredService { MonitorType = MonitorType.KurrentDb }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Normalized.Status); + Assert.Contains("connection refused", result.Normalized.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(HealthStatus.Unhealthy, result.Normalized.Checks[0].Status); + } + + private sealed class StubProbe(Exception? exception = null) : IKurrentDbProbe + { + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => + exception is null ? Task.CompletedTask : Task.FromException(exception); + } +} diff --git a/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs b/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs new file mode 100644 index 0000000..5a93118 --- /dev/null +++ b/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs @@ -0,0 +1,39 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Monitoring; + +namespace HealthMonitoring.Tests.Monitoring; + +public sealed class SqlServerMonitorClientTests +{ + [Fact] + public async Task Successful_probe_returns_healthy_sql_server_observation() + { + var client = new SqlServerMonitorClient(new StubProbe()); + var service = new MonitoredService { MonitorType = MonitorType.SqlServer }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Healthy, result.Normalized.Status); + Assert.Single(result.Normalized.Checks); + Assert.Equal("SQL Server", result.Normalized.Checks[0].Name); + } + + [Fact] + public async Task Failed_probe_returns_unhealthy_with_the_failure_detail() + { + var client = new SqlServerMonitorClient(new StubProbe(new InvalidOperationException("login failed"))); + var service = new MonitoredService { MonitorType = MonitorType.SqlServer }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Normalized.Status); + Assert.Contains("login failed", result.Normalized.Error, StringComparison.OrdinalIgnoreCase); + Assert.Equal(HealthStatus.Unhealthy, result.Normalized.Checks[0].Status); + } + + private sealed class StubProbe(Exception? exception = null) : ISqlServerProbe + { + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => + exception is null ? Task.CompletedTask : Task.FromException(exception); + } +} diff --git a/HealthMonitoring.Tests/Persistence/DependencyMappingTests.cs b/HealthMonitoring.Tests/Persistence/DependencyMappingTests.cs new file mode 100644 index 0000000..4e70a58 --- /dev/null +++ b/HealthMonitoring.Tests/Persistence/DependencyMappingTests.cs @@ -0,0 +1,24 @@ +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; +using Microsoft.EntityFrameworkCore; + +namespace HealthMonitoring.Tests.Persistence; + +public sealed class DependencyMappingTests +{ + [Fact] + public void Dependency_links_are_mapped_with_a_unique_parent_and_name_index() + { + var options = new DbContextOptionsBuilder() + .UseSqlServer("Server=.;Database=ignored;") + .Options; + using var context = new HealthMonitoringDbContext(options); + + var entity = context.Model.FindEntityType(typeof(ServiceDependencyLink)); + + Assert.NotNull(entity); + Assert.Contains(entity!.GetIndexes(), index => + index.IsUnique && index.Properties.Select(property => property.Name) + .SequenceEqual([nameof(ServiceDependencyLink.MonitoredServiceId), nameof(ServiceDependencyLink.DependencyName)])); + } +} diff --git a/HealthMonitoring.Tests/Persistence/TimeSpanMappingTests.cs b/HealthMonitoring.Tests/Persistence/TimeSpanMappingTests.cs new file mode 100644 index 0000000..6cbed71 --- /dev/null +++ b/HealthMonitoring.Tests/Persistence/TimeSpanMappingTests.cs @@ -0,0 +1,28 @@ +using HealthMonitoring.Persistence; +using AppDomain = HealthMonitoring.Domain; +using Microsoft.EntityFrameworkCore; + +namespace HealthMonitoring.Tests.Persistence; + +public sealed class TimeSpanMappingTests +{ + [Fact] + public void Long_duration_properties_use_a_numeric_sql_provider_type() + { + var options = new DbContextOptionsBuilder().UseSqlServer("Server=.;Database=ignored;").Options; + using var context = new HealthMonitoringDbContext(options); + var model = context.Model; + + var service = model.FindEntityType(typeof(AppDomain.MonitoredService))!; + var observation = model.FindEntityType(typeof(AppDomain.HealthObservation))!; + var check = model.FindEntityType(typeof(AppDomain.HealthCheckResult))!; + var snapshot = model.FindEntityType(typeof(AppDomain.ServiceStatusSnapshot))!; + + Assert.Equal(typeof(long), service.FindProperty(nameof(AppDomain.MonitoredService.RetentionPeriod))!.GetTypeMapping().Converter!.ProviderClrType); + Assert.Equal(typeof(long), service.FindProperty(nameof(AppDomain.MonitoredService.PollingInterval))!.GetTypeMapping().Converter!.ProviderClrType); + Assert.Equal(typeof(long), service.FindProperty(nameof(AppDomain.MonitoredService.RequestTimeout))!.GetTypeMapping().Converter!.ProviderClrType); + Assert.Equal(typeof(long), observation.FindProperty(nameof(AppDomain.HealthObservation.ResponseDuration))!.GetTypeMapping().Converter!.ProviderClrType); + Assert.Equal(typeof(long), check.FindProperty(nameof(AppDomain.HealthCheckResult.Duration))!.GetTypeMapping().Converter!.ProviderClrType); + Assert.Equal(typeof(long?), snapshot.FindProperty(nameof(AppDomain.ServiceStatusSnapshot.LastResponseDuration))!.GetTypeMapping().Converter!.ProviderClrType); + } +} diff --git a/HealthMonitoring/Api/DependencyMappingContracts.cs b/HealthMonitoring/Api/DependencyMappingContracts.cs new file mode 100644 index 0000000..32b3034 --- /dev/null +++ b/HealthMonitoring/Api/DependencyMappingContracts.cs @@ -0,0 +1,10 @@ +namespace HealthMonitoring.Api; + +public sealed record DependencyLinkRequest(string DependencyName, Guid TargetMonitoredServiceId); + +public sealed record DependencyLinkResponse( + Guid Id, + string DependencyName, + Guid TargetMonitoredServiceId, + string TargetServiceId, + string TargetServiceName); diff --git a/HealthMonitoring/Api/DependencyMappingEndpoints.cs b/HealthMonitoring/Api/DependencyMappingEndpoints.cs new file mode 100644 index 0000000..455d36a --- /dev/null +++ b/HealthMonitoring/Api/DependencyMappingEndpoints.cs @@ -0,0 +1,90 @@ +using HealthMonitoring.Persistence; + +namespace HealthMonitoring.Api; + +public static class DependencyMappingEndpoints +{ + public static IEndpointRouteBuilder MapDependencyMappingEndpoints(this IEndpointRouteBuilder endpoints) + { + endpoints.MapGet("/api/services/{serviceId}/dependency-links", async (string serviceId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + var service = await repository.GetServiceAsync(serviceId, cancellationToken); + if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound(); + + var links = await repository.ListDependencyLinksAsync(service.Id, cancellationToken); + var services = await repository.ListServicesAsync(false, cancellationToken); + return Results.Ok(ToResponses(links, services)); + }); + + endpoints.MapPost("/api/services/{serviceId}/dependency-links", async (string serviceId, DependencyLinkRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + var service = await repository.GetServiceAsync(serviceId, cancellationToken); + if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound(); + + var validation = await ValidateAsync(service.Id, request, null, repository, cancellationToken); + if (validation is not null) return validation; + + var link = new Domain.ServiceDependencyLink + { + Id = Guid.NewGuid(), + MonitoredServiceId = service.Id, + DependencyName = request.DependencyName.Trim(), + TargetMonitoredServiceId = request.TargetMonitoredServiceId + }; + await repository.UpsertDependencyLinkAsync(link, cancellationToken); + return Results.Created($"/api/services/{serviceId}/dependency-links/{link.Id}", link); + }); + + endpoints.MapPut("/api/services/{serviceId}/dependency-links/{linkId:guid}", async (string serviceId, Guid linkId, DependencyLinkRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + var service = await repository.GetServiceAsync(serviceId, cancellationToken); + if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound(); + + var links = await repository.ListDependencyLinksAsync(service.Id, cancellationToken); + var existing = links.SingleOrDefault(link => link.Id == linkId); + if (existing is null) return Results.NotFound(); + + var validation = await ValidateAsync(service.Id, request, linkId, repository, cancellationToken); + if (validation is not null) return validation; + + existing.DependencyName = request.DependencyName.Trim(); + existing.TargetMonitoredServiceId = request.TargetMonitoredServiceId; + existing.UpdatedAtUtc = DateTimeOffset.UtcNow; + await repository.UpsertDependencyLinkAsync(existing, cancellationToken); + return Results.Ok(existing); + }); + + endpoints.MapDelete("/api/services/{serviceId}/dependency-links/{linkId:guid}", async (string serviceId, Guid linkId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => + { + var service = await repository.GetServiceAsync(serviceId, cancellationToken); + if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound(); + + var links = await repository.ListDependencyLinksAsync(service.Id, cancellationToken); + if (links.All(link => link.Id != linkId)) return Results.NotFound(); + + await repository.DeleteDependencyLinkAsync(linkId, cancellationToken); + return Results.NoContent(); + }); + + return endpoints; + } + + private static async Task ValidateAsync(Guid parentId, DependencyLinkRequest request, Guid? currentLinkId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) + { + var errors = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(request.DependencyName)) errors["DependencyName"] = ["Dependency name is required."]; + if (request.TargetMonitoredServiceId == parentId) errors["TargetMonitoredServiceId"] = ["A service cannot link to itself."]; + + var target = await repository.ListServicesAsync(false, cancellationToken); + if (target.All(service => service.Id != request.TargetMonitoredServiceId)) errors["TargetMonitoredServiceId"] = ["Target service must be an active monitored service."]; + + var links = await repository.ListDependencyLinksAsync(parentId, cancellationToken); + if (links.Any(link => link.Id != currentLinkId && string.Equals(link.DependencyName.Trim(), request.DependencyName.Trim(), StringComparison.OrdinalIgnoreCase))) + errors["DependencyName"] = ["A mapping for this dependency already exists."]; + + return errors.Count == 0 ? null : Results.ValidationProblem(errors); + } + + private static IReadOnlyList ToResponses(IReadOnlyList links, IReadOnlyList services) => + links.Join(services, link => link.TargetMonitoredServiceId, service => service.Id, (link, service) => new DependencyLinkResponse(link.Id, link.DependencyName, link.TargetMonitoredServiceId, service.ServiceId, service.Name)).ToArray(); +} diff --git a/HealthMonitoring/Api/InitializationEndpoints.cs b/HealthMonitoring/Api/InitializationEndpoints.cs new file mode 100644 index 0000000..bf30986 --- /dev/null +++ b/HealthMonitoring/Api/InitializationEndpoints.cs @@ -0,0 +1,27 @@ +using HealthMonitoring.Monitoring; + +namespace HealthMonitoring.Api; + +public static class InitializationEndpoints +{ + public static IEndpointRouteBuilder MapInitializationEndpoints(this IEndpointRouteBuilder endpoints, string dashboardEnvironment) + { + endpoints.MapGet("/api/initialization/status", async (IInitializationService initialization, CancellationToken cancellationToken) => + Results.Ok(await initialization.GetStatusAsync(cancellationToken))); + + endpoints.MapPost("/api/initialization", async (InitializationRequest request, IInitializationService initialization, CancellationToken cancellationToken) => + { + try + { + await initialization.InitializeAsync(request, dashboardEnvironment, cancellationToken); + return Results.Ok(await initialization.GetStatusAsync(cancellationToken)); + } + catch (ArgumentException exception) + { + return Results.ValidationProblem(new Dictionary { ["initialization"] = [exception.Message] }); + } + }); + + return endpoints; + } +} diff --git a/HealthMonitoring/Api/ServiceConfigurationValidator.cs b/HealthMonitoring/Api/ServiceConfigurationValidator.cs index ef748e0..2692ac4 100644 --- a/HealthMonitoring/Api/ServiceConfigurationValidator.cs +++ b/HealthMonitoring/Api/ServiceConfigurationValidator.cs @@ -9,23 +9,27 @@ public static IReadOnlyList Validate(ServiceRegistrationRequest request) var errors = new List(); if (string.IsNullOrWhiteSpace(request.ServiceId)) errors.Add("ServiceId is required."); if (string.IsNullOrWhiteSpace(request.Name)) errors.Add("Name is required."); - if (string.IsNullOrWhiteSpace(request.Environment)) errors.Add("Environment is required."); - if (!Uri.TryCreate(request.HealthUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https")) errors.Add("HealthUrl must be an absolute HTTP or HTTPS URL."); + if (!Enum.IsDefined(request.MonitorType)) errors.Add("MonitorType is invalid."); + if (request.MonitorType == MonitorType.HttpHealthEndpoint && (!Uri.TryCreate(request.HealthUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))) errors.Add("HealthUrl must be an absolute HTTP or HTTPS URL."); + if (request.MonitorType is MonitorType.KurrentDb or MonitorType.SqlServer && string.IsNullOrWhiteSpace(request.ConnectionString)) errors.Add("ConnectionString is required for database monitors."); if (request.PollingIntervalSeconds < 5) errors.Add("PollingIntervalSeconds must be at least 5."); if (request.RequestTimeoutSeconds < 1 || request.RequestTimeoutSeconds > request.PollingIntervalSeconds) errors.Add("RequestTimeoutSeconds must be between 1 and the polling interval."); if (request.RetentionDays < 1) errors.Add("RetentionDays must be positive."); return errors; } - public static MonitoredService ToService(ServiceRegistrationRequest request, MonitoredService? existing = null, bool preserveManagedSettings = true) => new() + public static MonitoredService ToService(ServiceRegistrationRequest request, string dashboardEnvironment, MonitoredService? existing = null, bool preserveManagedSettings = true) => new() { Id = existing?.Id ?? Guid.NewGuid(), ServiceId = request.ServiceId, Name = request.Name, - Environment = request.Environment, + Environment = dashboardEnvironment, Group = request.Group, Description = request.Description, - HealthUrl = new Uri(request.HealthUrl), + MonitorType = request.MonitorType, + HealthUrl = Uri.TryCreate(request.HealthUrl, UriKind.Absolute, out var healthUrl) ? healthUrl : new Uri("http://localhost/health"), + ConnectionString = request.ConnectionString, + IgnoreCertificateErrors = request.IgnoreCertificateErrors, IsEnabled = existing?.IsEnabled ?? true, PollingInterval = preserveManagedSettings && existing is not null ? existing.PollingInterval : TimeSpan.FromSeconds(request.PollingIntervalSeconds), RequestTimeout = preserveManagedSettings && existing is not null ? existing.RequestTimeout : TimeSpan.FromSeconds(request.RequestTimeoutSeconds), diff --git a/HealthMonitoring/Api/ServiceRegistrationContracts.cs b/HealthMonitoring/Api/ServiceRegistrationContracts.cs index 91313f8..3f69c06 100644 --- a/HealthMonitoring/Api/ServiceRegistrationContracts.cs +++ b/HealthMonitoring/Api/ServiceRegistrationContracts.cs @@ -5,11 +5,13 @@ namespace HealthMonitoring.Api; public sealed class ServiceRegistrationRequest { public ServiceRegistrationRequest() { } - public ServiceRegistrationRequest(string serviceId, string name, string environment, string healthUrl) { ServiceId = serviceId; Name = name; Environment = environment; HealthUrl = healthUrl; } + public ServiceRegistrationRequest(string serviceId, string name, string healthUrl) { ServiceId = serviceId; Name = name; HealthUrl = healthUrl; } public string ServiceId { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; - public string Environment { get; set; } = string.Empty; public string HealthUrl { get; set; } = string.Empty; + public MonitorType MonitorType { get; set; } = MonitorType.HttpHealthEndpoint; + public string? ConnectionString { get; set; } + public bool IgnoreCertificateErrors { get; set; } public int PollingIntervalSeconds { get; set; } = 60; public int RequestTimeoutSeconds { get; set; } = 10; public int RetentionDays { get; set; } = 365; @@ -23,9 +25,11 @@ public ServiceRegistrationRequest() { } public sealed record ServiceRegistrationResponse(string ServiceId, string Action, MonitoredService Service); public sealed record ServiceSummary( + Guid Id, string ServiceId, string Name, string Environment, + MonitorType MonitorType, string HealthUrl, bool IsEnabled, HealthStatus Status, diff --git a/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs index 88f8f20..ab8de49 100644 --- a/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs +++ b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs @@ -5,7 +5,7 @@ namespace HealthMonitoring.Api; public static class ServiceRegistrationEndpoints { - public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpointRouteBuilder endpoints) + public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpointRouteBuilder endpoints, string dashboardEnvironment) { endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { @@ -13,7 +13,7 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi if (errors.Count > 0) return Results.ValidationProblem(errors.ToDictionary(error => error, error => new[] { error })); var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken); - var service = ServiceConfigurationValidator.ToService(request, existing, preserveManagedSettings: true); + var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: true); await repository.UpsertServiceAsync(service, cancellationToken); var action = existing is null ? "created" : "acknowledged"; return existing is null @@ -28,7 +28,7 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi foreach (var service in services) { var snapshot = await repository.GetSnapshotAsync(service.Id, cancellationToken); - results.Add(new ServiceSummary(service.ServiceId, service.Name, service.Environment, service.HealthUrl.ToString(), service.IsEnabled, snapshot?.Status ?? HealthStatus.Unknown, snapshot?.LastObservedAtUtc, snapshot?.LastResponseDuration, snapshot?.LastError)); + results.Add(new ServiceSummary(service.Id, service.ServiceId, service.Name, service.Environment, service.MonitorType, service.HealthUrl.ToString(), service.IsEnabled, snapshot?.Status ?? HealthStatus.Unknown, snapshot?.LastObservedAtUtc, snapshot?.LastResponseDuration, snapshot?.LastError)); } return Results.Ok(results); @@ -48,7 +48,7 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi if (errors.Count > 0) return Results.ValidationProblem(errors.ToDictionary(error => error, error => new[] { error })); var existing = await repository.GetServiceAsync(serviceId, cancellationToken); if (existing is null) return Results.NotFound(); - var service = ServiceConfigurationValidator.ToService(request, existing, preserveManagedSettings: false); + var service = ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: false); await repository.UpsertServiceAsync(service, cancellationToken); return Results.Ok(service); }); diff --git a/HealthMonitoring/Components/DashboardApiUri.cs b/HealthMonitoring/Components/DashboardApiUri.cs new file mode 100644 index 0000000..5c1656b --- /dev/null +++ b/HealthMonitoring/Components/DashboardApiUri.cs @@ -0,0 +1,6 @@ +namespace HealthMonitoring.Components; + +public static class DashboardApiUri +{ + public static Uri Create(string baseUri, string relativePath) => new(new Uri(baseUri, UriKind.Absolute), relativePath); +} diff --git a/HealthMonitoring/Components/InitializationGate.razor b/HealthMonitoring/Components/InitializationGate.razor new file mode 100644 index 0000000..a2189e6 --- /dev/null +++ b/HealthMonitoring/Components/InitializationGate.razor @@ -0,0 +1,28 @@ +@inject HealthMonitoring.Monitoring.IInitializationService Initialization +@inject NavigationManager Navigation + +@if (_checking) +{ +

Checking initialisation…

+} +else +{ + @ChildContent +} + +@code { + [Parameter] public RenderFragment? ChildContent { get; set; } + private bool _checking = true; + + protected override async Task OnParametersSetAsync() + { + _checking = true; + var status = await Initialization.GetStatusAsync(CancellationToken.None); + var path = Navigation.ToBaseRelativePath(Navigation.Uri); + var onInitialisationOverview = path.Equals("initialise", StringComparison.OrdinalIgnoreCase); + var onHealthMonitoringInitialise = path.Equals("health-monitoring/initialise", StringComparison.OrdinalIgnoreCase); + if (status.Required && !status.Complete && !onInitialisationOverview && !onHealthMonitoringInitialise) + Navigation.NavigateTo("/initialise", replace: true); + _checking = false; + } +} diff --git a/HealthMonitoring/Components/Layout/MainLayout.razor b/HealthMonitoring/Components/Layout/MainLayout.razor index f7fe91d..b7c20bd 100644 --- a/HealthMonitoring/Components/Layout/MainLayout.razor +++ b/HealthMonitoring/Components/Layout/MainLayout.razor @@ -2,12 +2,21 @@
@Body
diff --git a/HealthMonitoring/Components/Pages/DependencyLinks.razor b/HealthMonitoring/Components/Pages/DependencyLinks.razor new file mode 100644 index 0000000..315effe --- /dev/null +++ b/HealthMonitoring/Components/Pages/DependencyLinks.razor @@ -0,0 +1,147 @@ +@page "/services/{ServiceId}/dependencies" +@inject HttpClient Http +@inject NavigationManager Navigation + +Operational Support · Dependency links + +@if (_service is null) +{ +

Loading dependency links…

+} +else +{ +
+
+ ← Back to @_service.Name +

CONFIGURATION

+

Dependency links

+

Map health checks to monitored services for drill-down navigation.

+
+
+ +
+ @if (_links.Count == 0) + { +

No dependency links configured yet.

+ } + else + { +
Dependency checkLinked monitored serviceActions
+ @foreach (var link in _links) + { +
+ @link.DependencyName + @link.TargetServiceName +
+ + +
+
+ } + } + +
+

@(_editingId is null ? "Add dependency link" : "Edit dependency link")

+
+ + +
@if (_editingId is not null) { }
+
+ @if (!string.IsNullOrWhiteSpace(_error)) {

@_error

} +
+ +

Unmapped dependency checks remain visible without a drill-down link.

+
+} + +@code { + [Parameter] public string ServiceId { get; set; } = string.Empty; + private MonitoredService? _service; + private List _links = []; + private List _services = []; + private string _dependencyName = string.Empty; + private string _targetServiceId = string.Empty; + private Guid? _editingId; + private bool _saving; + private string? _error; + + protected override async Task OnParametersSetAsync() + { + _service = await Http.GetFromJsonAsync(ApiUri($"api/services/{ServiceId}")); + if (_service is null) return; + _links = await Http.GetFromJsonAsync>(ApiUri($"api/services/{ServiceId}/dependency-links")) ?? []; + _services = await Http.GetFromJsonAsync>(ApiUri("api/services")) ?? []; + } + + private void BeginEdit(DependencyLinkResponse link) + { + _editingId = link.Id; + _dependencyName = link.DependencyName; + _targetServiceId = link.TargetServiceId; + _error = null; + } + + private void CancelEdit() + { + _editingId = null; + _dependencyName = string.Empty; + _targetServiceId = string.Empty; + _error = null; + } + + private async Task SaveAsync() + { + _saving = true; + _error = null; + try + { + if (string.IsNullOrWhiteSpace(_dependencyName) || string.IsNullOrWhiteSpace(_targetServiceId)) + { + _error = "Dependency name and target service are required."; + return; + } + + var target = _services.FirstOrDefault(service => service.ServiceId == _targetServiceId); + if (target is null) + { + _error = "Select an active monitored service."; + return; + } + + var request = new DependencyLinkRequest(_dependencyName, target.Id); + HttpResponseMessage response; + if (_editingId is { } editingId) + response = await Http.PutAsJsonAsync(ApiUri($"api/services/{ServiceId}/dependency-links/{editingId}"), request); + else + response = await Http.PostAsJsonAsync(ApiUri($"api/services/{ServiceId}/dependency-links"), request); + + if (!response.IsSuccessStatusCode) + { + _error = await response.Content.ReadAsStringAsync(); + return; + } + + CancelEdit(); + _links = await Http.GetFromJsonAsync>(ApiUri($"api/services/{ServiceId}/dependency-links")) ?? []; + } + catch (Exception exception) + { + _error = exception.Message; + } + finally + { + _saving = false; + } + } + + private async Task DeleteAsync(Guid linkId) + { + var response = await Http.DeleteAsync(ApiUri($"api/services/{ServiceId}/dependency-links/{linkId}")); + if (response.IsSuccessStatusCode) + _links = await Http.GetFromJsonAsync>(ApiUri($"api/services/{ServiceId}/dependency-links")) ?? []; + else + _error = await response.Content.ReadAsStringAsync(); + } + + private Uri ApiUri(string relativePath) => DashboardApiUri.Create(Navigation.BaseUri, relativePath); +} diff --git a/HealthMonitoring/Components/Pages/InitialisationOverview.razor b/HealthMonitoring/Components/Pages/InitialisationOverview.razor new file mode 100644 index 0000000..a7a3274 --- /dev/null +++ b/HealthMonitoring/Components/Pages/InitialisationOverview.razor @@ -0,0 +1,56 @@ +@page "/initialise" +@inject HttpClient Http +@inject NavigationManager Navigation + +Operational Support · Initialisation +
+
Home › Initialisation
+
+

OPERATIONAL SUPPORT

+

Initialisation

+

Set up and configure the areas of the Operational Support platform. Complete each section to enable full functionality.

+
+ + @if (_status is null) + { +
Loading initialisation status…
+ } + else + { +
+
+
⌁
+
+

Health Monitoring

@(_status.Complete ? "✓ Configured" : "Action required")
+

Configure SQL Server and KurrentDB data sources for health monitoring.

+ @(_status.Complete ? "Review settings" : "Set up Health Monitoring") › +
+
+
+
△
+

Incidents

Coming soon

Set up incident management and notification integrations.

›
+
+
+
◇
+

Service Catalogue

Coming soon

Configure service definitions, ownership, and support contacts.

›
+
+
+
▤
+

Runbooks

Coming soon

Set up runbook repositories and operational recovery procedures.

›
+
+
+ + } + @if (!string.IsNullOrWhiteSpace(_error)) {

@_error

} +
+ +@code { + private InitializationStatus? _status; + private string? _error; + + protected override async Task OnInitializedAsync() + { + try { _status = await Http.GetFromJsonAsync(new Uri(new Uri(Navigation.BaseUri), "api/initialization/status")); } + catch (Exception exception) { _error = exception.Message; } + } +} diff --git a/HealthMonitoring/Components/Pages/Initialise.razor b/HealthMonitoring/Components/Pages/Initialise.razor new file mode 100644 index 0000000..0e9effe --- /dev/null +++ b/HealthMonitoring/Components/Pages/Initialise.razor @@ -0,0 +1,70 @@ +@page "/health-monitoring/initialise" +@inject HttpClient Http +@inject NavigationManager Navigation + +Operational Support · Initialise Health Monitoring +
+
Home › Initialise
+
+

FIRST-RUN SETUP

+

Initialise Health Monitoring

+

Set up the monitors required for your dashboard before you continue.

+
+
1Set up required monitors2Finish
+
ⓘ Configure the base monitors for your dashboard. Required monitors can be changed independently in application settings.
+ +
+
+
▤
+
+

SQL Server

@(_status?.RequireSqlServer == true ? "Required" : "Optional")
+

● @(_status?.SqlServerConfigured == true ? "Configured" : "Not configured")

+ +
+
+
+
⌘
+
+

KurrentDB

@(_status?.RequireKurrentDb == true ? "Required" : "Optional")
+

● @(_status?.KurrentDbConfigured == true ? "Configured" : "Not configured")

+ +
+
+
+ + @if (!string.IsNullOrWhiteSpace(_error)) {

@_error

} +
+

ⓘ Required monitors can be changed in Settings > Application.

+
+ +@code { + private InitializationRequest _request = new("", ""); + private InitializationStatus? _status; + private bool _saving; + private string? _error; + + protected override async Task OnInitializedAsync() + { + try { _status = await Http.GetFromJsonAsync(new Uri(new Uri(Navigation.BaseUri), "api/initialization/status")); } + catch (Exception exception) { _error = exception.Message; } + } + + private async Task SaveAsync() + { + _saving = true; + _error = null; + try + { + var response = await Http.PostAsJsonAsync(new Uri(new Uri(Navigation.BaseUri), "api/initialization"), _request); + if (response.IsSuccessStatusCode) Navigation.NavigateTo("/", forceLoad: true); + else _error = await response.Content.ReadAsStringAsync(); + } + catch (Exception exception) { _error = exception.Message; } + finally { _saving = false; } + } + + private void Skip() => Navigation.NavigateTo("/"); +} diff --git a/HealthMonitoring/Components/Pages/Overview.razor b/HealthMonitoring/Components/Pages/Overview.razor index 5f0f4a6..175273d 100644 --- a/HealthMonitoring/Components/Pages/Overview.razor +++ b/HealthMonitoring/Components/Pages/Overview.razor @@ -1,8 +1,8 @@ @page "/" @inject IDashboardQueryService Dashboard -HealthWatch · Overview -

OPERATIONS

Services

Live health and history for registered services.

Register service
+Operational Support · Health Monitoring +

OPERATIONAL SUPPORT · HEALTH MONITORING

Services

Live health and history for registered services.

Register service
@if (_overview is null) { diff --git a/HealthMonitoring/Components/Pages/ServiceDetail.razor b/HealthMonitoring/Components/Pages/ServiceDetail.razor index b86b76e..89beb0e 100644 --- a/HealthMonitoring/Components/Pages/ServiceDetail.razor +++ b/HealthMonitoring/Components/Pages/ServiceDetail.razor @@ -1,18 +1,18 @@ @page "/services/{ServiceId}" @inject IDashboardQueryService Dashboard -@(_model?.Service.Name ?? "Service") · HealthWatch +@(_model?.Service.Name ?? "Service") · Operational Support @if (_model is null) {

Service not found.

} else { -

SERVICE DETAIL · @_model.Service.Environment

@_model.Service.Name

@_model.Service.HealthUrl

Back to overview
+

SERVICE DETAIL · @_model.Service.Environment

@_model.Service.Name

@(_model.Service.MonitorType == MonitorType.KurrentDb ? "KurrentDB monitor" : _model.Service.HealthUrl.ToString())

Last checked @( _model.Summary.LastChecked?.ToLocalTime().ToString("dd MMM, HH:mm:ss") ?? "never")

Status timeline

@foreach (var observation in _model.Observations.OrderBy(item => item.ObservedAtUtc)) { }
-

Dependency details

@if (_model.Checks.Count == 0) {

No dependency details have been returned.

} else {
CheckStatusDurationDetails
@foreach (var check in _model.Checks.GroupBy(item => item.Name).Select(group => group.OrderByDescending(item => item.HealthObservationId).First())) {
@check.Name
@check.Duration.TotalMilliseconds.ToString("0") ms
@(check.Description ?? check.Exception ?? "No detail")
}
}
+

Dependency details

@if (_model.Checks.Count == 0) {

No dependency details have been returned.

} else {
CheckStatusDurationDetails
@foreach (var check in _model.Checks.GroupBy(item => item.Name).Select(group => group.OrderByDescending(item => item.HealthObservationId).First())) { var link = _model.DependencyLinks.FirstOrDefault(item => string.Equals(item.DependencyName.Trim(), check.Name.Trim(), StringComparison.OrdinalIgnoreCase));
@if (link is null) { @check.Name } else { @check.Name ↗ }
@check.Duration.TotalMilliseconds.ToString("0") ms
@(check.Description ?? check.Exception ?? "No detail")
}
}
Latest raw health response
@(_model.Observations.FirstOrDefault()?.RawResponseJson ?? "No response recorded.")
} @@ -21,5 +21,5 @@ else private ServiceDetailModel? _model; private string UptimeText => $"{_model?.Summary.UptimePercent:0.00}%"; private string ResponseText => _model?.Summary.ResponseDuration is { } duration ? $"{duration.TotalMilliseconds:0} ms" : "—"; - protected override async Task OnInitializedAsync() => _model = await Dashboard.GetServiceDetailAsync(ServiceId, TimeSpan.FromHours(24), CancellationToken.None); + protected override async Task OnParametersSetAsync() => _model = await Dashboard.GetServiceDetailAsync(ServiceId, TimeSpan.FromHours(24), CancellationToken.None); } diff --git a/HealthMonitoring/Components/Pages/ServiceRegister.razor b/HealthMonitoring/Components/Pages/ServiceRegister.razor index 5a0a69c..6fef353 100644 --- a/HealthMonitoring/Components/Pages/ServiceRegister.razor +++ b/HealthMonitoring/Components/Pages/ServiceRegister.razor @@ -2,20 +2,28 @@ @inject HttpClient Http @inject NavigationManager Navigation -Register service · HealthWatch +Operational Support · Register service

CONFIGURATION

Register service

Add a service manually or use the self-registration API.

Cancel
- - + + @if (_request.MonitorType is MonitorType.KurrentDb or MonitorType.SqlServer) + { + + } + else + { + + + }
@if (!string.IsNullOrWhiteSpace(_error)) {

@_error

}
@code { - private ServiceRegistrationRequest _request = new("", "", "Production", "https://localhost/health"); + private ServiceRegistrationRequest _request = new("", "", "https://localhost/health"); private bool _saving; private string? _error; private async Task RegisterAsync() diff --git a/HealthMonitoring/Components/Routes.razor b/HealthMonitoring/Components/Routes.razor index eaecd3b..a0d1d02 100644 --- a/HealthMonitoring/Components/Routes.razor +++ b/HealthMonitoring/Components/Routes.razor @@ -1,8 +1,12 @@ - + + + -

Page not found.

+ +

Page not found.

+
diff --git a/HealthMonitoring/Components/_Imports.razor b/HealthMonitoring/Components/_Imports.razor index 43048c2..3c5818d 100644 --- a/HealthMonitoring/Components/_Imports.razor +++ b/HealthMonitoring/Components/_Imports.razor @@ -3,6 +3,7 @@ @using HealthMonitoring.Reporting @using HealthMonitoring.Components.Shared @using HealthMonitoring.Api +@using HealthMonitoring.Monitoring @using Microsoft.AspNetCore.Components.Forms @using static Microsoft.AspNetCore.Components.Web.RenderMode @using Microsoft.AspNetCore.Components diff --git a/HealthMonitoring/Domain/MonitorType.cs b/HealthMonitoring/Domain/MonitorType.cs new file mode 100644 index 0000000..91c9ee1 --- /dev/null +++ b/HealthMonitoring/Domain/MonitorType.cs @@ -0,0 +1,8 @@ +namespace HealthMonitoring.Domain; + +public enum MonitorType +{ + HttpHealthEndpoint, + KurrentDb, + SqlServer +} diff --git a/HealthMonitoring/Domain/MonitoredService.cs b/HealthMonitoring/Domain/MonitoredService.cs index 93ae70a..5579994 100644 --- a/HealthMonitoring/Domain/MonitoredService.cs +++ b/HealthMonitoring/Domain/MonitoredService.cs @@ -1,3 +1,5 @@ +using System.Text.Json.Serialization; + namespace HealthMonitoring.Domain; public sealed class MonitoredService @@ -6,9 +8,13 @@ public sealed class MonitoredService public string ServiceId { get; set; } = string.Empty; public string Name { get; set; } = string.Empty; public string Environment { get; set; } = string.Empty; + public MonitorType MonitorType { get; set; } = MonitorType.HttpHealthEndpoint; public string? Group { get; set; } public string? Description { get; set; } public Uri HealthUrl { get; set; } = new("https://localhost"); + [JsonIgnore] + public string? ConnectionString { get; set; } + public bool IgnoreCertificateErrors { get; set; } public bool IsEnabled { get; set; } = true; public TimeSpan PollingInterval { get; set; } = TimeSpan.FromMinutes(1); public TimeSpan RequestTimeout { get; set; } = TimeSpan.FromSeconds(10); diff --git a/HealthMonitoring/Domain/ServiceDependencyLink.cs b/HealthMonitoring/Domain/ServiceDependencyLink.cs new file mode 100644 index 0000000..fe26a57 --- /dev/null +++ b/HealthMonitoring/Domain/ServiceDependencyLink.cs @@ -0,0 +1,11 @@ +namespace HealthMonitoring.Domain; + +public sealed class ServiceDependencyLink +{ + public Guid Id { get; set; } + public Guid MonitoredServiceId { get; set; } + public string DependencyName { get; set; } = string.Empty; + public Guid TargetMonitoredServiceId { get; set; } + public DateTimeOffset CreatedAtUtc { get; set; } = DateTimeOffset.UtcNow; + public DateTimeOffset UpdatedAtUtc { get; set; } = DateTimeOffset.UtcNow; +} diff --git a/HealthMonitoring/HealthMonitoring.csproj b/HealthMonitoring/HealthMonitoring.csproj index 509c68b..1989f69 100644 --- a/HealthMonitoring/HealthMonitoring.csproj +++ b/HealthMonitoring/HealthMonitoring.csproj @@ -6,6 +6,9 @@ dotnet-HealthMonitoring + + Always + PreserveNewest @@ -14,5 +17,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive + + diff --git a/HealthMonitoring/HealthMonitoring.sln b/HealthMonitoring/HealthMonitoring.sln index d72b291..ff73d54 100644 --- a/HealthMonitoring/HealthMonitoring.sln +++ b/HealthMonitoring/HealthMonitoring.sln @@ -1,3 +1,4 @@ + Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio Version 18 VisualStudioVersion = 18.4.11626.88 @@ -6,20 +7,56 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HealthMonitoring", "HealthM EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HealthMonitoring.Tests", "..\HealthMonitoring.Tests\HealthMonitoring.Tests.csproj", "{2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "HealthMonitoring.Client", "..\HealthMonitoring.Client\HealthMonitoring.Client.csproj", "{F4E63156-3065-4905-8A31-9E9CCA5E5993}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU + Debug|x64 = Debug|x64 + Debug|x86 = Debug|x86 Release|Any CPU = Release|Any CPU + Release|x64 = Release|x64 + Release|x86 = Release|x86 EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|x64.ActiveCfg = Debug|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|x64.Build.0 = Debug|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|x86.ActiveCfg = Debug|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Debug|x86.Build.0 = Debug|Any CPU {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|Any CPU.ActiveCfg = Release|Any CPU {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|Any CPU.Build.0 = Release|Any CPU - {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|Any CPU.Build.0 = Release|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|x64.ActiveCfg = Release|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|x64.Build.0 = Release|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|x86.ActiveCfg = Release|Any CPU + {7D3A3F01-BB55-4FC6-9664-4F01D9F970A1}.Release|x86.Build.0 = Release|Any CPU {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|x64.ActiveCfg = Debug|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|x64.Build.0 = Debug|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|x86.ActiveCfg = Debug|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Debug|x86.Build.0 = Debug|Any CPU {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|Any CPU.ActiveCfg = Release|Any CPU {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|Any CPU.Build.0 = Release|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|x64.ActiveCfg = Release|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|x64.Build.0 = Release|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|x86.ActiveCfg = Release|Any CPU + {2F2C0D8E-0AB7-4C70-9B07-4BAF8E4C9E2A}.Release|x86.Build.0 = Release|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Debug|x64.ActiveCfg = Debug|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Debug|x64.Build.0 = Debug|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Debug|x86.ActiveCfg = Debug|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Debug|x86.Build.0 = Debug|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Release|Any CPU.Build.0 = Release|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Release|x64.ActiveCfg = Release|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Release|x64.Build.0 = Release|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Release|x86.ActiveCfg = Release|Any CPU + {F4E63156-3065-4905-8A31-9E9CCA5E5993}.Release|x86.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE EndGlobalSection EndGlobal diff --git a/HealthMonitoring/Monitoring/HealthEndpointClient.cs b/HealthMonitoring/Monitoring/HealthEndpointClient.cs index f987426..5743efe 100644 --- a/HealthMonitoring/Monitoring/HealthEndpointClient.cs +++ b/HealthMonitoring/Monitoring/HealthEndpointClient.cs @@ -16,7 +16,7 @@ public interface IHealthEndpointClient Task CheckAsync(MonitoredService service, CancellationToken cancellationToken); } -public sealed class HealthEndpointClient(HttpClient httpClient, IHealthStatusNormalizer normalizer) : IHealthEndpointClient +public sealed class HealthEndpointClient(IHttpClientFactory httpClientFactory, IHealthStatusNormalizer normalizer) : IHealthEndpointClient { public async Task CheckAsync(MonitoredService service, CancellationToken cancellationToken) { @@ -25,6 +25,13 @@ public async Task CheckAsync(MonitoredService service, Can { using var timeout = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeout.CancelAfter(service.RequestTimeout); + using var customClient = service.IgnoreCertificateErrors && service.HealthUrl.Scheme == Uri.UriSchemeHttps + ? new HttpClient(new HttpClientHandler + { + ServerCertificateCustomValidationCallback = HttpClientHandler.DangerousAcceptAnyServerCertificateValidator + }) + : null; + var httpClient = customClient ?? httpClientFactory.CreateClient(); using var response = await httpClient.GetAsync(service.HealthUrl, timeout.Token); var raw = await response.Content.ReadAsStringAsync(timeout.Token); stopwatch.Stop(); diff --git a/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs b/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs index 4f95c6c..c3b3f39 100644 --- a/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs +++ b/HealthMonitoring/Monitoring/HealthMonitoringServiceCollectionExtensions.cs @@ -12,7 +12,13 @@ public static IServiceCollection AddHealthMonitoringServices(this IServiceCollec services.AddScoped(provider => provider.GetRequiredService()); services.AddScoped(provider => provider.GetRequiredService()); services.AddSingleton(); - services.AddHttpClient(); + // HealthEndpointClient selects the default or certificate-bypassing client per service, + // so it must receive IHttpClientFactory rather than be registered as a typed HttpClient. + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); + services.AddSingleton(); + services.AddScoped(); services.AddSingleton(); services.AddScoped(); services.AddScoped(); diff --git a/HealthMonitoring/Monitoring/HealthPollingWorker.cs b/HealthMonitoring/Monitoring/HealthPollingWorker.cs index 3f2debd..474e927 100644 --- a/HealthMonitoring/Monitoring/HealthPollingWorker.cs +++ b/HealthMonitoring/Monitoring/HealthPollingWorker.cs @@ -36,6 +36,8 @@ private async Task PollDueServicesAsync(CancellationToken cancellationToken) await using var scope = scopeFactory.CreateAsyncScope(); var repository = scope.ServiceProvider.GetRequiredService(); var endpointClient = scope.ServiceProvider.GetRequiredService(); + var kurrentDbClient = scope.ServiceProvider.GetRequiredService(); + var sqlServerClient = scope.ServiceProvider.GetRequiredService(); var calculator = scope.ServiceProvider.GetRequiredService(); var services = await repository.ListServicesAsync(false, cancellationToken); var now = DateTimeOffset.UtcNow; @@ -48,7 +50,12 @@ private async Task PollDueServicesAsync(CancellationToken cancellationToken) } _nextPollAt[service.Id] = now.Add(service.PollingInterval); - var result = await endpointClient.CheckAsync(service, cancellationToken); + var result = service.MonitorType switch + { + MonitorType.KurrentDb => await kurrentDbClient.CheckAsync(service, cancellationToken), + MonitorType.SqlServer => await sqlServerClient.CheckAsync(service, cancellationToken), + _ => await endpointClient.CheckAsync(service, cancellationToken) + }; var observation = ToObservation(service, result, now); await repository.AddObservationAsync(observation, cancellationToken); await calculator.ApplyAsync(service, observation, cancellationToken); diff --git a/HealthMonitoring/Monitoring/InitializationService.cs b/HealthMonitoring/Monitoring/InitializationService.cs new file mode 100644 index 0000000..41eecd8 --- /dev/null +++ b/HealthMonitoring/Monitoring/InitializationService.cs @@ -0,0 +1,107 @@ +using HealthMonitoring.Api; +using HealthMonitoring.Domain; +using HealthMonitoring.Persistence; + +namespace HealthMonitoring.Monitoring; + +public sealed class InitializationOptions +{ + public bool RequireSqlServer { get; init; } = true; + public bool RequireKurrentDb { get; init; } = true; +} + +public sealed record InitializationStatus(bool Required, bool Complete, bool RequireSqlServer, bool RequireKurrentDb, bool SqlServerConfigured, bool KurrentDbConfigured); + +public sealed class InitializationRequest +{ + public InitializationRequest() { } + public InitializationRequest(string sqlServerConnectionString, string kurrentDbConnectionString) + { + SqlServerConnectionString = sqlServerConnectionString; + KurrentDbConnectionString = kurrentDbConnectionString; + } + + public string SqlServerConnectionString { get; set; } = string.Empty; + public string KurrentDbConnectionString { get; set; } = string.Empty; + public bool TestConnections { get; set; } = true; +} + +public static class InitializationStateCalculator +{ + public static bool IsComplete(IEnumerable services, bool requireSqlServer = true, bool requireKurrentDb = true) + { + var activeTypes = services + .Where(service => service.ArchivedAtUtc is null) + .Select(service => service.MonitorType) + .ToHashSet(); + return (!requireSqlServer || activeTypes.Contains(MonitorType.SqlServer)) + && (!requireKurrentDb || activeTypes.Contains(MonitorType.KurrentDb)); + } +} + +public interface IInitializationService +{ + Task GetStatusAsync(CancellationToken cancellationToken); + Task InitializeAsync(InitializationRequest request, string dashboardEnvironment, CancellationToken cancellationToken); +} + +public sealed class InitializationService( + IHealthMonitoringRepository repository, + InitializationOptions options, + SqlServerMonitorClient sqlServerClient, + KurrentDbMonitorClient kurrentDbClient) : IInitializationService +{ + public async Task GetStatusAsync(CancellationToken cancellationToken) + { + var services = await repository.ListServicesAsync(false, cancellationToken); + var sqlConfigured = services.Any(service => service.MonitorType == MonitorType.SqlServer); + var kurrentConfigured = services.Any(service => service.MonitorType == MonitorType.KurrentDb); + var required = options.RequireSqlServer || options.RequireKurrentDb; + return new InitializationStatus(required, InitializationStateCalculator.IsComplete(services, options.RequireSqlServer, options.RequireKurrentDb), options.RequireSqlServer, options.RequireKurrentDb, sqlConfigured, kurrentConfigured); + } + + public async Task InitializeAsync(InitializationRequest request, string dashboardEnvironment, CancellationToken cancellationToken) + { + if (options.RequireSqlServer && string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) throw new ArgumentException("SQL Server connection string is required.", nameof(request)); + if (options.RequireKurrentDb && string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) throw new ArgumentException("KurrentDB connection string is required.", nameof(request)); + + if (request.TestConnections && !string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) + { + var sqlServer = ServiceConfigurationValidator.ToService(new ServiceRegistrationRequest + { + ServiceId = "sql-server", + Name = "SQL Server", + MonitorType = MonitorType.SqlServer, + ConnectionString = request.SqlServerConnectionString + }, dashboardEnvironment); + var sqlResult = await sqlServerClient.CheckAsync(sqlServer, cancellationToken); + if (sqlResult.Normalized.Status == HealthStatus.Unhealthy) throw new ArgumentException($"SQL Server connection test failed: {sqlResult.Normalized.Error}"); + } + + if (request.TestConnections && !string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) + { + var kurrentDb = ServiceConfigurationValidator.ToService(new ServiceRegistrationRequest + { + ServiceId = "kurrentdb", + Name = "KurrentDB", + MonitorType = MonitorType.KurrentDb, + ConnectionString = request.KurrentDbConnectionString + }, dashboardEnvironment); + var kurrentResult = await kurrentDbClient.CheckAsync(kurrentDb, cancellationToken); + if (kurrentResult.Normalized.Status == HealthStatus.Unhealthy) throw new ArgumentException($"KurrentDB connection test failed: {kurrentResult.Normalized.Error}"); + } + + if (!string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) + await SaveAsync(new ServiceRegistrationRequest { ServiceId = "sql-server", Name = "SQL Server", MonitorType = MonitorType.SqlServer, ConnectionString = request.SqlServerConnectionString, PollingIntervalSeconds = 60, RequestTimeoutSeconds = 10, RetentionDays = 365 }, dashboardEnvironment, cancellationToken); + if (!string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) + await SaveAsync(new ServiceRegistrationRequest { ServiceId = "kurrentdb", Name = "KurrentDB", MonitorType = MonitorType.KurrentDb, ConnectionString = request.KurrentDbConnectionString, PollingIntervalSeconds = 60, RequestTimeoutSeconds = 10, RetentionDays = 365 }, dashboardEnvironment, cancellationToken); + } + + private async Task SaveAsync(ServiceRegistrationRequest request, string dashboardEnvironment, CancellationToken cancellationToken) + { + var errors = ServiceConfigurationValidator.Validate(request); + if (errors.Count > 0) throw new ArgumentException(string.Join(" ", errors)); + var existing = await repository.GetServiceAsync(request.ServiceId, cancellationToken); + await repository.UpsertServiceAsync(ServiceConfigurationValidator.ToService(request, dashboardEnvironment, existing, preserveManagedSettings: false), cancellationToken); + } +} diff --git a/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs b/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs new file mode 100644 index 0000000..430ede7 --- /dev/null +++ b/HealthMonitoring/Monitoring/KurrentDbMonitorClient.cs @@ -0,0 +1,73 @@ +using System.Diagnostics; +using System.Text.Json; +using EventStore.Client; +using KurrentDB.Client; +using HealthMonitoring.Domain; + +namespace HealthMonitoring.Monitoring; + +public interface IKurrentDbProbe +{ + Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken); +} + +public sealed class KurrentDbMonitorClient(IKurrentDbProbe probe) : IHealthEndpointClient +{ + public async Task CheckAsync(MonitoredService service, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await probe.ProbeAsync(service, cancellationToken); + stopwatch.Stop(); + var check = new NormalizedCheckResult("KurrentDB", HealthStatus.Healthy, "KurrentDB is reachable.", stopwatch.Elapsed, null, new Dictionary()); + var raw = JsonSerializer.Serialize(new { status = nameof(HealthStatus.Healthy), totalDuration = stopwatch.Elapsed, entries = new { KurrentDB = new { status = nameof(HealthStatus.Healthy), duration = stopwatch.Elapsed, data = new { } } } }); + return new HealthEndpointResult(new NormalizedHealthResult(HealthStatus.Healthy, nameof(HealthStatus.Healthy), [check], RawResponse: raw), null, stopwatch.Elapsed, raw); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + stopwatch.Stop(); + return Failure(service, stopwatch.Elapsed, "KurrentDB health check timed out."); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + stopwatch.Stop(); + return Failure(service, stopwatch.Elapsed, exception.Message, exception); + } + } + + private static HealthEndpointResult Failure(MonitoredService service, TimeSpan duration, string error, Exception? exception = null) + { + var status = string.Equals(service.StatusPolicy.EndpointFailureStatus, nameof(HealthStatus.Degraded), StringComparison.OrdinalIgnoreCase) + ? HealthStatus.Degraded + : HealthStatus.Unhealthy; + var check = new NormalizedCheckResult("KurrentDB", status, error, duration, exception?.ToString(), new Dictionary()); + var raw = JsonSerializer.Serialize(new { status = status.ToString(), totalDuration = duration, entries = new { KurrentDB = new { status = status.ToString(), duration, exception = exception?.ToString(), data = new { } } } }); + return new HealthEndpointResult(new NormalizedHealthResult(status, status.ToString(), [check], error, raw), null, duration, raw); + } +} + +internal sealed class KurrentDbProbe : IKurrentDbProbe +{ + public async Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(service.ConnectionString)) throw new InvalidOperationException("KurrentDB connection string is not configured."); + + var settings = KurrentDBClientSettings.Create(service.ConnectionString); + using var client = new KurrentDBClient(settings); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(service.RequestTimeout); + + await foreach (var _ in client.ReadAllAsync( + Direction.Forwards, + Position.Start, + maxCount: 1, + resolveLinkTos: false, + deadline: service.RequestTimeout, + userCredentials: null, + cancellationToken: timeoutSource.Token).WithCancellation(timeoutSource.Token)) + { + break; + } + } +} diff --git a/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs b/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs new file mode 100644 index 0000000..613506c --- /dev/null +++ b/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs @@ -0,0 +1,64 @@ +using System.Diagnostics; +using System.Text.Json; +using HealthMonitoring.Domain; +using Microsoft.Data.SqlClient; + +namespace HealthMonitoring.Monitoring; + +public interface ISqlServerProbe +{ + Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken); +} + +public sealed class SqlServerMonitorClient(ISqlServerProbe probe) : IHealthEndpointClient +{ + public async Task CheckAsync(MonitoredService service, CancellationToken cancellationToken) + { + var stopwatch = Stopwatch.StartNew(); + try + { + await probe.ProbeAsync(service, cancellationToken); + stopwatch.Stop(); + var check = new NormalizedCheckResult("SQL Server", HealthStatus.Healthy, "SQL Server is reachable.", stopwatch.Elapsed, null, new Dictionary()); + var raw = JsonSerializer.Serialize(new { status = nameof(HealthStatus.Healthy), totalDuration = stopwatch.Elapsed, entries = new { SqlServer = new { status = nameof(HealthStatus.Healthy), duration = stopwatch.Elapsed, data = new { } } } }); + return new HealthEndpointResult(new NormalizedHealthResult(HealthStatus.Healthy, nameof(HealthStatus.Healthy), [check], RawResponse: raw), null, stopwatch.Elapsed, raw); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + stopwatch.Stop(); + return Failure(service, stopwatch.Elapsed, "SQL Server health check timed out."); + } + catch (Exception exception) when (!cancellationToken.IsCancellationRequested) + { + stopwatch.Stop(); + return Failure(service, stopwatch.Elapsed, exception.Message, exception); + } + } + + private static HealthEndpointResult Failure(MonitoredService service, TimeSpan duration, string error, Exception? exception = null) + { + var status = string.Equals(service.StatusPolicy.EndpointFailureStatus, nameof(HealthStatus.Degraded), StringComparison.OrdinalIgnoreCase) + ? HealthStatus.Degraded + : HealthStatus.Unhealthy; + var check = new NormalizedCheckResult("SQL Server", status, error, duration, exception?.ToString(), new Dictionary()); + var raw = JsonSerializer.Serialize(new { status = status.ToString(), totalDuration = duration, entries = new { SqlServer = new { status = status.ToString(), duration, exception = exception?.ToString(), data = new { } } } }); + return new HealthEndpointResult(new NormalizedHealthResult(status, status.ToString(), [check], error, raw), null, duration, raw); + } +} + +internal sealed class SqlServerProbe : ISqlServerProbe +{ + public async Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(service.ConnectionString)) throw new InvalidOperationException("SQL Server connection string is not configured."); + + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(service.RequestTimeout); + await using var connection = new SqlConnection(service.ConnectionString); + await connection.OpenAsync(timeoutSource.Token); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1"; + command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(service.RequestTimeout.TotalSeconds)); + await command.ExecuteScalarAsync(timeoutSource.Token); + } +} diff --git a/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs b/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs index 8d154ca..05c9434 100644 --- a/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs +++ b/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs @@ -1,15 +1,25 @@ using HealthMonitoring.Domain; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; namespace HealthMonitoring.Persistence; public sealed class HealthMonitoringDbContext(DbContextOptions options) : DbContext(options) { + private static readonly ValueConverter TimeSpanTicksConverter = new( + value => value.Ticks, + value => TimeSpan.FromTicks(value)); + + private static readonly ValueConverter NullableTimeSpanTicksConverter = new( + value => value.HasValue ? value.Value.Ticks : null, + value => value.HasValue ? TimeSpan.FromTicks(value.Value) : null); + public DbSet MonitoredServices => Set(); public DbSet HealthObservations => Set(); public DbSet HealthCheckResults => Set(); public DbSet ServiceIncidents => Set(); public DbSet ServiceStatusSnapshots => Set(); + public DbSet ServiceDependencyLinks => Set(); protected override void OnModelCreating(ModelBuilder modelBuilder) { @@ -20,8 +30,13 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(service => service.ServiceId).HasMaxLength(200).IsRequired(); entity.Property(service => service.Name).HasMaxLength(300).IsRequired(); entity.Property(service => service.Environment).HasMaxLength(100).IsRequired(); + entity.Property(service => service.MonitorType).HasConversion().HasMaxLength(50).IsRequired(); entity.Property(service => service.HealthUrl).HasConversion().HasMaxLength(2048).IsRequired(); + entity.Property(service => service.ConnectionString).HasColumnType("nvarchar(max)"); entity.Property(service => service.StatusPolicyJson).HasColumnType("nvarchar(max)").IsRequired(); + entity.Property(service => service.PollingInterval).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); + entity.Property(service => service.RequestTimeout).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); + entity.Property(service => service.RetentionPeriod).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); entity.Ignore(service => service.StatusPolicy); }); @@ -31,6 +46,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(observation => new { observation.MonitoredServiceId, observation.ObservedAtUtc }); entity.Property(observation => observation.Status).HasConversion().HasMaxLength(20); entity.Property(observation => observation.AggregateStatus).HasMaxLength(50).IsRequired(); + entity.Property(observation => observation.ResponseDuration).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); entity.Property(observation => observation.RawResponseJson).HasColumnType("nvarchar(max)"); entity.Property(observation => observation.Error).HasColumnType("nvarchar(max)"); entity.HasMany(observation => observation.Checks) @@ -44,6 +60,7 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(checkResult => checkResult.Id); entity.Property(checkResult => checkResult.Name).HasMaxLength(300).IsRequired(); entity.Property(checkResult => checkResult.Status).HasConversion().HasMaxLength(20); + entity.Property(checkResult => checkResult.Duration).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); entity.Property(checkResult => checkResult.DataJson).HasColumnType("nvarchar(max)"); entity.Property(checkResult => checkResult.Exception).HasColumnType("nvarchar(max)"); }); @@ -61,7 +78,23 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasKey(snapshot => snapshot.MonitoredServiceId); entity.Property(snapshot => snapshot.Status).HasConversion().HasMaxLength(20); + entity.Property(snapshot => snapshot.LastResponseDuration).HasConversion(NullableTimeSpanTicksConverter).HasColumnType("bigint"); entity.Property(snapshot => snapshot.LastError).HasColumnType("nvarchar(max)"); }); + + modelBuilder.Entity(entity => + { + entity.HasKey(link => link.Id); + entity.HasIndex(link => new { link.MonitoredServiceId, link.DependencyName }).IsUnique(); + entity.Property(link => link.DependencyName).HasMaxLength(300).IsRequired(); + entity.HasOne() + .WithMany() + .HasForeignKey(link => link.MonitoredServiceId) + .OnDelete(DeleteBehavior.Restrict); + entity.HasOne() + .WithMany() + .HasForeignKey(link => link.TargetMonitoredServiceId) + .OnDelete(DeleteBehavior.Restrict); + }); } } diff --git a/HealthMonitoring/Persistence/HealthMonitoringRepository.cs b/HealthMonitoring/Persistence/HealthMonitoringRepository.cs index 859d59e..7f6236b 100644 --- a/HealthMonitoring/Persistence/HealthMonitoringRepository.cs +++ b/HealthMonitoring/Persistence/HealthMonitoringRepository.cs @@ -38,9 +38,12 @@ public async Task UpsertServiceAsync(MonitoredService service, { existing.Name = service.Name; existing.Environment = service.Environment; + existing.MonitorType = service.MonitorType; existing.Group = service.Group; existing.Description = service.Description; existing.HealthUrl = service.HealthUrl; + existing.ConnectionString = service.ConnectionString; + existing.IgnoreCertificateErrors = service.IgnoreCertificateErrors; existing.IsEnabled = service.IsEnabled; existing.PollingInterval = service.PollingInterval; existing.RequestTimeout = service.RequestTimeout; @@ -125,6 +128,42 @@ await dbContext.HealthObservations .Where(observation => observation.MonitoredServiceId == serviceId && observation.ObservedAtUtc < cutoffUtc) .ExecuteDeleteAsync(cancellationToken); + public async Task> ListDependencyLinksAsync(Guid serviceId, CancellationToken cancellationToken) => + await dbContext.ServiceDependencyLinks.AsNoTracking() + .Where(link => link.MonitoredServiceId == serviceId) + .OrderBy(link => link.DependencyName) + .ToListAsync(cancellationToken); + + public async Task UpsertDependencyLinkAsync(ServiceDependencyLink link, CancellationToken cancellationToken) + { + var existing = await dbContext.ServiceDependencyLinks.SingleOrDefaultAsync( + item => item.Id == link.Id, + cancellationToken); + + if (existing is null) + { + dbContext.ServiceDependencyLinks.Add(link); + } + else + { + existing.MonitoredServiceId = link.MonitoredServiceId; + existing.DependencyName = link.DependencyName; + existing.TargetMonitoredServiceId = link.TargetMonitoredServiceId; + existing.UpdatedAtUtc = link.UpdatedAtUtc; + link = existing; + } + + await dbContext.SaveChangesAsync(cancellationToken); + return link; + } + + public async Task DeleteDependencyLinkAsync(Guid linkId, CancellationToken cancellationToken) + { + await dbContext.ServiceDependencyLinks + .Where(link => link.Id == linkId) + .ExecuteDeleteAsync(cancellationToken); + } + private static MonitoredService? HydratePolicy(MonitoredService? service) { if (service is null) diff --git a/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs new file mode 100644 index 0000000..acda5b4 --- /dev/null +++ b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs @@ -0,0 +1,113 @@ +using Microsoft.EntityFrameworkCore; + +#pragma warning disable EF1002 + +namespace HealthMonitoring.Persistence; + +public static class HealthMonitoringSchemaInitializer +{ + private static readonly LegacyDurationColumn[] LegacyDurationColumns = + [ + new("MonitoredServices", "PollingInterval", Nullable: false), + new("MonitoredServices", "RequestTimeout", Nullable: false), + new("MonitoredServices", "RetentionPeriod", Nullable: false), + new("HealthObservations", "ResponseDuration", Nullable: false), + new("HealthCheckResults", "Duration", Nullable: false), + new("ServiceStatusSnapshots", "LastResponseDuration", Nullable: true) + ]; + + public static async Task InitializeAsync(HealthMonitoringDbContext dbContext, CancellationToken cancellationToken = default) + { + await dbContext.Database.EnsureCreatedAsync(cancellationToken); + + foreach (var column in LegacyDurationColumns) + await ConvertLegacyColumnAsync(dbContext, column, cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[ServiceDependencyLinks]', N'U') IS NULL + BEGIN + CREATE TABLE [dbo].[ServiceDependencyLinks] + ( + [Id] uniqueidentifier NOT NULL, + [MonitoredServiceId] uniqueidentifier NOT NULL, + [DependencyName] nvarchar(300) NOT NULL, + [TargetMonitoredServiceId] uniqueidentifier NOT NULL, + [CreatedAtUtc] datetimeoffset(7) NOT NULL, + [UpdatedAtUtc] datetimeoffset(7) NOT NULL, + CONSTRAINT [PK_ServiceDependencyLinks] PRIMARY KEY ([Id]), + CONSTRAINT [FK_ServiceDependencyLinks_MonitoredServices_Parent] FOREIGN KEY ([MonitoredServiceId]) REFERENCES [dbo].[MonitoredServices] ([Id]), + CONSTRAINT [FK_ServiceDependencyLinks_MonitoredServices_Target] FOREIGN KEY ([TargetMonitoredServiceId]) REFERENCES [dbo].[MonitoredServices] ([Id]) + ); + CREATE UNIQUE INDEX [IX_ServiceDependencyLinks_MonitoredServiceId_DependencyName] + ON [dbo].[ServiceDependencyLinks] ([MonitoredServiceId], [DependencyName]); + END; + """, cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'MonitorType') + ALTER TABLE [dbo].[MonitoredServices] + ADD [MonitorType] nvarchar(50) NOT NULL CONSTRAINT [DF_MonitoredServices_MonitorType] DEFAULT (N'HttpHealthEndpoint'); + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'ConnectionString') + ALTER TABLE [dbo].[MonitoredServices] + ADD [ConnectionString] nvarchar(max) NULL; + """, cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'IgnoreCertificateErrors') + ALTER TABLE [dbo].[MonitoredServices] + ADD [IgnoreCertificateErrors] bit NOT NULL CONSTRAINT [DF_MonitoredServices_IgnoreCertificateErrors] DEFAULT (0); + """, cancellationToken); + } + + private static async Task ConvertLegacyColumnAsync( + HealthMonitoringDbContext dbContext, + LegacyDurationColumn column, + CancellationToken cancellationToken) + { + var table = $"[dbo].[{column.TableName}]"; + var oldColumn = $"[{column.ColumnName}]"; + var temporaryColumn = $"[__{column.ColumnName}Ticks]"; + + await dbContext.Database.ExecuteSqlRawAsync($""" + IF OBJECT_ID(N'{table}', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) + AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') + ALTER TABLE {table} ADD {temporaryColumn} bigint NULL; + """, cancellationToken); + + await dbContext.Database.ExecuteSqlRawAsync($""" + IF OBJECT_ID(N'{table}', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') + UPDATE {table} + SET {temporaryColumn} = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), {oldColumn}) / 100; + """, cancellationToken); + + if (!column.Nullable) + { + await dbContext.Database.ExecuteSqlRawAsync($""" + IF OBJECT_ID(N'{table}', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') + ALTER TABLE {table} ALTER COLUMN {temporaryColumn} bigint NOT NULL; + """, cancellationToken); + } + + await dbContext.Database.ExecuteSqlRawAsync($""" + IF OBJECT_ID(N'{table}', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') + BEGIN + ALTER TABLE {table} DROP COLUMN {oldColumn}; + EXEC sp_rename N'dbo.{column.TableName}.__{column.ColumnName}Ticks', N'{column.ColumnName}', N'COLUMN'; + END; + """, cancellationToken); + } + + private sealed record LegacyDurationColumn(string TableName, string ColumnName, bool Nullable); +} + +#pragma warning restore EF1002 diff --git a/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs b/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs index 47753d4..1c19415 100644 --- a/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs +++ b/HealthMonitoring/Persistence/IHealthMonitoringRepository.cs @@ -16,4 +16,7 @@ public interface IHealthMonitoringRepository Task AddIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken); Task UpdateIncidentAsync(ServiceIncident incident, CancellationToken cancellationToken); Task DeleteObservationsBeforeAsync(Guid serviceId, DateTimeOffset cutoffUtc, CancellationToken cancellationToken); + Task> ListDependencyLinksAsync(Guid serviceId, CancellationToken cancellationToken); + Task UpsertDependencyLinkAsync(ServiceDependencyLink link, CancellationToken cancellationToken); + Task DeleteDependencyLinkAsync(Guid linkId, CancellationToken cancellationToken); } diff --git a/HealthMonitoring/Program.cs b/HealthMonitoring/Program.cs index 9b24e64..1a7e3fc 100644 --- a/HealthMonitoring/Program.cs +++ b/HealthMonitoring/Program.cs @@ -10,6 +10,11 @@ var builder = WebApplication.CreateBuilder(args); builder.Configuration.AddJsonFile("hosting.json", optional: true, reloadOnChange: true); +builder.Services.AddSingleton(new InitializationOptions +{ + RequireSqlServer = builder.Configuration.GetValue("Initialization:RequireSqlServer", true), + RequireKurrentDb = builder.Configuration.GetValue("Initialization:RequireKurrentDb", true) +}); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("HealthMonitoring"))); @@ -17,6 +22,7 @@ builder.Services.AddHttpClient(); builder.Services.AddRazorComponents().AddInteractiveServerComponents(); builder.Services.AddScoped(); +builder.Services.AddScoped(); builder.Services.AddHealthChecks().AddCheck("sql-server"); var app = builder.Build(); @@ -26,11 +32,14 @@ var connectionString = app.Configuration.GetConnectionString("HealthMonitoring"); if (!string.IsNullOrWhiteSpace(connectionString)) { - await scope.ServiceProvider.GetRequiredService().Database.EnsureCreatedAsync(); + await HealthMonitoringSchemaInitializer.InitializeAsync( + scope.ServiceProvider.GetRequiredService()); } } -app.MapServiceRegistrationEndpoints(); +app.MapServiceRegistrationEndpoints(app.Environment.EnvironmentName); +app.MapDependencyMappingEndpoints(); +app.MapInitializationEndpoints(app.Environment.EnvironmentName); app.MapHealthChecks("/health"); app.UseStaticFiles(); app.UseAntiforgery(); diff --git a/HealthMonitoring/Reporting/DashboardModels.cs b/HealthMonitoring/Reporting/DashboardModels.cs index f3b220b..2c226f8 100644 --- a/HealthMonitoring/Reporting/DashboardModels.cs +++ b/HealthMonitoring/Reporting/DashboardModels.cs @@ -4,4 +4,5 @@ namespace HealthMonitoring.Reporting; public sealed record DashboardOverview(IReadOnlyList Services, int Healthy, int Degraded, int Unhealthy, int OpenIncidents); public sealed record ServiceDashboardRow(string ServiceId, string Name, string Environment, HealthStatus Status, TimeSpan? ResponseDuration, double UptimePercent, DateTimeOffset? LastChecked, string? LastError); -public sealed record ServiceDetailModel(MonitoredService Service, ServiceDashboardRow Summary, IReadOnlyList Observations, IReadOnlyList Incidents, IReadOnlyList Checks); +public sealed record ResolvedDependencyLink(string DependencyName, string TargetServiceId, string TargetServiceName); +public sealed record ServiceDetailModel(MonitoredService Service, ServiceDashboardRow Summary, IReadOnlyList Observations, IReadOnlyList Incidents, IReadOnlyList Checks, IReadOnlyList DependencyLinks); diff --git a/HealthMonitoring/Reporting/DashboardQueryService.cs b/HealthMonitoring/Reporting/DashboardQueryService.cs index e6cfdb4..cfc2073 100644 --- a/HealthMonitoring/Reporting/DashboardQueryService.cs +++ b/HealthMonitoring/Reporting/DashboardQueryService.cs @@ -37,7 +37,11 @@ public async Task GetOverviewAsync(TimeSpan range, Cancellati var incidents = await dbContext.ServiceIncidents.AsNoTracking().Where(item => item.MonitoredServiceId == service.Id && (item.EndedAtUtc == null || item.EndedAtUtc >= from)).OrderByDescending(item => item.StartedAtUtc).ToListAsync(cancellationToken); var snapshot = await dbContext.ServiceStatusSnapshots.AsNoTracking().SingleOrDefaultAsync(item => item.MonitoredServiceId == service.Id, cancellationToken); var checks = observations.SelectMany(item => item.Checks).ToArray(); - return new ServiceDetailModel(service, ToRow(service, snapshot, observations), observations, incidents, checks); + var dependencyLinks = await dbContext.ServiceDependencyLinks.AsNoTracking() + .Where(link => link.MonitoredServiceId == service.Id) + .Join(dbContext.MonitoredServices.AsNoTracking(), link => link.TargetMonitoredServiceId, target => target.Id, (link, target) => new ResolvedDependencyLink(link.DependencyName, target.ServiceId, target.Name)) + .ToListAsync(cancellationToken); + return new ServiceDetailModel(service, ToRow(service, snapshot, observations), observations, incidents, checks, dependencyLinks); } private static ServiceDashboardRow ToRow(MonitoredService service, ServiceStatusSnapshot? snapshot, IReadOnlyList observations) diff --git a/HealthMonitoring/appsettings.Development.json b/HealthMonitoring/appsettings.Development.json index bde607e..7771a13 100644 --- a/HealthMonitoring/appsettings.Development.json +++ b/HealthMonitoring/appsettings.Development.json @@ -1,5 +1,5 @@ { "ConnectionStrings": { - "HealthMonitoring": "Server=.;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True;Encrypt=False;" + "HealthMonitoring": "Server=localhost;Database=HealthMonitoring;User Id=sa;Password=sp1ttal;TrustServerCertificate=True;Encrypt=False;" } } diff --git a/HealthMonitoring/appsettings.json b/HealthMonitoring/appsettings.json index 3b7aa8c..822932b 100644 --- a/HealthMonitoring/appsettings.json +++ b/HealthMonitoring/appsettings.json @@ -1,11 +1,15 @@ { "ConnectionStrings": { - "HealthMonitoring": "Server=.;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True;Encrypt=False;" + //"HealthMonitoring": "Server=.;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True;Encrypt=False;" }, "Logging": { "LogLevel": { "Default": "Information", "Microsoft.AspNetCore": "Warning" } + }, + "Initialization": { + "RequireSqlServer": true, + "RequireKurrentDb": true } } diff --git a/HealthMonitoring/wwwroot/app.css b/HealthMonitoring/wwwroot/app.css index 99f33b2..59b5d3e 100644 --- a/HealthMonitoring/wwwroot/app.css +++ b/HealthMonitoring/wwwroot/app.css @@ -4,8 +4,8 @@ body { margin: 0; } a { color: inherit; text-decoration: none; } .shell { display: flex; min-height: 100vh; } .sidebar { width: 230px; background: #17212b; color: #d7e1e5; padding: 28px 18px; display: flex; flex-direction: column; } -.brand { color: #fff; font-size: 20px; font-weight: 800; margin: 0 12px 34px; } -nav { display: grid; gap: 6px; } nav a { padding: 11px 12px; border-radius: 8px; } nav a.active, nav a:hover { background: #263946; color: #fff; } +.brand { color: #fff; font-size: 20px; font-weight: 800; margin: 0 12px 25px; } .brand small { display: block; color: #91aebe; font-size: 10px; font-weight: 600; letter-spacing: .1em; margin-top: 7px; text-transform: uppercase; } +.nav-section-label { color: #718b9b; font-size: 10px; font-weight: 800; letter-spacing: .12em; margin: 0 12px 8px; text-transform: uppercase; } nav { display: grid; gap: 6px; margin-bottom: 25px; } nav a, .nav-future { align-items: center; border-radius: 8px; display: flex; justify-content: space-between; padding: 11px 12px; } nav a.active, nav a:hover { background: #263946; color: #fff; } .nav-future { color: #8297a7; cursor: default; } .nav-future span { border: 1px solid #4d6779; border-radius: 10px; color: #9fb3c0; font-size: 10px; padding: 3px 7px; } .sidebar-note { margin-top: auto; font-size: 12px; color: #91a3ad; line-height: 1.5; padding: 12px; } .main-content { max-width: 1280px; width: 100%; padding: 42px 48px; margin: 0 auto; } .page-heading { display: flex; justify-content: space-between; align-items: end; gap: 20px; margin-bottom: 28px; } h1 { margin: 4px 0; font-size: 32px; } h2 { font-size: 18px; margin: 0 0 18px; } .eyebrow { color: #6d7e88; font-size: 11px; letter-spacing: .12em; font-weight: 800; margin: 0; } .muted { color: #71818a; } @@ -17,4 +17,7 @@ nav { display: grid; gap: 6px; } nav a { padding: 11px 12px; border-radius: 8px; .detail-status { display: flex; gap: 16px; align-items: center; margin: -12px 0 20px; color: #71818a; } .timeline { display: flex; gap: 2px; height: 28px; } .timeline-segment { flex: 1; border-radius: 4px; min-width: 3px; } .timeline-segment.healthy { background: #39c77c; } .timeline-segment.degraded { background: #e2aa3c; } .timeline-segment.unhealthy { background: #e14d5c; } .timeline-segment.unknown { background: #a7b2b8; } .check-table .service-row { grid-template-columns: 1.5fr .8fr 1fr 2fr; } details summary { cursor: pointer; font-weight: 800; } pre { white-space: pre-wrap; overflow: auto; background: #17212b; color: #d7e1e5; padding: 16px; border-radius: 8px; } .form { max-width: 760px; display: grid; gap: 16px; } label { display: grid; gap: 7px; font-size: 13px; font-weight: 700; } input { border: 1px solid #ccd7dc; border-radius: 7px; padding: 11px; font: inherit; } .form-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 14px; } .error { color: #c52f40; } +.back-link, .dependency-link, .link-button { color: #2563d8; font-weight: 700; } .back-link { display: inline-block; margin-bottom: 20px; } .mapping-panel { max-width: 1100px; } .mapping-heading, .mapping-row { display: grid; grid-template-columns: 1fr 1fr 180px; gap: 18px; align-items: center; } .mapping-heading { padding: 0 12px 12px; color: #71818a; font-size: 11px; font-weight: 800; text-transform: uppercase; } .mapping-row { padding: 15px 12px; border-top: 1px solid #edf0f1; } .mapping-row a:hover, .dependency-link:hover, .back-link:hover { text-decoration: underline; } .mapping-actions { display: flex; gap: 12px; align-items: center; } .link-button, .danger-button { border: 0; background: none; cursor: pointer; font: inherit; padding: 0; } .danger-button { color: #c52f40; } .mapping-form { border-top: 1px solid #edf0f1; margin-top: 18px; padding-top: 22px; } .mapping-form-grid { display: grid; grid-template-columns: 1fr 1fr auto; gap: 14px; align-items: end; } select { border: 1px solid #ccd7dc; border-radius: 7px; padding: 11px; font: inherit; background: #fff; } .mapping-form-actions { display: flex; gap: 8px; } .mapping-note { background: #f1f6ff; color: #52657b; border-radius: 8px; padding: 13px 15px; margin: 20px 0 0; } @media (max-width: 900px) { .sidebar { width: 180px; } .main-content { padding: 28px 20px; } .stat-grid { grid-template-columns: repeat(2, 1fr); } .table-heading { display: none; } .service-row { grid-template-columns: 2fr 1fr 1fr; } .service-row > :nth-child(4), .service-row > :nth-child(5) { display: none; } } +.initialise-page { max-width: 1100px; margin: 0 auto; } .initialise-breadcrumb { color: #2563d8; font-size: 14px; margin-bottom: 28px; } .initialise-breadcrumb span { color: #8b9aa3; margin: 0 10px; } .initialise-heading { margin-bottom: 24px; } .initialise-heading h1 { font-size: 40px; margin: 6px 0; } .wizard-steps { display: flex; align-items: center; gap: 12px; margin: 24px auto 28px; max-width: 560px; color: #71818a; } .wizard-step { width: 38px; height: 38px; border-radius: 50%; background: #e7edf1; display: grid; place-items: center; font-weight: 800; color: #52657b; } .wizard-step.active { background: #2563d8; color: #fff; } .wizard-line { height: 1px; background: #ccd7dc; flex: 1; } .initialise-notice { background: #e9f3ff; border: 1px solid #c5defb; color: #1457ad; border-radius: 9px; padding: 15px 18px; display: flex; gap: 12px; align-items: center; margin-bottom: 20px; } .monitor-cards { display: grid; gap: 20px; } .monitor-card { background: #fff; border-radius: 12px; padding: 22px; box-shadow: 0 2px 10px #23323c0b; display: flex; gap: 24px; align-items: flex-start; } .monitor-icon { width: 68px; height: 68px; border-radius: 12px; background: #f0f5fa; display: grid; place-items: center; color: #172d4c; font-size: 35px; flex: 0 0 auto; } .monitor-card-body { flex: 1; } .monitor-card-title { display: flex; align-items: center; gap: 12px; } .monitor-card-title h2 { margin: 0; } .required-badge { background: #dcecff; color: #1457ad; border-radius: 16px; padding: 5px 11px; font-size: 12px; font-weight: 800; } .monitor-state { color: #71818a; margin: 8px 0 18px; } .initialise-footer { display: flex; justify-content: space-between; align-items: center; gap: 20px; border-top: 1px solid #dfe6e9; margin-top: 28px; padding-top: 22px; } .checkbox-label { display: flex; align-items: center; gap: 8px; } .checkbox-label input { accent-color: #2563d8; } .initialise-actions { display: flex; gap: 12px; } .initialise-footnote { color: #71818a; font-size: 13px; margin-top: 20px; } +.initialisation-overview { max-width: 900px; margin: 0 auto; } .initialisation-cards { display: grid; gap: 14px; } .initialisation-card { align-items: flex-start; background: #fff; border: 1px solid #e1e8eb; border-radius: 12px; box-shadow: 0 2px 10px #23323c0b; display: flex; gap: 18px; padding: 20px; position: relative; } .initialisation-card.configured { border-color: #39c77c; } .initialisation-card.future { color: #52657b; } .initialisation-icon { align-items: center; background: #eef4f8; border-radius: 10px; color: #2563d8; display: flex; flex: 0 0 auto; font-size: 30px; height: 58px; justify-content: center; width: 58px; } .initialisation-card-body { flex: 1; } .initialisation-card-heading { align-items: center; display: flex; gap: 12px; } .initialisation-card-heading h2 { margin: 0; } .initialisation-card p { color: #71818a; margin: 6px 0 12px; } .initialisation-status { border-radius: 16px; font-size: 12px; font-weight: 800; padding: 5px 11px; } .initialisation-status.complete { background: #dff7e9; color: #15975a; } .initialisation-status.required { background: #ffe4e7; color: #c52f40; } .initialisation-status.soon { background: #eef1f3; color: #71818a; } .initialisation-card-action { color: #2563d8; font-weight: 800; } .initialisation-card-action span, .initialisation-card-arrow { font-size: 20px; margin-left: 7px; } .initialisation-card-arrow { color: #71818a; } .initialisation-footer { align-items: center; border-top: 1px solid #dfe6e9; display: flex; justify-content: space-between; margin-top: 28px; padding-top: 22px; } diff --git a/docs/superpowers/plans/2026-09-18-dependency-mapping-implementation.md b/docs/superpowers/plans/2026-09-18-dependency-mapping-implementation.md new file mode 100644 index 0000000..359336c --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-dependency-mapping-implementation.md @@ -0,0 +1,89 @@ +# Per-Service Dependency Mapping Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task with review checkpoints. + +**Goal:** Store per-service dependency links and render mapped health checks as drill-down links in the Blazor dashboard. + +**Architecture:** Add a `ServiceDependencyLink` entity with a unique parent-service/dependency-name key and a target monitored-service foreign key. Extend the existing repository and dashboard query model, add a scoped Blazor management page, and render links from the existing service detail dependency table. + +**Tech Stack:** ASP.NET Core, Blazor Server, EF Core, SQL Server, xUnit. + +**Spec:** `docs/superpowers/specs/2026-09-18-dependency-mapping-design.md` + +## Global Constraints + +- Keep authentication out of scope. +- Store mappings in SQL Server. +- Keep the self-registration API unchanged. +- Do not delete health history when a mapping is deleted. +- Use the existing repository, Razor, and test conventions. + +--- + +### Task 1: Add the dependency-link domain and persistence model + +**Files:** +- Create: `HealthMonitoring/Domain/ServiceDependencyLink.cs` +- Modify: `HealthMonitoring/Persistence/HealthMonitoringDbContext.cs` +- Modify: `HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs` +- Test: `HealthMonitoring.Tests/Persistence/DependencyMappingTests.cs` + +- [ ] Write a failing model test asserting the entity has parent and target service keys and a unique parent/name index. +- [ ] Run the focused test and confirm it fails because the entity is not yet mapped. +- [ ] Add the entity and EF model configuration with restrictive foreign keys and the unique index. +- [ ] Add idempotent startup SQL to create the table when `EnsureCreated` is operating against an existing database. +- [ ] Run the focused test and confirm it passes. + +### Task 2: Add repository operations and mapping read model + +**Files:** +- Modify: `HealthMonitoring/Persistence/IHealthMonitoringRepository.cs` +- Modify: `HealthMonitoring/Persistence/HealthMonitoringRepository.cs` +- Modify: `HealthMonitoring/Reporting/DashboardModels.cs` +- Modify: `HealthMonitoring/Reporting/DashboardQueryService.cs` +- Test: `HealthMonitoring.Tests/Persistence/DependencyMappingTests.cs` + +- [ ] Add list, upsert, and delete repository contracts scoped by parent service ID. +- [ ] Test duplicate dependency names are rejected and mappings are scoped to the parent service. +- [ ] Implement normalized trimmed name handling and target-service validation. +- [ ] Return resolved link targets in `ServiceDetailModel`. +- [ ] Run the persistence-focused tests. + +### Task 3: Add dependency mapping HTTP endpoints + +**Files:** +- Create: `HealthMonitoring/Api/DependencyMappingEndpoints.cs` +- Create: `HealthMonitoring/Api/DependencyMappingContracts.cs` +- Modify: `HealthMonitoring/Program.cs` +- Test: `HealthMonitoring.Tests/Api/DependencyMappingEndpointTests.cs` + +- [ ] Define GET, POST/PUT, and DELETE routes scoped under `/api/services/{serviceId}/dependency-links`. +- [ ] Add validation for required names, active target services, self-links, and duplicate names. +- [ ] Test successful create/update/delete and validation failures. +- [ ] Map the endpoints in `Program.cs`. + +### Task 4: Build the Blazor dependency mapping screen + +**Files:** +- Create: `HealthMonitoring/Components/Pages/DependencyLinks.razor` +- Modify: `HealthMonitoring/Components/Pages/ServiceDetail.razor` +- Modify: `HealthMonitoring/wwwroot/app.css` + +- [ ] Add the `/services/{ServiceId}/dependencies` route with parent-service context. +- [ ] Render existing mappings, active target-service choices, add/edit/delete controls, validation errors, and empty state. +- [ ] Add a `Manage dependency links` action to the service detail page. +- [ ] Match the existing dashboard panel, form, button, and table styles. +- [ ] Build the application and verify the route compiles. + +### Task 5: Render drill-down links and verify the feature + +**Files:** +- Modify: `HealthMonitoring/Components/Pages/ServiceDetail.razor` +- Modify: `HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs` +- Create or modify: `HealthMonitoring.Tests/Reporting/DashboardQueryServiceTests.cs` + +- [ ] Add a case-insensitive mapping test for `Eventstore` and whitespace normalization. +- [ ] Render mapped dependency names as links to `/services/{targetServiceId}` and leave unmapped checks as plain text. +- [ ] Verify the supplied ASP.NET payload still produces both dependency rows. +- [ ] Run the complete test suite and a clean application build. +- [ ] Review the final diff for unrelated changes. diff --git a/docs/superpowers/plans/2026-09-18-operational-support-shell-implementation.md b/docs/superpowers/plans/2026-09-18-operational-support-shell-implementation.md new file mode 100644 index 0000000..eda0fb0 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-operational-support-shell-implementation.md @@ -0,0 +1,196 @@ +# Operational Support Shell Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Reframe the existing HealthWatch Blazor application as an Operational Support application with Health Monitoring as its first active section and future support sections visible but disabled. + +**Architecture:** Keep the existing routes, APIs, database, monitoring workers, and page behavior unchanged. Replace the standalone layout navigation with a grouped application shell, update page branding, and add disabled future-section navigation items without creating routes for them. + +**Tech Stack:** ASP.NET Core Blazor Server, Razor components, existing CSS, xUnit test suite, .NET 10. + +**Spec:** `docs/superpowers/specs/2026-09-18-operational-support-shell-design.md` + +## Global Constraints + +- Health Monitoring remains the only active section. +- Existing UI routes remain unchanged: `/`, `/services/new`, `/services/{serviceId}`, `/services/{serviceId}/dependencies`, and `/initialise`. +- Future navigation items must not navigate or create placeholder routes. +- No API, database, client-library, domain, or monitoring-worker changes. +- No authentication or authorization changes. + +--- + +### Task 1: Replace the standalone layout with the Operational Support shell + +**Files:** +- Modify: `HealthMonitoring/Components/Layout/MainLayout.razor` +- Modify: `HealthMonitoring/wwwroot/app.css` + +**Interfaces:** +- Consumes: Existing Blazor `LayoutComponentBase`, `NavLink`, and current page routes. +- Produces: Operational Support branding, grouped Health Monitoring navigation, and non-interactive future-section items. + +- [ ] **Step 1: Add the failing markup expectation** + +Add a focused test only if the existing test project can render Razor layouts without adding a new test framework. The expectation must verify that the layout contains the text `Operational Support`, an active `Health Monitoring` navigation item, and the future labels `Incidents`, `Service Catalogue`, `Runbooks`, `Configuration`, and `Alerting`. + +If the current test project cannot render this component directly, record the verification through the host smoke/build checks in Task 4 instead of adding a new UI test dependency. + +- [ ] **Step 2: Implement the navigation shell** + +Update `MainLayout.razor` to render: + +```razor +
+ +
@Body
+
+``` + +Keep `NavLink` only for the active Health Monitoring route. Use spans for future items so they cannot be activated or navigate. + +- [ ] **Step 3: Add shell styles** + +Extend `app.css` with styles for `.brand small`, `.nav-section-label`, `.nav-future`, and the `Soon` badge. Preserve the existing `.shell`, `.sidebar`, `nav`, `.main-content`, and responsive rules unless a selector must be adjusted to support the grouped navigation. Keep text labels visible below the existing 900px breakpoint. + +- [ ] **Step 4: Run the focused verification** + +Run: + +```powershell +dotnet build HealthMonitoring/HealthMonitoring.csproj --no-restore +``` + +Expected: build succeeds with zero errors. + +### Task 2: Update page-level Operational Support branding + +**Files:** +- Modify: `HealthMonitoring/Components/Pages/Overview.razor` +- Modify: `HealthMonitoring/Components/Pages/ServiceRegister.razor` +- Modify: `HealthMonitoring/Components/Pages/ServiceDetail.razor` +- Modify: `HealthMonitoring/Components/Pages/DependencyLinks.razor` +- Modify: `HealthMonitoring/Components/Pages/Initialise.razor` + +**Interfaces:** +- Consumes: Existing page routes and page content. +- Produces: Consistent document titles and breadcrumbs/copy using Operational Support and Health Monitoring terminology. + +- [ ] **Step 1: Replace document titles** + +Use titles with the following forms: + +```text +Operational Support · Health Monitoring +Operational Support · Register service +Operational Support · Service detail +Operational Support · Dependency links +Operational Support · Initialise Health Monitoring +``` + +- [ ] **Step 2: Update overview context** + +Change the overview eyebrow to `OPERATIONAL SUPPORT · HEALTH MONITORING`, retain the current service metrics, and keep the register-service action and existing `/services/new` route unchanged. + +- [ ] **Step 3: Update setup and configuration copy** + +Use `Initialise Health Monitoring` in the initialisation page heading and keep its existing backend behavior. Update service registration, detail, and dependency pages to use Operational Support branding without changing API calls, forms, or route parameters. + +- [ ] **Step 4: Run the focused verification** + +Run: + +```powershell +dotnet build HealthMonitoring/HealthMonitoring.csproj --no-restore +``` + +Expected: build succeeds with zero errors. + +### Task 3: Add route and future-section regression coverage + +**Files:** +- Modify or create: `HealthMonitoring.Tests/Components/MainLayoutTests.cs` only if direct component testing is already supported. +- Modify: `HealthMonitoring.Tests/HostSmokeTests.cs` only if existing smoke-test patterns can verify page routes. + +**Interfaces:** +- Consumes: Existing application routes and layout markup. +- Produces: Regression coverage that active Health Monitoring navigation remains available and future items are not links. + +- [ ] **Step 1: Inspect the existing test host** + +Use the existing test project references and test helpers. Do not add a browser-testing framework or a new UI test dependency solely for this shell change. + +- [ ] **Step 2: Add the smallest viable assertions** + +Verify, using the existing supported test approach, that: + +```text +Operational Support is present. +Health Monitoring remains an active navigation route. +Incidents, Service Catalogue, Runbooks, Configuration, and Alerting are displayed as non-link future items. +``` + +- [ ] **Step 3: Run the focused tests** + +Run: + +```powershell +dotnet test HealthMonitoring.Tests/HealthMonitoring.Tests.csproj --no-restore --filter "FullyQualifiedName~HostSmokeTests|FullyQualifiedName~MainLayoutTests" +``` + +Expected: all selected tests pass. If `MainLayoutTests` is not created because the project lacks a supported renderer, use the build and broader regression test as the verification instead. + +### Task 4: Full verification and review + +**Files:** +- Inspect: all files changed by Tasks 1–3. + +**Interfaces:** +- Consumes: Completed shell and branding changes. +- Produces: Verified implementation with no API, database, or monitoring regressions. + +- [ ] **Step 1: Run the complete applicable test suite** + +Run: + +```powershell +dotnet test HealthMonitoring.Tests/HealthMonitoring.Tests.csproj --no-restore --filter "FullyQualifiedName!~ConfigurationTests" +``` + +Expected: all applicable tests pass. + +- [ ] **Step 2: Verify the application build** + +Run: + +```powershell +dotnet build HealthMonitoring/HealthMonitoring.csproj --no-restore +``` + +Expected: build succeeds with zero warnings and zero errors. + +- [ ] **Step 3: Review the diff** + +Run: + +```powershell +git diff --check +git diff -- HealthMonitoring/Components/Layout/MainLayout.razor HealthMonitoring/Components/Pages/Overview.razor HealthMonitoring/Components/Pages/ServiceRegister.razor HealthMonitoring/Components/Pages/ServiceDetail.razor HealthMonitoring/Components/Pages/DependencyLinks.razor HealthMonitoring/Components/Pages/Initialise.razor HealthMonitoring/wwwroot/app.css +``` + +Confirm that only presentation/navigation files changed and that future-section items cannot navigate. diff --git a/docs/superpowers/specs/2026-09-18-dependency-mapping-design.md b/docs/superpowers/specs/2026-09-18-dependency-mapping-design.md new file mode 100644 index 0000000..587a90f --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-dependency-mapping-design.md @@ -0,0 +1,71 @@ +# Per-Service Dependency Mapping + +## Status + +Design approved in conversation; implementation begins after this specification is reviewed. + +## Goal + +Allow operators to map dependency names returned by a monitored service's ASP.NET health response to other monitored services, so dependency checks can link to the target service's detail page. + +## User experience + +- The service detail page contains a **Manage dependency links** action. +- The mapping page is scoped to one parent monitored service at `/services/{serviceId}/dependencies`. +- Each mapping shows the health-check dependency name and the target monitored service. +- Operators can add, edit, and remove mappings. +- The target service is selected from active monitored services and cannot be the parent service itself. +- The dependency detail table renders a link when a mapping exists; unmapped checks remain plain text. +- The mapping page displays a clear empty state and validation errors without losing unsaved form values. + +## Data model + +Add `ServiceDependencyLink`: + +- `Id` (`Guid`) primary key. +- `MonitoredServiceId` (`Guid`) parent service whose health response contains the dependency name. +- `DependencyName` (`string`) case-insensitive health response entry name. +- `TargetMonitoredServiceId` (`Guid`) monitored service opened by the drill-down link. +- `CreatedAtUtc` and `UpdatedAtUtc` timestamps. + +Constraints: + +- Unique `(MonitoredServiceId, DependencyName)` so one dependency has at most one target per parent service. +- Foreign keys to monitored services use restrictive behavior to avoid silently deleting mappings. +- Archived target services are not offered for new mappings, but existing mappings remain readable and render as unavailable rather than breaking the page. + +## Persistence and API boundary + +- Extend the repository with list, upsert, and delete operations scoped by parent service ID. +- Keep registration API behavior unchanged; dependency links are dashboard-managed configuration. +- Use SQL Server through the existing EF Core `HealthMonitoringDbContext` and the existing startup schema approach. + +## Detail-page behavior + +- Load dependency links with the service detail model. +- Match health-check names case-insensitively after trimming whitespace. +- Resolve the mapped target by monitored-service ID. +- Render the dependency name as a link to `/services/{targetServiceId}` when the target is active. +- Show an `Unmapped` label or plain dependency name when no mapping exists. +- Preserve the existing status, duration, and description/exception display. + +## Validation and failure handling + +- Dependency name is required and trimmed. +- Target service is required, active, and different from the parent service. +- Duplicate dependency names for the same parent return a validation error rather than creating duplicate rows. +- Deleting a mapping only removes the link; it does not alter health history or either monitored service. + +## Verification + +- Unit tests cover case-insensitive matching, duplicate prevention, self-link rejection, and archived target handling. +- Repository tests cover persistence and uniqueness behavior. +- UI/API tests cover adding, editing, removing, and rendering a mapped dependency link. +- Build and the existing test suite must pass after implementation. + +## Out of scope + +- Automatic mapping by dependency name. +- Changing service registration payloads. +- Dependency topology graphs. +- Authentication and authorization. diff --git a/docs/superpowers/specs/2026-09-18-operational-support-shell-design.md b/docs/superpowers/specs/2026-09-18-operational-support-shell-design.md new file mode 100644 index 0000000..00cc03d --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-operational-support-shell-design.md @@ -0,0 +1,82 @@ +# Operational Support Application Shell Design + +**Status:** Design approved for review + +## Goal + +Present the existing HealthWatch functionality as the first section of a broader standalone Operational Support application, while making the navigation ready for future support capabilities. + +## Scope + +This change is a presentation and navigation restructuring only. It does not add incident workflows, service catalogue data, runbooks, alert delivery, authentication, or new persistence. + +The first release will expose: + +- Operational Support application branding. +- Health Monitoring as the active workspace section. +- Existing overview, service registration, service detail, dependency mapping, and first-run initialisation flows unchanged in behavior. +- Future navigation entries for Incidents, Service Catalogue, Runbooks, Configuration, and Alerting, clearly marked as coming soon and non-interactive. + +## User experience + +The main layout will use a dark left navigation rail and the existing light content area. + +The navigation hierarchy is: + +```text +Operational Support +├── Workspace +│ ├── Health Monitoring active +│ ├── Incidents coming soon +│ ├── Service Catalogue coming soon +│ └── Runbooks coming soon +└── Administration + ├── Configuration coming soon + └── Alerting coming soon +``` + +Health Monitoring remains the only active section. Its existing navigation entries will be grouped beneath it without changing their routes: + +- Overview: `/` +- Register service: `/services/new` +- Service detail: `/services/{serviceId}` +- Dependency links: `/services/{serviceId}/dependencies` +- Initialisation: `/initialise` + +The overview page will identify itself as `Operational Support · Health Monitoring` and retain the current health metrics and service list. Page titles will use `Operational Support` branding rather than `HealthWatch`. + +Future entries will be rendered as disabled-looking navigation items with a `Soon` badge. They must not navigate or create placeholder routes, avoiding false functionality while making the intended application roadmap visible. + +## Components and files + +- Modify `HealthMonitoring/Components/Layout/MainLayout.razor` to implement the application shell, grouped navigation, branding, and future-section placeholders. +- Modify `HealthMonitoring/Components/Pages/Overview.razor` to use Operational Support / Health Monitoring copy. +- Modify `HealthMonitoring/Components/Pages/ServiceRegister.razor`, `ServiceDetail.razor`, `DependencyLinks.razor`, and `Initialise.razor` to use consistent Operational Support page titles where they currently mention HealthWatch. +- Modify `HealthMonitoring/wwwroot/app.css` for grouped navigation, disabled future links, section labels, and responsive behavior while preserving existing component styles. +- Add `HealthMonitoring.Tests/Components/MainLayoutTests.cs` only if the existing test setup can render the layout without introducing a new test framework; otherwise verify the rendered markup through the existing host smoke/build tests. + +No API, database, client-library, domain, or monitoring-worker changes are required. + +## Interaction and accessibility + +- Active Health Monitoring navigation must use the existing `NavLink` behavior so the current route is visibly selected. +- Future items must be non-links or have `aria-disabled="true"` and must not be keyboard-activatable as navigation targets. +- Text labels must remain visible without relying on icons. +- Existing responsive layout behavior must continue to work below the current 900px breakpoint. + +## Verification + +1. Build `HealthMonitoring/HealthMonitoring.csproj`. +2. Run the HealthMonitoring test suite, excluding only the existing environment-specific `ConfigurationTests` if it still requires the default output layout. +3. Confirm the existing routes still render and the Health Monitoring navigation remains active for overview, registration, service detail, dependency links, and initialisation. +4. Confirm future navigation entries are visible but do not navigate. +5. Run `git diff --check`. + +## Out of scope + +- Incident history screens or acknowledgement. +- External alert providers. +- Service ownership and catalogue records. +- Runbook storage or execution. +- Authentication and authorization. +- Environment switching or tenant-specific navigation. From 0796cff3e2ccfea39c5325d5eae2f88092c26b64 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 18:53:50 +0100 Subject: [PATCH 13/17] Add Health Monitoring unit tests to CI --- .github/workflows/pullrequest.yml | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/.github/workflows/pullrequest.yml b/.github/workflows/pullrequest.yml index 1ab0be3..5baf467 100644 --- a/.github/workflows/pullrequest.yml +++ b/.github/workflows/pullrequest.yml @@ -56,3 +56,23 @@ jobs: './ScheduledTasks/ReplayParkedQueue/Tests' './ScheduledTasks/SettlementProcessing/Tests' ) -CI + + health-monitoring-tests: + name: "Run Health Monitoring Unit Tests" + runs-on: ubuntu-latest + permissions: + contents: read + + steps: + - uses: actions/checkout@v4 + + - name: Install NET 10 + uses: actions/setup-dotnet@v4.0.1 + with: + dotnet-version: '10.0.x' + + - name: Restore Health Monitoring solution + run: dotnet restore HealthMonitoring/HealthMonitoring.sln + + - name: Run Health Monitoring unit tests + run: dotnet test HealthMonitoring.Tests/HealthMonitoring.Tests.csproj --configuration Release --no-restore --filter "FullyQualifiedName!~ConfigurationTests" From 744aa44c559bda688703b9a77845f3052fbaed87 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 18:58:03 +0100 Subject: [PATCH 14/17] Potential fix for pull request finding 'CodeQL / Resource injection' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- .../Monitoring/SqlServerMonitorClient.cs | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs b/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs index 613506c..7466bfd 100644 --- a/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs +++ b/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs @@ -52,9 +52,19 @@ public async Task ProbeAsync(MonitoredService service, CancellationToken cancell { if (string.IsNullOrWhiteSpace(service.ConnectionString)) throw new InvalidOperationException("SQL Server connection string is not configured."); + SqlConnectionStringBuilder builder; + try + { + builder = new SqlConnectionStringBuilder(service.ConnectionString); + } + catch (ArgumentException exception) + { + throw new InvalidOperationException("SQL Server connection string is invalid.", exception); + } + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); timeoutSource.CancelAfter(service.RequestTimeout); - await using var connection = new SqlConnection(service.ConnectionString); + await using var connection = new SqlConnection(builder.ConnectionString); await connection.OpenAsync(timeoutSource.Token); await using var command = connection.CreateCommand(); command.CommandText = "SELECT 1"; From 2e5f51772b1f13a8dd8f770a69215dbb646b1350 Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 19:42:29 +0100 Subject: [PATCH 15/17] Resolve pull request review comments --- .../Api/ServiceConfigurationValidatorTests.cs | 11 +- ...HealthMonitoringRegistrationClientTests.cs | 37 +++-- HealthMonitoring.Tests/ConfigurationTests.cs | 8 +- .../Domain/HealthStatusNormalizerTests.cs | 6 +- .../Monitoring/HealthEndpointClientTests.cs | 33 +++- .../Monitoring/KurrentDbMonitorClientTests.cs | 2 +- .../Monitoring/SqlServerMonitorClientTests.cs | 2 +- .../Api/DependencyMappingEndpoints.cs | 17 +- .../Api/ServiceConfigurationValidator.cs | 98 +++++++---- .../Api/ServiceRegistrationEndpoints.cs | 20 ++- .../Components/Pages/ServiceDetail.razor | 9 +- .../Monitoring/HealthEndpointClient.cs | 12 +- .../Monitoring/InitializationService.cs | 102 +++++++++--- .../Monitoring/SqlServerMonitorClient.cs | 38 +++-- .../Persistence/HealthMonitoringDbContext.cs | 29 ++-- .../HealthMonitoringSchemaInitializer.cs | 155 ++++++++++++------ HealthMonitoring/Program.cs | 2 + .../Reporting/DashboardQueryService.cs | 4 +- HealthMonitoring/appsettings.Development.json | 5 + HealthMonitoring/appsettings.json | 5 + 20 files changed, 420 insertions(+), 175 deletions(-) diff --git a/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs b/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs index 2913745..33e0705 100644 --- a/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs +++ b/HealthMonitoring.Tests/Api/ServiceConfigurationValidatorTests.cs @@ -5,10 +5,13 @@ namespace HealthMonitoring.Tests.Api; public sealed class ServiceConfigurationValidatorTests { + private const string ServiceId = "service"; + private const string ServiceName = "Service"; + [Fact] public void Rejects_non_http_urls_and_invalid_intervals() { - var request = new ServiceRegistrationRequest("service", "Service", "ftp://service") + var request = new ServiceRegistrationRequest(ServiceId, ServiceName, "ftp://service") { PollingIntervalSeconds = 3, RequestTimeoutSeconds = 5, @@ -28,14 +31,14 @@ public void Self_registration_preserves_dashboard_managed_settings_for_existing_ var existing = new HealthMonitoring.Domain.MonitoredService { Id = Guid.NewGuid(), - ServiceId = "service", - Name = "Service", + ServiceId = ServiceId, + Name = ServiceName, Environment = "Production", HealthUrl = new Uri("https://old/health"), PollingInterval = TimeSpan.FromMinutes(5), RetentionPeriod = TimeSpan.FromDays(10) }; - var request = new ServiceRegistrationRequest("service", "Updated", "https://new/health") { PollingIntervalSeconds = 30, RetentionDays = 1 }; + var request = new ServiceRegistrationRequest(ServiceId, "Updated", "https://new/health") { PollingIntervalSeconds = 30, RetentionDays = 1 }; var result = ServiceConfigurationValidator.ToService(request, "Development", existing, preserveManagedSettings: true); diff --git a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs index 7641910..3bf5e75 100644 --- a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs +++ b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs @@ -6,6 +6,11 @@ namespace HealthMonitoring.Tests.Client; public sealed class HealthMonitoringRegistrationClientTests { + private const string PaymentsApiServiceId = "payments-api"; + private const string SecurityServiceId = "security-service"; + private const string SecurityServiceName = "Security Service"; + private const string MonitoringBaseUrl = "https://monitoring.local"; + [Fact] public async Task RegisterAsync_posts_service_configuration_and_returns_acknowledgement() { @@ -13,7 +18,7 @@ public async Task RegisterAsync_posts_service_configuration_and_returns_acknowle { Content = JsonContent.Create(new { - serviceId = "payments-api", + serviceId = PaymentsApiServiceId, action = "created", service = new { id = Guid.Parse("11111111-1111-1111-1111-111111111111") } }) @@ -22,7 +27,7 @@ public async Task RegisterAsync_posts_service_configuration_and_returns_acknowle var result = await client.RegisterAsync(new ServiceRegistrationOptions { - ServiceId = "payments-api", + ServiceId = PaymentsApiServiceId, Name = "Payments API", HealthUrl = new Uri("https://payments-api/health"), PollingInterval = TimeSpan.FromSeconds(30), @@ -34,9 +39,9 @@ public async Task RegisterAsync_posts_service_configuration_and_returns_acknowle Assert.Equal("created", result.Action); Assert.Equal(Guid.Parse("11111111-1111-1111-1111-111111111111"), result.MonitoredServiceId); Assert.Equal(HttpMethod.Post, handler.Request!.Method); - Assert.Equal("http://monitoring.local/api/services/register", handler.Request.RequestUri!.ToString()); + Assert.Equal($"{MonitoringBaseUrl}/api/services/register", handler.Request.RequestUri!.ToString()); var payload = await handler.Request.Content!.ReadFromJsonAsync(); - Assert.Equal("payments-api", payload!.ServiceId); + Assert.Equal(PaymentsApiServiceId, payload!.ServiceId); Assert.Equal(0, payload.MonitorType); Assert.Equal(30, payload.PollingIntervalSeconds); Assert.True(payload.IgnoreCertificateErrors); @@ -48,21 +53,21 @@ public async Task SetDependencyMappingsAsync_resolves_target_service_and_upserts var targetId = Guid.Parse("22222222-2222-2222-2222-222222222222"); var handler = new QueueHandler( JsonResponse(HttpStatusCode.OK, Array.Empty()), - JsonResponse(HttpStatusCode.OK, new { id = targetId, serviceId = "security-service", name = "Security Service" }), + JsonResponse(HttpStatusCode.OK, new { id = targetId, serviceId = SecurityServiceId, name = SecurityServiceName }), JsonResponse(HttpStatusCode.Created, new { })); var client = CreateClient(handler); await client.SetDependencyMappingsAsync("payments-api", new[] { - new DependencyMappingOptions("Security Service", "security-service") + new DependencyMappingOptions(SecurityServiceName, SecurityServiceId) }); Assert.Collection(handler.Requests, - request => Assert.Equal("http://monitoring.local/api/services/payments-api/dependency-links", request.RequestUri!.ToString()), - request => Assert.Equal("http://monitoring.local/api/services/security-service", request.RequestUri!.ToString()), + request => Assert.Equal($"{MonitoringBaseUrl}/api/services/{PaymentsApiServiceId}/dependency-links", request.RequestUri!.ToString()), + request => Assert.Equal($"{MonitoringBaseUrl}/api/services/{SecurityServiceId}", request.RequestUri!.ToString()), request => Assert.Equal(HttpMethod.Post, request.Method)); var payload = await handler.Requests[2].Content!.ReadFromJsonAsync(); - Assert.Equal("Security Service", payload!.DependencyName); + Assert.Equal(SecurityServiceName, payload!.DependencyName); Assert.Equal(targetId, payload.TargetMonitoredServiceId); } @@ -72,22 +77,22 @@ public async Task SetDependencyMappingsAsync_updates_existing_mapping_instead_of var linkId = Guid.Parse("33333333-3333-3333-3333-333333333333"); var targetId = Guid.Parse("44444444-4444-4444-4444-444444444444"); var handler = new QueueHandler( - JsonResponse(HttpStatusCode.OK, new[] { new { id = linkId, dependencyName = "Security Service" } }), - JsonResponse(HttpStatusCode.OK, new { id = targetId, serviceId = "security-service", name = "Security Service" }), + JsonResponse(HttpStatusCode.OK, new[] { new { id = linkId, dependencyName = SecurityServiceName } }), + JsonResponse(HttpStatusCode.OK, new { id = targetId, serviceId = SecurityServiceId, name = SecurityServiceName }), JsonResponse(HttpStatusCode.OK, new { })); var client = CreateClient(handler); await client.SetDependencyMappingsAsync("payments-api", new[] { - new DependencyMappingOptions("Security Service", "security-service") + new DependencyMappingOptions(SecurityServiceName, SecurityServiceId) }); Assert.Equal(HttpMethod.Put, handler.Requests[2].Method); - Assert.EndsWith($"/api/services/payments-api/dependency-links/{linkId}", handler.Requests[2].RequestUri!.ToString()); + Assert.EndsWith($"/api/services/{PaymentsApiServiceId}/dependency-links/{linkId}", handler.Requests[2].RequestUri!.ToString()); } private static HealthMonitoringRegistrationClient CreateClient(HttpMessageHandler handler) => - new(new HttpClient(handler) { BaseAddress = new Uri("http://monitoring.local") }); + new(new HttpClient(handler) { BaseAddress = new Uri(MonitoringBaseUrl) }); private sealed class ServiceRegistrationPayload { @@ -107,7 +112,7 @@ private sealed class RecordingHandler(Func SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override Task SendAsync(HttpRequestMessage request, CancellationToken _) { Request = request; return Task.FromResult(responseFactory(request)); @@ -119,7 +124,7 @@ private sealed class QueueHandler(params HttpResponseMessage[] responses) : Http private int _index; public List Requests { get; } = []; - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override Task SendAsync(HttpRequestMessage request, CancellationToken _) { Requests.Add(request); return Task.FromResult(responses[_index++]); diff --git a/HealthMonitoring.Tests/ConfigurationTests.cs b/HealthMonitoring.Tests/ConfigurationTests.cs index 91ca454..85fc90f 100644 --- a/HealthMonitoring.Tests/ConfigurationTests.cs +++ b/HealthMonitoring.Tests/ConfigurationTests.cs @@ -5,14 +5,16 @@ namespace HealthMonitoring.Tests; public sealed class ConfigurationTests { [Fact] - public void Base_configuration_contains_the_development_sql_server_connection() + public void Development_configuration_contains_the_sql_server_connection() { var repositoryRoot = Directory.GetParent(AppContext.BaseDirectory)!.Parent!.Parent!.Parent!.Parent!.FullName; var configuration = new ConfigurationBuilder() .SetBasePath(Path.Combine(repositoryRoot, "HealthMonitoring")) - .AddJsonFile("appsettings.json") + .AddJsonFile("appsettings.Development.json") .Build(); - Assert.Equal("Server=.;Database=HealthMonitoring;User Id=sa;Password=ChangeThisStrongPassword!123;TrustServerCertificate=True;Encrypt=False;", configuration.GetConnectionString("HealthMonitoring")); + var connectionString = configuration.GetConnectionString("HealthMonitoring"); + Assert.Contains("Server=localhost;Database=HealthMonitoring;User Id=sa;", connectionString, StringComparison.Ordinal); + Assert.Contains("TrustServerCertificate=True;Encrypt=False;", connectionString, StringComparison.Ordinal); } } diff --git a/HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs b/HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs index a16f823..87d044f 100644 --- a/HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs +++ b/HealthMonitoring.Tests/Domain/HealthStatusNormalizerTests.cs @@ -6,6 +6,8 @@ namespace HealthMonitoring.Tests.Domain; public sealed class HealthStatusNormalizerTests { + private const string UnhealthyStatus = "Unhealthy"; + [Fact] public void All_healthy_checks_produce_healthy_status() { @@ -21,7 +23,7 @@ public void All_healthy_checks_produce_healthy_status() public void Non_critical_unhealthy_check_produces_degraded_status_by_default() { var normalizer = new HealthStatusNormalizer(); - var response = Response("Unhealthy", ("cache", "Unhealthy")); + var response = Response(UnhealthyStatus, ("cache", UnhealthyStatus)); var result = normalizer.Normalize(response, HttpStatusCode.ServiceUnavailable, TimeSpan.FromMilliseconds(9)); @@ -32,7 +34,7 @@ public void Non_critical_unhealthy_check_produces_degraded_status_by_default() public void Critical_check_failure_produces_unhealthy_status() { var normalizer = new HealthStatusNormalizer(); - var response = Response("Unhealthy", ("database", "Unhealthy")); + var response = Response(UnhealthyStatus, ("database", UnhealthyStatus)); var policy = new StatusPolicy { CriticalChecks = ["database"] }; var result = normalizer.Normalize(response, HttpStatusCode.ServiceUnavailable, TimeSpan.FromMilliseconds(9), policy); diff --git a/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs index 7611d9c..cb87a2f 100644 --- a/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs +++ b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs @@ -35,6 +35,35 @@ public async Task Timeout_is_recorded_as_unhealthy_observation_candidate() Assert.Contains("timeout", result.Normalized.Error, StringComparison.OrdinalIgnoreCase); } + [Fact] + public async Task Non_success_response_preserves_degraded_policy_for_non_critical_dependency() + { + var body = "{\"status\":\"Unhealthy\",\"entries\":{\"cache\":{\"status\":\"Unhealthy\",\"duration\":\"00:00:00.004\",\"data\":{}}}}"; + var handler = new StubHandler(HttpStatusCode.ServiceUnavailable, body); + var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); + var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Degraded, result.Normalized.Status); + Assert.Contains("503", result.Normalized.Error, StringComparison.Ordinal); + } + + [Theory] + [InlineData("{}")] + [InlineData("[]")] + public async Task Invalid_health_payload_is_recorded_as_unhealthy_observation_candidate(string body) + { + var handler = new StubHandler(HttpStatusCode.OK, body); + var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); + var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + + var result = await client.CheckAsync(service, CancellationToken.None); + + Assert.Equal(HealthStatus.Unhealthy, result.Normalized.Status); + Assert.Contains("Invalid health response", result.Normalized.Error, StringComparison.Ordinal); + } + private sealed class StubHandler : HttpMessageHandler { private readonly HttpStatusCode? _status; @@ -44,7 +73,7 @@ private sealed class StubHandler : HttpMessageHandler public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; } public StubHandler(Exception exception) { _exception = exception; } - protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + protected override Task SendAsync(HttpRequestMessage _, CancellationToken cancellationToken) { if (_exception is not null) return Task.FromException(_exception); return Task.FromResult(new HttpResponseMessage(_status!.Value) { Content = new StringContent(_body!) }); @@ -53,6 +82,6 @@ protected override Task SendAsync(HttpRequestMessage reques private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { - public HttpClient CreateClient(string name) => new(handler, disposeHandler: false); + public HttpClient CreateClient(string _) => new(handler, disposeHandler: false); } } diff --git a/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs b/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs index 5a48db6..790b15e 100644 --- a/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs +++ b/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs @@ -34,7 +34,7 @@ public async Task Failed_probe_returns_unhealthy_with_the_failure_detail() private sealed class StubProbe(Exception? exception = null) : IKurrentDbProbe { - public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => + public Task ProbeAsync(MonitoredService _, CancellationToken cancellationToken) => exception is null ? Task.CompletedTask : Task.FromException(exception); } } diff --git a/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs b/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs index 5a93118..2e45772 100644 --- a/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs +++ b/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs @@ -33,7 +33,7 @@ public async Task Failed_probe_returns_unhealthy_with_the_failure_detail() private sealed class StubProbe(Exception? exception = null) : ISqlServerProbe { - public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) => + public Task ProbeAsync(MonitoredService _, CancellationToken cancellationToken) => exception is null ? Task.CompletedTask : Task.FromException(exception); } } diff --git a/HealthMonitoring/Api/DependencyMappingEndpoints.cs b/HealthMonitoring/Api/DependencyMappingEndpoints.cs index 455d36a..873429c 100644 --- a/HealthMonitoring/Api/DependencyMappingEndpoints.cs +++ b/HealthMonitoring/Api/DependencyMappingEndpoints.cs @@ -6,16 +6,26 @@ public static class DependencyMappingEndpoints { public static IEndpointRouteBuilder MapDependencyMappingEndpoints(this IEndpointRouteBuilder endpoints) { + MapListEndpoint(endpoints); + MapCreateEndpoint(endpoints); + MapUpdateEndpoint(endpoints); + MapDeleteEndpoint(endpoints); + + return endpoints; + } + + private static void MapListEndpoint(IEndpointRouteBuilder endpoints) => endpoints.MapGet("/api/services/{serviceId}/dependency-links", async (string serviceId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { var service = await repository.GetServiceAsync(serviceId, cancellationToken); if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound(); var links = await repository.ListDependencyLinksAsync(service.Id, cancellationToken); - var services = await repository.ListServicesAsync(false, cancellationToken); + var services = await repository.ListServicesAsync(true, cancellationToken); return Results.Ok(ToResponses(links, services)); }); + private static void MapCreateEndpoint(IEndpointRouteBuilder endpoints) => endpoints.MapPost("/api/services/{serviceId}/dependency-links", async (string serviceId, DependencyLinkRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { var service = await repository.GetServiceAsync(serviceId, cancellationToken); @@ -35,6 +45,7 @@ public static IEndpointRouteBuilder MapDependencyMappingEndpoints(this IEndpoint return Results.Created($"/api/services/{serviceId}/dependency-links/{link.Id}", link); }); + private static void MapUpdateEndpoint(IEndpointRouteBuilder endpoints) => endpoints.MapPut("/api/services/{serviceId}/dependency-links/{linkId:guid}", async (string serviceId, Guid linkId, DependencyLinkRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { var service = await repository.GetServiceAsync(serviceId, cancellationToken); @@ -54,6 +65,7 @@ public static IEndpointRouteBuilder MapDependencyMappingEndpoints(this IEndpoint return Results.Ok(existing); }); + private static void MapDeleteEndpoint(IEndpointRouteBuilder endpoints) => endpoints.MapDelete("/api/services/{serviceId}/dependency-links/{linkId:guid}", async (string serviceId, Guid linkId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { var service = await repository.GetServiceAsync(serviceId, cancellationToken); @@ -66,9 +78,6 @@ public static IEndpointRouteBuilder MapDependencyMappingEndpoints(this IEndpoint return Results.NoContent(); }); - return endpoints; - } - private static async Task ValidateAsync(Guid parentId, DependencyLinkRequest request, Guid? currentLinkId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) { var errors = new Dictionary(StringComparer.OrdinalIgnoreCase); diff --git a/HealthMonitoring/Api/ServiceConfigurationValidator.cs b/HealthMonitoring/Api/ServiceConfigurationValidator.cs index 2692ac4..e062289 100644 --- a/HealthMonitoring/Api/ServiceConfigurationValidator.cs +++ b/HealthMonitoring/Api/ServiceConfigurationValidator.cs @@ -4,41 +4,73 @@ namespace HealthMonitoring.Api; public static class ServiceConfigurationValidator { - public static IReadOnlyList Validate(ServiceRegistrationRequest request) + private const string DefaultHealthUrl = "http://localhost/health"; + + public static IReadOnlyList Validate(ServiceRegistrationRequest request) => + [.. ValidateIdentity(request), .. ValidateMonitorSettings(request)]; + + public static MonitoredService ToService(ServiceRegistrationRequest request, string dashboardEnvironment, MonitoredService? existing = null, bool preserveManagedSettings = true) + { + var now = DateTimeOffset.UtcNow; + return new MonitoredService + { + Id = existing?.Id ?? Guid.NewGuid(), + ServiceId = request.ServiceId, + Name = request.Name, + Environment = dashboardEnvironment, + Group = request.Group, + Description = request.Description, + MonitorType = request.MonitorType, + HealthUrl = ParseHealthUrl(request.HealthUrl), + ConnectionString = request.ConnectionString, + IgnoreCertificateErrors = request.IgnoreCertificateErrors, + IsEnabled = existing?.IsEnabled ?? true, + PollingInterval = GetPollingInterval(request, existing, preserveManagedSettings), + RequestTimeout = GetRequestTimeout(request, existing, preserveManagedSettings), + RetentionPeriod = GetRetentionPeriod(request, existing, preserveManagedSettings), + StatusPolicy = GetStatusPolicy(request, existing, preserveManagedSettings), + Version = request.Version, + Host = request.Host, + LastRegisteredAtUtc = now, + CreatedAtUtc = existing?.CreatedAtUtc ?? now, + UpdatedAtUtc = now + }; + } + + private static IEnumerable ValidateIdentity(ServiceRegistrationRequest request) { - var errors = new List(); - if (string.IsNullOrWhiteSpace(request.ServiceId)) errors.Add("ServiceId is required."); - if (string.IsNullOrWhiteSpace(request.Name)) errors.Add("Name is required."); - if (!Enum.IsDefined(request.MonitorType)) errors.Add("MonitorType is invalid."); - if (request.MonitorType == MonitorType.HttpHealthEndpoint && (!Uri.TryCreate(request.HealthUrl, UriKind.Absolute, out var uri) || uri.Scheme is not ("http" or "https"))) errors.Add("HealthUrl must be an absolute HTTP or HTTPS URL."); - if (request.MonitorType is MonitorType.KurrentDb or MonitorType.SqlServer && string.IsNullOrWhiteSpace(request.ConnectionString)) errors.Add("ConnectionString is required for database monitors."); - if (request.PollingIntervalSeconds < 5) errors.Add("PollingIntervalSeconds must be at least 5."); - if (request.RequestTimeoutSeconds < 1 || request.RequestTimeoutSeconds > request.PollingIntervalSeconds) errors.Add("RequestTimeoutSeconds must be between 1 and the polling interval."); - if (request.RetentionDays < 1) errors.Add("RetentionDays must be positive."); - return errors; + if (string.IsNullOrWhiteSpace(request.ServiceId)) yield return "ServiceId is required."; + if (string.IsNullOrWhiteSpace(request.Name)) yield return "Name is required."; + if (!Enum.IsDefined(request.MonitorType)) yield return "MonitorType is invalid."; } - public static MonitoredService ToService(ServiceRegistrationRequest request, string dashboardEnvironment, MonitoredService? existing = null, bool preserveManagedSettings = true) => new() + private static IEnumerable ValidateMonitorSettings(ServiceRegistrationRequest request) { - Id = existing?.Id ?? Guid.NewGuid(), - ServiceId = request.ServiceId, - Name = request.Name, - Environment = dashboardEnvironment, - Group = request.Group, - Description = request.Description, - MonitorType = request.MonitorType, - HealthUrl = Uri.TryCreate(request.HealthUrl, UriKind.Absolute, out var healthUrl) ? healthUrl : new Uri("http://localhost/health"), - ConnectionString = request.ConnectionString, - IgnoreCertificateErrors = request.IgnoreCertificateErrors, - IsEnabled = existing?.IsEnabled ?? true, - PollingInterval = preserveManagedSettings && existing is not null ? existing.PollingInterval : TimeSpan.FromSeconds(request.PollingIntervalSeconds), - RequestTimeout = preserveManagedSettings && existing is not null ? existing.RequestTimeout : TimeSpan.FromSeconds(request.RequestTimeoutSeconds), - RetentionPeriod = preserveManagedSettings && existing is not null ? existing.RetentionPeriod : TimeSpan.FromDays(request.RetentionDays), - StatusPolicy = preserveManagedSettings && existing is not null ? existing.StatusPolicy : request.StatusPolicy ?? new StatusPolicy(), - Version = request.Version, - Host = request.Host, - LastRegisteredAtUtc = DateTimeOffset.UtcNow, - CreatedAtUtc = existing?.CreatedAtUtc ?? DateTimeOffset.UtcNow, - UpdatedAtUtc = DateTimeOffset.UtcNow - }; + if (request.MonitorType == MonitorType.HttpHealthEndpoint && !IsHttpUrl(request.HealthUrl)) + yield return "HealthUrl must be an absolute HTTP or HTTPS URL."; + if (request.MonitorType is MonitorType.KurrentDb or MonitorType.SqlServer && string.IsNullOrWhiteSpace(request.ConnectionString)) + yield return "ConnectionString is required for database monitors."; + if (request.PollingIntervalSeconds < 5) yield return "PollingIntervalSeconds must be at least 5."; + if (request.RequestTimeoutSeconds < 1 || request.RequestTimeoutSeconds > request.PollingIntervalSeconds) + yield return "RequestTimeoutSeconds must be between 1 and the polling interval."; + if (request.RetentionDays < 1) yield return "RetentionDays must be positive."; + } + + private static bool IsHttpUrl(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && uri.Scheme is "http" or "https"; + + private static Uri ParseHealthUrl(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) ? uri : new Uri(DefaultHealthUrl); + + private static TimeSpan GetPollingInterval(ServiceRegistrationRequest request, MonitoredService? existing, bool preserveManagedSettings) => + preserveManagedSettings && existing is not null ? existing.PollingInterval : TimeSpan.FromSeconds(request.PollingIntervalSeconds); + + private static TimeSpan GetRequestTimeout(ServiceRegistrationRequest request, MonitoredService? existing, bool preserveManagedSettings) => + preserveManagedSettings && existing is not null ? existing.RequestTimeout : TimeSpan.FromSeconds(request.RequestTimeoutSeconds); + + private static TimeSpan GetRetentionPeriod(ServiceRegistrationRequest request, MonitoredService? existing, bool preserveManagedSettings) => + preserveManagedSettings && existing is not null ? existing.RetentionPeriod : TimeSpan.FromDays(request.RetentionDays); + + private static StatusPolicy GetStatusPolicy(ServiceRegistrationRequest request, MonitoredService? existing, bool preserveManagedSettings) => + preserveManagedSettings && existing is not null ? existing.StatusPolicy : request.StatusPolicy ?? new StatusPolicy(); } diff --git a/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs index ab8de49..6ae9ff5 100644 --- a/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs +++ b/HealthMonitoring/Api/ServiceRegistrationEndpoints.cs @@ -7,6 +7,16 @@ public static class ServiceRegistrationEndpoints { public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpointRouteBuilder endpoints, string dashboardEnvironment) { + MapRegistrationEndpoint(endpoints, dashboardEnvironment); + MapListEndpoint(endpoints); + MapGetEndpoint(endpoints); + MapUpdateEndpoint(endpoints, dashboardEnvironment); + MapArchiveEndpoint(endpoints); + + return endpoints; + } + + private static void MapRegistrationEndpoint(IEndpointRouteBuilder endpoints, string dashboardEnvironment) => endpoints.MapPost("/api/services/register", async (ServiceRegistrationRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { var errors = ServiceConfigurationValidator.Validate(request); @@ -21,6 +31,7 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi : Results.Ok(new ServiceRegistrationResponse(service.ServiceId, action, service)); }); + private static void MapListEndpoint(IEndpointRouteBuilder endpoints) => endpoints.MapGet("/api/services", async (IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { var services = await repository.ListServicesAsync(false, cancellationToken); @@ -34,13 +45,14 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi return Results.Ok(results); }); + private static void MapGetEndpoint(IEndpointRouteBuilder endpoints) => endpoints.MapGet("/api/services/{serviceId}", async (string serviceId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { var service = await repository.GetServiceAsync(serviceId, cancellationToken); - if (service is null || service.ArchivedAtUtc is not null) return Results.NotFound(); - return Results.Ok(service); + return service is null || service.ArchivedAtUtc is not null ? Results.NotFound() : Results.Ok(service); }); + private static void MapUpdateEndpoint(IEndpointRouteBuilder endpoints, string dashboardEnvironment) => endpoints.MapPut("/api/services/{serviceId}", async (string serviceId, ServiceRegistrationRequest request, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { if (!string.Equals(serviceId, request.ServiceId, StringComparison.OrdinalIgnoreCase)) return Results.BadRequest("Route serviceId must match request ServiceId."); @@ -53,12 +65,10 @@ public static IEndpointRouteBuilder MapServiceRegistrationEndpoints(this IEndpoi return Results.Ok(service); }); + private static void MapArchiveEndpoint(IEndpointRouteBuilder endpoints) => endpoints.MapDelete("/api/services/{serviceId}", async (string serviceId, IHealthMonitoringRepository repository, CancellationToken cancellationToken) => { await repository.ArchiveServiceAsync(serviceId, DateTimeOffset.UtcNow, cancellationToken); return Results.NoContent(); }); - - return endpoints; - } } diff --git a/HealthMonitoring/Components/Pages/ServiceDetail.razor b/HealthMonitoring/Components/Pages/ServiceDetail.razor index 89beb0e..f9ee400 100644 --- a/HealthMonitoring/Components/Pages/ServiceDetail.razor +++ b/HealthMonitoring/Components/Pages/ServiceDetail.razor @@ -12,14 +12,21 @@ else
Last checked @( _model.Summary.LastChecked?.ToLocalTime().ToString("dd MMM, HH:mm:ss") ?? "never")

Status timeline

@foreach (var observation in _model.Observations.OrderBy(item => item.ObservedAtUtc)) { }
-

Dependency details

@if (_model.Checks.Count == 0) {

No dependency details have been returned.

} else {
CheckStatusDurationDetails
@foreach (var check in _model.Checks.GroupBy(item => item.Name).Select(group => group.OrderByDescending(item => item.HealthObservationId).First())) { var link = _model.DependencyLinks.FirstOrDefault(item => string.Equals(item.DependencyName.Trim(), check.Name.Trim(), StringComparison.OrdinalIgnoreCase));
@if (link is null) { @check.Name } else { @check.Name ↗ }
@check.Duration.TotalMilliseconds.ToString("0") ms
@(check.Description ?? check.Exception ?? "No detail")
}
}
+

Dependency details

@if (_model.Checks.Count == 0) {

No dependency details have been returned.

} else {
CheckStatusDurationDetails
@foreach (var check in LatestChecks) { var link = _model.DependencyLinks.FirstOrDefault(item => string.Equals(item.DependencyName.Trim(), check.Result.Name.Trim(), StringComparison.OrdinalIgnoreCase));
@if (link is null) { @check.Result.Name } else { @check.Result.Name ↗ }
@check.Result.Duration.TotalMilliseconds.ToString("0") ms
@(check.Result.Description ?? check.Result.Exception ?? "No detail")
}
}
Latest raw health response
@(_model.Observations.FirstOrDefault()?.RawResponseJson ?? "No response recorded.")
} @code { [Parameter] public string ServiceId { get; set; } = string.Empty; private ServiceDetailModel? _model; + private IEnumerable LatestChecks => _model?.Observations + .SelectMany(observation => observation.Checks.Select(check => new ObservedCheck(check, observation.ObservedAtUtc))) + .GroupBy(item => item.Result.Name, StringComparer.OrdinalIgnoreCase) + .Select(group => group.OrderByDescending(item => item.ObservedAtUtc).First()) + ?? []; private string UptimeText => $"{_model?.Summary.UptimePercent:0.00}%"; private string ResponseText => _model?.Summary.ResponseDuration is { } duration ? $"{duration.TotalMilliseconds:0} ms" : "—"; protected override async Task OnParametersSetAsync() => _model = await Dashboard.GetServiceDetailAsync(ServiceId, TimeSpan.FromHours(24), CancellationToken.None); + + private sealed record ObservedCheck(HealthCheckResult Result, DateTimeOffset ObservedAtUtc); } diff --git a/HealthMonitoring/Monitoring/HealthEndpointClient.cs b/HealthMonitoring/Monitoring/HealthEndpointClient.cs index 5743efe..7d44f1e 100644 --- a/HealthMonitoring/Monitoring/HealthEndpointClient.cs +++ b/HealthMonitoring/Monitoring/HealthEndpointClient.cs @@ -41,14 +41,20 @@ public async Task CheckAsync(MonitoredService service, Can var normalized = normalizer.Normalize(parsed, response.StatusCode, stopwatch.Elapsed, service.StatusPolicy) with { RawResponse = raw }; if (!response.IsSuccessStatusCode) { - normalized = normalized with { Status = HealthStatus.Unhealthy, Error = $"Health endpoint returned {(int)response.StatusCode} {response.ReasonPhrase}." }; + normalized = normalized with + { + Error = normalized.Error ?? $"Health endpoint returned {(int)response.StatusCode} {response.ReasonPhrase}." + }; } return new HealthEndpointResult(normalized, response.StatusCode, stopwatch.Elapsed, raw); } - catch (JsonException) + catch (Exception exception) when (exception is JsonException or InvalidOperationException or KeyNotFoundException) { - var normalized = normalizer.NormalizeInvalid(response.StatusCode, raw, stopwatch.Elapsed, service.StatusPolicy); + var normalized = normalizer.NormalizeInvalid(response.StatusCode, raw, stopwatch.Elapsed, service.StatusPolicy) with + { + Error = $"Invalid health response: {raw}" + }; return new HealthEndpointResult(normalized, response.StatusCode, stopwatch.Elapsed, raw); } } diff --git a/HealthMonitoring/Monitoring/InitializationService.cs b/HealthMonitoring/Monitoring/InitializationService.cs index 41eecd8..a426416 100644 --- a/HealthMonitoring/Monitoring/InitializationService.cs +++ b/HealthMonitoring/Monitoring/InitializationService.cs @@ -62,41 +62,89 @@ public async Task GetStatusAsync(CancellationToken cancell public async Task InitializeAsync(InitializationRequest request, string dashboardEnvironment, CancellationToken cancellationToken) { - if (options.RequireSqlServer && string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) throw new ArgumentException("SQL Server connection string is required.", nameof(request)); - if (options.RequireKurrentDb && string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) throw new ArgumentException("KurrentDB connection string is required.", nameof(request)); + ValidateRequiredConnections(request); + if (request.TestConnections) await TestConnectionsAsync(request, dashboardEnvironment, cancellationToken); + await SaveConfiguredServicesAsync(request, dashboardEnvironment, cancellationToken); + } + + private void ValidateRequiredConnections(InitializationRequest request) + { + if (options.RequireSqlServer && string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) + throw new ArgumentException("SQL Server connection string is required.", nameof(request)); + if (options.RequireKurrentDb && string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) + throw new ArgumentException("KurrentDB connection string is required.", nameof(request)); + } - if (request.TestConnections && !string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) + private async Task TestConnectionsAsync(InitializationRequest request, string dashboardEnvironment, CancellationToken cancellationToken) + { + if (!string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) + await TestSqlServerAsync(request.SqlServerConnectionString, dashboardEnvironment, cancellationToken); + if (!string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) + await TestKurrentDbAsync(request.KurrentDbConnectionString, dashboardEnvironment, cancellationToken); + } + + private async Task TestSqlServerAsync(string connectionString, string dashboardEnvironment, CancellationToken cancellationToken) + { + var service = ServiceConfigurationValidator.ToService(new ServiceRegistrationRequest { - var sqlServer = ServiceConfigurationValidator.ToService(new ServiceRegistrationRequest - { - ServiceId = "sql-server", - Name = "SQL Server", - MonitorType = MonitorType.SqlServer, - ConnectionString = request.SqlServerConnectionString - }, dashboardEnvironment); - var sqlResult = await sqlServerClient.CheckAsync(sqlServer, cancellationToken); - if (sqlResult.Normalized.Status == HealthStatus.Unhealthy) throw new ArgumentException($"SQL Server connection test failed: {sqlResult.Normalized.Error}"); - } - - if (request.TestConnections && !string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) + ServiceId = "sql-server", + Name = "SQL Server", + MonitorType = MonitorType.SqlServer, + ConnectionString = connectionString + }, dashboardEnvironment); + var result = await sqlServerClient.CheckAsync(service, cancellationToken); + ThrowIfConnectionTestFailed(result.Normalized, "SQL Server"); + } + + private async Task TestKurrentDbAsync(string connectionString, string dashboardEnvironment, CancellationToken cancellationToken) + { + var service = ServiceConfigurationValidator.ToService(new ServiceRegistrationRequest { - var kurrentDb = ServiceConfigurationValidator.ToService(new ServiceRegistrationRequest - { - ServiceId = "kurrentdb", - Name = "KurrentDB", - MonitorType = MonitorType.KurrentDb, - ConnectionString = request.KurrentDbConnectionString - }, dashboardEnvironment); - var kurrentResult = await kurrentDbClient.CheckAsync(kurrentDb, cancellationToken); - if (kurrentResult.Normalized.Status == HealthStatus.Unhealthy) throw new ArgumentException($"KurrentDB connection test failed: {kurrentResult.Normalized.Error}"); - } + ServiceId = "kurrentdb", + Name = "KurrentDB", + MonitorType = MonitorType.KurrentDb, + ConnectionString = connectionString + }, dashboardEnvironment); + var result = await kurrentDbClient.CheckAsync(service, cancellationToken); + ThrowIfConnectionTestFailed(result.Normalized, "KurrentDB"); + } + + private static void ThrowIfConnectionTestFailed(NormalizedHealthResult result, string name) + { + if (result.Status == HealthStatus.Unhealthy) + throw new ArgumentException($"{name} connection test failed: {result.Error}"); + } + private async Task SaveConfiguredServicesAsync(InitializationRequest request, string dashboardEnvironment, CancellationToken cancellationToken) + { if (!string.IsNullOrWhiteSpace(request.SqlServerConnectionString)) - await SaveAsync(new ServiceRegistrationRequest { ServiceId = "sql-server", Name = "SQL Server", MonitorType = MonitorType.SqlServer, ConnectionString = request.SqlServerConnectionString, PollingIntervalSeconds = 60, RequestTimeoutSeconds = 10, RetentionDays = 365 }, dashboardEnvironment, cancellationToken); + await SaveAsync(CreateSqlServerRequest(request.SqlServerConnectionString), dashboardEnvironment, cancellationToken); if (!string.IsNullOrWhiteSpace(request.KurrentDbConnectionString)) - await SaveAsync(new ServiceRegistrationRequest { ServiceId = "kurrentdb", Name = "KurrentDB", MonitorType = MonitorType.KurrentDb, ConnectionString = request.KurrentDbConnectionString, PollingIntervalSeconds = 60, RequestTimeoutSeconds = 10, RetentionDays = 365 }, dashboardEnvironment, cancellationToken); + await SaveAsync(CreateKurrentDbRequest(request.KurrentDbConnectionString), dashboardEnvironment, cancellationToken); } + private static ServiceRegistrationRequest CreateSqlServerRequest(string connectionString) => new() + { + ServiceId = "sql-server", + Name = "SQL Server", + MonitorType = MonitorType.SqlServer, + ConnectionString = connectionString, + PollingIntervalSeconds = 60, + RequestTimeoutSeconds = 10, + RetentionDays = 365 + }; + + private static ServiceRegistrationRequest CreateKurrentDbRequest(string connectionString) => new() + { + ServiceId = "kurrentdb", + Name = "KurrentDB", + MonitorType = MonitorType.KurrentDb, + ConnectionString = connectionString, + PollingIntervalSeconds = 60, + RequestTimeoutSeconds = 10, + RetentionDays = 365 + }; + private async Task SaveAsync(ServiceRegistrationRequest request, string dashboardEnvironment, CancellationToken cancellationToken) { var errors = ServiceConfigurationValidator.Validate(request); diff --git a/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs b/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs index 7466bfd..baadeb2 100644 --- a/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs +++ b/HealthMonitoring/Monitoring/SqlServerMonitorClient.cs @@ -2,6 +2,7 @@ using System.Text.Json; using HealthMonitoring.Domain; using Microsoft.Data.SqlClient; +using Microsoft.Extensions.Options; namespace HealthMonitoring.Monitoring; @@ -10,6 +11,11 @@ public interface ISqlServerProbe Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken); } +public sealed class SqlServerMonitorOptions +{ + public string[] AllowedDataSources { get; init; } = []; +} + public sealed class SqlServerMonitorClient(ISqlServerProbe probe) : IHealthEndpointClient { public async Task CheckAsync(MonitoredService service, CancellationToken cancellationToken) @@ -46,29 +52,41 @@ private static HealthEndpointResult Failure(MonitoredService service, TimeSpan d } } -internal sealed class SqlServerProbe : ISqlServerProbe +internal sealed class SqlServerProbe(IOptions options) : ISqlServerProbe { public async Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(service.ConnectionString)) throw new InvalidOperationException("SQL Server connection string is not configured."); + var connectionString = ValidateConnectionString(service.ConnectionString, options.Value.AllowedDataSources); + using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + timeoutSource.CancelAfter(service.RequestTimeout); + await using var connection = new SqlConnection(connectionString); + await connection.OpenAsync(timeoutSource.Token); + await using var command = connection.CreateCommand(); + command.CommandText = "SELECT 1"; + command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(service.RequestTimeout.TotalSeconds)); + await command.ExecuteScalarAsync(timeoutSource.Token); + } + + private static string ValidateConnectionString(string connectionString, IReadOnlyCollection allowedDataSources) + { SqlConnectionStringBuilder builder; try { - builder = new SqlConnectionStringBuilder(service.ConnectionString); + builder = new SqlConnectionStringBuilder(connectionString); } catch (ArgumentException exception) { throw new InvalidOperationException("SQL Server connection string is invalid.", exception); } + if (allowedDataSources.Count == 0) + throw new InvalidOperationException("No SQL Server data sources are configured for monitoring."); - using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - timeoutSource.CancelAfter(service.RequestTimeout); - await using var connection = new SqlConnection(builder.ConnectionString); - await connection.OpenAsync(timeoutSource.Token); - await using var command = connection.CreateCommand(); - command.CommandText = "SELECT 1"; - command.CommandTimeout = Math.Max(1, (int)Math.Ceiling(service.RequestTimeout.TotalSeconds)); - await command.ExecuteScalarAsync(timeoutSource.Token); + var dataSource = builder.DataSource.Trim(); + if (!allowedDataSources.Any(allowed => string.Equals(allowed.Trim(), dataSource, StringComparison.OrdinalIgnoreCase))) + throw new InvalidOperationException($"SQL Server data source '{dataSource}' is not allowed for monitoring."); + + return builder.ConnectionString; } } diff --git a/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs b/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs index 05c9434..77023a1 100644 --- a/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs +++ b/HealthMonitoring/Persistence/HealthMonitoringDbContext.cs @@ -6,6 +6,9 @@ namespace HealthMonitoring.Persistence; public sealed class HealthMonitoringDbContext(DbContextOptions options) : DbContext(options) { + private const string BigintColumnType = "bigint"; + private const string NvarcharMaxColumnType = "nvarchar(max)"; + private static readonly ValueConverter TimeSpanTicksConverter = new( value => value.Ticks, value => TimeSpan.FromTicks(value)); @@ -32,11 +35,11 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.Property(service => service.Environment).HasMaxLength(100).IsRequired(); entity.Property(service => service.MonitorType).HasConversion().HasMaxLength(50).IsRequired(); entity.Property(service => service.HealthUrl).HasConversion().HasMaxLength(2048).IsRequired(); - entity.Property(service => service.ConnectionString).HasColumnType("nvarchar(max)"); - entity.Property(service => service.StatusPolicyJson).HasColumnType("nvarchar(max)").IsRequired(); - entity.Property(service => service.PollingInterval).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); - entity.Property(service => service.RequestTimeout).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); - entity.Property(service => service.RetentionPeriod).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); + entity.Property(service => service.ConnectionString).HasColumnType(NvarcharMaxColumnType); + entity.Property(service => service.StatusPolicyJson).HasColumnType(NvarcharMaxColumnType).IsRequired(); + entity.Property(service => service.PollingInterval).HasConversion(TimeSpanTicksConverter).HasColumnType(BigintColumnType); + entity.Property(service => service.RequestTimeout).HasConversion(TimeSpanTicksConverter).HasColumnType(BigintColumnType); + entity.Property(service => service.RetentionPeriod).HasConversion(TimeSpanTicksConverter).HasColumnType(BigintColumnType); entity.Ignore(service => service.StatusPolicy); }); @@ -46,9 +49,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasIndex(observation => new { observation.MonitoredServiceId, observation.ObservedAtUtc }); entity.Property(observation => observation.Status).HasConversion().HasMaxLength(20); entity.Property(observation => observation.AggregateStatus).HasMaxLength(50).IsRequired(); - entity.Property(observation => observation.ResponseDuration).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); - entity.Property(observation => observation.RawResponseJson).HasColumnType("nvarchar(max)"); - entity.Property(observation => observation.Error).HasColumnType("nvarchar(max)"); + entity.Property(observation => observation.ResponseDuration).HasConversion(TimeSpanTicksConverter).HasColumnType(BigintColumnType); + entity.Property(observation => observation.RawResponseJson).HasColumnType(NvarcharMaxColumnType); + entity.Property(observation => observation.Error).HasColumnType(NvarcharMaxColumnType); entity.HasMany(observation => observation.Checks) .WithOne() .HasForeignKey(checkResult => checkResult.HealthObservationId) @@ -60,9 +63,9 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) entity.HasKey(checkResult => checkResult.Id); entity.Property(checkResult => checkResult.Name).HasMaxLength(300).IsRequired(); entity.Property(checkResult => checkResult.Status).HasConversion().HasMaxLength(20); - entity.Property(checkResult => checkResult.Duration).HasConversion(TimeSpanTicksConverter).HasColumnType("bigint"); - entity.Property(checkResult => checkResult.DataJson).HasColumnType("nvarchar(max)"); - entity.Property(checkResult => checkResult.Exception).HasColumnType("nvarchar(max)"); + entity.Property(checkResult => checkResult.Duration).HasConversion(TimeSpanTicksConverter).HasColumnType(BigintColumnType); + entity.Property(checkResult => checkResult.DataJson).HasColumnType(NvarcharMaxColumnType); + entity.Property(checkResult => checkResult.Exception).HasColumnType(NvarcharMaxColumnType); }); modelBuilder.Entity(entity => @@ -78,8 +81,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) { entity.HasKey(snapshot => snapshot.MonitoredServiceId); entity.Property(snapshot => snapshot.Status).HasConversion().HasMaxLength(20); - entity.Property(snapshot => snapshot.LastResponseDuration).HasConversion(NullableTimeSpanTicksConverter).HasColumnType("bigint"); - entity.Property(snapshot => snapshot.LastError).HasColumnType("nvarchar(max)"); + entity.Property(snapshot => snapshot.LastResponseDuration).HasConversion(NullableTimeSpanTicksConverter).HasColumnType(BigintColumnType); + entity.Property(snapshot => snapshot.LastError).HasColumnType(NvarcharMaxColumnType); }); modelBuilder.Entity(entity => diff --git a/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs index acda5b4..a33cb11 100644 --- a/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs +++ b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs @@ -6,22 +6,108 @@ namespace HealthMonitoring.Persistence; public static class HealthMonitoringSchemaInitializer { - private static readonly LegacyDurationColumn[] LegacyDurationColumns = + private static readonly LegacyDurationMigration[] LegacyDurationMigrations = [ - new("MonitoredServices", "PollingInterval", Nullable: false), - new("MonitoredServices", "RequestTimeout", Nullable: false), - new("MonitoredServices", "RetentionPeriod", Nullable: false), - new("HealthObservations", "ResponseDuration", Nullable: false), - new("HealthCheckResults", "Duration", Nullable: false), - new("ServiceStatusSnapshots", "LastResponseDuration", Nullable: true) + new( + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + ALTER TABLE [dbo].[MonitoredServices] ADD [__PollingIntervalTicks] bigint NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + UPDATE [dbo].[MonitoredServices] + SET [__PollingIntervalTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [PollingInterval]) / 100; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__PollingIntervalTicks] bigint NOT NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + BEGIN + ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [PollingInterval]; + EXEC sp_rename N'dbo.MonitoredServices.__PollingIntervalTicks', N'PollingInterval', N'COLUMN'; + END; + """) , + new( + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') ALTER TABLE [dbo].[MonitoredServices] ADD [__RequestTimeoutTicks] bigint NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') UPDATE [dbo].[MonitoredServices] SET [__RequestTimeoutTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [RequestTimeout]) / 100; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__RequestTimeoutTicks] bigint NOT NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') BEGIN ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [RequestTimeout]; EXEC sp_rename N'dbo.MonitoredServices.__RequestTimeoutTicks', N'RequestTimeout', N'COLUMN'; END; + """) , + new( + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') ALTER TABLE [dbo].[MonitoredServices] ADD [__RetentionPeriodTicks] bigint NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') UPDATE [dbo].[MonitoredServices] SET [__RetentionPeriodTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [RetentionPeriod]) / 100; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__RetentionPeriodTicks] bigint NOT NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') BEGIN ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [RetentionPeriod]; EXEC sp_rename N'dbo.MonitoredServices.__RetentionPeriodTicks', N'RetentionPeriod', N'COLUMN'; END; + """) , + new( + """ + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') ALTER TABLE [dbo].[HealthObservations] ADD [__ResponseDurationTicks] bigint NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') UPDATE [dbo].[HealthObservations] SET [__ResponseDurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [ResponseDuration]) / 100; + """, + """ + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') ALTER TABLE [dbo].[HealthObservations] ALTER COLUMN [__ResponseDurationTicks] bigint NOT NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') BEGIN ALTER TABLE [dbo].[HealthObservations] DROP COLUMN [ResponseDuration]; EXEC sp_rename N'dbo.HealthObservations.__ResponseDurationTicks', N'ResponseDuration', N'COLUMN'; END; + """) , + new( + """ + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') ALTER TABLE [dbo].[HealthCheckResults] ADD [__DurationTicks] bigint NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') UPDATE [dbo].[HealthCheckResults] SET [__DurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [Duration]) / 100; + """, + """ + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') ALTER TABLE [dbo].[HealthCheckResults] ALTER COLUMN [__DurationTicks] bigint NOT NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') BEGIN ALTER TABLE [dbo].[HealthCheckResults] DROP COLUMN [Duration]; EXEC sp_rename N'dbo.HealthCheckResults.__DurationTicks', N'Duration', N'COLUMN'; END; + """) , + new( + """ + IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') ALTER TABLE [dbo].[ServiceStatusSnapshots] ADD [__LastResponseDurationTicks] bigint NULL; + """, + """ + IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') UPDATE [dbo].[ServiceStatusSnapshots] SET [__LastResponseDurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [LastResponseDuration]) / 100; + """, + null, + """ + IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') BEGIN ALTER TABLE [dbo].[ServiceStatusSnapshots] DROP COLUMN [LastResponseDuration]; EXEC sp_rename N'dbo.ServiceStatusSnapshots.__LastResponseDurationTicks', N'LastResponseDuration', N'COLUMN'; END; + """) ]; public static async Task InitializeAsync(HealthMonitoringDbContext dbContext, CancellationToken cancellationToken = default) { await dbContext.Database.EnsureCreatedAsync(cancellationToken); - foreach (var column in LegacyDurationColumns) - await ConvertLegacyColumnAsync(dbContext, column, cancellationToken); + foreach (var migration in LegacyDurationMigrations) + await ConvertLegacyColumnAsync(dbContext, migration, cancellationToken); await dbContext.Database.ExecuteSqlRawAsync(""" IF OBJECT_ID(N'[dbo].[ServiceDependencyLinks]', N'U') IS NULL @@ -64,50 +150,21 @@ ALTER TABLE [dbo].[MonitoredServices] private static async Task ConvertLegacyColumnAsync( HealthMonitoringDbContext dbContext, - LegacyDurationColumn column, + LegacyDurationMigration migration, CancellationToken cancellationToken) { - var table = $"[dbo].[{column.TableName}]"; - var oldColumn = $"[{column.ColumnName}]"; - var temporaryColumn = $"[__{column.ColumnName}Ticks]"; - - await dbContext.Database.ExecuteSqlRawAsync($""" - IF OBJECT_ID(N'{table}', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) - AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') - ALTER TABLE {table} ADD {temporaryColumn} bigint NULL; - """, cancellationToken); - - await dbContext.Database.ExecuteSqlRawAsync($""" - IF OBJECT_ID(N'{table}', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') - UPDATE {table} - SET {temporaryColumn} = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), {oldColumn}) / 100; - """, cancellationToken); - - if (!column.Nullable) - { - await dbContext.Database.ExecuteSqlRawAsync($""" - IF OBJECT_ID(N'{table}', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') - ALTER TABLE {table} ALTER COLUMN {temporaryColumn} bigint NOT NULL; - """, cancellationToken); - } - - await dbContext.Database.ExecuteSqlRawAsync($""" - IF OBJECT_ID(N'{table}', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'{column.ColumnName}' AND system_type_id = 41) - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'{table}') AND name = N'__{column.ColumnName}Ticks') - BEGIN - ALTER TABLE {table} DROP COLUMN {oldColumn}; - EXEC sp_rename N'dbo.{column.TableName}.__{column.ColumnName}Ticks', N'{column.ColumnName}', N'COLUMN'; - END; - """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(migration.AddTemporaryColumnSql, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(migration.CopyDurationSql, cancellationToken); + if (migration.MakeRequiredSql is not null) + await dbContext.Database.ExecuteSqlRawAsync(migration.MakeRequiredSql, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(migration.ReplaceColumnSql, cancellationToken); } - private sealed record LegacyDurationColumn(string TableName, string ColumnName, bool Nullable); + private sealed record LegacyDurationMigration( + string AddTemporaryColumnSql, + string CopyDurationSql, + string? MakeRequiredSql, + string ReplaceColumnSql); } #pragma warning restore EF1002 diff --git a/HealthMonitoring/Program.cs b/HealthMonitoring/Program.cs index 1a7e3fc..0f724ae 100644 --- a/HealthMonitoring/Program.cs +++ b/HealthMonitoring/Program.cs @@ -6,6 +6,7 @@ using HealthMonitoring.Components; using HealthMonitoring.Health; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); @@ -15,6 +16,7 @@ RequireSqlServer = builder.Configuration.GetValue("Initialization:RequireSqlServer", true), RequireKurrentDb = builder.Configuration.GetValue("Initialization:RequireKurrentDb", true) }); +builder.Services.Configure(builder.Configuration.GetSection("Monitoring:SqlServer")); builder.Services.AddDbContext(options => options.UseSqlServer(builder.Configuration.GetConnectionString("HealthMonitoring"))); diff --git a/HealthMonitoring/Reporting/DashboardQueryService.cs b/HealthMonitoring/Reporting/DashboardQueryService.cs index cfc2073..673e7ff 100644 --- a/HealthMonitoring/Reporting/DashboardQueryService.cs +++ b/HealthMonitoring/Reporting/DashboardQueryService.cs @@ -24,7 +24,9 @@ public async Task GetOverviewAsync(TimeSpan range, Cancellati rows.Add(ToRow(service, snapshot, observations)); } - var incidents = await dbContext.ServiceIncidents.AsNoTracking().CountAsync(incident => incident.EndedAtUtc == null, cancellationToken); + var incidents = await dbContext.ServiceIncidents.AsNoTracking() + .Where(incident => incident.EndedAtUtc == null) + .CountAsync(incident => dbContext.MonitoredServices.Any(service => service.Id == incident.MonitoredServiceId && service.ArchivedAtUtc == null), cancellationToken); return new DashboardOverview(rows.OrderBy(row => row.Name).ToArray(), rows.Count(row => row.Status == HealthStatus.Healthy), rows.Count(row => row.Status == HealthStatus.Degraded), rows.Count(row => row.Status == HealthStatus.Unhealthy), incidents); } diff --git a/HealthMonitoring/appsettings.Development.json b/HealthMonitoring/appsettings.Development.json index 7771a13..b543ffa 100644 --- a/HealthMonitoring/appsettings.Development.json +++ b/HealthMonitoring/appsettings.Development.json @@ -1,5 +1,10 @@ { "ConnectionStrings": { "HealthMonitoring": "Server=localhost;Database=HealthMonitoring;User Id=sa;Password=sp1ttal;TrustServerCertificate=True;Encrypt=False;" + }, + "Monitoring": { + "SqlServer": { + "AllowedDataSources": ["localhost", ".", "(local)", "sqlserver"] + } } } diff --git a/HealthMonitoring/appsettings.json b/HealthMonitoring/appsettings.json index 822932b..827c5d3 100644 --- a/HealthMonitoring/appsettings.json +++ b/HealthMonitoring/appsettings.json @@ -11,5 +11,10 @@ "Initialization": { "RequireSqlServer": true, "RequireKurrentDb": true + }, + "Monitoring": { + "SqlServer": { + "AllowedDataSources": ["localhost", ".", "(local)", "sqlserver"] + } } } From 3ecbe18ccfae875b3f3d291e07bd144a89daf20b Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 19:56:52 +0100 Subject: [PATCH 16/17] Fix Codacy SQL injection findings --- .../HealthMonitoringSchemaInitializer.cs | 256 +++++++++++------- 1 file changed, 152 insertions(+), 104 deletions(-) diff --git a/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs index a33cb11..81fbe60 100644 --- a/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs +++ b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs @@ -6,100 +6,14 @@ namespace HealthMonitoring.Persistence; public static class HealthMonitoringSchemaInitializer { - private static readonly LegacyDurationMigration[] LegacyDurationMigrations = + private static readonly LegacyDurationColumn[] LegacyDurationMigrations = [ - new( - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) - AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') - ALTER TABLE [dbo].[MonitoredServices] ADD [__PollingIntervalTicks] bigint NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') - UPDATE [dbo].[MonitoredServices] - SET [__PollingIntervalTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [PollingInterval]) / 100; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') - ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__PollingIntervalTicks] bigint NOT NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) - AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') - BEGIN - ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [PollingInterval]; - EXEC sp_rename N'dbo.MonitoredServices.__PollingIntervalTicks', N'PollingInterval', N'COLUMN'; - END; - """) , - new( - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') ALTER TABLE [dbo].[MonitoredServices] ADD [__RequestTimeoutTicks] bigint NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') UPDATE [dbo].[MonitoredServices] SET [__RequestTimeoutTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [RequestTimeout]) / 100; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__RequestTimeoutTicks] bigint NOT NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') BEGIN ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [RequestTimeout]; EXEC sp_rename N'dbo.MonitoredServices.__RequestTimeoutTicks', N'RequestTimeout', N'COLUMN'; END; - """) , - new( - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') ALTER TABLE [dbo].[MonitoredServices] ADD [__RetentionPeriodTicks] bigint NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') UPDATE [dbo].[MonitoredServices] SET [__RetentionPeriodTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [RetentionPeriod]) / 100; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__RetentionPeriodTicks] bigint NOT NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') BEGIN ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [RetentionPeriod]; EXEC sp_rename N'dbo.MonitoredServices.__RetentionPeriodTicks', N'RetentionPeriod', N'COLUMN'; END; - """) , - new( - """ - IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') ALTER TABLE [dbo].[HealthObservations] ADD [__ResponseDurationTicks] bigint NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') UPDATE [dbo].[HealthObservations] SET [__ResponseDurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [ResponseDuration]) / 100; - """, - """ - IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') ALTER TABLE [dbo].[HealthObservations] ALTER COLUMN [__ResponseDurationTicks] bigint NOT NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') BEGIN ALTER TABLE [dbo].[HealthObservations] DROP COLUMN [ResponseDuration]; EXEC sp_rename N'dbo.HealthObservations.__ResponseDurationTicks', N'ResponseDuration', N'COLUMN'; END; - """) , - new( - """ - IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') ALTER TABLE [dbo].[HealthCheckResults] ADD [__DurationTicks] bigint NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') UPDATE [dbo].[HealthCheckResults] SET [__DurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [Duration]) / 100; - """, - """ - IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') ALTER TABLE [dbo].[HealthCheckResults] ALTER COLUMN [__DurationTicks] bigint NOT NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') BEGIN ALTER TABLE [dbo].[HealthCheckResults] DROP COLUMN [Duration]; EXEC sp_rename N'dbo.HealthCheckResults.__DurationTicks', N'Duration', N'COLUMN'; END; - """) , - new( - """ - IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') ALTER TABLE [dbo].[ServiceStatusSnapshots] ADD [__LastResponseDurationTicks] bigint NULL; - """, - """ - IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') UPDATE [dbo].[ServiceStatusSnapshots] SET [__LastResponseDurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [LastResponseDuration]) / 100; - """, - null, - """ - IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') BEGIN ALTER TABLE [dbo].[ServiceStatusSnapshots] DROP COLUMN [LastResponseDuration]; EXEC sp_rename N'dbo.ServiceStatusSnapshots.__LastResponseDurationTicks', N'LastResponseDuration', N'COLUMN'; END; - """) + LegacyDurationColumn.PollingInterval, + LegacyDurationColumn.RequestTimeout, + LegacyDurationColumn.RetentionPeriod, + LegacyDurationColumn.ResponseDuration, + LegacyDurationColumn.Duration, + LegacyDurationColumn.LastResponseDuration ]; public static async Task InitializeAsync(HealthMonitoringDbContext dbContext, CancellationToken cancellationToken = default) @@ -150,21 +64,155 @@ ALTER TABLE [dbo].[MonitoredServices] private static async Task ConvertLegacyColumnAsync( HealthMonitoringDbContext dbContext, - LegacyDurationMigration migration, + LegacyDurationColumn migration, + CancellationToken cancellationToken) + { + switch (migration) + { + case LegacyDurationColumn.PollingInterval: + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + ALTER TABLE [dbo].[MonitoredServices] ADD [__PollingIntervalTicks] bigint NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + UPDATE [dbo].[MonitoredServices] + SET [__PollingIntervalTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [PollingInterval]) / 100; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__PollingIntervalTicks] bigint NOT NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'PollingInterval' AND system_type_id = 41) + AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__PollingIntervalTicks') + BEGIN + ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [PollingInterval]; + EXEC sp_rename N'dbo.MonitoredServices.__PollingIntervalTicks', N'PollingInterval', N'COLUMN'; + END; + """, cancellationToken); + break; + + case LegacyDurationColumn.RequestTimeout: + await ConvertLegacyMonitoredServiceDurationAsync(dbContext, LegacyDurationColumn.RequestTimeout, cancellationToken); + break; + + case LegacyDurationColumn.RetentionPeriod: + await ConvertLegacyMonitoredServiceDurationAsync(dbContext, LegacyDurationColumn.RetentionPeriod, cancellationToken); + break; + + case LegacyDurationColumn.ResponseDuration: + await ConvertLegacyHealthObservationDurationAsync(dbContext, cancellationToken); + break; + + case LegacyDurationColumn.Duration: + await ConvertLegacyHealthCheckDurationAsync(dbContext, cancellationToken); + break; + + case LegacyDurationColumn.LastResponseDuration: + await ConvertLegacySnapshotDurationAsync(dbContext, cancellationToken); + break; + } + } + + private static async Task ConvertLegacyMonitoredServiceDurationAsync( + HealthMonitoringDbContext dbContext, + LegacyDurationColumn migration, CancellationToken cancellationToken) { - await dbContext.Database.ExecuteSqlRawAsync(migration.AddTemporaryColumnSql, cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync(migration.CopyDurationSql, cancellationToken); - if (migration.MakeRequiredSql is not null) - await dbContext.Database.ExecuteSqlRawAsync(migration.MakeRequiredSql, cancellationToken); - await dbContext.Database.ExecuteSqlRawAsync(migration.ReplaceColumnSql, cancellationToken); + if (migration == LegacyDurationColumn.RequestTimeout) + { + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') ALTER TABLE [dbo].[MonitoredServices] ADD [__RequestTimeoutTicks] bigint NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') UPDATE [dbo].[MonitoredServices] SET [__RequestTimeoutTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [RequestTimeout]) / 100; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__RequestTimeoutTicks] bigint NOT NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RequestTimeout' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RequestTimeoutTicks') BEGIN ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [RequestTimeout]; EXEC sp_rename N'dbo.MonitoredServices.__RequestTimeoutTicks', N'RequestTimeout', N'COLUMN'; END; + """, cancellationToken); + } + else + { + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') ALTER TABLE [dbo].[MonitoredServices] ADD [__RetentionPeriodTicks] bigint NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') UPDATE [dbo].[MonitoredServices] SET [__RetentionPeriodTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [RetentionPeriod]) / 100; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') ALTER TABLE [dbo].[MonitoredServices] ALTER COLUMN [__RetentionPeriodTicks] bigint NOT NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[MonitoredServices]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'RetentionPeriod' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[MonitoredServices]') AND name = N'__RetentionPeriodTicks') BEGIN ALTER TABLE [dbo].[MonitoredServices] DROP COLUMN [RetentionPeriod]; EXEC sp_rename N'dbo.MonitoredServices.__RetentionPeriodTicks', N'RetentionPeriod', N'COLUMN'; END; + """, cancellationToken); + } } - private sealed record LegacyDurationMigration( - string AddTemporaryColumnSql, - string CopyDurationSql, - string? MakeRequiredSql, - string ReplaceColumnSql); + private static async Task ConvertLegacyHealthObservationDurationAsync(HealthMonitoringDbContext dbContext, CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') ALTER TABLE [dbo].[HealthObservations] ADD [__ResponseDurationTicks] bigint NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') UPDATE [dbo].[HealthObservations] SET [__ResponseDurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [ResponseDuration]) / 100; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') ALTER TABLE [dbo].[HealthObservations] ALTER COLUMN [__ResponseDurationTicks] bigint NOT NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthObservations]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'ResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthObservations]') AND name = N'__ResponseDurationTicks') BEGIN ALTER TABLE [dbo].[HealthObservations] DROP COLUMN [ResponseDuration]; EXEC sp_rename N'dbo.HealthObservations.__ResponseDurationTicks', N'ResponseDuration', N'COLUMN'; END; + """, cancellationToken); + } + + private static async Task ConvertLegacyHealthCheckDurationAsync(HealthMonitoringDbContext dbContext, CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') ALTER TABLE [dbo].[HealthCheckResults] ADD [__DurationTicks] bigint NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') UPDATE [dbo].[HealthCheckResults] SET [__DurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [Duration]) / 100; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') ALTER TABLE [dbo].[HealthCheckResults] ALTER COLUMN [__DurationTicks] bigint NOT NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[HealthCheckResults]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'Duration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[HealthCheckResults]') AND name = N'__DurationTicks') BEGIN ALTER TABLE [dbo].[HealthCheckResults] DROP COLUMN [Duration]; EXEC sp_rename N'dbo.HealthCheckResults.__DurationTicks', N'Duration', N'COLUMN'; END; + """, cancellationToken); + } + + private static async Task ConvertLegacySnapshotDurationAsync(HealthMonitoringDbContext dbContext, CancellationToken cancellationToken) + { + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND NOT EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') ALTER TABLE [dbo].[ServiceStatusSnapshots] ADD [__LastResponseDurationTicks] bigint NULL; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') UPDATE [dbo].[ServiceStatusSnapshots] SET [__LastResponseDurationTicks] = DATEDIFF_BIG(NANOSECOND, CAST('00:00:00' AS time), [LastResponseDuration]) / 100; + """, cancellationToken); + await dbContext.Database.ExecuteSqlRawAsync(""" + IF OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]', N'U') IS NOT NULL AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'LastResponseDuration' AND system_type_id = 41) AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[ServiceStatusSnapshots]') AND name = N'__LastResponseDurationTicks') BEGIN ALTER TABLE [dbo].[ServiceStatusSnapshots] DROP COLUMN [LastResponseDuration]; EXEC sp_rename N'dbo.ServiceStatusSnapshots.__LastResponseDurationTicks', N'LastResponseDuration', N'COLUMN'; END; + """, cancellationToken); + } + + private enum LegacyDurationColumn + { + PollingInterval, + RequestTimeout, + RetentionPeriod, + ResponseDuration, + Duration, + LastResponseDuration + } } #pragma warning restore EF1002 From f361e59699aa26ac16d9bae769f83e414c9db38d Mon Sep 17 00:00:00 2001 From: StuartFerguson Date: Fri, 18 Sep 2026 20:20:59 +0100 Subject: [PATCH 17/17] Resolve remaining Codacy issues --- ...HealthMonitoringRegistrationClientTests.cs | 6 ++++-- .../Monitoring/HealthEndpointClientTests.cs | 20 +++++++++++++------ .../Monitoring/KurrentDbMonitorClientTests.cs | 8 ++++++-- .../Monitoring/SqlServerMonitorClientTests.cs | 8 ++++++-- .../HealthMonitoringSchemaInitializer.cs | 3 +++ HealthMonitoring/Program.cs | 7 ++++++- 6 files changed, 39 insertions(+), 13 deletions(-) diff --git a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs index 3bf5e75..167cb6f 100644 --- a/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs +++ b/HealthMonitoring.Tests/Client/HealthMonitoringRegistrationClientTests.cs @@ -112,8 +112,9 @@ private sealed class RecordingHandler(Func SendAsync(HttpRequestMessage request, CancellationToken _) + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); Request = request; return Task.FromResult(responseFactory(request)); } @@ -124,8 +125,9 @@ private sealed class QueueHandler(params HttpResponseMessage[] responses) : Http private int _index; public List Requests { get; } = []; - protected override Task SendAsync(HttpRequestMessage request, CancellationToken _) + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + cancellationToken.ThrowIfCancellationRequested(); Requests.Add(request); return Task.FromResult(responses[_index++]); } diff --git a/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs index cb87a2f..42de61f 100644 --- a/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs +++ b/HealthMonitoring.Tests/Monitoring/HealthEndpointClientTests.cs @@ -8,12 +8,14 @@ namespace HealthMonitoring.Tests.Monitoring; public sealed class HealthEndpointClientTests { + private const string HealthEndpointUrl = "https://service/health"; + [Fact] public async Task Parses_standard_aspnet_health_response() { var handler = new StubHandler(HttpStatusCode.OK, "{\"status\":\"Healthy\",\"entries\":{\"database\":{\"status\":\"Healthy\",\"duration\":\"00:00:00.004\",\"data\":{}}}}" ); var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); - var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + var service = new MonitoredService { HealthUrl = new Uri(HealthEndpointUrl) }; var result = await client.CheckAsync(service, CancellationToken.None); @@ -27,7 +29,7 @@ public async Task Timeout_is_recorded_as_unhealthy_observation_candidate() { var handler = new StubHandler(new TaskCanceledException("timeout")); var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); - var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + var service = new MonitoredService { HealthUrl = new Uri(HealthEndpointUrl) }; var result = await client.CheckAsync(service, CancellationToken.None); @@ -41,7 +43,7 @@ public async Task Non_success_response_preserves_degraded_policy_for_non_critica var body = "{\"status\":\"Unhealthy\",\"entries\":{\"cache\":{\"status\":\"Unhealthy\",\"duration\":\"00:00:00.004\",\"data\":{}}}}"; var handler = new StubHandler(HttpStatusCode.ServiceUnavailable, body); var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); - var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + var service = new MonitoredService { HealthUrl = new Uri(HealthEndpointUrl) }; var result = await client.CheckAsync(service, CancellationToken.None); @@ -56,7 +58,7 @@ public async Task Invalid_health_payload_is_recorded_as_unhealthy_observation_ca { var handler = new StubHandler(HttpStatusCode.OK, body); var client = new HealthEndpointClient(new StubHttpClientFactory(handler), new HealthStatusNormalizer()); - var service = new MonitoredService { HealthUrl = new Uri("https://service/health") }; + var service = new MonitoredService { HealthUrl = new Uri(HealthEndpointUrl) }; var result = await client.CheckAsync(service, CancellationToken.None); @@ -73,8 +75,10 @@ private sealed class StubHandler : HttpMessageHandler public StubHandler(HttpStatusCode status, string body) { _status = status; _body = body; } public StubHandler(Exception exception) { _exception = exception; } - protected override Task SendAsync(HttpRequestMessage _, CancellationToken cancellationToken) + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) { + _ = request; + cancellationToken.ThrowIfCancellationRequested(); if (_exception is not null) return Task.FromException(_exception); return Task.FromResult(new HttpResponseMessage(_status!.Value) { Content = new StringContent(_body!) }); } @@ -82,6 +86,10 @@ protected override Task SendAsync(HttpRequestMessage _, Can private sealed class StubHttpClientFactory(HttpMessageHandler handler) : IHttpClientFactory { - public HttpClient CreateClient(string _) => new(handler, disposeHandler: false); + public HttpClient CreateClient(string name) + { + _ = name; + return new(handler, disposeHandler: false); + } } } diff --git a/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs b/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs index 790b15e..6891757 100644 --- a/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs +++ b/HealthMonitoring.Tests/Monitoring/KurrentDbMonitorClientTests.cs @@ -34,7 +34,11 @@ public async Task Failed_probe_returns_unhealthy_with_the_failure_detail() private sealed class StubProbe(Exception? exception = null) : IKurrentDbProbe { - public Task ProbeAsync(MonitoredService _, CancellationToken cancellationToken) => - exception is null ? Task.CompletedTask : Task.FromException(exception); + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) + { + _ = service; + cancellationToken.ThrowIfCancellationRequested(); + return exception is null ? Task.CompletedTask : Task.FromException(exception); + } } } diff --git a/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs b/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs index 2e45772..3580c4f 100644 --- a/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs +++ b/HealthMonitoring.Tests/Monitoring/SqlServerMonitorClientTests.cs @@ -33,7 +33,11 @@ public async Task Failed_probe_returns_unhealthy_with_the_failure_detail() private sealed class StubProbe(Exception? exception = null) : ISqlServerProbe { - public Task ProbeAsync(MonitoredService _, CancellationToken cancellationToken) => - exception is null ? Task.CompletedTask : Task.FromException(exception); + public Task ProbeAsync(MonitoredService service, CancellationToken cancellationToken) + { + _ = service; + cancellationToken.ThrowIfCancellationRequested(); + return exception is null ? Task.CompletedTask : Task.FromException(exception); + } } } diff --git a/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs index 81fbe60..dcd0093 100644 --- a/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs +++ b/HealthMonitoring/Persistence/HealthMonitoringSchemaInitializer.cs @@ -119,6 +119,9 @@ AND EXISTS (SELECT 1 FROM sys.columns WHERE object_id = OBJECT_ID(N'[dbo].[Monit case LegacyDurationColumn.LastResponseDuration: await ConvertLegacySnapshotDurationAsync(dbContext, cancellationToken); break; + + default: + throw new ArgumentOutOfRangeException(nameof(migration), migration, "Unknown legacy duration column."); } } diff --git a/HealthMonitoring/Program.cs b/HealthMonitoring/Program.cs index 0f724ae..6315a02 100644 --- a/HealthMonitoring/Program.cs +++ b/HealthMonitoring/Program.cs @@ -49,4 +49,9 @@ await HealthMonitoringSchemaInitializer.InitializeAsync( app.Run(); -public partial class Program; +public partial class Program +{ + protected Program() + { + } +}