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 2351e9e..2a25cd5 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,12 @@ 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 +- 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 +- Post-v1: persisted notification dispatch history in runtime state Not implemented yet: - Backlog items tracked for post-v1 work @@ -84,8 +90,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 @@ -100,20 +109,29 @@ 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) +- [`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) 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` +- 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 @@ -166,6 +184,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 @@ -202,7 +221,11 @@ 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 -- renders a live endpoint table with last check, duration, error summary, and manual refresh actions +- 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 - shows a clearer empty state when no endpoints are configured @@ -214,11 +237,29 @@ 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 +- 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 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 + ### CLI Execution The app now includes a one-shot CLI mode for scripted health execution without starting the web UI. @@ -228,6 +269,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,9 +278,12 @@ 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 +- 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 @@ -310,6 +355,20 @@ 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. + +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 +- `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:NotificationHistoryLimit` to cap how many notification dispatch records are retained per endpoint + You can also override it with an environment variable: ```powershell @@ -455,7 +514,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 @@ -478,8 +537,9 @@ 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 +- 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 add external email API delivery in addition to the current SMTP implementation ## Notes For Ongoing Updates 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/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..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] }; } } @@ -61,12 +95,18 @@ 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(); public List ExcludeChecks { get; set; } = new(); + public List NotificationEmails { get; set; } = new(); + + public List NotificationCc { get; set; } = new(); + public EndpointConfig Clone() { return new EndpointConfig @@ -77,9 +117,12 @@ public EndpointConfig Clone() Enabled = Enabled, FrequencySeconds = FrequencySeconds, TimeoutSeconds = TimeoutSeconds, + 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 08f175c..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]; @@ -58,6 +76,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)) @@ -65,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/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/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/RuntimeStateOptions.cs b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs new file mode 100644 index 0000000..55e5019 --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/RuntimeStateOptions.cs @@ -0,0 +1,59 @@ +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 bool CleanupEnabled { get; set; } = true; + + public double CleanupIntervalMinutes { get; set; } = 30; + + public bool DeleteOrphanedStateFiles { get; set; } = true; + + public double OrphanedStateFileRetentionHours { get; set; } = 5; + + public int RecentSampleLimit { get; set; } = 25; + + public int NotificationHistoryLimit { get; set; } = 20; + + 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)); + } + + public TimeSpan GetCleanupInterval() + { + return CleanupIntervalMinutes <= 0 + ? TimeSpan.Zero + : TimeSpan.FromMinutes(CleanupIntervalMinutes); + } + + public TimeSpan GetOrphanedStateFileRetention() + { + return OrphanedStateFileRetentionHours <= 0 + ? TimeSpan.Zero + : TimeSpan.FromHours(OrphanedStateFileRetentionHours); + } + + public int GetRecentSampleLimit() + { + return Math.Max(RecentSampleLimit, 0); + } + + public int GetNotificationHistoryLimit() + { + return Math.Max(NotificationHistoryLimit, 0); + } +} 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 6969b69..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); @@ -279,11 +284,14 @@ 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); endpoint.IncludeChecks = NormalizeCheckList(endpoint.IncludeChecks); endpoint.ExcludeChecks = NormalizeCheckList(endpoint.ExcludeChecks); + endpoint.NotificationEmails = NormalizeEmailList(endpoint.NotificationEmails); + endpoint.NotificationCc = NormalizeEmailList(endpoint.NotificationCc); endpoints[index] = endpoint; } @@ -315,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( @@ -343,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/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 74c496b..f689b3a 100644 --- a/src/ApiHealthDashboard/Domain/EndpointState.cs +++ b/src/ApiHealthDashboard/Domain/EndpointState.cs @@ -20,6 +20,10 @@ public sealed class EndpointState public bool IsPolling { get; set; } + public List RecentSamples { get; set; } = new(); + + public List NotificationDispatches { get; set; } = new(); + public EndpointState Clone() { return new EndpointState @@ -32,7 +36,9 @@ public EndpointState Clone() DurationMs = DurationMs, LastError = LastError, Snapshot = Snapshot?.Clone(), - IsPolling = IsPolling + IsPolling = IsPolling, + RecentSamples = RecentSamples.Select(static sample => sample.Clone()).ToList(), + NotificationDispatches = NotificationDispatches.Select(static dispatch => dispatch.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 523fdf9..5130364 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
@@ -205,6 +210,51 @@ else
+
+
+

Recent Poll Metrics

+
+
+ @if (!endpoint.HasRecentSamples) + { +
No recent samples have been retained yet for this endpoint.
+ } + else + { +
+
+ @endpoint.RecentTrendText + Last status change: @endpoint.LastStatusChangeText +
+

@endpoint.RecentTrendSummary

+
+ +
+
+ Retained samples +
@endpoint.RecentSampleCount
+
+
+ Recent success rate +
@endpoint.RecentSuccessRateText
+
+
+ Recent failures +
@endpoint.RecentFailureCountText
+
+
+ Average duration +
@endpoint.RecentAverageDurationText
+
+
+ Last status change +
@endpoint.LastStatusChangeText
+
+
+ } +
+
+

Request Filters

@@ -252,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

@@ -316,6 +396,88 @@ 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

+
+
+ @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 ad389ed..975b964 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; @@ -118,6 +119,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; } @@ -138,6 +143,22 @@ 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 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; @@ -176,6 +197,18 @@ public sealed class EndpointDetailsViewModel public bool HasSnapshotMetadata => SnapshotMetadata.Count > 0; + public bool HasRecentSamples => RecentSamples.Count > 0; + + public IReadOnlyList RecentSamples { get; init; } = []; + + public IReadOnlyList RecentStatusTransitions { get; init; } = []; + + 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, @@ -186,6 +219,11 @@ 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); + var trendAnalysis = RecentPollTrendAnalyzer.Analyze(recentSamples); return new EndpointDetailsViewModel { @@ -194,6 +232,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, @@ -204,6 +244,22 @@ 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", + 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) @@ -236,7 +292,22 @@ 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, + RecentStatusTransitions = trendAnalysis.Transitions + .OrderByDescending(static transition => transition.ChangedUtc) + .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) + .Select(CreateRecentSampleViewModel) + .ToArray() }; } @@ -322,6 +393,154 @@ 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" + }; + } + + private static RecentPollSampleViewModel CreateRecentSampleViewModel(RecentPollSample sample) + { + return new RecentPollSampleViewModel + { + CheckedText = FormatDateTime(sample.CheckedUtc), + Status = sample.Status, + StatusBadgeClass = ToBadgeClass(sample.Status), + IndicatorBadgeClass = ToRecentIndicatorClass(sample), + 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" + }; + } + + 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 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 + { + 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 @@ -337,4 +556,51 @@ 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 IndicatorBadgeClass { 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; } + } + + 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; } + } + + 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/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/Pages/Index.cshtml.cs b/src/ApiHealthDashboard/Pages/Index.cshtml.cs index a5d33d9..6e3acac 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; @@ -112,7 +113,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 +132,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 +172,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; } @@ -179,6 +188,22 @@ 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 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; } public string ErrorSummary => string.IsNullOrWhiteSpace(ErrorText) ? "None" : ErrorText; @@ -191,9 +216,16 @@ 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); + var trendAnalysis = RecentPollTrendAnalyzer.Analyze(recentSamples); return new EndpointSummaryViewModel { @@ -203,15 +235,51 @@ 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), 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", + 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) + .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"; @@ -258,5 +326,89 @@ 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" + }; + } + + 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 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 + { + 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 3b9a7a8..262345a 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml @@ -79,7 +79,7 @@
-
+

Endpoint Summary

@@ -93,7 +93,7 @@
@@ -126,9 +126,11 @@ Name Status + Priority Last Checked Last Successful Duration + Recent Signal Frequency Error Actions @@ -144,11 +146,35 @@ endpoint.Name, endpoint.Id, endpoint.Status, + endpoint.Priority, 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) @@ -167,9 +193,34 @@
@endpoint.StatusDescription
} + + @endpoint.Priority + @endpoint.LastCheckedText @endpoint.LastSuccessfulText @endpoint.DurationText + + @if (!endpoint.HasRecentSamples) + { + No recent samples + } + else + { +
+ @foreach (var indicator in endpoint.RecentIndicators) + { + + } +
+
+ @endpoint.RecentTrendText + @endpoint.RecentLastChangeText +
+
@endpoint.RecentTrendSummary
+
@endpoint.RecentSuccessRateText
+
@endpoint.RecentAverageDurationText • @endpoint.RecentFailureCountText
+ } + @endpoint.FrequencyText @if (string.IsNullOrWhiteSpace(endpoint.ErrorText)) @@ -182,7 +233,7 @@ } -
+
-
+

Health Distribution

@@ -259,6 +310,7 @@ @endpoint.Name @endpoint.Status + @endpoint.Priority
Last checked: @endpoint.LastCheckedText
@if (!string.IsNullOrWhiteSpace(endpoint.ErrorText)) diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index 5507054..4e8431d 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -27,6 +27,18 @@ builder.Configuration.GetSection(DashboardBootstrapOptions.SectionName)); builder.Services.Configure( builder.Configuration.GetSection(ImportUiOptions.SectionName)); +builder.Services.Configure( + 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 => @@ -77,21 +89,44 @@ 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, + runtimeStateOptions, + 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)); builder.Services.AddSingleton(); 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 09e4674..fdd66d5 100644 --- a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs +++ b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs @@ -13,8 +13,10 @@ 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; private readonly IEndpointStateStore _stateStore; private readonly TimeProvider _timeProvider; private readonly object _syncRoot = new(); @@ -27,14 +29,18 @@ public PollingSchedulerService( DashboardConfig dashboardConfig, IEndpointStateStore stateStore, IEndpointPoller endpointPoller, + IEndpointNotificationService endpointNotificationService, IHealthResponseParser healthResponseParser, + RuntimeStateOptions runtimeStateOptions, TimeProvider timeProvider, ILogger logger) { _dashboardConfig = dashboardConfig; _stateStore = stateStore; _endpointPoller = endpointPoller; + _endpointNotificationService = endpointNotificationService; _healthResponseParser = healthResponseParser; + _runtimeStateOptions = runtimeStateOptions; _timeProvider = timeProvider; _logger = logger; } @@ -192,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); @@ -238,6 +245,24 @@ private async Task PollEndpointCoreAsync(EndpointConfig endpoint, string trigger updatedState.LastError = result.ErrorMessage; } + AppendRecentSample(updatedState, result); + + try + { + await _endpointNotificationService.NotifyAsync( + endpoint, + previousState, + updatedState, + cancellationToken); + } + catch (Exception ex) + { + _logger.LogError( + ex, + "Failed to process email notifications for endpoint {EndpointId}.", + endpoint.Id); + } + _stateStore.Upsert(updatedState); _logger.LogInformation( @@ -260,6 +285,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/Services/EndpointEmailNotificationService.cs b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs new file mode 100644 index 0000000..f2bf324 --- /dev/null +++ b/src/ApiHealthDashboard/Services/EndpointEmailNotificationService.cs @@ -0,0 +1,335 @@ +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 INotificationEmailTemplateRenderer _templateRenderer; + private readonly ILogger _logger; + private readonly RuntimeStateOptions _runtimeStateOptions; + private readonly SmtpEmailOptions _smtpOptions; + private readonly TimeProvider _timeProvider; + + public EndpointEmailNotificationService( + DashboardConfig dashboardConfig, + RuntimeStateOptions runtimeStateOptions, + SmtpEmailOptions smtpOptions, + IEmailSender emailSender, + INotificationEmailTemplateRenderer templateRenderer, + TimeProvider timeProvider, + ILogger logger) + { + _dashboardConfig = dashboardConfig; + _runtimeStateOptions = runtimeStateOptions; + _smtpOptions = smtpOptions; + _emailSender = emailSender; + _templateRenderer = templateRenderer; + _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 hasExistingDispatchRecord = currentState.NotificationDispatches.Count > 0; + var notification = BuildNotificationDecision( + notificationSettings, + previousCondition, + currentCondition, + hasExistingDispatchRecord); + if (notification is null) + { + return; + } + + if (IsWithinCooldown(currentState, 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, subject); + + await _emailSender.SendAsync( + new EmailMessage + { + To = recipients.To, + Cc = recipients.Cc, + Subject = subject, + TextBody = body.TextBody, + HtmlBody = body.HtmlBody + }, + cancellationToken); + + AppendNotificationDispatch(currentState, notification, recipients); + + _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( + DashboardNotificationSettings settings, + NotificationCondition previousCondition, + NotificationCondition currentCondition, + bool hasExistingDispatchRecord) + { + if (currentCondition.IsProblem) + { + if (!hasExistingDispatchRecord || + !previousCondition.IsProblem || + !string.Equals(previousCondition.Label, currentCondition.Label, StringComparison.OrdinalIgnoreCase) || + !string.Equals(previousCondition.TrendLabel, currentCondition.TrendLabel, StringComparison.OrdinalIgnoreCase)) + { + return new NotificationDecision( + "Alert", + currentCondition.SubjectLabel, + $"alert:{currentCondition.Label}:{currentCondition.Status}:{currentCondition.TrendLabel}"); + } + } + else if (previousCondition.IsProblem && settings.NotifyOnRecovery) + { + return new NotificationDecision( + "Recovery", + 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 + .OrderByDescending(static dispatch => dispatch.SentUtc) + .FirstOrDefault(dispatch => string.Equals(dispatch.Signature, signature, StringComparison.OrdinalIgnoreCase)); + + if (record is null) + { + return false; + } + + return (_timeProvider.GetUtcNow() - record.SentUtc) < TimeSpan.FromMinutes(cooldownMinutes); + } + + private void AppendNotificationDispatch( + EndpointState currentState, + NotificationDecision notification, + NotificationRecipients recipients) + { + currentState.NotificationDispatches.Add(new EndpointNotificationDispatch + { + 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; + } + + 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) + { + var prefix = string.IsNullOrWhiteSpace(subjectPrefix) ? "[ApiHealthDashboard]" : subjectPrefix.Trim(); + return $"{prefix} {eventType}: {endpointName} - {label}"; + } + + private NotificationEmailContent BuildBody( + EndpointConfig endpoint, + NotificationCondition previousCondition, + NotificationCondition currentCondition, + NotificationDecision notification, + string subject) + { + 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); + + 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 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/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 323e286..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 { @@ -67,6 +71,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,15 +87,21 @@ 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 = [] + ExcludeChecks = [], + NotificationEmails = notificationEmails.Count > 0 + ? notificationEmails + : [.. existingEndpoint?.NotificationEmails ?? []], + NotificationCc = notificationCc.Count > 0 + ? notificationCc + : [.. existingEndpoint?.NotificationCc ?? []] }; 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) ? [] @@ -196,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 => @@ -399,6 +435,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}" }; @@ -437,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..e004d27 --- /dev/null +++ b/src/ApiHealthDashboard/Services/IEmailSender.cs @@ -0,0 +1,19 @@ +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 TextBody { get; init; } + + public string? HtmlBody { 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/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 new file mode 100644 index 0000000..eb32a6b --- /dev/null +++ b/src/ApiHealthDashboard/Services/SmtpEmailSender.cs @@ -0,0 +1,75 @@ +using System.Net; +using System.Net.Mail; +using System.Net.Mime; +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 + }; + + 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); + } + + 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/State/FileBackedEndpointStateStore.cs b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs new file mode 100644 index 0000000..8b03388 --- /dev/null +++ b/src/ApiHealthDashboard/State/FileBackedEndpointStateStore.cs @@ -0,0 +1,396 @@ +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 _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); + var endpointList = endpoints + .Where(static endpoint => endpoint is not null) + .Select(static endpoint => endpoint.Clone()) + .ToArray(); + _innerStore = new InMemoryEndpointStateStore(endpointList); + + Directory.CreateDirectory(_stateDirectoryPath); + UpdateConfiguredStateFilePaths(endpointList); + RestorePersistedStates(endpointList, restoreWhenStateIsInitial: true); + TryCleanupPersistedFiles(force: 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); + TryCleanupPersistedFiles(force: false); + } + + 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); + UpdateConfiguredStateFilePaths(endpointList); + RestorePersistedStates(endpointList, restoreWhenStateIsInitial: true); + TryCleanupPersistedFiles(force: 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 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"); + } + + 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 List RecentSamples { get; set; } = new(); + + public List NotificationDispatches { get; set; } = new(); + + 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(), + RecentSamples = state.RecentSamples.Select(static sample => sample.Clone()).ToList(), + NotificationDispatches = state.NotificationDispatches.Select(static dispatch => dispatch.Clone()).ToList() + }; + } + + public EndpointState ToRuntimeState() + { + return new EndpointState + { + EndpointId = EndpointId, + EndpointName = EndpointName, + Status = Status, + LastCheckedUtc = LastCheckedUtc, + LastSuccessfulUtc = LastSuccessfulUtc, + DurationMs = DurationMs, + 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/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/Statistics/RecentPollTrendAnalyzer.cs b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs new file mode 100644 index 0000000..8140088 --- /dev/null +++ b/src/ApiHealthDashboard/Statistics/RecentPollTrendAnalyzer.cs @@ -0,0 +1,199 @@ +using ApiHealthDashboard.Domain; + +namespace ApiHealthDashboard.Statistics; + +public static class RecentPollTrendAnalyzer +{ + private const int SettledStatusStreakLength = 4; + + 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; + } + + var currentStatus = NormalizeStatus(orderedSamples[^1].Status); + 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); + var currentRank = GetStatusRank(currentStatus); + + if (currentRank > previousRank) + { + return RecentPollTrendKind.Improving; + } + + if (currentRank < previousRank) + { + return RecentPollTrendKind.Worsening; + } + } + + 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 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) || + !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/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 638847b..2d1b28e 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -2,9 +2,36 @@ "Bootstrap": { "DashboardConfigPath": "dashboard.yaml" }, + "RuntimeState": { + "Enabled": true, + "DirectoryPath": "runtime-state/endpoints", + "CleanupEnabled": true, + "CleanupIntervalMinutes": 30, + "DeleteOrphanedStateFiles": true, + "OrphanedStateFileRetentionHours": 5, + "RecentSampleLimit": 25, + "NotificationHistoryLimit": 20 + }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 }, + "Email": { + "Smtp": { + "Enabled": true, + "Host": "127.0.0.1", + "Port": 2525, + "UseSsl": false, + "Username": "", + "Password": "", + "FromAddress": "noreply@mail.com", + "FromName": "ApiHealthDashboard" + }, + "Templates": { + "DirectoryPath": "Templates/Email", + "TextTemplateFileName": "notification.txt", + "HtmlTemplateFileName": "notification.html" + } + }, "DetailedErrors": true, "Logging": { "LogLevel": { diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index 1742e57..60aafb1 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -2,9 +2,36 @@ "Bootstrap": { "DashboardConfigPath": "dashboard.yaml" }, + "RuntimeState": { + "Enabled": true, + "DirectoryPath": "runtime-state/endpoints", + "CleanupEnabled": true, + "CleanupIntervalMinutes": 0.5, + "DeleteOrphanedStateFiles": true, + "OrphanedStateFileRetentionHours": 5, + "RecentSampleLimit": 25, + "NotificationHistoryLimit": 20 + }, "Import": { "MinimumRecommendedPollFrequencySeconds": 180 }, + "Email": { + "Smtp": { + "Enabled": true, + "Host": "127.0.0.1", + "Port": 2525, + "UseSsl": false, + "Username": "", + "Password": "", + "FromAddress": "noreply@mail.com", + "FromName": "ApiHealthDashboard" + }, + "Templates": { + "DirectoryPath": "Templates/Email", + "TextTemplateFileName": "notification.txt", + "HtmlTemplateFileName": "notification.html" + } + }, "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 4ee57b4..26b43b4 100644 --- a/src/ApiHealthDashboard/endpoints/billing-api.yaml +++ b/src/ApiHealthDashboard/endpoints/billing-api.yaml @@ -2,8 +2,11 @@ id: billing-api name: Billing API url: https://billing.example.com/health enabled: true +priority: Normal frequencySeconds: 60 timeoutSeconds: 15 headers: {} includeChecks: [] excludeChecks: [] +notificationEmails: + - 'gali@phillip.com.sg' \ No newline at end of file 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/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/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index 678d9bd..e5f505c 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; @@ -202,12 +209,68 @@ body { vertical-align: top; } +.dashboard-row-flash-update > td { + animation: dashboard-row-flash-update 1.8s ease-out; +} + +.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 { min-width: 14rem; max-width: 18rem; white-space: normal; } +.dashboard-recent-cell { + min-width: 14rem; +} + +.sample-indicator-strip { + display: flex; + flex-wrap: wrap; + gap: 0.3rem; + 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; + 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,9 +584,150 @@ body { border-top: 0; } +.recent-poll-list { + display: grid; + 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; + 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; + 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; +} + +@keyframes dashboard-row-flash-update { + 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); + } +} + +@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.65rem; + font-size: 1.35rem; + } + + .dashboard-hero .card-body { + padding: 0.95rem 1rem; } .import-form-section { diff --git a/src/ApiHealthDashboard/wwwroot/js/site.js b/src/ApiHealthDashboard/wwwroot/js/site.js index 379c834..63d405c 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 previousRowStates = getEndpointRowStates(container); try { const response = await fetch(refreshUrl, { @@ -150,6 +186,9 @@ function initializeDashboardSectionRefresh() { initializeDisabledActions(container); initializeEndpointSearch(container, preservedQuery); + initializeEndpointRefreshForms(container); + flashChangedEndpointRows(container, previousRowStates); + applyPendingEndpointFlash(container); initializeCopyButtons(container); } catch { // Keep the current rendered section if the background refresh fails. @@ -170,3 +209,102 @@ 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 getEndpointRowStates(root) { + const rowStates = new Map(); + + root.querySelectorAll("[data-endpoint-row-id]").forEach((row) => { + const endpointId = row.getAttribute("data-endpoint-row-id"); + if (!endpointId) { + return; + } + + rowStates.set(endpointId, { + signature: row.getAttribute("data-endpoint-row-signature") || "", + status: row.getAttribute("data-endpoint-row-status") || "" + }); + }); + + return rowStates; +} + +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 || !previousRowStates.has(endpointId)) { + return; + } + + 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) { + const flashClass = getFlashClassForStatusChange(previousStatus, currentStatus); + + flashEndpointRow(row, flashClass); + } + }); +} + +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(flashClass); + + window.setTimeout(() => { + 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; + } +} 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/DashboardConfigHotReloadServiceTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs index 3bafe24..468a73f 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs @@ -58,7 +58,9 @@ public async Task ReloadNowAsync_WhenYamlChanges_UpdatesSharedConfigAndStateStor sharedConfig, stateStore, new StubEndpointPoller(), + new NoOpEndpointNotificationService(), new StubHealthResponseParser(), + new RuntimeStateOptions(), TimeProvider.System, NullLogger.Instance); @@ -176,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/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..b8a8180 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"); @@ -95,6 +96,36 @@ 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." + } + ], + 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", @@ -145,11 +176,26 @@ 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); 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("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); + 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}", model.Endpoint.RawPayload!.Replace("\r\n", "\n", StringComparison.Ordinal)); @@ -211,6 +257,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/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/Pages/IndexModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs index 27d8d7d..ac9619a 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 @@ -99,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); @@ -115,6 +135,115 @@ 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); + Assert.Equal("50% success", model.ProblemEndpoints[0].RecentSuccessRateText); + Assert.Equal("230 ms avg", model.ProblemEndpoints[0].RecentAverageDurationText); + } + + [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] + 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] @@ -190,6 +319,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 +328,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/Scheduling/PollingSchedulerReloadTests.cs b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs index dc59849..6c36f78 100644 --- a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs +++ b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs @@ -39,7 +39,9 @@ public async Task ReloadConfigurationAsync_WhenNotStarted_SynchronizesStateStore sharedConfig, stateStore, new NoOpEndpointPoller(), + new NoOpEndpointNotificationService(), new NoOpHealthResponseParser(), + new RuntimeStateOptions(), TimeProvider.System, NullLogger.Instance); @@ -79,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 d1a8374..de91f92 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,95 @@ 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, + new NoOpEndpointNotificationService(), healthResponseParser, + runtimeStateOptions ?? new RuntimeStateOptions(), TimeProvider.System, NullLogger.Instance); } @@ -339,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..19dd1f2 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointEmailNotificationServiceTests.cs @@ -0,0 +1,476 @@ +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.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); + Assert.Equal("Alert", dispatch.EventType); + Assert.Equal("Failing", dispatch.ConditionLabel); + } + + [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); + Assert.Contains("Event: Recovery", message.TextBody, StringComparison.Ordinal); + Assert.Single(currentState.NotificationDispatches); + } + + [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); + 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.Contains("Event: Stabilized", message.TextBody, 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( + config, + new RuntimeStateOptions + { + NotificationHistoryLimit = 20 + }, + new SmtpEmailOptions + { + Enabled = true, + Host = "smtp.example.com", + Port = 587, + FromAddress = "dashboard@example.com", + FromName = "ApiHealthDashboard" + }, + sender, + new FakeNotificationEmailTemplateRenderer(), + 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; + } + } + + 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/EndpointImportServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs index 6c67b2a..7daff78 100644 --- a/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs @@ -21,8 +21,11 @@ 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 + FrequencySeconds = 60, + NotificationEmails = ["ops@example.com"], + NotificationCc = ["lead@example.com"] } ] }; @@ -59,13 +62,19 @@ public async Task ImportAsync_WithSuccessfulProbe_GeneratesYamlAndDiffAgainstExi { Url = "https://orders.example.com/health", FrequencySeconds = 30, - IncludeDiscoveredChecks = true + IncludeDiscoveredChecks = true, + NotificationCcText = "override@example.com" }, CancellationToken.None); 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("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); @@ -118,6 +127,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); @@ -152,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/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; } + } +} diff --git a/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs new file mode 100644 index 0000000..f8b68de --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/State/FileBackedEndpointStateStoreTests.cs @@ -0,0 +1,350 @@ +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, + CreateRuntimeStateOptions(), + 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, + RecentSamples = + [ + new RecentPollSample + { + CheckedUtc = DateTimeOffset.Parse("2026-03-18T14:59:00Z"), + Status = "Healthy", + DurationMs = 120, + 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", + 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.Contains("\"recentSamples\":[", json); + Assert.Contains("\"notificationDispatches\":[", json); + Assert.DoesNotContain("isPolling", json, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Constructor_RestoresPersistedStateForConfiguredEndpoint() + { + var initialStore = new FileBackedEndpointStateStore( + [CreateEndpoint("orders-api", "Orders API")], + _stateDirectoryPath, + CreateRuntimeStateOptions(), + 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", + 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" + } + ], + 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", + RetrievedUtc = DateTimeOffset.Parse("2026-03-18T16:00:00Z"), + DurationMs = 456, + RawPayload = """{"status":"Degraded"}""" + } + }); + + var restoredStore = new FileBackedEndpointStateStore( + [CreateEndpoint("orders-api", "Orders API Reloaded")], + _stateDirectoryPath, + CreateRuntimeStateOptions(), + 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); + 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] + public void Initialize_RestoresPersistedStateOnlyForNewEndpoints() + { + var seedStore = new FileBackedEndpointStateStore( + [CreateEndpoint("billing-api", "Billing API")], + _stateDirectoryPath, + CreateRuntimeStateOptions(), + 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, + CreateRuntimeStateOptions(), + 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); + } + + [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)) + { + 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 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, + RecentSampleLimit = 25 + }; + } + + private static void OverwritePersistedState( + string stateFilePath, + string endpointId, + string endpointName, + string status) + { + var payload = new + { + endpointId, + endpointName, + status + }; + + File.WriteAllText(stateFilePath, JsonSerializer.Serialize(payload)); + } +} 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] diff --git a/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs new file mode 100644 index 0000000..853959c --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Statistics/RecentPollTrendAnalyzerTests.cs @@ -0,0 +1,165 @@ +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); + } + + [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); + } + + [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); + } +}