Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -47,7 +47,8 @@ Not implemented yet:
| |-- State/
| |-- Pages/
| |-- wwwroot/
| `-- endpoints.yaml
| |-- dashboard.yaml
| `-- endpoints/
`-- tests/
`-- ApiHealthDashboard.Tests/
```
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
```

Expand All @@ -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

Expand Down
4 changes: 2 additions & 2 deletions src/ApiHealthDashboard/ApiHealthDashboard.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@
</ItemGroup>

<ItemGroup>
<None Remove="endpoints.yaml" />
<Content Include="endpoints.yaml">
<None Remove="**\*.yaml" />
<Content Include="**\*.yaml" Exclude="bin\**;obj\**">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
<CopyToPublishDirectory>PreserveNewest</CopyToPublishDirectory>
</Content>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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";
}
}
2 changes: 2 additions & 0 deletions src/ApiHealthDashboard/Configuration/DashboardConfig.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ public sealed class DashboardConfig
{
public DashboardSettings Dashboard { get; set; } = new();

public List<string> EndpointFiles { get; set; } = new();

public List<EndpointConfig> Endpoints { get; set; } = new();
}

Expand Down
197 changes: 178 additions & 19 deletions src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>(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<DashboardConfig>(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<EndpointConfig> LoadEndpointsFromFile(string path)
{
var yaml = ReadYaml(path);

try
{
var expandedYaml = ReplaceEnvironmentTokens(yaml);
config = _deserializer.Deserialize<DashboardConfig>(expandedYaml) ?? new DashboardConfig();
var endpoints = new List<EndpointConfig>();
YamlException? parseException = null;

try
{
var endpointFile = _deserializer.Deserialize<EndpointFileDocument>(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<EndpointConfig>(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)
{
Expand All @@ -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<EndpointConfig>();
NormalizeEndpoints(config.Endpoints);
}

private static void NormalizeEndpoints(List<EndpointConfig>? 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;
Expand All @@ -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<string> NormalizeFileList(List<string>? values)
{
if (values is null)
{
return new List<string>();
}

return values
.Where(static value => !string.IsNullOrWhiteSpace(value))
.Select(static value => value.Trim())
.ToList();
}

private static List<string> NormalizeCheckList(List<string>? values)
Expand All @@ -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<string, string>(endpoint.Headers, StringComparer.OrdinalIgnoreCase),
IncludeChecks = [.. endpoint.IncludeChecks],
ExcludeChecks = [.. endpoint.ExcludeChecks]
};
}

private sealed class EndpointFileDocument
{
public List<EndpointConfig> Endpoints { get; set; } = new();
}

[GeneratedRegex(@"\$\{(?<name>[A-Za-z_][A-Za-z0-9_]*)\}", RegexOptions.Compiled)]
private static partial Regex EnvironmentVariablePattern();
}
Loading