diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index f25dd64..9e8b28a 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -3,12 +3,14 @@ name: CI Build on: push: branches: - - main + - master - develop + - enhancements pull_request: branches: - - main + - master - develop + - enhancements permissions: contents: read diff --git a/.github/workflows/sast-scan.yml b/.github/workflows/sast-scan.yml index a18e32c..b80a2c0 100644 --- a/.github/workflows/sast-scan.yml +++ b/.github/workflows/sast-scan.yml @@ -3,10 +3,14 @@ name: SAST Scan on: push: branches: - - main + - master + - develop + - enhancements pull_request: branches: - - main + - master + - develop + - enhancements workflow_dispatch: permissions: diff --git a/README.md b/README.md index 138126d..2351e9e 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,8 @@ Implemented so far: - Phase 13: publish and deployment validation - Phase 14: GitHub Actions CI/CD and Dependabot automation - 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 Not implemented yet: - Backlog items tracked for post-v1 work @@ -87,6 +89,7 @@ Current configuration support: - `${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 +- runtime changes to `dashboard.yaml` and referenced endpoint YAML files are watched and hot-reloaded without restarting the app Validation currently checks: - required endpoint id, name, and url @@ -165,6 +168,19 @@ Current behavior: - records last checked time, last successful time, duration, status, and current error - 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 + +### YAML Hot-Reload + +The web app now watches the dashboard YAML and referenced endpoint YAML files for runtime changes. + +Current hot-reload behavior: +- detects create, update, delete, and rename events for `dashboard.yaml` and referenced endpoint YAML files +- reloads configuration with a short debounce to avoid duplicate reload storms while saving +- applies the new config without restarting the app when YAML stays valid +- keeps the last successfully loaded config active if a reload fails +- updates the visible configuration warning banner when reload warnings or failures occur +- preserves runtime status for endpoints that still exist after a reload and initializes new endpoints as `Unknown` ### Manual Refresh Actions @@ -203,6 +219,18 @@ Current import behavior: - 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 +### CLI Execution + +The app now includes a one-shot CLI mode for scripted health execution without starting the web UI. + +Current CLI behavior: +- executes the full configured suite with `--cli --all` +- 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 +- 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 + ### Endpoint Details The endpoint details page now acts as a diagnostic view for a single configured endpoint. @@ -262,9 +290,9 @@ Current automation files: - [`.github/dependabot.yml`](.github/dependabot.yml) Current automation behavior: -- CI runs on pushes to `main` and `develop`, and on pull requests targeting those branches +- CI runs on pushes to `master`, `develop`, and `enhancements`, and on pull requests targeting those branches - CI restores, builds in Release mode, runs tests, and uploads TRX test results -- CodeQL runs for C# on pushes to `main`, pull requests targeting `main`, and manual dispatches +- CodeQL runs for C# on pushes to `master`, `develop`, and `enhancements`, pull requests targeting those branches, and manual dispatches - Release automation verifies the solution, publishes self-contained artifacts for `win-x64` and `linux-x64`, packages them, generates checksums, and uploads them to the GitHub release - Dependabot monitors both NuGet dependencies and GitHub Actions workflow dependencies on a weekly schedule @@ -289,6 +317,36 @@ $env:APIHEALTHDASHBOARD_BOOTSTRAP__DASHBOARDCONFIGPATH="D:\path\to\dashboard.yam dotnet run --project .\src\ApiHealthDashboard\ApiHealthDashboard.csproj ``` +## Running The CLI + +Run the full suite and print JSON to stdout: + +```powershell +dotnet run --project .\src\ApiHealthDashboard\ApiHealthDashboard.csproj --no-build --no-launch-profile -- --cli --all +``` + +Run only selected endpoint YAML files: + +```powershell +dotnet run --project .\src\ApiHealthDashboard\ApiHealthDashboard.csproj --no-build --no-launch-profile -- --cli --endpoint-file .\src\ApiHealthDashboard\endpoints\orders-api.yaml --endpoint-file .\src\ApiHealthDashboard\endpoints\billing-api.yaml +``` + +Write a JSON or XML file in addition to the JSON stdout output: + +```powershell +dotnet run --project .\src\ApiHealthDashboard\ApiHealthDashboard.csproj --no-build --no-launch-profile -- --cli --all --output-file .\artifacts\health-report.json +dotnet run --project .\src\ApiHealthDashboard\ApiHealthDashboard.csproj --no-build --no-launch-profile -- --cli --all --output-file .\artifacts\health-report.xml --output-format xml +``` + +For the cleanest machine-readable stdout, prefer a published build or `dotnet run` with both `--no-build` and `--no-launch-profile`. + +Optional CLI arguments: +- `--dashboard-config ` to override the dashboard YAML path +- `--all` to execute the whole configured suite +- `--endpoint-file ` to execute only selected endpoint YAML files +- `--output-file ` to persist the report to disk +- `--output-format json|xml` to choose file output format + ## Running Tests ```powershell @@ -317,7 +375,7 @@ Deployment notes: ## CI/CD Automation Repository automation now includes: -- CI build and test workflow for `main` and `develop` +- CI build and test workflow for `master`, `develop`, and `enhancements` - CodeQL SAST workflow for C# - release packaging workflow for self-contained GitHub release artifacts - Dependabot configuration for NuGet and GitHub Actions dependencies @@ -336,6 +394,9 @@ Local note: - the workflow files were added and reviewed locally, and the application build still passes, but this shell environment does not include a YAML workflow linter for direct execution-free validation Current automated coverage includes: +- CLI option parsing and validation +- CLI report JSON and XML serialization +- CLI execution summary generation for full-suite and selected-endpoint runs - valid YAML load - normalization of null optional collections - missing configuration file handling @@ -417,7 +478,6 @@ Test file: ## Future Plans These are planned enhancements after the current v1 path: -- add CLI execution with machine-readable output for automation and scripting scenarios - 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 diff --git a/src/ApiHealthDashboard/Cli/CliExecutionReport.cs b/src/ApiHealthDashboard/Cli/CliExecutionReport.cs new file mode 100644 index 0000000..2622052 --- /dev/null +++ b/src/ApiHealthDashboard/Cli/CliExecutionReport.cs @@ -0,0 +1,133 @@ +using System.Xml.Serialization; + +namespace ApiHealthDashboard.Cli; + +[XmlRoot("suiteExecution")] +public sealed class CliExecutionReport +{ + public string Mode { get; set; } = "suite"; + + public string DashboardConfigPath { get; set; } = string.Empty; + + public string ExecutedUtc { get; set; } = string.Empty; + + [XmlArray("selectedEndpointFiles")] + [XmlArrayItem("file")] + public List SelectedEndpointFiles { get; set; } = new(); + + [XmlArray("configurationWarnings")] + [XmlArrayItem("warning")] + public List ConfigurationWarnings { get; set; } = new(); + + public CliExecutionSummary Summary { get; set; } = new(); + + [XmlArray("endpoints")] + [XmlArrayItem("endpoint")] + public List Endpoints { get; set; } = new(); +} + +public sealed class CliExecutionSummary +{ + public int TotalEndpoints { get; set; } + + public int EnabledEndpoints { get; set; } + + public int ExecutedEndpoints { get; set; } + + public int SkippedEndpoints { get; set; } + + public int SuccessfulPolls { get; set; } + + public int FailedPolls { get; set; } + + public int HealthyEndpoints { get; set; } + + public int DegradedEndpoints { get; set; } + + public int UnhealthyEndpoints { get; set; } + + public int UnknownEndpoints { get; set; } + + public string OverallStatus { get; set; } = "Unknown"; +} + +public sealed class CliEndpointExecutionReport +{ + public string Id { get; set; } = string.Empty; + + public string Name { get; set; } = string.Empty; + + public string Url { get; set; } = string.Empty; + + public bool Enabled { get; set; } + + public int FrequencySeconds { get; set; } + + public int? TimeoutSeconds { get; set; } + + public string ExecutionState { get; set; } = "Skipped"; + + public string Status { get; set; } = "Unknown"; + + public string PollResultKind { get; set; } = "Unknown"; + + public string? CheckedUtc { get; set; } + + public long? DurationMs { get; set; } + + public int? StatusCode { get; set; } + + public string? ErrorMessage { get; set; } + + public string? ResponseBody { get; set; } + + public CliSnapshotReport? Snapshot { get; set; } +} + +public sealed class CliSnapshotReport +{ + public string OverallStatus { get; set; } = "Unknown"; + + public string RetrievedUtc { get; set; } = string.Empty; + + public long DurationMs { get; set; } + + public string RawPayload { get; set; } = string.Empty; + + [XmlArray("metadataEntries")] + [XmlArrayItem("entry")] + public List MetadataEntries { get; set; } = new(); + + [XmlArray("nodes")] + [XmlArrayItem("node")] + public List Nodes { get; set; } = new(); +} + +public sealed class CliNodeReport +{ + public string Name { get; set; } = string.Empty; + + public string Status { get; set; } = "Unknown"; + + public string? Description { get; set; } + + public string? ErrorMessage { get; set; } + + public string? DurationText { get; set; } + + [XmlArray("dataEntries")] + [XmlArrayItem("entry")] + public List DataEntries { get; set; } = new(); + + [XmlArray("children")] + [XmlArrayItem("node")] + public List Children { get; set; } = new(); +} + +public sealed class CliKeyValueEntry +{ + [XmlAttribute("key")] + public string Key { get; set; } = string.Empty; + + public string? Value { get; set; } +} diff --git a/src/ApiHealthDashboard/Cli/CliExecutionService.cs b/src/ApiHealthDashboard/Cli/CliExecutionService.cs new file mode 100644 index 0000000..be19938 --- /dev/null +++ b/src/ApiHealthDashboard/Cli/CliExecutionService.cs @@ -0,0 +1,282 @@ +using System.Text.Json; +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Parsing; +using ApiHealthDashboard.Services; + +namespace ApiHealthDashboard.Cli; + +public sealed class CliExecutionService +{ + private readonly IEndpointPoller _endpointPoller; + private readonly IHealthResponseParser _healthResponseParser; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + public CliExecutionService( + IEndpointPoller endpointPoller, + IHealthResponseParser healthResponseParser, + TimeProvider timeProvider, + ILogger logger) + { + _endpointPoller = endpointPoller; + _healthResponseParser = healthResponseParser; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task ExecuteAsync( + DashboardConfig config, + CliOptions options, + string dashboardConfigPath, + IReadOnlyCollection configurationWarnings, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(config); + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(configurationWarnings); + + _logger.LogInformation( + "Starting CLI execution in {Mode} mode for {EndpointCount} configured endpoints.", + options.RunAll ? "suite" : "selected-endpoints", + config.Endpoints.Count); + + var tasks = config.Endpoints + .Select(endpoint => ExecuteEndpointAsync(config, endpoint, cancellationToken)) + .ToArray(); + + var endpoints = tasks.Length == 0 + ? new List() + : [.. await Task.WhenAll(tasks)]; + + var summary = BuildSummary(config, endpoints); + var report = new CliExecutionReport + { + Mode = options.RunAll ? "suite" : "selected-endpoints", + DashboardConfigPath = dashboardConfigPath, + ExecutedUtc = _timeProvider.GetUtcNow().ToString("O"), + SelectedEndpointFiles = [.. options.EndpointFiles], + ConfigurationWarnings = [.. configurationWarnings], + Summary = summary, + Endpoints = endpoints + }; + + _logger.LogInformation( + "Completed CLI execution with overall status {OverallStatus}. Executed {ExecutedEndpoints} endpoints and skipped {SkippedEndpoints}.", + summary.OverallStatus, + summary.ExecutedEndpoints, + summary.SkippedEndpoints); + + return report; + } + + private async Task ExecuteEndpointAsync( + DashboardConfig config, + EndpointConfig endpoint, + CancellationToken cancellationToken) + { + if (!endpoint.Enabled) + { + return new CliEndpointExecutionReport + { + Id = endpoint.Id, + Name = endpoint.Name, + Url = endpoint.Url, + Enabled = false, + FrequencySeconds = endpoint.FrequencySeconds, + TimeoutSeconds = endpoint.TimeoutSeconds ?? config.Dashboard.RequestTimeoutSecondsDefault, + ExecutionState = "Skipped", + Status = "Unknown", + PollResultKind = "Skipped", + ErrorMessage = "Endpoint is disabled." + }; + } + + var pollResult = await _endpointPoller.PollAsync(endpoint, cancellationToken); + var report = new CliEndpointExecutionReport + { + Id = endpoint.Id, + Name = endpoint.Name, + Url = endpoint.Url, + Enabled = true, + FrequencySeconds = endpoint.FrequencySeconds, + TimeoutSeconds = endpoint.TimeoutSeconds ?? config.Dashboard.RequestTimeoutSecondsDefault, + ExecutionState = "Executed", + Status = "Unknown", + PollResultKind = pollResult.Kind.ToString(), + CheckedUtc = pollResult.CheckedUtc.ToString("O"), + DurationMs = pollResult.DurationMs, + StatusCode = pollResult.StatusCode is null ? null : (int)pollResult.StatusCode.Value, + ErrorMessage = pollResult.ErrorMessage, + ResponseBody = pollResult.ResponseBody + }; + + if (pollResult.IsSuccess && !string.IsNullOrWhiteSpace(pollResult.ResponseBody)) + { + var snapshot = _healthResponseParser.Parse(endpoint, pollResult.ResponseBody, pollResult.DurationMs); + report.Status = snapshot.OverallStatus; + report.Snapshot = CreateSnapshotReport(snapshot); + + if (TryGetParserError(snapshot, out var parserError)) + { + report.ErrorMessage = $"Failed to parse health response: {parserError}"; + } + } + + return report; + } + + private static CliExecutionSummary BuildSummary( + DashboardConfig config, + IReadOnlyCollection endpoints) + { + var summary = new CliExecutionSummary + { + TotalEndpoints = config.Endpoints.Count, + EnabledEndpoints = config.Endpoints.Count(static endpoint => endpoint.Enabled), + ExecutedEndpoints = endpoints.Count(static endpoint => string.Equals(endpoint.ExecutionState, "Executed", StringComparison.OrdinalIgnoreCase)), + SkippedEndpoints = endpoints.Count(static endpoint => string.Equals(endpoint.ExecutionState, "Skipped", StringComparison.OrdinalIgnoreCase)), + SuccessfulPolls = endpoints.Count(static endpoint => string.Equals(endpoint.PollResultKind, PollResultKind.Success.ToString(), StringComparison.OrdinalIgnoreCase)), + FailedPolls = endpoints.Count(static endpoint => + string.Equals(endpoint.ExecutionState, "Executed", StringComparison.OrdinalIgnoreCase) && + !string.Equals(endpoint.PollResultKind, PollResultKind.Success.ToString(), StringComparison.OrdinalIgnoreCase)) + }; + + foreach (var endpoint in endpoints) + { + switch (NormalizeStatus(endpoint.Status)) + { + case "Healthy": + summary.HealthyEndpoints++; + break; + case "Degraded": + summary.DegradedEndpoints++; + break; + case "Unhealthy": + summary.UnhealthyEndpoints++; + break; + default: + summary.UnknownEndpoints++; + break; + } + } + + summary.OverallStatus = AggregateStatus(endpoints.Select(static endpoint => endpoint.Status)); + return summary; + } + + private static CliSnapshotReport CreateSnapshotReport(HealthSnapshot snapshot) + { + return new CliSnapshotReport + { + OverallStatus = snapshot.OverallStatus, + RetrievedUtc = snapshot.RetrievedUtc.ToString("O"), + DurationMs = snapshot.DurationMs, + RawPayload = snapshot.RawPayload, + MetadataEntries = snapshot.Metadata + .Select(CreateEntry) + .ToList(), + Nodes = snapshot.Nodes + .Select(CreateNodeReport) + .ToList() + }; + } + + private static CliNodeReport CreateNodeReport(HealthNode node) + { + return new CliNodeReport + { + Name = node.Name, + Status = node.Status, + Description = node.Description, + ErrorMessage = node.ErrorMessage, + DurationText = node.DurationText, + DataEntries = node.Data + .Select(CreateEntry) + .ToList(), + Children = node.Children + .Select(CreateNodeReport) + .ToList() + }; + } + + private static CliKeyValueEntry CreateEntry(KeyValuePair pair) + { + return new CliKeyValueEntry + { + Key = pair.Key, + Value = ConvertValue(pair.Value) + }; + } + + private static string? ConvertValue(object? value) + { + if (value is null) + { + return null; + } + + return value switch + { + string text => text, + _ => JsonSerializer.Serialize(value, CliReportSerializer.JsonOptions) + }; + } + + private static bool TryGetParserError(HealthSnapshot snapshot, out string parserError) + { + if (snapshot.Metadata.TryGetValue("parserError", out var value) && + value is string text && + !string.IsNullOrWhiteSpace(text)) + { + parserError = text; + return true; + } + + parserError = string.Empty; + return false; + } + + private static string AggregateStatus(IEnumerable statuses) + { + var highestSeverity = 0; + var resolvedStatus = "Unknown"; + + foreach (var status in statuses) + { + var normalized = NormalizeStatus(status); + var severity = normalized switch + { + "Unhealthy" => 4, + "Degraded" => 3, + "Healthy" => 2, + _ => 1 + }; + + if (severity > highestSeverity) + { + highestSeverity = severity; + resolvedStatus = normalized; + } + } + + return resolvedStatus; + } + + private static string NormalizeStatus(string? value) + { + if (string.IsNullOrWhiteSpace(value)) + { + return "Unknown"; + } + + return value.Trim().ToLowerInvariant() switch + { + "healthy" => "Healthy", + "degraded" => "Degraded", + "unhealthy" => "Unhealthy", + "unknown" => "Unknown", + _ => value.Trim() + }; + } +} diff --git a/src/ApiHealthDashboard/Cli/CliFileOutputFormat.cs b/src/ApiHealthDashboard/Cli/CliFileOutputFormat.cs new file mode 100644 index 0000000..9b06f67 --- /dev/null +++ b/src/ApiHealthDashboard/Cli/CliFileOutputFormat.cs @@ -0,0 +1,7 @@ +namespace ApiHealthDashboard.Cli; + +public enum CliFileOutputFormat +{ + Json, + Xml +} diff --git a/src/ApiHealthDashboard/Cli/CliOptions.cs b/src/ApiHealthDashboard/Cli/CliOptions.cs new file mode 100644 index 0000000..7e269f1 --- /dev/null +++ b/src/ApiHealthDashboard/Cli/CliOptions.cs @@ -0,0 +1,219 @@ +using System.Text; + +namespace ApiHealthDashboard.Cli; + +public sealed class CliOptions +{ + public bool IsCliMode { get; set; } + + public bool RunAll { get; set; } + + public string? DashboardConfigPathOverride { get; set; } + + public List EndpointFiles { get; set; } = new(); + + public string? OutputFilePath { get; set; } + + public CliFileOutputFormat? OutputFileFormat { get; set; } + + public static CliParseResult Parse(string[] args) + { + ArgumentNullException.ThrowIfNull(args); + + if (args.Length == 0) + { + return CliParseResult.NotCli(); + } + + var isCliMode = args.Any(static arg => + string.Equals(arg, "--cli", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "cli", StringComparison.OrdinalIgnoreCase)); + + if (!isCliMode) + { + return CliParseResult.NotCli(); + } + + var options = new CliOptions + { + IsCliMode = true + }; + + var endpointFiles = new List(); + string? dashboardConfigPathOverride = null; + string? outputFilePath = null; + CliFileOutputFormat? outputFileFormat = null; + var runAll = false; + + for (var index = 0; index < args.Length; index++) + { + var arg = args[index]; + + if (string.Equals(arg, "--cli", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "cli", StringComparison.OrdinalIgnoreCase)) + { + continue; + } + + if (string.Equals(arg, "--help", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "-h", StringComparison.OrdinalIgnoreCase) || + string.Equals(arg, "/?", StringComparison.OrdinalIgnoreCase)) + { + return CliParseResult.Help(); + } + + if (string.Equals(arg, "--all", StringComparison.OrdinalIgnoreCase)) + { + runAll = true; + continue; + } + + if (string.Equals(arg, "--endpoint-file", StringComparison.OrdinalIgnoreCase)) + { + if (!TryReadValue(args, ref index, out var endpointFile)) + { + return CliParseResult.Invalid("Missing value for --endpoint-file."); + } + + endpointFiles.Add(endpointFile); + continue; + } + + if (string.Equals(arg, "--dashboard-config", StringComparison.OrdinalIgnoreCase)) + { + if (!TryReadValue(args, ref index, out var dashboardConfigPath)) + { + return CliParseResult.Invalid("Missing value for --dashboard-config."); + } + + dashboardConfigPathOverride = dashboardConfigPath; + continue; + } + + if (string.Equals(arg, "--output-file", StringComparison.OrdinalIgnoreCase)) + { + if (!TryReadValue(args, ref index, out var outputPath)) + { + return CliParseResult.Invalid("Missing value for --output-file."); + } + + outputFilePath = outputPath; + continue; + } + + if (string.Equals(arg, "--output-format", StringComparison.OrdinalIgnoreCase)) + { + if (!TryReadValue(args, ref index, out var formatText)) + { + return CliParseResult.Invalid("Missing value for --output-format."); + } + + if (!TryParseOutputFormat(formatText, out var parsedFormat)) + { + return CliParseResult.Invalid("Invalid value for --output-format. Allowed values are 'json' and 'xml'."); + } + + outputFileFormat = parsedFormat; + continue; + } + + return CliParseResult.Invalid($"Unknown CLI argument '{arg}'."); + } + + if (runAll && endpointFiles.Count > 0) + { + return CliParseResult.Invalid("Use either --all or one or more --endpoint-file values, but not both."); + } + + if (!runAll && endpointFiles.Count == 0) + { + return CliParseResult.Invalid("CLI mode requires --all or at least one --endpoint-file value."); + } + + if (outputFileFormat is not null && string.IsNullOrWhiteSpace(outputFilePath)) + { + return CliParseResult.Invalid("--output-format requires --output-file."); + } + + options.RunAll = runAll; + options.DashboardConfigPathOverride = dashboardConfigPathOverride; + options.EndpointFiles.AddRange(endpointFiles); + options.OutputFilePath = outputFilePath; + options.OutputFileFormat = outputFileFormat; + + return CliParseResult.Success(options); + } + + public CliFileOutputFormat ResolveOutputFileFormat() + { + if (OutputFileFormat is not null) + { + return OutputFileFormat.Value; + } + + if (!string.IsNullOrWhiteSpace(OutputFilePath)) + { + var extension = Path.GetExtension(OutputFilePath); + if (string.Equals(extension, ".xml", StringComparison.OrdinalIgnoreCase)) + { + return CliFileOutputFormat.Xml; + } + } + + return CliFileOutputFormat.Json; + } + + public static string GetHelpText() + { + var builder = new StringBuilder(); + builder.AppendLine("ApiHealthDashboard CLI"); + builder.AppendLine(); + builder.AppendLine("Usage:"); + builder.AppendLine(" dotnet run --project .\\src\\ApiHealthDashboard\\ApiHealthDashboard.csproj -- --cli --all"); + builder.AppendLine(" dotnet run --project .\\src\\ApiHealthDashboard\\ApiHealthDashboard.csproj -- --cli --endpoint-file .\\src\\ApiHealthDashboard\\endpoints\\orders-api.yaml"); + builder.AppendLine(); + builder.AppendLine("Options:"); + builder.AppendLine(" --cli Run one-shot CLI execution mode."); + builder.AppendLine(" --all Execute all endpoints from the dashboard suite."); + builder.AppendLine(" --endpoint-file Execute only the specified endpoint YAML file. Repeat to include more than one file."); + builder.AppendLine(" --dashboard-config Override the dashboard YAML path used for defaults and endpoint resolution."); + builder.AppendLine(" --output-file Write the report to a file."); + builder.AppendLine(" --output-format File format for --output-file. Defaults to the output file extension or JSON."); + builder.AppendLine(" --help, -h Show this help text."); + builder.AppendLine(); + builder.AppendLine("Behavior:"); + builder.AppendLine(" - CLI output written to stdout is always JSON."); + builder.AppendLine(" - File output is optional and can be JSON or XML."); + return builder.ToString().TrimEnd(); + } + + private static bool TryReadValue(string[] args, ref int index, out string value) + { + if (index + 1 >= args.Length) + { + value = string.Empty; + return false; + } + + value = args[++index]; + return !string.IsNullOrWhiteSpace(value); + } + + private static bool TryParseOutputFormat(string value, out CliFileOutputFormat format) + { + if (string.Equals(value, "json", StringComparison.OrdinalIgnoreCase)) + { + format = CliFileOutputFormat.Json; + return true; + } + + if (string.Equals(value, "xml", StringComparison.OrdinalIgnoreCase)) + { + format = CliFileOutputFormat.Xml; + return true; + } + + format = default; + return false; + } +} diff --git a/src/ApiHealthDashboard/Cli/CliParseResult.cs b/src/ApiHealthDashboard/Cli/CliParseResult.cs new file mode 100644 index 0000000..bb6da63 --- /dev/null +++ b/src/ApiHealthDashboard/Cli/CliParseResult.cs @@ -0,0 +1,51 @@ +namespace ApiHealthDashboard.Cli; + +public sealed class CliParseResult +{ + public bool IsCliMode { get; init; } + + public bool IsHelpRequested { get; init; } + + public bool IsValid { get; init; } + + public string? ErrorMessage { get; init; } + + public CliOptions? Options { get; init; } + + public static CliParseResult NotCli() + { + return new CliParseResult + { + IsValid = true + }; + } + + public static CliParseResult Help() + { + return new CliParseResult + { + IsCliMode = true, + IsHelpRequested = true, + IsValid = true + }; + } + + public static CliParseResult Success(CliOptions options) + { + return new CliParseResult + { + IsCliMode = true, + IsValid = true, + Options = options + }; + } + + public static CliParseResult Invalid(string errorMessage) + { + return new CliParseResult + { + IsCliMode = true, + ErrorMessage = errorMessage + }; + } +} diff --git a/src/ApiHealthDashboard/Cli/CliReportSerializer.cs b/src/ApiHealthDashboard/Cli/CliReportSerializer.cs new file mode 100644 index 0000000..09c9e2c --- /dev/null +++ b/src/ApiHealthDashboard/Cli/CliReportSerializer.cs @@ -0,0 +1,62 @@ +using System.Text; +using System.Text.Json; +using System.Xml; +using System.Xml.Serialization; + +namespace ApiHealthDashboard.Cli; + +public static class CliReportSerializer +{ + public static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + WriteIndented = true + }; + + public static string SerializeJson(CliExecutionReport report) + { + ArgumentNullException.ThrowIfNull(report); + return JsonSerializer.Serialize(report, JsonOptions); + } + + public static string SerializeXml(CliExecutionReport report) + { + ArgumentNullException.ThrowIfNull(report); + + var serializer = new XmlSerializer(typeof(CliExecutionReport)); + var settings = new XmlWriterSettings + { + Encoding = new UTF8Encoding(false), + Indent = true, + OmitXmlDeclaration = false + }; + + using var writer = new StringWriter(); + using var xmlWriter = XmlWriter.Create(writer, settings); + serializer.Serialize(xmlWriter, report); + return writer.ToString(); + } + + public static async Task WriteToFileAsync( + CliExecutionReport report, + string outputFilePath, + CliFileOutputFormat format, + CancellationToken cancellationToken) + { + ArgumentException.ThrowIfNullOrWhiteSpace(outputFilePath); + + var directory = Path.GetDirectoryName(Path.GetFullPath(outputFilePath)); + if (!string.IsNullOrWhiteSpace(directory)) + { + Directory.CreateDirectory(directory); + } + + var content = format switch + { + CliFileOutputFormat.Xml => SerializeXml(report), + _ => SerializeJson(report) + }; + + await File.WriteAllTextAsync(outputFilePath, content, Encoding.UTF8, cancellationToken); + } +} diff --git a/src/ApiHealthDashboard/Configuration/ConfigurationWarningState.cs b/src/ApiHealthDashboard/Configuration/ConfigurationWarningState.cs index 31269a1..3d4f740 100644 --- a/src/ApiHealthDashboard/Configuration/ConfigurationWarningState.cs +++ b/src/ApiHealthDashboard/Configuration/ConfigurationWarningState.cs @@ -2,12 +2,41 @@ namespace ApiHealthDashboard.Configuration; public sealed class ConfigurationWarningState { + private readonly object _syncRoot = new(); + private IReadOnlyList _warnings; + public ConfigurationWarningState(IReadOnlyList warnings) { - Warnings = warnings; + _warnings = warnings ?? []; + } + + public IReadOnlyList Warnings + { + get + { + lock (_syncRoot) + { + return _warnings; + } + } } - public IReadOnlyList Warnings { get; } + public bool HasWarnings + { + get + { + lock (_syncRoot) + { + return _warnings.Count > 0; + } + } + } - public bool HasWarnings => Warnings.Count > 0; + public void UpdateWarnings(IReadOnlyList warnings) + { + lock (_syncRoot) + { + _warnings = warnings ?? []; + } + } } diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs index 01f98ef..727e81d 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs @@ -7,6 +7,25 @@ public sealed class DashboardConfig public List EndpointFiles { get; set; } = new(); public List Endpoints { get; set; } = new(); + + public DashboardConfig Clone() + { + return new DashboardConfig + { + Dashboard = Dashboard.Clone(), + EndpointFiles = [.. EndpointFiles], + Endpoints = Endpoints.Select(static endpoint => endpoint.Clone()).ToList() + }; + } + + public void CopyFrom(DashboardConfig source) + { + ArgumentNullException.ThrowIfNull(source); + + Dashboard = source.Dashboard.Clone(); + EndpointFiles = [.. source.EndpointFiles]; + Endpoints = source.Endpoints.Select(static endpoint => endpoint.Clone()).ToList(); + } } public sealed class DashboardSettings @@ -16,6 +35,16 @@ public sealed class DashboardSettings public int RequestTimeoutSecondsDefault { get; set; } = 10; public bool ShowRawPayload { get; set; } + + public DashboardSettings Clone() + { + return new DashboardSettings + { + RefreshUiSeconds = RefreshUiSeconds, + RequestTimeoutSecondsDefault = RequestTimeoutSecondsDefault, + ShowRawPayload = ShowRawPayload + }; + } } public sealed class EndpointConfig @@ -37,4 +66,20 @@ public sealed class EndpointConfig public List IncludeChecks { get; set; } = new(); public List ExcludeChecks { get; set; } = new(); + + public EndpointConfig Clone() + { + return new EndpointConfig + { + Id = Id, + Name = Name, + Url = Url, + Enabled = Enabled, + FrequencySeconds = FrequencySeconds, + TimeoutSeconds = TimeoutSeconds, + Headers = new Dictionary(Headers, StringComparer.OrdinalIgnoreCase), + IncludeChecks = [.. IncludeChecks], + ExcludeChecks = [.. ExcludeChecks] + }; + } } diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfigHotReloadService.cs b/src/ApiHealthDashboard/Configuration/DashboardConfigHotReloadService.cs new file mode 100644 index 0000000..b999a40 --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/DashboardConfigHotReloadService.cs @@ -0,0 +1,261 @@ +using System.Collections.Concurrent; +using Microsoft.Extensions.Options; +using ApiHealthDashboard.Scheduling; + +namespace ApiHealthDashboard.Configuration; + +public sealed class DashboardConfigHotReloadService : IHostedService, IDisposable +{ + private static readonly TimeSpan ReloadDebounce = TimeSpan.FromMilliseconds(750); + + private readonly DashboardBootstrapOptions _bootstrapOptions; + private readonly DashboardConfig _dashboardConfig; + private readonly ConfigurationWarningState _configurationWarningState; + private readonly IHostEnvironment _environment; + private readonly ILogger _logger; + private readonly PollingSchedulerService _pollingSchedulerService; + private readonly IYamlConfigLoader _yamlConfigLoader; + private readonly SemaphoreSlim _reloadGate = new(1, 1); + private readonly object _syncRoot = new(); + private readonly ConcurrentDictionary _watchedFilePaths = new(StringComparer.OrdinalIgnoreCase); + + private List _watchers = new(); + private CancellationTokenSource? _reloadDelayCancellationTokenSource; + private CancellationToken _stoppingToken; + private string _resolvedDashboardPath = string.Empty; + + public DashboardConfigHotReloadService( + DashboardConfig dashboardConfig, + DashboardConfigLoadResult initialLoadResult, + ConfigurationWarningState configurationWarningState, + PollingSchedulerService pollingSchedulerService, + IYamlConfigLoader yamlConfigLoader, + IOptions bootstrapOptions, + IHostEnvironment environment, + ILogger logger) + { + _dashboardConfig = dashboardConfig; + _configurationWarningState = configurationWarningState; + _pollingSchedulerService = pollingSchedulerService; + _yamlConfigLoader = yamlConfigLoader; + _bootstrapOptions = bootstrapOptions.Value; + _environment = environment; + _logger = logger; + _resolvedDashboardPath = ResolveConfigPath(_bootstrapOptions.ResolveDashboardConfigPath(), _environment.ContentRootPath); + + foreach (var path in initialLoadResult.WatchedFilePaths) + { + _watchedFilePaths.TryAdd(Path.GetFullPath(path), 0); + } + } + + public Task StartAsync(CancellationToken cancellationToken) + { + _stoppingToken = cancellationToken; + ResetWatchers(); + return Task.CompletedTask; + } + + public Task StopAsync(CancellationToken cancellationToken) + { + Dispose(); + return Task.CompletedTask; + } + + public void Dispose() + { + lock (_syncRoot) + { + _reloadDelayCancellationTokenSource?.Cancel(); + _reloadDelayCancellationTokenSource?.Dispose(); + _reloadDelayCancellationTokenSource = null; + + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + + _watchers.Clear(); + } + + _reloadGate.Dispose(); + } + + private void ResetWatchers() + { + lock (_syncRoot) + { + foreach (var watcher in _watchers) + { + watcher.EnableRaisingEvents = false; + watcher.Dispose(); + } + + _watchers = BuildWatchers(); + } + } + + private List BuildWatchers() + { + var directories = _watchedFilePaths.Keys + .Append(_resolvedDashboardPath) + .Select(static path => Path.GetDirectoryName(path)) + .Where(static path => !string.IsNullOrWhiteSpace(path)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .ToArray(); + + var watchers = new List(directories.Length); + foreach (var directory in directories) + { + Directory.CreateDirectory(directory!); + + var watcher = new FileSystemWatcher(directory!) + { + IncludeSubdirectories = false, + NotifyFilter = NotifyFilters.FileName | NotifyFilters.LastWrite | NotifyFilters.CreationTime | NotifyFilters.Size + }; + + watcher.Changed += OnWatchedFileChanged; + watcher.Created += OnWatchedFileChanged; + watcher.Deleted += OnWatchedFileChanged; + watcher.Renamed += OnWatchedFileRenamed; + watcher.EnableRaisingEvents = true; + watchers.Add(watcher); + } + + _logger.LogInformation( + "Started YAML hot-reload watchers for {DirectoryCount} configuration directory/directories.", + watchers.Count); + + return watchers; + } + + private void OnWatchedFileChanged(object sender, FileSystemEventArgs e) + { + if (!ShouldReloadForPath(e.FullPath)) + { + return; + } + + ScheduleReload(e.FullPath, e.ChangeType.ToString()); + } + + private void OnWatchedFileRenamed(object sender, RenamedEventArgs e) + { + if (!ShouldReloadForPath(e.OldFullPath) && !ShouldReloadForPath(e.FullPath)) + { + return; + } + + ScheduleReload(e.FullPath, $"Renamed from {e.OldName}"); + } + + private bool ShouldReloadForPath(string path) + { + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var fullPath = Path.GetFullPath(path); + return _watchedFilePaths.ContainsKey(fullPath) || + string.Equals(fullPath, _resolvedDashboardPath, StringComparison.OrdinalIgnoreCase); + } + + private void ScheduleReload(string path, string reason) + { + _logger.LogInformation( + "Detected YAML configuration change for {ChangedPath} ({Reason}). Scheduling reload.", + path, + reason); + + CancellationTokenSource delayCts; + + lock (_syncRoot) + { + _reloadDelayCancellationTokenSource?.Cancel(); + _reloadDelayCancellationTokenSource?.Dispose(); + _reloadDelayCancellationTokenSource = CancellationTokenSource.CreateLinkedTokenSource(_stoppingToken); + delayCts = _reloadDelayCancellationTokenSource; + } + + _ = Task.Run(async () => + { + try + { + await Task.Delay(ReloadDebounce, delayCts.Token); + await ReloadAsync(delayCts.Token); + } + catch (OperationCanceledException) + { + } + }, delayCts.Token); + } + + public Task ReloadNowAsync(CancellationToken cancellationToken = default) + { + return ReloadAsync(cancellationToken); + } + + private async Task ReloadAsync(CancellationToken cancellationToken) + { + await _reloadGate.WaitAsync(cancellationToken); + try + { + _resolvedDashboardPath = ResolveConfigPath(_bootstrapOptions.ResolveDashboardConfigPath(), _environment.ContentRootPath); + var loadResult = _yamlConfigLoader.Load(_resolvedDashboardPath); + + _dashboardConfig.CopyFrom(loadResult.Config); + _configurationWarningState.UpdateWarnings(loadResult.Warnings); + + _watchedFilePaths.Clear(); + foreach (var path in loadResult.WatchedFilePaths) + { + _watchedFilePaths.TryAdd(Path.GetFullPath(path), 0); + } + + ResetWatchers(); + await _pollingSchedulerService.ReloadConfigurationAsync(cancellationToken); + + foreach (var warning in loadResult.Warnings) + { + _logger.LogWarning("{ConfigurationWarning}", warning); + } + + _logger.LogInformation( + "YAML hot-reload applied successfully with {EndpointCount} configured endpoints.", + _dashboardConfig.Endpoints.Count); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + } + catch (Exception ex) + { + var warnings = new[] + { + $"Configuration reload failed. The last successfully loaded configuration is still active. {ex.Message}" + }; + _configurationWarningState.UpdateWarnings(warnings); + + _logger.LogError( + ex, + "YAML hot-reload failed. The last successfully loaded configuration remains active."); + } + finally + { + _reloadGate.Release(); + } + } + + private static string ResolveConfigPath(string configuredPath, string contentRootPath) + { + var configPath = string.IsNullOrWhiteSpace(configuredPath) + ? "dashboard.yaml" + : configuredPath; + + return Path.IsPathRooted(configPath) + ? Path.GetFullPath(configPath) + : Path.GetFullPath(Path.Combine(contentRootPath, configPath)); + } +} diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfigLoadResult.cs b/src/ApiHealthDashboard/Configuration/DashboardConfigLoadResult.cs index 38fb34c..7a16973 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardConfigLoadResult.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardConfigLoadResult.cs @@ -5,4 +5,6 @@ public sealed class DashboardConfigLoadResult public required DashboardConfig Config { get; init; } public IReadOnlyList Warnings { get; init; } = []; + + public IReadOnlyList WatchedFilePaths { get; init; } = []; } diff --git a/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs b/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs index 9fd3dac..31092cb 100644 --- a/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs +++ b/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs @@ -3,4 +3,6 @@ namespace ApiHealthDashboard.Configuration; public interface IYamlConfigLoader { DashboardConfigLoadResult Load(string path); + + DashboardConfigLoadResult LoadSelectedEndpoints(string dashboardPath, IEnumerable endpointFilePaths); } diff --git a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs index 9a696da..6969b69 100644 --- a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs +++ b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs @@ -21,6 +21,7 @@ public YamlConfigLoader(DashboardConfigValidator validator) public DashboardConfigLoadResult Load(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path); + var watchedFilePaths = new List { Path.GetFullPath(path) }; if (!File.Exists(path)) { @@ -30,7 +31,8 @@ public DashboardConfigLoadResult Load(string path) Warnings = [ $"Dashboard configuration file '{path}' was not found. The dashboard started with no configured endpoints." - ] + ], + WatchedFilePaths = watchedFilePaths }; } @@ -52,6 +54,7 @@ public DashboardConfigLoadResult Load(string path) foreach (var endpointFilePath in dashboardConfig.EndpointFiles) { var resolvedEndpointFilePath = ResolveConfigPath(endpointFilePath, dashboardDirectory); + watchedFilePaths.Add(resolvedEndpointFilePath); if (!File.Exists(resolvedEndpointFilePath)) { warnings.Add($"Endpoint configuration file '{resolvedEndpointFilePath}' was not found. It was skipped."); @@ -73,7 +76,75 @@ public DashboardConfigLoadResult Load(string path) return new DashboardConfigLoadResult { Config = mergedConfig, - Warnings = warnings + Warnings = warnings, + WatchedFilePaths = watchedFilePaths + }; + } + + public DashboardConfigLoadResult LoadSelectedEndpoints(string dashboardPath, IEnumerable endpointFilePaths) + { + ArgumentException.ThrowIfNullOrWhiteSpace(dashboardPath); + ArgumentNullException.ThrowIfNull(endpointFilePaths); + + var normalizedFiles = NormalizeFileList([.. endpointFilePaths]); + var warnings = new List(); + var watchedFilePaths = new List { Path.GetFullPath(dashboardPath) }; + DashboardSettings dashboardSettings; + string dashboardDirectory; + + if (!File.Exists(dashboardPath)) + { + dashboardSettings = new DashboardSettings(); + dashboardDirectory = Path.GetDirectoryName(Path.GetFullPath(dashboardPath)) ?? Directory.GetCurrentDirectory(); + warnings.Add( + $"Dashboard configuration file '{dashboardPath}' was not found. Default dashboard settings were used for CLI execution."); + } + else + { + var dashboardConfig = DeserializeDashboardConfig(dashboardPath); + Normalize(dashboardConfig); + dashboardSettings = dashboardConfig.Dashboard; + dashboardDirectory = Path.GetDirectoryName(Path.GetFullPath(dashboardPath)) ?? Directory.GetCurrentDirectory(); + } + + var resolvedFiles = new List(); + var endpoints = new List(); + + foreach (var endpointFilePath in normalizedFiles) + { + var resolvedEndpointFilePath = ResolveConfigPath(endpointFilePath, dashboardDirectory); + watchedFilePaths.Add(resolvedEndpointFilePath); + resolvedFiles.Add(resolvedEndpointFilePath); + + if (!File.Exists(resolvedEndpointFilePath)) + { + warnings.Add($"Endpoint configuration file '{resolvedEndpointFilePath}' was not found. It was skipped."); + continue; + } + + endpoints.AddRange(LoadEndpointsFromFile(resolvedEndpointFilePath)); + } + + var mergedConfig = new DashboardConfig + { + Dashboard = dashboardSettings, + EndpointFiles = resolvedFiles, + Endpoints = endpoints + }; + + Normalize(mergedConfig); + + var errors = _validator.Validate(mergedConfig); + if (errors.Count > 0) + { + throw new DashboardConfigurationException(dashboardPath, errors); + } + + return new DashboardConfigLoadResult + { + Config = mergedConfig, + Warnings = warnings, + WatchedFilePaths = watchedFilePaths }; } @@ -278,18 +349,7 @@ endpoint.TimeoutSeconds is not null || private static EndpointConfig CloneEndpoint(EndpointConfig endpoint) { - return new EndpointConfig - { - Id = endpoint.Id, - Name = endpoint.Name, - Url = endpoint.Url, - Enabled = endpoint.Enabled, - FrequencySeconds = endpoint.FrequencySeconds, - TimeoutSeconds = endpoint.TimeoutSeconds, - Headers = new Dictionary(endpoint.Headers, StringComparer.OrdinalIgnoreCase), - IncludeChecks = [.. endpoint.IncludeChecks], - ExcludeChecks = [.. endpoint.ExcludeChecks] - }; + return endpoint.Clone(); } private sealed class EndpointFileDocument diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index c1f2e23..5507054 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -1,11 +1,24 @@ +using ApiHealthDashboard.Cli; using ApiHealthDashboard.Configuration; using ApiHealthDashboard.Parsing; using ApiHealthDashboard.Scheduling; using ApiHealthDashboard.Services; using ApiHealthDashboard.State; +using Microsoft.Extensions.Logging.Console; using Microsoft.Extensions.Options; var builder = WebApplication.CreateBuilder(args); +var cliParseResult = CliOptions.Parse(args); + +if (cliParseResult.IsCliMode) +{ + builder.Logging.ClearProviders(); + builder.Logging.AddConsole(options => + { + options.LogToStandardErrorThreshold = LogLevel.Trace; + options.FormatterName = ConsoleFormatterNames.Simple; + }); +} builder.Configuration.AddEnvironmentVariables(prefix: "APIHEALTHDASHBOARD_"); @@ -60,7 +73,7 @@ return new ConfigurationWarningState(loadResult.Warnings); }); builder.Services.AddSingleton(static serviceProvider => - serviceProvider.GetRequiredService().Config); + serviceProvider.GetRequiredService().Config.Clone()); builder.Services.AddSingleton(static serviceProvider => { var config = serviceProvider.GetRequiredService(); @@ -79,14 +92,94 @@ builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => serviceProvider.GetRequiredService()); builder.Services.AddHostedService(static serviceProvider => serviceProvider.GetRequiredService()); +builder.Services.AddSingleton(); +builder.Services.AddHostedService(static serviceProvider => + serviceProvider.GetRequiredService()); builder.Services.AddRazorPages(); -var app = builder.Build(); +await using var app = builder.Build(); + +if (cliParseResult.IsCliMode) +{ + if (cliParseResult.IsHelpRequested) + { + Console.WriteLine(CliOptions.GetHelpText()); + return; + } + + if (!cliParseResult.IsValid || cliParseResult.Options is null) + { + Console.Error.WriteLine(cliParseResult.ErrorMessage ?? "Invalid CLI arguments."); + Console.Error.WriteLine(); + Console.Error.WriteLine(CliOptions.GetHelpText()); + Environment.ExitCode = 2; + return; + } + + try + { + var cliOptions = cliParseResult.Options; + var bootstrapOptions = app.Services.GetRequiredService>().Value; + var yamlLoader = app.Services.GetRequiredService(); + var httpClientFactory = app.Services.GetRequiredService(); + var loggerFactory = app.Services.GetRequiredService(); + var timeProvider = app.Services.GetRequiredService(); + + var resolvedDashboardPath = ResolveConfigPath( + string.IsNullOrWhiteSpace(cliOptions.DashboardConfigPathOverride) + ? bootstrapOptions.ResolveDashboardConfigPath() + : cliOptions.DashboardConfigPathOverride, + app.Environment.ContentRootPath); + + var loadResult = cliOptions.RunAll + ? yamlLoader.Load(resolvedDashboardPath) + : yamlLoader.LoadSelectedEndpoints(resolvedDashboardPath, cliOptions.EndpointFiles); + + var poller = new EndpointPoller( + httpClientFactory, + loadResult.Config, + loggerFactory.CreateLogger()); + var parser = new HealthResponseParser(loggerFactory.CreateLogger()); + var cliExecutionService = new CliExecutionService( + poller, + parser, + timeProvider, + loggerFactory.CreateLogger()); + var report = await cliExecutionService.ExecuteAsync( + loadResult.Config, + cliOptions, + resolvedDashboardPath, + loadResult.Warnings, + CancellationToken.None); + + Console.WriteLine(CliReportSerializer.SerializeJson(report)); + + if (!string.IsNullOrWhiteSpace(cliOptions.OutputFilePath)) + { + var outputPath = ResolveOutputPath(cliOptions.OutputFilePath, app.Environment.ContentRootPath); + await CliReportSerializer.WriteToFileAsync( + report, + outputPath, + cliOptions.ResolveOutputFileFormat(), + CancellationToken.None); + } + + return; + } + catch (Exception ex) + { + app.Logger.LogError(ex, "CLI execution failed."); + Console.Error.WriteLine(ex.Message); + Environment.ExitCode = 1; + return; + } +} app.Logger.LogInformation( "Starting ApiHealthDashboard in {EnvironmentName} environment with content root {ContentRoot}.", @@ -145,10 +238,17 @@ static string ResolveConfigPath(string configuredPath, string contentRootPath) { var configPath = string.IsNullOrWhiteSpace(configuredPath) - ? "endpoints.yaml" + ? "dashboard.yaml" : configuredPath; return Path.IsPathRooted(configPath) ? Path.GetFullPath(configPath) : Path.GetFullPath(Path.Combine(contentRootPath, configPath)); } + +static string ResolveOutputPath(string configuredPath, string contentRootPath) +{ + return Path.IsPathRooted(configuredPath) + ? Path.GetFullPath(configuredPath) + : Path.GetFullPath(Path.Combine(contentRootPath, configuredPath)); +} diff --git a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs index bf446fb..09e4674 100644 --- a/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs +++ b/src/ApiHealthDashboard/Scheduling/PollingSchedulerService.cs @@ -12,13 +12,16 @@ public sealed class PollingSchedulerService : BackgroundService, IEndpointSchedu private const string ScheduledTriggerSource = "scheduled"; private readonly DashboardConfig _dashboardConfig; - private readonly Dictionary _endpointsById; - private readonly Dictionary _endpointLocks; private readonly IEndpointPoller _endpointPoller; private readonly IHealthResponseParser _healthResponseParser; private readonly ILogger _logger; private readonly IEndpointStateStore _stateStore; private readonly TimeProvider _timeProvider; + private readonly object _syncRoot = new(); + private readonly Dictionary _loopRegistrations = new(StringComparer.OrdinalIgnoreCase); + private readonly Dictionary _endpointLocks = new(StringComparer.OrdinalIgnoreCase); + private CancellationToken _serviceCancellationToken; + private bool _started; public PollingSchedulerService( DashboardConfig dashboardConfig, @@ -34,38 +37,43 @@ public PollingSchedulerService( _healthResponseParser = healthResponseParser; _timeProvider = timeProvider; _logger = logger; - _endpointsById = dashboardConfig.Endpoints.ToDictionary( - static endpoint => endpoint.Id, - StringComparer.OrdinalIgnoreCase); - _endpointLocks = dashboardConfig.Endpoints.ToDictionary( - static endpoint => endpoint.Id, - static _ => new SemaphoreSlim(1, 1), - StringComparer.OrdinalIgnoreCase); } - protected override Task ExecuteAsync(CancellationToken stoppingToken) + protected override async Task ExecuteAsync(CancellationToken stoppingToken) { - var tasks = _dashboardConfig.Endpoints - .Where(static endpoint => endpoint.Enabled) - .Select(endpoint => RunEndpointLoopAsync(endpoint, stoppingToken)) - .ToArray(); - _logger.LogInformation( "Starting polling scheduler for {EnabledEndpointCount} enabled endpoints out of {TotalEndpointCount} configured endpoints.", - tasks.Length, + _dashboardConfig.Endpoints.Count(static endpoint => endpoint.Enabled), _dashboardConfig.Endpoints.Count); - return tasks.Length == 0 - ? Task.CompletedTask - : Task.WhenAll(tasks); + lock (_syncRoot) + { + _serviceCancellationToken = stoppingToken; + _started = true; + } + + await ApplyCurrentConfigurationAsync(stoppingToken); + + try + { + await Task.Delay(Timeout.InfiniteTimeSpan, stoppingToken); + } + catch (OperationCanceledException) when (stoppingToken.IsCancellationRequested) + { + } + finally + { + await StopAllLoopsAsync(); + } } public async Task RefreshEndpointAsync(string endpointId, CancellationToken cancellationToken = default) { ArgumentException.ThrowIfNullOrWhiteSpace(endpointId); - if (!_endpointsById.TryGetValue(endpointId, out var endpoint) || - !_endpointLocks.TryGetValue(endpointId, out var endpointLock)) + var endpoint = GetEndpoint(endpointId); + var endpointLock = GetOrCreateEndpointLock(endpointId); + if (endpoint is null) { return false; } @@ -113,6 +121,22 @@ public async Task RefreshAllEnabledAsync(CancellationToken cancellationToke return results.Count(static result => result); } + public async Task ReloadConfigurationAsync(CancellationToken cancellationToken = default) + { + _logger.LogInformation( + "Reloading polling scheduler for {EnabledEndpointCount} enabled endpoints out of {TotalEndpointCount} configured endpoints.", + _dashboardConfig.Endpoints.Count(static endpoint => endpoint.Enabled), + _dashboardConfig.Endpoints.Count); + + if (!_started) + { + _stateStore.Initialize(_dashboardConfig.Endpoints); + return; + } + + await ApplyCurrentConfigurationAsync(cancellationToken); + } + private async Task RunEndpointLoopAsync(EndpointConfig endpoint, CancellationToken cancellationToken) { var interval = TimeSpan.FromSeconds(endpoint.FrequencySeconds); @@ -123,14 +147,7 @@ private async Task RunEndpointLoopAsync(EndpointConfig endpoint, CancellationTok { await Task.Delay(interval, _timeProvider, cancellationToken); - if (!_endpointLocks.TryGetValue(endpoint.Id, out var endpointLock)) - { - _logger.LogWarning( - "Skipping scheduled poll for endpoint {EndpointId} because no endpoint lock was configured.", - endpoint.Id); - continue; - } - + var endpointLock = GetOrCreateEndpointLock(endpoint.Id); var lockAcquired = false; try @@ -256,4 +273,109 @@ value is string errorText && parserError = string.Empty; return false; } + + private EndpointConfig? GetEndpoint(string endpointId) + { + return _dashboardConfig.Endpoints.FirstOrDefault( + endpoint => string.Equals(endpoint.Id, endpointId, StringComparison.OrdinalIgnoreCase)); + } + + private SemaphoreSlim GetOrCreateEndpointLock(string endpointId) + { + lock (_syncRoot) + { + if (_endpointLocks.TryGetValue(endpointId, out var existingLock)) + { + return existingLock; + } + + var endpointLock = new SemaphoreSlim(1, 1); + _endpointLocks[endpointId] = endpointLock; + return endpointLock; + } + } + + private async Task ApplyCurrentConfigurationAsync(CancellationToken cancellationToken) + { + EndpointLoopRegistration[] previousRegistrations; + List enabledEndpoints; + CancellationToken serviceToken; + + lock (_syncRoot) + { + previousRegistrations = _loopRegistrations.Values.ToArray(); + _loopRegistrations.Clear(); + enabledEndpoints = _dashboardConfig.Endpoints + .Where(static endpoint => endpoint.Enabled) + .Select(static endpoint => endpoint.Clone()) + .ToList(); + serviceToken = _serviceCancellationToken; + } + + foreach (var registration in previousRegistrations) + { + registration.CancellationTokenSource.Cancel(); + } + + if (previousRegistrations.Length > 0) + { + try + { + await Task.WhenAll(previousRegistrations.Select(static registration => registration.Task)); + } + catch (OperationCanceledException) + { + } + } + + _stateStore.Initialize(_dashboardConfig.Endpoints); + + foreach (var endpoint in enabledEndpoints) + { + cancellationToken.ThrowIfCancellationRequested(); + + var loopCts = CancellationTokenSource.CreateLinkedTokenSource(serviceToken); + var loopTask = RunEndpointLoopAsync(endpoint, loopCts.Token); + + lock (_syncRoot) + { + _endpointLocks[endpoint.Id] = GetOrCreateEndpointLock(endpoint.Id); + _loopRegistrations[endpoint.Id] = new EndpointLoopRegistration(loopCts, loopTask); + } + } + } + + private async Task StopAllLoopsAsync() + { + EndpointLoopRegistration[] registrations; + + lock (_syncRoot) + { + registrations = _loopRegistrations.Values.ToArray(); + _loopRegistrations.Clear(); + _started = false; + } + + foreach (var registration in registrations) + { + registration.CancellationTokenSource.Cancel(); + } + + if (registrations.Length == 0) + { + return; + } + + try + { + await Task.WhenAll(registrations.Select(static registration => registration.Task)); + } + catch (OperationCanceledException) + { + } + } + + private sealed record EndpointLoopRegistration( + CancellationTokenSource CancellationTokenSource, + Task Task); } diff --git a/src/ApiHealthDashboard/State/InMemoryEndpointStateStore.cs b/src/ApiHealthDashboard/State/InMemoryEndpointStateStore.cs index 37014a1..d095de3 100644 --- a/src/ApiHealthDashboard/State/InMemoryEndpointStateStore.cs +++ b/src/ApiHealthDashboard/State/InMemoryEndpointStateStore.cs @@ -54,6 +54,7 @@ public void Initialize(IEnumerable endpoints) lock (_syncRoot) { + var existingStates = new Dictionary(_states, StringComparer.OrdinalIgnoreCase); _states.Clear(); foreach (var endpoint in endpoints) @@ -63,6 +64,15 @@ public void Initialize(IEnumerable endpoints) continue; } + if (existingStates.TryGetValue(endpoint.Id, out var existingState)) + { + var preservedState = existingState.Clone(); + preservedState.EndpointId = endpoint.Id; + preservedState.EndpointName = endpoint.Name; + _states[endpoint.Id] = preservedState; + continue; + } + _states[endpoint.Id] = CreateInitialState(endpoint); } } diff --git a/tests/ApiHealthDashboard.Tests/Cli/CliExecutionServiceTests.cs b/tests/ApiHealthDashboard.Tests/Cli/CliExecutionServiceTests.cs new file mode 100644 index 0000000..07362b5 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Cli/CliExecutionServiceTests.cs @@ -0,0 +1,199 @@ +using ApiHealthDashboard.Cli; +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Parsing; +using ApiHealthDashboard.Services; +using ApiHealthDashboard.Tests.Logging; + +namespace ApiHealthDashboard.Tests.Cli; + +public sealed class CliExecutionServiceTests +{ + [Fact] + public async Task ExecuteAsync_WithSuiteMode_ProducesSummaryAndSnapshot() + { + var poller = new StubEndpointPoller( + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["orders-api"] = new() + { + Kind = PollResultKind.Success, + CheckedUtc = DateTimeOffset.Parse("2026-03-18T00:00:00Z"), + DurationMs = 42, + StatusCode = System.Net.HttpStatusCode.OK, + ResponseBody = """{"status":"Healthy"}""" + }, + ["billing-api"] = new() + { + Kind = PollResultKind.HttpError, + CheckedUtc = DateTimeOffset.Parse("2026-03-18T00:01:00Z"), + DurationMs = 15, + StatusCode = System.Net.HttpStatusCode.NotFound, + ResponseBody = "not found", + ErrorMessage = "Endpoint returned HTTP 404 (NotFound)." + } + }); + var parser = new StubHealthResponseParser( + new HealthSnapshot + { + OverallStatus = "Healthy", + RetrievedUtc = DateTimeOffset.Parse("2026-03-18T00:00:00Z"), + DurationMs = 42, + RawPayload = """{"status":"Healthy"}""", + Metadata = new Dictionary + { + ["region"] = "apac" + }, + Nodes = + [ + new HealthNode + { + Name = "self", + Status = "Healthy" + } + ] + }); + var logger = new TestLogger(); + var service = new CliExecutionService(poller, parser, TimeProvider.System, logger); + var config = new DashboardConfig + { + Dashboard = new DashboardSettings + { + RequestTimeoutSecondsDefault = 10 + }, + Endpoints = + [ + new EndpointConfig + { + Id = "orders-api", + Name = "Orders API", + Url = "https://orders.example.com/health", + Enabled = true, + FrequencySeconds = 30 + }, + new EndpointConfig + { + Id = "billing-api", + Name = "Billing API", + Url = "https://billing.example.com/health", + Enabled = true, + FrequencySeconds = 60 + } + ] + }; + var options = new CliOptions + { + IsCliMode = true, + RunAll = true + }; + + var report = await service.ExecuteAsync( + config, + options, + "dashboard.yaml", + ["missing endpoint file warning"], + CancellationToken.None); + + Assert.Equal("suite", report.Mode); + Assert.Equal("dashboard.yaml", report.DashboardConfigPath); + Assert.Single(report.ConfigurationWarnings); + Assert.Equal(2, report.Summary.TotalEndpoints); + Assert.Equal(2, report.Summary.ExecutedEndpoints); + Assert.Equal(1, report.Summary.SuccessfulPolls); + Assert.Equal(1, report.Summary.FailedPolls); + Assert.Equal("Healthy", report.Summary.HealthyEndpoints == 1 ? "Healthy" : "Unknown"); + Assert.Equal("Healthy", report.Summary.OverallStatus); + + var successEndpoint = Assert.Single(report.Endpoints.Where(static endpoint => endpoint.Id == "orders-api")); + Assert.Equal("Executed", successEndpoint.ExecutionState); + Assert.Equal("Healthy", successEndpoint.Status); + Assert.NotNull(successEndpoint.Snapshot); + Assert.Single(successEndpoint.Snapshot!.Nodes); + Assert.Single(successEndpoint.Snapshot.MetadataEntries); + + var failedEndpoint = Assert.Single(report.Endpoints.Where(static endpoint => endpoint.Id == "billing-api")); + Assert.Equal("Executed", failedEndpoint.ExecutionState); + Assert.Equal("HttpError", failedEndpoint.PollResultKind); + Assert.Equal("Unknown", failedEndpoint.Status); + Assert.Null(failedEndpoint.Snapshot); + } + + [Fact] + public async Task ExecuteAsync_WithDisabledEndpoint_SkipsExecution() + { + var poller = new StubEndpointPoller(new Dictionary(StringComparer.OrdinalIgnoreCase)); + var parser = new StubHealthResponseParser(new HealthSnapshot()); + var logger = new TestLogger(); + var service = new CliExecutionService(poller, parser, TimeProvider.System, logger); + var config = new DashboardConfig + { + Dashboard = new DashboardSettings(), + Endpoints = + [ + new EndpointConfig + { + Id = "disabled-api", + Name = "Disabled API", + Url = "https://disabled.example.com/health", + Enabled = false, + FrequencySeconds = 30 + } + ] + }; + var options = new CliOptions + { + IsCliMode = true, + EndpointFiles = ["endpoints/disabled-api.yaml"] + }; + + var report = await service.ExecuteAsync( + config, + options, + "dashboard.yaml", + [], + CancellationToken.None); + + var endpoint = Assert.Single(report.Endpoints); + Assert.Equal("selected-endpoints", report.Mode); + Assert.Equal("Skipped", endpoint.ExecutionState); + Assert.Equal("Skipped", endpoint.PollResultKind); + Assert.Equal("Endpoint is disabled.", endpoint.ErrorMessage); + Assert.Equal(1, report.Summary.SkippedEndpoints); + Assert.Empty(poller.PolledEndpointIds); + } + + private sealed class StubEndpointPoller : IEndpointPoller + { + private readonly IReadOnlyDictionary _results; + + public StubEndpointPoller(IReadOnlyDictionary results) + { + _results = results; + } + + public List PolledEndpointIds { get; } = new(); + + public Task PollAsync(EndpointConfig endpoint, CancellationToken cancellationToken) + { + PolledEndpointIds.Add(endpoint.Id); + return Task.FromResult(_results[endpoint.Id]); + } + } + + private sealed class StubHealthResponseParser : IHealthResponseParser + { + private readonly HealthSnapshot _snapshot; + + public StubHealthResponseParser(HealthSnapshot snapshot) + { + _snapshot = snapshot; + } + + public Task? CallCountTask { get; private set; } + + public HealthSnapshot Parse(EndpointConfig endpoint, string json, long durationMs) + { + return _snapshot.Clone(); + } + } +} diff --git a/tests/ApiHealthDashboard.Tests/Cli/CliOptionsTests.cs b/tests/ApiHealthDashboard.Tests/Cli/CliOptionsTests.cs new file mode 100644 index 0000000..8518eea --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Cli/CliOptionsTests.cs @@ -0,0 +1,64 @@ +using ApiHealthDashboard.Cli; + +namespace ApiHealthDashboard.Tests.Cli; + +public sealed class CliOptionsTests +{ + [Fact] + public void Parse_WithAllMode_ReturnsValidOptions() + { + var result = CliOptions.Parse(["--cli", "--all"]); + + Assert.True(result.IsCliMode); + Assert.True(result.IsValid); + Assert.NotNull(result.Options); + Assert.True(result.Options.RunAll); + Assert.Empty(result.Options.EndpointFiles); + } + + [Fact] + public void Parse_WithEndpointFiles_ReturnsValidOptions() + { + var result = CliOptions.Parse( + [ + "--cli", + "--endpoint-file", "endpoints/orders-api.yaml", + "--endpoint-file", "endpoints/billing-api.yaml", + "--output-file", "artifacts/report.xml" + ]); + + Assert.True(result.IsValid); + Assert.NotNull(result.Options); + Assert.False(result.Options.RunAll); + Assert.Equal( + [ + "endpoints/orders-api.yaml", + "endpoints/billing-api.yaml" + ], result.Options.EndpointFiles); + Assert.Equal("artifacts/report.xml", result.Options.OutputFilePath); + Assert.Equal(CliFileOutputFormat.Xml, result.Options.ResolveOutputFileFormat()); + } + + [Fact] + public void Parse_WithAllAndEndpointFile_ReturnsInvalidResult() + { + var result = CliOptions.Parse( + [ + "--cli", + "--all", + "--endpoint-file", "endpoints/orders-api.yaml" + ]); + + Assert.False(result.IsValid); + Assert.Contains("either --all or one or more --endpoint-file", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public void Parse_WithOutputFormatButNoOutputFile_ReturnsInvalidResult() + { + var result = CliOptions.Parse(["--cli", "--all", "--output-format", "xml"]); + + Assert.False(result.IsValid); + Assert.Contains("--output-format requires --output-file", result.ErrorMessage, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/tests/ApiHealthDashboard.Tests/Cli/CliReportSerializerTests.cs b/tests/ApiHealthDashboard.Tests/Cli/CliReportSerializerTests.cs new file mode 100644 index 0000000..b9cca1e --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Cli/CliReportSerializerTests.cs @@ -0,0 +1,48 @@ +using ApiHealthDashboard.Cli; + +namespace ApiHealthDashboard.Tests.Cli; + +public sealed class CliReportSerializerTests +{ + [Fact] + public void SerializeJson_UsesMachineReadableCamelCaseOutput() + { + var report = new CliExecutionReport + { + Mode = "suite", + DashboardConfigPath = "dashboard.yaml", + ExecutedUtc = "2026-03-18T00:00:00.0000000+00:00", + Summary = new CliExecutionSummary + { + TotalEndpoints = 1, + OverallStatus = "Healthy" + } + }; + + var json = CliReportSerializer.SerializeJson(report); + + Assert.Contains("\"dashboardConfigPath\"", json, StringComparison.Ordinal); + Assert.Contains("\"overallStatus\": \"Healthy\"", json, StringComparison.Ordinal); + } + + [Fact] + public void SerializeXml_WritesSuiteExecutionDocument() + { + var report = new CliExecutionReport + { + Mode = "suite", + DashboardConfigPath = "dashboard.yaml", + ExecutedUtc = "2026-03-18T00:00:00.0000000+00:00", + Summary = new CliExecutionSummary + { + TotalEndpoints = 1, + OverallStatus = "Healthy" + } + }; + + var xml = CliReportSerializer.SerializeXml(report); + + Assert.Contains("Healthy", xml, StringComparison.Ordinal); + } +} diff --git a/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs new file mode 100644 index 0000000..3bafe24 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Configuration/DashboardConfigHotReloadServiceTests.cs @@ -0,0 +1,179 @@ +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Parsing; +using ApiHealthDashboard.Scheduling; +using ApiHealthDashboard.Services; +using ApiHealthDashboard.State; +using Microsoft.Extensions.Logging.Abstractions; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Options; + +namespace ApiHealthDashboard.Tests.Configuration; + +public sealed class DashboardConfigHotReloadServiceTests : IDisposable +{ + private readonly string _tempDirectory; + + public DashboardConfigHotReloadServiceTests() + { + _tempDirectory = Path.Combine(Path.GetTempPath(), "ApiHealthDashboard.Tests", Guid.NewGuid().ToString("N")); + Directory.CreateDirectory(_tempDirectory); + } + + [Fact] + public async Task ReloadNowAsync_WhenYamlChanges_UpdatesSharedConfigAndStateStore() + { + var dashboardPath = WriteNamedConfig( + "dashboard.yaml", + """ + dashboard: + refreshUiSeconds: 10 + endpointFiles: + - endpoints/orders-api.yaml + """); + + WriteNamedConfig( + "endpoints/orders-api.yaml", + """ + id: orders-api + name: Orders API + url: https://orders.example.com/health + enabled: true + frequencySeconds: 30 + """); + + var loader = new YamlConfigLoader(new DashboardConfigValidator()); + var initialLoadResult = loader.Load(dashboardPath); + var sharedConfig = initialLoadResult.Config.Clone(); + var warningState = new ConfigurationWarningState(initialLoadResult.Warnings); + var stateStore = new InMemoryEndpointStateStore(sharedConfig.Endpoints); + stateStore.Upsert(new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy" + }); + + var scheduler = new PollingSchedulerService( + sharedConfig, + stateStore, + new StubEndpointPoller(), + new StubHealthResponseParser(), + TimeProvider.System, + NullLogger.Instance); + + var service = new DashboardConfigHotReloadService( + sharedConfig, + initialLoadResult, + warningState, + scheduler, + loader, + Options.Create(new DashboardBootstrapOptions + { + DashboardConfigPath = dashboardPath + }), + new TestHostEnvironment + { + ContentRootPath = _tempDirectory + }, + NullLogger.Instance); + + try + { + WriteNamedConfig( + "dashboard.yaml", + """ + dashboard: + refreshUiSeconds: 25 + endpointFiles: + - endpoints/orders-api.yaml + - endpoints/billing-api.yaml + """); + + WriteNamedConfig( + "endpoints/billing-api.yaml", + """ + id: billing-api + name: Billing API + url: https://billing.example.com/health + enabled: true + frequencySeconds: 60 + """); + + await service.ReloadNowAsync(); + + Assert.Equal(25, sharedConfig.Dashboard.RefreshUiSeconds); + Assert.Equal(2, sharedConfig.Endpoints.Count); + Assert.Contains(sharedConfig.Endpoints, static endpoint => endpoint.Id == "billing-api"); + Assert.False(warningState.HasWarnings); + + var preservedOrdersState = stateStore.Get("orders-api"); + Assert.NotNull(preservedOrdersState); + Assert.Equal("Healthy", preservedOrdersState!.Status); + + var newBillingState = stateStore.Get("billing-api"); + Assert.NotNull(newBillingState); + Assert.Equal("Unknown", newBillingState!.Status); + } + finally + { + service.Dispose(); + } + } + + public void Dispose() + { + if (Directory.Exists(_tempDirectory)) + { + Directory.Delete(_tempDirectory, recursive: true); + } + } + + private string WriteNamedConfig(string relativePath, string content) + { + var path = Path.Combine(_tempDirectory, relativePath.Replace('/', Path.DirectorySeparatorChar)); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, content); + return path; + } + + private sealed class TestHostEnvironment : IHostEnvironment + { + public string EnvironmentName { get; set; } = "Development"; + + public string ApplicationName { get; set; } = "ApiHealthDashboard.Tests"; + + public string ContentRootPath { get; set; } = string.Empty; + + public Microsoft.Extensions.FileProviders.IFileProvider ContentRootFileProvider { get; set; } + = new Microsoft.Extensions.FileProviders.NullFileProvider(); + } + + private sealed class StubEndpointPoller : IEndpointPoller + { + public Task PollAsync(EndpointConfig endpoint, CancellationToken cancellationToken) + { + return Task.FromResult(new PollResult + { + Kind = PollResultKind.Success, + CheckedUtc = DateTimeOffset.UtcNow, + DurationMs = 1, + ResponseBody = """{"status":"Healthy"}""" + }); + } + } + + private sealed class StubHealthResponseParser : IHealthResponseParser + { + public HealthSnapshot Parse(EndpointConfig endpoint, string json, long durationMs) + { + return new HealthSnapshot + { + OverallStatus = "Healthy", + RetrievedUtc = DateTimeOffset.UtcNow, + DurationMs = durationMs, + RawPayload = json + }; + } + } +} diff --git a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs index c3aa0e3..2a65b79 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs @@ -269,6 +269,50 @@ public void Load_WithMissingEndpointFile_ReturnsWarningAndContinues() Assert.Single(result.Config.Endpoints); Assert.Equal("orders-api", result.Config.Endpoints[0].Id); Assert.Contains("missing.yaml", result.Warnings.Single(), StringComparison.OrdinalIgnoreCase); + Assert.Contains(result.WatchedFilePaths, path => path.EndsWith("dashboard.yaml", StringComparison.OrdinalIgnoreCase)); + Assert.Contains(result.WatchedFilePaths, path => path.EndsWith("missing.yaml", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public void LoadSelectedEndpoints_WithSpecificFiles_LoadsOnlyThoseEndpoints() + { + WriteNamedConfig( + "endpoints/orders-api.yaml", + """ + id: orders-api + name: Orders API + url: https://orders.example.com/health + frequencySeconds: 30 + """); + + WriteNamedConfig( + "endpoints/billing-api.yaml", + """ + id: billing-api + name: Billing API + url: https://billing.example.com/health + frequencySeconds: 60 + """); + + var dashboardConfigPath = WriteNamedConfig( + "dashboard.yaml", + """ + dashboard: + refreshUiSeconds: 25 + requestTimeoutSecondsDefault: 14 + endpointFiles: + - endpoints/orders-api.yaml + - endpoints/billing-api.yaml + """); + + var result = _loader.LoadSelectedEndpoints(dashboardConfigPath, ["endpoints/billing-api.yaml"]); + + Assert.Single(result.Config.Endpoints); + Assert.Equal("billing-api", result.Config.Endpoints[0].Id); + Assert.Equal(25, result.Config.Dashboard.RefreshUiSeconds); + Assert.Equal(14, result.Config.Dashboard.RequestTimeoutSecondsDefault); + Assert.Equal(Path.Combine(_tempDirectory, "endpoints", "billing-api.yaml"), result.Config.EndpointFiles[0]); + Assert.Empty(result.Warnings); } public void Dispose() diff --git a/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs new file mode 100644 index 0000000..dc59849 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Scheduling/PollingSchedulerReloadTests.cs @@ -0,0 +1,82 @@ +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Parsing; +using ApiHealthDashboard.Scheduling; +using ApiHealthDashboard.Services; +using ApiHealthDashboard.State; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ApiHealthDashboard.Tests.Scheduling; + +public sealed class PollingSchedulerReloadTests +{ + [Fact] + public async Task ReloadConfigurationAsync_WhenNotStarted_SynchronizesStateStoreToCurrentConfig() + { + var sharedConfig = new DashboardConfig + { + Endpoints = + [ + new EndpointConfig + { + Id = "orders-api", + Name = "Orders API", + Url = "https://orders.example.com/health", + Enabled = true, + FrequencySeconds = 30 + } + ] + }; + var stateStore = new InMemoryEndpointStateStore(sharedConfig.Endpoints); + stateStore.Upsert(new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy" + }); + + var scheduler = new PollingSchedulerService( + sharedConfig, + stateStore, + new NoOpEndpointPoller(), + new NoOpHealthResponseParser(), + TimeProvider.System, + NullLogger.Instance); + + sharedConfig.CopyFrom(new DashboardConfig + { + Endpoints = + [ + new EndpointConfig + { + Id = "billing-api", + Name = "Billing API", + Url = "https://billing.example.com/health", + Enabled = true, + FrequencySeconds = 60 + } + ] + }); + + await scheduler.ReloadConfigurationAsync(); + + Assert.Null(stateStore.Get("orders-api")); + Assert.NotNull(stateStore.Get("billing-api")); + } + + private sealed class NoOpEndpointPoller : IEndpointPoller + { + public Task PollAsync(EndpointConfig endpoint, CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + } + + private sealed class NoOpHealthResponseParser : IHealthResponseParser + { + public HealthSnapshot Parse(EndpointConfig endpoint, string json, long durationMs) + { + throw new NotSupportedException(); + } + } +} diff --git a/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs b/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs index a53df88..4af335f 100644 --- a/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs +++ b/tests/ApiHealthDashboard.Tests/State/InMemoryEndpointStateStoreTests.cs @@ -98,6 +98,32 @@ public void Initialize_ReplacesExistingRuntimeStateWithConfiguredEndpoints() Assert.Null(billingState.LastError); } + [Fact] + public void Initialize_PreservesExistingRuntimeStateForMatchingEndpointIds() + { + var store = new InMemoryEndpointStateStore( + [new EndpointConfig { Id = "orders-api", Name = "Orders API", Url = "https://orders.example.com/health" }]); + + store.Upsert(new EndpointState + { + EndpointId = "orders-api", + EndpointName = "Orders API", + Status = "Healthy", + LastError = "none" + }); + + store.Initialize( + [ + new EndpointConfig { Id = "orders-api", Name = "Orders API Reloaded", Url = "https://orders.example.com/health" } + ]); + + var state = store.Get("orders-api"); + Assert.NotNull(state); + Assert.Equal("Healthy", state!.Status); + Assert.Equal("Orders API Reloaded", state.EndpointName); + Assert.Equal("none", state.LastError); + } + [Fact] public async Task Upsert_AllowsConcurrentUpdatesWithoutCorruptingTheStore() {