From afc53fd3633ee2dc8ef290aa6542ebc3ac4d2efd Mon Sep 17 00:00:00 2001 From: gali Date: Wed, 18 Mar 2026 22:51:17 +0800 Subject: [PATCH 01/15] feat: add endpoint priority feature with sorting and validation --- README.md | 7 ++- .../Cli/CliExecutionReport.cs | 3 + .../Cli/CliExecutionService.cs | 2 + .../Configuration/DashboardConfig.cs | 3 + .../Configuration/DashboardConfigValidator.cs | 6 ++ .../Configuration/EndpointPriority.cs | 57 +++++++++++++++++++ .../Configuration/YamlConfigLoader.cs | 1 + .../Pages/Endpoints/Details.cshtml | 5 ++ .../Pages/Endpoints/Details.cshtml.cs | 17 ++++++ src/ApiHealthDashboard/Pages/Index.cshtml.cs | 26 ++++++++- .../Pages/Shared/_DashboardLiveSection.cshtml | 10 +++- .../Services/EndpointImportService.cs | 4 +- .../endpoints/billing-api.yaml | 1 + .../endpoints/orders-api.yaml | 1 + .../Cli/CliExecutionServiceTests.cs | 5 ++ .../DashboardConfigValidatorTests.cs | 23 ++++++++ .../Configuration/YamlConfigLoaderTests.cs | 3 + .../Pages/Endpoints/DetailsModelTests.cs | 3 + .../Pages/IndexModelTests.cs | 20 +++++++ .../Services/EndpointImportServiceTests.cs | 4 ++ 20 files changed, 194 insertions(+), 7 deletions(-) create mode 100644 src/ApiHealthDashboard/Configuration/EndpointPriority.cs diff --git a/README.md b/README.md index 2351e9e..c2c686a 100644 --- a/README.md +++ b/README.md @@ -85,6 +85,7 @@ Current configuration support: - `dashboard.showRawPayload` - `dashboard.endpointFiles` for loading endpoints from one or more separate YAML files - endpoint `id`, `name`, `url`, `enabled`, `frequencySeconds`, `timeoutSeconds` +- endpoint `priority` with `Critical`, `High`, `Normal`, or `Low` - endpoint `headers`, `includeChecks`, `excludeChecks` - `${ENV_VAR}` substitution in YAML values - endpoint definitions can be kept inline in `dashboard.yaml` or split into multiple files under [`src/ApiHealthDashboard/endpoints`](src/ApiHealthDashboard/endpoints) @@ -202,6 +203,7 @@ Current dashboard behavior: - highlights healthy, degraded, unhealthy, and unknown totals in summary cards - includes a client-side search field for filtering endpoint rows by name, id, status, or error text - refreshes the live dashboard section with same-origin timed GET requests instead of reloading the whole page +- sorts endpoint summaries and active issues by endpoint priority before name - renders a live endpoint table with last check, duration, error summary, and manual refresh actions - surfaces degraded and unhealthy endpoints in an active issues panel for faster triage - shows a clearer empty state when no endpoints are configured @@ -214,6 +216,7 @@ Current import behavior: - sends a live request using the entered URL, headers, timeout, and enabled/frequency settings - auto-suggests endpoint id and name when those fields are left blank - shows a soft warning when the chosen poll frequency is below the configured appsettings recommendation +- emits generated YAML with a normalized endpoint priority value - parses discovered checks from the response and can optionally populate `includeChecks` - generates a normalized YAML snippet for manual copy into `dashboard.yaml` or a separate endpoint file - compares the generated YAML against the currently loaded config when an existing endpoint matches by id or URL @@ -228,6 +231,7 @@ Current CLI behavior: - executes one or more specific endpoint YAML files with repeated `--endpoint-file` arguments - reuses dashboard settings from `dashboard.yaml`, including default request timeout values - writes a machine-readable JSON report to standard output +- includes endpoint priority in JSON and XML execution reports - can optionally write the same execution report to a JSON or XML file - keeps missing dashboard or endpoint YAML files as warnings in the output instead of failing hard @@ -236,7 +240,7 @@ Current CLI behavior: The endpoint details page now acts as a diagnostic view for a single configured endpoint. Current details-page behavior: -- shows endpoint metadata including enabled state, frequency, timeout, and masked request headers +- shows endpoint metadata including enabled state, priority, frequency, timeout, and masked request headers - shows request filter configuration for included and excluded checks - summarizes the latest poll with status, timings, retrieved timestamp, and current error - renders top-level and nested health checks recursively with native expand and collapse support @@ -478,7 +482,6 @@ Test file: ## Future Plans These are planned enhancements after the current v1 path: -- allow per-endpoint priority so important endpoints can be surfaced and scheduled differently - optionally allow email sending, either through direct SMTP configuration or by calling an external API ## Notes For Ongoing Updates diff --git a/src/ApiHealthDashboard/Cli/CliExecutionReport.cs b/src/ApiHealthDashboard/Cli/CliExecutionReport.cs index 2622052..e4410fe 100644 --- a/src/ApiHealthDashboard/Cli/CliExecutionReport.cs +++ b/src/ApiHealthDashboard/Cli/CliExecutionReport.cs @@ -1,4 +1,5 @@ using System.Xml.Serialization; +using ApiHealthDashboard.Configuration; namespace ApiHealthDashboard.Cli; @@ -61,6 +62,8 @@ public sealed class CliEndpointExecutionReport public bool Enabled { get; set; } + public string Priority { get; set; } = EndpointPriority.Normal; + public int FrequencySeconds { get; set; } public int? TimeoutSeconds { get; set; } diff --git a/src/ApiHealthDashboard/Cli/CliExecutionService.cs b/src/ApiHealthDashboard/Cli/CliExecutionService.cs index be19938..0f84b22 100644 --- a/src/ApiHealthDashboard/Cli/CliExecutionService.cs +++ b/src/ApiHealthDashboard/Cli/CliExecutionService.cs @@ -83,6 +83,7 @@ private async Task ExecuteEndpointAsync( Name = endpoint.Name, Url = endpoint.Url, Enabled = false, + Priority = EndpointPriority.Normalize(endpoint.Priority), FrequencySeconds = endpoint.FrequencySeconds, TimeoutSeconds = endpoint.TimeoutSeconds ?? config.Dashboard.RequestTimeoutSecondsDefault, ExecutionState = "Skipped", @@ -99,6 +100,7 @@ private async Task ExecuteEndpointAsync( Name = endpoint.Name, Url = endpoint.Url, Enabled = true, + Priority = EndpointPriority.Normalize(endpoint.Priority), FrequencySeconds = endpoint.FrequencySeconds, TimeoutSeconds = endpoint.TimeoutSeconds ?? config.Dashboard.RequestTimeoutSecondsDefault, ExecutionState = "Executed", diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs index 727e81d..1901942 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs @@ -61,6 +61,8 @@ public sealed class EndpointConfig public int? TimeoutSeconds { get; set; } + public string Priority { get; set; } = EndpointPriority.Normal; + public Dictionary Headers { get; set; } = new(StringComparer.OrdinalIgnoreCase); public List IncludeChecks { get; set; } = new(); @@ -77,6 +79,7 @@ public EndpointConfig Clone() Enabled = Enabled, FrequencySeconds = FrequencySeconds, TimeoutSeconds = TimeoutSeconds, + Priority = Priority, Headers = new Dictionary(Headers, StringComparer.OrdinalIgnoreCase), IncludeChecks = [.. IncludeChecks], ExcludeChecks = [.. ExcludeChecks] diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs b/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs index 08f175c..6805a9f 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs @@ -58,6 +58,12 @@ public IReadOnlyList Validate(DashboardConfig config) errors.Add($"{prefix}.timeoutSeconds must be greater than zero when specified."); } + if (!EndpointPriority.IsValid(endpoint.Priority)) + { + errors.Add( + $"{prefix}.priority must be one of: {string.Join(", ", EndpointPriority.AllowedValues)}."); + } + foreach (var header in endpoint.Headers) { if (string.IsNullOrWhiteSpace(header.Key)) diff --git a/src/ApiHealthDashboard/Configuration/EndpointPriority.cs b/src/ApiHealthDashboard/Configuration/EndpointPriority.cs new file mode 100644 index 0000000..173f0ef --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/EndpointPriority.cs @@ -0,0 +1,57 @@ +namespace ApiHealthDashboard.Configuration; + +public static class EndpointPriority +{ + public const string Critical = "Critical"; + public const string High = "High"; + public const string Normal = "Normal"; + public const string Low = "Low"; + + public static IReadOnlyList AllowedValues { get; } = + [ + Critical, + High, + Normal, + Low + ]; + + public static string Normalize(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return Normal; + } + + return value.Trim().ToLowerInvariant() switch + { + "critical" => Critical, + "high" => High, + "normal" => Normal, + "low" => Low, + _ => value.Trim() + }; + } + + public static bool IsValid(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return true; + } + + var normalized = Normalize(value); + return AllowedValues.Contains(normalized, StringComparer.OrdinalIgnoreCase); + } + + public static int GetSortOrder(string? value) + { + return Normalize(value) switch + { + Critical => 4, + High => 3, + Normal => 2, + Low => 1, + _ => 0 + }; + } +} diff --git a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs index 6969b69..24f5616 100644 --- a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs +++ b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs @@ -279,6 +279,7 @@ private static void NormalizeEndpoints(List? endpoints) endpoint.Id = endpoint.Id?.Trim() ?? string.Empty; endpoint.Name = endpoint.Name?.Trim() ?? string.Empty; endpoint.Url = endpoint.Url?.Trim() ?? string.Empty; + endpoint.Priority = EndpointPriority.Normalize(endpoint.Priority); endpoint.Headers = endpoint.Headers is null ? new Dictionary(StringComparer.OrdinalIgnoreCase) : new Dictionary(endpoint.Headers, StringComparer.OrdinalIgnoreCase); diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml index 523fdf9..75de863 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml @@ -61,6 +61,7 @@ else Overall status
@endpoint.Status + @endpoint.Priority @if (endpoint.IsPolling) { Polling now @@ -159,6 +160,10 @@ else Polling Frequency
@endpoint.FrequencyText
+
+ Priority +
@endpoint.Priority
+
Timeout
@endpoint.TimeoutText
diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs index ad389ed..a5890f2 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs @@ -118,6 +118,10 @@ public sealed class EndpointDetailsViewModel public required string EnabledText { get; init; } + public required string Priority { get; init; } + + public required string PriorityBadgeClass { get; init; } + public required string FrequencyText { get; init; } public required string TimeoutText { get; init; } @@ -194,6 +198,8 @@ public static EndpointDetailsViewModel From( Url = endpoint.Url, Enabled = endpoint.Enabled, EnabledText = endpoint.Enabled ? "Enabled" : "Disabled", + Priority = EndpointPriority.Normalize(endpoint.Priority), + PriorityBadgeClass = ToPriorityBadgeClass(endpoint.Priority), FrequencyText = $"{endpoint.FrequencySeconds} seconds", TimeoutText = endpoint.TimeoutSeconds is null ? "Default timeout" : $"{timeoutSeconds} seconds", Status = status, @@ -322,6 +328,17 @@ private static string ToBadgeClass(string status) _ => "badge-secondary" }; } + + private static string ToPriorityBadgeClass(string priority) + { + return EndpointPriority.Normalize(priority) switch + { + EndpointPriority.Critical => "badge-danger", + EndpointPriority.High => "badge-warning", + EndpointPriority.Low => "badge-secondary", + _ => "badge-info" + }; + } } public sealed class HeaderSummaryViewModel diff --git a/src/ApiHealthDashboard/Pages/Index.cshtml.cs b/src/ApiHealthDashboard/Pages/Index.cshtml.cs index a5d33d9..4a05bfc 100644 --- a/src/ApiHealthDashboard/Pages/Index.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Index.cshtml.cs @@ -112,7 +112,8 @@ private void LoadDashboard() statesById.TryGetValue(endpoint.Id, out var state); return EndpointSummaryViewModel.From(endpoint, state); }) - .OrderBy(static endpoint => endpoint.Name, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(static endpoint => endpoint.PrioritySortOrder) + .ThenBy(static endpoint => endpoint.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); Counters = new DashboardCountersViewModel @@ -130,7 +131,8 @@ private void LoadDashboard() ProblemEndpoints = Endpoints .Where(static endpoint => !string.IsNullOrWhiteSpace(endpoint.ErrorText) || endpoint.Status is "Degraded" or "Unhealthy") - .OrderBy(static endpoint => endpoint.Name, StringComparer.OrdinalIgnoreCase) + .OrderByDescending(static endpoint => endpoint.PrioritySortOrder) + .ThenBy(static endpoint => endpoint.Name, StringComparer.OrdinalIgnoreCase) .ToArray(); _logger.LogDebug("Loaded dashboard with {EndpointCount} endpoint summaries.", Endpoints.Count); @@ -169,6 +171,12 @@ public sealed class EndpointSummaryViewModel public required bool Enabled { get; init; } + public required string Priority { get; init; } + + public required string PriorityBadgeClass { get; init; } + + public int PrioritySortOrder { get; init; } + public bool IsPolling { get; init; } public bool ShowIdHint { get; init; } @@ -203,6 +211,9 @@ public static EndpointSummaryViewModel From(EndpointConfig endpoint, EndpointSta StatusBadgeClass = ToBadgeClass(status), FrequencyText = $"{endpoint.FrequencySeconds} sec", Enabled = endpoint.Enabled, + Priority = EndpointPriority.Normalize(endpoint.Priority), + PriorityBadgeClass = ToPriorityBadgeClass(endpoint.Priority), + PrioritySortOrder = EndpointPriority.GetSortOrder(endpoint.Priority), IsPolling = state?.IsPolling ?? false, ShowIdHint = ShouldShowIdHint(endpoint.Name, endpoint.Id), LastCheckedText = FormatDateTime(state?.LastCheckedUtc), @@ -258,5 +269,16 @@ private static string ToBadgeClass(string status) _ => "badge-secondary" }; } + + private static string ToPriorityBadgeClass(string priority) + { + return EndpointPriority.Normalize(priority) switch + { + EndpointPriority.Critical => "badge-danger", + EndpointPriority.High => "badge-warning", + EndpointPriority.Low => "badge-secondary", + _ => "badge-info" + }; + } } } diff --git a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml index 3b9a7a8..8eb8d1b 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml @@ -93,7 +93,7 @@
@@ -126,6 +126,7 @@ Name Status + Priority Last Checked Last Successful Duration @@ -144,6 +145,7 @@ endpoint.Name, endpoint.Id, endpoint.Status, + endpoint.Priority, endpoint.StatusDescription, endpoint.ErrorSummary }.Where(static value => !string.IsNullOrWhiteSpace(value))); @@ -167,6 +169,9 @@
@endpoint.StatusDescription
} + + @endpoint.Priority + @endpoint.LastCheckedText @endpoint.LastSuccessfulText @endpoint.DurationText @@ -193,7 +198,7 @@ } - + No endpoints match your search. @@ -259,6 +264,7 @@ @endpoint.Name @endpoint.Status + @endpoint.Priority
Last checked: @endpoint.LastCheckedText
@if (!string.IsNullOrWhiteSpace(endpoint.ErrorText)) diff --git a/src/ApiHealthDashboard/Services/EndpointImportService.cs b/src/ApiHealthDashboard/Services/EndpointImportService.cs index 323e286..0824dce 100644 --- a/src/ApiHealthDashboard/Services/EndpointImportService.cs +++ b/src/ApiHealthDashboard/Services/EndpointImportService.cs @@ -67,6 +67,7 @@ public async Task ImportAsync(EndpointImportRequest reques var snapshot = string.IsNullOrWhiteSpace(pollResult.ResponseBody) ? null : _healthResponseParser.Parse(probeEndpoint, pollResult.ResponseBody, pollResult.DurationMs); + var existingEndpoint = FindExistingEndpoint(probeEndpoint); var topLevelCheckNames = snapshot?.Nodes .Select(static node => node.Name) .Where(static name => !string.IsNullOrWhiteSpace(name)) @@ -82,6 +83,7 @@ public async Task ImportAsync(EndpointImportRequest reques Enabled = probeEndpoint.Enabled, FrequencySeconds = probeEndpoint.FrequencySeconds, TimeoutSeconds = probeEndpoint.TimeoutSeconds, + Priority = EndpointPriority.Normalize(existingEndpoint?.Priority), Headers = new Dictionary(probeEndpoint.Headers, StringComparer.OrdinalIgnoreCase), IncludeChecks = request.IncludeDiscoveredChecks ? [.. topLevelCheckNames] : [], ExcludeChecks = [] @@ -90,7 +92,6 @@ public async Task ImportAsync(EndpointImportRequest reques var shouldGenerateYamlPreview = !(pollResult.Kind == PollResultKind.HttpError && pollResult.StatusCode == System.Net.HttpStatusCode.NotFound); var generatedYaml = shouldGenerateYamlPreview ? RenderEndpointYaml(suggestedEndpoint) : null; - var existingEndpoint = FindExistingEndpoint(suggestedEndpoint); var existingYaml = existingEndpoint is null ? null : RenderEndpointYaml(existingEndpoint); var diffLines = existingYaml is null || string.IsNullOrWhiteSpace(generatedYaml) ? [] @@ -399,6 +400,7 @@ private static string RenderEndpointYaml(EndpointConfig endpoint) $"name: {Quote(endpoint.Name)}", $"url: {Quote(endpoint.Url)}", $"enabled: {endpoint.Enabled.ToString().ToLowerInvariant()}", + $"priority: {Quote(EndpointPriority.Normalize(endpoint.Priority))}", $"frequencySeconds: {endpoint.FrequencySeconds}" }; diff --git a/src/ApiHealthDashboard/endpoints/billing-api.yaml b/src/ApiHealthDashboard/endpoints/billing-api.yaml index 4ee57b4..cc07e2e 100644 --- a/src/ApiHealthDashboard/endpoints/billing-api.yaml +++ b/src/ApiHealthDashboard/endpoints/billing-api.yaml @@ -2,6 +2,7 @@ id: billing-api name: Billing API url: https://billing.example.com/health enabled: true +priority: Normal frequencySeconds: 60 timeoutSeconds: 15 headers: {} diff --git a/src/ApiHealthDashboard/endpoints/orders-api.yaml b/src/ApiHealthDashboard/endpoints/orders-api.yaml index a12b240..5574d11 100644 --- a/src/ApiHealthDashboard/endpoints/orders-api.yaml +++ b/src/ApiHealthDashboard/endpoints/orders-api.yaml @@ -2,6 +2,7 @@ id: orders-api name: Orders API url: https://orders.example.com/health enabled: true +priority: High frequencySeconds: 30 timeoutSeconds: 10 headers: diff --git a/tests/ApiHealthDashboard.Tests/Cli/CliExecutionServiceTests.cs b/tests/ApiHealthDashboard.Tests/Cli/CliExecutionServiceTests.cs index 07362b5..5d22da6 100644 --- a/tests/ApiHealthDashboard.Tests/Cli/CliExecutionServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Cli/CliExecutionServiceTests.cs @@ -68,6 +68,7 @@ public async Task ExecuteAsync_WithSuiteMode_ProducesSummaryAndSnapshot() Id = "orders-api", Name = "Orders API", Url = "https://orders.example.com/health", + Priority = EndpointPriority.Critical, Enabled = true, FrequencySeconds = 30 }, @@ -76,6 +77,7 @@ public async Task ExecuteAsync_WithSuiteMode_ProducesSummaryAndSnapshot() Id = "billing-api", Name = "Billing API", Url = "https://billing.example.com/health", + Priority = EndpointPriority.Low, Enabled = true, FrequencySeconds = 60 } @@ -106,6 +108,7 @@ public async Task ExecuteAsync_WithSuiteMode_ProducesSummaryAndSnapshot() var successEndpoint = Assert.Single(report.Endpoints.Where(static endpoint => endpoint.Id == "orders-api")); Assert.Equal("Executed", successEndpoint.ExecutionState); + Assert.Equal(EndpointPriority.Critical, successEndpoint.Priority); Assert.Equal("Healthy", successEndpoint.Status); Assert.NotNull(successEndpoint.Snapshot); Assert.Single(successEndpoint.Snapshot!.Nodes); @@ -135,6 +138,7 @@ public async Task ExecuteAsync_WithDisabledEndpoint_SkipsExecution() Id = "disabled-api", Name = "Disabled API", Url = "https://disabled.example.com/health", + Priority = EndpointPriority.High, Enabled = false, FrequencySeconds = 30 } @@ -155,6 +159,7 @@ public async Task ExecuteAsync_WithDisabledEndpoint_SkipsExecution() var endpoint = Assert.Single(report.Endpoints); Assert.Equal("selected-endpoints", report.Mode); + Assert.Equal(EndpointPriority.High, endpoint.Priority); Assert.Equal("Skipped", endpoint.ExecutionState); Assert.Equal("Skipped", endpoint.PollResultKind); Assert.Equal("Endpoint is disabled.", endpoint.ErrorMessage); diff --git a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigValidatorTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigValidatorTests.cs index 1cdfcc2..2a06251 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigValidatorTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigValidatorTests.cs @@ -88,4 +88,27 @@ public void Validate_WithRelativeUrl_ReturnsUrlError() Assert.Contains("endpoints[0].url must be an absolute HTTP or HTTPS URL.", errors); } + + [Fact] + public void Validate_WithInvalidPriority_ReturnsPriorityError() + { + var config = new DashboardConfig + { + Endpoints = + [ + new EndpointConfig + { + Id = "orders-api", + Name = "Orders API", + Url = "https://orders.example.com/health", + Priority = "Urgent", + FrequencySeconds = 30 + } + ] + }; + + var errors = _validator.Validate(config); + + Assert.Contains("endpoints[0].priority must be one of: Critical, High, Normal, Low.", errors); + } } diff --git a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs index 2a65b79..117a5cc 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs @@ -28,6 +28,7 @@ public void Load_WithValidYaml_ReturnsNormalizedConfiguration() name: Orders API url: https://orders.example.com/health enabled: true + priority: critical frequencySeconds: 30 timeoutSeconds: 5 headers: @@ -51,6 +52,7 @@ public void Load_WithValidYaml_ReturnsNormalizedConfiguration() Assert.Equal("orders-api", endpoint.Id); Assert.Equal("Orders API", endpoint.Name); Assert.Equal("https://orders.example.com/health", endpoint.Url); + Assert.Equal(EndpointPriority.Critical, endpoint.Priority); Assert.Equal(30, endpoint.FrequencySeconds); Assert.Equal(5, endpoint.TimeoutSeconds); Assert.Equal("enabled", endpoint.Headers["X-Trace"]); @@ -203,6 +205,7 @@ public void Load_WithSeparateEndpointFiles_LoadsAndMergesEndpoints() id: orders-api name: Orders API url: https://orders.example.com/health + priority: high frequencySeconds: 30 """); diff --git a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs index dcdf96c..3584e82 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs @@ -81,6 +81,7 @@ public void OnGet_WithoutRouteId_LoadsFirstConfiguredEndpoint() public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() { var config = CreateConfig(showRawPayload: true); + config.Endpoints[0].Priority = EndpointPriority.Critical; config.Endpoints[0].Headers["X-Api-Key"] = "super-secret"; config.Endpoints[0].IncludeChecks.Add("database"); config.Endpoints[0].ExcludeChecks.Add("cache"); @@ -145,6 +146,7 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() Assert.Equal(1, model.Endpoint.HealthyCheckCount); Assert.Equal(1, model.Endpoint.DegradedCheckCount); Assert.Equal(1, model.Endpoint.UnhealthyCheckCount); + Assert.Equal(EndpointPriority.Critical, model.Endpoint.Priority); Assert.Equal("********", model.Endpoint.Headers.Single().ValuePreview); Assert.Equal(["database"], model.Endpoint.IncludeChecks); Assert.Equal(["cache"], model.Endpoint.ExcludeChecks); @@ -211,6 +213,7 @@ private static DashboardConfig CreateConfig(bool showRawPayload = false) Id = "orders-api", Name = "Orders API", Url = "https://orders.example.com/health", + Priority = EndpointPriority.Normal, Enabled = true, FrequencySeconds = 30 } diff --git a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs index 27d8d7d..5123d22 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs @@ -85,6 +85,8 @@ public void OnGet_LoadsConfiguredEndpointSummaries() public void OnGet_CalculatesMixedStatusCountersAndProblemEndpoints() { var config = CreateConfig(); + config.Endpoints[0].Priority = EndpointPriority.High; + config.Endpoints[1].Priority = EndpointPriority.Critical; var store = new InMemoryEndpointStateStore(config.Endpoints); store.Upsert(new ApiHealthDashboard.Domain.EndpointState @@ -115,6 +117,22 @@ public void OnGet_CalculatesMixedStatusCountersAndProblemEndpoints() Assert.Equal(0, model.Counters.Unknown); Assert.Single(model.ProblemEndpoints); Assert.Equal("billing-api", model.ProblemEndpoints[0].Id); + Assert.Equal(EndpointPriority.Critical, model.ProblemEndpoints[0].Priority); + } + + [Fact] + public void OnGet_SortsEndpointsByPriorityBeforeName() + { + var config = CreateConfig(); + config.Endpoints[0].Priority = EndpointPriority.Low; + config.Endpoints[1].Priority = EndpointPriority.Critical; + var store = new InMemoryEndpointStateStore(config.Endpoints); + var model = new IndexModel(config, store, new StubEndpointScheduler(), NullLogger.Instance); + + model.OnGet(); + + Assert.Equal("billing-api", model.Endpoints[0].Id); + Assert.Equal(EndpointPriority.Critical, model.Endpoints[0].Priority); } [Fact] @@ -190,6 +208,7 @@ private static DashboardConfig CreateConfig() Id = "orders-api", Name = "Orders API", Url = "https://orders.example.com/health", + Priority = EndpointPriority.Normal, Enabled = true, FrequencySeconds = 30 }, @@ -198,6 +217,7 @@ private static DashboardConfig CreateConfig() Id = "billing-api", Name = "Billing API", Url = "https://billing.example.com/health", + Priority = EndpointPriority.Normal, Enabled = true, FrequencySeconds = 60 } diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs index 6c67b2a..0a009bc 100644 --- a/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs @@ -21,6 +21,7 @@ public async Task ImportAsync_WithSuccessfulProbe_GeneratesYamlAndDiffAgainstExi Id = "orders-api", Name = "Orders API", Url = "https://orders.example.com/health", + Priority = EndpointPriority.High, Enabled = true, FrequencySeconds = 60 } @@ -65,7 +66,9 @@ public async Task ImportAsync_WithSuccessfulProbe_GeneratesYamlAndDiffAgainstExi Assert.Equal("orders-api", result.SuggestedEndpoint.Id); Assert.Equal("Orders API", result.SuggestedEndpoint.Name); + Assert.Equal(EndpointPriority.High, result.SuggestedEndpoint.Priority); Assert.True(result.HasExistingMatch); + Assert.Contains("priority: 'High'", result.GeneratedYaml, StringComparison.Ordinal); Assert.Contains("includeChecks:", result.GeneratedYaml, StringComparison.Ordinal); Assert.Contains("'database'", result.GeneratedYaml, StringComparison.Ordinal); Assert.Equal(["cache", "database"], result.TopLevelCheckNames); @@ -118,6 +121,7 @@ public async Task ImportAsync_WithoutExistingMatch_ReturnsTruncatedResponsePrevi CancellationToken.None); Assert.False(result.HasExistingMatch); + Assert.Equal(EndpointPriority.Normal, result.SuggestedEndpoint.Priority); Assert.True(result.ResponsePreviewWasTruncated); Assert.Equal(12000, result.ResponsePreview.Length); Assert.Empty(result.DiffLines); From 9e6544f71f6e8ca22e698b18b995eaa8a8a4b7bf Mon Sep 17 00:00:00 2001 From: Alan Gali Date: Wed, 18 Mar 2026 23:24:03 +0800 Subject: [PATCH 02/15] Updated the Post-v1 roadmap. --- README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/README.md b/README.md index c2c686a..cf4faea 100644 --- a/README.md +++ b/README.md @@ -482,6 +482,11 @@ Test file: ## Future Plans These are planned enhancements after the current v1 path: +- optionally add short status history and mini trends so the dashboard can show recent health changes instead of only the latest snapshot +- optionally persist runtime state to compact per-endpoint files while keeping the active runtime cache in memory +- store persisted endpoint state outside the YAML config area so runtime data stays separate from configuration hot-reload inputs +- add configurable retention controls for persisted runtime data, including cleanup of old history files and orphaned endpoint state files +- make runtime data cleanup best-effort and time-based, such as deleting eligible files older than a configured number of hours - optionally allow email sending, either through direct SMTP configuration or by calling an external API ## Notes For Ongoing Updates From c060e60216e43179cf43beebdab911193b9ea2a4 Mon Sep 17 00:00:00 2001 From: Alan Gali Date: Wed, 18 Mar 2026 23:44:52 +0800 Subject: [PATCH 03/15] feat: persist endpoint runtime state to files --- .gitignore | 1 + README.md | 16 +- .../Configuration/RuntimeStateOptions.cs | 23 ++ src/ApiHealthDashboard/Program.cs | 29 +- .../State/FileBackedEndpointStateStore.cs | 271 ++++++++++++++++++ .../appsettings.Development.json | 4 + src/ApiHealthDashboard/appsettings.json | 4 + .../FileBackedEndpointStateStoreTests.cs | 200 +++++++++++++ 8 files changed, 539 insertions(+), 9 deletions(-) create mode 100644 src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs create mode 100644 src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs create mode 100644 tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs diff --git a/.gitignore b/.gitignore index 6e5f3e2..194874c 100644 --- a/.gitignore +++ b/.gitignore @@ -36,6 +36,7 @@ bld/ **/obj/ [Ll]og/ [Ll]ogs/ +src/ApiHealthDashboard/runtime-state/ # Visual Studio 2015/2017 cache/options directory .vs/ diff --git a/README.md b/README.md index cf4faea..63932df 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ This project is being built to: - parse JSON health responses without Xabaril UI libraries - render a local-only AdminLTE dashboard - load endpoint definitions from YAML -- keep runtime state in memory +- keep active runtime state in memory with optional per-endpoint file persistence for current state - stay portable for internal and restricted environments ## Current Status @@ -30,6 +30,7 @@ Implemented so far: - Post-v1: endpoint import flow with live probe, YAML preview, and diff comparison - Post-v1: CLI execution mode with JSON/XML reporting - Post-v1: YAML hot-reload for dashboard and endpoint files +- Post-v1: per-endpoint current-state persistence with compact JSON files Not implemented yet: - Backlog items tracked for post-v1 work @@ -101,7 +102,7 @@ Validation currently checks: ### Runtime State Store -The app now includes an in-memory endpoint state store for current runtime status. +The app now includes a runtime endpoint state store with an in-memory cache and optional per-endpoint current-state persistence. Current runtime models: - [`src/ApiHealthDashboard/Domain/EndpointState.cs`](src/ApiHealthDashboard/Domain/EndpointState.cs) @@ -111,10 +112,14 @@ Current runtime models: State store components: - [`src/ApiHealthDashboard/State/IEndpointStateStore.cs`](src/ApiHealthDashboard/State/IEndpointStateStore.cs) - [`src/ApiHealthDashboard/State/InMemoryEndpointStateStore.cs`](src/ApiHealthDashboard/State/InMemoryEndpointStateStore.cs) +- [`src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs`](src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs) +- [`src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs`](src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs) Current behavior: - initializes one runtime state entry per configured endpoint at startup -- stores endpoint state in memory only +- keeps active runtime state in memory for fast reads by pages and the scheduler +- can persist the latest current state for each endpoint to a compact JSON file under a configurable runtime-state directory +- restores persisted current state on startup for configured endpoints and resets any stale `IsPolling` flag to `false` - supports get-all, get-one, upsert, and reinitialize operations - returns deep copies so callers cannot mutate internal store state accidentally - uses thread-safe locking for concurrent access @@ -314,6 +319,8 @@ The app reads the dashboard YAML path from the `Bootstrap:DashboardConfigPath` s The current primary setting is `Bootstrap:DashboardConfigPath`. `Bootstrap:EndpointsConfigPath` is still accepted as a legacy fallback. +Runtime state persistence is configured through `RuntimeState:Enabled` and `RuntimeState:DirectoryPath` in the same appsettings files. By default, the app writes compact per-endpoint current-state files under `runtime-state/endpoints` relative to the app content root. + You can also override it with an environment variable: ```powershell @@ -483,10 +490,9 @@ Test file: These are planned enhancements after the current v1 path: - optionally add short status history and mini trends so the dashboard can show recent health changes instead of only the latest snapshot -- optionally persist runtime state to compact per-endpoint files while keeping the active runtime cache in memory -- store persisted endpoint state outside the YAML config area so runtime data stays separate from configuration hot-reload inputs - add configurable retention controls for persisted runtime data, including cleanup of old history files and orphaned endpoint state files - make runtime data cleanup best-effort and time-based, such as deleting eligible files older than a configured number of hours +- optionally add per-endpoint history files or embedded `recentSamples` arrays once trend capture is introduced - optionally allow email sending, either through direct SMTP configuration or by calling an external API ## Notes For Ongoing Updates diff --git a/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs new file mode 100644 index 0000000..facd740 --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs @@ -0,0 +1,23 @@ +namespace ApiHealthDashboard.Configuration; + +public sealed class RuntimeStateOptions +{ + public const string SectionName = "RuntimeState"; + + public bool Enabled { get; set; } = true; + + public string DirectoryPath { get; set; } = "runtime-state/endpoints"; + + public string ResolveDirectoryPath(string contentRootPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(contentRootPath); + + var configuredPath = string.IsNullOrWhiteSpace(DirectoryPath) + ? "runtime-state/endpoints" + : DirectoryPath; + + return Path.IsPathRooted(configuredPath) + ? Path.GetFullPath(configuredPath) + : Path.GetFullPath(Path.Combine(contentRootPath, configuredPath)); + } +} diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index 5507054..1e808b2 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -27,6 +27,8 @@ builder.Configuration.GetSection(DashboardBootstrapOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(ImportUiOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(RuntimeStateOptions.SectionName)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => @@ -77,15 +79,34 @@ builder.Services.AddSingleton(static serviceProvider => { var config = serviceProvider.GetRequiredService(); + var environment = serviceProvider.GetRequiredService(); var logger = serviceProvider.GetRequiredService() .CreateLogger("ApiHealthDashboard.State"); - var store = new InMemoryEndpointStateStore(config.Endpoints); + var runtimeStateOptions = serviceProvider.GetRequiredService>().Value; + + if (!runtimeStateOptions.Enabled) + { + var inMemoryStore = new InMemoryEndpointStateStore(config.Endpoints); + + logger.LogInformation( + "Initialized in-memory endpoint state store with {EndpointCount} configured endpoints.", + inMemoryStore.GetAll().Count); + + return inMemoryStore; + } + + var resolvedStateDirectory = runtimeStateOptions.ResolveDirectoryPath(environment.ContentRootPath); + var fileBackedStore = new FileBackedEndpointStateStore( + config.Endpoints, + resolvedStateDirectory, + serviceProvider.GetRequiredService>()); logger.LogInformation( - "Initialized endpoint state store with {EndpointCount} configured endpoints.", - store.GetAll().Count); + "Initialized file-backed endpoint state store with {EndpointCount} configured endpoints in {StateDirectoryPath}.", + fileBackedStore.GetAll().Count, + resolvedStateDirectory); - return store; + return fileBackedStore; }); builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddHttpClient(nameof(EndpointPoller)); diff --git a/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs new file mode 100644 index 0000000..b3e8e7c --- /dev/null +++ b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs @@ -0,0 +1,271 @@ +using System.Collections.Concurrent; +using System.Security.Cryptography; +using System.Text; +using System.Text.Json; +using System.Text.Json.Serialization; +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; + +namespace ApiHealthDashboard.State; + +public sealed class FileBackedEndpointStateStore : IEndpointStateStore +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + WriteIndented = false + }; + + private readonly InMemoryEndpointStateStore _innerStore; + private readonly ConcurrentDictionary _fileLocks = new(StringComparer.OrdinalIgnoreCase); + private readonly object _initializeSyncRoot = new(); + private readonly ILogger _logger; + private readonly string _stateDirectoryPath; + + public FileBackedEndpointStateStore( + IEnumerable endpoints, + string stateDirectoryPath, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(endpoints); + ArgumentException.ThrowIfNullOrWhiteSpace(stateDirectoryPath); + + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _stateDirectoryPath = Path.GetFullPath(stateDirectoryPath); + _innerStore = new InMemoryEndpointStateStore(endpoints); + + Directory.CreateDirectory(_stateDirectoryPath); + RestorePersistedStates(endpoints, restoreWhenStateIsInitial: true); + } + + public IReadOnlyCollection GetAll() + { + return _innerStore.GetAll(); + } + + public EndpointState? Get(string endpointId) + { + return _innerStore.Get(endpointId); + } + + public void Upsert(EndpointState state) + { + ArgumentNullException.ThrowIfNull(state); + + _innerStore.Upsert(state); + PersistState(state); + } + + public void Initialize(IEnumerable endpoints) + { + ArgumentNullException.ThrowIfNull(endpoints); + + var endpointList = endpoints + .Where(static endpoint => endpoint is not null) + .Select(static endpoint => endpoint.Clone()) + .ToArray(); + + lock (_initializeSyncRoot) + { + _innerStore.Initialize(endpointList); + RestorePersistedStates(endpointList, restoreWhenStateIsInitial: true); + } + } + + private void RestorePersistedStates( + IEnumerable endpoints, + bool restoreWhenStateIsInitial) + { + foreach (var endpoint in endpoints) + { + if (endpoint is null || string.IsNullOrWhiteSpace(endpoint.Id)) + { + continue; + } + + if (restoreWhenStateIsInitial) + { + var currentState = _innerStore.Get(endpoint.Id); + if (currentState is not null && !IsInitialState(currentState)) + { + continue; + } + } + + var filePath = GetStateFilePath(endpoint.Id); + if (!File.Exists(filePath)) + { + continue; + } + + try + { + using var stream = File.OpenRead(filePath); + var persistedState = JsonSerializer.Deserialize(stream, JsonOptions); + if (persistedState is null) + { + continue; + } + + var restoredState = persistedState.ToRuntimeState(); + restoredState.EndpointId = endpoint.Id; + restoredState.EndpointName = endpoint.Name; + restoredState.IsPolling = false; + + _innerStore.Upsert(restoredState); + + _logger.LogInformation( + "Restored persisted runtime state for endpoint {EndpointId} from {StateFilePath}.", + endpoint.Id, + filePath); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to restore persisted runtime state for endpoint {EndpointId} from {StateFilePath}. The endpoint will start with a fresh in-memory state.", + endpoint.Id, + filePath); + } + } + } + + private void PersistState(EndpointState state) + { + var endpointId = state.EndpointId; + var stateFilePath = GetStateFilePath(endpointId); + var tempFilePath = $"{stateFilePath}.tmp"; + var fileLock = _fileLocks.GetOrAdd(endpointId, static _ => new object()); + var persistedState = PersistedEndpointState.FromRuntimeState(state); + + lock (fileLock) + { + Directory.CreateDirectory(_stateDirectoryPath); + + try + { + using (var stream = new FileStream(tempFilePath, FileMode.Create, FileAccess.Write, FileShare.None)) + { + JsonSerializer.Serialize(stream, persistedState, JsonOptions); + stream.Flush(flushToDisk: true); + } + + if (File.Exists(stateFilePath)) + { + File.Replace(tempFilePath, stateFilePath, destinationBackupFileName: null, ignoreMetadataErrors: true); + } + else + { + File.Move(tempFilePath, stateFilePath); + } + } + catch + { + TryDeleteTempFile(tempFilePath); + throw; + } + } + } + + private string GetStateFilePath(string endpointId) + { + return Path.Combine(_stateDirectoryPath, $"{CreateSafeFileStem(endpointId)}.state.json"); + } + + private static string CreateSafeFileStem(string endpointId) + { + var sanitizedCharacters = endpointId + .Select(static character => char.IsLetterOrDigit(character) || character is '-' or '_' or '.' + ? character + : '-') + .ToArray(); + + var sanitizedId = new string(sanitizedCharacters).Trim('-'); + if (string.IsNullOrWhiteSpace(sanitizedId)) + { + sanitizedId = "endpoint"; + } + + var hashBytes = SHA256.HashData(Encoding.UTF8.GetBytes(endpointId)); + var hash = Convert.ToHexString(hashBytes.AsSpan(0, 6)).ToLowerInvariant(); + + return $"{sanitizedId}-{hash}"; + } + + private static bool IsInitialState(EndpointState state) + { + return state.Status == "Unknown" && + state.LastCheckedUtc is null && + state.LastSuccessfulUtc is null && + state.DurationMs is null && + string.IsNullOrWhiteSpace(state.LastError) && + state.Snapshot is null && + !state.IsPolling; + } + + private static void TryDeleteTempFile(string tempFilePath) + { + try + { + if (File.Exists(tempFilePath)) + { + File.Delete(tempFilePath); + } + } + catch + { + } + } + + private sealed class PersistedEndpointState + { + public string EndpointId { get; set; } = string.Empty; + + public string EndpointName { get; set; } = string.Empty; + + public string Status { get; set; } = "Unknown"; + + public DateTimeOffset? LastCheckedUtc { get; set; } + + public DateTimeOffset? LastSuccessfulUtc { get; set; } + + public long? DurationMs { get; set; } + + public string? LastError { get; set; } + + public HealthSnapshot? Snapshot { get; set; } + + public static PersistedEndpointState FromRuntimeState(EndpointState state) + { + return new PersistedEndpointState + { + EndpointId = state.EndpointId, + EndpointName = state.EndpointName, + Status = state.Status, + LastCheckedUtc = state.LastCheckedUtc, + LastSuccessfulUtc = state.LastSuccessfulUtc, + DurationMs = state.DurationMs, + LastError = state.LastError, + Snapshot = state.Snapshot?.Clone() + }; + } + + public EndpointState ToRuntimeState() + { + return new EndpointState + { + EndpointId = EndpointId, + EndpointName = EndpointName, + Status = Status, + LastCheckedUtc = LastCheckedUtc, + LastSuccessfulUtc = LastSuccessfulUtc, + DurationMs = DurationMs, + LastError = LastError, + Snapshot = Snapshot?.Clone(), + IsPolling = false + }; + } + } +} diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index 638847b..de1b7f7 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -2,6 +2,10 @@ "Bootstrap": { "DashboardConfigPath": "dashboard.yaml" }, + "RuntimeState": { + "Enabled": true, + "DirectoryPath": "runtime-state/endpoints" + }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 }, diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index 1742e57..fe066d8 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -2,6 +2,10 @@ "Bootstrap": { "DashboardConfigPath": "dashboard.yaml" }, + "RuntimeState": { + "Enabled": true, + "DirectoryPath": "runtime-state/endpoints" + }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 }, diff --git a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs new file mode 100644 index 0000000..f37df89 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs @@ -0,0 +1,200 @@ +using System.Text.Json; +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.State; +using ApiHealthDashboard.Tests.Logging; + +namespace ApiHealthDashboard.Tests.State; + +public sealed class FileBackedEndpointStateStoreTests : IDisposable +{ + private readonly string _stateDirectoryPath = Path.Combine( + Path.GetTempPath(), + "ApiHealthDashboard.Tests", + nameof(FileBackedEndpointStateStoreTests), + Guid.NewGuid().ToString("N")); + + [Fact] + public void Upsert_PersistsCurrentStateToPerEndpointFile() + { + var logger = new TestLogger(); + var store = new FileBackedEndpointStateStore( + [CreateEndpoint("orders-api", "Orders API")], + _stateDirectoryPath, + logger); + + store.Upsert(new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-18T15:00:00Z"), + LastSuccessfulUtc = DateTimeOffset.Parse("2026-03-18T15:00:00Z"), + DurationMs = 123, + LastError = null, + IsPolling = true, + Snapshot = new HealthSnapshot + { + OverallStatus = "Healthy", + RetrievedUtc = DateTimeOffset.Parse("2026-03-18T15:00:00Z"), + DurationMs = 123, + RawPayload = """{"status":"Healthy"}""", + Metadata = new Dictionary + { + ["region"] = "apac" + }, + Nodes = + [ + new HealthNode + { + Name = "database", + Status = "Healthy", + Data = new Dictionary + { + ["provider"] = "sql" + } + } + ] + } + }); + + var stateFiles = Directory.GetFiles(_stateDirectoryPath, "*.state.json"); + + var stateFile = Assert.Single(stateFiles); + var json = File.ReadAllText(stateFile); + + Assert.Contains("\"status\":\"Healthy\"", json); + Assert.Contains("\"endpointId\":\"orders-api\"", json); + Assert.DoesNotContain("isPolling", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_RestoresPersistedStateForConfiguredEndpoint() + { + var initialStore = new FileBackedEndpointStateStore( + [CreateEndpoint("orders-api", "Orders API")], + _stateDirectoryPath, + new TestLogger()); + + initialStore.Upsert(new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Degraded", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-18T16:00:00Z"), + LastSuccessfulUtc = DateTimeOffset.Parse("2026-03-18T15:59:00Z"), + DurationMs = 456, + LastError = "Slow dependency", + Snapshot = new HealthSnapshot + { + OverallStatus = "Degraded", + RetrievedUtc = DateTimeOffset.Parse("2026-03-18T16:00:00Z"), + DurationMs = 456, + RawPayload = """{"status":"Degraded"}""" + } + }); + + var restoredStore = new FileBackedEndpointStateStore( + [CreateEndpoint("orders-api", "Orders API Reloaded")], + _stateDirectoryPath, + new TestLogger()); + + var restoredState = restoredStore.Get("orders-api"); + + Assert.NotNull(restoredState); + Assert.Equal("Degraded", restoredState!.Status); + Assert.Equal("Orders API Reloaded", restoredState.EndpointName); + Assert.Equal("Slow dependency", restoredState.LastError); + Assert.NotNull(restoredState.Snapshot); + Assert.False(restoredState.IsPolling); + } + + [Fact] + public void Initialize_RestoresPersistedStateOnlyForNewEndpoints() + { + var seedStore = new FileBackedEndpointStateStore( + [CreateEndpoint("billing-api", "Billing API")], + _stateDirectoryPath, + new TestLogger()); + + seedStore.Upsert(new EndpointState + { + EndpointId = "billing-api", + EndpointName = "Billing API", + Status = "Healthy", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-18T17:00:00Z") + }); + + var logger = new TestLogger(); + var store = new FileBackedEndpointStateStore( + [CreateEndpoint("orders-api", "Orders API")], + _stateDirectoryPath, + logger); + + store.Upsert(new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Degraded" + }); + + var ordersStateFile = Directory.GetFiles(_stateDirectoryPath, "*.state.json") + .Single(path => Path.GetFileName(path).Contains("orders-api", StringComparison.OrdinalIgnoreCase)); + + OverwritePersistedState( + ordersStateFile, + "orders-api", + "Orders API", + "Unhealthy"); + + store.Initialize( + [ + CreateEndpoint("orders-api", "Orders API Reloaded"), + CreateEndpoint("billing-api", "Billing API Reloaded") + ]); + + var ordersState = store.Get("orders-api"); + var billingState = store.Get("billing-api"); + + Assert.NotNull(ordersState); + Assert.NotNull(billingState); + Assert.Equal("Degraded", ordersState!.Status); + Assert.Equal("Orders API Reloaded", ordersState.EndpointName); + Assert.Equal("Healthy", billingState!.Status); + Assert.Equal("Billing API Reloaded", billingState.EndpointName); + } + + public void Dispose() + { + if (Directory.Exists(_stateDirectoryPath)) + { + Directory.Delete(_stateDirectoryPath, recursive: true); + } + } + + private static EndpointConfig CreateEndpoint(string id, string name) + { + return new EndpointConfig + { + Id = id, + Name = name, + Url = $"https://{id}.example.com/health" + }; + } + + private static void OverwritePersistedState( + string stateFilePath, + string endpointId, + string endpointName, + string status) + { + var payload = new + { + endpointId, + endpointName, + status + }; + + File.WriteAllText(stateFilePath, JsonSerializer.Serialize(payload)); + } +} From 4a8572ff105cb73d7bb7874aa28700c760a4584f Mon Sep 17 00:00:00 2001 From: Alan Gali Date: Thu, 19 Mar 2026 00:01:57 +0800 Subject: [PATCH 04/15] feat: add runtime state cleanup retention --- README.md | 11 +- .../Configuration/RuntimeStateOptions.cs | 22 ++++ src/ApiHealthDashboard/Program.cs | 1 + .../State/FileBackedEndpointStateStore.cs | 121 +++++++++++++++++- .../appsettings.Development.json | 6 +- src/ApiHealthDashboard/appsettings.json | 6 +- .../FileBackedEndpointStateStoreTests.cs | 91 +++++++++++++ 7 files changed, 252 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 63932df..01e2c7b 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ Implemented so far: - Post-v1: CLI execution mode with JSON/XML reporting - Post-v1: YAML hot-reload for dashboard and endpoint files - Post-v1: per-endpoint current-state persistence with compact JSON files +- Post-v1: runtime-state cleanup and retention settings for orphaned persisted state files Not implemented yet: - Backlog items tracked for post-v1 work @@ -120,6 +121,7 @@ Current behavior: - keeps active runtime state in memory for fast reads by pages and the scheduler - can persist the latest current state for each endpoint to a compact JSON file under a configurable runtime-state directory - restores persisted current state on startup for configured endpoints and resets any stale `IsPolling` flag to `false` +- can clean up orphaned persisted state files on a configurable interval after a configurable retention window - supports get-all, get-one, upsert, and reinitialize operations - returns deep copies so callers cannot mutate internal store state accidentally - uses thread-safe locking for concurrent access @@ -321,6 +323,12 @@ The current primary setting is `Bootstrap:DashboardConfigPath`. `Bootstrap:Endpo Runtime state persistence is configured through `RuntimeState:Enabled` and `RuntimeState:DirectoryPath` in the same appsettings files. By default, the app writes compact per-endpoint current-state files under `runtime-state/endpoints` relative to the app content root. +Current cleanup settings: +- `RuntimeState:CleanupEnabled` to enable periodic runtime-state cleanup +- `RuntimeState:CleanupIntervalMinutes` to control how often orphan cleanup runs +- `RuntimeState:DeleteOrphanedStateFiles` to enable deletion of persisted state files that no longer belong to configured endpoints +- `RuntimeState:OrphanedStateFileRetentionHours` to keep orphaned state files for a configurable grace period before deletion + You can also override it with an environment variable: ```powershell @@ -490,8 +498,7 @@ Test file: These are planned enhancements after the current v1 path: - optionally add short status history and mini trends so the dashboard can show recent health changes instead of only the latest snapshot -- add configurable retention controls for persisted runtime data, including cleanup of old history files and orphaned endpoint state files -- make runtime data cleanup best-effort and time-based, such as deleting eligible files older than a configured number of hours +- add configurable retention controls for future persisted history files once trend capture is introduced - optionally add per-endpoint history files or embedded `recentSamples` arrays once trend capture is introduced - optionally allow email sending, either through direct SMTP configuration or by calling an external API diff --git a/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs index facd740..b6db6d9 100644 --- a/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs +++ b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs @@ -8,6 +8,14 @@ public sealed class RuntimeStateOptions public string DirectoryPath { get; set; } = "runtime-state/endpoints"; + public bool CleanupEnabled { get; set; } = true; + + public double CleanupIntervalMinutes { get; set; } = 30; + + public bool DeleteOrphanedStateFiles { get; set; } = true; + + public double OrphanedStateFileRetentionHours { get; set; } = 5; + public string ResolveDirectoryPath(string contentRootPath) { ArgumentException.ThrowIfNullOrWhiteSpace(contentRootPath); @@ -20,4 +28,18 @@ public string ResolveDirectoryPath(string contentRootPath) ? Path.GetFullPath(configuredPath) : Path.GetFullPath(Path.Combine(contentRootPath, configuredPath)); } + + public TimeSpan GetCleanupInterval() + { + return CleanupIntervalMinutes <= 0 + ? TimeSpan.Zero + : TimeSpan.FromMinutes(CleanupIntervalMinutes); + } + + public TimeSpan GetOrphanedStateFileRetention() + { + return OrphanedStateFileRetentionHours <= 0 + ? TimeSpan.Zero + : TimeSpan.FromHours(OrphanedStateFileRetentionHours); + } } diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index 1e808b2..b3374f0 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -99,6 +99,7 @@ var fileBackedStore = new FileBackedEndpointStateStore( config.Endpoints, resolvedStateDirectory, + runtimeStateOptions, serviceProvider.GetRequiredService>()); logger.LogInformation( diff --git a/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs index b3e8e7c..5eaecdf 100644 --- a/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs +++ b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs @@ -20,24 +20,37 @@ public sealed class FileBackedEndpointStateStore : IEndpointStateStore private readonly InMemoryEndpointStateStore _innerStore; private readonly ConcurrentDictionary _fileLocks = new(StringComparer.OrdinalIgnoreCase); + private readonly object _cleanupSyncRoot = new(); private readonly object _initializeSyncRoot = new(); private readonly ILogger _logger; + private readonly RuntimeStateOptions _options; private readonly string _stateDirectoryPath; + private DateTimeOffset _nextCleanupUtc = DateTimeOffset.MinValue; + private HashSet _configuredStateFilePaths = new(StringComparer.OrdinalIgnoreCase); public FileBackedEndpointStateStore( IEnumerable endpoints, string stateDirectoryPath, + RuntimeStateOptions options, ILogger logger) { ArgumentNullException.ThrowIfNull(endpoints); ArgumentException.ThrowIfNullOrWhiteSpace(stateDirectoryPath); + ArgumentNullException.ThrowIfNull(options); _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _options = options; _stateDirectoryPath = Path.GetFullPath(stateDirectoryPath); - _innerStore = new InMemoryEndpointStateStore(endpoints); + var endpointList = endpoints + .Where(static endpoint => endpoint is not null) + .Select(static endpoint => endpoint.Clone()) + .ToArray(); + _innerStore = new InMemoryEndpointStateStore(endpointList); Directory.CreateDirectory(_stateDirectoryPath); - RestorePersistedStates(endpoints, restoreWhenStateIsInitial: true); + UpdateConfiguredStateFilePaths(endpointList); + RestorePersistedStates(endpointList, restoreWhenStateIsInitial: true); + TryCleanupPersistedFiles(force: true); } public IReadOnlyCollection GetAll() @@ -56,6 +69,7 @@ public void Upsert(EndpointState state) _innerStore.Upsert(state); PersistState(state); + TryCleanupPersistedFiles(force: false); } public void Initialize(IEnumerable endpoints) @@ -70,7 +84,9 @@ public void Initialize(IEnumerable endpoints) lock (_initializeSyncRoot) { _innerStore.Initialize(endpointList); + UpdateConfiguredStateFilePaths(endpointList); RestorePersistedStates(endpointList, restoreWhenStateIsInitial: true); + TryCleanupPersistedFiles(force: true); } } @@ -169,6 +185,107 @@ private void PersistState(EndpointState state) } } + private void UpdateConfiguredStateFilePaths(IEnumerable endpoints) + { + var configuredPaths = endpoints + .Where(static endpoint => endpoint is not null && !string.IsNullOrWhiteSpace(endpoint.Id)) + .Select(endpoint => Path.GetFullPath(GetStateFilePath(endpoint.Id))) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + lock (_cleanupSyncRoot) + { + _configuredStateFilePaths = configuredPaths; + } + } + + private void TryCleanupPersistedFiles(bool force) + { + if (!_options.CleanupEnabled || !Directory.Exists(_stateDirectoryPath)) + { + return; + } + + HashSet configuredPaths; + var now = DateTimeOffset.UtcNow; + var cleanupInterval = _options.GetCleanupInterval(); + + lock (_cleanupSyncRoot) + { + if (!force && + cleanupInterval > TimeSpan.Zero && + now < _nextCleanupUtc) + { + return; + } + + _nextCleanupUtc = cleanupInterval > TimeSpan.Zero + ? now.Add(cleanupInterval) + : now; + + configuredPaths = new HashSet(_configuredStateFilePaths, StringComparer.OrdinalIgnoreCase); + } + + CleanupOrphanedStateFiles(configuredPaths, now); + } + + private void CleanupOrphanedStateFiles( + HashSet configuredPaths, + DateTimeOffset now) + { + if (!_options.DeleteOrphanedStateFiles) + { + return; + } + + var retention = _options.GetOrphanedStateFileRetention(); + var cutoffUtc = now.UtcDateTime - retention; + + foreach (var stateFilePath in Directory.EnumerateFiles(_stateDirectoryPath, "*.state.json", SearchOption.TopDirectoryOnly)) + { + var fullStateFilePath = Path.GetFullPath(stateFilePath); + if (configuredPaths.Contains(fullStateFilePath)) + { + continue; + } + + DateTime lastWriteUtc; + + try + { + lastWriteUtc = File.GetLastWriteTimeUtc(fullStateFilePath); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to inspect orphaned runtime state file {StateFilePath} during cleanup.", + fullStateFilePath); + continue; + } + + if (retention > TimeSpan.Zero && lastWriteUtc > cutoffUtc) + { + continue; + } + + try + { + File.Delete(fullStateFilePath); + + _logger.LogInformation( + "Deleted orphaned runtime state file {StateFilePath} during cleanup.", + fullStateFilePath); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to delete orphaned runtime state file {StateFilePath} during cleanup.", + fullStateFilePath); + } + } + } + private string GetStateFilePath(string endpointId) { return Path.Combine(_stateDirectoryPath, $"{CreateSafeFileStem(endpointId)}.state.json"); diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index de1b7f7..719b481 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -4,7 +4,11 @@ }, "RuntimeState": { "Enabled": true, - "DirectoryPath": "runtime-state/endpoints" + "DirectoryPath": "runtime-state/endpoints", + "CleanupEnabled": true, + "CleanupIntervalMinutes": 30, + "DeleteOrphanedStateFiles": true, + "OrphanedStateFileRetentionHours": 5 }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index fe066d8..ab04b6c 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -4,7 +4,11 @@ }, "RuntimeState": { "Enabled": true, - "DirectoryPath": "runtime-state/endpoints" + "DirectoryPath": "runtime-state/endpoints", + "CleanupEnabled": true, + "CleanupIntervalMinutes": 5, + "DeleteOrphanedStateFiles": true, + "OrphanedStateFileRetentionHours": 5 }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 diff --git a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs index f37df89..ad2616b 100644 --- a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs +++ b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs @@ -21,6 +21,7 @@ public void Upsert_PersistsCurrentStateToPerEndpointFile() var store = new FileBackedEndpointStateStore( [CreateEndpoint("orders-api", "Orders API")], _stateDirectoryPath, + CreateRuntimeStateOptions(), logger); store.Upsert(new EndpointState @@ -74,6 +75,7 @@ public void Constructor_RestoresPersistedStateForConfiguredEndpoint() var initialStore = new FileBackedEndpointStateStore( [CreateEndpoint("orders-api", "Orders API")], _stateDirectoryPath, + CreateRuntimeStateOptions(), new TestLogger()); initialStore.Upsert(new EndpointState @@ -97,6 +99,7 @@ public void Constructor_RestoresPersistedStateForConfiguredEndpoint() var restoredStore = new FileBackedEndpointStateStore( [CreateEndpoint("orders-api", "Orders API Reloaded")], _stateDirectoryPath, + CreateRuntimeStateOptions(), new TestLogger()); var restoredState = restoredStore.Get("orders-api"); @@ -115,6 +118,7 @@ public void Initialize_RestoresPersistedStateOnlyForNewEndpoints() var seedStore = new FileBackedEndpointStateStore( [CreateEndpoint("billing-api", "Billing API")], _stateDirectoryPath, + CreateRuntimeStateOptions(), new TestLogger()); seedStore.Upsert(new EndpointState @@ -129,6 +133,7 @@ public void Initialize_RestoresPersistedStateOnlyForNewEndpoints() var store = new FileBackedEndpointStateStore( [CreateEndpoint("orders-api", "Orders API")], _stateDirectoryPath, + CreateRuntimeStateOptions(), logger); store.Upsert(new EndpointState @@ -164,6 +169,75 @@ public void Initialize_RestoresPersistedStateOnlyForNewEndpoints() Assert.Equal("Billing API Reloaded", billingState.EndpointName); } + [Fact] + public void Initialize_DeletesOrphanedStateFilesWhenRetentionHasExpired() + { + var seedStore = new FileBackedEndpointStateStore( + [CreateEndpoint("billing-api", "Billing API")], + _stateDirectoryPath, + CreateRuntimeStateOptions(cleanupIntervalMinutes: 0, orphanedRetentionHours: 5), + new TestLogger()); + + seedStore.Upsert(new EndpointState + { + EndpointId = "billing-api", + EndpointName = "Billing API", + Status = "Healthy" + }); + + var billingStateFile = Assert.Single(Directory.GetFiles(_stateDirectoryPath, "*.state.json")); + File.SetLastWriteTimeUtc(billingStateFile, DateTime.UtcNow.AddHours(-6)); + + seedStore.Initialize([CreateEndpoint("orders-api", "Orders API")]); + + Assert.Empty(Directory.GetFiles(_stateDirectoryPath, "*.state.json")); + } + + [Fact] + public void Initialize_KeepsRecentOrphanedStateFilesUntilRetentionExpires() + { + var seedStore = new FileBackedEndpointStateStore( + [CreateEndpoint("billing-api", "Billing API")], + _stateDirectoryPath, + CreateRuntimeStateOptions(cleanupIntervalMinutes: 0, orphanedRetentionHours: 5), + new TestLogger()); + + seedStore.Upsert(new EndpointState + { + EndpointId = "billing-api", + EndpointName = "Billing API", + Status = "Healthy" + }); + + var billingStateFile = Assert.Single(Directory.GetFiles(_stateDirectoryPath, "*.state.json")); + File.SetLastWriteTimeUtc(billingStateFile, DateTime.UtcNow.AddHours(-1)); + + seedStore.Initialize([CreateEndpoint("orders-api", "Orders API")]); + + Assert.Single(Directory.GetFiles(_stateDirectoryPath, "*.state.json")); + } + + [Fact] + public void Initialize_DoesNotDeleteOrphanedStateFilesWhenCleanupIsDisabled() + { + var seedStore = new FileBackedEndpointStateStore( + [CreateEndpoint("billing-api", "Billing API")], + _stateDirectoryPath, + CreateRuntimeStateOptions(cleanupEnabled: true, cleanupIntervalMinutes: 0, deleteOrphanedStateFiles: false, orphanedRetentionHours: 0), + new TestLogger()); + + seedStore.Upsert(new EndpointState + { + EndpointId = "billing-api", + EndpointName = "Billing API", + Status = "Healthy" + }); + + seedStore.Initialize([CreateEndpoint("orders-api", "Orders API")]); + + Assert.Single(Directory.GetFiles(_stateDirectoryPath, "*.state.json")); + } + public void Dispose() { if (Directory.Exists(_stateDirectoryPath)) @@ -182,6 +256,23 @@ private static EndpointConfig CreateEndpoint(string id, string name) }; } + private static RuntimeStateOptions CreateRuntimeStateOptions( + bool cleanupEnabled = true, + double cleanupIntervalMinutes = 30, + bool deleteOrphanedStateFiles = true, + double orphanedRetentionHours = 5) + { + return new RuntimeStateOptions + { + Enabled = true, + DirectoryPath = "runtime-state/endpoints", + CleanupEnabled = cleanupEnabled, + CleanupIntervalMinutes = cleanupIntervalMinutes, + DeleteOrphanedStateFiles = deleteOrphanedStateFiles, + OrphanedStateFileRetentionHours = orphanedRetentionHours + }; + } + private static void OverwritePersistedState( string stateFilePath, string endpointId, From bc2518d250c7dfd7ccd4d107d6d347f23acc9065 Mon Sep 17 00:00:00 2001 From: Alan Gali Date: Thu, 19 Mar 2026 00:31:05 +0800 Subject: [PATCH 05/15] feat: add recent poll sample metrics --- README.md | 13 ++- .../Configuration/RuntimeStateOptions.cs | 7 ++ .../Domain/EndpointState.cs | 5 +- .../Domain/RecentPollSample.cs | 26 +++++ .../Pages/Endpoints/Details.cshtml | 77 +++++++++++++++ .../Pages/Endpoints/Details.cshtml.cs | 91 ++++++++++++++++- src/ApiHealthDashboard/Pages/Index.cshtml.cs | 66 +++++++++++++ .../Pages/Shared/_DashboardLiveSection.cshtml | 20 +++- src/ApiHealthDashboard/Program.cs | 2 + .../Scheduling/PollingSchedulerService.cs | 47 +++++++++ .../State/FileBackedEndpointStateStore.cs | 6 +- .../RecentPollSampleMetricsCalculator.cs | 97 +++++++++++++++++++ .../appsettings.Development.json | 3 +- src/ApiHealthDashboard/appsettings.json | 5 +- src/ApiHealthDashboard/wwwroot/css/site.css | 75 ++++++++++++++ .../DashboardConfigHotReloadServiceTests.cs | 1 + .../Pages/Endpoints/DetailsModelTests.cs | 24 +++++ .../Pages/IndexModelTests.cs | 22 ++++- .../Scheduling/PollingSchedulerReloadTests.cs | 1 + .../PollingSchedulerServiceTests.cs | 81 +++++++++++++++- .../FileBackedEndpointStateStoreTests.cs | 34 ++++++- .../State/InMemoryEndpointStateStoreTests.cs | 13 +++ 22 files changed, 703 insertions(+), 13 deletions(-) create mode 100644 src/ApiHealthDashboard/Domain/RecentPollSample.cs create mode 100644 src/ApiHealthDashboard/Statistics/RecentPollSampleMetricsCalculator.cs diff --git a/README.md b/README.md index 01e2c7b..15047d6 100644 --- a/README.md +++ b/README.md @@ -32,6 +32,7 @@ Implemented so far: - Post-v1: YAML hot-reload for dashboard and endpoint files - Post-v1: per-endpoint current-state persistence with compact JSON files - Post-v1: runtime-state cleanup and retention settings for orphaned persisted state files +- Post-v1: recent poll sample retention with derived dashboard and details metrics Not implemented yet: - Backlog items tracked for post-v1 work @@ -107,6 +108,7 @@ The app now includes a runtime endpoint state store with an in-memory cache and Current runtime models: - [`src/ApiHealthDashboard/Domain/EndpointState.cs`](src/ApiHealthDashboard/Domain/EndpointState.cs) +- [`src/ApiHealthDashboard/Domain/RecentPollSample.cs`](src/ApiHealthDashboard/Domain/RecentPollSample.cs) - [`src/ApiHealthDashboard/Domain/HealthSnapshot.cs`](src/ApiHealthDashboard/Domain/HealthSnapshot.cs) - [`src/ApiHealthDashboard/Domain/HealthNode.cs`](src/ApiHealthDashboard/Domain/HealthNode.cs) @@ -122,6 +124,7 @@ Current behavior: - can persist the latest current state for each endpoint to a compact JSON file under a configurable runtime-state directory - restores persisted current state on startup for configured endpoints and resets any stale `IsPolling` flag to `false` - can clean up orphaned persisted state files on a configurable interval after a configurable retention window +- retains a configurable rolling window of recent poll samples per endpoint for derived runtime metrics - supports get-all, get-one, upsert, and reinitialize operations - returns deep copies so callers cannot mutate internal store state accidentally - uses thread-safe locking for concurrent access @@ -211,7 +214,7 @@ Current dashboard behavior: - includes a client-side search field for filtering endpoint rows by name, id, status, or error text - refreshes the live dashboard section with same-origin timed GET requests instead of reloading the whole page - sorts endpoint summaries and active issues by endpoint priority before name -- renders a live endpoint table with last check, duration, error summary, and manual refresh actions +- renders a live endpoint table with last check, duration, recent signal metrics, error summary, and manual refresh actions - surfaces degraded and unhealthy endpoints in an active issues panel for faster triage - shows a clearer empty state when no endpoints are configured @@ -250,6 +253,7 @@ Current details-page behavior: - shows endpoint metadata including enabled state, priority, frequency, timeout, and masked request headers - shows request filter configuration for included and excluded checks - summarizes the latest poll with status, timings, retrieved timestamp, and current error +- shows retained recent sample metrics including success rate, failure count, average duration, last status change, and recent activity - renders top-level and nested health checks recursively with native expand and collapse support - surfaces snapshot metadata captured from the parsed response - shows the raw payload section only when enabled in configuration @@ -328,6 +332,8 @@ Current cleanup settings: - `RuntimeState:CleanupIntervalMinutes` to control how often orphan cleanup runs - `RuntimeState:DeleteOrphanedStateFiles` to enable deletion of persisted state files that no longer belong to configured endpoints - `RuntimeState:OrphanedStateFileRetentionHours` to keep orphaned state files for a configurable grace period before deletion +- `RuntimeState:RecentSampleLimit` to cap how many recent poll samples are retained per endpoint +- `RuntimeState:RecentSampleLimit` to cap how many recent poll samples are retained per endpoint You can also override it with an environment variable: @@ -474,7 +480,7 @@ Test file: - Do not use Xabaril health check UI packages - Do not rely on CDN-hosted frontend assets - Do not require a database -- Keep runtime state in memory only +- Keep active runtime state in memory and persisted runtime files lightweight - Prefer small, focused services ## Development Progress @@ -497,9 +503,10 @@ Test file: ## Future Plans These are planned enhancements after the current v1 path: +- next up: add mini trend visuals and short status history views based on the retained recent poll samples already captured in runtime state - optionally add short status history and mini trends so the dashboard can show recent health changes instead of only the latest snapshot - add configurable retention controls for future persisted history files once trend capture is introduced -- optionally add per-endpoint history files or embedded `recentSamples` arrays once trend capture is introduced +- optionally add per-endpoint history files once the embedded recent-sample window is no longer sufficient - optionally allow email sending, either through direct SMTP configuration or by calling an external API ## Notes For Ongoing Updates diff --git a/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs index b6db6d9..a52c500 100644 --- a/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs +++ b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs @@ -16,6 +16,8 @@ public sealed class RuntimeStateOptions public double OrphanedStateFileRetentionHours { get; set; } = 5; + public int RecentSampleLimit { get; set; } = 25; + public string ResolveDirectoryPath(string contentRootPath) { ArgumentException.ThrowIfNullOrWhiteSpace(contentRootPath); @@ -42,4 +44,9 @@ public TimeSpan GetOrphanedStateFileRetention() ? TimeSpan.Zero : TimeSpan.FromHours(OrphanedStateFileRetentionHours); } + + public int GetRecentSampleLimit() + { + return Math.Max(RecentSampleLimit, 0); + } } diff --git a/src/ApiHealthDashboard/Domain/EndpointState.cs b/src/ApiHealthDashboard/Domain/EndpointState.cs index 74c496b..ab12f30 100644 --- a/src/ApiHealthDashboard/Domain/EndpointState.cs +++ b/src/ApiHealthDashboard/Domain/EndpointState.cs @@ -20,6 +20,8 @@ public sealed class EndpointState public bool IsPolling { get; set; } + public List RecentSamples { get; set; } = new(); + public EndpointState Clone() { return new EndpointState @@ -32,7 +34,8 @@ public EndpointState Clone() DurationMs = DurationMs, LastError = LastError, Snapshot = Snapshot?.Clone(), - IsPolling = IsPolling + IsPolling = IsPolling, + RecentSamples = RecentSamples.Select(static sample => sample.Clone()).ToList() }; } } diff --git a/src/ApiHealthDashboard/Domain/RecentPollSample.cs b/src/ApiHealthDashboard/Domain/RecentPollSample.cs new file mode 100644 index 0000000..f5ca629 --- /dev/null +++ b/src/ApiHealthDashboard/Domain/RecentPollSample.cs @@ -0,0 +1,26 @@ +namespace ApiHealthDashboard.Domain; + +public sealed class RecentPollSample +{ + public DateTimeOffset CheckedUtc { get; set; } + + public string Status { get; set; } = "Unknown"; + + public long DurationMs { get; set; } + + public string ResultKind { get; set; } = string.Empty; + + public string? ErrorSummary { get; set; } + + public RecentPollSample Clone() + { + return new RecentPollSample + { + CheckedUtc = CheckedUtc, + Status = Status, + DurationMs = DurationMs, + ResultKind = ResultKind, + ErrorSummary = ErrorSummary + }; + } +} diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml index 75de863..296369e 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml @@ -210,6 +210,43 @@ else +
+
+

Recent Poll Metrics

+
+
+ @if (!endpoint.HasRecentSamples) + { +
No recent samples have been retained yet for this endpoint.
+ } + else + { +
+
+ Retained samples +
@endpoint.RecentSampleCount
+
+
+ Recent success rate +
@endpoint.RecentSuccessRateText
+
+
+ Recent failures +
@endpoint.RecentFailureCountText
+
+
+ Average duration +
@endpoint.RecentAverageDurationText
+
+
+ Last status change +
@endpoint.LastStatusChangeText
+
+
+ } +
+
+

Request Filters

@@ -321,6 +358,46 @@ else
+
+
+

Recent Poll Activity

+
+
+ @if (!endpoint.HasRecentSamples) + { +
Recent poll activity will appear here after enough runtime samples have been collected.
+ } + else + { +
+ @foreach (var sample in endpoint.RecentSamples) + { +
+
+
+ @sample.Status + @sample.ResultKind +
+
@sample.CheckedText
+
+
+ @sample.DurationText + @if (sample.HasError) + { + @sample.ErrorSummary + } + else + { + No error + } +
+
+ } +
+ } +
+
+ @if (endpoint.ShowRawPayload) {
diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs index a5890f2..13ab5e7 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs @@ -2,6 +2,7 @@ using ApiHealthDashboard.Domain; using ApiHealthDashboard.Formatting; using ApiHealthDashboard.Scheduling; +using ApiHealthDashboard.Statistics; using ApiHealthDashboard.State; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -142,6 +143,16 @@ public sealed class EndpointDetailsViewModel public string DurationText { get; init; } = "-"; + public int RecentSampleCount { get; init; } + + public string RecentSuccessRateText { get; init; } = "No recent samples"; + + public string RecentFailureCountText { get; init; } = "0 failures"; + + public string RecentAverageDurationText { get; init; } = "-"; + + public string LastStatusChangeText { get; init; } = "No recent change"; + public string? ErrorText { get; init; } public string ErrorSummary => string.IsNullOrWhiteSpace(ErrorText) ? "None" : ErrorText; @@ -180,6 +191,10 @@ public sealed class EndpointDetailsViewModel public bool HasSnapshotMetadata => SnapshotMetadata.Count > 0; + public bool HasRecentSamples => RecentSamples.Count > 0; + + public IReadOnlyList RecentSamples { get; init; } = []; + public static EndpointDetailsViewModel From( EndpointConfig endpoint, EndpointState? state, @@ -190,6 +205,10 @@ public static EndpointDetailsViewModel From( var nodes = state?.Snapshot?.Nodes.Select(static node => node.Clone()).ToArray() ?? []; var flattenedNodes = FlattenNodes(nodes).ToArray(); var snapshotDurationMs = state?.DurationMs ?? state?.Snapshot?.DurationMs; + var recentSamples = state?.RecentSamples + .Select(static sample => sample.Clone()) + .ToArray() ?? []; + var recentMetrics = RecentPollSampleMetricsCalculator.Calculate(recentSamples); return new EndpointDetailsViewModel { @@ -210,6 +229,19 @@ public static EndpointDetailsViewModel From( LastSuccessfulText = FormatDateTime(state?.LastSuccessfulUtc), LastRetrievedText = FormatDateTime(state?.Snapshot?.RetrievedUtc), DurationText = snapshotDurationMs is long durationMs ? $"{durationMs} ms" : "-", + RecentSampleCount = recentMetrics.SampleCount, + RecentSuccessRateText = recentMetrics.HasSamples + ? $"{recentMetrics.SuccessRatePercent}% success" + : "No recent samples", + RecentFailureCountText = recentMetrics.HasSamples + ? $"{recentMetrics.FailureCount} failure{(recentMetrics.FailureCount == 1 ? string.Empty : "s")}" + : "0 failures", + RecentAverageDurationText = recentMetrics.HasSamples + ? $"{recentMetrics.AverageDurationMs} ms" + : "-", + LastStatusChangeText = recentMetrics.LastStatusChangeUtc is DateTimeOffset lastStatusChangeUtc + ? FormatDateTime(lastStatusChangeUtc) + : "No recent change", ErrorText = state?.LastError, Headers = endpoint.Headers .OrderBy(static header => header.Key, StringComparer.OrdinalIgnoreCase) @@ -242,7 +274,12 @@ public static EndpointDetailsViewModel From( UnhealthyCheckCount = flattenedNodes.Count(static node => node.Status == "Unhealthy"), UnknownCheckCount = flattenedNodes.Count(static node => node.Status is not ("Healthy" or "Degraded" or "Unhealthy")), RawPayload = showRawPayload ? FormatPayloadPreview(state?.Snapshot?.RawPayload) : null, - ShowRawPayload = showRawPayload + ShowRawPayload = showRawPayload, + RecentSamples = recentSamples + .OrderByDescending(static sample => sample.CheckedUtc) + .Take(10) + .Select(CreateRecentSampleViewModel) + .ToArray() }; } @@ -339,6 +376,39 @@ private static string ToPriorityBadgeClass(string priority) _ => "badge-info" }; } + + private static RecentPollSampleViewModel CreateRecentSampleViewModel(RecentPollSample sample) + { + return new RecentPollSampleViewModel + { + CheckedText = FormatDateTime(sample.CheckedUtc), + Status = sample.Status, + StatusBadgeClass = ToBadgeClass(sample.Status), + ResultKind = sample.ResultKind, + ResultKindBadgeClass = ToResultKindBadgeClass(sample), + DurationText = $"{sample.DurationMs} ms", + ErrorSummary = string.IsNullOrWhiteSpace(sample.ErrorSummary) ? "None" : sample.ErrorSummary, + HasError = !string.IsNullOrWhiteSpace(sample.ErrorSummary) + }; + } + + private static string ToResultKindBadgeClass(RecentPollSample sample) + { + if (!string.IsNullOrWhiteSpace(sample.ErrorSummary)) + { + return "badge-danger"; + } + + return sample.ResultKind switch + { + "Success" => "badge-success", + "Timeout" => "badge-warning", + "NetworkError" => "badge-danger", + "HttpError" => "badge-danger", + "EmptyResponse" => "badge-warning", + _ => "badge-secondary" + }; + } } public sealed class HeaderSummaryViewModel @@ -354,4 +424,23 @@ public sealed class MetadataSummaryViewModel public required string Value { get; init; } } + + public sealed class RecentPollSampleViewModel + { + public required string CheckedText { get; init; } + + public required string Status { get; init; } + + public required string StatusBadgeClass { get; init; } + + public required string ResultKind { get; init; } + + public required string ResultKindBadgeClass { get; init; } + + public required string DurationText { get; init; } + + public required string ErrorSummary { get; init; } + + public bool HasError { get; init; } + } } diff --git a/src/ApiHealthDashboard/Pages/Index.cshtml.cs b/src/ApiHealthDashboard/Pages/Index.cshtml.cs index 4a05bfc..329c641 100644 --- a/src/ApiHealthDashboard/Pages/Index.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Index.cshtml.cs @@ -1,6 +1,7 @@ using ApiHealthDashboard.Configuration; using ApiHealthDashboard.Domain; using ApiHealthDashboard.Scheduling; +using ApiHealthDashboard.Statistics; using ApiHealthDashboard.State; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; @@ -187,6 +188,14 @@ public sealed class EndpointSummaryViewModel public string DurationText { get; init; } = "-"; + public string RecentSuccessRateText { get; init; } = "No recent samples"; + + public string RecentAverageDurationText { get; init; } = "-"; + + public string RecentFailureCountText { get; init; } = "0 failures"; + + public IReadOnlyList RecentIndicators { get; init; } = []; + public string? ErrorText { get; init; } public string ErrorSummary => string.IsNullOrWhiteSpace(ErrorText) ? "None" : ErrorText; @@ -199,9 +208,15 @@ public sealed class EndpointSummaryViewModel public bool ShowStatusDescription => !string.Equals(StatusDescription, Status, StringComparison.OrdinalIgnoreCase); + public bool HasRecentSamples => RecentIndicators.Count > 0; + public static EndpointSummaryViewModel From(EndpointConfig endpoint, EndpointState? state) { var status = state?.Status ?? "Unknown"; + var recentSamples = state?.RecentSamples + .Select(static sample => sample.Clone()) + .ToArray() ?? []; + var recentMetrics = RecentPollSampleMetricsCalculator.Calculate(recentSamples); return new EndpointSummaryViewModel { @@ -219,10 +234,37 @@ public static EndpointSummaryViewModel From(EndpointConfig endpoint, EndpointSta LastCheckedText = FormatDateTime(state?.LastCheckedUtc), LastSuccessfulText = FormatDateTime(state?.LastSuccessfulUtc), DurationText = state?.DurationMs is long durationMs ? $"{durationMs} ms" : "-", + RecentSuccessRateText = recentMetrics.HasSamples + ? $"{recentMetrics.SuccessRatePercent}% success" + : "No recent samples", + RecentAverageDurationText = recentMetrics.HasSamples + ? $"{recentMetrics.AverageDurationMs} ms avg" + : "-", + RecentFailureCountText = recentMetrics.HasSamples + ? $"{recentMetrics.FailureCount} failure{(recentMetrics.FailureCount == 1 ? string.Empty : "s")}" + : "0 failures", + RecentIndicators = recentSamples + .OrderByDescending(static sample => sample.CheckedUtc) + .Take(8) + .Select(CreateRecentIndicator) + .ToArray(), ErrorText = state?.LastError }; } + private static RecentSampleIndicatorViewModel CreateRecentIndicator(RecentPollSample sample) + { + var summary = string.IsNullOrWhiteSpace(sample.ErrorSummary) + ? $"{sample.Status} via {sample.ResultKind}" + : $"{sample.Status} via {sample.ResultKind}: {sample.ErrorSummary}"; + + return new RecentSampleIndicatorViewModel + { + BadgeClass = ToRecentIndicatorClass(sample), + Summary = $"{sample.CheckedUtc.ToUniversalTime():yyyy-MM-dd HH:mm:ss 'UTC'} - {summary}" + }; + } + private static string FormatDateTime(DateTimeOffset? value) { return value?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") ?? "Never"; @@ -280,5 +322,29 @@ private static string ToPriorityBadgeClass(string priority) _ => "badge-info" }; } + + private static string ToRecentIndicatorClass(RecentPollSample sample) + { + if (!string.IsNullOrWhiteSpace(sample.ErrorSummary) || + !string.Equals(sample.ResultKind, "Success", StringComparison.OrdinalIgnoreCase)) + { + return "sample-indicator-failure"; + } + + return sample.Status switch + { + "Healthy" => "sample-indicator-healthy", + "Degraded" => "sample-indicator-degraded", + "Unhealthy" => "sample-indicator-unhealthy", + _ => "sample-indicator-unknown" + }; + } + } + + public sealed class RecentSampleIndicatorViewModel + { + public required string BadgeClass { get; init; } + + public required string Summary { get; init; } } } diff --git a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml index 8eb8d1b..078f15c 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml @@ -130,6 +130,7 @@ Last Checked Last Successful Duration + Recent Signal Frequency Error Actions @@ -175,6 +176,23 @@ @endpoint.LastCheckedText @endpoint.LastSuccessfulText @endpoint.DurationText + + @if (!endpoint.HasRecentSamples) + { + No recent samples + } + else + { +
+ @foreach (var indicator in endpoint.RecentIndicators) + { + + } +
+
@endpoint.RecentSuccessRateText
+
@endpoint.RecentAverageDurationText • @endpoint.RecentFailureCountText
+ } + @endpoint.FrequencyText @if (string.IsNullOrWhiteSpace(endpoint.ErrorText)) @@ -198,7 +216,7 @@ } - + No endpoints match your search. diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index b3374f0..7db78c9 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -29,6 +29,8 @@ builder.Configuration.GetSection(ImportUiOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(RuntimeStateOptions.SectionName)); +builder.Services.AddSingleton(static serviceProvider => + serviceProvider.GetRequiredService>().Value); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => diff --git a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs index 09e4674..ba568b3 100644 --- a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs +++ b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs @@ -15,6 +15,7 @@ public sealed class PollingSchedulerService : BackgroundService, IEndpointSchedu private readonly IEndpointPoller _endpointPoller; private readonly IHealthResponseParser _healthResponseParser; private readonly ILogger _logger; + private readonly RuntimeStateOptions _runtimeStateOptions; private readonly IEndpointStateStore _stateStore; private readonly TimeProvider _timeProvider; private readonly object _syncRoot = new(); @@ -28,6 +29,7 @@ public PollingSchedulerService( IEndpointStateStore stateStore, IEndpointPoller endpointPoller, IHealthResponseParser healthResponseParser, + RuntimeStateOptions runtimeStateOptions, TimeProvider timeProvider, ILogger logger) { @@ -35,6 +37,7 @@ public PollingSchedulerService( _stateStore = stateStore; _endpointPoller = endpointPoller; _healthResponseParser = healthResponseParser; + _runtimeStateOptions = runtimeStateOptions; _timeProvider = timeProvider; _logger = logger; } @@ -238,6 +241,8 @@ private async Task PollEndpointCoreAsync(EndpointConfig endpoint, string trigger updatedState.LastError = result.ErrorMessage; } + AppendRecentSample(updatedState, result); + _stateStore.Upsert(updatedState); _logger.LogInformation( @@ -260,6 +265,48 @@ private static EndpointState CreateFallbackState(EndpointConfig endpoint) }; } + private void AppendRecentSample(EndpointState state, PollResult result) + { + var recentSampleLimit = _runtimeStateOptions.GetRecentSampleLimit(); + if (recentSampleLimit <= 0) + { + state.RecentSamples.Clear(); + return; + } + + state.RecentSamples.Add(new RecentPollSample + { + CheckedUtc = result.CheckedUtc, + Status = state.Status, + DurationMs = result.DurationMs, + ResultKind = result.Kind.ToString(), + ErrorSummary = SummarizeError(state.LastError) + }); + + if (state.RecentSamples.Count <= recentSampleLimit) + { + return; + } + + var removeCount = state.RecentSamples.Count - recentSampleLimit; + state.RecentSamples.RemoveRange(0, removeCount); + } + + private static string? SummarizeError(string? errorText) + { + if (string.IsNullOrWhiteSpace(errorText)) + { + return null; + } + + const int maxLength = 160; + var trimmed = errorText.Trim(); + + return trimmed.Length <= maxLength + ? trimmed + : $"{trimmed[..(maxLength - 3)]}..."; + } + private static bool TryGetParserError(HealthSnapshot snapshot, out string parserError) { if (snapshot.Metadata.TryGetValue("parserError", out var value) && diff --git a/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs index 5eaecdf..e2b55c8 100644 --- a/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs +++ b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs @@ -354,6 +354,8 @@ private sealed class PersistedEndpointState public HealthSnapshot? Snapshot { get; set; } + public List RecentSamples { get; set; } = new(); + public static PersistedEndpointState FromRuntimeState(EndpointState state) { return new PersistedEndpointState @@ -365,7 +367,8 @@ public static PersistedEndpointState FromRuntimeState(EndpointState state) LastSuccessfulUtc = state.LastSuccessfulUtc, DurationMs = state.DurationMs, LastError = state.LastError, - Snapshot = state.Snapshot?.Clone() + Snapshot = state.Snapshot?.Clone(), + RecentSamples = state.RecentSamples.Select(static sample => sample.Clone()).ToList() }; } @@ -381,6 +384,7 @@ public EndpointState ToRuntimeState() DurationMs = DurationMs, LastError = LastError, Snapshot = Snapshot?.Clone(), + RecentSamples = RecentSamples.Select(static sample => sample.Clone()).ToList(), IsPolling = false }; } diff --git a/src/ApiHealthDashboard/Statistics/RecentPollSampleMetricsCalculator.cs b/src/ApiHealthDashboard/Statistics/RecentPollSampleMetricsCalculator.cs new file mode 100644 index 0000000..1c20956 --- /dev/null +++ b/src/ApiHealthDashboard/Statistics/RecentPollSampleMetricsCalculator.cs @@ -0,0 +1,97 @@ +using ApiHealthDashboard.Domain; + +namespace ApiHealthDashboard.Statistics; + +public static class RecentPollSampleMetricsCalculator +{ + public static RecentPollSampleMetrics Calculate(IEnumerable samples) + { + ArgumentNullException.ThrowIfNull(samples); + + var orderedSamples = samples + .Where(static sample => sample is not null) + .OrderBy(static sample => sample.CheckedUtc) + .ToArray(); + + if (orderedSamples.Length == 0) + { + return RecentPollSampleMetrics.Empty; + } + + var successCount = orderedSamples.Count(IsSuccessfulSample); + var failureCount = orderedSamples.Length - successCount; + var averageDurationMs = (long)Math.Round( + orderedSamples.Average(static sample => sample.DurationMs), + MidpointRounding.AwayFromZero); + + return new RecentPollSampleMetrics + { + SampleCount = orderedSamples.Length, + SuccessCount = successCount, + FailureCount = failureCount, + AverageDurationMs = averageDurationMs, + LastStatusChangeUtc = ResolveLastStatusChangeUtc(orderedSamples) + }; + } + + private static bool IsSuccessfulSample(RecentPollSample sample) + { + return string.Equals(sample.ResultKind, "Success", StringComparison.OrdinalIgnoreCase) && + string.IsNullOrWhiteSpace(sample.ErrorSummary); + } + + private static DateTimeOffset? ResolveLastStatusChangeUtc(IReadOnlyList orderedSamples) + { + if (orderedSamples.Count < 2) + { + return null; + } + + var currentStatus = NormalizeStatus(orderedSamples[^1].Status); + var streakStartUtc = orderedSamples[^1].CheckedUtc; + var sawDifferentStatus = false; + + for (var index = orderedSamples.Count - 2; index >= 0; index--) + { + var sampleStatus = NormalizeStatus(orderedSamples[index].Status); + if (string.Equals(sampleStatus, currentStatus, StringComparison.OrdinalIgnoreCase)) + { + streakStartUtc = orderedSamples[index].CheckedUtc; + continue; + } + + sawDifferentStatus = true; + break; + } + + return sawDifferentStatus ? streakStartUtc : null; + } + + private static string NormalizeStatus(string? status) + { + return string.IsNullOrWhiteSpace(status) + ? "Unknown" + : status.Trim(); + } +} + +public sealed class RecentPollSampleMetrics +{ + public static RecentPollSampleMetrics Empty { get; } = new(); + + public int SampleCount { get; init; } + + public int SuccessCount { get; init; } + + public int FailureCount { get; init; } + + public long AverageDurationMs { get; init; } + + public DateTimeOffset? LastStatusChangeUtc { get; init; } + + public bool HasSamples => SampleCount > 0; + + public int SuccessRatePercent => SampleCount == 0 + ? 0 + : (int)Math.Round((double)SuccessCount * 100 / SampleCount, MidpointRounding.AwayFromZero); +} diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index 719b481..55f0b54 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -8,7 +8,8 @@ "CleanupEnabled": true, "CleanupIntervalMinutes": 30, "DeleteOrphanedStateFiles": true, - "OrphanedStateFileRetentionHours": 5 + "OrphanedStateFileRetentionHours": 5, + "RecentSampleLimit": 25 }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index ab04b6c..871df8c 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -6,9 +6,10 @@ "Enabled": true, "DirectoryPath": "runtime-state/endpoints", "CleanupEnabled": true, - "CleanupIntervalMinutes": 5, + "CleanupIntervalMinutes": 0.5, "DeleteOrphanedStateFiles": true, - "OrphanedStateFileRetentionHours": 5 + "OrphanedStateFileRetentionHours": 5, + "RecentSampleLimit": 25 }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index 678d9bd..27e89e0 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -208,6 +208,42 @@ body { white-space: normal; } +.dashboard-recent-cell { + min-width: 14rem; +} + +.sample-indicator-strip { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + margin-bottom: 0.4rem; +} + +.sample-indicator { + display: inline-block; + width: 0.7rem; + height: 0.7rem; + border-radius: 999px; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.08); +} + +.sample-indicator-healthy { + background: #28a745; +} + +.sample-indicator-degraded { + background: #f59e0b; +} + +.sample-indicator-unhealthy, +.sample-indicator-failure { + background: #dc3545; +} + +.sample-indicator-unknown { + background: #6c757d; +} + .dashboard-card-tools { display: flex; align-items: center; @@ -521,6 +557,45 @@ body { border-top: 0; } +.recent-poll-list { + display: grid; + gap: 0.85rem; +} + +.recent-poll-item { + padding: 0.9rem 1rem; + border-radius: 0.9rem; + background: rgba(15, 23, 42, 0.035); + border: 1px solid rgba(31, 41, 55, 0.08); +} + +.recent-poll-heading { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.recent-poll-badges { + display: flex; + gap: 0.5rem; + flex-wrap: wrap; +} + +.recent-poll-time { + font-size: 0.84rem; + color: #6b7280; +} + +.recent-poll-meta { + display: flex; + gap: 0.85rem; + flex-wrap: wrap; + margin-top: 0.5rem; + font-size: 0.9rem; +} + @media (max-width: 991.98px) { .hero-title { font-size: 1.65rem; diff --git a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs index 3bafe24..1aa924d 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs @@ -59,6 +59,7 @@ public async Task ReloadNowAsync_WhenYamlChanges_UpdatesSharedConfigAndStateStor stateStore, new StubEndpointPoller(), new StubHealthResponseParser(), + new RuntimeStateOptions(), TimeProvider.System, NullLogger.Instance); diff --git a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs index 3584e82..7bf6bc5 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs @@ -96,6 +96,24 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() LastSuccessfulUtc = new DateTimeOffset(2026, 03, 18, 8, 29, 0, TimeSpan.Zero), DurationMs = 420, LastError = "Database latency exceeded threshold.", + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 18, 8, 28, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 190, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 18, 8, 30, 0, TimeSpan.Zero), + Status = "Degraded", + DurationMs = 420, + ResultKind = "Success", + ErrorSummary = "Database latency exceeded threshold." + } + ], Snapshot = new HealthSnapshot { OverallStatus = "Degraded", @@ -152,6 +170,12 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() Assert.Equal(["cache"], model.Endpoint.ExcludeChecks); Assert.Equal(3, model.Endpoint.SnapshotMetadata.Count); Assert.Equal("(empty)", model.Endpoint.SnapshotMetadata.Single(static item => item.Name == "tags").Value); + Assert.Equal(2, model.Endpoint.RecentSampleCount); + Assert.Equal("50% success", model.Endpoint.RecentSuccessRateText); + Assert.Equal("1 failure", model.Endpoint.RecentFailureCountText); + Assert.Equal("305 ms", model.Endpoint.RecentAverageDurationText); + Assert.Equal("2026-03-18 08:30:00 UTC", model.Endpoint.LastStatusChangeText); + Assert.Equal(2, model.Endpoint.RecentSamples.Count); Assert.Equal( "{\n \"status\": \"Degraded\"\n}", model.Endpoint.RawPayload!.Replace("\r\n", "\n", StringComparison.Ordinal)); diff --git a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs index 5123d22..0e841fc 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs @@ -101,7 +101,25 @@ public void OnGet_CalculatesMixedStatusCountersAndProblemEndpoints() EndpointId = "billing-api", EndpointName = "Billing API", Status = "Degraded", - LastError = "Dependency timeout" + LastError = "Dependency timeout", + RecentSamples = + [ + new ApiHealthDashboard.Domain.RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 0, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 120, + ResultKind = "Success" + }, + new ApiHealthDashboard.Domain.RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 1, 0, TimeSpan.Zero), + Status = "Degraded", + DurationMs = 340, + ResultKind = "Success", + ErrorSummary = "Dependency timeout" + } + ] }); var model = new IndexModel(config, store, new StubEndpointScheduler(), NullLogger.Instance); @@ -118,6 +136,8 @@ public void OnGet_CalculatesMixedStatusCountersAndProblemEndpoints() Assert.Single(model.ProblemEndpoints); Assert.Equal("billing-api", model.ProblemEndpoints[0].Id); Assert.Equal(EndpointPriority.Critical, model.ProblemEndpoints[0].Priority); + Assert.Equal("50% success", model.ProblemEndpoints[0].RecentSuccessRateText); + Assert.Equal("230 ms avg", model.ProblemEndpoints[0].RecentAverageDurationText); } [Fact] diff --git a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs index dc59849..3aca6cd 100644 --- a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs +++ b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs @@ -40,6 +40,7 @@ public async Task ReloadConfigurationAsync_WhenNotStarted_SynchronizesStateStore stateStore, new NoOpEndpointPoller(), new NoOpHealthResponseParser(), + new RuntimeStateOptions(), TimeProvider.System, NullLogger.Instance); diff --git a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs index d1a8374..80cc47b 100644 --- a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs @@ -54,6 +54,10 @@ public async Task RefreshEndpointAsync_OnSuccessfulPoll_UpdatesRuntimeState() Assert.Null(state.LastError); Assert.NotNull(state.Snapshot); Assert.Equal("Healthy", state.Snapshot!.OverallStatus); + var recentSample = Assert.Single(state.RecentSamples); + Assert.Equal("Healthy", recentSample.Status); + Assert.Equal("Success", recentSample.ResultKind); + Assert.Null(recentSample.ErrorSummary); } [Fact] @@ -293,19 +297,94 @@ public async Task RefreshEndpointAsync_WhenParserReturnsErrorSnapshot_PersistsLa Assert.Equal("Unknown", state!.Status); Assert.Equal("Failed to parse health response: Malformed nested payload", state.LastError); Assert.NotNull(state.Snapshot); + var recentSample = Assert.Single(state.RecentSamples); + Assert.Equal("Unknown", recentSample.Status); + Assert.Equal("Success", recentSample.ResultKind); + Assert.Equal("Failed to parse health response: Malformed nested payload", recentSample.ErrorSummary); + } + + [Fact] + public async Task RefreshEndpointAsync_TrimsRecentSamplesToConfiguredLimit() + { + var endpoint = new EndpointConfig + { + Id = "orders-api", + Name = "Orders API", + Url = "https://orders.example.com/health", + Enabled = true, + FrequencySeconds = 30 + }; + + var store = new InMemoryEndpointStateStore([endpoint]); + store.Upsert(new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy", + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T00:00:00Z"), + Status = "Healthy", + DurationMs = 10, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T00:01:00Z"), + Status = "Healthy", + DurationMs = 11, + ResultKind = "Success" + } + ] + }); + + var scheduler = CreateScheduler( + new DashboardConfig { Endpoints = [endpoint] }, + store, + new DelegateEndpointPoller((_, _) => Task.FromResult(new PollResult + { + Kind = PollResultKind.Timeout, + CheckedUtc = DateTimeOffset.Parse("2026-03-19T00:02:00Z"), + DurationMs = 999, + ErrorMessage = "Timed out" + })), + new DelegateHealthResponseParser((_, _, durationMs) => new HealthSnapshot + { + OverallStatus = "Healthy", + RetrievedUtc = DateTimeOffset.UtcNow, + DurationMs = durationMs + }), + new RuntimeStateOptions + { + RecentSampleLimit = 2 + }); + + await scheduler.RefreshEndpointAsync("orders-api"); + + var state = store.Get("orders-api"); + + Assert.NotNull(state); + Assert.Equal(2, state!.RecentSamples.Count); + Assert.Equal(DateTimeOffset.Parse("2026-03-19T00:01:00Z"), state.RecentSamples[0].CheckedUtc); + Assert.Equal(DateTimeOffset.Parse("2026-03-19T00:02:00Z"), state.RecentSamples[1].CheckedUtc); + Assert.Equal("Timeout", state.RecentSamples[1].ResultKind); } private static PollingSchedulerService CreateScheduler( DashboardConfig config, IEndpointStateStore stateStore, IEndpointPoller endpointPoller, - IHealthResponseParser healthResponseParser) + IHealthResponseParser healthResponseParser, + RuntimeStateOptions? runtimeStateOptions = null) { return new PollingSchedulerService( config, stateStore, endpointPoller, healthResponseParser, + runtimeStateOptions ?? new RuntimeStateOptions(), TimeProvider.System, NullLogger.Instance); } diff --git a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs index ad2616b..669aa8a 100644 --- a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs +++ b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs @@ -34,6 +34,16 @@ public void Upsert_PersistsCurrentStateToPerEndpointFile() DurationMs = 123, LastError = null, IsPolling = true, + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-18T14:59:00Z"), + Status = "Healthy", + DurationMs = 120, + ResultKind = "Success" + } + ], Snapshot = new HealthSnapshot { OverallStatus = "Healthy", @@ -66,6 +76,7 @@ public void Upsert_PersistsCurrentStateToPerEndpointFile() Assert.Contains("\"status\":\"Healthy\"", json); Assert.Contains("\"endpointId\":\"orders-api\"", json); + Assert.Contains("\"recentSamples\":[", json); Assert.DoesNotContain("isPolling", json, StringComparison.OrdinalIgnoreCase); } @@ -87,6 +98,24 @@ public void Constructor_RestoresPersistedStateForConfiguredEndpoint() LastSuccessfulUtc = DateTimeOffset.Parse("2026-03-18T15:59:00Z"), DurationMs = 456, LastError = "Slow dependency", + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-18T15:58:00Z"), + Status = "Healthy", + DurationMs = 430, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-18T16:00:00Z"), + Status = "Degraded", + DurationMs = 456, + ResultKind = "Success", + ErrorSummary = "Slow dependency" + } + ], Snapshot = new HealthSnapshot { OverallStatus = "Degraded", @@ -110,6 +139,8 @@ public void Constructor_RestoresPersistedStateForConfiguredEndpoint() Assert.Equal("Slow dependency", restoredState.LastError); Assert.NotNull(restoredState.Snapshot); Assert.False(restoredState.IsPolling); + Assert.Equal(2, restoredState.RecentSamples.Count); + Assert.Equal("Degraded", restoredState.RecentSamples[^1].Status); } [Fact] @@ -269,7 +300,8 @@ private static RuntimeStateOptions CreateRuntimeStateOptions( CleanupEnabled = cleanupEnabled, CleanupIntervalMinutes = cleanupIntervalMinutes, DeleteOrphanedStateFiles = deleteOrphanedStateFiles, - OrphanedStateFileRetentionHours = orphanedRetentionHours + OrphanedStateFileRetentionHours = orphanedRetentionHours, + RecentSampleLimit = 25 }; } diff --git a/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs b/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs index 4af335f..43a449d 100644 --- a/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs +++ b/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs @@ -35,6 +35,16 @@ public void Upsert_StoresAndReturnsDeepCopies() Status = "Healthy", LastError = "none", IsPolling = true, + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T00:00:00Z"), + Status = "Healthy", + DurationMs = 42, + ResultKind = "Success" + } + ], Snapshot = new HealthSnapshot { OverallStatus = "Healthy", @@ -64,11 +74,14 @@ public void Upsert_StoresAndReturnsDeepCopies() Assert.NotNull(storedState); Assert.Equal("Healthy", storedState!.Status); Assert.Equal("Healthy", storedState.Snapshot!.Nodes[0].Status); + Assert.Single(storedState.RecentSamples); storedState.Snapshot.Nodes[0].Children[0].Status = "Unhealthy"; + storedState.RecentSamples[0].Status = "Unhealthy"; var storedStateAgain = store.Get("orders-api"); Assert.Equal("Healthy", storedStateAgain!.Snapshot!.Nodes[0].Children[0].Status); + Assert.Equal("Healthy", storedStateAgain.RecentSamples[0].Status); } [Fact] From 288c80e63b49cc71d1f773b5a634c2f12312ca96 Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 10:03:55 +0800 Subject: [PATCH 06/15] feat: enhance dashboard styling with improved spacing and font adjustments --- src/ApiHealthDashboard/wwwroot/css/site.css | 35 ++++++++++++++------- 1 file changed, 23 insertions(+), 12 deletions(-) diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index 27e89e0..45a3fad 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -43,12 +43,16 @@ body { color: #fff; } +.dashboard-hero .card-body { + padding: 1.1rem 1.25rem; +} + .hero-kicker { display: inline-block; - margin-bottom: 0.85rem; - padding: 0.35rem 0.65rem; + margin-bottom: 0.55rem; + padding: 0.25rem 0.55rem; border-radius: 999px; - font-size: 0.75rem; + font-size: 0.68rem; font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase; @@ -56,32 +60,35 @@ body { } .hero-title { - margin-bottom: 0.75rem; - font-size: 2rem; + margin-bottom: 0.5rem; + font-size: 1.55rem; font-weight: 700; + line-height: 1.2; } .hero-copy { - max-width: 44rem; + max-width: 40rem; + font-size: 0.95rem; + line-height: 1.45; color: rgba(255, 255, 255, 0.86); } .hero-panel { - margin-top: 1rem; - padding: 1rem; - border-radius: 1rem; + margin-top: 0.5rem; + padding: 0.8rem 0.9rem; + border-radius: 0.85rem; background: rgba(7, 14, 29, 0.18); backdrop-filter: blur(2px); } .hero-stat { - margin-bottom: 0.9rem; + margin-bottom: 0.65rem; } .hero-stat-label { display: block; margin-bottom: 0.1rem; - font-size: 0.78rem; + font-size: 0.72rem; color: rgba(255, 255, 255, 0.76); text-transform: uppercase; letter-spacing: 0.06em; @@ -598,7 +605,11 @@ body { @media (max-width: 991.98px) { .hero-title { - font-size: 1.65rem; + font-size: 1.35rem; + } + + .dashboard-hero .card-body { + padding: 0.95rem 1rem; } .import-form-section { From 3d27fa0dad740907d571111ffd719949c39a4d70 Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 10:54:16 +0800 Subject: [PATCH 07/15] feat: enhance dashboard functionality with new endpoint integration and improved UI interactions --- .../Pages/Shared/_DashboardLiveSection.cshtml | 30 +++++- src/ApiHealthDashboard/wwwroot/css/site.css | 22 +++++ src/ApiHealthDashboard/wwwroot/js/site.js | 92 +++++++++++++++++++ 3 files changed, 140 insertions(+), 4 deletions(-) diff --git a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml index 078f15c..d749c96 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml @@ -79,7 +79,7 @@
-
+

Endpoint Summary

@@ -150,8 +150,30 @@ endpoint.StatusDescription, endpoint.ErrorSummary }.Where(static value => !string.IsNullOrWhiteSpace(value))); + var rowSignature = string.Join( + '|', + new[] + { + endpoint.Status, + endpoint.StatusDescription, + endpoint.Priority, + endpoint.LastCheckedText, + endpoint.LastSuccessfulText, + endpoint.DurationText, + endpoint.RecentSuccessRateText, + endpoint.RecentAverageDurationText, + endpoint.RecentFailureCountText, + endpoint.ErrorSummary, + endpoint.Enabled ? "enabled" : "disabled", + endpoint.IsPolling ? "polling" : "idle", + endpoint.HasRecentSamples ? "samples" : "no-samples", + string.Join(';', endpoint.RecentIndicators.Select(static indicator => indicator.Summary)) + }.Where(static value => !string.IsNullOrWhiteSpace(value))); - +
@endpoint.Name
@if (endpoint.ShowIdHint) @@ -205,7 +227,7 @@ } -
+
-
+

Health Distribution

diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index 45a3fad..7030fb3 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -209,6 +209,14 @@ body { vertical-align: top; } +.dashboard-row-flash { + animation: dashboard-row-flash 1.8s ease-out; +} + +.dashboard-row-flash > td { + background: rgba(15, 157, 138, 0.14); +} + .dashboard-error-cell { min-width: 14rem; max-width: 18rem; @@ -603,6 +611,20 @@ body { font-size: 0.9rem; } +@keyframes dashboard-row-flash { + 0% { + box-shadow: inset 0 0 0 999px rgba(15, 157, 138, 0.2); + } + + 45% { + box-shadow: inset 0 0 0 999px rgba(15, 157, 138, 0.08); + } + + 100% { + box-shadow: inset 0 0 0 999px rgba(15, 157, 138, 0); + } +} + @media (max-width: 991.98px) { .hero-title { font-size: 1.35rem; diff --git a/src/ApiHealthDashboard/wwwroot/js/site.js b/src/ApiHealthDashboard/wwwroot/js/site.js index 379c834..def6695 100644 --- a/src/ApiHealthDashboard/wwwroot/js/site.js +++ b/src/ApiHealthDashboard/wwwroot/js/site.js @@ -1,6 +1,8 @@ document.addEventListener("DOMContentLoaded", () => { initializeDisabledActions(document); initializeEndpointSearch(document); + initializeEndpointRefreshForms(document); + applyPendingEndpointFlash(document); initializeCopyButtons(document); initializeDashboardSectionRefresh(); }); @@ -105,6 +107,39 @@ function initializeCopyButtons(root) { }); } +function initializeEndpointRefreshForms(root) { + root.querySelectorAll("[data-endpoint-refresh-form]").forEach((form) => { + if (form.dataset.refreshFlashBound === "true") { + return; + } + + form.dataset.refreshFlashBound = "true"; + form.addEventListener("submit", () => { + const endpointInput = form.querySelector("input[name='endpointId']"); + const endpointId = endpointInput ? endpointInput.value.trim() : ""; + + if (endpointId !== "") { + window.sessionStorage.setItem("dashboardPendingFlashEndpointId", endpointId); + } + }); + }); +} + +function applyPendingEndpointFlash(root) { + const endpointId = window.sessionStorage.getItem("dashboardPendingFlashEndpointId"); + if (!endpointId) { + return; + } + + const row = root.querySelector(`[data-endpoint-row-id="${cssEscape(endpointId)}"]`); + if (!row) { + return; + } + + flashEndpointRow(row); + window.sessionStorage.removeItem("dashboardPendingFlashEndpointId"); +} + function setCopyFeedback(feedback, message) { if (feedback) { feedback.textContent = message; @@ -130,6 +165,7 @@ function initializeDashboardSectionRefresh() { } const preservedQuery = getCurrentSearchQuery(container); + const previousRowSignatures = getEndpointRowSignatures(container); try { const response = await fetch(refreshUrl, { @@ -150,6 +186,9 @@ function initializeDashboardSectionRefresh() { initializeDisabledActions(container); initializeEndpointSearch(container, preservedQuery); + initializeEndpointRefreshForms(container); + flashChangedEndpointRows(container, previousRowSignatures); + applyPendingEndpointFlash(container); initializeCopyButtons(container); } catch { // Keep the current rendered section if the background refresh fails. @@ -170,3 +209,56 @@ function shouldPauseDashboardRefresh(container) { return activeElement.matches("input, textarea, select"); } + +function cssEscape(value) { + if (window.CSS && typeof window.CSS.escape === "function") { + return window.CSS.escape(value); + } + + return value.replace(/["\\]/g, "\\$&"); +} + +function getEndpointRowSignatures(root) { + const signatures = new Map(); + + root.querySelectorAll("[data-endpoint-row-id]").forEach((row) => { + const endpointId = row.getAttribute("data-endpoint-row-id"); + if (!endpointId) { + return; + } + + signatures.set(endpointId, row.getAttribute("data-endpoint-row-signature") || ""); + }); + + return signatures; +} + +function flashChangedEndpointRows(root, previousRowSignatures) { + if (!previousRowSignatures || previousRowSignatures.size === 0) { + return; + } + + root.querySelectorAll("[data-endpoint-row-id]").forEach((row) => { + const endpointId = row.getAttribute("data-endpoint-row-id"); + if (!endpointId || !previousRowSignatures.has(endpointId)) { + return; + } + + const previousSignature = previousRowSignatures.get(endpointId) || ""; + const currentSignature = row.getAttribute("data-endpoint-row-signature") || ""; + + if (previousSignature !== currentSignature) { + flashEndpointRow(row); + } + }); +} + +function flashEndpointRow(row) { + row.classList.remove("dashboard-row-flash"); + void row.offsetWidth; + row.classList.add("dashboard-row-flash"); + + window.setTimeout(() => { + row.classList.remove("dashboard-row-flash"); + }, 2200); +} From e48ae3c1188d899c32667685acb1b2f04bc0ca5a Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 11:08:48 +0800 Subject: [PATCH 08/15] feat: enhance dashboard row flash cues for endpoint status changes --- README.md | 2 + .../Pages/Shared/_DashboardLiveSection.cshtml | 1 + src/ApiHealthDashboard/wwwroot/css/site.css | 42 ++++++++-- src/ApiHealthDashboard/wwwroot/js/site.js | 76 +++++++++++++++---- 4 files changed, 101 insertions(+), 20 deletions(-) diff --git a/README.md b/README.md index 15047d6..1e23ceb 100644 --- a/README.md +++ b/README.md @@ -213,6 +213,8 @@ Current dashboard behavior: - highlights healthy, degraded, unhealthy, and unknown totals in summary cards - includes a client-side search field for filtering endpoint rows by name, id, status, or error text - refreshes the live dashboard section with same-origin timed GET requests instead of reloading the whole page +- briefly flashes endpoint rows when they are manually refreshed or when background polling updates change the rendered row state +- uses different row flash cues for routine updates, improving health transitions, and worsening health transitions - sorts endpoint summaries and active issues by endpoint priority before name - renders a live endpoint table with last check, duration, recent signal metrics, error summary, and manual refresh actions - surfaces degraded and unhealthy endpoints in an active issues panel for faster triage diff --git a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml index d749c96..3738119 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml @@ -173,6 +173,7 @@
@endpoint.Name
diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index 7030fb3..e959a78 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -209,12 +209,16 @@ body { vertical-align: top; } -.dashboard-row-flash { - animation: dashboard-row-flash 1.8s ease-out; +.dashboard-row-flash-update > td { + animation: dashboard-row-flash-update 1.8s ease-out; } -.dashboard-row-flash > td { - background: rgba(15, 157, 138, 0.14); +.dashboard-row-flash-improving > td { + animation: dashboard-row-flash-improving 1.9s ease-out; +} + +.dashboard-row-flash-worsening > td { + animation: dashboard-row-flash-worsening 1.9s ease-out; } .dashboard-error-cell { @@ -611,7 +615,7 @@ body { font-size: 0.9rem; } -@keyframes dashboard-row-flash { +@keyframes dashboard-row-flash-update { 0% { box-shadow: inset 0 0 0 999px rgba(15, 157, 138, 0.2); } @@ -625,6 +629,34 @@ body { } } +@keyframes dashboard-row-flash-improving { + 0% { + box-shadow: inset 0 0 0 999px rgba(40, 167, 69, 0.24); + } + + 40% { + box-shadow: inset 0 0 0 999px rgba(40, 167, 69, 0.1); + } + + 100% { + box-shadow: inset 0 0 0 999px rgba(40, 167, 69, 0); + } +} + +@keyframes dashboard-row-flash-worsening { + 0% { + box-shadow: inset 0 0 0 999px rgba(245, 158, 11, 0.28); + } + + 40% { + box-shadow: inset 0 0 0 999px rgba(245, 158, 11, 0.12); + } + + 100% { + box-shadow: inset 0 0 0 999px rgba(245, 158, 11, 0); + } +} + @media (max-width: 991.98px) { .hero-title { font-size: 1.35rem; diff --git a/src/ApiHealthDashboard/wwwroot/js/site.js b/src/ApiHealthDashboard/wwwroot/js/site.js index def6695..63d405c 100644 --- a/src/ApiHealthDashboard/wwwroot/js/site.js +++ b/src/ApiHealthDashboard/wwwroot/js/site.js @@ -165,7 +165,7 @@ function initializeDashboardSectionRefresh() { } const preservedQuery = getCurrentSearchQuery(container); - const previousRowSignatures = getEndpointRowSignatures(container); + const previousRowStates = getEndpointRowStates(container); try { const response = await fetch(refreshUrl, { @@ -187,7 +187,7 @@ function initializeDashboardSectionRefresh() { initializeDisabledActions(container); initializeEndpointSearch(container, preservedQuery); initializeEndpointRefreshForms(container); - flashChangedEndpointRows(container, previousRowSignatures); + flashChangedEndpointRows(container, previousRowStates); applyPendingEndpointFlash(container); initializeCopyButtons(container); } catch { @@ -218,8 +218,8 @@ function cssEscape(value) { return value.replace(/["\\]/g, "\\$&"); } -function getEndpointRowSignatures(root) { - const signatures = new Map(); +function getEndpointRowStates(root) { + const rowStates = new Map(); root.querySelectorAll("[data-endpoint-row-id]").forEach((row) => { const endpointId = row.getAttribute("data-endpoint-row-id"); @@ -227,38 +227,84 @@ function getEndpointRowSignatures(root) { return; } - signatures.set(endpointId, row.getAttribute("data-endpoint-row-signature") || ""); + rowStates.set(endpointId, { + signature: row.getAttribute("data-endpoint-row-signature") || "", + status: row.getAttribute("data-endpoint-row-status") || "" + }); }); - return signatures; + return rowStates; } -function flashChangedEndpointRows(root, previousRowSignatures) { - if (!previousRowSignatures || previousRowSignatures.size === 0) { +function flashChangedEndpointRows(root, previousRowStates) { + if (!previousRowStates || previousRowStates.size === 0) { return; } root.querySelectorAll("[data-endpoint-row-id]").forEach((row) => { const endpointId = row.getAttribute("data-endpoint-row-id"); - if (!endpointId || !previousRowSignatures.has(endpointId)) { + if (!endpointId || !previousRowStates.has(endpointId)) { return; } - const previousSignature = previousRowSignatures.get(endpointId) || ""; + const previousState = previousRowStates.get(endpointId); + const previousSignature = previousState ? previousState.signature : ""; + const previousStatus = previousState ? previousState.status : ""; const currentSignature = row.getAttribute("data-endpoint-row-signature") || ""; + const currentStatus = row.getAttribute("data-endpoint-row-status") || ""; if (previousSignature !== currentSignature) { - flashEndpointRow(row); + const flashClass = getFlashClassForStatusChange(previousStatus, currentStatus); + + flashEndpointRow(row, flashClass); } }); } -function flashEndpointRow(row) { - row.classList.remove("dashboard-row-flash"); +function flashEndpointRow(row, flashClass = "dashboard-row-flash-update") { + row.classList.remove( + "dashboard-row-flash-update", + "dashboard-row-flash-improving", + "dashboard-row-flash-worsening" + ); void row.offsetWidth; - row.classList.add("dashboard-row-flash"); + row.classList.add(flashClass); window.setTimeout(() => { - row.classList.remove("dashboard-row-flash"); + row.classList.remove(flashClass); }, 2200); } + +function getFlashClassForStatusChange(previousStatus, currentStatus) { + if (previousStatus === currentStatus) { + return "dashboard-row-flash-update"; + } + + const previousRank = getEndpointStatusRank(previousStatus); + const currentRank = getEndpointStatusRank(currentStatus); + + if (currentRank > previousRank) { + return "dashboard-row-flash-improving"; + } + + if (currentRank < previousRank) { + return "dashboard-row-flash-worsening"; + } + + return "dashboard-row-flash-update"; +} + +function getEndpointStatusRank(status) { + switch ((status || "").toLowerCase()) { + case "healthy": + return 3; + case "degraded": + return 2; + case "unhealthy": + return 1; + case "unknown": + return 0; + default: + return 0; + } +} From a16ea0e4ca9602c89df82f85527ce5af1c5d8382 Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 12:12:11 +0800 Subject: [PATCH 09/15] feat: add recent trend visuals and status history for endpoints --- README.md | 7 +- .../Pages/Endpoints/Details.cshtml | 50 +++++++ .../Pages/Endpoints/Details.cshtml.cs | 113 ++++++++++++++ src/ApiHealthDashboard/Pages/Index.cshtml.cs | 64 ++++++++ .../Pages/Shared/_DashboardLiveSection.cshtml | 5 + .../Statistics/RecentPollTrendAnalyzer.cs | 141 ++++++++++++++++++ src/ApiHealthDashboard/wwwroot/css/site.css | 52 +++++++ .../Pages/Endpoints/DetailsModelTests.cs | 5 + .../Pages/IndexModelTests.cs | 91 +++++++++++ .../RecentPollTrendAnalyzerTests.cs | 62 ++++++++ 10 files changed, 587 insertions(+), 3 deletions(-) create mode 100644 src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs create mode 100644 tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs diff --git a/README.md b/README.md index 1e23ceb..52ec404 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,7 @@ Implemented so far: - Post-v1: per-endpoint current-state persistence with compact JSON files - Post-v1: runtime-state cleanup and retention settings for orphaned persisted state files - Post-v1: recent poll sample retention with derived dashboard and details metrics +- Post-v1: mini trend visuals and short status history from retained runtime samples Not implemented yet: - Backlog items tracked for post-v1 work @@ -215,6 +216,7 @@ Current dashboard behavior: - refreshes the live dashboard section with same-origin timed GET requests instead of reloading the whole page - briefly flashes endpoint rows when they are manually refreshed or when background polling updates change the rendered row state - uses different row flash cues for routine updates, improving health transitions, and worsening health transitions +- shows a compact recent-status indicator strip plus a short trend label for each endpoint - sorts endpoint summaries and active issues by endpoint priority before name - renders a live endpoint table with last check, duration, recent signal metrics, error summary, and manual refresh actions - surfaces degraded and unhealthy endpoints in an active issues panel for faster triage @@ -255,7 +257,8 @@ Current details-page behavior: - shows endpoint metadata including enabled state, priority, frequency, timeout, and masked request headers - shows request filter configuration for included and excluded checks - summarizes the latest poll with status, timings, retrieved timestamp, and current error -- shows retained recent sample metrics including success rate, failure count, average duration, last status change, and recent activity +- shows retained recent sample metrics including success rate, failure count, average duration, last status change, and a short trend summary +- shows a short recent status history with the latest observed status transitions - renders top-level and nested health checks recursively with native expand and collapse support - surfaces snapshot metadata captured from the parsed response - shows the raw payload section only when enabled in configuration @@ -505,8 +508,6 @@ Test file: ## Future Plans These are planned enhancements after the current v1 path: -- next up: add mini trend visuals and short status history views based on the retained recent poll samples already captured in runtime state -- optionally add short status history and mini trends so the dashboard can show recent health changes instead of only the latest snapshot - add configurable retention controls for future persisted history files once trend capture is introduced - optionally add per-endpoint history files once the embedded recent-sample window is no longer sufficient - optionally allow email sending, either through direct SMTP configuration or by calling an external API diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml index 296369e..9c01144 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml @@ -221,6 +221,14 @@ else } else { +
+
+ @endpoint.RecentTrendText + Last status change: @endpoint.LastStatusChangeText +
+

@endpoint.RecentTrendSummary

+
+
Retained samples @@ -358,6 +366,48 @@ else
+
+
+

Recent Status History

+
+
+ @if (!endpoint.HasRecentSamples) + { +
Recent status history will appear here after enough runtime samples have been collected.
+ } + else + { +
+ @foreach (var sample in endpoint.RecentSamples) + { + + } +
+ + @if (!endpoint.HasStatusTransitions) + { +
No status transition has been observed in the retained sample window.
+ } + else + { +
+ @foreach (var transition in endpoint.RecentStatusTransitions) + { +
+
+ @transition.FromStatus + + @transition.ToStatus +
+
@transition.ChangedText
+
+ } +
+ } + } +
+
+

Recent Poll Activity

diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs index 13ab5e7..ebbe4ab 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs @@ -153,6 +153,12 @@ public sealed class EndpointDetailsViewModel public string LastStatusChangeText { get; init; } = "No recent change"; + public string RecentTrendText { get; init; } = "Awaiting trend"; + + public string RecentTrendBadgeClass { get; init; } = "badge-light"; + + public string RecentTrendSummary { get; init; } = "Need more samples to detect a trend."; + public string? ErrorText { get; init; } public string ErrorSummary => string.IsNullOrWhiteSpace(ErrorText) ? "None" : ErrorText; @@ -195,6 +201,10 @@ public sealed class EndpointDetailsViewModel public IReadOnlyList RecentSamples { get; init; } = []; + public IReadOnlyList RecentStatusTransitions { get; init; } = []; + + public bool HasStatusTransitions => RecentStatusTransitions.Count > 0; + public static EndpointDetailsViewModel From( EndpointConfig endpoint, EndpointState? state, @@ -209,6 +219,7 @@ public static EndpointDetailsViewModel From( .Select(static sample => sample.Clone()) .ToArray() ?? []; var recentMetrics = RecentPollSampleMetricsCalculator.Calculate(recentSamples); + var trendAnalysis = RecentPollTrendAnalyzer.Analyze(recentSamples); return new EndpointDetailsViewModel { @@ -242,6 +253,9 @@ public static EndpointDetailsViewModel From( LastStatusChangeText = recentMetrics.LastStatusChangeUtc is DateTimeOffset lastStatusChangeUtc ? FormatDateTime(lastStatusChangeUtc) : "No recent change", + RecentTrendText = ToTrendText(trendAnalysis.TrendKind, status), + RecentTrendBadgeClass = ToTrendBadgeClass(trendAnalysis.TrendKind), + RecentTrendSummary = BuildTrendSummary(trendAnalysis, recentMetrics), ErrorText = state?.LastError, Headers = endpoint.Headers .OrderBy(static header => header.Key, StringComparer.OrdinalIgnoreCase) @@ -275,6 +289,11 @@ public static EndpointDetailsViewModel From( UnknownCheckCount = flattenedNodes.Count(static node => node.Status is not ("Healthy" or "Degraded" or "Unhealthy")), RawPayload = showRawPayload ? FormatPayloadPreview(state?.Snapshot?.RawPayload) : null, ShowRawPayload = showRawPayload, + RecentStatusTransitions = trendAnalysis.Transitions + .OrderByDescending(static transition => transition.ChangedUtc) + .Take(6) + .Select(CreateStatusTransitionViewModel) + .ToArray(), RecentSamples = recentSamples .OrderByDescending(static sample => sample.CheckedUtc) .Take(10) @@ -384,6 +403,7 @@ private static RecentPollSampleViewModel CreateRecentSampleViewModel(RecentPollS CheckedText = FormatDateTime(sample.CheckedUtc), Status = sample.Status, StatusBadgeClass = ToBadgeClass(sample.Status), + IndicatorBadgeClass = ToRecentIndicatorClass(sample), ResultKind = sample.ResultKind, ResultKindBadgeClass = ToResultKindBadgeClass(sample), DurationText = $"{sample.DurationMs} ms", @@ -409,6 +429,84 @@ private static string ToResultKindBadgeClass(RecentPollSample sample) _ => "badge-secondary" }; } + + private static string ToRecentIndicatorClass(RecentPollSample sample) + { + if (!string.IsNullOrWhiteSpace(sample.ErrorSummary) || + !string.Equals(sample.ResultKind, "Success", StringComparison.OrdinalIgnoreCase)) + { + return "sample-indicator-failure"; + } + + return sample.Status switch + { + "Healthy" => "sample-indicator-healthy", + "Degraded" => "sample-indicator-degraded", + "Unhealthy" => "sample-indicator-unhealthy", + _ => "sample-indicator-unknown" + }; + } + + private static StatusTransitionViewModel CreateStatusTransitionViewModel(RecentPollStatusTransition transition) + { + return new StatusTransitionViewModel + { + FromStatus = transition.FromStatus, + FromStatusBadgeClass = ToBadgeClass(transition.FromStatus), + ToStatus = transition.ToStatus, + ToStatusBadgeClass = ToBadgeClass(transition.ToStatus), + ChangedText = FormatDateTime(transition.ChangedUtc) + }; + } + + private static string ToTrendText(RecentPollTrendKind trendKind, string currentStatus) + { + return trendKind switch + { + RecentPollTrendKind.Failing => "Failing", + RecentPollTrendKind.Improving => "Improving", + RecentPollTrendKind.Worsening => "Worsening", + RecentPollTrendKind.Flapping => "Flapping", + RecentPollTrendKind.Stable => $"Stable {currentStatus}", + _ => "Awaiting trend" + }; + } + + private static string ToTrendBadgeClass(RecentPollTrendKind trendKind) + { + return trendKind switch + { + RecentPollTrendKind.Failing => "badge-danger", + RecentPollTrendKind.Improving => "badge-success", + RecentPollTrendKind.Worsening => "badge-warning", + RecentPollTrendKind.Flapping => "badge-danger", + RecentPollTrendKind.Stable => "badge-info", + _ => "badge-light" + }; + } + + private static string BuildTrendSummary(RecentPollTrendAnalysis trendAnalysis, RecentPollSampleMetrics recentMetrics) + { + if (!recentMetrics.HasSamples) + { + return "No recent samples retained yet."; + } + + return trendAnalysis.TrendKind switch + { + RecentPollTrendKind.Failing => + "Recent checks are consistently failing and need attention.", + RecentPollTrendKind.Improving => + $"Recent checks are recovering across {trendAnalysis.Transitions.Count} status change{(trendAnalysis.Transitions.Count == 1 ? string.Empty : "s")}.", + RecentPollTrendKind.Worsening => + $"Recent checks are trending worse across {trendAnalysis.Transitions.Count} status change{(trendAnalysis.Transitions.Count == 1 ? string.Empty : "s")}.", + RecentPollTrendKind.Flapping => + $"Recent checks have changed status {trendAnalysis.Transitions.Count} times and may be unstable.", + RecentPollTrendKind.Stable => + "Recent checks are holding a consistent status.", + _ => "Need at least two retained samples to detect a trend." + }; + } } public sealed class HeaderSummaryViewModel @@ -433,6 +531,8 @@ public sealed class RecentPollSampleViewModel public required string StatusBadgeClass { get; init; } + public required string IndicatorBadgeClass { get; init; } + public required string ResultKind { get; init; } public required string ResultKindBadgeClass { get; init; } @@ -443,4 +543,17 @@ public sealed class RecentPollSampleViewModel public bool HasError { get; init; } } + + public sealed class StatusTransitionViewModel + { + public required string FromStatus { get; init; } + + public required string FromStatusBadgeClass { get; init; } + + public required string ToStatus { get; init; } + + public required string ToStatusBadgeClass { get; init; } + + public required string ChangedText { get; init; } + } } diff --git a/src/ApiHealthDashboard/Pages/Index.cshtml.cs b/src/ApiHealthDashboard/Pages/Index.cshtml.cs index 329c641..6e3acac 100644 --- a/src/ApiHealthDashboard/Pages/Index.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Index.cshtml.cs @@ -194,6 +194,14 @@ public sealed class EndpointSummaryViewModel public string RecentFailureCountText { get; init; } = "0 failures"; + public string RecentTrendText { get; init; } = "Awaiting trend"; + + public string RecentTrendBadgeClass { get; init; } = "badge-light"; + + public string RecentTrendSummary { get; init; } = "Need more samples to detect a trend."; + + public string RecentLastChangeText { get; init; } = "No recent change"; + public IReadOnlyList RecentIndicators { get; init; } = []; public string? ErrorText { get; init; } @@ -217,6 +225,7 @@ public static EndpointSummaryViewModel From(EndpointConfig endpoint, EndpointSta .Select(static sample => sample.Clone()) .ToArray() ?? []; var recentMetrics = RecentPollSampleMetricsCalculator.Calculate(recentSamples); + var trendAnalysis = RecentPollTrendAnalyzer.Analyze(recentSamples); return new EndpointSummaryViewModel { @@ -243,6 +252,12 @@ public static EndpointSummaryViewModel From(EndpointConfig endpoint, EndpointSta RecentFailureCountText = recentMetrics.HasSamples ? $"{recentMetrics.FailureCount} failure{(recentMetrics.FailureCount == 1 ? string.Empty : "s")}" : "0 failures", + RecentTrendText = ToTrendText(trendAnalysis.TrendKind, status), + RecentTrendBadgeClass = ToTrendBadgeClass(trendAnalysis.TrendKind), + RecentTrendSummary = BuildTrendSummary(trendAnalysis, recentMetrics), + RecentLastChangeText = recentMetrics.LastStatusChangeUtc is DateTimeOffset lastStatusChangeUtc + ? FormatDateTime(lastStatusChangeUtc) + : "No recent change", RecentIndicators = recentSamples .OrderByDescending(static sample => sample.CheckedUtc) .Take(8) @@ -339,6 +354,55 @@ private static string ToRecentIndicatorClass(RecentPollSample sample) _ => "sample-indicator-unknown" }; } + + private static string ToTrendText(RecentPollTrendKind trendKind, string currentStatus) + { + return trendKind switch + { + RecentPollTrendKind.Failing => "Failing", + RecentPollTrendKind.Improving => "Improving", + RecentPollTrendKind.Worsening => "Worsening", + RecentPollTrendKind.Flapping => "Flapping", + RecentPollTrendKind.Stable => $"Stable {currentStatus}", + _ => "Awaiting trend" + }; + } + + private static string ToTrendBadgeClass(RecentPollTrendKind trendKind) + { + return trendKind switch + { + RecentPollTrendKind.Failing => "badge-danger", + RecentPollTrendKind.Improving => "badge-success", + RecentPollTrendKind.Worsening => "badge-warning", + RecentPollTrendKind.Flapping => "badge-danger", + RecentPollTrendKind.Stable => "badge-info", + _ => "badge-light" + }; + } + + private static string BuildTrendSummary(RecentPollTrendAnalysis trendAnalysis, RecentPollSampleMetrics recentMetrics) + { + if (!recentMetrics.HasSamples) + { + return "No recent samples retained yet."; + } + + return trendAnalysis.TrendKind switch + { + RecentPollTrendKind.Failing => + "Recent samples are consistently failing and need attention.", + RecentPollTrendKind.Improving => + $"Recent samples are recovering across {trendAnalysis.Transitions.Count} status change{(trendAnalysis.Transitions.Count == 1 ? string.Empty : "s")}.", + RecentPollTrendKind.Worsening => + $"Recent samples are trending worse across {trendAnalysis.Transitions.Count} status change{(trendAnalysis.Transitions.Count == 1 ? string.Empty : "s")}.", + RecentPollTrendKind.Flapping => + $"Recent samples have changed status {trendAnalysis.Transitions.Count} times and may be flapping.", + RecentPollTrendKind.Stable => + "Recent samples are holding a consistent status.", + _ => "Need at least two retained samples to detect a trend." + }; + } } public sealed class RecentSampleIndicatorViewModel diff --git a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml index 3738119..262345a 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml @@ -212,6 +212,11 @@ }
+
+ @endpoint.RecentTrendText + @endpoint.RecentLastChangeText +
+
@endpoint.RecentTrendSummary
@endpoint.RecentSuccessRateText
@endpoint.RecentAverageDurationText • @endpoint.RecentFailureCountText
} diff --git a/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs new file mode 100644 index 0000000..04129f1 --- /dev/null +++ b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs @@ -0,0 +1,141 @@ +using ApiHealthDashboard.Domain; + +namespace ApiHealthDashboard.Statistics; + +public static class RecentPollTrendAnalyzer +{ + public static RecentPollTrendAnalysis Analyze(IEnumerable samples) + { + ArgumentNullException.ThrowIfNull(samples); + + var orderedSamples = samples + .Where(static sample => sample is not null) + .OrderBy(static sample => sample.CheckedUtc) + .ToArray(); + + if (orderedSamples.Length == 0) + { + return RecentPollTrendAnalysis.Empty; + } + + var transitions = new List(); + + for (var index = 1; index < orderedSamples.Length; index++) + { + var previousStatus = NormalizeStatus(orderedSamples[index - 1].Status); + var currentStatus = NormalizeStatus(orderedSamples[index].Status); + + if (string.Equals(previousStatus, currentStatus, StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + transitions.Add(new RecentPollStatusTransition + { + FromStatus = previousStatus, + ToStatus = currentStatus, + ChangedUtc = orderedSamples[index].CheckedUtc + }); + } + + return new RecentPollTrendAnalysis + { + TrendKind = ResolveTrendKind(orderedSamples, transitions), + Transitions = transitions + }; + } + + private static RecentPollTrendKind ResolveTrendKind( + IReadOnlyList orderedSamples, + IReadOnlyCollection transitions) + { + if (orderedSamples.Count < 2) + { + return RecentPollTrendKind.InsufficientData; + } + + if (orderedSamples.All(IsFailedSample)) + { + return RecentPollTrendKind.Failing; + } + + if (transitions.Count == 0) + { + return RecentPollTrendKind.Stable; + } + + if (transitions.Count >= 3) + { + return RecentPollTrendKind.Flapping; + } + + var firstRank = GetStatusRank(orderedSamples[0].Status); + var lastRank = GetStatusRank(orderedSamples[^1].Status); + + if (lastRank > firstRank) + { + return RecentPollTrendKind.Improving; + } + + if (lastRank < firstRank) + { + return RecentPollTrendKind.Worsening; + } + + return RecentPollTrendKind.Flapping; + } + + private static bool IsFailedSample(RecentPollSample sample) + { + return !string.Equals(sample.ResultKind, "Success", StringComparison.OrdinalIgnoreCase) || + !string.IsNullOrWhiteSpace(sample.ErrorSummary); + } + + private static string NormalizeStatus(string? status) + { + return string.IsNullOrWhiteSpace(status) + ? "Unknown" + : status.Trim(); + } + + private static int GetStatusRank(string? status) + { + return NormalizeStatus(status) switch + { + "Healthy" => 3, + "Degraded" => 2, + "Unhealthy" => 1, + _ => 0 + }; + } +} + +public sealed class RecentPollTrendAnalysis +{ + public static RecentPollTrendAnalysis Empty { get; } = new(); + + public RecentPollTrendKind TrendKind { get; init; } + + public IReadOnlyList Transitions { get; init; } = []; + + public bool HasTransitions => Transitions.Count > 0; +} + +public sealed class RecentPollStatusTransition +{ + public required string FromStatus { get; init; } + + public required string ToStatus { get; init; } + + public required DateTimeOffset ChangedUtc { get; init; } +} + +public enum RecentPollTrendKind +{ + InsufficientData = 0, + Stable = 1, + Improving = 2, + Worsening = 3, + Flapping = 4, + Failing = 5 +} diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index e959a78..be4ee29 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -238,6 +238,14 @@ body { margin-bottom: 0.4rem; } +.dashboard-trend-summary { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.35rem; + flex-wrap: wrap; +} + .sample-indicator { display: inline-block; width: 0.7rem; @@ -581,6 +589,50 @@ body { gap: 0.85rem; } +.recent-trend-card { + padding: 0.9rem 1rem; + border-radius: 0.9rem; + background: rgba(15, 23, 42, 0.035); + border: 1px solid rgba(31, 41, 55, 0.08); +} + +.recent-trend-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + flex-wrap: wrap; +} + +.status-history-list { + display: grid; + gap: 0.75rem; +} + +.status-history-item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.75rem; + padding: 0.85rem 1rem; + border-radius: 0.9rem; + background: rgba(15, 23, 42, 0.035); + border: 1px solid rgba(31, 41, 55, 0.08); + flex-wrap: wrap; +} + +.status-history-badges { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.status-history-time { + font-size: 0.84rem; + color: #6b7280; +} + .recent-poll-item { padding: 0.9rem 1rem; border-radius: 0.9rem; diff --git a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs index 7bf6bc5..4d60881 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs @@ -175,6 +175,11 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() Assert.Equal("1 failure", model.Endpoint.RecentFailureCountText); Assert.Equal("305 ms", model.Endpoint.RecentAverageDurationText); Assert.Equal("2026-03-18 08:30:00 UTC", model.Endpoint.LastStatusChangeText); + Assert.Equal("Worsening", model.Endpoint.RecentTrendText); + Assert.Equal("badge-warning", model.Endpoint.RecentTrendBadgeClass); + Assert.Single(model.Endpoint.RecentStatusTransitions); + Assert.Equal("Healthy", model.Endpoint.RecentStatusTransitions[0].FromStatus); + Assert.Equal("Degraded", model.Endpoint.RecentStatusTransitions[0].ToStatus); Assert.Equal(2, model.Endpoint.RecentSamples.Count); Assert.Equal( "{\n \"status\": \"Degraded\"\n}", diff --git a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs index 0e841fc..ac9619a 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs @@ -155,6 +155,97 @@ public void OnGet_SortsEndpointsByPriorityBeforeName() Assert.Equal(EndpointPriority.Critical, model.Endpoints[0].Priority); } + [Fact] + public void OnGet_ExposesRecentTrendSummaryForEndpointRows() + { + var config = CreateConfig(); + var store = new InMemoryEndpointStateStore(config.Endpoints); + + store.Upsert(new ApiHealthDashboard.Domain.EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Unhealthy", + RecentSamples = + [ + new ApiHealthDashboard.Domain.RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 0, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 110, + ResultKind = "Success" + }, + new ApiHealthDashboard.Domain.RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 1, 0, TimeSpan.Zero), + Status = "Degraded", + DurationMs = 230, + ResultKind = "Success", + ErrorSummary = "Slow dependency" + }, + new ApiHealthDashboard.Domain.RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 2, 0, TimeSpan.Zero), + Status = "Unhealthy", + DurationMs = 410, + ResultKind = "HttpError", + ErrorSummary = "503" + } + ] + }); + + var model = new IndexModel(config, store, new StubEndpointScheduler(), NullLogger.Instance); + + model.OnGet(); + + var endpoint = Assert.Single(model.Endpoints.Where(static endpoint => endpoint.Id == "orders-api")); + Assert.Equal("Worsening", endpoint.RecentTrendText); + Assert.Equal("badge-warning", endpoint.RecentTrendBadgeClass); + Assert.Equal("2026-03-19 00:02:00 UTC", endpoint.RecentLastChangeText); + } + + [Fact] + public void OnGet_AllFailedUnknownSamples_AreShownAsFailingTrend() + { + var config = CreateConfig(); + var store = new InMemoryEndpointStateStore(config.Endpoints); + + store.Upsert(new ApiHealthDashboard.Domain.EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Unknown", + LastError = "Connection refused", + RecentSamples = + [ + new ApiHealthDashboard.Domain.RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 1, 0, 0, TimeSpan.Zero), + Status = "Unknown", + DurationMs = 200, + ResultKind = "Timeout", + ErrorSummary = "Timed out" + }, + new ApiHealthDashboard.Domain.RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 1, 1, 0, TimeSpan.Zero), + Status = "Unknown", + DurationMs = 210, + ResultKind = "NetworkError", + ErrorSummary = "Connection refused" + } + ] + }); + + var model = new IndexModel(config, store, new StubEndpointScheduler(), NullLogger.Instance); + + model.OnGet(); + + var endpoint = Assert.Single(model.Endpoints.Where(static endpoint => endpoint.Id == "orders-api")); + Assert.Equal("Failing", endpoint.RecentTrendText); + Assert.Equal("badge-danger", endpoint.RecentTrendBadgeClass); + } + [Fact] public void OnGet_WithNoConfiguredEndpoints_ExposesEmptyDashboardState() { diff --git a/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs new file mode 100644 index 0000000..53fa3a8 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs @@ -0,0 +1,62 @@ +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Statistics; + +namespace ApiHealthDashboard.Tests.Statistics; + +public sealed class RecentPollTrendAnalyzerTests +{ + [Fact] + public void Analyze_AllFailedUnknownSamples_ReturnsFailingTrend() + { + var samples = new[] + { + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 0, 0, TimeSpan.Zero), + Status = "Unknown", + DurationMs = 120, + ResultKind = "Timeout", + ErrorSummary = "Timed out" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 1, 0, TimeSpan.Zero), + Status = "Unknown", + DurationMs = 130, + ResultKind = "NetworkError", + ErrorSummary = "Connection refused" + } + }; + + var result = RecentPollTrendAnalyzer.Analyze(samples); + + Assert.Equal(RecentPollTrendKind.Failing, result.TrendKind); + Assert.Empty(result.Transitions); + } + + [Fact] + public void Analyze_StableSuccessfulSamples_RemainsStable() + { + var samples = new[] + { + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 0, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 90, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 1, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 95, + ResultKind = "Success" + } + }; + + var result = RecentPollTrendAnalyzer.Analyze(samples); + + Assert.Equal(RecentPollTrendKind.Stable, result.TrendKind); + } +} From 5f5f4b73fa272b9df4d752e0f3bf6a6b54e20796 Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 15:38:33 +0800 Subject: [PATCH 10/15] feat: Add email notification functionality for endpoint status changes - Introduced email notification settings in the Import page for notification emails and CC recipients. - Enhanced the ImportModel to handle notification email inputs. - Configured SMTP settings in the application to enable email sending. - Implemented IEmailSender and SmtpEmailSender for sending emails via SMTP. - Created EndpointEmailNotificationService to manage notification logic based on endpoint status changes. - Updated PollingSchedulerService to trigger notifications on state changes. - Added email validation for notification addresses. - Updated dashboard configuration to include notification settings. - Created tests for the new email notification service and its integration with endpoint state changes. --- README.md | 21 +- .../Configuration/DashboardConfig.cs | 44 ++- .../Configuration/DashboardConfigValidator.cs | 36 +++ .../Configuration/SmtpEmailOptions.cs | 22 ++ .../Configuration/YamlConfigLoader.cs | 23 ++ src/ApiHealthDashboard/Pages/Import.cshtml | 22 ++ src/ApiHealthDashboard/Pages/Import.cshtml.cs | 12 +- src/ApiHealthDashboard/Program.cs | 6 + .../Scheduling/PollingSchedulerService.cs | 22 +- .../EndpointEmailNotificationService.cs | 277 ++++++++++++++++++ .../Services/EndpointImportRequest.cs | 4 + .../Services/EndpointImportService.cs | 57 +++- .../Services/IEmailSender.cs | 17 ++ .../Services/IEndpointNotificationService.cs | 13 + .../Services/SmtpEmailSender.cs | 59 ++++ .../Statistics/RecentPollTrendAnalyzer.cs | 51 ++++ .../appsettings.Development.json | 12 + src/ApiHealthDashboard/appsettings.json | 12 + src/ApiHealthDashboard/dashboard.yaml | 11 + .../endpoints/billing-api.yaml | 2 + .../endpoints/utpl-integration-api.yaml | 15 + .../DashboardConfigHotReloadServiceTests.cs | 9 + .../Pages/ImportModelTests.cs | 10 +- .../Scheduling/PollingSchedulerReloadTests.cs | 9 + .../PollingSchedulerServiceTests.cs | 9 + .../EndpointEmailNotificationServiceTests.cs | 207 +++++++++++++ .../Services/EndpointImportServiceTests.cs | 41 ++- .../RecentPollTrendAnalyzerTests.cs | 47 +++ 28 files changed, 1060 insertions(+), 10 deletions(-) create mode 100644 src/ApiHealthDashboard/Configuration/SmtpEmailOptions.cs create mode 100644 src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs create mode 100644 src/ApiHealthDashboard/Services/IEmailSender.cs create mode 100644 src/ApiHealthDashboard/Services/IEndpointNotificationService.cs create mode 100644 src/ApiHealthDashboard/Services/SmtpEmailSender.cs create mode 100644 src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml create mode 100644 tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs diff --git a/README.md b/README.md index 52ec404..965b9c8 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,7 @@ Implemented so far: - Post-v1: runtime-state cleanup and retention settings for orphaned persisted state files - Post-v1: recent poll sample retention with derived dashboard and details metrics - Post-v1: mini trend visuals and short status history from retained runtime samples +- Post-v1: SMTP email notifications with dashboard defaults and per-endpoint recipients Not implemented yet: - Backlog items tracked for post-v1 work @@ -88,9 +89,11 @@ Current configuration support: - `dashboard.requestTimeoutSecondsDefault` - `dashboard.showRawPayload` - `dashboard.endpointFiles` for loading endpoints from one or more separate YAML files +- `dashboard.notifications.enabled`, `notifyOnRecovery`, `cooldownMinutes`, `minimumPriority`, `subjectPrefix`, `to`, and `cc` - endpoint `id`, `name`, `url`, `enabled`, `frequencySeconds`, `timeoutSeconds` - endpoint `priority` with `Critical`, `High`, `Normal`, or `Low` - endpoint `headers`, `includeChecks`, `excludeChecks` +- endpoint `notificationEmails` and `notificationCc` - `${ENV_VAR}` substitution in YAML values - endpoint definitions can be kept inline in `dashboard.yaml` or split into multiple files under [`src/ApiHealthDashboard/endpoints`](src/ApiHealthDashboard/endpoints) - project YAML files are copied to both build and publish output by default @@ -178,6 +181,7 @@ Current behavior: - prevents overlapping polls for the same endpoint with endpoint-level locking - updates runtime state before and after each poll - records last checked time, last successful time, duration, status, and current error +- triggers email notifications when an endpoint enters a problem state, changes alert state, or recovers - keeps slow endpoints from blocking other endpoint loops - already exposes a scheduler interface that Phase 8 can reuse for manual refresh actions - restarts enabled polling loops automatically when YAML hot-reload changes the configured endpoint set @@ -231,11 +235,24 @@ Current import behavior: - auto-suggests endpoint id and name when those fields are left blank - shows a soft warning when the chosen poll frequency is below the configured appsettings recommendation - emits generated YAML with a normalized endpoint priority value +- allows notification recipients and per-endpoint CC recipients to be entered and included in generated YAML - parses discovered checks from the response and can optionally populate `includeChecks` - generates a normalized YAML snippet for manual copy into `dashboard.yaml` or a separate endpoint file - compares the generated YAML against the currently loaded config when an existing endpoint matches by id or URL - shows a diff preview plus a raw response preview for review before any manual save +### Email Notifications + +The app now supports SMTP email notifications with dashboard-level defaults and per-endpoint recipient overrides. + +Current email notification behavior: +- uses SMTP settings from appsettings under `Email:Smtp` +- uses dashboard YAML settings to enable notifications, set recovery behavior, cooldown, minimum priority, subject prefix, and default recipients +- merges global dashboard recipients with endpoint-specific `notificationEmails` and `notificationCc` +- sends alert emails when an endpoint enters or changes problem state +- can send recovery emails when an endpoint returns to a non-problem state +- treats repeated transport failures as a `Failing` condition for alerting purposes + ### CLI Execution The app now includes a one-shot CLI mode for scripted health execution without starting the web UI. @@ -332,6 +349,8 @@ The current primary setting is `Bootstrap:DashboardConfigPath`. `Bootstrap:Endpo Runtime state persistence is configured through `RuntimeState:Enabled` and `RuntimeState:DirectoryPath` in the same appsettings files. By default, the app writes compact per-endpoint current-state files under `runtime-state/endpoints` relative to the app content root. +SMTP email delivery is configured through `Email:Smtp` in the same appsettings files. Keep secrets such as SMTP usernames and passwords in environment variables or deployment-time configuration rather than committing them to source control. + Current cleanup settings: - `RuntimeState:CleanupEnabled` to enable periodic runtime-state cleanup - `RuntimeState:CleanupIntervalMinutes` to control how often orphan cleanup runs @@ -510,7 +529,7 @@ Test file: These are planned enhancements after the current v1 path: - add configurable retention controls for future persisted history files once trend capture is introduced - optionally add per-endpoint history files once the embedded recent-sample window is no longer sufficient -- optionally allow email sending, either through direct SMTP configuration or by calling an external API +- optionally add external email API delivery in addition to the current SMTP implementation ## Notes For Ongoing Updates diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs index 1901942..56f95fb 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs @@ -36,13 +36,47 @@ public sealed class DashboardSettings public bool ShowRawPayload { get; set; } + public DashboardNotificationSettings Notifications { get; set; } = new(); + public DashboardSettings Clone() { return new DashboardSettings { RefreshUiSeconds = RefreshUiSeconds, RequestTimeoutSecondsDefault = RequestTimeoutSecondsDefault, - ShowRawPayload = ShowRawPayload + ShowRawPayload = ShowRawPayload, + Notifications = Notifications.Clone() + }; + } +} + +public sealed class DashboardNotificationSettings +{ + public bool Enabled { get; set; } + + public bool NotifyOnRecovery { get; set; } = true; + + public int CooldownMinutes { get; set; } = 60; + + public string MinimumPriority { get; set; } = EndpointPriority.Normal; + + public string SubjectPrefix { get; set; } = "[ApiHealthDashboard]"; + + public List To { get; set; } = new(); + + public List Cc { get; set; } = new(); + + public DashboardNotificationSettings Clone() + { + return new DashboardNotificationSettings + { + Enabled = Enabled, + NotifyOnRecovery = NotifyOnRecovery, + CooldownMinutes = CooldownMinutes, + MinimumPriority = MinimumPriority, + SubjectPrefix = SubjectPrefix, + To = [.. To], + Cc = [.. Cc] }; } } @@ -69,6 +103,10 @@ public sealed class EndpointConfig public List ExcludeChecks { get; set; } = new(); + public List NotificationEmails { get; set; } = new(); + + public List NotificationCc { get; set; } = new(); + public EndpointConfig Clone() { return new EndpointConfig @@ -82,7 +120,9 @@ public EndpointConfig Clone() Priority = Priority, Headers = new Dictionary(Headers, StringComparer.OrdinalIgnoreCase), IncludeChecks = [.. IncludeChecks], - ExcludeChecks = [.. ExcludeChecks] + ExcludeChecks = [.. ExcludeChecks], + NotificationEmails = [.. NotificationEmails], + NotificationCc = [.. NotificationCc] }; } } diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs b/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs index 6805a9f..24e74d9 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardConfigValidator.cs @@ -1,7 +1,11 @@ +using System.ComponentModel.DataAnnotations; + namespace ApiHealthDashboard.Configuration; public sealed class DashboardConfigValidator { + private static readonly EmailAddressAttribute EmailValidator = new(); + public IReadOnlyList Validate(DashboardConfig config) { ArgumentNullException.ThrowIfNull(config); @@ -19,6 +23,20 @@ public IReadOnlyList Validate(DashboardConfig config) errors.Add("dashboard.requestTimeoutSecondsDefault must be greater than zero."); } + if (config.Dashboard.Notifications.CooldownMinutes <= 0) + { + errors.Add("dashboard.notifications.cooldownMinutes must be greater than zero."); + } + + if (!EndpointPriority.IsValid(config.Dashboard.Notifications.MinimumPriority)) + { + errors.Add( + $"dashboard.notifications.minimumPriority must be one of: {string.Join(", ", EndpointPriority.AllowedValues)}."); + } + + ValidateEmailList(config.Dashboard.Notifications.To, "dashboard.notifications.to", errors); + ValidateEmailList(config.Dashboard.Notifications.Cc, "dashboard.notifications.cc", errors); + for (var index = 0; index < config.Endpoints.Count; index++) { var endpoint = config.Endpoints[index]; @@ -71,8 +89,26 @@ public IReadOnlyList Validate(DashboardConfig config) errors.Add($"{prefix}.headers contains an empty header name."); } } + + ValidateEmailList(endpoint.NotificationEmails, $"{prefix}.notificationEmails", errors); + ValidateEmailList(endpoint.NotificationCc, $"{prefix}.notificationCc", errors); } return errors; } + + private static void ValidateEmailList(IEnumerable values, string prefix, ICollection errors) + { + var index = 0; + + foreach (var value in values) + { + if (string.IsNullOrWhiteSpace(value) || !EmailValidator.IsValid(value)) + { + errors.Add($"{prefix}[{index}] must be a valid email address."); + } + + index++; + } + } } diff --git a/src/ApiHealthDashboard/Configuration/SmtpEmailOptions.cs b/src/ApiHealthDashboard/Configuration/SmtpEmailOptions.cs new file mode 100644 index 0000000..50d92bc --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/SmtpEmailOptions.cs @@ -0,0 +1,22 @@ +namespace ApiHealthDashboard.Configuration; + +public sealed class SmtpEmailOptions +{ + public const string SectionName = "Email:Smtp"; + + public bool Enabled { get; set; } + + public string Host { get; set; } = string.Empty; + + public int Port { get; set; } = 587; + + public bool UseSsl { get; set; } = true; + + public string Username { get; set; } = string.Empty; + + public string Password { get; set; } = string.Empty; + + public string FromAddress { get; set; } = string.Empty; + + public string FromName { get; set; } = "ApiHealthDashboard"; +} diff --git a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs index 24f5616..3ef2167 100644 --- a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs +++ b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs @@ -260,6 +260,11 @@ private static string ReadYaml(string path) private static void Normalize(DashboardConfig config) { config.Dashboard ??= new DashboardSettings(); + config.Dashboard.Notifications ??= new DashboardNotificationSettings(); + config.Dashboard.Notifications.MinimumPriority = EndpointPriority.Normalize(config.Dashboard.Notifications.MinimumPriority); + config.Dashboard.Notifications.SubjectPrefix = config.Dashboard.Notifications.SubjectPrefix?.Trim() ?? "[ApiHealthDashboard]"; + config.Dashboard.Notifications.To = NormalizeEmailList(config.Dashboard.Notifications.To); + config.Dashboard.Notifications.Cc = NormalizeEmailList(config.Dashboard.Notifications.Cc); config.EndpointFiles = NormalizeFileList(config.EndpointFiles); config.Endpoints ??= new List(); NormalizeEndpoints(config.Endpoints); @@ -285,6 +290,8 @@ private static void NormalizeEndpoints(List? endpoints) : new Dictionary(endpoint.Headers, StringComparer.OrdinalIgnoreCase); endpoint.IncludeChecks = NormalizeCheckList(endpoint.IncludeChecks); endpoint.ExcludeChecks = NormalizeCheckList(endpoint.ExcludeChecks); + endpoint.NotificationEmails = NormalizeEmailList(endpoint.NotificationEmails); + endpoint.NotificationCc = NormalizeEmailList(endpoint.NotificationCc); endpoints[index] = endpoint; } @@ -316,6 +323,20 @@ private static List NormalizeCheckList(List? values) .ToList(); } + private static List NormalizeEmailList(List? values) + { + if (values is null) + { + return new List(); + } + + return values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + } + private static string ReplaceEnvironmentTokens(string yaml) { return EnvironmentVariablePattern().Replace( @@ -344,6 +365,8 @@ endpoint.TimeoutSeconds is not null || endpoint.Headers.Count > 0 || endpoint.IncludeChecks.Count > 0 || endpoint.ExcludeChecks.Count > 0 || + endpoint.NotificationEmails.Count > 0 || + endpoint.NotificationCc.Count > 0 || endpoint.Enabled != true || endpoint.FrequencySeconds != 30); } diff --git a/src/ApiHealthDashboard/Pages/Import.cshtml b/src/ApiHealthDashboard/Pages/Import.cshtml index 3b152ee..20b4963 100644 --- a/src/ApiHealthDashboard/Pages/Import.cshtml +++ b/src/ApiHealthDashboard/Pages/Import.cshtml @@ -133,6 +133,28 @@
+
+ +
+
+ + + One address per line, or separate with commas or semicolons. +
+
+ + + Optional CC recipients for endpoint-specific notifications. +
+
+
+
diff --git a/src/ApiHealthDashboard/Pages/Import.cshtml.cs b/src/ApiHealthDashboard/Pages/Import.cshtml.cs index 76e8486..c0493c9 100644 --- a/src/ApiHealthDashboard/Pages/Import.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Import.cshtml.cs @@ -66,12 +66,16 @@ public async Task OnPostPreviewAsync(CancellationToken cancellati FrequencySeconds = Input.FrequencySeconds, TimeoutSeconds = Input.TimeoutSeconds, HeadersText = Input.HeadersText, - IncludeDiscoveredChecks = Input.IncludeDiscoveredChecks + IncludeDiscoveredChecks = Input.IncludeDiscoveredChecks, + NotificationEmailsText = Input.NotificationEmailsText, + NotificationCcText = Input.NotificationCcText }, cancellationToken); Input.Id = Result.SuggestedEndpoint.Id; Input.Name = Result.SuggestedEndpoint.Name; + Input.NotificationEmailsText = string.Join(Environment.NewLine, Result.SuggestedEndpoint.NotificationEmails); + Input.NotificationCcText = string.Join(Environment.NewLine, Result.SuggestedEndpoint.NotificationCc); ModelState.Clear(); _logger.LogInformation( @@ -161,5 +165,11 @@ public sealed class InputModel [Display(Name = "Use discovered top-level checks as includeChecks")] public bool IncludeDiscoveredChecks { get; set; } + + [Display(Name = "Notification emails")] + public string NotificationEmailsText { get; set; } = string.Empty; + + [Display(Name = "Notification CC")] + public string NotificationCcText { get; set; } = string.Empty; } } diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index 7db78c9..10b4b48 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -29,8 +29,12 @@ builder.Configuration.GetSection(ImportUiOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(RuntimeStateOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(SmtpEmailOptions.SectionName)); builder.Services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService>().Value); +builder.Services.AddSingleton(static serviceProvider => + serviceProvider.GetRequiredService>().Value); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => @@ -116,6 +120,8 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => diff --git a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs index ba568b3..2ac47c6 100644 --- a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs +++ b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs @@ -13,6 +13,7 @@ public sealed class PollingSchedulerService : BackgroundService, IEndpointSchedu private readonly DashboardConfig _dashboardConfig; private readonly IEndpointPoller _endpointPoller; + private readonly IEndpointNotificationService _endpointNotificationService; private readonly IHealthResponseParser _healthResponseParser; private readonly ILogger _logger; private readonly RuntimeStateOptions _runtimeStateOptions; @@ -28,6 +29,7 @@ public PollingSchedulerService( DashboardConfig dashboardConfig, IEndpointStateStore stateStore, IEndpointPoller endpointPoller, + IEndpointNotificationService endpointNotificationService, IHealthResponseParser healthResponseParser, RuntimeStateOptions runtimeStateOptions, TimeProvider timeProvider, @@ -36,6 +38,7 @@ public PollingSchedulerService( _dashboardConfig = dashboardConfig; _stateStore = stateStore; _endpointPoller = endpointPoller; + _endpointNotificationService = endpointNotificationService; _healthResponseParser = healthResponseParser; _runtimeStateOptions = runtimeStateOptions; _timeProvider = timeProvider; @@ -195,7 +198,8 @@ private async Task RunEndpointLoopAsync(EndpointConfig endpoint, CancellationTok private async Task PollEndpointCoreAsync(EndpointConfig endpoint, string triggerSource, CancellationToken cancellationToken) { - var state = _stateStore.Get(endpoint.Id) ?? CreateFallbackState(endpoint); + var previousState = _stateStore.Get(endpoint.Id); + var state = previousState?.Clone() ?? CreateFallbackState(endpoint); state.EndpointName = endpoint.Name; state.IsPolling = true; _stateStore.Upsert(state); @@ -245,6 +249,22 @@ private async Task PollEndpointCoreAsync(EndpointConfig endpoint, string trigger _stateStore.Upsert(updatedState); + try + { + await _endpointNotificationService.NotifyAsync( + endpoint, + previousState, + updatedState.Clone(), + cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to process email notifications for endpoint {EndpointId}.", + endpoint.Id); + } + _logger.LogInformation( "Completed {TriggerSource} poll for endpoint {EndpointId} with status {EndpointStatus}, result kind {PollResultKind}, duration {DurationMs}ms, and status code {StatusCode}.", triggerSource, diff --git a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs new file mode 100644 index 0000000..058d4c9 --- /dev/null +++ b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs @@ -0,0 +1,277 @@ +using System.Collections.Concurrent; +using System.Text; +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Statistics; + +namespace ApiHealthDashboard.Services; + +public sealed class EndpointEmailNotificationService : IEndpointNotificationService +{ + private readonly DashboardConfig _dashboardConfig; + private readonly IEmailSender _emailSender; + private readonly ILogger _logger; + private readonly SmtpEmailOptions _smtpOptions; + private readonly TimeProvider _timeProvider; + private readonly ConcurrentDictionary _dispatchRecords = new(StringComparer.OrdinalIgnoreCase); + + public EndpointEmailNotificationService( + DashboardConfig dashboardConfig, + SmtpEmailOptions smtpOptions, + IEmailSender emailSender, + TimeProvider timeProvider, + ILogger logger) + { + _dashboardConfig = dashboardConfig; + _smtpOptions = smtpOptions; + _emailSender = emailSender; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task NotifyAsync( + EndpointConfig endpoint, + EndpointState? previousState, + EndpointState currentState, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(endpoint); + ArgumentNullException.ThrowIfNull(currentState); + + var notificationSettings = _dashboardConfig.Dashboard.Notifications; + if (!notificationSettings.Enabled) + { + return; + } + + if (!_smtpOptions.Enabled || !HasSmtpConfiguration()) + { + _logger.LogDebug( + "Skipped email notification for endpoint {EndpointId} because SMTP email is not enabled or configured.", + endpoint.Id); + return; + } + + if (EndpointPriority.GetSortOrder(endpoint.Priority) < EndpointPriority.GetSortOrder(notificationSettings.MinimumPriority)) + { + return; + } + + var recipients = ResolveRecipients(endpoint, notificationSettings); + if (recipients.To.Count == 0) + { + _logger.LogDebug( + "Skipped email notification for endpoint {EndpointId} because no notification recipients were configured.", + endpoint.Id); + return; + } + + var previousCondition = DescribeCondition(previousState); + var currentCondition = DescribeCondition(currentState); + var notification = BuildNotificationDecision(endpoint, notificationSettings, previousCondition, currentCondition); + if (notification is null) + { + return; + } + + if (IsWithinCooldown(endpoint.Id, notification.Signature, notificationSettings.CooldownMinutes)) + { + _logger.LogDebug( + "Skipped email notification for endpoint {EndpointId} because the notification is within the configured cooldown window.", + endpoint.Id); + return; + } + + var subject = BuildSubject(notificationSettings.SubjectPrefix, notification.EventType, endpoint.Name, notification.SubjectLabel); + var body = BuildBody(endpoint, previousCondition, currentCondition, notification); + + await _emailSender.SendAsync( + new EmailMessage + { + To = recipients.To, + Cc = recipients.Cc, + Subject = subject, + Body = body + }, + cancellationToken); + + _dispatchRecords[endpoint.Id] = new NotificationDispatchRecord( + notification.Signature, + _timeProvider.GetUtcNow()); + + _logger.LogInformation( + "Sent {NotificationEventType} email notification for endpoint {EndpointId} to {ToCount} recipient(s).", + notification.EventType, + endpoint.Id, + recipients.To.Count); + } + + private bool HasSmtpConfiguration() + { + return !string.IsNullOrWhiteSpace(_smtpOptions.Host) && + _smtpOptions.Port > 0 && + !string.IsNullOrWhiteSpace(_smtpOptions.FromAddress); + } + + private static NotificationRecipients ResolveRecipients( + EndpointConfig endpoint, + DashboardNotificationSettings settings) + { + var to = settings.To + .Concat(endpoint.NotificationEmails) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var cc = settings.Cc + .Concat(endpoint.NotificationCc) + .Where(email => !to.Contains(email, StringComparer.OrdinalIgnoreCase)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + return new NotificationRecipients(to, cc); + } + + private NotificationCondition DescribeCondition(EndpointState? state) + { + if (state is null) + { + return NotificationCondition.Empty; + } + + var trendAnalysis = RecentPollTrendAnalyzer.Analyze(state.RecentSamples.Select(static sample => sample.Clone())); + var status = string.IsNullOrWhiteSpace(state.Status) ? "Unknown" : state.Status.Trim(); + var trendLabel = trendAnalysis.TrendKind switch + { + RecentPollTrendKind.Failing => "Failing", + RecentPollTrendKind.Flapping => "Flapping", + RecentPollTrendKind.Worsening => "Worsening", + RecentPollTrendKind.Improving => "Improving", + RecentPollTrendKind.Stable => $"Stable {status}", + _ => "Awaiting trend" + }; + + if (!string.IsNullOrWhiteSpace(state.LastError) && trendAnalysis.TrendKind == RecentPollTrendKind.Failing) + { + return new NotificationCondition(true, "Failing", status, trendLabel, state.LastError, state.LastCheckedUtc); + } + + if (status is "Unhealthy" or "Degraded") + { + return new NotificationCondition(true, status, status, trendLabel, state.LastError, state.LastCheckedUtc); + } + + if (trendAnalysis.TrendKind is RecentPollTrendKind.Failing or RecentPollTrendKind.Flapping or RecentPollTrendKind.Worsening) + { + return new NotificationCondition(true, trendLabel, status, trendLabel, state.LastError, state.LastCheckedUtc); + } + + if (!string.IsNullOrWhiteSpace(state.LastError)) + { + return new NotificationCondition(true, "Error", status, trendLabel, state.LastError, state.LastCheckedUtc); + } + + return new NotificationCondition(false, status, status, trendLabel, null, state.LastCheckedUtc); + } + + private static NotificationDecision? BuildNotificationDecision( + EndpointConfig endpoint, + DashboardNotificationSettings settings, + NotificationCondition previousCondition, + NotificationCondition currentCondition) + { + if (currentCondition.IsProblem) + { + if (!previousCondition.IsProblem || !string.Equals(previousCondition.Label, currentCondition.Label, StringComparison.OrdinalIgnoreCase)) + { + return new NotificationDecision( + "Alert", + currentCondition.Label, + $"alert:{currentCondition.Label}:{currentCondition.Status}:{currentCondition.TrendLabel}"); + } + } + else if (previousCondition.IsProblem && settings.NotifyOnRecovery) + { + return new NotificationDecision( + "Recovery", + currentCondition.Label, + $"recovery:{previousCondition.Label}:{currentCondition.Status}"); + } + + return null; + } + + private bool IsWithinCooldown(string endpointId, string signature, int cooldownMinutes) + { + if (!_dispatchRecords.TryGetValue(endpointId, out var record)) + { + return false; + } + + if (!string.Equals(record.Signature, signature, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return (_timeProvider.GetUtcNow() - record.SentUtc) < TimeSpan.FromMinutes(cooldownMinutes); + } + + private static string BuildSubject(string subjectPrefix, string eventType, string endpointName, string label) + { + var prefix = string.IsNullOrWhiteSpace(subjectPrefix) ? "[ApiHealthDashboard]" : subjectPrefix.Trim(); + return $"{prefix} {eventType}: {endpointName} - {label}"; + } + + private static string BuildBody( + EndpointConfig endpoint, + NotificationCondition previousCondition, + NotificationCondition currentCondition, + NotificationDecision notification) + { + var builder = new StringBuilder(); + builder.AppendLine($"Endpoint: {endpoint.Name} ({endpoint.Id})"); + builder.AppendLine($"URL: {endpoint.Url}"); + builder.AppendLine($"Priority: {EndpointPriority.Normalize(endpoint.Priority)}"); + builder.AppendLine($"Event: {notification.EventType}"); + builder.AppendLine($"Current status: {currentCondition.Status}"); + builder.AppendLine($"Current trend: {currentCondition.TrendLabel}"); + + if (!string.IsNullOrWhiteSpace(previousCondition.Label)) + { + builder.AppendLine($"Previous condition: {previousCondition.Label}"); + } + + if (!string.IsNullOrWhiteSpace(currentCondition.ErrorSummary)) + { + builder.AppendLine($"Error: {currentCondition.ErrorSummary}"); + } + + if (currentCondition.CheckedUtc is DateTimeOffset checkedUtc) + { + builder.AppendLine($"Checked: {checkedUtc.ToUniversalTime():yyyy-MM-dd HH:mm:ss 'UTC'}"); + } + + builder.AppendLine(); + builder.AppendLine(notification.EventType == "Recovery" + ? "The endpoint has recovered from its previous problem state." + : "The endpoint entered or changed problem state and may need attention."); + + return builder.ToString().TrimEnd(); + } + + private sealed record NotificationDispatchRecord(string Signature, DateTimeOffset SentUtc); + + private sealed record NotificationRecipients(IReadOnlyList To, IReadOnlyList Cc); + + private sealed record NotificationDecision(string EventType, string SubjectLabel, string Signature); + + private sealed record NotificationCondition( + bool IsProblem, + string Label, + string Status, + string TrendLabel, + string? ErrorSummary, + DateTimeOffset? CheckedUtc) + { + public static NotificationCondition Empty { get; } = new(false, string.Empty, "Unknown", "Awaiting trend", null, null); + } +} diff --git a/src/ApiHealthDashboard/Services/EndpointImportRequest.cs b/src/ApiHealthDashboard/Services/EndpointImportRequest.cs index 07849d8..1e70afa 100644 --- a/src/ApiHealthDashboard/Services/EndpointImportRequest.cs +++ b/src/ApiHealthDashboard/Services/EndpointImportRequest.cs @@ -17,4 +17,8 @@ public sealed class EndpointImportRequest public string HeadersText { get; init; } = string.Empty; public bool IncludeDiscoveredChecks { get; init; } + + public string NotificationEmailsText { get; init; } = string.Empty; + + public string NotificationCcText { get; init; } = string.Empty; } diff --git a/src/ApiHealthDashboard/Services/EndpointImportService.cs b/src/ApiHealthDashboard/Services/EndpointImportService.cs index 0824dce..f2a754b 100644 --- a/src/ApiHealthDashboard/Services/EndpointImportService.cs +++ b/src/ApiHealthDashboard/Services/EndpointImportService.cs @@ -1,6 +1,7 @@ using System.Globalization; using System.Text; using System.Text.Json; +using System.ComponentModel.DataAnnotations; using ApiHealthDashboard.Configuration; using ApiHealthDashboard.Domain; using ApiHealthDashboard.Parsing; @@ -11,6 +12,7 @@ public sealed class EndpointImportService : IEndpointImportService { private const int ResponsePreviewLimit = 12000; private static readonly string[] GenericPathSegments = ["health", "healthz", "status", "ready", "live"]; + private static readonly EmailAddressAttribute EmailValidator = new(); private readonly DashboardConfig _dashboardConfig; private readonly IHealthResponseParser _healthResponseParser; @@ -46,6 +48,8 @@ public async Task ImportAsync(EndpointImportRequest reques var suggestedName = string.IsNullOrWhiteSpace(request.Name) ? SuggestEndpointName(suggestedId) : request.Name.Trim(); + var notificationEmails = ParseEmailList(request.NotificationEmailsText, "notification email"); + var notificationCc = ParseEmailList(request.NotificationCcText, "notification CC"); var probeEndpoint = new EndpointConfig { @@ -86,7 +90,13 @@ public async Task ImportAsync(EndpointImportRequest reques Priority = EndpointPriority.Normalize(existingEndpoint?.Priority), Headers = new Dictionary(probeEndpoint.Headers, StringComparer.OrdinalIgnoreCase), IncludeChecks = request.IncludeDiscoveredChecks ? [.. topLevelCheckNames] : [], - ExcludeChecks = [] + ExcludeChecks = [], + NotificationEmails = notificationEmails.Count > 0 + ? notificationEmails + : [.. existingEndpoint?.NotificationEmails ?? []], + NotificationCc = notificationCc.Count > 0 + ? notificationCc + : [.. existingEndpoint?.NotificationCc ?? []] }; var shouldGenerateYamlPreview = !(pollResult.Kind == PollResultKind.HttpError && @@ -197,6 +207,31 @@ private static Dictionary ParseHeaders(string headersText) return headers; } + private static List ParseEmailList(string value, string fieldLabel) + { + if (string.IsNullOrWhiteSpace(value)) + { + return []; + } + + var emails = value + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split(['\n', ',', ';'], StringSplitOptions.TrimEntries | StringSplitOptions.RemoveEmptyEntries) + .Select(static email => email.Trim()) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToList(); + + for (var index = 0; index < emails.Count; index++) + { + if (!EmailValidator.IsValid(emails[index])) + { + throw new EndpointImportException([$"The {fieldLabel} value '{emails[index]}' is not a valid email address."]); + } + } + + return emails; + } + private EndpointConfig? FindExistingEndpoint(EndpointConfig suggestedEndpoint) { return _dashboardConfig.Endpoints.FirstOrDefault(endpoint => @@ -439,6 +474,26 @@ private static string RenderEndpointYaml(EndpointConfig endpoint) } } + if (endpoint.NotificationEmails.Count > 0) + { + lines.Add("notificationEmails:"); + + foreach (var email in endpoint.NotificationEmails.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)) + { + lines.Add($" - {Quote(email)}"); + } + } + + if (endpoint.NotificationCc.Count > 0) + { + lines.Add("notificationCc:"); + + foreach (var email in endpoint.NotificationCc.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)) + { + lines.Add($" - {Quote(email)}"); + } + } + return string.Join(Environment.NewLine, lines); } diff --git a/src/ApiHealthDashboard/Services/IEmailSender.cs b/src/ApiHealthDashboard/Services/IEmailSender.cs new file mode 100644 index 0000000..1709328 --- /dev/null +++ b/src/ApiHealthDashboard/Services/IEmailSender.cs @@ -0,0 +1,17 @@ +namespace ApiHealthDashboard.Services; + +public interface IEmailSender +{ + Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default); +} + +public sealed class EmailMessage +{ + public IReadOnlyList To { get; init; } = []; + + public IReadOnlyList Cc { get; init; } = []; + + public required string Subject { get; init; } + + public required string Body { get; init; } +} diff --git a/src/ApiHealthDashboard/Services/IEndpointNotificationService.cs b/src/ApiHealthDashboard/Services/IEndpointNotificationService.cs new file mode 100644 index 0000000..2d6e641 --- /dev/null +++ b/src/ApiHealthDashboard/Services/IEndpointNotificationService.cs @@ -0,0 +1,13 @@ +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; + +namespace ApiHealthDashboard.Services; + +public interface IEndpointNotificationService +{ + Task NotifyAsync( + EndpointConfig endpoint, + EndpointState? previousState, + EndpointState currentState, + CancellationToken cancellationToken = default); +} diff --git a/src/ApiHealthDashboard/Services/SmtpEmailSender.cs b/src/ApiHealthDashboard/Services/SmtpEmailSender.cs new file mode 100644 index 0000000..30f5db6 --- /dev/null +++ b/src/ApiHealthDashboard/Services/SmtpEmailSender.cs @@ -0,0 +1,59 @@ +using System.Net; +using System.Net.Mail; +using ApiHealthDashboard.Configuration; + +namespace ApiHealthDashboard.Services; + +public sealed class SmtpEmailSender : IEmailSender +{ + private readonly ILogger _logger; + private readonly SmtpEmailOptions _options; + + public SmtpEmailSender( + SmtpEmailOptions options, + ILogger logger) + { + _options = options; + _logger = logger; + } + + public async Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(message); + + using var mailMessage = new MailMessage + { + From = new MailAddress(_options.FromAddress, _options.FromName), + Subject = message.Subject, + Body = message.Body, + IsBodyHtml = false + }; + + foreach (var recipient in message.To) + { + mailMessage.To.Add(recipient); + } + + foreach (var recipient in message.Cc) + { + mailMessage.CC.Add(recipient); + } + + using var client = new SmtpClient(_options.Host, _options.Port) + { + EnableSsl = _options.UseSsl + }; + + if (!string.IsNullOrWhiteSpace(_options.Username)) + { + client.Credentials = new NetworkCredential(_options.Username, _options.Password); + } + + _logger.LogInformation( + "Sending SMTP email notification to {ToCount} recipient(s) and {CcCount} CC recipient(s).", + mailMessage.To.Count, + mailMessage.CC.Count); + + await client.SendMailAsync(mailMessage, cancellationToken); + } +} diff --git a/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs index 04129f1..a0520c3 100644 --- a/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs +++ b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs @@ -64,6 +64,26 @@ private static RecentPollTrendKind ResolveTrendKind( return RecentPollTrendKind.Stable; } + var currentStatus = NormalizeStatus(orderedSamples[^1].Status); + var currentStreakLength = GetTrailingStatusStreakLength(orderedSamples, currentStatus); + var previousDifferentStatus = GetPreviousDifferentStatus(orderedSamples, currentStatus); + + if (currentStreakLength >= 2 && previousDifferentStatus is not null) + { + var previousRank = GetStatusRank(previousDifferentStatus); + var currentRank = GetStatusRank(currentStatus); + + if (currentRank > previousRank) + { + return RecentPollTrendKind.Improving; + } + + if (currentRank < previousRank) + { + return RecentPollTrendKind.Worsening; + } + } + if (transitions.Count >= 3) { return RecentPollTrendKind.Flapping; @@ -85,6 +105,37 @@ private static RecentPollTrendKind ResolveTrendKind( return RecentPollTrendKind.Flapping; } + private static int GetTrailingStatusStreakLength(IReadOnlyList orderedSamples, string currentStatus) + { + var streakLength = 0; + + for (var index = orderedSamples.Count - 1; index >= 0; index--) + { + if (!string.Equals(NormalizeStatus(orderedSamples[index].Status), currentStatus, StringComparison.OrdinalIgnoreCase)) + { + break; + } + + streakLength++; + } + + return streakLength; + } + + private static string? GetPreviousDifferentStatus(IReadOnlyList orderedSamples, string currentStatus) + { + for (var index = orderedSamples.Count - 1; index >= 0; index--) + { + var sampleStatus = NormalizeStatus(orderedSamples[index].Status); + if (!string.Equals(sampleStatus, currentStatus, StringComparison.OrdinalIgnoreCase)) + { + return sampleStatus; + } + } + + return null; + } + private static bool IsFailedSample(RecentPollSample sample) { return !string.Equals(sample.ResultKind, "Success", StringComparison.OrdinalIgnoreCase) || diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index 55f0b54..b242871 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -14,6 +14,18 @@ "Import": { "MinimumRecommendedPollFrequencySeconds": 180 }, + "Email": { + "Smtp": { + "Enabled": false, + "Host": "", + "Port": 587, + "UseSsl": true, + "Username": "", + "Password": "", + "FromAddress": "", + "FromName": "ApiHealthDashboard" + } + }, "DetailedErrors": true, "Logging": { "LogLevel": { diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index 871df8c..eaf5591 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -14,6 +14,18 @@ "Import": { "MinimumRecommendedPollFrequencySeconds": 180 }, + "Email": { + "Smtp": { + "Enabled": true, + "Host": "127.0.0.1", + "Port": 2525, + "UseSsl": false, + "Username": "", + "Password": "", + "FromAddress": "wmhealthchecks_dev@phillip.com.sg", + "FromName": "ApiHealthDashboard" + } + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/ApiHealthDashboard/dashboard.yaml b/src/ApiHealthDashboard/dashboard.yaml index 778d989..68cc04e 100644 --- a/src/ApiHealthDashboard/dashboard.yaml +++ b/src/ApiHealthDashboard/dashboard.yaml @@ -2,7 +2,18 @@ dashboard: refreshUiSeconds: 10 requestTimeoutSecondsDefault: 10 showRawPayload: true + notifications: + enabled: true + notifyOnRecovery: true + cooldownMinutes: 60 + minimumPriority: Normal + subjectPrefix: '[ApiHealthDashboard]' + to: + - 'ops@example.com' + cc: + - 'teamlead@example.com' endpointFiles: - endpoints/orders-api.yaml - endpoints/billing-api.yaml + - endpoints/utpl-integration-api.yaml diff --git a/src/ApiHealthDashboard/endpoints/billing-api.yaml b/src/ApiHealthDashboard/endpoints/billing-api.yaml index cc07e2e..26b43b4 100644 --- a/src/ApiHealthDashboard/endpoints/billing-api.yaml +++ b/src/ApiHealthDashboard/endpoints/billing-api.yaml @@ -8,3 +8,5 @@ timeoutSeconds: 15 headers: {} includeChecks: [] excludeChecks: [] +notificationEmails: + - 'gali@phillip.com.sg' \ No newline at end of file diff --git a/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml b/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml new file mode 100644 index 0000000..9a35121 --- /dev/null +++ b/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml @@ -0,0 +1,15 @@ +id: 'utpl-integration-api' +name: 'UTPL Integration API' +url: 'http://10.30.23.166:8114/hc' +enabled: true +priority: 'Normal' +frequencySeconds: 5 +timeoutSeconds: 30 +includeChecks: + - 'GlobalConfigApi' + - 'GWMCoreOrderMailingAPI' + - 'GWMDB Database' + - 'LookupApi' + - 'UTPLConfig' +notificationEmails: + - 'gali@phillip.com.sg' \ No newline at end of file diff --git a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs index 1aa924d..468a73f 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs @@ -58,6 +58,7 @@ public async Task ReloadNowAsync_WhenYamlChanges_UpdatesSharedConfigAndStateStor sharedConfig, stateStore, new StubEndpointPoller(), + new NoOpEndpointNotificationService(), new StubHealthResponseParser(), new RuntimeStateOptions(), TimeProvider.System, @@ -177,4 +178,12 @@ public HealthSnapshot Parse(EndpointConfig endpoint, string json, long durationM }; } } + + private sealed class NoOpEndpointNotificationService : IEndpointNotificationService + { + public Task NotifyAsync(EndpointConfig endpoint, EndpointState? previousState, EndpointState currentState, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } } diff --git a/tests/ApiHealthDashboard.Tests/Pages/ImportModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/ImportModelTests.cs index 8eed77a..c179f95 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/ImportModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/ImportModelTests.cs @@ -21,7 +21,9 @@ public async Task OnPostPreviewAsync_WithValidInput_LoadsImportResult() Name = "Orders API", Url = "https://orders.example.com/health", Enabled = true, - FrequencySeconds = 30 + FrequencySeconds = 30, + NotificationEmails = ["ops@example.com"], + NotificationCc = ["lead@example.com"] }, GeneratedYaml = "id: 'orders-api'", ProbeResult = new PollResult @@ -45,7 +47,9 @@ public async Task OnPostPreviewAsync_WithValidInput_LoadsImportResult() Input = new ImportModel.InputModel { Url = "https://orders.example.com/health", - FrequencySeconds = 30 + FrequencySeconds = 30, + NotificationEmailsText = "ops@example.com", + NotificationCcText = "lead@example.com" } }; @@ -55,6 +59,8 @@ public async Task OnPostPreviewAsync_WithValidInput_LoadsImportResult() Assert.NotNull(model.Result); Assert.Equal("orders-api", model.Input.Id); Assert.Equal("Orders API", model.Input.Name); + Assert.Equal("ops@example.com", model.Input.NotificationEmailsText); + Assert.Equal("lead@example.com", model.Input.NotificationCcText); Assert.Equal("Poll frequency below the recommended soft limit of 180 seconds may create unnecessary load.", model.FrequencyRecommendationWarning); } diff --git a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs index 3aca6cd..6c36f78 100644 --- a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs +++ b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs @@ -39,6 +39,7 @@ public async Task ReloadConfigurationAsync_WhenNotStarted_SynchronizesStateStore sharedConfig, stateStore, new NoOpEndpointPoller(), + new NoOpEndpointNotificationService(), new NoOpHealthResponseParser(), new RuntimeStateOptions(), TimeProvider.System, @@ -80,4 +81,12 @@ public HealthSnapshot Parse(EndpointConfig endpoint, string json, long durationM throw new NotSupportedException(); } } + + private sealed class NoOpEndpointNotificationService : IEndpointNotificationService + { + public Task NotifyAsync(EndpointConfig endpoint, EndpointState? previousState, EndpointState currentState, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } } diff --git a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs index 80cc47b..de91f92 100644 --- a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerServiceTests.cs @@ -383,6 +383,7 @@ private static PollingSchedulerService CreateScheduler( config, stateStore, endpointPoller, + new NoOpEndpointNotificationService(), healthResponseParser, runtimeStateOptions ?? new RuntimeStateOptions(), TimeProvider.System, @@ -418,4 +419,12 @@ public HealthSnapshot Parse(EndpointConfig endpoint, string json, long durationM return _parse(endpoint, json, durationMs); } } + + private sealed class NoOpEndpointNotificationService : IEndpointNotificationService + { + public Task NotifyAsync(EndpointConfig endpoint, EndpointState? previousState, EndpointState currentState, CancellationToken cancellationToken = default) + { + return Task.CompletedTask; + } + } } diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs new file mode 100644 index 0000000..3425a61 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs @@ -0,0 +1,207 @@ +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ApiHealthDashboard.Tests.Services; + +public sealed class EndpointEmailNotificationServiceTests +{ + [Fact] + public async Task NotifyAsync_WhenEndpointEntersFailingState_SendsAlertEmail() + { + var config = CreateConfig(); + var sender = new FakeEmailSender(); + var service = CreateService(config, sender); + + var previousState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T01:00:00Z"), + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T00:59:00Z"), + Status = "Healthy", + DurationMs = 90, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:00:00Z"), + Status = "Healthy", + DurationMs = 95, + ResultKind = "Success" + } + ] + }; + + var currentState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Unknown", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T01:05:00Z"), + LastError = "Timed out", + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:04:00Z"), + Status = "Unknown", + DurationMs = 200, + ResultKind = "Timeout", + ErrorSummary = "Timed out" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:05:00Z"), + Status = "Unknown", + DurationMs = 210, + ResultKind = "NetworkError", + ErrorSummary = "Connection refused" + } + ] + }; + + await service.NotifyAsync(config.Endpoints[0], previousState, currentState); + + var message = Assert.Single(sender.Messages); + Assert.Contains("Alert", message.Subject, StringComparison.Ordinal); + Assert.Contains("Failing", message.Subject, StringComparison.Ordinal); + Assert.Equal(["ops@example.com", "service-owner@example.com"], message.To.OrderBy(static value => value).ToArray()); + Assert.Equal(["lead@example.com", "teamlead@example.com"], message.Cc.OrderBy(static value => value).ToArray()); + } + + [Fact] + public async Task NotifyAsync_WhenEndpointRecovers_SendsRecoveryEmail() + { + var config = CreateConfig(); + var sender = new FakeEmailSender(); + var service = CreateService(config, sender); + + var previousState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Unknown", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T01:05:00Z"), + LastError = "Timed out", + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:04:00Z"), + Status = "Unknown", + DurationMs = 200, + ResultKind = "Timeout", + ErrorSummary = "Timed out" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:05:00Z"), + Status = "Unknown", + DurationMs = 210, + ResultKind = "NetworkError", + ErrorSummary = "Connection refused" + } + ] + }; + + var currentState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T01:10:00Z"), + LastSuccessfulUtc = DateTimeOffset.Parse("2026-03-19T01:10:00Z"), + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:09:00Z"), + Status = "Healthy", + DurationMs = 80, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:10:00Z"), + Status = "Healthy", + DurationMs = 82, + ResultKind = "Success" + } + ] + }; + + await service.NotifyAsync(config.Endpoints[0], previousState, currentState); + + var message = Assert.Single(sender.Messages); + Assert.Contains("Recovery", message.Subject, StringComparison.Ordinal); + } + + private static EndpointEmailNotificationService CreateService(DashboardConfig config, FakeEmailSender sender) + { + return new EndpointEmailNotificationService( + config, + new SmtpEmailOptions + { + Enabled = true, + Host = "smtp.example.com", + Port = 587, + FromAddress = "dashboard@example.com", + FromName = "ApiHealthDashboard" + }, + sender, + TimeProvider.System, + NullLogger.Instance); + } + + private static DashboardConfig CreateConfig() + { + return new DashboardConfig + { + Dashboard = new DashboardSettings + { + Notifications = new DashboardNotificationSettings + { + Enabled = true, + NotifyOnRecovery = true, + CooldownMinutes = 60, + MinimumPriority = EndpointPriority.Normal, + SubjectPrefix = "[ApiHealthDashboard]", + To = ["ops@example.com"], + Cc = ["teamlead@example.com"] + } + }, + Endpoints = + [ + new EndpointConfig + { + Id = "orders-api", + Name = "Orders API", + Url = "https://orders.example.com/health", + Enabled = true, + FrequencySeconds = 30, + Priority = EndpointPriority.High, + NotificationEmails = ["service-owner@example.com"], + NotificationCc = ["lead@example.com"] + } + ] + }; + } + + private sealed class FakeEmailSender : IEmailSender + { + public List Messages { get; } = []; + + public Task SendAsync(EmailMessage message, CancellationToken cancellationToken = default) + { + Messages.Add(message); + return Task.CompletedTask; + } + } +} diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs index 0a009bc..7daff78 100644 --- a/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs @@ -23,7 +23,9 @@ public async Task ImportAsync_WithSuccessfulProbe_GeneratesYamlAndDiffAgainstExi Url = "https://orders.example.com/health", Priority = EndpointPriority.High, Enabled = true, - FrequencySeconds = 60 + FrequencySeconds = 60, + NotificationEmails = ["ops@example.com"], + NotificationCc = ["lead@example.com"] } ] }; @@ -60,7 +62,8 @@ public async Task ImportAsync_WithSuccessfulProbe_GeneratesYamlAndDiffAgainstExi { Url = "https://orders.example.com/health", FrequencySeconds = 30, - IncludeDiscoveredChecks = true + IncludeDiscoveredChecks = true, + NotificationCcText = "override@example.com" }, CancellationToken.None); @@ -69,6 +72,9 @@ public async Task ImportAsync_WithSuccessfulProbe_GeneratesYamlAndDiffAgainstExi Assert.Equal(EndpointPriority.High, result.SuggestedEndpoint.Priority); Assert.True(result.HasExistingMatch); Assert.Contains("priority: 'High'", result.GeneratedYaml, StringComparison.Ordinal); + Assert.Contains("notificationEmails:", result.GeneratedYaml, StringComparison.Ordinal); + Assert.Contains("'ops@example.com'", result.GeneratedYaml, StringComparison.Ordinal); + Assert.Contains("'override@example.com'", result.GeneratedYaml, StringComparison.Ordinal); Assert.Contains("includeChecks:", result.GeneratedYaml, StringComparison.Ordinal); Assert.Contains("'database'", result.GeneratedYaml, StringComparison.Ordinal); Assert.Equal(["cache", "database"], result.TopLevelCheckNames); @@ -156,6 +162,37 @@ public async Task ImportAsync_WithJsonResponse_PrettyPrintsResponsePreview() Assert.Contains("\"status\": \"Healthy\"", result.ResponsePreview, StringComparison.Ordinal); } + [Fact] + public async Task ImportAsync_WithNotificationRecipients_AddsThemToSuggestedEndpoint() + { + var service = new EndpointImportService( + new DashboardConfig(), + new StubEndpointPoller(new PollResult + { + Kind = PollResultKind.Success, + DurationMs = 25, + ResponseBody = "{\"status\":\"Healthy\"}" + }), + new StubHealthResponseParser(new HealthSnapshot + { + OverallStatus = "Healthy" + }), + NullLogger.Instance); + + var result = await service.ImportAsync( + new EndpointImportRequest + { + Url = "https://orders.example.com/health", + FrequencySeconds = 180, + NotificationEmailsText = "ops@example.com; oncall@example.com", + NotificationCcText = "lead@example.com" + }, + CancellationToken.None); + + Assert.Equal(["oncall@example.com", "ops@example.com"], result.SuggestedEndpoint.NotificationEmails.OrderBy(static value => value).ToArray()); + Assert.Equal(["lead@example.com"], result.SuggestedEndpoint.NotificationCc); + } + [Fact] public async Task ImportAsync_WithHttpNotFound_DoesNotGenerateYamlPreview() { diff --git a/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs index 53fa3a8..d0748bc 100644 --- a/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs +++ b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs @@ -59,4 +59,51 @@ public void Analyze_StableSuccessfulSamples_RemainsStable() Assert.Equal(RecentPollTrendKind.Stable, result.TrendKind); } + + [Fact] + public void Analyze_WhenRecentSamplesSettleIntoBetterStatus_ChangesFromFlappingToImproving() + { + var samples = new[] + { + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 0, 0, TimeSpan.Zero), + Status = "Degraded", + DurationMs = 90, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 1, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 92, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 2, 0, TimeSpan.Zero), + Status = "Degraded", + DurationMs = 94, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 3, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 91, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 4, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 89, + ResultKind = "Success" + } + }; + + var result = RecentPollTrendAnalyzer.Analyze(samples); + + Assert.Equal(RecentPollTrendKind.Improving, result.TrendKind); + } } From 2a42e85528f25ca3ebd154b32a4466fc0f9a9f3f Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 16:29:11 +0800 Subject: [PATCH 11/15] feat: remove UTPL Integration API from dashboard configuration --- src/ApiHealthDashboard/dashboard.yaml | 1 - .../endpoints/utpl-integration-api.yaml | 15 --------------- 2 files changed, 16 deletions(-) delete mode 100644 src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml diff --git a/src/ApiHealthDashboard/dashboard.yaml b/src/ApiHealthDashboard/dashboard.yaml index 68cc04e..eaf7939 100644 --- a/src/ApiHealthDashboard/dashboard.yaml +++ b/src/ApiHealthDashboard/dashboard.yaml @@ -16,4 +16,3 @@ dashboard: endpointFiles: - endpoints/orders-api.yaml - endpoints/billing-api.yaml - - endpoints/utpl-integration-api.yaml diff --git a/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml b/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml deleted file mode 100644 index 9a35121..0000000 --- a/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml +++ /dev/null @@ -1,15 +0,0 @@ -id: 'utpl-integration-api' -name: 'UTPL Integration API' -url: 'http://10.30.23.166:8114/hc' -enabled: true -priority: 'Normal' -frequencySeconds: 5 -timeoutSeconds: 30 -includeChecks: - - 'GlobalConfigApi' - - 'GWMCoreOrderMailingAPI' - - 'GWMDB Database' - - 'LookupApi' - - 'UTPLConfig' -notificationEmails: - - 'gali@phillip.com.sg' \ No newline at end of file From fb11416f99acb1281ab3d7ef1470fda0231f176e Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 17:47:15 +0800 Subject: [PATCH 12/15] feat: enhance notification logic to check for existing dispatch records before sending alerts --- .../EndpointEmailNotificationService.cs | 15 +++++-- .../EndpointEmailNotificationServiceTests.cs | 45 +++++++++++++++++++ 2 files changed, 56 insertions(+), 4 deletions(-) diff --git a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs index 058d4c9..8a7026b 100644 --- a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs +++ b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs @@ -68,7 +68,12 @@ public async Task NotifyAsync( var previousCondition = DescribeCondition(previousState); var currentCondition = DescribeCondition(currentState); - var notification = BuildNotificationDecision(endpoint, notificationSettings, previousCondition, currentCondition); + var hasExistingDispatchRecord = _dispatchRecords.ContainsKey(endpoint.Id); + var notification = BuildNotificationDecision( + notificationSettings, + previousCondition, + currentCondition, + hasExistingDispatchRecord); if (notification is null) { return; @@ -174,14 +179,16 @@ private NotificationCondition DescribeCondition(EndpointState? state) } private static NotificationDecision? BuildNotificationDecision( - EndpointConfig endpoint, DashboardNotificationSettings settings, NotificationCondition previousCondition, - NotificationCondition currentCondition) + NotificationCondition currentCondition, + bool hasExistingDispatchRecord) { if (currentCondition.IsProblem) { - if (!previousCondition.IsProblem || !string.Equals(previousCondition.Label, currentCondition.Label, StringComparison.OrdinalIgnoreCase)) + if (!hasExistingDispatchRecord || + !previousCondition.IsProblem || + !string.Equals(previousCondition.Label, currentCondition.Label, StringComparison.OrdinalIgnoreCase)) { return new NotificationDecision( "Alert", diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs index 3425a61..a66a74c 100644 --- a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs @@ -143,6 +143,51 @@ public async Task NotifyAsync_WhenEndpointRecovers_SendsRecoveryEmail() Assert.Contains("Recovery", message.Subject, StringComparison.Ordinal); } + [Fact] + public async Task NotifyAsync_WhenEndpointIsAlreadyFailingAndNoPriorNotificationExists_SendsInitialAlert() + { + var config = CreateConfig(); + var sender = new FakeEmailSender(); + var service = CreateService(config, sender); + + var previousState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Unknown", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T01:05:00Z"), + LastError = "Timed out", + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:04:00Z"), + Status = "Unknown", + DurationMs = 200, + ResultKind = "Timeout", + ErrorSummary = "Timed out" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:05:00Z"), + Status = "Unknown", + DurationMs = 210, + ResultKind = "NetworkError", + ErrorSummary = "Connection refused" + } + ] + }; + + var currentState = previousState.Clone(); + currentState.LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T01:06:00Z"); + + await service.NotifyAsync(config.Endpoints[0], previousState, currentState); + + var message = Assert.Single(sender.Messages); + Assert.Contains("Alert", message.Subject, StringComparison.Ordinal); + Assert.Contains("Failing", message.Subject, StringComparison.Ordinal); + } + private static EndpointEmailNotificationService CreateService(DashboardConfig config, FakeEmailSender sender) { return new EndpointEmailNotificationService( From 1a0ac020ad1f0d96aa92bf2007ea2f6b5337a8f7 Mon Sep 17 00:00:00 2001 From: gali Date: Thu, 19 Mar 2026 18:24:20 +0800 Subject: [PATCH 13/15] feat: implement notification dispatch history and enhance endpoint state management --- README.md | 7 ++- .../Configuration/RuntimeStateOptions.cs | 7 +++ .../Domain/EndpointNotificationDispatch.cs | 29 ++++++++++ .../Domain/EndpointState.cs | 5 +- .../Pages/Endpoints/Details.cshtml | 30 ++++++++++ .../Pages/Endpoints/Details.cshtml.cs | 47 ++++++++++++++++ .../Scheduling/PollingSchedulerService.cs | 6 +- .../EndpointEmailNotificationService.cs | 55 ++++++++++++++----- .../State/FileBackedEndpointStateStore.cs | 6 +- .../appsettings.Development.json | 3 +- src/ApiHealthDashboard/appsettings.json | 3 +- src/ApiHealthDashboard/wwwroot/css/site.css | 12 ++++ .../Pages/Endpoints/DetailsModelTests.cs | 15 +++++ .../EndpointEmailNotificationServiceTests.cs | 9 +++ .../FileBackedEndpointStateStoreTests.cs | 27 +++++++++ 15 files changed, 239 insertions(+), 22 deletions(-) create mode 100644 src/ApiHealthDashboard/Domain/EndpointNotificationDispatch.cs diff --git a/README.md b/README.md index 965b9c8..e6b2466 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ Implemented so far: - Post-v1: recent poll sample retention with derived dashboard and details metrics - Post-v1: mini trend visuals and short status history from retained runtime samples - Post-v1: SMTP email notifications with dashboard defaults and per-endpoint recipients +- Post-v1: persisted notification dispatch history in runtime state Not implemented yet: - Backlog items tracked for post-v1 work @@ -127,8 +128,10 @@ Current behavior: - keeps active runtime state in memory for fast reads by pages and the scheduler - can persist the latest current state for each endpoint to a compact JSON file under a configurable runtime-state directory - restores persisted current state on startup for configured endpoints and resets any stale `IsPolling` flag to `false` +- persists successful email notification dispatch history alongside endpoint runtime state so cooldown survives restarts - can clean up orphaned persisted state files on a configurable interval after a configurable retention window - retains a configurable rolling window of recent poll samples per endpoint for derived runtime metrics +- retains a configurable rolling window of recent notification dispatch records per endpoint - supports get-all, get-one, upsert, and reinitialize operations - returns deep copies so callers cannot mutate internal store state accidentally - uses thread-safe locking for concurrent access @@ -252,6 +255,7 @@ Current email notification behavior: - sends alert emails when an endpoint enters or changes problem state - can send recovery emails when an endpoint returns to a non-problem state - treats repeated transport failures as a `Failing` condition for alerting purposes +- records successful dispatches in endpoint runtime state so notification cooldown and history survive restarts ### CLI Execution @@ -276,6 +280,7 @@ Current details-page behavior: - summarizes the latest poll with status, timings, retrieved timestamp, and current error - shows retained recent sample metrics including success rate, failure count, average duration, last status change, and a short trend summary - shows a short recent status history with the latest observed status transitions +- shows recent successful notification dispatch history with event type, condition, timestamp, and recipients - renders top-level and nested health checks recursively with native expand and collapse support - surfaces snapshot metadata captured from the parsed response - shows the raw payload section only when enabled in configuration @@ -357,7 +362,7 @@ Current cleanup settings: - `RuntimeState:DeleteOrphanedStateFiles` to enable deletion of persisted state files that no longer belong to configured endpoints - `RuntimeState:OrphanedStateFileRetentionHours` to keep orphaned state files for a configurable grace period before deletion - `RuntimeState:RecentSampleLimit` to cap how many recent poll samples are retained per endpoint -- `RuntimeState:RecentSampleLimit` to cap how many recent poll samples are retained per endpoint +- `RuntimeState:NotificationHistoryLimit` to cap how many notification dispatch records are retained per endpoint You can also override it with an environment variable: diff --git a/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs index a52c500..55e5019 100644 --- a/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs +++ b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs @@ -18,6 +18,8 @@ public sealed class RuntimeStateOptions public int RecentSampleLimit { get; set; } = 25; + public int NotificationHistoryLimit { get; set; } = 20; + public string ResolveDirectoryPath(string contentRootPath) { ArgumentException.ThrowIfNullOrWhiteSpace(contentRootPath); @@ -49,4 +51,9 @@ public int GetRecentSampleLimit() { return Math.Max(RecentSampleLimit, 0); } + + public int GetNotificationHistoryLimit() + { + return Math.Max(NotificationHistoryLimit, 0); + } } diff --git a/src/ApiHealthDashboard/Domain/EndpointNotificationDispatch.cs b/src/ApiHealthDashboard/Domain/EndpointNotificationDispatch.cs new file mode 100644 index 0000000..c43850b --- /dev/null +++ b/src/ApiHealthDashboard/Domain/EndpointNotificationDispatch.cs @@ -0,0 +1,29 @@ +namespace ApiHealthDashboard.Domain; + +public sealed class EndpointNotificationDispatch +{ + public string EventType { get; set; } = string.Empty; + + public string ConditionLabel { get; set; } = string.Empty; + + public string Signature { get; set; } = string.Empty; + + public DateTimeOffset SentUtc { get; set; } + + public List To { get; set; } = new(); + + public List Cc { get; set; } = new(); + + public EndpointNotificationDispatch Clone() + { + return new EndpointNotificationDispatch + { + EventType = EventType, + ConditionLabel = ConditionLabel, + Signature = Signature, + SentUtc = SentUtc, + To = [.. To], + Cc = [.. Cc] + }; + } +} diff --git a/src/ApiHealthDashboard/Domain/EndpointState.cs b/src/ApiHealthDashboard/Domain/EndpointState.cs index ab12f30..f689b3a 100644 --- a/src/ApiHealthDashboard/Domain/EndpointState.cs +++ b/src/ApiHealthDashboard/Domain/EndpointState.cs @@ -22,6 +22,8 @@ public sealed class EndpointState public List RecentSamples { get; set; } = new(); + public List NotificationDispatches { get; set; } = new(); + public EndpointState Clone() { return new EndpointState @@ -35,7 +37,8 @@ public EndpointState Clone() LastError = LastError, Snapshot = Snapshot?.Clone(), IsPolling = IsPolling, - RecentSamples = RecentSamples.Select(static sample => sample.Clone()).ToList() + RecentSamples = RecentSamples.Select(static sample => sample.Clone()).ToList(), + NotificationDispatches = NotificationDispatches.Select(static dispatch => dispatch.Clone()).ToList() }; } } diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml index 9c01144..5130364 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml @@ -302,6 +302,36 @@ else
+
+
+

Notification History

+
+
+ @if (!endpoint.HasNotificationDispatches) + { +
Successful notification dispatches will appear here after emails are sent for this endpoint.
+ } + else + { +
+ @foreach (var dispatch in endpoint.NotificationDispatches) + { +
+
+
+ @dispatch.EventType + @dispatch.ConditionLabel +
+
@dispatch.SentText
+
+
@dispatch.RecipientSummary
+
+ } +
+ } +
+
+

Snapshot Metadata

diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs index ebbe4ab..975b964 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs @@ -205,6 +205,10 @@ public sealed class EndpointDetailsViewModel public bool HasStatusTransitions => RecentStatusTransitions.Count > 0; + public IReadOnlyList NotificationDispatches { get; init; } = []; + + public bool HasNotificationDispatches => NotificationDispatches.Count > 0; + public static EndpointDetailsViewModel From( EndpointConfig endpoint, EndpointState? state, @@ -294,6 +298,11 @@ public static EndpointDetailsViewModel From( .Take(6) .Select(CreateStatusTransitionViewModel) .ToArray(), + NotificationDispatches = state?.NotificationDispatches + .OrderByDescending(static dispatch => dispatch.SentUtc) + .Take(8) + .Select(CreateNotificationDispatchViewModel) + .ToArray() ?? [], RecentSamples = recentSamples .OrderByDescending(static sample => sample.CheckedUtc) .Take(10) @@ -459,6 +468,31 @@ private static StatusTransitionViewModel CreateStatusTransitionViewModel(RecentP }; } + private static NotificationDispatchViewModel CreateNotificationDispatchViewModel(EndpointNotificationDispatch dispatch) + { + return new NotificationDispatchViewModel + { + EventType = dispatch.EventType, + EventBadgeClass = string.Equals(dispatch.EventType, "Recovery", StringComparison.OrdinalIgnoreCase) + ? "badge-success" + : "badge-warning", + ConditionLabel = dispatch.ConditionLabel, + SentText = FormatDateTime(dispatch.SentUtc), + RecipientSummary = BuildRecipientSummary(dispatch.To, dispatch.Cc) + }; + } + + private static string BuildRecipientSummary(IReadOnlyList to, IReadOnlyList cc) + { + var toSummary = to.Count == 0 ? "No direct recipients" : $"To: {string.Join(", ", to)}"; + if (cc.Count == 0) + { + return toSummary; + } + + return $"{toSummary} | CC: {string.Join(", ", cc)}"; + } + private static string ToTrendText(RecentPollTrendKind trendKind, string currentStatus) { return trendKind switch @@ -556,4 +590,17 @@ public sealed class StatusTransitionViewModel public required string ChangedText { get; init; } } + + public sealed class NotificationDispatchViewModel + { + public required string EventType { get; init; } + + public required string EventBadgeClass { get; init; } + + public required string ConditionLabel { get; init; } + + public required string SentText { get; init; } + + public required string RecipientSummary { get; init; } + } } diff --git a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs index 2ac47c6..fdd66d5 100644 --- a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs +++ b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs @@ -247,14 +247,12 @@ private async Task PollEndpointCoreAsync(EndpointConfig endpoint, string trigger AppendRecentSample(updatedState, result); - _stateStore.Upsert(updatedState); - try { await _endpointNotificationService.NotifyAsync( endpoint, previousState, - updatedState.Clone(), + updatedState, cancellationToken); } catch (Exception ex) @@ -265,6 +263,8 @@ await _endpointNotificationService.NotifyAsync( endpoint.Id); } + _stateStore.Upsert(updatedState); + _logger.LogInformation( "Completed {TriggerSource} poll for endpoint {EndpointId} with status {EndpointStatus}, result kind {PollResultKind}, duration {DurationMs}ms, and status code {StatusCode}.", triggerSource, diff --git a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs index 8a7026b..0046883 100644 --- a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs +++ b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs @@ -1,4 +1,3 @@ -using System.Collections.Concurrent; using System.Text; using ApiHealthDashboard.Configuration; using ApiHealthDashboard.Domain; @@ -11,18 +10,20 @@ public sealed class EndpointEmailNotificationService : IEndpointNotificationServ private readonly DashboardConfig _dashboardConfig; private readonly IEmailSender _emailSender; private readonly ILogger _logger; + private readonly RuntimeStateOptions _runtimeStateOptions; private readonly SmtpEmailOptions _smtpOptions; private readonly TimeProvider _timeProvider; - private readonly ConcurrentDictionary _dispatchRecords = new(StringComparer.OrdinalIgnoreCase); public EndpointEmailNotificationService( DashboardConfig dashboardConfig, + RuntimeStateOptions runtimeStateOptions, SmtpEmailOptions smtpOptions, IEmailSender emailSender, TimeProvider timeProvider, ILogger logger) { _dashboardConfig = dashboardConfig; + _runtimeStateOptions = runtimeStateOptions; _smtpOptions = smtpOptions; _emailSender = emailSender; _timeProvider = timeProvider; @@ -68,7 +69,7 @@ public async Task NotifyAsync( var previousCondition = DescribeCondition(previousState); var currentCondition = DescribeCondition(currentState); - var hasExistingDispatchRecord = _dispatchRecords.ContainsKey(endpoint.Id); + var hasExistingDispatchRecord = currentState.NotificationDispatches.Count > 0; var notification = BuildNotificationDecision( notificationSettings, previousCondition, @@ -79,7 +80,7 @@ public async Task NotifyAsync( return; } - if (IsWithinCooldown(endpoint.Id, notification.Signature, notificationSettings.CooldownMinutes)) + if (IsWithinCooldown(currentState, notification.Signature, notificationSettings.CooldownMinutes)) { _logger.LogDebug( "Skipped email notification for endpoint {EndpointId} because the notification is within the configured cooldown window.", @@ -100,9 +101,7 @@ await _emailSender.SendAsync( }, cancellationToken); - _dispatchRecords[endpoint.Id] = new NotificationDispatchRecord( - notification.Signature, - _timeProvider.GetUtcNow()); + AppendNotificationDispatch(currentState, notification, recipients); _logger.LogInformation( "Sent {NotificationEventType} email notification for endpoint {EndpointId} to {ToCount} recipient(s).", @@ -207,19 +206,49 @@ private NotificationCondition DescribeCondition(EndpointState? state) return null; } - private bool IsWithinCooldown(string endpointId, string signature, int cooldownMinutes) + private bool IsWithinCooldown(EndpointState currentState, string signature, int cooldownMinutes) { - if (!_dispatchRecords.TryGetValue(endpointId, out var record)) + var record = currentState.NotificationDispatches + .OrderByDescending(static dispatch => dispatch.SentUtc) + .FirstOrDefault(dispatch => string.Equals(dispatch.Signature, signature, StringComparison.OrdinalIgnoreCase)); + + if (record is null) { return false; } - if (!string.Equals(record.Signature, signature, StringComparison.OrdinalIgnoreCase)) + return (_timeProvider.GetUtcNow() - record.SentUtc) < TimeSpan.FromMinutes(cooldownMinutes); + } + + private void AppendNotificationDispatch( + EndpointState currentState, + NotificationDecision notification, + NotificationRecipients recipients) + { + currentState.NotificationDispatches.Add(new EndpointNotificationDispatch { - return false; + EventType = notification.EventType, + ConditionLabel = notification.SubjectLabel, + Signature = notification.Signature, + SentUtc = _timeProvider.GetUtcNow(), + To = [.. recipients.To], + Cc = [.. recipients.Cc] + }); + + var historyLimit = _runtimeStateOptions.GetNotificationHistoryLimit(); + if (historyLimit <= 0) + { + currentState.NotificationDispatches.Clear(); + return; } - return (_timeProvider.GetUtcNow() - record.SentUtc) < TimeSpan.FromMinutes(cooldownMinutes); + if (currentState.NotificationDispatches.Count <= historyLimit) + { + return; + } + + var removeCount = currentState.NotificationDispatches.Count - historyLimit; + currentState.NotificationDispatches.RemoveRange(0, removeCount); } private static string BuildSubject(string subjectPrefix, string eventType, string endpointName, string label) @@ -265,8 +294,6 @@ private static string BuildBody( return builder.ToString().TrimEnd(); } - private sealed record NotificationDispatchRecord(string Signature, DateTimeOffset SentUtc); - private sealed record NotificationRecipients(IReadOnlyList To, IReadOnlyList Cc); private sealed record NotificationDecision(string EventType, string SubjectLabel, string Signature); diff --git a/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs index e2b55c8..8b03388 100644 --- a/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs +++ b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs @@ -356,6 +356,8 @@ private sealed class PersistedEndpointState public List RecentSamples { get; set; } = new(); + public List NotificationDispatches { get; set; } = new(); + public static PersistedEndpointState FromRuntimeState(EndpointState state) { return new PersistedEndpointState @@ -368,7 +370,8 @@ public static PersistedEndpointState FromRuntimeState(EndpointState state) DurationMs = state.DurationMs, LastError = state.LastError, Snapshot = state.Snapshot?.Clone(), - RecentSamples = state.RecentSamples.Select(static sample => sample.Clone()).ToList() + RecentSamples = state.RecentSamples.Select(static sample => sample.Clone()).ToList(), + NotificationDispatches = state.NotificationDispatches.Select(static dispatch => dispatch.Clone()).ToList() }; } @@ -385,6 +388,7 @@ public EndpointState ToRuntimeState() LastError = LastError, Snapshot = Snapshot?.Clone(), RecentSamples = RecentSamples.Select(static sample => sample.Clone()).ToList(), + NotificationDispatches = NotificationDispatches.Select(static dispatch => dispatch.Clone()).ToList(), IsPolling = false }; } diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index b242871..f2cbceb 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -9,7 +9,8 @@ "CleanupIntervalMinutes": 30, "DeleteOrphanedStateFiles": true, "OrphanedStateFileRetentionHours": 5, - "RecentSampleLimit": 25 + "RecentSampleLimit": 25, + "NotificationHistoryLimit": 20 }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index eaf5591..8cb69cf 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -9,7 +9,8 @@ "CleanupIntervalMinutes": 0.5, "DeleteOrphanedStateFiles": true, "OrphanedStateFileRetentionHours": 5, - "RecentSampleLimit": 25 + "RecentSampleLimit": 25, + "NotificationHistoryLimit": 20 }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index be4ee29..e5f505c 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -589,6 +589,18 @@ body { gap: 0.85rem; } +.notification-history-list { + display: grid; + gap: 0.85rem; +} + +.notification-history-item { + padding: 0.9rem 1rem; + border-radius: 0.9rem; + background: rgba(15, 23, 42, 0.035); + border: 1px solid rgba(31, 41, 55, 0.08); +} + .recent-trend-card { padding: 0.9rem 1rem; border-radius: 0.9rem; diff --git a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs index 4d60881..b8a8180 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs @@ -114,6 +114,18 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() ErrorSummary = "Database latency exceeded threshold." } ], + NotificationDispatches = + [ + new EndpointNotificationDispatch + { + EventType = "Alert", + ConditionLabel = "Degraded", + Signature = "alert:degraded", + SentUtc = new DateTimeOffset(2026, 03, 18, 8, 31, 0, TimeSpan.Zero), + To = ["ops@example.com"], + Cc = ["lead@example.com"] + } + ], Snapshot = new HealthSnapshot { OverallStatus = "Degraded", @@ -180,6 +192,9 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() Assert.Single(model.Endpoint.RecentStatusTransitions); Assert.Equal("Healthy", model.Endpoint.RecentStatusTransitions[0].FromStatus); Assert.Equal("Degraded", model.Endpoint.RecentStatusTransitions[0].ToStatus); + var notificationDispatch = Assert.Single(model.Endpoint.NotificationDispatches); + Assert.Equal("Alert", notificationDispatch.EventType); + Assert.Contains("ops@example.com", notificationDispatch.RecipientSummary, StringComparison.Ordinal); Assert.Equal(2, model.Endpoint.RecentSamples.Count); Assert.Equal( "{\n \"status\": \"Degraded\"\n}", diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs index a66a74c..83c5bf8 100644 --- a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs @@ -74,6 +74,9 @@ public async Task NotifyAsync_WhenEndpointEntersFailingState_SendsAlertEmail() Assert.Contains("Failing", message.Subject, StringComparison.Ordinal); Assert.Equal(["ops@example.com", "service-owner@example.com"], message.To.OrderBy(static value => value).ToArray()); Assert.Equal(["lead@example.com", "teamlead@example.com"], message.Cc.OrderBy(static value => value).ToArray()); + var dispatch = Assert.Single(currentState.NotificationDispatches); + Assert.Equal("Alert", dispatch.EventType); + Assert.Equal("Failing", dispatch.ConditionLabel); } [Fact] @@ -141,6 +144,7 @@ public async Task NotifyAsync_WhenEndpointRecovers_SendsRecoveryEmail() var message = Assert.Single(sender.Messages); Assert.Contains("Recovery", message.Subject, StringComparison.Ordinal); + Assert.Single(currentState.NotificationDispatches); } [Fact] @@ -186,12 +190,17 @@ public async Task NotifyAsync_WhenEndpointIsAlreadyFailingAndNoPriorNotification var message = Assert.Single(sender.Messages); Assert.Contains("Alert", message.Subject, StringComparison.Ordinal); Assert.Contains("Failing", message.Subject, StringComparison.Ordinal); + Assert.Single(currentState.NotificationDispatches); } private static EndpointEmailNotificationService CreateService(DashboardConfig config, FakeEmailSender sender) { return new EndpointEmailNotificationService( config, + new RuntimeStateOptions + { + NotificationHistoryLimit = 20 + }, new SmtpEmailOptions { Enabled = true, diff --git a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs index 669aa8a..f8b68de 100644 --- a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs +++ b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs @@ -44,6 +44,18 @@ public void Upsert_PersistsCurrentStateToPerEndpointFile() ResultKind = "Success" } ], + NotificationDispatches = + [ + new EndpointNotificationDispatch + { + EventType = "Alert", + ConditionLabel = "Failing", + Signature = "alert:failing", + SentUtc = DateTimeOffset.Parse("2026-03-18T15:00:30Z"), + To = ["ops@example.com"], + Cc = ["lead@example.com"] + } + ], Snapshot = new HealthSnapshot { OverallStatus = "Healthy", @@ -77,6 +89,7 @@ public void Upsert_PersistsCurrentStateToPerEndpointFile() Assert.Contains("\"status\":\"Healthy\"", json); Assert.Contains("\"endpointId\":\"orders-api\"", json); Assert.Contains("\"recentSamples\":[", json); + Assert.Contains("\"notificationDispatches\":[", json); Assert.DoesNotContain("isPolling", json, StringComparison.OrdinalIgnoreCase); } @@ -116,6 +129,17 @@ public void Constructor_RestoresPersistedStateForConfiguredEndpoint() ErrorSummary = "Slow dependency" } ], + NotificationDispatches = + [ + new EndpointNotificationDispatch + { + EventType = "Alert", + ConditionLabel = "Degraded", + Signature = "alert:degraded", + SentUtc = DateTimeOffset.Parse("2026-03-18T16:01:00Z"), + To = ["ops@example.com"] + } + ], Snapshot = new HealthSnapshot { OverallStatus = "Degraded", @@ -141,6 +165,9 @@ public void Constructor_RestoresPersistedStateForConfiguredEndpoint() Assert.False(restoredState.IsPolling); Assert.Equal(2, restoredState.RecentSamples.Count); Assert.Equal("Degraded", restoredState.RecentSamples[^1].Status); + var restoredDispatch = Assert.Single(restoredState.NotificationDispatches); + Assert.Equal("Alert", restoredDispatch.EventType); + Assert.Equal("Degraded", restoredDispatch.ConditionLabel); } [Fact] From 0c22a7650064912595c95b1325f2401b41b06794 Mon Sep 17 00:00:00 2001 From: gali Date: Fri, 20 Mar 2026 12:37:32 +0800 Subject: [PATCH 14/15] feat: add support for 'Stabilized' email notifications and enhance trend analysis logic --- README.md | 1 + .../EndpointEmailNotificationService.cs | 44 +++- .../Statistics/RecentPollTrendAnalyzer.cs | 7 + .../EndpointEmailNotificationServiceTests.cs | 200 ++++++++++++++++++ .../RecentPollTrendAnalyzerTests.cs | 56 +++++ 5 files changed, 302 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index e6b2466..1ab5dca 100644 --- a/README.md +++ b/README.md @@ -254,6 +254,7 @@ Current email notification behavior: - merges global dashboard recipients with endpoint-specific `notificationEmails` and `notificationCc` - sends alert emails when an endpoint enters or changes problem state - can send recovery emails when an endpoint returns to a non-problem state +- can send a one-time `Stabilized` email when an endpoint settles into a `Stable ...` trend after recovering - treats repeated transport failures as a `Failing` condition for alerting purposes - records successful dispatches in endpoint runtime state so notification cooldown and history survive restarts diff --git a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs index 0046883..78ca277 100644 --- a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs +++ b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs @@ -187,11 +187,12 @@ private NotificationCondition DescribeCondition(EndpointState? state) { if (!hasExistingDispatchRecord || !previousCondition.IsProblem || - !string.Equals(previousCondition.Label, currentCondition.Label, StringComparison.OrdinalIgnoreCase)) + !string.Equals(previousCondition.Label, currentCondition.Label, StringComparison.OrdinalIgnoreCase) || + !string.Equals(previousCondition.TrendLabel, currentCondition.TrendLabel, StringComparison.OrdinalIgnoreCase)) { return new NotificationDecision( "Alert", - currentCondition.Label, + currentCondition.SubjectLabel, $"alert:{currentCondition.Label}:{currentCondition.Status}:{currentCondition.TrendLabel}"); } } @@ -199,13 +200,36 @@ private NotificationCondition DescribeCondition(EndpointState? state) { return new NotificationDecision( "Recovery", - currentCondition.Label, + currentCondition.SubjectLabel, $"recovery:{previousCondition.Label}:{currentCondition.Status}"); } + else if (settings.NotifyOnRecovery && + hasExistingDispatchRecord && + IsStableTransition(previousCondition, currentCondition)) + { + return new NotificationDecision( + "Stabilized", + currentCondition.TrendLabel, + $"stabilized:{currentCondition.Status}:{currentCondition.TrendLabel}"); + } return null; } + private static bool IsStableTransition(NotificationCondition previousCondition, NotificationCondition currentCondition) + { + return !previousCondition.IsProblem && + !currentCondition.IsProblem && + !IsStableTrend(previousCondition.TrendLabel) && + IsStableTrend(currentCondition.TrendLabel); + } + + private static bool IsStableTrend(string? trendLabel) + { + return !string.IsNullOrWhiteSpace(trendLabel) && + trendLabel.StartsWith("Stable ", StringComparison.OrdinalIgnoreCase); + } + private bool IsWithinCooldown(EndpointState currentState, string signature, int cooldownMinutes) { var record = currentState.NotificationDispatches @@ -287,9 +311,12 @@ private static string BuildBody( } builder.AppendLine(); - builder.AppendLine(notification.EventType == "Recovery" - ? "The endpoint has recovered from its previous problem state." - : "The endpoint entered or changed problem state and may need attention."); + builder.AppendLine(notification.EventType switch + { + "Recovery" => "The endpoint has recovered from its previous problem state.", + "Stabilized" => "The endpoint has settled into a stable state after recovering from an earlier issue.", + _ => "The endpoint entered or changed problem state and may need attention." + }); return builder.ToString().TrimEnd(); } @@ -306,6 +333,11 @@ private sealed record NotificationCondition( string? ErrorSummary, DateTimeOffset? CheckedUtc) { + public string SubjectLabel => string.Equals(Label, TrendLabel, StringComparison.OrdinalIgnoreCase) || + string.Equals(Status, TrendLabel, StringComparison.OrdinalIgnoreCase) + ? Label + : $"{Label} ({TrendLabel})"; + public static NotificationCondition Empty { get; } = new(false, string.Empty, "Unknown", "Awaiting trend", null, null); } } diff --git a/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs index a0520c3..8140088 100644 --- a/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs +++ b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs @@ -4,6 +4,8 @@ namespace ApiHealthDashboard.Statistics; public static class RecentPollTrendAnalyzer { + private const int SettledStatusStreakLength = 4; + public static RecentPollTrendAnalysis Analyze(IEnumerable samples) { ArgumentNullException.ThrowIfNull(samples); @@ -68,6 +70,11 @@ private static RecentPollTrendKind ResolveTrendKind( var currentStreakLength = GetTrailingStatusStreakLength(orderedSamples, currentStatus); var previousDifferentStatus = GetPreviousDifferentStatus(orderedSamples, currentStatus); + if (currentStreakLength >= SettledStatusStreakLength) + { + return RecentPollTrendKind.Stable; + } + if (currentStreakLength >= 2 && previousDifferentStatus is not null) { var previousRank = GetStatusRank(previousDifferentStatus); diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs index 83c5bf8..601cc96 100644 --- a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs @@ -193,6 +193,206 @@ public async Task NotifyAsync_WhenEndpointIsAlreadyFailingAndNoPriorNotification Assert.Single(currentState.NotificationDispatches); } + [Fact] + public async Task NotifyAsync_WhenProblemTrendChangesFromImprovingToWorsening_SendsNewAlert() + { + var config = CreateConfig(); + var sender = new FakeEmailSender(); + var service = CreateService(config, sender); + + var previousState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Degraded", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T02:00:00Z"), + LastError = "Latency exceeded threshold", + NotificationDispatches = + [ + new EndpointNotificationDispatch + { + EventType = "Alert", + ConditionLabel = "Degraded (Improving)", + Signature = "alert:Degraded:Degraded:Improving", + SentUtc = DateTimeOffset.Parse("2026-03-19T01:30:00Z"), + To = ["ops@example.com"] + } + ], + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:58:00Z"), + Status = "Unhealthy", + DurationMs = 140, + ResultKind = "Success", + ErrorSummary = "Latency exceeded threshold" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T01:59:00Z"), + Status = "Degraded", + DurationMs = 120, + ResultKind = "Success", + ErrorSummary = "Latency exceeded threshold" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:00:00Z"), + Status = "Degraded", + DurationMs = 115, + ResultKind = "Success", + ErrorSummary = "Latency exceeded threshold" + } + ] + }; + + var currentState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Degraded", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T02:05:00Z"), + LastError = "Latency exceeded threshold", + NotificationDispatches = previousState.NotificationDispatches.Select(static dispatch => dispatch.Clone()).ToList(), + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:03:00Z"), + Status = "Healthy", + DurationMs = 90, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:04:00Z"), + Status = "Degraded", + DurationMs = 125, + ResultKind = "Success", + ErrorSummary = "Latency exceeded threshold" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:05:00Z"), + Status = "Degraded", + DurationMs = 140, + ResultKind = "Success", + ErrorSummary = "Latency exceeded threshold" + } + ] + }; + + await service.NotifyAsync(config.Endpoints[0], previousState, currentState); + + var message = Assert.Single(sender.Messages); + Assert.Contains("Alert", message.Subject, StringComparison.Ordinal); + Assert.Contains("Worsening", message.Subject, StringComparison.Ordinal); + Assert.Equal(2, currentState.NotificationDispatches.Count); + } + + [Fact] + public async Task NotifyAsync_WhenRecoveredEndpointSettlesFromImprovingToStable_SendsStabilizedEmail() + { + var config = CreateConfig(); + var sender = new FakeEmailSender(); + var service = CreateService(config, sender); + + var previousState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T02:10:00Z"), + LastSuccessfulUtc = DateTimeOffset.Parse("2026-03-19T02:10:00Z"), + NotificationDispatches = + [ + new EndpointNotificationDispatch + { + EventType = "Recovery", + ConditionLabel = "Healthy (Improving)", + Signature = "recovery:Flapping:Healthy", + SentUtc = DateTimeOffset.Parse("2026-03-19T02:08:00Z"), + To = ["ops@example.com"] + } + ], + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:08:00Z"), + Status = "Unknown", + DurationMs = 200, + ResultKind = "HttpError", + ErrorSummary = "Endpoint returned HTTP 404 (NotFound)." + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:09:00Z"), + Status = "Healthy", + DurationMs = 80, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:10:00Z"), + Status = "Healthy", + DurationMs = 82, + ResultKind = "Success" + } + ] + }; + + var currentState = new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy", + LastCheckedUtc = DateTimeOffset.Parse("2026-03-19T02:12:00Z"), + LastSuccessfulUtc = DateTimeOffset.Parse("2026-03-19T02:12:00Z"), + NotificationDispatches = previousState.NotificationDispatches.Select(static dispatch => dispatch.Clone()).ToList(), + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:09:00Z"), + Status = "Healthy", + DurationMs = 80, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:10:00Z"), + Status = "Healthy", + DurationMs = 82, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:11:00Z"), + Status = "Healthy", + DurationMs = 79, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-19T02:12:00Z"), + Status = "Healthy", + DurationMs = 81, + ResultKind = "Success" + } + ] + }; + + await service.NotifyAsync(config.Endpoints[0], previousState, currentState); + + var message = Assert.Single(sender.Messages); + Assert.Contains("Stabilized", message.Subject, StringComparison.Ordinal); + Assert.Contains("Stable Healthy", message.Subject, StringComparison.Ordinal); + Assert.Equal(2, currentState.NotificationDispatches.Count); + Assert.Equal("Stabilized", currentState.NotificationDispatches[^1].EventType); + } + private static EndpointEmailNotificationService CreateService(DashboardConfig config, FakeEmailSender sender) { return new EndpointEmailNotificationService( diff --git a/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs index d0748bc..853959c 100644 --- a/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs +++ b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs @@ -106,4 +106,60 @@ public void Analyze_WhenRecentSamplesSettleIntoBetterStatus_ChangesFromFlappingT Assert.Equal(RecentPollTrendKind.Improving, result.TrendKind); } + + [Fact] + public void Analyze_WhenImprovingStreakSettlesIntoSameStatus_ReturnsStable() + { + var samples = new[] + { + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 0, 0, TimeSpan.Zero), + Status = "Unknown", + DurationMs = 90, + ResultKind = "HttpError", + ErrorSummary = "Endpoint returned HTTP 404 (NotFound)." + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 1, 0, TimeSpan.Zero), + Status = "Unknown", + DurationMs = 92, + ResultKind = "HttpError", + ErrorSummary = "Endpoint returned HTTP 404 (NotFound)." + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 2, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 30, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 3, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 29, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 4, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 28, + ResultKind = "Success" + }, + new RecentPollSample + { + CheckedUtc = new DateTimeOffset(2026, 03, 19, 0, 5, 0, TimeSpan.Zero), + Status = "Healthy", + DurationMs = 27, + ResultKind = "Success" + } + }; + + var result = RecentPollTrendAnalyzer.Analyze(samples); + + Assert.Equal(RecentPollTrendKind.Stable, result.TrendKind); + } } From 82b2debea6573e1a4155f7cb9a3a97da86181669 Mon Sep 17 00:00:00 2001 From: gali Date: Fri, 20 Mar 2026 15:51:21 +0800 Subject: [PATCH 15/15] feat: implement email notification templates and enhance email sending logic --- README.md | 4 + .../ApiHealthDashboard.csproj | 7 + .../Configuration/EmailTemplateOptions.cs | 23 +++ src/ApiHealthDashboard/Program.cs | 5 + .../EndpointEmailNotificationService.cs | 66 ++++---- .../Services/IEmailSender.cs | 4 +- .../NotificationEmailTemplateRenderer.cs | 141 ++++++++++++++++++ .../Services/SmtpEmailSender.cs | 22 ++- .../Templates/Email/notification.html | 60 ++++++++ .../Templates/Email/notification.txt | 13 ++ .../appsettings.Development.json | 15 +- src/ApiHealthDashboard/appsettings.json | 7 +- src/ApiHealthDashboard/dashboard.yaml | 1 + .../endpoints/utpl-integration-api.yaml | 15 ++ .../EndpointEmailNotificationServiceTests.cs | 15 ++ .../NotificationEmailTemplateRendererTests.cs | 83 +++++++++++ 16 files changed, 434 insertions(+), 47 deletions(-) create mode 100644 src/ApiHealthDashboard/Configuration/EmailTemplateOptions.cs create mode 100644 src/ApiHealthDashboard/Services/NotificationEmailTemplateRenderer.cs create mode 100644 src/ApiHealthDashboard/Templates/Email/notification.html create mode 100644 src/ApiHealthDashboard/Templates/Email/notification.txt create mode 100644 src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml create mode 100644 tests/ApiHealthDashboard.Tests/Services/NotificationEmailTemplateRendererTests.cs diff --git a/README.md b/README.md index 1ab5dca..2a25cd5 100644 --- a/README.md +++ b/README.md @@ -250,11 +250,13 @@ The app now supports SMTP email notifications with dashboard-level defaults and Current email notification behavior: - uses SMTP settings from appsettings under `Email:Smtp` +- uses file-based email templates from `Email:Templates` with defaults under [`src/ApiHealthDashboard/Templates/Email`](src/ApiHealthDashboard/Templates/Email) - uses dashboard YAML settings to enable notifications, set recovery behavior, cooldown, minimum priority, subject prefix, and default recipients - merges global dashboard recipients with endpoint-specific `notificationEmails` and `notificationCc` - sends alert emails when an endpoint enters or changes problem state - can send recovery emails when an endpoint returns to a non-problem state - can send a one-time `Stabilized` email when an endpoint settles into a `Stable ...` trend after recovering +- sends multipart email with HTML content plus a plain-text fallback body - treats repeated transport failures as a `Failing` condition for alerting purposes - records successful dispatches in endpoint runtime state so notification cooldown and history survive restarts @@ -357,6 +359,8 @@ Runtime state persistence is configured through `RuntimeState:Enabled` and `Runt SMTP email delivery is configured through `Email:Smtp` in the same appsettings files. Keep secrets such as SMTP usernames and passwords in environment variables or deployment-time configuration rather than committing them to source control. +Email template rendering is configured through `Email:Templates` in the same appsettings files. By default, the app loads [`notification.txt`](src/ApiHealthDashboard/Templates/Email/notification.txt) and [`notification.html`](src/ApiHealthDashboard/Templates/Email/notification.html) from `Templates/Email` under the app content root. + Current cleanup settings: - `RuntimeState:CleanupEnabled` to enable periodic runtime-state cleanup - `RuntimeState:CleanupIntervalMinutes` to control how often orphan cleanup runs diff --git a/src/ApiHealthDashboard/ApiHealthDashboard.csproj b/src/ApiHealthDashboard/ApiHealthDashboard.csproj index fb26f3a..f0aa798 100644 --- a/src/ApiHealthDashboard/ApiHealthDashboard.csproj +++ b/src/ApiHealthDashboard/ApiHealthDashboard.csproj @@ -18,4 +18,11 @@ + + + PreserveNewest + PreserveNewest + + + diff --git a/src/ApiHealthDashboard/Configuration/EmailTemplateOptions.cs b/src/ApiHealthDashboard/Configuration/EmailTemplateOptions.cs new file mode 100644 index 0000000..3216ffc --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/EmailTemplateOptions.cs @@ -0,0 +1,23 @@ +namespace ApiHealthDashboard.Configuration; + +public sealed class EmailTemplateOptions +{ + public const string SectionName = "Email:Templates"; + + public string DirectoryPath { get; set; } = "Templates/Email"; + + public string TextTemplateFileName { get; set; } = "notification.txt"; + + public string HtmlTemplateFileName { get; set; } = "notification.html"; + + public string ResolveDirectoryPath(string contentRootPath) + { + var configuredPath = string.IsNullOrWhiteSpace(DirectoryPath) + ? "Templates/Email" + : DirectoryPath.Trim(); + + return Path.IsPathRooted(configuredPath) + ? Path.GetFullPath(configuredPath) + : Path.GetFullPath(Path.Combine(contentRootPath, configuredPath)); + } +} diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index 10b4b48..4e8431d 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -31,10 +31,14 @@ builder.Configuration.GetSection(RuntimeStateOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(SmtpEmailOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(EmailTemplateOptions.SectionName)); builder.Services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService>().Value); builder.Services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService>().Value); +builder.Services.AddSingleton(static serviceProvider => + serviceProvider.GetRequiredService>().Value); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => @@ -121,6 +125,7 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); diff --git a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs index 78ca277..f2bf324 100644 --- a/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs +++ b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs @@ -1,4 +1,3 @@ -using System.Text; using ApiHealthDashboard.Configuration; using ApiHealthDashboard.Domain; using ApiHealthDashboard.Statistics; @@ -9,6 +8,7 @@ public sealed class EndpointEmailNotificationService : IEndpointNotificationServ { private readonly DashboardConfig _dashboardConfig; private readonly IEmailSender _emailSender; + private readonly INotificationEmailTemplateRenderer _templateRenderer; private readonly ILogger _logger; private readonly RuntimeStateOptions _runtimeStateOptions; private readonly SmtpEmailOptions _smtpOptions; @@ -19,6 +19,7 @@ public EndpointEmailNotificationService( RuntimeStateOptions runtimeStateOptions, SmtpEmailOptions smtpOptions, IEmailSender emailSender, + INotificationEmailTemplateRenderer templateRenderer, TimeProvider timeProvider, ILogger logger) { @@ -26,6 +27,7 @@ public EndpointEmailNotificationService( _runtimeStateOptions = runtimeStateOptions; _smtpOptions = smtpOptions; _emailSender = emailSender; + _templateRenderer = templateRenderer; _timeProvider = timeProvider; _logger = logger; } @@ -89,7 +91,7 @@ public async Task NotifyAsync( } var subject = BuildSubject(notificationSettings.SubjectPrefix, notification.EventType, endpoint.Name, notification.SubjectLabel); - var body = BuildBody(endpoint, previousCondition, currentCondition, notification); + var body = BuildBody(endpoint, previousCondition, currentCondition, notification, subject); await _emailSender.SendAsync( new EmailMessage @@ -97,7 +99,8 @@ await _emailSender.SendAsync( To = recipients.To, Cc = recipients.Cc, Subject = subject, - Body = body + TextBody = body.TextBody, + HtmlBody = body.HtmlBody }, cancellationToken); @@ -281,44 +284,33 @@ private static string BuildSubject(string subjectPrefix, string eventType, strin return $"{prefix} {eventType}: {endpointName} - {label}"; } - private static string BuildBody( + private NotificationEmailContent BuildBody( EndpointConfig endpoint, NotificationCondition previousCondition, NotificationCondition currentCondition, - NotificationDecision notification) + NotificationDecision notification, + string subject) { - var builder = new StringBuilder(); - builder.AppendLine($"Endpoint: {endpoint.Name} ({endpoint.Id})"); - builder.AppendLine($"URL: {endpoint.Url}"); - builder.AppendLine($"Priority: {EndpointPriority.Normalize(endpoint.Priority)}"); - builder.AppendLine($"Event: {notification.EventType}"); - builder.AppendLine($"Current status: {currentCondition.Status}"); - builder.AppendLine($"Current trend: {currentCondition.TrendLabel}"); - - if (!string.IsNullOrWhiteSpace(previousCondition.Label)) - { - builder.AppendLine($"Previous condition: {previousCondition.Label}"); - } - - if (!string.IsNullOrWhiteSpace(currentCondition.ErrorSummary)) - { - builder.AppendLine($"Error: {currentCondition.ErrorSummary}"); - } - - if (currentCondition.CheckedUtc is DateTimeOffset checkedUtc) - { - builder.AppendLine($"Checked: {checkedUtc.ToUniversalTime():yyyy-MM-dd HH:mm:ss 'UTC'}"); - } - - builder.AppendLine(); - builder.AppendLine(notification.EventType switch - { - "Recovery" => "The endpoint has recovered from its previous problem state.", - "Stabilized" => "The endpoint has settled into a stable state after recovering from an earlier issue.", - _ => "The endpoint entered or changed problem state and may need attention." - }); - - return builder.ToString().TrimEnd(); + return _templateRenderer.Render(new NotificationEmailTemplateModel( + subject, + notification.EventType, + endpoint.Name, + endpoint.Id, + endpoint.Url, + EndpointPriority.Normalize(endpoint.Priority), + currentCondition.Status, + currentCondition.TrendLabel, + previousCondition.Label, + currentCondition.ErrorSummary ?? string.Empty, + currentCondition.CheckedUtc is DateTimeOffset checkedUtc + ? checkedUtc.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") + : "-", + notification.EventType switch + { + "Recovery" => "The endpoint has recovered from its previous problem state.", + "Stabilized" => "The endpoint has settled into a stable state after recovering from an earlier issue.", + _ => "The endpoint entered or changed problem state and may need attention." + })); } private sealed record NotificationRecipients(IReadOnlyList To, IReadOnlyList Cc); diff --git a/src/ApiHealthDashboard/Services/IEmailSender.cs b/src/ApiHealthDashboard/Services/IEmailSender.cs index 1709328..e004d27 100644 --- a/src/ApiHealthDashboard/Services/IEmailSender.cs +++ b/src/ApiHealthDashboard/Services/IEmailSender.cs @@ -13,5 +13,7 @@ public sealed class EmailMessage public required string Subject { get; init; } - public required string Body { get; init; } + public required string TextBody { get; init; } + + public string? HtmlBody { get; init; } } diff --git a/src/ApiHealthDashboard/Services/NotificationEmailTemplateRenderer.cs b/src/ApiHealthDashboard/Services/NotificationEmailTemplateRenderer.cs new file mode 100644 index 0000000..3acae1a --- /dev/null +++ b/src/ApiHealthDashboard/Services/NotificationEmailTemplateRenderer.cs @@ -0,0 +1,141 @@ +using System.Net; +using ApiHealthDashboard.Configuration; + +namespace ApiHealthDashboard.Services; + +public interface INotificationEmailTemplateRenderer +{ + NotificationEmailContent Render(NotificationEmailTemplateModel model); +} + +public sealed class NotificationEmailTemplateRenderer : INotificationEmailTemplateRenderer +{ + private readonly string _htmlTemplatePath; + private readonly ILogger _logger; + private readonly string _textTemplatePath; + + public NotificationEmailTemplateRenderer( + EmailTemplateOptions options, + IHostEnvironment environment, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(environment); + + _logger = logger; + + var templateDirectory = options.ResolveDirectoryPath(environment.ContentRootPath); + _textTemplatePath = Path.Combine(templateDirectory, options.TextTemplateFileName); + _htmlTemplatePath = Path.Combine(templateDirectory, options.HtmlTemplateFileName); + } + + public NotificationEmailContent Render(NotificationEmailTemplateModel model) + { + ArgumentNullException.ThrowIfNull(model); + + var textBody = RenderTemplate(_textTemplatePath, BuildTokenMap(model, encodeHtml: false)) + ?? BuildFallbackTextBody(model); + var htmlBody = RenderTemplate(_htmlTemplatePath, BuildTokenMap(model, encodeHtml: true)); + + return new NotificationEmailContent(textBody, htmlBody); + } + + private string? RenderTemplate(string templatePath, IReadOnlyDictionary tokens) + { + try + { + if (!File.Exists(templatePath)) + { + _logger.LogWarning( + "Notification email template file {TemplatePath} was not found. Falling back to built-in content where available.", + templatePath); + return null; + } + + var template = File.ReadAllText(templatePath); + return ApplyTokens(template, tokens); + } + catch (Exception ex) + { + _logger.LogWarning( + ex, + "Failed to render notification email template {TemplatePath}. Falling back to built-in content where available.", + templatePath); + return null; + } + } + + private static string ApplyTokens(string template, IReadOnlyDictionary tokens) + { + var rendered = template; + + foreach (var token in tokens) + { + rendered = rendered.Replace($"{{{{{token.Key}}}}}", token.Value, StringComparison.Ordinal); + } + + return rendered; + } + + private static Dictionary BuildTokenMap(NotificationEmailTemplateModel model, bool encodeHtml) + { + string Sanitize(string? value) + { + var normalized = string.IsNullOrWhiteSpace(value) ? "-" : value.Trim(); + return encodeHtml ? WebUtility.HtmlEncode(normalized) : normalized; + } + + return new Dictionary(StringComparer.Ordinal) + { + ["Subject"] = Sanitize(model.Subject), + ["EventType"] = Sanitize(model.EventType), + ["EndpointName"] = Sanitize(model.EndpointName), + ["EndpointId"] = Sanitize(model.EndpointId), + ["EndpointUrl"] = Sanitize(model.EndpointUrl), + ["Priority"] = Sanitize(model.Priority), + ["CurrentStatus"] = Sanitize(model.CurrentStatus), + ["CurrentTrend"] = Sanitize(model.CurrentTrend), + ["PreviousCondition"] = Sanitize(model.PreviousCondition), + ["ErrorSummary"] = Sanitize(model.ErrorSummary), + ["CheckedUtc"] = Sanitize(model.CheckedUtcText), + ["SummaryText"] = Sanitize(model.SummaryText) + }; + } + + private static string BuildFallbackTextBody(NotificationEmailTemplateModel model) + { + var previousCondition = string.IsNullOrWhiteSpace(model.PreviousCondition) ? "-" : model.PreviousCondition; + var errorSummary = string.IsNullOrWhiteSpace(model.ErrorSummary) ? "-" : model.ErrorSummary; + + return +$"""" +Endpoint: {model.EndpointName} ({model.EndpointId}) +URL: {model.EndpointUrl} +Priority: {model.Priority} +Event: {model.EventType} +Current status: {model.CurrentStatus} +Current trend: {model.CurrentTrend} +Previous condition: {previousCondition} +Error: {errorSummary} +Checked: {model.CheckedUtcText} + +{model.SummaryText} +""""; + } +} + +public sealed record NotificationEmailContent(string TextBody, string? HtmlBody); + +public sealed record NotificationEmailTemplateModel( + string Subject, + string EventType, + string EndpointName, + string EndpointId, + string EndpointUrl, + string Priority, + string CurrentStatus, + string CurrentTrend, + string PreviousCondition, + string ErrorSummary, + string CheckedUtcText, + string SummaryText); diff --git a/src/ApiHealthDashboard/Services/SmtpEmailSender.cs b/src/ApiHealthDashboard/Services/SmtpEmailSender.cs index 30f5db6..eb32a6b 100644 --- a/src/ApiHealthDashboard/Services/SmtpEmailSender.cs +++ b/src/ApiHealthDashboard/Services/SmtpEmailSender.cs @@ -1,5 +1,6 @@ using System.Net; using System.Net.Mail; +using System.Net.Mime; using ApiHealthDashboard.Configuration; namespace ApiHealthDashboard.Services; @@ -24,11 +25,26 @@ public async Task SendAsync(EmailMessage message, CancellationToken cancellation using var mailMessage = new MailMessage { From = new MailAddress(_options.FromAddress, _options.FromName), - Subject = message.Subject, - Body = message.Body, - IsBodyHtml = false + Subject = message.Subject }; + if (!string.IsNullOrWhiteSpace(message.HtmlBody)) + { + mailMessage.Body = message.HtmlBody; + mailMessage.IsBodyHtml = true; + mailMessage.AlternateViews.Add(AlternateView.CreateAlternateViewFromString( + message.TextBody, + new ContentType(MediaTypeNames.Text.Plain))); + mailMessage.AlternateViews.Add(AlternateView.CreateAlternateViewFromString( + message.HtmlBody, + new ContentType(MediaTypeNames.Text.Html))); + } + else + { + mailMessage.Body = message.TextBody; + mailMessage.IsBodyHtml = false; + } + foreach (var recipient in message.To) { mailMessage.To.Add(recipient); diff --git a/src/ApiHealthDashboard/Templates/Email/notification.html b/src/ApiHealthDashboard/Templates/Email/notification.html new file mode 100644 index 0000000..e23678d --- /dev/null +++ b/src/ApiHealthDashboard/Templates/Email/notification.html @@ -0,0 +1,60 @@ + + + + + {{Subject}} + + + + + + + + + +
+
ApiHealthDashboard
+
{{Subject}}
+
{{SummaryText}}
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Endpoint{{EndpointName}} ({{EndpointId}})
URL{{EndpointUrl}}
Priority{{Priority}}
Event{{EventType}}
Current status{{CurrentStatus}}
Current trend{{CurrentTrend}}
Previous condition{{PreviousCondition}}
Error{{ErrorSummary}}
Checked{{CheckedUtc}}
+
+ + diff --git a/src/ApiHealthDashboard/Templates/Email/notification.txt b/src/ApiHealthDashboard/Templates/Email/notification.txt new file mode 100644 index 0000000..346739c --- /dev/null +++ b/src/ApiHealthDashboard/Templates/Email/notification.txt @@ -0,0 +1,13 @@ +{{Subject}} + +Endpoint: {{EndpointName}} ({{EndpointId}}) +URL: {{EndpointUrl}} +Priority: {{Priority}} +Event: {{EventType}} +Current status: {{CurrentStatus}} +Current trend: {{CurrentTrend}} +Previous condition: {{PreviousCondition}} +Error: {{ErrorSummary}} +Checked: {{CheckedUtc}} + +{{SummaryText}} diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index f2cbceb..2d1b28e 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -17,14 +17,19 @@ }, "Email": { "Smtp": { - "Enabled": false, - "Host": "", - "Port": 587, - "UseSsl": true, + "Enabled": true, + "Host": "127.0.0.1", + "Port": 2525, + "UseSsl": false, "Username": "", "Password": "", - "FromAddress": "", + "FromAddress": "noreply@mail.com", "FromName": "ApiHealthDashboard" + }, + "Templates": { + "DirectoryPath": "Templates/Email", + "TextTemplateFileName": "notification.txt", + "HtmlTemplateFileName": "notification.html" } }, "DetailedErrors": true, diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index 8cb69cf..60aafb1 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -23,8 +23,13 @@ "UseSsl": false, "Username": "", "Password": "", - "FromAddress": "wmhealthchecks_dev@phillip.com.sg", + "FromAddress": "noreply@mail.com", "FromName": "ApiHealthDashboard" + }, + "Templates": { + "DirectoryPath": "Templates/Email", + "TextTemplateFileName": "notification.txt", + "HtmlTemplateFileName": "notification.html" } }, "Logging": { diff --git a/src/ApiHealthDashboard/dashboard.yaml b/src/ApiHealthDashboard/dashboard.yaml index eaf7939..68cc04e 100644 --- a/src/ApiHealthDashboard/dashboard.yaml +++ b/src/ApiHealthDashboard/dashboard.yaml @@ -16,3 +16,4 @@ dashboard: endpointFiles: - endpoints/orders-api.yaml - endpoints/billing-api.yaml + - endpoints/utpl-integration-api.yaml diff --git a/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml b/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml new file mode 100644 index 0000000..47569c3 --- /dev/null +++ b/src/ApiHealthDashboard/endpoints/utpl-integration-api.yaml @@ -0,0 +1,15 @@ +id: 'utpl-integration-api' +name: 'UTPL Integration API' +url: 'http://10.30.23.166:8114/hc1' +enabled: true +priority: 'High' +frequencySeconds: 5 +timeoutSeconds: 30 +includeChecks: + - 'GlobalConfigApi' + - 'GWMCoreOrderMailingAPI' + - 'GWMDB Database' + - 'LookupApi' + - 'UTPLConfig' +notificationEmails: + - 'gali@phillip.com.sg' \ No newline at end of file diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs index 601cc96..19dd1f2 100644 --- a/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs @@ -72,6 +72,8 @@ public async Task NotifyAsync_WhenEndpointEntersFailingState_SendsAlertEmail() var message = Assert.Single(sender.Messages); Assert.Contains("Alert", message.Subject, StringComparison.Ordinal); Assert.Contains("Failing", message.Subject, StringComparison.Ordinal); + Assert.Contains("Event: Alert", message.TextBody, StringComparison.Ordinal); + Assert.Contains("Alert", message.HtmlBody, StringComparison.Ordinal); Assert.Equal(["ops@example.com", "service-owner@example.com"], message.To.OrderBy(static value => value).ToArray()); Assert.Equal(["lead@example.com", "teamlead@example.com"], message.Cc.OrderBy(static value => value).ToArray()); var dispatch = Assert.Single(currentState.NotificationDispatches); @@ -144,6 +146,7 @@ public async Task NotifyAsync_WhenEndpointRecovers_SendsRecoveryEmail() var message = Assert.Single(sender.Messages); Assert.Contains("Recovery", message.Subject, StringComparison.Ordinal); + Assert.Contains("Event: Recovery", message.TextBody, StringComparison.Ordinal); Assert.Single(currentState.NotificationDispatches); } @@ -389,6 +392,7 @@ public async Task NotifyAsync_WhenRecoveredEndpointSettlesFromImprovingToStable_ var message = Assert.Single(sender.Messages); Assert.Contains("Stabilized", message.Subject, StringComparison.Ordinal); Assert.Contains("Stable Healthy", message.Subject, StringComparison.Ordinal); + Assert.Contains("Event: Stabilized", message.TextBody, StringComparison.Ordinal); Assert.Equal(2, currentState.NotificationDispatches.Count); Assert.Equal("Stabilized", currentState.NotificationDispatches[^1].EventType); } @@ -410,6 +414,7 @@ private static EndpointEmailNotificationService CreateService(DashboardConfig co FromName = "ApiHealthDashboard" }, sender, + new FakeNotificationEmailTemplateRenderer(), TimeProvider.System, NullLogger.Instance); } @@ -458,4 +463,14 @@ public Task SendAsync(EmailMessage message, CancellationToken cancellationToken return Task.CompletedTask; } } + + private sealed class FakeNotificationEmailTemplateRenderer : INotificationEmailTemplateRenderer + { + public NotificationEmailContent Render(NotificationEmailTemplateModel model) + { + return new NotificationEmailContent( + $"Subject: {model.Subject}\nEvent: {model.EventType}\nCurrent status: {model.CurrentStatus}\nCurrent trend: {model.CurrentTrend}\nSummary: {model.SummaryText}", + $"{model.EventType}
{model.EndpointName}
{model.CurrentTrend}
"); + } + } } diff --git a/tests/ApiHealthDashboard.Tests/Services/NotificationEmailTemplateRendererTests.cs b/tests/ApiHealthDashboard.Tests/Services/NotificationEmailTemplateRendererTests.cs new file mode 100644 index 0000000..9e40e43 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Services/NotificationEmailTemplateRendererTests.cs @@ -0,0 +1,83 @@ +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Services; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.FileProviders; + +namespace ApiHealthDashboard.Tests.Services; + +public sealed class NotificationEmailTemplateRendererTests : IDisposable +{ + private readonly string _rootDirectory; + + public NotificationEmailTemplateRendererTests() + { + _rootDirectory = Path.Combine(Path.GetTempPath(), "ApiHealthDashboard.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_rootDirectory); + } + + [Fact] + public void Render_WhenTemplatesExist_RendersTextAndHtmlContent() + { + var templateDirectory = Path.Combine(_rootDirectory, "Templates", "Email"); + Directory.CreateDirectory(templateDirectory); + File.WriteAllText(Path.Combine(templateDirectory, "notification.txt"), "Event: {{EventType}}\nEndpoint: {{EndpointName}}\nSummary: {{SummaryText}}"); + File.WriteAllText(Path.Combine(templateDirectory, "notification.html"), "

{{EventType}}

{{EndpointName}}
{{SummaryText}}
"); + + var renderer = new NotificationEmailTemplateRenderer( + new EmailTemplateOptions(), + new FakeHostEnvironment(_rootDirectory), + NullLogger.Instance); + + var result = renderer.Render(new NotificationEmailTemplateModel( + "Test subject", + "Alert", + "Orders API", + "orders-api", + "https://orders.example.com/health", + "High", + "Unknown", + "Failing", + "Healthy", + "Timed out", + "2026-03-20 12:00:00 UTC", + "The endpoint entered or changed problem state and may need attention.")); + + Assert.Contains("Event: Alert", result.TextBody, StringComparison.Ordinal); + Assert.Contains("Orders API", result.TextBody, StringComparison.Ordinal); + Assert.NotNull(result.HtmlBody); + Assert.Contains("

Alert

", result.HtmlBody, StringComparison.Ordinal); + Assert.Contains("Orders API", result.HtmlBody, StringComparison.Ordinal); + } + + public void Dispose() + { + try + { + if (Directory.Exists(_rootDirectory)) + { + Directory.Delete(_rootDirectory, recursive: true); + } + } + catch + { + } + } + + private sealed class FakeHostEnvironment : IHostEnvironment + { + public FakeHostEnvironment(string contentRootPath) + { + ContentRootPath = contentRootPath; + ContentRootFileProvider = new NullFileProvider(); + } + + public string EnvironmentName { get; set; } = "Development"; + + public string ApplicationName { get; set; } = "ApiHealthDashboard.Tests"; + + public string ContentRootPath { get; set; } + + public IFileProvider ContentRootFileProvider { get; set; } + } +}