From 5eb3dec24b447b58a923ceb1838ef484e6861866 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 29 May 2026 01:32:41 +0300 Subject: [PATCH] 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. --- .../Abstractions/ISnapshotRepository.cs | 6 ++ .../Repositories/SnapshotRepository.cs | 12 +++ src/Marathon.UI/Components/NavBody.razor | 4 + src/Marathon.UI/Pages/Health.razor | 93 +++++++++++++++++++ .../Resources/SharedResource.en.resx | 18 ++++ .../Resources/SharedResource.ru.resx | 18 ++++ .../Services/IPipelineHealthService.cs | 37 ++++++++ .../Services/PipelineHealthService.cs | 58 ++++++++++++ .../Services/UiServicesExtensions.cs | 1 + .../Persistence/RoundTripTests.cs | 23 +++++ 10 files changed, 270 insertions(+) create mode 100644 src/Marathon.UI/Pages/Health.razor create mode 100644 src/Marathon.UI/Services/IPipelineHealthService.cs create mode 100644 src/Marathon.UI/Services/PipelineHealthService.cs diff --git a/src/Marathon.Application/Abstractions/ISnapshotRepository.cs b/src/Marathon.Application/Abstractions/ISnapshotRepository.cs index a22bdbd..7855c01 100644 --- a/src/Marathon.Application/Abstractions/ISnapshotRepository.cs +++ b/src/Marathon.Application/Abstractions/ISnapshotRepository.cs @@ -22,6 +22,12 @@ public interface ISnapshotRepository /// Task CountSinceAsync(DateTimeOffset since, CancellationToken ct = default); + /// + /// The most recent snapshot capture time across all events, or null when the + /// store is empty. Backs the pipeline-health freshness indicator. + /// + Task GetLatestCapturedAtAsync(CancellationToken ct = default); + Task> ListByEventAsync( EventId eventId, DateTimeOffset from, diff --git a/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs b/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs index 17395c5..342508c 100644 --- a/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs +++ b/src/Marathon.Infrastructure/Persistence/Repositories/SnapshotRepository.cs @@ -27,6 +27,18 @@ internal sealed class SnapshotRepository : ISnapshotRepository .CountAsync(ct); } + public async Task 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> ListByEventAsync( EventId eventId, DateTimeOffset from, diff --git a/src/Marathon.UI/Components/NavBody.razor b/src/Marathon.UI/Components/NavBody.razor index afe1e97..4c39222 100644 --- a/src/Marathon.UI/Components/NavBody.razor +++ b/src/Marathon.UI/Components/NavBody.razor @@ -57,6 +57,10 @@ @L["Nav.Export"] + + + @L["Nav.Health"] + @L["Nav.Settings"] diff --git a/src/Marathon.UI/Pages/Health.razor b/src/Marathon.UI/Pages/Health.razor new file mode 100644 index 0000000..cbc7c29 --- /dev/null +++ b/src/Marathon.UI/Pages/Health.razor @@ -0,0 +1,93 @@ +@page "/health" +@inject IStringLocalizer L +@inject IPipelineHealthService HealthService +@inject ILogger Logger + +@L["App.Title"] · @L["Nav.Health"] + +
+
+ @L["Health.Kicker"] +

@L["Health.Title"]

+

@L["Health.Lede"]

+
+ +
+ +
+ + @L["Health.LastCapture"]: + @FreshnessText +
+ +
+ + + + +
+ + + + @if (!_health.HasData) + { +

+ @L["Health.Empty"] +

+ } +
+ +@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)", + }; +} diff --git a/src/Marathon.UI/Resources/SharedResource.en.resx b/src/Marathon.UI/Resources/SharedResource.en.resx index 7e335f4..84c7255 100644 --- a/src/Marathon.UI/Resources/SharedResource.en.resx +++ b/src/Marathon.UI/Resources/SharedResource.en.resx @@ -65,6 +65,7 @@ Results Settings Export + Health Briefing Hunting odds-flip anomalies @@ -272,6 +273,23 @@ Export captured odds snapshots to an Excel workbook for any date range — no need to open a specific event first. Configure export Saved as Marathon_<from>_to_<to>.xlsx in the configured export directory. + Operations + Pipeline health + Capture freshness, recent volumes, and worker status at a glance. + Last capture + no captures yet + {0} min ago + Snapshots (24h) + Anomalies (24h) + Events tracked + Sports covered + {0} total + Workers + Schedule poller + Live poller + Anomaly detection + Results poller + No data captured yet — enable the pollers in Settings. Pick a start and end date. End date must be on or after the start date. Export failed. diff --git a/src/Marathon.UI/Resources/SharedResource.ru.resx b/src/Marathon.UI/Resources/SharedResource.ru.resx index d49dfb0..10ee367 100644 --- a/src/Marathon.UI/Resources/SharedResource.ru.resx +++ b/src/Marathon.UI/Resources/SharedResource.ru.resx @@ -67,6 +67,7 @@ Результаты Настройки Экспорт + Состояние Сводка @@ -285,6 +286,23 @@ Экспорт собранных снимков коэффициентов в книгу Excel за любой диапазон дат — без необходимости открывать конкретное событие. Настроить экспорт Сохраняется как Marathon_<от>_to_<до>.xlsx в указанной папке экспорта. + Операции + Состояние конвейера + Свежесть сбора, недавние объёмы и статус воркеров на одном экране. + Последний снимок + снимков пока нет + {0} мин назад + Снимков (24ч) + Аномалий (24ч) + Событий в работе + Видов спорта + всего {0} + Воркеры + Сбор расписания + Сбор лайва + Детектор аномалий + Сбор результатов + Данные ещё не собраны — включите сборщики в «Настройках». Выберите даты начала и конца. Дата конца должна быть не раньше даты начала. Экспорт не удался. diff --git a/src/Marathon.UI/Services/IPipelineHealthService.cs b/src/Marathon.UI/Services/IPipelineHealthService.cs new file mode 100644 index 0000000..6de5aa4 --- /dev/null +++ b/src/Marathon.UI/Services/IPipelineHealthService.cs @@ -0,0 +1,37 @@ +namespace Marathon.UI.Services; + +/// +/// Read-only operational health of the capture/detection pipeline, for the ops page: +/// data freshness, recent vs total volumes, and which workers are enabled. +/// +public interface IPipelineHealthService +{ + Task GetAsync(CancellationToken ct); +} + +/// Snapshot of pipeline health. drives the freshness pill. +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) +{ + /// True once anything has been captured — gates the empty state. + public bool HasData => SnapshotsTotal > 0 || EventsTracked > 0; + + /// Empty snapshot used as the initial render state before data loads. + 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); +} diff --git a/src/Marathon.UI/Services/PipelineHealthService.cs b/src/Marathon.UI/Services/PipelineHealthService.cs new file mode 100644 index 0000000..20d116f --- /dev/null +++ b/src/Marathon.UI/Services/PipelineHealthService.cs @@ -0,0 +1,58 @@ +using Marathon.Application.Abstractions; +using Marathon.Domain.ValueObjects; +using Microsoft.Extensions.Options; + +namespace Marathon.UI.Services; + +/// +/// Repository-backed implementation of . Composes +/// server-side counts (no full-table materialisation) with the snapshot freshness and +/// the worker-toggle state. Scoped — captures the per-circuit repository scope. +/// +public sealed class PipelineHealthService : IPipelineHealthService +{ + private readonly IEventRepository _events; + private readonly ISnapshotRepository _snapshots; + private readonly IAnomalyRepository _anomalies; + private readonly IOptionsMonitor _workers; + + public PipelineHealthService( + IEventRepository events, + ISnapshotRepository snapshots, + IAnomalyRepository anomalies, + IOptionsMonitor 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 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); + } +} diff --git a/src/Marathon.UI/Services/UiServicesExtensions.cs b/src/Marathon.UI/Services/UiServicesExtensions.cs index 9502005..7293a6b 100644 --- a/src/Marathon.UI/Services/UiServicesExtensions.cs +++ b/src/Marathon.UI/Services/UiServicesExtensions.cs @@ -62,6 +62,7 @@ public static class UiServicesExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Settings writer — file path is host-resolved. services.AddSingleton(_ => new JsonSettingsWriter(settingsLocalPath)); diff --git a/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs b/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs index bf2d349..44cbd38 100644 --- a/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs +++ b/tests/Marathon.Infrastructure.Tests/Persistence/RoundTripTests.cs @@ -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 { 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) =>