From 281572753f49084377cd1f195bafad76bb7d00bb Mon Sep 17 00:00:00 2001 From: gali Date: Wed, 18 Mar 2026 15:53:56 +0800 Subject: [PATCH] Refactor dashboard configuration to support multiple endpoint YAML files and update related documentation --- README.md | 22 +- .../ApiHealthDashboard.csproj | 4 +- .../DashboardBootstrapOptions.cs | 11 +- .../Configuration/DashboardConfig.cs | 2 + .../Configuration/YamlConfigLoader.cs | 197 ++++++++++++++++-- src/ApiHealthDashboard/Pages/Index.cshtml | 41 +++- src/ApiHealthDashboard/Program.cs | 2 +- .../appsettings.Development.json | 2 +- src/ApiHealthDashboard/appsettings.json | 2 +- src/ApiHealthDashboard/dashboard.yaml | 8 + src/ApiHealthDashboard/endpoints.yaml | 30 --- .../endpoints/billing-api.yaml | 9 + .../endpoints/orders-api.yaml | 14 ++ src/ApiHealthDashboard/wwwroot/css/site.css | 41 ++++ src/ApiHealthDashboard/wwwroot/js/site.js | 35 ++++ .../Configuration/YamlConfigLoaderTests.cs | 71 +++++++ 16 files changed, 423 insertions(+), 68 deletions(-) create mode 100644 src/ApiHealthDashboard/dashboard.yaml delete mode 100644 src/ApiHealthDashboard/endpoints.yaml create mode 100644 src/ApiHealthDashboard/endpoints/billing-api.yaml create mode 100644 src/ApiHealthDashboard/endpoints/orders-api.yaml diff --git a/README.md b/README.md index 647c160..79f82dc 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,7 @@ Implemented so far: - Phase 14: GitHub Actions CI/CD and Dependabot automation Not implemented yet: -- CI/CD workflows +- Backlog items tracked for post-v1 work ## Solution Layout @@ -47,7 +47,8 @@ Not implemented yet: | |-- State/ | |-- Pages/ | |-- wwwroot/ -| `-- endpoints.yaml +| |-- dashboard.yaml +| `-- endpoints/ `-- tests/ `-- ApiHealthDashboard.Tests/ ``` @@ -72,16 +73,18 @@ Current UI pages: ### YAML Configuration -Dashboard configuration is loaded at startup from [`src/ApiHealthDashboard/endpoints.yaml`](src/ApiHealthDashboard/endpoints.yaml). +Dashboard configuration is loaded at startup from [`src/ApiHealthDashboard/dashboard.yaml`](src/ApiHealthDashboard/dashboard.yaml). Current configuration support: - `dashboard.refreshUiSeconds` - `dashboard.requestTimeoutSecondsDefault` - `dashboard.showRawPayload` +- `dashboard.endpointFiles` for loading endpoints from one or more separate YAML files - endpoint `id`, `name`, `url`, `enabled`, `frequencySeconds`, `timeoutSeconds` - endpoint `headers`, `includeChecks`, `excludeChecks` - `${ENV_VAR}` substitution in YAML values -- `endpoints.yaml` is copied to both build and publish output by default +- 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 Validation currently checks: - required endpoint id, name, and url @@ -179,6 +182,7 @@ The dashboard home page now acts as an operational summary instead of a transiti Current dashboard behavior: - shows configured, enabled, disabled, and actively polling endpoint counts - 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 - renders a live endpoint table with last check, duration, error summary, and manual refresh actions - surfaces degraded and unhealthy endpoints in an active issues panel for faster triage - shows a clearer empty state when no endpoints are configured @@ -225,7 +229,7 @@ The app has now been validated as a portable publishable deployment, not just a Validated deployment behavior: - framework-dependent publish completes successfully - self-contained Windows `win-x64` publish completes successfully -- published output includes `endpoints.yaml`, `appsettings.json`, and bundled local AdminLTE assets +- published output includes `dashboard.yaml`, endpoint YAML files, `appsettings.json`, and bundled local AdminLTE assets - both published variants run directly from their publish folders and return HTTP `200` for `/` - bundled CSS assets load from the published folders without relying on external CDNs - no database package or runtime dependency is required @@ -256,14 +260,16 @@ From the repository root: dotnet run --project .\src\ApiHealthDashboard\ApiHealthDashboard.csproj ``` -The app reads the YAML path from the `Bootstrap:EndpointsConfigPath` setting in: +The app reads the dashboard YAML path from the `Bootstrap:DashboardConfigPath` setting in: - [`src/ApiHealthDashboard/appsettings.json`](src/ApiHealthDashboard/appsettings.json) - [`src/ApiHealthDashboard/appsettings.Development.json`](src/ApiHealthDashboard/appsettings.Development.json) +The current primary setting is `Bootstrap:DashboardConfigPath`. `Bootstrap:EndpointsConfigPath` is still accepted as a legacy fallback. + You can also override it with an environment variable: ```powershell -$env:APIHEALTHDASHBOARD_BOOTSTRAP__ENDPOINTSCONFIGPATH="D:\path\to\endpoints.yaml" +$env:APIHEALTHDASHBOARD_BOOTSTRAP__DASHBOARDCONFIGPATH="D:\path\to\dashboard.yaml" dotnet run --project .\src\ApiHealthDashboard\ApiHealthDashboard.csproj ``` @@ -288,7 +294,7 @@ dotnet publish .\src\ApiHealthDashboard\ApiHealthDashboard.csproj -c Release -r ``` Deployment notes: -- the published folder is runnable on its own with the included `endpoints.yaml` +- the published folder is runnable on its own with the included `dashboard.yaml` and endpoint YAML files - local UI assets under `wwwroot/adminlte` remain bundled after publish - no additional database or Node.js setup is required for the published app diff --git a/src/ApiHealthDashboard/ApiHealthDashboard.csproj b/src/ApiHealthDashboard/ApiHealthDashboard.csproj index 50d8daa..fb26f3a 100644 --- a/src/ApiHealthDashboard/ApiHealthDashboard.csproj +++ b/src/ApiHealthDashboard/ApiHealthDashboard.csproj @@ -11,8 +11,8 @@ - - + + PreserveNewest PreserveNewest diff --git a/src/ApiHealthDashboard/Configuration/DashboardBootstrapOptions.cs b/src/ApiHealthDashboard/Configuration/DashboardBootstrapOptions.cs index c16ce54..8c33b2f 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardBootstrapOptions.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardBootstrapOptions.cs @@ -4,5 +4,14 @@ public sealed class DashboardBootstrapOptions { public const string SectionName = "Bootstrap"; - public string EndpointsConfigPath { get; set; } = "endpoints.yaml"; + public string DashboardConfigPath { get; set; } = "dashboard.yaml"; + + public string? EndpointsConfigPath { get; set; } + + public string ResolveDashboardConfigPath() + { + return !string.IsNullOrWhiteSpace(DashboardConfigPath) + ? DashboardConfigPath + : EndpointsConfigPath ?? "dashboard.yaml"; + } } diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs index 403d63b..01f98ef 100644 --- a/src/ApiHealthDashboard/Configuration/DashboardConfig.cs +++ b/src/ApiHealthDashboard/Configuration/DashboardConfig.cs @@ -4,6 +4,8 @@ public sealed class DashboardConfig { public DashboardSettings Dashboard { get; set; } = new(); + public List EndpointFiles { get; set; } = new(); + public List Endpoints { get; set; } = new(); } diff --git a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs index 9b5a52c..d94d7f2 100644 --- a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs +++ b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs @@ -22,33 +22,115 @@ public DashboardConfig Load(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path); - if (!File.Exists(path)) + var dashboardConfig = DeserializeDashboardConfig(path); + Normalize(dashboardConfig); + + var mergedConfig = new DashboardConfig { - throw new DashboardConfigurationException( - path, - [$"Configuration file '{path}' was not found."]); + Dashboard = dashboardConfig.Dashboard, + EndpointFiles = new List(dashboardConfig.EndpointFiles), + Endpoints = dashboardConfig.Endpoints + .Select(CloneEndpoint) + .ToList() + }; + + var dashboardDirectory = Path.GetDirectoryName(Path.GetFullPath(path)) ?? Directory.GetCurrentDirectory(); + + foreach (var endpointFilePath in dashboardConfig.EndpointFiles) + { + var resolvedEndpointFilePath = ResolveConfigPath(endpointFilePath, dashboardDirectory); + var fileEndpoints = LoadEndpointsFromFile(resolvedEndpointFilePath); + mergedConfig.Endpoints.AddRange(fileEndpoints); + } + + Normalize(mergedConfig); + + var errors = _validator.Validate(mergedConfig); + if (errors.Count > 0) + { + throw new DashboardConfigurationException(path, errors); } - string yaml; + return mergedConfig; + } + + private DashboardConfig DeserializeDashboardConfig(string path) + { + var yaml = ReadYaml(path); try { - yaml = File.ReadAllText(path); + var expandedYaml = ReplaceEnvironmentTokens(yaml); + return _deserializer.Deserialize(expandedYaml) ?? new DashboardConfig(); } - catch (Exception ex) + catch (YamlException ex) { throw new DashboardConfigurationException( path, - [$"Unable to read configuration file '{path}'."], + [$"Failed to parse YAML at line {ex.Start.Line}, column {ex.Start.Column}: {ex.Message}"], ex); } + } - DashboardConfig config; + private List LoadEndpointsFromFile(string path) + { + var yaml = ReadYaml(path); try { var expandedYaml = ReplaceEnvironmentTokens(yaml); - config = _deserializer.Deserialize(expandedYaml) ?? new DashboardConfig(); + var endpoints = new List(); + YamlException? parseException = null; + + try + { + var endpointFile = _deserializer.Deserialize(expandedYaml) ?? new EndpointFileDocument(); + if (endpointFile.Endpoints.Count > 0) + { + endpoints = endpointFile.Endpoints; + } + } + catch (YamlException ex) + { + parseException = ex; + } + + if (endpoints.Count == 0) + { + try + { + var singleEndpoint = _deserializer.Deserialize(expandedYaml); + if (HasEndpointContent(singleEndpoint)) + { + endpoints = [singleEndpoint!]; + } + } + catch (YamlException ex) + { + parseException ??= ex; + } + } + + NormalizeEndpoints(endpoints); + + if (endpoints.Count == 0) + { + if (parseException is not null) + { + throw new DashboardConfigurationException( + path, + [$"Failed to parse YAML at line {parseException.Start.Line}, column {parseException.Start.Column}: {parseException.Message}"], + parseException); + } + + throw new DashboardConfigurationException(path, [$"Endpoint file '{path}' did not contain any endpoint definitions."]); + } + + return endpoints; + } + catch (DashboardConfigurationException) + { + throw; } catch (YamlException ex) { @@ -57,26 +139,48 @@ public DashboardConfig Load(string path) [$"Failed to parse YAML at line {ex.Start.Line}, column {ex.Start.Column}: {ex.Message}"], ex); } + } - Normalize(config); - - var errors = _validator.Validate(config); - if (errors.Count > 0) + private static string ReadYaml(string path) + { + if (!File.Exists(path)) { - throw new DashboardConfigurationException(path, errors); + throw new DashboardConfigurationException( + path, + [$"Configuration file '{path}' was not found."]); } - return config; + try + { + return File.ReadAllText(path); + } + catch (Exception ex) + { + throw new DashboardConfigurationException( + path, + [$"Unable to read configuration file '{path}'."], + ex); + } } private static void Normalize(DashboardConfig config) { config.Dashboard ??= new DashboardSettings(); + config.EndpointFiles = NormalizeFileList(config.EndpointFiles); config.Endpoints ??= new List(); + NormalizeEndpoints(config.Endpoints); + } + + private static void NormalizeEndpoints(List? endpoints) + { + if (endpoints is null) + { + return; + } - for (var index = 0; index < config.Endpoints.Count; index++) + for (var index = 0; index < endpoints.Count; index++) { - var endpoint = config.Endpoints[index] ?? new EndpointConfig(); + var endpoint = endpoints[index] ?? new EndpointConfig(); endpoint.Id = endpoint.Id?.Trim() ?? string.Empty; endpoint.Name = endpoint.Name?.Trim() ?? string.Empty; @@ -87,8 +191,21 @@ private static void Normalize(DashboardConfig config) endpoint.IncludeChecks = NormalizeCheckList(endpoint.IncludeChecks); endpoint.ExcludeChecks = NormalizeCheckList(endpoint.ExcludeChecks); - config.Endpoints[index] = endpoint; + endpoints[index] = endpoint; + } + } + + private static List NormalizeFileList(List? values) + { + if (values is null) + { + return new List(); } + + return values + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .Select(static value => value.Trim()) + .ToList(); } private static List NormalizeCheckList(List? values) @@ -115,6 +232,48 @@ private static string ReplaceEnvironmentTokens(string yaml) }); } + private static string ResolveConfigPath(string configuredPath, string baseDirectory) + { + return Path.IsPathRooted(configuredPath) + ? Path.GetFullPath(configuredPath) + : Path.GetFullPath(Path.Combine(baseDirectory, configuredPath)); + } + + private static bool HasEndpointContent(EndpointConfig? endpoint) + { + return endpoint is not null && + (!string.IsNullOrWhiteSpace(endpoint.Id) || + !string.IsNullOrWhiteSpace(endpoint.Name) || + !string.IsNullOrWhiteSpace(endpoint.Url) || + endpoint.TimeoutSeconds is not null || + endpoint.Headers.Count > 0 || + endpoint.IncludeChecks.Count > 0 || + endpoint.ExcludeChecks.Count > 0 || + endpoint.Enabled != true || + endpoint.FrequencySeconds != 30); + } + + 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] + }; + } + + private sealed class EndpointFileDocument + { + public List Endpoints { get; set; } = new(); + } + [GeneratedRegex(@"\$\{(?[A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled)] private static partial Regex EnvironmentVariablePattern(); } diff --git a/src/ApiHealthDashboard/Pages/Index.cshtml b/src/ApiHealthDashboard/Pages/Index.cshtml index 74d612e..abaa143 100644 --- a/src/ApiHealthDashboard/Pages/Index.cshtml +++ b/src/ApiHealthDashboard/Pages/Index.cshtml @@ -37,7 +37,7 @@
- Phase 9 Summary View + Live Summary

Operational overview of monitored endpoint health

This dashboard summarizes the live runtime state for configured endpoints, including the latest status, @@ -116,8 +116,23 @@

Endpoint Summary

-
-
+
+
+
+
+ + + +
+ +
+ +
+ @@ -133,7 +148,7 @@

No endpoints configured yet

- Add one or more endpoints to endpoints.yaml and restart the app to begin monitoring. + Add endpoint YAML files to your configured dashboard setup and restart the app to begin monitoring.

} @@ -155,7 +170,18 @@ @foreach (var endpoint in Model.Endpoints) { - + var searchText = string.Join( + ' ', + new[] + { + endpoint.Name, + endpoint.Id, + endpoint.Status, + endpoint.StatusDescription, + endpoint.ErrorSummary + }.Where(static value => !string.IsNullOrWhiteSpace(value))); + +
@endpoint.Name
@endpoint.Id @@ -193,6 +219,11 @@ } + + + No endpoints match your search. + + } diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index 466e1e5..04f3bfa 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -22,7 +22,7 @@ var logger = serviceProvider.GetRequiredService() .CreateLogger("ApiHealthDashboard.Configuration"); - var resolvedPath = ResolveConfigPath(options.EndpointsConfigPath, environment.ContentRootPath); + var resolvedPath = ResolveConfigPath(options.ResolveDashboardConfigPath(), environment.ContentRootPath); logger.LogInformation( "Loading dashboard configuration from {ConfigPath}.", resolvedPath); diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index 813e0fe..b32c146 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -1,6 +1,6 @@ { "Bootstrap": { - "EndpointsConfigPath": "endpoints.yaml" + "DashboardConfigPath": "dashboard.yaml" }, "DetailedErrors": true, "Logging": { diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index 6052f55..1821cf1 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -1,6 +1,6 @@ { "Bootstrap": { - "EndpointsConfigPath": "endpoints.yaml" + "DashboardConfigPath": "dashboard.yaml" }, "Logging": { "LogLevel": { diff --git a/src/ApiHealthDashboard/dashboard.yaml b/src/ApiHealthDashboard/dashboard.yaml new file mode 100644 index 0000000..778d989 --- /dev/null +++ b/src/ApiHealthDashboard/dashboard.yaml @@ -0,0 +1,8 @@ +dashboard: + refreshUiSeconds: 10 + requestTimeoutSecondsDefault: 10 + showRawPayload: true + +endpointFiles: + - endpoints/orders-api.yaml + - endpoints/billing-api.yaml diff --git a/src/ApiHealthDashboard/endpoints.yaml b/src/ApiHealthDashboard/endpoints.yaml deleted file mode 100644 index 470fa43..0000000 --- a/src/ApiHealthDashboard/endpoints.yaml +++ /dev/null @@ -1,30 +0,0 @@ -dashboard: - refreshUiSeconds: 10 - requestTimeoutSecondsDefault: 10 - showRawPayload: true - -endpoints: - - id: orders-api - name: Orders API - url: https://orders.example.com/health - enabled: true - frequencySeconds: 30 - timeoutSeconds: 10 - headers: - X-API-Key: ${ORDERS_API_KEY} - includeChecks: - - self - - database - - redis - excludeChecks: - - optional-third-party - - - id: billing-api - name: Billing API - url: https://billing.example.com/health - enabled: true - frequencySeconds: 60 - timeoutSeconds: 15 - headers: {} - includeChecks: [] - excludeChecks: [] diff --git a/src/ApiHealthDashboard/endpoints/billing-api.yaml b/src/ApiHealthDashboard/endpoints/billing-api.yaml new file mode 100644 index 0000000..4ee57b4 --- /dev/null +++ b/src/ApiHealthDashboard/endpoints/billing-api.yaml @@ -0,0 +1,9 @@ +id: billing-api +name: Billing API +url: https://billing.example.com/health +enabled: true +frequencySeconds: 60 +timeoutSeconds: 15 +headers: {} +includeChecks: [] +excludeChecks: [] diff --git a/src/ApiHealthDashboard/endpoints/orders-api.yaml b/src/ApiHealthDashboard/endpoints/orders-api.yaml new file mode 100644 index 0000000..a12b240 --- /dev/null +++ b/src/ApiHealthDashboard/endpoints/orders-api.yaml @@ -0,0 +1,14 @@ +id: orders-api +name: Orders API +url: https://orders.example.com/health +enabled: true +frequencySeconds: 30 +timeoutSeconds: 10 +headers: + X-API-Key: ${ORDERS_API_KEY} +includeChecks: + - self + - database + - redis +excludeChecks: + - optional-third-party diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index bfb65c1..b0ed9a3 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -194,6 +194,34 @@ body { white-space: normal; } +.dashboard-card-tools { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.endpoint-search-tools { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.endpoint-search-group { + flex: 1 1 18rem; + min-width: 18rem; +} + +.endpoint-search-count { + min-width: 4.5rem; + text-align: right; +} + +.dashboard-refresh-all-form { + margin-bottom: 0; +} + .dashboard-empty-state-compact { min-height: 12rem; padding: 1.5rem; @@ -315,4 +343,17 @@ body { .hero-title { font-size: 1.65rem; } + + .endpoint-search-tools { + width: 100%; + } + + .endpoint-search-group { + min-width: 100%; + } + + .endpoint-search-count { + min-width: auto; + text-align: left; + } } diff --git a/src/ApiHealthDashboard/wwwroot/js/site.js b/src/ApiHealthDashboard/wwwroot/js/site.js index e994fd4..573ce76 100644 --- a/src/ApiHealthDashboard/wwwroot/js/site.js +++ b/src/ApiHealthDashboard/wwwroot/js/site.js @@ -4,4 +4,39 @@ document.addEventListener("DOMContentLoaded", () => { event.preventDefault(); }); }); + + const searchInput = document.querySelector("[data-endpoint-search-input]"); + const searchRows = Array.from(document.querySelectorAll("[data-endpoint-search-row]")); + const emptyRow = document.querySelector("[data-endpoint-search-empty]"); + const searchCount = document.querySelector("[data-endpoint-search-count]"); + + if (!searchInput || searchRows.length === 0) { + return; + } + + const applyEndpointFilter = () => { + const query = searchInput.value.trim().toLowerCase(); + let visibleCount = 0; + + searchRows.forEach((row) => { + const searchText = (row.getAttribute("data-endpoint-search-text") || row.textContent || "").toLowerCase(); + const isVisible = query === "" || searchText.includes(query); + row.hidden = !isVisible; + + if (isVisible) { + visibleCount += 1; + } + }); + + if (emptyRow) { + emptyRow.hidden = query === "" || visibleCount > 0; + } + + if (searchCount) { + searchCount.textContent = query === "" ? "" : `${visibleCount} match${visibleCount === 1 ? "" : "es"}`; + } + }; + + searchInput.addEventListener("input", applyEndpointFilter); + searchInput.addEventListener("search", applyEndpointFilter); }); diff --git a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs index 18ca5d4..20c8d11 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs @@ -191,6 +191,69 @@ public void Load_WithMalformedYaml_ThrowsHelpfulParseError() Assert.Contains("Failed to parse YAML", exception.Errors.Single(), StringComparison.Ordinal); } + [Fact] + public void Load_WithSeparateEndpointFiles_LoadsAndMergesEndpoints() + { + WriteNamedConfig( + "endpoints/orders-api.yaml", + """ + id: orders-api + name: Orders API + url: https://orders.example.com/health + frequencySeconds: 30 + """); + + WriteNamedConfig( + "endpoints/services.yaml", + """ + endpoints: + - id: billing-api + name: Billing API + url: https://billing.example.com/health + frequencySeconds: 60 + - id: notifications-api + name: Notifications API + url: https://notifications.example.com/health + frequencySeconds: 45 + """); + + var dashboardConfigPath = WriteNamedConfig( + "dashboard.yaml", + """ + dashboard: + refreshUiSeconds: 20 + requestTimeoutSecondsDefault: 15 + endpointFiles: + - endpoints/orders-api.yaml + - endpoints/services.yaml + """); + + var config = _loader.Load(dashboardConfigPath); + + Assert.Equal(20, config.Dashboard.RefreshUiSeconds); + Assert.Equal(15, config.Dashboard.RequestTimeoutSecondsDefault); + Assert.Equal(2, config.EndpointFiles.Count); + Assert.Equal(3, config.Endpoints.Count); + Assert.Contains(config.Endpoints, static endpoint => endpoint.Id == "orders-api"); + Assert.Contains(config.Endpoints, static endpoint => endpoint.Id == "billing-api"); + Assert.Contains(config.Endpoints, static endpoint => endpoint.Id == "notifications-api"); + } + + [Fact] + public void Load_WithMissingEndpointFile_ThrowsHelpfulError() + { + var dashboardConfigPath = WriteNamedConfig( + "dashboard.yaml", + """ + endpointFiles: + - endpoints/missing.yaml + """); + + var exception = Assert.Throws(() => _loader.Load(dashboardConfigPath)); + + Assert.Contains("missing.yaml", exception.Errors.Single(), StringComparison.OrdinalIgnoreCase); + } + public void Dispose() { if (Directory.Exists(_tempDirectory)) @@ -205,4 +268,12 @@ private string WriteConfig(string content) File.WriteAllText(path, content); return path; } + + 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; + } }