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
@@ -22,6 +22,12 @@ public interface ISnapshotRepository
/// </summary>
Task<int> CountSinceAsync(DateTimeOffset since, CancellationToken ct = default);
/// <summary>
/// The most recent snapshot capture time across all events, or <c>null</c> when the
/// store is empty. Backs the pipeline-health freshness indicator.
/// </summary>
Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default);
Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
@@ -27,6 +27,18 @@ internal sealed class SnapshotRepository : ISnapshotRepository
.CountAsync(ct);
}
public async Task<DateTimeOffset?> GetLatestCapturedAtAsync(CancellationToken ct = default)
{
// O-format TEXT sorts lexically == chronologically (see SqliteDateText), so the
// max CapturedAt is the most recent capture. ORDER BY + LIMIT 1 pushed to SQLite.
var latest = await _db.Snapshots.AsNoTracking()
.OrderByDescending(s => s.CapturedAt)
.Select(s => s.CapturedAt)
.FirstOrDefaultAsync(ct);
return latest is null ? null : SqliteDateText.Parse(latest);
}
public async Task<IReadOnlyList<OddsSnapshot>> ListByEventAsync(
EventId eventId,
DateTimeOffset from,
+4
View File
@@ -57,6 +57,10 @@
<MudIcon Icon="@Icons.Material.Outlined.FileDownload" Size="Size.Small" />
<span>@L["Nav.Export"]</span>
</NavLink>
<NavLink class="m-nav__link" href="health">
<MudIcon Icon="@Icons.Material.Outlined.MonitorHeart" Size="Size.Small" />
<span>@L["Nav.Health"]</span>
</NavLink>
<NavLink class="m-nav__link" href="settings">
<MudIcon Icon="@Icons.Material.Outlined.Tune" Size="Size.Small" />
<span>@L["Nav.Settings"]</span>
+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)",
};
}
@@ -65,6 +65,7 @@
<data name="Nav.Results"><value>Results</value></data>
<data name="Nav.Settings"><value>Settings</value></data>
<data name="Nav.Export"><value>Export</value></data>
<data name="Nav.Health"><value>Health</value></data>
<data name="Home.Kicker"><value>Briefing</value></data>
<data name="Home.Title"><value>Hunting odds-flip anomalies</value></data>
@@ -272,6 +273,23 @@
<data name="Export.Hub.Lede"><value>Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first.</value></data>
<data name="Export.Hub.Action"><value>Configure export</value></data>
<data name="Export.Hub.FilenameHint"><value>Saved as Marathon_&lt;from&gt;_to_&lt;to&gt;.xlsx in the configured export directory.</value></data>
<data name="Health.Kicker"><value>Operations</value></data>
<data name="Health.Title"><value>Pipeline health</value></data>
<data name="Health.Lede"><value>Capture freshness, recent volumes, and worker status at a glance.</value></data>
<data name="Health.LastCapture"><value>Last capture</value></data>
<data name="Health.LastCapture.Never"><value>no captures yet</value></data>
<data name="Health.MinutesAgo"><value>{0} min ago</value></data>
<data name="Health.Stat.Snapshots"><value>Snapshots (24h)</value></data>
<data name="Health.Stat.Anomalies"><value>Anomalies (24h)</value></data>
<data name="Health.Stat.Events"><value>Events tracked</value></data>
<data name="Health.Stat.Sports"><value>Sports covered</value></data>
<data name="Health.Total"><value>{0} total</value></data>
<data name="Health.Workers"><value>Workers</value></data>
<data name="Health.Worker.Schedule"><value>Schedule poller</value></data>
<data name="Health.Worker.Live"><value>Live poller</value></data>
<data name="Health.Worker.Detection"><value>Anomaly detection</value></data>
<data name="Health.Worker.Results"><value>Results poller</value></data>
<data name="Health.Empty"><value>No data captured yet — enable the pollers in Settings.</value></data>
<data name="Export.Error.MissingDates"><value>Pick a start and end date.</value></data>
<data name="Export.Error.InvalidRange"><value>End date must be on or after the start date.</value></data>
<data name="Export.Error.Failed"><value>Export failed.</value></data>
@@ -67,6 +67,7 @@
<data name="Nav.Results"><value>Результаты</value></data>
<data name="Nav.Settings"><value>Настройки</value></data>
<data name="Nav.Export"><value>Экспорт</value></data>
<data name="Nav.Health"><value>Состояние</value></data>
<!-- Home / Dashboard -->
<data name="Home.Kicker"><value>Сводка</value></data>
@@ -285,6 +286,23 @@
<data name="Export.Hub.Lede"><value>Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие.</value></data>
<data name="Export.Hub.Action"><value>Настроить экспорт</value></data>
<data name="Export.Hub.FilenameHint"><value>Сохраняется как Marathon_&lt;от&gt;_to_&lt;до&gt;.xlsx в указанной папке экспорта.</value></data>
<data name="Health.Kicker"><value>Операции</value></data>
<data name="Health.Title"><value>Состояние конвейера</value></data>
<data name="Health.Lede"><value>Свежесть сбора, недавние объёмы и статус воркеров на одном экране.</value></data>
<data name="Health.LastCapture"><value>Последний снимок</value></data>
<data name="Health.LastCapture.Never"><value>снимков пока нет</value></data>
<data name="Health.MinutesAgo"><value>{0} мин назад</value></data>
<data name="Health.Stat.Snapshots"><value>Снимков (24ч)</value></data>
<data name="Health.Stat.Anomalies"><value>Аномалий (24ч)</value></data>
<data name="Health.Stat.Events"><value>Событий в работе</value></data>
<data name="Health.Stat.Sports"><value>Видов спорта</value></data>
<data name="Health.Total"><value>всего {0}</value></data>
<data name="Health.Workers"><value>Воркеры</value></data>
<data name="Health.Worker.Schedule"><value>Сбор расписания</value></data>
<data name="Health.Worker.Live"><value>Сбор лайва</value></data>
<data name="Health.Worker.Detection"><value>Детектор аномалий</value></data>
<data name="Health.Worker.Results"><value>Сбор результатов</value></data>
<data name="Health.Empty"><value>Данные ещё не собраны — включите сборщики в «Настройках».</value></data>
<data name="Export.Error.MissingDates"><value>Выберите даты начала и конца.</value></data>
<data name="Export.Error.InvalidRange"><value>Дата конца должна быть не раньше даты начала.</value></data>
<data name="Export.Error.Failed"><value>Экспорт не удался.</value></data>
@@ -0,0 +1,37 @@
namespace Marathon.UI.Services;
/// <summary>
/// Read-only operational health of the capture/detection pipeline, for the ops page:
/// data freshness, recent vs total volumes, and which workers are enabled.
/// </summary>
public interface IPipelineHealthService
{
Task<PipelineHealth> GetAsync(CancellationToken ct);
}
/// <summary>Snapshot of pipeline health. <see cref="LastSnapshotAt"/> drives the freshness pill.</summary>
public sealed record PipelineHealth(
int EventsTracked,
int SportsCovered,
int SnapshotsLast24h,
int SnapshotsTotal,
int AnomaliesLast24h,
int AnomaliesTotal,
DateTimeOffset? LastSnapshotAt,
bool UpcomingPollerEnabled,
bool LivePollerEnabled,
bool ResultsPollerEnabled,
bool AnomalyDetectionEnabled)
{
/// <summary>True once anything has been captured — gates the empty state.</summary>
public bool HasData => SnapshotsTotal > 0 || EventsTracked > 0;
/// <summary>Empty snapshot used as the initial render state before data loads.</summary>
public static PipelineHealth Empty { get; } = new(
EventsTracked: 0, SportsCovered: 0,
SnapshotsLast24h: 0, SnapshotsTotal: 0,
AnomaliesLast24h: 0, AnomaliesTotal: 0,
LastSnapshotAt: null,
UpcomingPollerEnabled: false, LivePollerEnabled: false,
ResultsPollerEnabled: false, AnomalyDetectionEnabled: false);
}
@@ -0,0 +1,58 @@
using Marathon.Application.Abstractions;
using Marathon.Domain.ValueObjects;
using Microsoft.Extensions.Options;
namespace Marathon.UI.Services;
/// <summary>
/// Repository-backed implementation of <see cref="IPipelineHealthService"/>. Composes
/// server-side counts (no full-table materialisation) with the snapshot freshness and
/// the worker-toggle state. Scoped — captures the per-circuit repository scope.
/// </summary>
public sealed class PipelineHealthService : IPipelineHealthService
{
private readonly IEventRepository _events;
private readonly ISnapshotRepository _snapshots;
private readonly IAnomalyRepository _anomalies;
private readonly IOptionsMonitor<WorkerOptions> _workers;
public PipelineHealthService(
IEventRepository events,
ISnapshotRepository snapshots,
IAnomalyRepository anomalies,
IOptionsMonitor<WorkerOptions> workers)
{
_events = events ?? throw new ArgumentNullException(nameof(events));
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
_workers = workers ?? throw new ArgumentNullException(nameof(workers));
}
public async Task<PipelineHealth> GetAsync(CancellationToken ct)
{
var since24h = MoscowTime.Now.AddHours(-24);
var eventsTracked = await _events.CountAsync(ct).ConfigureAwait(false);
var sports = await _events.ListDistinctSportCodesAsync(ct).ConfigureAwait(false);
var snapshotsTotal = await _snapshots.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false);
var snapshots24h = await _snapshots.CountSinceAsync(since24h, ct).ConfigureAwait(false);
var anomaliesTotal = await _anomalies.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false);
var anomalies24h = await _anomalies.CountSinceAsync(since24h, ct).ConfigureAwait(false);
var lastSnapshotAt = await _snapshots.GetLatestCapturedAtAsync(ct).ConfigureAwait(false);
var w = _workers.CurrentValue;
return new PipelineHealth(
EventsTracked: eventsTracked,
SportsCovered: sports.Count,
SnapshotsLast24h: snapshots24h,
SnapshotsTotal: snapshotsTotal,
AnomaliesLast24h: anomalies24h,
AnomaliesTotal: anomaliesTotal,
LastSnapshotAt: lastSnapshotAt,
UpcomingPollerEnabled: w.UpcomingPollerEnabled,
LivePollerEnabled: w.LivePollerEnabled,
ResultsPollerEnabled: w.ResultsPollerEnabled,
AnomalyDetectionEnabled: w.AnomalyDetectionEnabled);
}
}
@@ -62,6 +62,7 @@ public static class UiServicesExtensions
services.AddScoped<IBetJournalService, BetJournalService>();
services.AddScoped<IBacktestService, BacktestService>();
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
services.AddScoped<IPipelineHealthService, PipelineHealthService>();
// Settings writer — file path is host-resolved.
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
@@ -366,6 +366,29 @@ public sealed class RoundTripTests : IDisposable
many.Keys.Select(k => k.Value).Should().BeEquivalentTo(new[] { "Q1", "Q3" });
}
[Fact]
public async Task Snapshot_GetLatestCapturedAt_ReturnsMostRecentOrNull()
{
// Empty store → null.
(await _snapshotRepo.GetLatestCapturedAtAsync()).Should().BeNull();
await _eventRepo.AddAsync(BuildEvent("L100"));
await _eventRepo.SaveChangesAsync();
var older = new DateTimeOffset(2026, 5, 1, 12, 0, 0, MoscowOffset);
var newer = new DateTimeOffset(2026, 5, 9, 18, 30, 0, MoscowOffset);
OddsSnapshot Snap(DateTimeOffset at) =>
new(new EventId("L100"), at, OddsSource.Live,
new List<Bet> { new(MatchScope.Instance, BetType.Win, Side.Side1, null, new OddsRate(1.90m)) });
await _snapshotRepo.AddAsync(Snap(older));
await _snapshotRepo.AddAsync(Snap(newer));
await _snapshotRepo.SaveChangesAsync();
_fixture.DbContext.ChangeTracker.Clear();
(await _snapshotRepo.GetLatestCapturedAtAsync()).Should().Be(newer);
}
// ── Helpers ─────────────────────────────────────────────────────────────
private static Event BuildEvent(string id, DateTimeOffset? scheduledAt = null) =>