diff --git a/README.md b/README.md index 79f82dc..138126d 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ Implemented so far: - Phase 12: automated test expansion for invalid and edge-case coverage - 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 Not implemented yet: - Backlog items tracked for post-v1 work @@ -70,6 +71,7 @@ The app uses locally bundled AdminLTE assets under [`src/ApiHealthDashboard/wwwr Current UI pages: - dashboard summary page: [`src/ApiHealthDashboard/Pages/Index.cshtml`](src/ApiHealthDashboard/Pages/Index.cshtml) - endpoint details page: [`src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml`](src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml) +- endpoint import preview page: [`src/ApiHealthDashboard/Pages/Import.cshtml`](src/ApiHealthDashboard/Pages/Import.cshtml) ### YAML Configuration @@ -183,10 +185,24 @@ 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 +- refreshes the live dashboard section with same-origin timed GET requests instead of reloading the whole page - renders a live endpoint table with last check, duration, error summary, and manual refresh actions - surfaces degraded and unhealthy endpoints in an active issues panel for faster triage - shows a clearer empty state when no endpoints are configured +### Endpoint Import Flow + +The app now includes an endpoint import preview flow for deriving YAML from a live API probe without writing files automatically. + +Current import behavior: +- sends a live request using the entered URL, headers, timeout, and enabled/frequency settings +- auto-suggests endpoint id and name when those fields are left blank +- shows a soft warning when the chosen poll frequency is below the configured appsettings recommendation +- parses discovered checks from the response and can optionally populate `includeChecks` +- generates a normalized YAML snippet for manual copy into `dashboard.yaml` or a separate endpoint file +- compares the generated YAML against the currently loaded config when an existing endpoint matches by id or URL +- shows a diff preview plus a raw response preview for review before any manual save + ### Endpoint Details The endpoint details page now acts as a diagnostic view for a single configured endpoint. @@ -401,7 +417,6 @@ Test file: ## Future Plans These are planned enhancements after the current v1 path: -- add an import flow that can derive YAML endpoint config from API request and response inspection, with preview and diff comparison before any manual save - 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/api-health-dashboard-build-checklist-v2.md b/api-health-dashboard-build-checklist-v2.md index ca4d476..2f26ea2 100644 --- a/api-health-dashboard-build-checklist-v2.md +++ b/api-health-dashboard-build-checklist-v2.md @@ -662,7 +662,6 @@ These should not block v1. - [ ] health status mini charts - [ ] SBOM generation - [ ] artifact signing -- [ ] import YAML endpoint definitions from API endpoint request/response inspection with preview and diff comparison before manual save - [ ] CLI execution mode with machine-readable output - [ ] per-endpoint priority support - [ ] optional email sending via SMTP or an external API diff --git a/api-health-dashboard-codex-requirements-v2.md b/api-health-dashboard-codex-requirements-v2.md index a841206..86553f2 100644 --- a/api-health-dashboard-codex-requirements-v2.md +++ b/api-health-dashboard-codex-requirements-v2.md @@ -784,7 +784,6 @@ These are optional later improvements and should not block v1: - tag-based filtering - retry policy with backoff - readonly config viewer page -- import YAML endpoint definitions from API endpoint request/response inspection with preview and diff comparison before manual file save - CLI execution mode with machine-readable output for automation workflows - per-endpoint priority for display and future scheduling behavior - optional email sending via directly configured SMTP or an external API integration diff --git a/src/ApiHealthDashboard/Configuration/ConfigurationWarningState.cs b/src/ApiHealthDashboard/Configuration/ConfigurationWarningState.cs new file mode 100644 index 0000000..31269a1 --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/ConfigurationWarningState.cs @@ -0,0 +1,13 @@ +namespace ApiHealthDashboard.Configuration; + +public sealed class ConfigurationWarningState +{ + public ConfigurationWarningState(IReadOnlyList warnings) + { + Warnings = warnings; + } + + public IReadOnlyList Warnings { get; } + + public bool HasWarnings => Warnings.Count > 0; +} diff --git a/src/ApiHealthDashboard/Configuration/DashboardConfigLoadResult.cs b/src/ApiHealthDashboard/Configuration/DashboardConfigLoadResult.cs new file mode 100644 index 0000000..38fb34c --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/DashboardConfigLoadResult.cs @@ -0,0 +1,8 @@ +namespace ApiHealthDashboard.Configuration; + +public sealed class DashboardConfigLoadResult +{ + public required DashboardConfig Config { get; init; } + + public IReadOnlyList Warnings { get; init; } = []; +} diff --git a/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs b/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs index 639b62b..9fd3dac 100644 --- a/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs +++ b/src/ApiHealthDashboard/Configuration/IYamlConfigLoader.cs @@ -2,5 +2,5 @@ namespace ApiHealthDashboard.Configuration; public interface IYamlConfigLoader { - DashboardConfig Load(string path); + DashboardConfigLoadResult Load(string path); } diff --git a/src/ApiHealthDashboard/Configuration/ImportUiOptions.cs b/src/ApiHealthDashboard/Configuration/ImportUiOptions.cs new file mode 100644 index 0000000..59b45d5 --- /dev/null +++ b/src/ApiHealthDashboard/Configuration/ImportUiOptions.cs @@ -0,0 +1,8 @@ +namespace ApiHealthDashboard.Configuration; + +public sealed class ImportUiOptions +{ + public const string SectionName = "Import"; + + public int MinimumRecommendedPollFrequencySeconds { get; set; } = 180; +} diff --git a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs index d94d7f2..9a696da 100644 --- a/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs +++ b/src/ApiHealthDashboard/Configuration/YamlConfigLoader.cs @@ -18,12 +18,25 @@ public YamlConfigLoader(DashboardConfigValidator validator) .Build(); } - public DashboardConfig Load(string path) + public DashboardConfigLoadResult Load(string path) { ArgumentException.ThrowIfNullOrWhiteSpace(path); + if (!File.Exists(path)) + { + return new DashboardConfigLoadResult + { + Config = new DashboardConfig(), + Warnings = + [ + $"Dashboard configuration file '{path}' was not found. The dashboard started with no configured endpoints." + ] + }; + } + var dashboardConfig = DeserializeDashboardConfig(path); Normalize(dashboardConfig); + var warnings = new List(); var mergedConfig = new DashboardConfig { @@ -39,6 +52,12 @@ public DashboardConfig Load(string path) foreach (var endpointFilePath in dashboardConfig.EndpointFiles) { var resolvedEndpointFilePath = ResolveConfigPath(endpointFilePath, dashboardDirectory); + if (!File.Exists(resolvedEndpointFilePath)) + { + warnings.Add($"Endpoint configuration file '{resolvedEndpointFilePath}' was not found. It was skipped."); + continue; + } + var fileEndpoints = LoadEndpointsFromFile(resolvedEndpointFilePath); mergedConfig.Endpoints.AddRange(fileEndpoints); } @@ -51,7 +70,11 @@ public DashboardConfig Load(string path) throw new DashboardConfigurationException(path, errors); } - return mergedConfig; + return new DashboardConfigLoadResult + { + Config = mergedConfig, + Warnings = warnings + }; } private DashboardConfig DeserializeDashboardConfig(string path) diff --git a/src/ApiHealthDashboard/Formatting/DisplayValueFormatter.cs b/src/ApiHealthDashboard/Formatting/DisplayValueFormatter.cs new file mode 100644 index 0000000..58477f8 --- /dev/null +++ b/src/ApiHealthDashboard/Formatting/DisplayValueFormatter.cs @@ -0,0 +1,27 @@ +using System.Collections; +using System.Text.Json; + +namespace ApiHealthDashboard.Formatting; + +public static class DisplayValueFormatter +{ + public static string Format(object? value) + { + return value switch + { + null => "(null)", + string text when string.IsNullOrWhiteSpace(text) => "(empty)", + string text => text, + IEnumerable values => FormatEnumerable(values), + _ => JsonSerializer.Serialize(value) + }; + } + + private static string FormatEnumerable(IEnumerable values) + { + var items = values.Cast().ToList(); + return items.Count == 0 + ? "(empty)" + : JsonSerializer.Serialize(items); + } +} diff --git a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs index aed5d4e..ad389ed 100644 --- a/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Endpoints/Details.cshtml.cs @@ -1,5 +1,6 @@ using ApiHealthDashboard.Configuration; using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Formatting; using ApiHealthDashboard.Scheduling; using ApiHealthDashboard.State; using Microsoft.AspNetCore.Mvc; @@ -234,7 +235,7 @@ public static EndpointDetailsViewModel From( DegradedCheckCount = flattenedNodes.Count(static node => node.Status == "Degraded"), UnhealthyCheckCount = flattenedNodes.Count(static node => node.Status == "Unhealthy"), UnknownCheckCount = flattenedNodes.Count(static node => node.Status is not ("Healthy" or "Degraded" or "Unhealthy")), - RawPayload = showRawPayload ? state?.Snapshot?.RawPayload : null, + RawPayload = showRawPayload ? FormatPayloadPreview(state?.Snapshot?.RawPayload) : null, ShowRawPayload = showRawPayload }; } @@ -285,13 +286,30 @@ private static string BuildStatusSummary(bool enabled, bool isPolling, string st private static string FormatMetadataValue(object? value) { - return value switch + return DisplayValueFormatter.Format(value); + } + + private static string? FormatPayloadPreview(string? rawPayload) + { + if (string.IsNullOrWhiteSpace(rawPayload)) { - null => "(null)", - string text when string.IsNullOrWhiteSpace(text) => "(empty)", - string text => text, - _ => JsonSerializer.Serialize(value) - }; + return rawPayload; + } + + try + { + using var document = JsonDocument.Parse(rawPayload); + return JsonSerializer.Serialize( + document.RootElement, + new JsonSerializerOptions + { + WriteIndented = true + }); + } + catch (JsonException) + { + return rawPayload; + } } private static string ToBadgeClass(string status) diff --git a/src/ApiHealthDashboard/Pages/Import.cshtml b/src/ApiHealthDashboard/Pages/Import.cshtml new file mode 100644 index 0000000..3b152ee --- /dev/null +++ b/src/ApiHealthDashboard/Pages/Import.cshtml @@ -0,0 +1,383 @@ +@page +@model ApiHealthDashboard.Pages.ImportModel +@{ + ViewData["Title"] = "Import Endpoint"; + var validationMessages = ViewData.ModelState.Values + .SelectMany(static entry => entry.Errors) + .Select(static error => string.IsNullOrWhiteSpace(error.ErrorMessage) + ? error.Exception?.Message + : error.ErrorMessage) + .Where(static message => !string.IsNullOrWhiteSpace(message)) + .Distinct(StringComparer.Ordinal) + .ToArray(); +} + +
+
+
+
+

Import Endpoint

+
+
+ +
+
+
+
+ +
+
+
+
+
+
+ Import Preview +

Probe an API, inspect the response, and generate endpoint YAML

+

+ This flow sends a live request, inspects the response, suggests endpoint metadata, and shows a diff + against the currently loaded config. Saving remains manual so you can review before editing YAML files. +

+
+
+
+
+ Loaded endpoints + @Model.ExistingEndpointCount +
+
+ Comparison mode + @(Model.HasExistingEndpoints ? "Enabled" : "No current matches") +
+
+ Save behavior + Manual only +
+
+
+
+
+
+ +
+
+
+
+

Request And Import Settings

+
+
+ @if (validationMessages.Length > 0) + { + + } + @if (!string.IsNullOrWhiteSpace(Model.FrequencyRecommendationWarning)) + { + + } + +
+
+ +
+ + + Supports absolute HTTP and HTTPS health endpoint URLs. +
+
+ +
+ +
+
+ + +
+
+ + +
+
+
+
+ + + Recommended minimum: @Model.MinimumRecommendedPollFrequencySeconds seconds. +
+
+ + +
+
+
+ +
+ +
+ + + Use one header per line in the format Name: value. +
+
+ +
+
+
+ + +
+ Controls the suggested YAML value only. It does not disable the preview probe. +
+
+
+ + +
+ When checked, discovered top-level checks will be added to the generated includeChecks list. +
+
+ +
+ + Preview only. YAML file edits stay manual. +
+
+
+
+
+ +
+
+
+

How This Works

+
+
+
+

1. Probe the endpoint

+

The dashboard sends a live request with the URL, timeout, and headers you provide.

+
+
+

2. Inspect the response

+

We parse the response when possible, summarize discovered checks, and keep a preview of the raw body.

+
+
+

3. Review the generated YAML

+

Use the proposed snippet and diff to manually update dashboard.yaml or a separate endpoint YAML file.

+
+
+
+
+
+ + @if (Model.Result is not null) + { +
+
+
+
+

Probe Summary

+
+
+
+
Result
+
@Model.Result.ProbeResult.Kind
+
HTTP
+
@Model.Result.ProbeHttpStatusText
+
Duration
+
@Model.Result.ProbeResult.DurationMs ms
+
Parser status
+
@(Model.Result.ParserStatus ?? "Not parsed")
+
+ +
+ @Model.Result.ProbeStatusText + @if (Model.Result.IsEndpointNotFound) + { +
YAML preview is not generated when the endpoint returns HTTP 404 Not Found.
+ } + else if (!Model.Result.ProbeResult.IsSuccess) + { +
YAML preview is still available below based on the request settings you entered.
+ } + @if (!string.IsNullOrWhiteSpace(Model.Result.ParserError)) + { +
Parser note: @Model.Result.ParserError
+ } +
+
+
+
+ +
+
+
+

Suggested Endpoint

+
+
+
+
Id
+
@Model.Result.SuggestedEndpoint.Id
+
Name
+
@Model.Result.SuggestedEndpoint.Name
+
URL
+
@Model.Result.SuggestedEndpoint.Url
+
Checks
+
@(Model.Result.TopLevelCheckNames.Count > 0 ? string.Join(", ", Model.Result.TopLevelCheckNames) : "None discovered")
+
+
+
+
+ +
+
+
+

Comparison

+
+
+

@Model.Result.MatchSummary

+
+
+
+
+ +
+
+
+
+

Generated YAML

+
+
+ @if (Model.Result.HasGeneratedYamlPreview) + { +
+

Copy this into dashboard.yaml or an endpoint YAML file after review.

+
+ + +
+
+
@Model.Result.GeneratedYaml
+ } + else + { +
No YAML preview was generated for this probe result.
+ } +
+
+
+ +
+
+
+

Diff Preview

+
+
+ @if (!Model.Result.HasGeneratedYamlPreview) + { +
Diff preview is unavailable because no YAML preview was generated.
+ } + else if (Model.Result.HasExistingMatch && Model.Result.HasDiff) + { +
+ @foreach (var line in Model.Result.DiffLines) + { +
@line.Prefix@line.Text
+ } +
+ } + else if (Model.Result.HasExistingMatch) + { +
The generated YAML matches the existing normalized endpoint config.
+ } + else + { +
No existing endpoint matched, so there is no diff to show yet.
+ } +
+
+
+
+ +
+
+
+
+

Discovered Checks

+
+
+ @if (Model.Result.DiscoveredChecks.Count == 0) + { +

No health checks were discovered in the response.

+ } + else + { +
+ + + + + + + + + @foreach (var check in Model.Result.DiscoveredChecks) + { + + + + + } + +
PathStatus
@check.Path@check.Status
+
+ } +
+
+
+ +
+
+
+

Response Preview

+
+
+ @if (Model.Result.HasResponsePreview) + { + @if (Model.Result.ResponsePreviewWasTruncated) + { +

The response preview was truncated for display.

+ } + +
@Model.Result.ResponsePreview
+ } + else + { +

No response body was captured for preview.

+ } +
+
+
+
+ } +
+
diff --git a/src/ApiHealthDashboard/Pages/Import.cshtml.cs b/src/ApiHealthDashboard/Pages/Import.cshtml.cs new file mode 100644 index 0000000..76e8486 --- /dev/null +++ b/src/ApiHealthDashboard/Pages/Import.cshtml.cs @@ -0,0 +1,165 @@ +using System.ComponentModel.DataAnnotations; +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; + +namespace ApiHealthDashboard.Pages; + +public sealed class ImportModel : PageModel +{ + private readonly DashboardConfig _dashboardConfig; + private readonly IEndpointImportService _endpointImportService; + private readonly ImportUiOptions _importUiOptions; + private readonly ILogger _logger; + + public ImportModel( + DashboardConfig dashboardConfig, + IEndpointImportService endpointImportService, + IOptions importUiOptions, + ILogger logger) + { + _dashboardConfig = dashboardConfig; + _endpointImportService = endpointImportService; + _importUiOptions = importUiOptions.Value; + _logger = logger; + } + + [BindProperty] + public InputModel Input { get; set; } = new(); + + public EndpointImportResult? Result { get; private set; } + + public int ExistingEndpointCount => _dashboardConfig.Endpoints.Count; + + public bool HasExistingEndpoints => ExistingEndpointCount > 0; + + public int MinimumRecommendedPollFrequencySeconds => Math.Max(_importUiOptions.MinimumRecommendedPollFrequencySeconds, 1); + + public string? FrequencyRecommendationWarning { get; private set; } + + public void OnGet() + { + InitializeDefaults(); + } + + public async Task OnPostPreviewAsync(CancellationToken cancellationToken) + { + InitializeDefaults(); + UpdateFrequencyRecommendationWarning(); + + if (!ValidateInput()) + { + return Page(); + } + + try + { + Result = await _endpointImportService.ImportAsync( + new EndpointImportRequest + { + Id = Input.Id, + Name = Input.Name, + Url = Input.Url, + Enabled = Input.Enabled, + FrequencySeconds = Input.FrequencySeconds, + TimeoutSeconds = Input.TimeoutSeconds, + HeadersText = Input.HeadersText, + IncludeDiscoveredChecks = Input.IncludeDiscoveredChecks + }, + cancellationToken); + + Input.Id = Result.SuggestedEndpoint.Id; + Input.Name = Result.SuggestedEndpoint.Name; + ModelState.Clear(); + + _logger.LogInformation( + "Import preview generated for suggested endpoint {EndpointId}.", + Result.SuggestedEndpoint.Id); + } + catch (EndpointImportException ex) + { + foreach (var error in ex.Errors) + { + ModelState.AddModelError(string.Empty, error); + } + + _logger.LogWarning( + "Import preview validation failed with {ErrorCount} error(s).", + ex.Errors.Count); + } + + return Page(); + } + + private void InitializeDefaults() + { + if (Input.FrequencySeconds <= 0) + { + Input.FrequencySeconds = MinimumRecommendedPollFrequencySeconds; + } + } + + private void UpdateFrequencyRecommendationWarning() + { + FrequencyRecommendationWarning = Input.FrequencySeconds < MinimumRecommendedPollFrequencySeconds + ? $"Poll frequency below the recommended soft limit of {MinimumRecommendedPollFrequencySeconds} seconds may create unnecessary load." + : null; + } + + private bool ValidateInput() + { + ModelState.ClearValidationState(nameof(Input)); + + var validationContext = new ValidationContext(Input); + var validationResults = new List(); + var isValid = Validator.TryValidateObject(Input, validationContext, validationResults, validateAllProperties: true); + + foreach (var validationResult in validationResults) + { + var memberNames = validationResult.MemberNames.Any() + ? validationResult.MemberNames + : [string.Empty]; + + foreach (var memberName in memberNames) + { + ModelState.AddModelError( + string.IsNullOrWhiteSpace(memberName) ? string.Empty : $"{nameof(Input)}.{memberName}", + validationResult.ErrorMessage ?? "Validation failed."); + } + } + + return isValid; + } + + public sealed class InputModel + { + [Display(Name = "Endpoint ID")] + public string? Id { get; set; } + + [Display(Name = "Endpoint name")] + public string? Name { get; set; } + + [Required] + [Display(Name = "Endpoint URL")] + public string Url { get; set; } = string.Empty; + + [Display(Name = "Enabled")] + public bool Enabled { get; set; } = true; + + [Range(1, int.MaxValue)] + [Display(Name = "Frequency seconds")] + public int FrequencySeconds { get; set; } = 30; + + [Range(1, int.MaxValue)] + [Display(Name = "Timeout seconds")] + public int? TimeoutSeconds { get; set; } + + [Display(Name = "Headers")] + public string HeadersText { get; set; } = string.Empty; + + [Display(Name = "Use discovered top-level checks as includeChecks")] + public bool IncludeDiscoveredChecks { get; set; } + } +} diff --git a/src/ApiHealthDashboard/Pages/Index.cshtml b/src/ApiHealthDashboard/Pages/Index.cshtml index abaa143..c10421a 100644 --- a/src/ApiHealthDashboard/Pages/Index.cshtml +++ b/src/ApiHealthDashboard/Pages/Index.cshtml @@ -33,272 +33,11 @@ } -
-
-
-
- Live Summary -

Operational overview of monitored endpoint health

-

- This dashboard summarizes the live runtime state for configured endpoints, including the latest status, - check timings, current failures, and safe refresh actions backed by the scheduler. -

-
-
-
-
- Configured endpoints - @Model.Counters.Total -
-
- Enabled / disabled - @Model.Counters.Enabled / @Model.Counters.Disabled -
-
- Polling now - @Model.Counters.Polling -
-
-
-
-
-
- -
-
-
-
-

@Model.Counters.Total

-

Total Endpoints

-
-
- -
-
-
-
-
-
-

@Model.Counters.Healthy

-

Healthy Endpoints

-
-
- -
-
-
-
-
-
-

@Model.Counters.Degraded

-

Degraded Endpoints

-
-
- -
-
-
-
-
-
-

@Model.Counters.Unhealthy

-

Unhealthy Endpoints

-
-
- -
-
-
-
- -
-
-
-
-

Endpoint Summary

-
-
-
-
- - - -
- -
- -
-
- -
-
-
-
- @if (!Model.HasConfiguredEndpoints) - { -
-
- -
-

No endpoints configured yet

-

- Add endpoint YAML files to your configured dashboard setup and restart the app to begin monitoring. -

-
- } - else - { - - - - - - - - - - - - - - - @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))); - - - - - - - - - - - - } - - - - -
NameStatusLast CheckedLast SuccessfulDurationFrequencyErrorActions
-
@endpoint.Name
- @endpoint.Id - @if (!endpoint.Enabled) - { -
Disabled in YAML
- } -
- @endpoint.Status -
@endpoint.StatusDescription
-
@endpoint.LastCheckedText@endpoint.LastSuccessfulText@endpoint.DurationText@endpoint.FrequencyText - @if (string.IsNullOrWhiteSpace(endpoint.ErrorText)) - { - None - } - else - { - @endpoint.ErrorSummary - } - -
- - -
- Details -
- } -
-
-
- -
-
-
-

Health Distribution

-
-
-
- Healthy - @Model.Counters.Healthy / @Model.Counters.Total -
-
-
-
-
- Degraded - @Model.Counters.Degraded / @Model.Counters.Total -
-
-
-
-
- Unhealthy - @Model.Counters.Unhealthy / @Model.Counters.Total -
-
-
-
-
- Unknown - @Model.Counters.Unknown / @Model.Counters.Total -
-
-
-
-
-
- -
-
-

Active Issues

-
-
- @if (Model.ProblemEndpoints.Count == 0) - { -
No degraded, unhealthy, or erroring endpoints are currently visible.
- } - else - { -
    - @foreach (var endpoint in Model.ProblemEndpoints) - { -
  • - - @endpoint.Name - @endpoint.Status - -
    Last checked: @endpoint.LastCheckedText
    - @if (!string.IsNullOrWhiteSpace(endpoint.ErrorText)) - { -
    @endpoint.ErrorSummary
    - } -
  • - } -
- } -
-
-
+
+
diff --git a/src/ApiHealthDashboard/Pages/Index.cshtml.cs b/src/ApiHealthDashboard/Pages/Index.cshtml.cs index aaf49b8..a5d33d9 100644 --- a/src/ApiHealthDashboard/Pages/Index.cshtml.cs +++ b/src/ApiHealthDashboard/Pages/Index.cshtml.cs @@ -4,6 +4,7 @@ using ApiHealthDashboard.State; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.AspNetCore.Mvc.ViewFeatures; namespace ApiHealthDashboard.Pages; @@ -34,11 +35,24 @@ public IndexModel( public bool HasConfiguredEndpoints => Endpoints.Count > 0; + public int RefreshUiSeconds => _dashboardConfig.Dashboard.RefreshUiSeconds; + public void OnGet() { LoadDashboard(); } + public IActionResult OnGetLiveSection() + { + LoadDashboard(); + + return new PartialViewResult + { + ViewName = "_DashboardLiveSection", + ViewData = new ViewDataDictionary(ViewData, this) + }; + } + public async Task OnPostRefreshAllAsync(CancellationToken cancellationToken) { _logger.LogInformation("Manual refresh requested for all enabled endpoints."); @@ -157,6 +171,8 @@ public sealed class EndpointSummaryViewModel public bool IsPolling { get; init; } + public bool ShowIdHint { get; init; } + public string LastCheckedText { get; init; } = "Never"; public string LastSuccessfulText { get; init; } = "Never"; @@ -173,6 +189,8 @@ public sealed class EndpointSummaryViewModel ? "Polling" : Status; + public bool ShowStatusDescription => !string.Equals(StatusDescription, Status, StringComparison.OrdinalIgnoreCase); + public static EndpointSummaryViewModel From(EndpointConfig endpoint, EndpointState? state) { var status = state?.Status ?? "Unknown"; @@ -186,6 +204,7 @@ public static EndpointSummaryViewModel From(EndpointConfig endpoint, EndpointSta FrequencyText = $"{endpoint.FrequencySeconds} sec", Enabled = endpoint.Enabled, IsPolling = state?.IsPolling ?? false, + ShowIdHint = ShouldShowIdHint(endpoint.Name, endpoint.Id), LastCheckedText = FormatDateTime(state?.LastCheckedUtc), LastSuccessfulText = FormatDateTime(state?.LastSuccessfulUtc), DurationText = state?.DurationMs is long durationMs ? $"{durationMs} ms" : "-", @@ -198,6 +217,37 @@ private static string FormatDateTime(DateTimeOffset? value) return value?.ToUniversalTime().ToString("yyyy-MM-dd HH:mm:ss 'UTC'") ?? "Never"; } + private static bool ShouldShowIdHint(string name, string id) + { + if (string.IsNullOrWhiteSpace(id)) + { + return false; + } + + if (string.IsNullOrWhiteSpace(name)) + { + return true; + } + + return !string.Equals(NormalizeForComparison(name), NormalizeForComparison(id), StringComparison.OrdinalIgnoreCase); + } + + private static string NormalizeForComparison(string value) + { + var buffer = new char[value.Length]; + var length = 0; + + foreach (var character in value) + { + if (char.IsLetterOrDigit(character)) + { + buffer[length++] = char.ToLowerInvariant(character); + } + } + + return new string(buffer, 0, length); + } + private static string ToBadgeClass(string status) { return status switch diff --git a/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml new file mode 100644 index 0000000..3b9a7a8 --- /dev/null +++ b/src/ApiHealthDashboard/Pages/Shared/_DashboardLiveSection.cshtml @@ -0,0 +1,275 @@ +@model ApiHealthDashboard.Pages.IndexModel + +
+
+
+
+ Live Summary +

Operational overview of monitored endpoint health

+

+ This dashboard summarizes the live runtime state for configured endpoints, including the latest status, + check timings, current failures, and safe refresh actions backed by the scheduler. +

+
+
+
+
+ Configured endpoints + @Model.Counters.Total +
+
+ Enabled / disabled + @Model.Counters.Enabled / @Model.Counters.Disabled +
+
+ Polling now + @Model.Counters.Polling +
+
+
+
+
+
+ +
+
+
+
+

@Model.Counters.Total

+

Total Endpoints

+
+
+ +
+
+
+
+
+
+

@Model.Counters.Healthy

+

Healthy Endpoints

+
+
+ +
+
+
+
+
+
+

@Model.Counters.Degraded

+

Degraded Endpoints

+
+
+ +
+
+
+
+
+
+

@Model.Counters.Unhealthy

+

Unhealthy Endpoints

+
+
+ +
+
+
+
+ +
+
+
+
+

Endpoint Summary

+
+
+
+
+ + + +
+ +
+ +
+
+ +
+
+
+
+ @if (!Model.HasConfiguredEndpoints) + { +
+
+ +
+

No endpoints configured yet

+

+ Add endpoint YAML files to your configured dashboard setup and restart the app to begin monitoring. +

+
+ } + else + { + + + + + + + + + + + + + + + @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))); + + + + + + + + + + + + } + + + + +
NameStatusLast CheckedLast SuccessfulDurationFrequencyErrorActions
+
@endpoint.Name
+ @if (endpoint.ShowIdHint) + { + @endpoint.Id + } + @if (!endpoint.Enabled) + { +
Disabled in YAML
+ } +
+ @endpoint.Status + @if (endpoint.ShowStatusDescription) + { +
@endpoint.StatusDescription
+ } +
@endpoint.LastCheckedText@endpoint.LastSuccessfulText@endpoint.DurationText@endpoint.FrequencyText + @if (string.IsNullOrWhiteSpace(endpoint.ErrorText)) + { + None + } + else + { + @endpoint.ErrorSummary + } + +
+ + +
+ Details +
+ } +
+
+
+ +
+
+
+

Health Distribution

+
+
+
+ Healthy + @Model.Counters.Healthy / @Model.Counters.Total +
+
+
+
+
+ Degraded + @Model.Counters.Degraded / @Model.Counters.Total +
+
+
+
+
+ Unhealthy + @Model.Counters.Unhealthy / @Model.Counters.Total +
+
+
+
+
+ Unknown + @Model.Counters.Unknown / @Model.Counters.Total +
+
+
+
+
+
+ +
+
+

Active Issues

+
+
+ @if (Model.ProblemEndpoints.Count == 0) + { +
No degraded, unhealthy, or erroring endpoints are currently visible.
+ } + else + { +
    + @foreach (var endpoint in Model.ProblemEndpoints) + { +
  • + + @endpoint.Name + @endpoint.Status + +
    Last checked: @endpoint.LastCheckedText
    + @if (!string.IsNullOrWhiteSpace(endpoint.ErrorText)) + { +
    @endpoint.ErrorSummary
    + } +
  • + } +
+ } +
+
+
+
diff --git a/src/ApiHealthDashboard/Pages/Shared/_HealthNode.cshtml b/src/ApiHealthDashboard/Pages/Shared/_HealthNode.cshtml index 0f3e345..f35d3d0 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_HealthNode.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_HealthNode.cshtml @@ -40,7 +40,7 @@
    @foreach (var item in Model.Data.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)) { -
  • @item.Key: @item.Value
  • +
  • @item.Key: @ApiHealthDashboard.Formatting.DisplayValueFormatter.Format(item.Value)
  • }
@@ -83,7 +83,7 @@ else
    @foreach (var item in Model.Data.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)) { -
  • @item.Key: @item.Value
  • +
  • @item.Key: @ApiHealthDashboard.Formatting.DisplayValueFormatter.Format(item.Value)
  • }
diff --git a/src/ApiHealthDashboard/Pages/Shared/_Layout.cshtml b/src/ApiHealthDashboard/Pages/Shared/_Layout.cshtml index f877398..b8f989e 100644 --- a/src/ApiHealthDashboard/Pages/Shared/_Layout.cshtml +++ b/src/ApiHealthDashboard/Pages/Shared/_Layout.cshtml @@ -1,7 +1,9 @@ +@inject ApiHealthDashboard.Configuration.ConfigurationWarningState ConfigurationWarnings @{ var currentPage = ViewContext.RouteData.Values["page"] as string ?? string.Empty; var isDashboardPage = currentPage == "/Index"; var isEndpointPage = currentPage.StartsWith("/Endpoints"); + var isImportPage = currentPage == "/Import"; } @@ -71,12 +73,32 @@

Endpoint Details

+
+ @if (ConfigurationWarnings.HasWarnings) + { +
+ +
+ } @RenderBody()
diff --git a/src/ApiHealthDashboard/Program.cs b/src/ApiHealthDashboard/Program.cs index 04f3bfa..c1f2e23 100644 --- a/src/ApiHealthDashboard/Program.cs +++ b/src/ApiHealthDashboard/Program.cs @@ -12,6 +12,8 @@ // Add services to the container. builder.Services.Configure( builder.Configuration.GetSection(DashboardBootstrapOptions.SectionName)); +builder.Services.Configure( + builder.Configuration.GetSection(ImportUiOptions.SectionName)); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => @@ -29,14 +31,19 @@ try { - var config = loader.Load(resolvedPath); + var loadResult = loader.Load(resolvedPath); + + foreach (var warning in loadResult.Warnings) + { + logger.LogWarning("{ConfigurationWarning}", warning); + } logger.LogInformation( "Loaded dashboard configuration from {ConfigPath} with {EndpointCount} endpoints.", resolvedPath, - config.Endpoints.Count); + loadResult.Config.Endpoints.Count); - return config; + return loadResult; } catch (Exception ex) { @@ -47,6 +54,13 @@ throw; } }); +builder.Services.AddSingleton(static serviceProvider => +{ + var loadResult = serviceProvider.GetRequiredService(); + return new ConfigurationWarningState(loadResult.Warnings); +}); +builder.Services.AddSingleton(static serviceProvider => + serviceProvider.GetRequiredService().Config); builder.Services.AddSingleton(static serviceProvider => { var config = serviceProvider.GetRequiredService(); @@ -63,6 +77,7 @@ builder.Services.AddSingleton(TimeProvider.System); builder.Services.AddHttpClient(nameof(EndpointPoller)); builder.Services.AddSingleton(); +builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(); builder.Services.AddSingleton(static serviceProvider => diff --git a/src/ApiHealthDashboard/Services/EndpointImportException.cs b/src/ApiHealthDashboard/Services/EndpointImportException.cs new file mode 100644 index 0000000..adb2f81 --- /dev/null +++ b/src/ApiHealthDashboard/Services/EndpointImportException.cs @@ -0,0 +1,15 @@ +namespace ApiHealthDashboard.Services; + +public sealed class EndpointImportException : Exception +{ + public EndpointImportException(IEnumerable errors) + : base("The endpoint import request is invalid.") + { + Errors = errors + .Where(static error => !string.IsNullOrWhiteSpace(error)) + .Select(static error => error.Trim()) + .ToArray(); + } + + public IReadOnlyList Errors { get; } +} diff --git a/src/ApiHealthDashboard/Services/EndpointImportRequest.cs b/src/ApiHealthDashboard/Services/EndpointImportRequest.cs new file mode 100644 index 0000000..07849d8 --- /dev/null +++ b/src/ApiHealthDashboard/Services/EndpointImportRequest.cs @@ -0,0 +1,20 @@ +namespace ApiHealthDashboard.Services; + +public sealed class EndpointImportRequest +{ + public string? Id { get; init; } + + public string? Name { get; init; } + + public string Url { get; init; } = string.Empty; + + public bool Enabled { get; init; } = true; + + public int FrequencySeconds { get; init; } = 30; + + public int? TimeoutSeconds { get; init; } + + public string HeadersText { get; init; } = string.Empty; + + public bool IncludeDiscoveredChecks { get; init; } +} diff --git a/src/ApiHealthDashboard/Services/EndpointImportResult.cs b/src/ApiHealthDashboard/Services/EndpointImportResult.cs new file mode 100644 index 0000000..f362d60 --- /dev/null +++ b/src/ApiHealthDashboard/Services/EndpointImportResult.cs @@ -0,0 +1,68 @@ +using System.Net; +using ApiHealthDashboard.Configuration; + +namespace ApiHealthDashboard.Services; + +public sealed class EndpointImportResult +{ + public required EndpointConfig SuggestedEndpoint { get; init; } + + public string? GeneratedYaml { get; init; } + + public required PollResult ProbeResult { get; init; } + + public required string ProbeStatusText { get; init; } + + public required string MatchSummary { get; init; } + + public EndpointConfig? ExistingEndpoint { get; init; } + + public string? ExistingYaml { get; init; } + + public IReadOnlyList DiffLines { get; init; } = []; + + public IReadOnlyList DiscoveredChecks { get; init; } = []; + + public IReadOnlyList TopLevelCheckNames { get; init; } = []; + + public string ResponsePreview { get; init; } = string.Empty; + + public bool ResponsePreviewWasTruncated { get; init; } + + public string? ParserStatus { get; init; } + + public string? ParserError { get; init; } + + public bool HasExistingMatch => ExistingEndpoint is not null; + + public bool HasDiff => DiffLines.Count > 0; + + public bool HasGeneratedYamlPreview => !string.IsNullOrWhiteSpace(GeneratedYaml); + + public bool HasResponsePreview => !string.IsNullOrWhiteSpace(ResponsePreview); + + public bool IsEndpointNotFound => ProbeResult.Kind == PollResultKind.HttpError && + ProbeResult.StatusCode == HttpStatusCode.NotFound; + + public string ProbeHttpStatusText => ProbeResult.StatusCode is HttpStatusCode statusCode + ? $"{(int)statusCode} {statusCode}" + : "None"; +} + +public sealed class EndpointImportDiffLine +{ + public required string Prefix { get; init; } + + public required string Text { get; init; } + + public required string CssClass { get; init; } +} + +public sealed class EndpointImportCheckSummary +{ + public required string Path { get; init; } + + public required string Status { get; init; } + + public required int Depth { get; init; } +} diff --git a/src/ApiHealthDashboard/Services/EndpointImportService.cs b/src/ApiHealthDashboard/Services/EndpointImportService.cs new file mode 100644 index 0000000..323e286 --- /dev/null +++ b/src/ApiHealthDashboard/Services/EndpointImportService.cs @@ -0,0 +1,521 @@ +using System.Globalization; +using System.Text; +using System.Text.Json; +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Parsing; + +namespace ApiHealthDashboard.Services; + +public sealed class EndpointImportService : IEndpointImportService +{ + private const int ResponsePreviewLimit = 12000; + private static readonly string[] GenericPathSegments = ["health", "healthz", "status", "ready", "live"]; + + private readonly DashboardConfig _dashboardConfig; + private readonly IHealthResponseParser _healthResponseParser; + private readonly ILogger _logger; + private readonly IEndpointPoller _endpointPoller; + + public EndpointImportService( + DashboardConfig dashboardConfig, + IEndpointPoller endpointPoller, + IHealthResponseParser healthResponseParser, + ILogger logger) + { + _dashboardConfig = dashboardConfig; + _endpointPoller = endpointPoller; + _healthResponseParser = healthResponseParser; + _logger = logger; + } + + public async Task ImportAsync(EndpointImportRequest request, CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(request); + + var validationErrors = ValidateRequest(request); + if (validationErrors.Count > 0) + { + throw new EndpointImportException(validationErrors); + } + + var headers = ParseHeaders(request.HeadersText); + var suggestedId = string.IsNullOrWhiteSpace(request.Id) + ? SuggestEndpointId(request.Url) + : request.Id.Trim(); + var suggestedName = string.IsNullOrWhiteSpace(request.Name) + ? SuggestEndpointName(suggestedId) + : request.Name.Trim(); + + var probeEndpoint = new EndpointConfig + { + Id = suggestedId, + Name = suggestedName, + Url = request.Url.Trim(), + Enabled = request.Enabled, + FrequencySeconds = request.FrequencySeconds, + TimeoutSeconds = request.TimeoutSeconds, + Headers = headers + }; + + _logger.LogInformation( + "Starting endpoint import probe for suggested endpoint {EndpointId} against {Url}.", + probeEndpoint.Id, + probeEndpoint.Url); + + var pollResult = await _endpointPoller.PollAsync(probeEndpoint, cancellationToken); + var snapshot = string.IsNullOrWhiteSpace(pollResult.ResponseBody) + ? null + : _healthResponseParser.Parse(probeEndpoint, pollResult.ResponseBody, pollResult.DurationMs); + var topLevelCheckNames = snapshot?.Nodes + .Select(static node => node.Name) + .Where(static name => !string.IsNullOrWhiteSpace(name)) + .Distinct(StringComparer.OrdinalIgnoreCase) + .OrderBy(static name => name, StringComparer.OrdinalIgnoreCase) + .ToArray() ?? []; + + var suggestedEndpoint = new EndpointConfig + { + Id = probeEndpoint.Id, + Name = probeEndpoint.Name, + Url = probeEndpoint.Url, + Enabled = probeEndpoint.Enabled, + FrequencySeconds = probeEndpoint.FrequencySeconds, + TimeoutSeconds = probeEndpoint.TimeoutSeconds, + Headers = new Dictionary(probeEndpoint.Headers, StringComparer.OrdinalIgnoreCase), + IncludeChecks = request.IncludeDiscoveredChecks ? [.. topLevelCheckNames] : [], + ExcludeChecks = [] + }; + + var shouldGenerateYamlPreview = !(pollResult.Kind == PollResultKind.HttpError && + pollResult.StatusCode == System.Net.HttpStatusCode.NotFound); + var generatedYaml = shouldGenerateYamlPreview ? RenderEndpointYaml(suggestedEndpoint) : null; + var existingEndpoint = FindExistingEndpoint(suggestedEndpoint); + var existingYaml = existingEndpoint is null ? null : RenderEndpointYaml(existingEndpoint); + var diffLines = existingYaml is null || string.IsNullOrWhiteSpace(generatedYaml) + ? [] + : BuildDiff(existingYaml, generatedYaml); + var responsePreview = BuildResponsePreview(pollResult.ResponseBody, out var responsePreviewWasTruncated); + var discoveredChecks = snapshot is null ? [] : FlattenChecks(snapshot.Nodes); + var parserError = TryGetParserError(snapshot); + + _logger.LogInformation( + "Completed endpoint import probe for {EndpointId} with poll result {ResultKind} and existing match {HasExistingMatch}.", + suggestedEndpoint.Id, + pollResult.Kind, + existingEndpoint is not null); + + return new EndpointImportResult + { + SuggestedEndpoint = suggestedEndpoint, + GeneratedYaml = generatedYaml, + ProbeResult = pollResult, + ProbeStatusText = DescribeProbeStatus(pollResult), + MatchSummary = DescribeExistingMatch(existingEndpoint, suggestedEndpoint), + ExistingEndpoint = existingEndpoint, + ExistingYaml = existingYaml, + DiffLines = diffLines, + DiscoveredChecks = discoveredChecks, + TopLevelCheckNames = topLevelCheckNames, + ResponsePreview = responsePreview, + ResponsePreviewWasTruncated = responsePreviewWasTruncated, + ParserStatus = snapshot?.OverallStatus, + ParserError = parserError + }; + } + + private static List ValidateRequest(EndpointImportRequest request) + { + var errors = new List(); + + if (string.IsNullOrWhiteSpace(request.Url)) + { + errors.Add("Endpoint URL is required."); + } + else if (!Uri.TryCreate(request.Url.Trim(), UriKind.Absolute, out var uri) || + (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)) + { + errors.Add("Endpoint URL must be an absolute HTTP or HTTPS address."); + } + + if (request.FrequencySeconds <= 0) + { + errors.Add("Frequency seconds must be greater than zero."); + } + + if (request.TimeoutSeconds is <= 0) + { + errors.Add("Timeout seconds must be greater than zero when specified."); + } + + return errors; + } + + private static Dictionary ParseHeaders(string headersText) + { + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(headersText)) + { + return headers; + } + + var lines = headersText + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n', StringSplitOptions.TrimEntries); + + for (var index = 0; index < lines.Length; index++) + { + var line = lines[index]; + if (string.IsNullOrWhiteSpace(line)) + { + continue; + } + + var separatorIndex = line.IndexOf(':'); + if (separatorIndex <= 0) + { + throw new EndpointImportException([$"Header line {index + 1} must use the format 'Name: value'."]); + } + + var headerName = line[..separatorIndex].Trim(); + var headerValue = line[(separatorIndex + 1)..].Trim(); + + if (string.IsNullOrWhiteSpace(headerName)) + { + throw new EndpointImportException([$"Header line {index + 1} contains an empty header name."]); + } + + if (headers.ContainsKey(headerName)) + { + throw new EndpointImportException([$"Header '{headerName}' is defined more than once."]); + } + + headers[headerName] = headerValue; + } + + return headers; + } + + private EndpointConfig? FindExistingEndpoint(EndpointConfig suggestedEndpoint) + { + return _dashboardConfig.Endpoints.FirstOrDefault(endpoint => + string.Equals(endpoint.Id, suggestedEndpoint.Id, StringComparison.OrdinalIgnoreCase)) + ?? _dashboardConfig.Endpoints.FirstOrDefault(endpoint => + string.Equals(endpoint.Url, suggestedEndpoint.Url, StringComparison.OrdinalIgnoreCase)); + } + + private static string DescribeExistingMatch(EndpointConfig? existingEndpoint, EndpointConfig suggestedEndpoint) + { + if (existingEndpoint is null) + { + return "No existing endpoint matched the suggested id or URL."; + } + + if (string.Equals(existingEndpoint.Id, suggestedEndpoint.Id, StringComparison.OrdinalIgnoreCase)) + { + return $"Matched existing endpoint '{existingEndpoint.Id}' by id."; + } + + return $"Matched existing endpoint '{existingEndpoint.Id}' by URL."; + } + + private static string DescribeProbeStatus(PollResult pollResult) + { + return pollResult.Kind switch + { + PollResultKind.Success => "Probe completed successfully.", + PollResultKind.HttpError => pollResult.ErrorMessage ?? "Probe completed with an HTTP error response.", + PollResultKind.EmptyResponse => pollResult.ErrorMessage ?? "Probe completed but returned an empty body.", + PollResultKind.Timeout => pollResult.ErrorMessage ?? "Probe timed out.", + PollResultKind.NetworkError => pollResult.ErrorMessage ?? "Probe failed with a network error.", + _ => pollResult.ErrorMessage ?? "Probe failed with an unexpected error." + }; + } + + private static string BuildResponsePreview(string? responseBody, out bool truncated) + { + if (string.IsNullOrEmpty(responseBody)) + { + truncated = false; + return string.Empty; + } + + var formattedResponse = FormatResponsePreview(responseBody); + + if (formattedResponse.Length <= ResponsePreviewLimit) + { + truncated = false; + return formattedResponse; + } + + truncated = true; + return formattedResponse[..ResponsePreviewLimit]; + } + + private static string FormatResponsePreview(string responseBody) + { + if (string.IsNullOrWhiteSpace(responseBody)) + { + return responseBody; + } + + try + { + using var document = JsonDocument.Parse(responseBody); + return JsonSerializer.Serialize( + document.RootElement, + new JsonSerializerOptions + { + WriteIndented = true + }); + } + catch (JsonException) + { + return responseBody; + } + } + + private static string? TryGetParserError(HealthSnapshot? snapshot) + { + if (snapshot?.Metadata.TryGetValue("parserError", out var parserError) == true) + { + return parserError?.ToString(); + } + + return null; + } + + private static IReadOnlyList FlattenChecks(IReadOnlyList nodes) + { + var results = new List(); + + for (var index = 0; index < nodes.Count; index++) + { + FlattenChecks(nodes[index], nodes[index].Name, 0, results); + } + + return results; + } + + private static void FlattenChecks( + HealthNode node, + string path, + int depth, + ICollection results) + { + results.Add(new EndpointImportCheckSummary + { + Path = path, + Status = node.Status, + Depth = depth + }); + + foreach (var child in node.Children) + { + FlattenChecks(child, $"{path} / {child.Name}", depth + 1, results); + } + } + + private static string SuggestEndpointId(string url) + { + if (!Uri.TryCreate(url.Trim(), UriKind.Absolute, out var uri)) + { + return "imported-endpoint"; + } + + var tokens = new List(); + var hostTokens = uri.Host.Split('.', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); + + if (hostTokens.Length > 0) + { + tokens.Add(Slugify(hostTokens[0])); + } + + var pathTokens = uri.AbsolutePath + .Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(Slugify) + .Where(static token => !string.IsNullOrWhiteSpace(token) && !GenericPathSegments.Contains(token, StringComparer.OrdinalIgnoreCase)) + .ToList(); + + tokens.AddRange(pathTokens); + + var combined = string.Join('-', tokens.Where(static token => !string.IsNullOrWhiteSpace(token))); + if (string.IsNullOrWhiteSpace(combined)) + { + combined = "imported-endpoint"; + } + + if (!combined.Contains("api", StringComparison.OrdinalIgnoreCase)) + { + combined = $"{combined}-api"; + } + + return combined; + } + + private static string SuggestEndpointName(string id) + { + var words = id + .Split('-', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(static word => string.Equals(word, "api", StringComparison.OrdinalIgnoreCase) + ? "API" + : CultureInfo.InvariantCulture.TextInfo.ToTitleCase(word)) + .ToArray(); + + return words.Length == 0 ? "Imported Endpoint" : string.Join(' ', words); + } + + private static string Slugify(string value) + { + var builder = new StringBuilder(value.Length); + var lastWasDash = false; + + foreach (var character in value) + { + if (char.IsLetterOrDigit(character)) + { + builder.Append(char.ToLowerInvariant(character)); + lastWasDash = false; + continue; + } + + if (lastWasDash) + { + continue; + } + + builder.Append('-'); + lastWasDash = true; + } + + return builder.ToString().Trim('-'); + } + + private static string RenderEndpointYaml(EndpointConfig endpoint) + { + var lines = new List + { + $"id: {Quote(endpoint.Id)}", + $"name: {Quote(endpoint.Name)}", + $"url: {Quote(endpoint.Url)}", + $"enabled: {endpoint.Enabled.ToString().ToLowerInvariant()}", + $"frequencySeconds: {endpoint.FrequencySeconds}" + }; + + if (endpoint.TimeoutSeconds is int timeoutSeconds) + { + lines.Add($"timeoutSeconds: {timeoutSeconds}"); + } + + if (endpoint.Headers.Count > 0) + { + lines.Add("headers:"); + + foreach (var header in endpoint.Headers.OrderBy(static item => item.Key, StringComparer.OrdinalIgnoreCase)) + { + lines.Add($" {header.Key}: {Quote(header.Value)}"); + } + } + + if (endpoint.IncludeChecks.Count > 0) + { + lines.Add("includeChecks:"); + + foreach (var check in endpoint.IncludeChecks.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)) + { + lines.Add($" - {Quote(check)}"); + } + } + + if (endpoint.ExcludeChecks.Count > 0) + { + lines.Add("excludeChecks:"); + + foreach (var check in endpoint.ExcludeChecks.OrderBy(static item => item, StringComparer.OrdinalIgnoreCase)) + { + lines.Add($" - {Quote(check)}"); + } + } + + return string.Join(Environment.NewLine, lines); + } + + private static string Quote(string value) + { + var normalized = value.Replace("'", "''", StringComparison.Ordinal); + return $"'{normalized}'"; + } + + private static IReadOnlyList BuildDiff(string existingYaml, string generatedYaml) + { + var existingLines = SplitLines(existingYaml); + var generatedLines = SplitLines(generatedYaml); + var lcs = BuildLongestCommonSubsequence(existingLines, generatedLines); + var results = new List(); + var left = 0; + var right = 0; + + while (left < existingLines.Count || right < generatedLines.Count) + { + if (left < existingLines.Count && right < generatedLines.Count && + string.Equals(existingLines[left], generatedLines[right], StringComparison.Ordinal)) + { + results.Add(CreateDiffLine(" ", existingLines[left], "diff-context")); + left++; + right++; + continue; + } + + if (right < generatedLines.Count && + (left == existingLines.Count || lcs[left, right + 1] >= lcs[left + 1, right])) + { + results.Add(CreateDiffLine("+", generatedLines[right], "diff-added")); + right++; + continue; + } + + if (left < existingLines.Count) + { + results.Add(CreateDiffLine("-", existingLines[left], "diff-removed")); + left++; + } + } + + return results; + } + + private static EndpointImportDiffLine CreateDiffLine(string prefix, string text, string cssClass) + { + return new EndpointImportDiffLine + { + Prefix = prefix, + Text = text, + CssClass = cssClass + }; + } + + private static List SplitLines(string value) + { + return value + .Replace("\r\n", "\n", StringComparison.Ordinal) + .Split('\n') + .ToList(); + } + + private static int[,] BuildLongestCommonSubsequence(IReadOnlyList left, IReadOnlyList right) + { + var matrix = new int[left.Count + 1, right.Count + 1]; + + for (var leftIndex = left.Count - 1; leftIndex >= 0; leftIndex--) + { + for (var rightIndex = right.Count - 1; rightIndex >= 0; rightIndex--) + { + matrix[leftIndex, rightIndex] = string.Equals(left[leftIndex], right[rightIndex], StringComparison.Ordinal) + ? matrix[leftIndex + 1, rightIndex + 1] + 1 + : Math.Max(matrix[leftIndex + 1, rightIndex], matrix[leftIndex, rightIndex + 1]); + } + } + + return matrix; + } +} diff --git a/src/ApiHealthDashboard/Services/IEndpointImportService.cs b/src/ApiHealthDashboard/Services/IEndpointImportService.cs new file mode 100644 index 0000000..cc618cc --- /dev/null +++ b/src/ApiHealthDashboard/Services/IEndpointImportService.cs @@ -0,0 +1,6 @@ +namespace ApiHealthDashboard.Services; + +public interface IEndpointImportService +{ + Task ImportAsync(EndpointImportRequest request, CancellationToken cancellationToken); +} diff --git a/src/ApiHealthDashboard/appsettings.Development.json b/src/ApiHealthDashboard/appsettings.Development.json index b32c146..638847b 100644 --- a/src/ApiHealthDashboard/appsettings.Development.json +++ b/src/ApiHealthDashboard/appsettings.Development.json @@ -2,6 +2,9 @@ "Bootstrap": { "DashboardConfigPath": "dashboard.yaml" }, + "Import": { + "MinimumRecommendedPollFrequencySeconds": 180 + }, "DetailedErrors": true, "Logging": { "LogLevel": { diff --git a/src/ApiHealthDashboard/appsettings.json b/src/ApiHealthDashboard/appsettings.json index 1821cf1..1742e57 100644 --- a/src/ApiHealthDashboard/appsettings.json +++ b/src/ApiHealthDashboard/appsettings.json @@ -2,6 +2,9 @@ "Bootstrap": { "DashboardConfigPath": "dashboard.yaml" }, + "Import": { + "MinimumRecommendedPollFrequencySeconds": 180 + }, "Logging": { "LogLevel": { "Default": "Information", diff --git a/src/ApiHealthDashboard/wwwroot/css/site.css b/src/ApiHealthDashboard/wwwroot/css/site.css index b0ed9a3..678d9bd 100644 --- a/src/ApiHealthDashboard/wwwroot/css/site.css +++ b/src/ApiHealthDashboard/wwwroot/css/site.css @@ -20,6 +20,11 @@ body { background: transparent; } +.configuration-warning-banner { + border-radius: 0.9rem; + box-shadow: 0 12px 30px rgba(217, 119, 6, 0.12); +} + .sidebar-avatar { display: grid; width: 2.35rem; @@ -147,10 +152,19 @@ body { border-radius: 0.85rem; color: #e5edf8; background: #142033; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; + overflow-x: auto; + line-height: 1.6; + font-size: 0.88rem; } .payload-preview code { + display: block; color: inherit; + white-space: inherit; + overflow-wrap: inherit; } .nested-health-group { @@ -222,6 +236,174 @@ body { margin-bottom: 0; } +.import-validation-summary:empty { + display: none; +} + +.import-validation-summary.validation-summary-valid { + display: none; +} + +.import-soft-warning { + border-radius: 0.85rem; +} + +.import-form { + display: grid; + gap: 1.15rem; +} + +.import-form-section { + padding: 1rem 1rem 0.35rem; + border: 1px solid rgba(31, 41, 55, 0.08); + border-radius: 1rem; + background: linear-gradient(180deg, rgba(248, 250, 252, 0.95), rgba(255, 255, 255, 1)); +} + +.import-section-label { + margin-bottom: 0.85rem; + color: #64748b; + font-size: 0.72rem; + font-weight: 700; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.import-headers-box { + font-family: Consolas, "Courier New", monospace; +} + +.import-toggle-panel { + display: grid; + gap: 0.9rem; + padding: 1rem 1rem 0.85rem; + border-radius: 1rem; + background: rgba(15, 23, 42, 0.035); + border: 1px solid rgba(31, 41, 55, 0.08); +} + +.import-toggle-copy { + display: block; + margin-top: 0.5rem; + margin-left: 2rem; + line-height: 1.45; +} + +.import-form-actions { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.import-guidance-item + .import-guidance-item { + margin-top: 1.25rem; +} + +.import-guidance-item h4 { + font-size: 1rem; + margin-bottom: 0.35rem; +} + +.import-summary-list dt, +.import-summary-list dd { + margin-bottom: 0.65rem; +} + +.import-break { + word-break: break-word; +} + +.import-preview-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + margin-bottom: 0.85rem; + flex-wrap: wrap; +} + +.import-preview-actions { + display: flex; + align-items: center; + gap: 0.75rem; + flex-wrap: wrap; +} + +.import-copy-feedback { + min-width: 3.5rem; +} + +.import-code-block { + margin: 0; + padding: 1rem; + border-radius: 0.85rem; + background: #0f172a; + color: #e2e8f0; + font-size: 0.85rem; + line-height: 1.55; + white-space: pre-wrap; + word-break: break-word; +} + +.import-code-block code { + display: block; + white-space: inherit; + overflow-wrap: anywhere; + word-break: break-word; +} + +.import-response-preview { + max-height: 32rem; + overflow: auto; + line-height: 1.65; +} + +.import-diff-block { + margin: 0; + padding: 0.85rem 0; + border-radius: 0.85rem; + background: #0f172a; + color: #e2e8f0; + font-family: Consolas, "Courier New", monospace; + font-size: 0.85rem; + line-height: 1.55; + overflow-x: auto; +} + +.import-diff-line { + display: flex; + gap: 0.75rem; + padding: 0.1rem 1rem; + white-space: pre-wrap; + word-break: break-word; +} + +.diff-prefix { + width: 1rem; + flex: 0 0 1rem; +} + +.diff-added { + background: rgba(34, 197, 94, 0.18); + color: #dcfce7; +} + +.diff-removed { + background: rgba(248, 113, 113, 0.18); + color: #fee2e2; +} + +.diff-context { + color: #cbd5e1; +} + +.import-check-table td, +.import-check-table th { + vertical-align: top; +} + .dashboard-empty-state-compact { min-height: 12rem; padding: 1.5rem; @@ -344,6 +526,22 @@ body { font-size: 1.65rem; } + .import-form-section { + padding: 0.9rem 0.9rem 0.25rem; + } + + .import-form-actions { + align-items: flex-start; + } + + .import-preview-header { + align-items: flex-start; + } + + .import-toggle-copy { + margin-left: 0; + } + .endpoint-search-tools { width: 100%; } diff --git a/src/ApiHealthDashboard/wwwroot/js/site.js b/src/ApiHealthDashboard/wwwroot/js/site.js index 573ce76..379c834 100644 --- a/src/ApiHealthDashboard/wwwroot/js/site.js +++ b/src/ApiHealthDashboard/wwwroot/js/site.js @@ -1,42 +1,172 @@ document.addEventListener("DOMContentLoaded", () => { - document.querySelectorAll("[aria-disabled='true']").forEach((element) => { + initializeDisabledActions(document); + initializeEndpointSearch(document); + initializeCopyButtons(document); + initializeDashboardSectionRefresh(); +}); + +function initializeDisabledActions(root) { + root.querySelectorAll("[aria-disabled='true']").forEach((element) => { + if (element.dataset.disabledActionBound === "true") { + return; + } + + element.dataset.disabledActionBound = "true"; element.addEventListener("click", (event) => { 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]"); +function initializeEndpointSearch(root, preservedQuery) { + const searchInput = root.querySelector("[data-endpoint-search-input]"); + const searchRows = Array.from(root.querySelectorAll("[data-endpoint-search-row]")); + const emptyRow = root.querySelector("[data-endpoint-search-empty]"); + const searchCount = root.querySelector("[data-endpoint-search-count]"); if (!searchInput || searchRows.length === 0) { return; } - const applyEndpointFilter = () => { - const query = searchInput.value.trim().toLowerCase(); - let visibleCount = 0; + if (typeof preservedQuery === "string") { + searchInput.value = preservedQuery; + } + + if (searchInput.dataset.searchBound === "true") { + applyEndpointFilter(searchInput, searchRows, emptyRow, searchCount); + return; + } + + searchInput.dataset.searchBound = "true"; + + const applyFilter = () => { + applyEndpointFilter(searchInput, searchRows, emptyRow, searchCount); + }; + + searchInput.addEventListener("input", applyFilter); + searchInput.addEventListener("search", applyFilter); + applyFilter(); +} + +function applyEndpointFilter(searchInput, searchRows, emptyRow, searchCount) { + 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"}`; + } +} + +function initializeCopyButtons(root) { + root.querySelectorAll("[data-copy-target]").forEach((button) => { + if (button.dataset.copyBound === "true") { + return; + } - searchRows.forEach((row) => { - const searchText = (row.getAttribute("data-endpoint-search-text") || row.textContent || "").toLowerCase(); - const isVisible = query === "" || searchText.includes(query); - row.hidden = !isVisible; + button.dataset.copyBound = "true"; + button.addEventListener("click", async () => { + const targetSelector = button.getAttribute("data-copy-target"); + const feedbackSelector = button.getAttribute("data-copy-feedback"); + const target = targetSelector ? document.querySelector(targetSelector) : null; + const feedback = feedbackSelector ? document.querySelector(feedbackSelector) : null; - if (isVisible) { - visibleCount += 1; + if (!target) { + setCopyFeedback(feedback, "Nothing to copy."); + return; + } + + const text = target.textContent || ""; + if (text.trim() === "") { + setCopyFeedback(feedback, "Nothing to copy."); + return; + } + + try { + await navigator.clipboard.writeText(text); + setCopyFeedback(feedback, "Copied."); + } catch { + setCopyFeedback(feedback, "Copy failed."); } }); + }); +} + +function setCopyFeedback(feedback, message) { + if (feedback) { + feedback.textContent = message; + } +} - if (emptyRow) { - emptyRow.hidden = query === "" || visibleCount > 0; +function initializeDashboardSectionRefresh() { + const container = document.querySelector("[data-dashboard-live-section]"); + if (!container) { + return; + } + + const refreshUrl = container.getAttribute("data-refresh-url"); + const refreshSeconds = Number.parseInt(container.getAttribute("data-refresh-seconds") || "", 10); + + if (!refreshUrl || !Number.isFinite(refreshSeconds) || refreshSeconds <= 0) { + return; + } + + window.setInterval(async () => { + if (shouldPauseDashboardRefresh(container)) { + return; } - if (searchCount) { - searchCount.textContent = query === "" ? "" : `${visibleCount} match${visibleCount === 1 ? "" : "es"}`; + const preservedQuery = getCurrentSearchQuery(container); + + try { + const response = await fetch(refreshUrl, { + method: "GET", + headers: { + "X-Requested-With": "XMLHttpRequest" + }, + credentials: "same-origin", + cache: "no-store" + }); + + if (!response.ok) { + return; + } + + const html = await response.text(); + container.innerHTML = html; + + initializeDisabledActions(container); + initializeEndpointSearch(container, preservedQuery); + initializeCopyButtons(container); + } catch { + // Keep the current rendered section if the background refresh fails. } - }; + }, refreshSeconds * 1000); +} - searchInput.addEventListener("input", applyEndpointFilter); - searchInput.addEventListener("search", applyEndpointFilter); -}); +function getCurrentSearchQuery(container) { + const searchInput = container.querySelector("[data-endpoint-search-input]"); + return searchInput ? searchInput.value : ""; +} + +function shouldPauseDashboardRefresh(container) { + const activeElement = document.activeElement; + if (!activeElement || !container.contains(activeElement)) { + return false; + } + + return activeElement.matches("input, textarea, select"); +} diff --git a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs index 20c8d11..c3aa0e3 100644 --- a/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs +++ b/tests/ApiHealthDashboard.Tests/Configuration/YamlConfigLoaderTests.cs @@ -39,7 +39,8 @@ public void Load_WithValidYaml_ReturnsNormalizedConfiguration() - optional-third-party """); - var config = _loader.Load(configPath); + var result = _loader.Load(configPath); + var config = result.Config; Assert.Equal(15, config.Dashboard.RefreshUiSeconds); Assert.Equal(12, config.Dashboard.RequestTimeoutSecondsDefault); @@ -55,6 +56,7 @@ public void Load_WithValidYaml_ReturnsNormalizedConfiguration() Assert.Equal("enabled", endpoint.Headers["X-Trace"]); Assert.Equal(["self", "database"], endpoint.IncludeChecks); Assert.Equal(["optional-third-party"], endpoint.ExcludeChecks); + Assert.Empty(result.Warnings); } [Fact] @@ -72,7 +74,7 @@ public void Load_WithNullOptionalCollections_NormalizesThemToEmpty() excludeChecks: """); - var config = _loader.Load(configPath); + var config = _loader.Load(configPath).Config; var endpoint = Assert.Single(config.Endpoints); Assert.Empty(endpoint.Headers); @@ -154,7 +156,7 @@ public void Load_ReplacesEnvironmentVariableTokensWhenPresent() X-Api-Key: ${{{variableName}}} """); - var config = _loader.Load(configPath); + var config = _loader.Load(configPath).Config; Assert.Equal("secret-value", config.Endpoints[0].Headers["X-Api-Key"]); } @@ -165,13 +167,14 @@ public void Load_ReplacesEnvironmentVariableTokensWhenPresent() } [Fact] - public void Load_WithMissingFile_ThrowsHelpfulError() + public void Load_WithMissingDashboardFile_ReturnsWarningAndEmptyConfig() { var missingPath = Path.Combine(_tempDirectory, "missing.yaml"); - var exception = Assert.Throws(() => _loader.Load(missingPath)); + var result = _loader.Load(missingPath); - Assert.Contains("was not found", exception.Errors.Single(), StringComparison.OrdinalIgnoreCase); + Assert.Empty(result.Config.Endpoints); + Assert.Contains("was not found", result.Warnings.Single(), StringComparison.OrdinalIgnoreCase); } [Fact] @@ -228,7 +231,8 @@ public void Load_WithSeparateEndpointFiles_LoadsAndMergesEndpoints() - endpoints/services.yaml """); - var config = _loader.Load(dashboardConfigPath); + var result = _loader.Load(dashboardConfigPath); + var config = result.Config; Assert.Equal(20, config.Dashboard.RefreshUiSeconds); Assert.Equal(15, config.Dashboard.RequestTimeoutSecondsDefault); @@ -237,21 +241,34 @@ public void Load_WithSeparateEndpointFiles_LoadsAndMergesEndpoints() 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"); + Assert.Empty(result.Warnings); } [Fact] - public void Load_WithMissingEndpointFile_ThrowsHelpfulError() + public void Load_WithMissingEndpointFile_ReturnsWarningAndContinues() { + WriteNamedConfig( + "endpoints/orders-api.yaml", + """ + id: orders-api + name: Orders API + url: https://orders.example.com/health + frequencySeconds: 30 + """); + var dashboardConfigPath = WriteNamedConfig( "dashboard.yaml", """ endpointFiles: + - endpoints/orders-api.yaml - endpoints/missing.yaml """); - var exception = Assert.Throws(() => _loader.Load(dashboardConfigPath)); + var result = _loader.Load(dashboardConfigPath); - Assert.Contains("missing.yaml", exception.Errors.Single(), StringComparison.OrdinalIgnoreCase); + Assert.Single(result.Config.Endpoints); + Assert.Equal("orders-api", result.Config.Endpoints[0].Id); + Assert.Contains("missing.yaml", result.Warnings.Single(), StringComparison.OrdinalIgnoreCase); } public void Dispose() diff --git a/tests/ApiHealthDashboard.Tests/Formatting/DisplayValueFormatterTests.cs b/tests/ApiHealthDashboard.Tests/Formatting/DisplayValueFormatterTests.cs new file mode 100644 index 0000000..6b39005 --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Formatting/DisplayValueFormatterTests.cs @@ -0,0 +1,22 @@ +using ApiHealthDashboard.Formatting; + +namespace ApiHealthDashboard.Tests.Formatting; + +public sealed class DisplayValueFormatterTests +{ + [Fact] + public void Format_WithEmptyList_ReturnsEmptyMarker() + { + var result = DisplayValueFormatter.Format(new List()); + + Assert.Equal("(empty)", result); + } + + [Fact] + public void Format_WithNonEmptyList_ReturnsJsonArray() + { + var result = DisplayValueFormatter.Format(new List { "core", "orders" }); + + Assert.Equal("[\"core\",\"orders\"]", result); + } +} diff --git a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs index e984c14..dcdf96c 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/Endpoints/DetailsModelTests.cs @@ -104,7 +104,8 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() Metadata = new Dictionary { ["region"] = "sgp-1", - ["statusCode"] = 200 + ["statusCode"] = 200, + ["tags"] = new List() }, Nodes = [ @@ -147,8 +148,11 @@ public void OnGet_WithSnapshot_LoadsDetailedDiagnostics() Assert.Equal("********", model.Endpoint.Headers.Single().ValuePreview); Assert.Equal(["database"], model.Endpoint.IncludeChecks); Assert.Equal(["cache"], model.Endpoint.ExcludeChecks); - Assert.Equal(2, model.Endpoint.SnapshotMetadata.Count); - Assert.Equal("{\"status\":\"Degraded\"}", model.Endpoint.RawPayload); + Assert.Equal(3, model.Endpoint.SnapshotMetadata.Count); + Assert.Equal("(empty)", model.Endpoint.SnapshotMetadata.Single(static item => item.Name == "tags").Value); + Assert.Equal( + "{\n \"status\": \"Degraded\"\n}", + model.Endpoint.RawPayload!.Replace("\r\n", "\n", StringComparison.Ordinal)); } [Fact] diff --git a/tests/ApiHealthDashboard.Tests/Pages/ImportModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/ImportModelTests.cs new file mode 100644 index 0000000..8eed77a --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Pages/ImportModelTests.cs @@ -0,0 +1,116 @@ +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Pages; +using ApiHealthDashboard.Services; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.RazorPages; +using Microsoft.Extensions.Options; +using Microsoft.Extensions.Logging.Abstractions; + +namespace ApiHealthDashboard.Tests.Pages; + +public sealed class ImportModelTests +{ + [Fact] + public async Task OnPostPreviewAsync_WithValidInput_LoadsImportResult() + { + var importResult = new EndpointImportResult + { + SuggestedEndpoint = new EndpointConfig + { + Id = "orders-api", + Name = "Orders API", + Url = "https://orders.example.com/health", + Enabled = true, + FrequencySeconds = 30 + }, + GeneratedYaml = "id: 'orders-api'", + ProbeResult = new PollResult + { + Kind = PollResultKind.Success, + DurationMs = 42 + }, + ProbeStatusText = "Probe completed successfully.", + MatchSummary = "No existing endpoint matched the suggested id or URL." + }; + + var model = new ImportModel( + new DashboardConfig(), + new StubEndpointImportService(importResult), + Options.Create(new ImportUiOptions + { + MinimumRecommendedPollFrequencySeconds = 180 + }), + NullLogger.Instance) + { + Input = new ImportModel.InputModel + { + Url = "https://orders.example.com/health", + FrequencySeconds = 30 + } + }; + + var result = await model.OnPostPreviewAsync(CancellationToken.None); + + Assert.IsType(result); + Assert.NotNull(model.Result); + Assert.Equal("orders-api", model.Input.Id); + Assert.Equal("Orders API", model.Input.Name); + Assert.Equal("Poll frequency below the recommended soft limit of 180 seconds may create unnecessary load.", model.FrequencyRecommendationWarning); + } + + [Fact] + public async Task OnPostPreviewAsync_WithInvalidInput_AddsModelError() + { + var model = new ImportModel( + new DashboardConfig(), + new StubEndpointImportService(new EndpointImportResult + { + SuggestedEndpoint = new EndpointConfig + { + Id = "ignored", + Name = "Ignored", + Url = "https://ignored.example.com/health", + Enabled = true, + FrequencySeconds = 30 + }, + GeneratedYaml = string.Empty, + ProbeResult = new PollResult(), + ProbeStatusText = string.Empty, + MatchSummary = string.Empty + }), + Options.Create(new ImportUiOptions + { + MinimumRecommendedPollFrequencySeconds = 180 + }), + NullLogger.Instance) + { + Input = new ImportModel.InputModel + { + Url = string.Empty, + FrequencySeconds = 30 + } + }; + + var result = await model.OnPostPreviewAsync(CancellationToken.None); + + Assert.IsType(result); + Assert.False(model.ModelState.IsValid); + Assert.Null(model.Result); + Assert.Equal("Poll frequency below the recommended soft limit of 180 seconds may create unnecessary load.", model.FrequencyRecommendationWarning); + } + + private sealed class StubEndpointImportService : IEndpointImportService + { + private readonly EndpointImportResult _result; + + public StubEndpointImportService(EndpointImportResult result) + { + _result = result; + } + + public Task ImportAsync(EndpointImportRequest request, CancellationToken cancellationToken) + { + return Task.FromResult(_result); + } + } +} diff --git a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs index 8971d12..27d8d7d 100644 --- a/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs +++ b/tests/ApiHealthDashboard.Tests/Pages/IndexModelTests.cs @@ -3,6 +3,9 @@ using ApiHealthDashboard.Scheduling; using ApiHealthDashboard.State; using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.ModelBinding; +using Microsoft.AspNetCore.Mvc.ViewFeatures; +using Microsoft.AspNetCore.Mvc.RazorPages; using Microsoft.Extensions.Logging.Abstractions; namespace ApiHealthDashboard.Tests.Pages; @@ -129,6 +132,26 @@ public void OnGet_WithNoConfiguredEndpoints_ExposesEmptyDashboardState() Assert.Equal(0, model.Counters.Total); } + [Fact] + public void OnGetLiveSection_ReturnsDashboardPartial() + { + var config = CreateConfig(); + var store = new InMemoryEndpointStateStore(config.Endpoints); + var model = new IndexModel(config, store, new StubEndpointScheduler(), NullLogger.Instance) + { + PageContext = new PageContext + { + ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider(), new ModelStateDictionary()) + } + }; + + var result = model.OnGetLiveSection(); + + var partial = Assert.IsType(result); + Assert.Equal("_DashboardLiveSection", partial.ViewName); + Assert.Equal(config.Dashboard.RefreshUiSeconds, model.RefreshUiSeconds); + } + [Fact] public void OnGet_CountsDisabledAndUnknownEndpoints() { @@ -156,6 +179,10 @@ private static DashboardConfig CreateConfig() { return new DashboardConfig { + Dashboard = new DashboardSettings + { + RefreshUiSeconds = 10 + }, Endpoints = [ new EndpointConfig diff --git a/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs new file mode 100644 index 0000000..6c67b2a --- /dev/null +++ b/tests/ApiHealthDashboard.Tests/Services/EndpointImportServiceTests.cs @@ -0,0 +1,213 @@ +using ApiHealthDashboard.Configuration; +using ApiHealthDashboard.Domain; +using ApiHealthDashboard.Parsing; +using ApiHealthDashboard.Services; +using Microsoft.Extensions.Logging.Abstractions; +using System.Net; + +namespace ApiHealthDashboard.Tests.Services; + +public sealed class EndpointImportServiceTests +{ + [Fact] + public async Task ImportAsync_WithSuccessfulProbe_GeneratesYamlAndDiffAgainstExistingEndpoint() + { + var config = new DashboardConfig + { + Endpoints = + [ + new EndpointConfig + { + Id = "orders-api", + Name = "Orders API", + Url = "https://orders.example.com/health", + Enabled = true, + FrequencySeconds = 60 + } + ] + }; + + var service = new EndpointImportService( + config, + new StubEndpointPoller(new PollResult + { + Kind = PollResultKind.Success, + DurationMs = 145, + ResponseBody = "{\"status\":\"Healthy\"}" + }), + new StubHealthResponseParser(new HealthSnapshot + { + OverallStatus = "Healthy", + Nodes = + [ + new HealthNode + { + Name = "database", + Status = "Healthy" + }, + new HealthNode + { + Name = "cache", + Status = "Healthy" + } + ] + }), + NullLogger.Instance); + + var result = await service.ImportAsync( + new EndpointImportRequest + { + Url = "https://orders.example.com/health", + FrequencySeconds = 30, + IncludeDiscoveredChecks = true + }, + CancellationToken.None); + + Assert.Equal("orders-api", result.SuggestedEndpoint.Id); + Assert.Equal("Orders API", result.SuggestedEndpoint.Name); + Assert.True(result.HasExistingMatch); + Assert.Contains("includeChecks:", result.GeneratedYaml, StringComparison.Ordinal); + Assert.Contains("'database'", result.GeneratedYaml, StringComparison.Ordinal); + Assert.Equal(["cache", "database"], result.TopLevelCheckNames); + Assert.Contains(result.DiffLines, static line => line.Prefix == "+"); + Assert.Contains("Matched existing endpoint 'orders-api'", result.MatchSummary, StringComparison.Ordinal); + } + + [Fact] + public async Task ImportAsync_WithInvalidHeaderFormat_ThrowsValidationException() + { + var service = new EndpointImportService( + new DashboardConfig(), + new StubEndpointPoller(new PollResult { Kind = PollResultKind.Success }), + new StubHealthResponseParser(new HealthSnapshot()), + NullLogger.Instance); + + var exception = await Assert.ThrowsAsync(() => + service.ImportAsync( + new EndpointImportRequest + { + Url = "https://orders.example.com/health", + HeadersText = "Authorization Bearer token" + }, + CancellationToken.None)); + + Assert.Contains("Header line 1 must use the format", exception.Errors[0], StringComparison.Ordinal); + } + + [Fact] + public async Task ImportAsync_WithoutExistingMatch_ReturnsTruncatedResponsePreview() + { + var longResponse = new string('x', 15000); + var service = new EndpointImportService( + new DashboardConfig(), + new StubEndpointPoller(new PollResult + { + Kind = PollResultKind.Success, + DurationMs = 50, + ResponseBody = longResponse + }), + new StubHealthResponseParser(new HealthSnapshot()), + NullLogger.Instance); + + var result = await service.ImportAsync( + new EndpointImportRequest + { + Url = "https://billing.example.com/health", + FrequencySeconds = 45 + }, + CancellationToken.None); + + Assert.False(result.HasExistingMatch); + Assert.True(result.ResponsePreviewWasTruncated); + Assert.Equal(12000, result.ResponsePreview.Length); + Assert.Empty(result.DiffLines); + } + + [Fact] + public async Task ImportAsync_WithJsonResponse_PrettyPrintsResponsePreview() + { + var service = new EndpointImportService( + new DashboardConfig(), + new StubEndpointPoller(new PollResult + { + Kind = PollResultKind.Success, + DurationMs = 25, + ResponseBody = "{\"status\":\"Healthy\",\"checks\":{\"db\":{\"status\":\"Healthy\"}}}" + }), + new StubHealthResponseParser(new HealthSnapshot + { + OverallStatus = "Healthy" + }), + NullLogger.Instance); + + var result = await service.ImportAsync( + new EndpointImportRequest + { + Url = "https://orders.example.com/health", + FrequencySeconds = 180 + }, + CancellationToken.None); + + Assert.Contains("\n", result.ResponsePreview.Replace("\r\n", "\n", StringComparison.Ordinal), StringComparison.Ordinal); + Assert.Contains("\"status\": \"Healthy\"", result.ResponsePreview, StringComparison.Ordinal); + } + + [Fact] + public async Task ImportAsync_WithHttpNotFound_DoesNotGenerateYamlPreview() + { + var service = new EndpointImportService( + new DashboardConfig(), + new StubEndpointPoller(new PollResult + { + Kind = PollResultKind.HttpError, + StatusCode = HttpStatusCode.NotFound, + ErrorMessage = "Endpoint returned HTTP 404 (NotFound)." + }), + new StubHealthResponseParser(new HealthSnapshot()), + NullLogger.Instance); + + var result = await service.ImportAsync( + new EndpointImportRequest + { + Url = "https://missing.example.com/health", + FrequencySeconds = 30 + }, + CancellationToken.None); + + Assert.Equal(PollResultKind.HttpError, result.ProbeResult.Kind); + Assert.False(result.HasGeneratedYamlPreview); + Assert.Null(result.GeneratedYaml); + Assert.Empty(result.DiffLines); + Assert.False(string.IsNullOrWhiteSpace(result.ProbeStatusText)); + } + + private sealed class StubEndpointPoller : IEndpointPoller + { + private readonly PollResult _pollResult; + + public StubEndpointPoller(PollResult pollResult) + { + _pollResult = pollResult; + } + + public Task PollAsync(EndpointConfig endpoint, CancellationToken cancellationToken) + { + return Task.FromResult(_pollResult); + } + } + + private sealed class StubHealthResponseParser : IHealthResponseParser + { + private readonly HealthSnapshot _snapshot; + + public StubHealthResponseParser(HealthSnapshot snapshot) + { + _snapshot = snapshot; + } + + public HealthSnapshot Parse(EndpointConfig endpoint, string json, long durationMs) + { + return _snapshot; + } + } +}