feat(ops): pipeline-health dashboard

- Add a /health ops page: snapshot freshness (last-capture-at, colour-coded), 24h vs
  total snapshots + anomalies, events tracked, sports covered, and the four worker
  on/off states. Nav entry under System; localized en+ru.
- New IPipelineHealthService + ISnapshotRepository.GetLatestCapturedAtAsync (max
  CapturedAt via indexed ORDER BY/LIMIT 1), with a real-SQLite round-trip test.
This commit is contained in:
2026-05-29 01:32:41 +03:00
parent b67030ae7f
commit 5eb3dec24b
10 changed files with 270 additions and 0 deletions
+93
View File
@@ -0,0 +1,93 @@
@page "/health"
@inject IStringLocalizer<SharedResource> L
@inject IPipelineHealthService HealthService
@inject ILogger<Health> Logger
<PageTitle>@L["App.Title"] · @L["Nav.Health"]</PageTitle>
<section class="m-shell">
<header class="m-rise m-rise-1" style="display: grid; gap: var(--m-space-3); max-width: 880px;">
<span class="m-kicker">@L["Health.Kicker"]</span>
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Health.Title"]</h1>
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Health.Lede"]</p>
</header>
<hr class="m-rule--double" />
<div class="m-rise m-rise-2" data-test="health-freshness"
style="display: inline-flex; align-items: center; gap: 10px; font-family: var(--m-font-mono); font-size: 0.8125rem; text-transform: uppercase; letter-spacing: 0.12em;">
<span style="width: 10px; height: 10px; border-radius: 50%; background: @FreshnessColor;"></span>
<span style="color: var(--m-c-ink-soft);">@L["Health.LastCapture"]:</span>
<span style="color: @FreshnessColor;">@FreshnessText</span>
</div>
<div class="m-grid--three m-rise m-rise-2" style="margin-top: var(--m-space-5);">
<StatCard Label="@L["Health.Stat.Snapshots"]"
Value="@_health.SnapshotsLast24h.ToString("N0")"
Delta="@string.Format(CultureInfo.CurrentCulture, L["Health.Total"].Value, _health.SnapshotsTotal)" />
<StatCard Label="@L["Health.Stat.Anomalies"]"
Value="@_health.AnomaliesLast24h.ToString("N0")"
Delta="@string.Format(CultureInfo.CurrentCulture, L["Health.Total"].Value, _health.AnomaliesTotal)"
Anomaly="true" />
<StatCard Label="@L["Health.Stat.Events"]" Value="@_health.EventsTracked.ToString("N0")" />
<StatCard Label="@L["Health.Stat.Sports"]" Value="@_health.SportsCovered.ToString()" />
</div>
<aside class="m-card m-card--accented m-rise m-rise-3" style="margin-top: var(--m-space-6);">
<span class="m-kicker">@L["Health.Workers"]</span>
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3);">
<PipelineStep Index="01" Label="@L["Health.Worker.Schedule"]" Status="@WorkerStatus(_health.UpcomingPollerEnabled)" />
<PipelineStep Index="02" Label="@L["Health.Worker.Live"]" Status="@WorkerStatus(_health.LivePollerEnabled)" />
<PipelineStep Index="03" Label="@L["Health.Worker.Detection"]" Status="@WorkerStatus(_health.AnomalyDetectionEnabled)" />
<PipelineStep Index="04" Label="@L["Health.Worker.Results"]" Status="@WorkerStatus(_health.ResultsPollerEnabled)" />
</ol>
</aside>
@if (!_health.HasData)
{
<p class="m-rise m-rise-3" data-test="health-empty"
style="margin-top: var(--m-space-5); color: var(--m-c-ink-soft);">
@L["Health.Empty"]
</p>
}
</section>
@code {
private PipelineHealth _health = PipelineHealth.Empty;
protected override async Task OnInitializedAsync()
{
try
{
_health = await HealthService.GetAsync(CancellationToken.None);
}
catch (Exception ex)
{
Logger.LogError(ex, "Health: failed to load pipeline health.");
}
}
private static string WorkerStatus(bool enabled) => enabled ? "ok" : "idle";
private double? MinutesSinceLastCapture =>
_health.LastSnapshotAt is { } at ? (MoscowTime.Now - at).TotalMinutes : null;
private string FreshnessText
{
get
{
if (MinutesSinceLastCapture is not { } mins)
return L["Health.LastCapture.Never"];
return string.Format(CultureInfo.CurrentCulture, L["Health.MinutesAgo"].Value, (int)Math.Max(0, mins));
}
}
// Green when fresh (<15 min), amber when slowing (<60 min), signal-red when stale.
private string FreshnessColor => MinutesSinceLastCapture switch
{
null => "var(--m-c-ink-soft)",
< 15 => "var(--m-c-positive)",
< 60 => "var(--m-c-accent)",
_ => "var(--m-c-anomaly)",
};
}