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:
@@ -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)",
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user