feat(ui): live dashboard, capture-status pill, bet/backtest UX
- Add IDashboardSummaryService/DashboardSummaryService: real event/snapshot/ anomaly counts, top-5 signals, and per-stage pipeline health from worker state. - Home: replace hard-coded zeros + placeholder feed with live data, a clickable signal feed, and a first-run empty state with a Settings CTA. - MainLayout: add an appbar capture-status pill (Capturing/Paused) bound to the poller toggles, refreshed via IOptionsMonitor.OnChange. - MyBets: success snackbar on bet submit. Backtest: surface a Cancel button while a run is in flight. - Add en/ru localization for all new strings; register IOptionsMonitor<WorkerOptions> in the bUnit test context for layout-rendering tests.
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
@inject ThemeState ThemeState
|
||||
@inject LocaleState LocaleState
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IOptionsMonitor<WorkerOptions> Workers
|
||||
|
||||
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
||||
<MudPopoverProvider />
|
||||
@@ -24,6 +25,12 @@
|
||||
<div class="m-appbar__spacer"></div>
|
||||
|
||||
<div class="m-appbar__tools m-rise m-rise-2">
|
||||
<span class="m-capture-pill" data-test="capture-pill"
|
||||
aria-label="@L["Scraping.Aria"]" title="@L["Scraping.Aria"]"
|
||||
style="display:inline-flex; align-items:center; gap:7px; font-family:var(--m-font-mono); font-size:0.6875rem; text-transform:uppercase; letter-spacing:0.12em; color:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");">
|
||||
<span style="width:8px; height:8px; border-radius:50%; background:@(Capturing ? "var(--m-c-positive)" : "var(--m-c-ink-soft)");"></span>
|
||||
@(Capturing ? L["Scraping.On"] : L["Scraping.Off"])
|
||||
</span>
|
||||
<LocaleSwitcher />
|
||||
<ThemeToggle />
|
||||
</div>
|
||||
@@ -123,11 +130,20 @@
|
||||
@code {
|
||||
private bool _drawerOpen = true;
|
||||
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
|
||||
private IDisposable? _workerOptionsListener;
|
||||
|
||||
// "Capturing" when any of the primary pollers is enabled in config.
|
||||
private bool Capturing =>
|
||||
Workers.CurrentValue.LivePollerEnabled
|
||||
|| Workers.CurrentValue.UpcomingPollerEnabled
|
||||
|| Workers.CurrentValue.AnomalyDetectionEnabled;
|
||||
|
||||
protected override void OnInitialized()
|
||||
{
|
||||
ThemeState.OnChange += StateHasChanged;
|
||||
LocaleState.OnChange += StateHasChanged;
|
||||
// Reflect Settings toggles live without requiring a navigation.
|
||||
_workerOptionsListener = Workers.OnChange(_ => InvokeAsync(StateHasChanged));
|
||||
}
|
||||
|
||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||
@@ -136,5 +152,6 @@
|
||||
{
|
||||
ThemeState.OnChange -= StateHasChanged;
|
||||
LocaleState.OnChange -= StateHasChanged;
|
||||
_workerOptionsListener?.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,15 @@
|
||||
<span class="m-backtest__submit-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
|
||||
<span>@(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"])</span>
|
||||
</button>
|
||||
@if (_running)
|
||||
{
|
||||
<button type="button"
|
||||
class="m-chip"
|
||||
@onclick="CancelRun"
|
||||
data-test="backtest-cancel">
|
||||
<span>@L["Backtest.Action.Cancel"]</span>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</article>
|
||||
</section>
|
||||
@@ -650,6 +659,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
private void CancelRun() => _runCts?.Cancel();
|
||||
|
||||
private void OnStakeRuleChanged(StakeRule next)
|
||||
{
|
||||
_form.StakeRule = next;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
@page "/"
|
||||
@inject IStringLocalizer<SharedResource> L
|
||||
@inject IDashboardSummaryService Dashboard
|
||||
@inject ILogger<Home> Logger
|
||||
|
||||
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
|
||||
|
||||
@@ -15,10 +17,13 @@
|
||||
<hr class="m-rule--double" />
|
||||
|
||||
<div class="m-grid--three m-rise m-rise-2">
|
||||
<StatCard Label="@L["Home.Stat.Events"]" Value="@_eventsTracked.ToString("N0")" Delta="+12%" />
|
||||
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
|
||||
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
|
||||
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
|
||||
<StatCard Label="@L["Home.Stat.Events"]" Value="@_summary.EventsTracked.ToString("N0")" />
|
||||
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_summary.SnapshotsToday.ToString("N0")" />
|
||||
<StatCard Label="@L["Home.Stat.Anomalies"]"
|
||||
Value="@_summary.AnomaliesTotal.ToString("N0")"
|
||||
Delta="@AnomaliesDelta"
|
||||
Anomaly="true" />
|
||||
<StatCard Label="@L["Home.Stat.Coverage"]" Value="@_summary.SportsCovered.ToString()" />
|
||||
</div>
|
||||
|
||||
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
||||
@@ -30,49 +35,112 @@
|
||||
@L["Anomaly.Kind.SuspensionFlip"]
|
||||
</h2>
|
||||
|
||||
<div style="display: grid; gap: var(--m-space-4);">
|
||||
@foreach (var item in _placeholderFeed)
|
||||
{
|
||||
<article style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
|
||||
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
||||
@item.Time
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 500;">@item.Match</div>
|
||||
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
|
||||
</div>
|
||||
<span class="m-anomaly">
|
||||
<span class="m-anomaly__pulse"></span>
|
||||
@($"{item.Score:0.00}")
|
||||
</span>
|
||||
</article>
|
||||
}
|
||||
</div>
|
||||
@if (!_summary.HasAnyData)
|
||||
{
|
||||
@* First-run: nothing captured yet. Make the next step unmissable. *@
|
||||
<div data-test="home-empty" style="display: grid; gap: var(--m-space-4); padding: var(--m-space-4) 0;">
|
||||
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-accent);">
|
||||
@L["Home.Empty.Heading"]
|
||||
</span>
|
||||
<p style="color: var(--m-c-ink-soft); max-width: 48ch; margin: 0;">
|
||||
@L["Home.Empty"]
|
||||
</p>
|
||||
<div>
|
||||
<a href="/settings" data-test="home-empty-cta"
|
||||
style="display: inline-flex; align-items: center; gap: 8px; padding: 10px 18px; border: 1px solid var(--m-c-accent); color: var(--m-c-accent); font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; text-decoration: none;">
|
||||
@L["Home.Empty.Cta"] →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
else if (_summary.LatestSignals.Count == 0)
|
||||
{
|
||||
@* Capturing, but the detector hasn't flagged anything yet. *@
|
||||
<div data-test="home-no-signals" style="display: grid; gap: var(--m-space-3); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule);">
|
||||
<p style="color: var(--m-c-ink-soft); margin: 0;">@L["Home.NoSignals"]</p>
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||
@L["Home.ViewAll"] →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
else
|
||||
{
|
||||
<div style="display: grid; gap: var(--m-space-4);">
|
||||
@foreach (var signal in _summary.LatestSignals)
|
||||
{
|
||||
<a href="@($"/anomalies/{signal.Id}")" data-test="home-signal"
|
||||
style="display: grid; grid-template-columns: 80px 1fr auto; gap: var(--m-space-4); padding: var(--m-space-3) 0; border-top: 1px solid var(--m-c-rule); text-decoration: none; color: inherit;">
|
||||
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
||||
@FormatSignalTime(signal.DetectedAt)
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-weight: 500;">@signal.EventTitle</div>
|
||||
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">
|
||||
@SportLabel(signal.Sport.Value) · @SeverityLabel(signal.Severity)
|
||||
</div>
|
||||
</div>
|
||||
<span class="m-anomaly">
|
||||
<span class="m-anomaly__pulse"></span>
|
||||
@signal.Score.ToString("0.00", CultureInfo.InvariantCulture)
|
||||
</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule); color: var(--m-c-ink-soft); font-size: 0.8125rem;">
|
||||
@L["Home.Empty"]
|
||||
</div>
|
||||
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule);">
|
||||
<a href="/anomalies" style="font-family: var(--m-font-mono); font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.12em; color: var(--m-c-accent); text-decoration: none;">
|
||||
@L["Home.ViewAll"] →
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
<aside class="m-card m-card--accented">
|
||||
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
|
||||
<ol style="list-style: none; padding: 0; margin: var(--m-space-4) 0 0; display: grid; gap: var(--m-space-3); counter-reset: m-step;">
|
||||
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="ok" />
|
||||
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
|
||||
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
|
||||
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
|
||||
<PipelineStep Index="01" Label="@L["Home.Pipeline.Step1"]" Status="@_summary.ScheduleStatus" />
|
||||
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="@_summary.SnapshotStatus" />
|
||||
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="@_summary.DetectorStatus" />
|
||||
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="@_summary.ExportStatus" />
|
||||
</ol>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@code {
|
||||
// Mock data — Phase 6+ will replace with live queries.
|
||||
private readonly int _eventsTracked = 0;
|
||||
private readonly int _snapshotsToday = 0;
|
||||
private readonly int _anomalies = 0;
|
||||
private DashboardSummary _summary = DashboardSummary.Empty;
|
||||
|
||||
private record FeedItem(string Time, string Match, string Detail, decimal Score);
|
||||
private string? AnomaliesDelta => _summary.AnomaliesToday > 0
|
||||
? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.NewToday"], _summary.AnomaliesToday)
|
||||
: null;
|
||||
|
||||
private readonly List<FeedItem> _placeholderFeed = new();
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
_summary = await Dashboard.GetAsync(CancellationToken.None);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Dashboard is read-only chrome; on failure keep the empty summary and log.
|
||||
Logger.LogError(ex, "Home: failed to load dashboard summary.");
|
||||
}
|
||||
}
|
||||
|
||||
private string FormatSignalTime(DateTimeOffset at)
|
||||
{
|
||||
var moscow = at.ToOffset(MoscowTime.Offset);
|
||||
return moscow.Date == MoscowTime.Now.Date
|
||||
? moscow.ToString("HH:mm", CultureInfo.InvariantCulture)
|
||||
: moscow.ToString("dd MMM", CultureInfo.InvariantCulture);
|
||||
}
|
||||
|
||||
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
||||
|
||||
private string SeverityLabel(AnomalySeverity severity) => severity switch
|
||||
{
|
||||
AnomalySeverity.High => L["Anomaly.Severity.High"],
|
||||
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
|
||||
_ => L["Anomaly.Severity.Low"],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -729,6 +729,7 @@
|
||||
await Service.AddAsync(_form, ct);
|
||||
_form = new AddBetForm();
|
||||
_formError = null;
|
||||
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
|
||||
await LoadAsync();
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
|
||||
@@ -79,6 +79,14 @@
|
||||
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
|
||||
<data name="Home.Pipeline.Step4"><value>XLSX export</value></data>
|
||||
<data name="Home.Empty"><value>No data yet. Enable the background pollers in Settings to start the feed.</value></data>
|
||||
<data name="Home.Empty.Heading"><value>Nothing captured yet</value></data>
|
||||
<data name="Home.Empty.Cta"><value>Open Settings</value></data>
|
||||
<data name="Home.NoSignals"><value>Capturing lines — no flips flagged yet.</value></data>
|
||||
<data name="Home.ViewAll"><value>View all signals</value></data>
|
||||
<data name="Home.Stat.NewToday"><value>{0} new today</value></data>
|
||||
<data name="Scraping.On"><value>Capturing</value></data>
|
||||
<data name="Scraping.Off"><value>Paused</value></data>
|
||||
<data name="Scraping.Aria"><value>Data capture status</value></data>
|
||||
|
||||
<data name="Settings.Kicker"><value>Configuration</value></data>
|
||||
<data name="Settings.Title"><value>Settings</value></data>
|
||||
@@ -407,6 +415,7 @@
|
||||
<data name="Journal.Empty.None"><value>No bets recorded yet. Use the form above to log a wager — once the event finishes the journal will auto-grade it and compute closing-line value against the latest pre-match snapshot.</value></data>
|
||||
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
||||
<data name="Journal.Error.Generic"><value>Failed to save bet — check the event ID and try again.</value></data>
|
||||
<data name="Journal.Submitted"><value>Bet recorded.</value></data>
|
||||
<data name="Journal.Resolve.None"><value>No pending bets needed grading.</value></data>
|
||||
<data name="Journal.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
|
||||
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</value></data>
|
||||
@@ -432,6 +441,7 @@
|
||||
<data name="Backtest.StakeRule.Kelly"><value>Kelly</value></data>
|
||||
<data name="Backtest.Action.Run"><value>Run simulation</value></data>
|
||||
<data name="Backtest.Action.Running"><value>Simulating…</value></data>
|
||||
<data name="Backtest.Action.Cancel"><value>Cancel</value></data>
|
||||
<data name="Backtest.Stat.FinalBankroll"><value>Final bankroll</value></data>
|
||||
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
|
||||
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
|
||||
|
||||
@@ -82,6 +82,14 @@
|
||||
<data name="Home.Pipeline.Step3"><value>Детектор разворота</value></data>
|
||||
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
|
||||
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</value></data>
|
||||
<data name="Home.Empty.Heading"><value>Пока ничего не собрано</value></data>
|
||||
<data name="Home.Empty.Cta"><value>Открыть настройки</value></data>
|
||||
<data name="Home.NoSignals"><value>Идёт сбор линий — разворотов пока нет.</value></data>
|
||||
<data name="Home.ViewAll"><value>Все сигналы</value></data>
|
||||
<data name="Home.Stat.NewToday"><value>{0} новых сегодня</value></data>
|
||||
<data name="Scraping.On"><value>Идёт сбор</value></data>
|
||||
<data name="Scraping.Off"><value>Пауза</value></data>
|
||||
<data name="Scraping.Aria"><value>Статус сбора данных</value></data>
|
||||
|
||||
<!-- Settings — sections -->
|
||||
<data name="Settings.Kicker"><value>Конфигурация</value></data>
|
||||
@@ -420,6 +428,7 @@
|
||||
<data name="Journal.Empty.None"><value>Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка.</value></data>
|
||||
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
||||
<data name="Journal.Error.Generic"><value>Не удалось сохранить ставку — проверьте ID события и повторите.</value></data>
|
||||
<data name="Journal.Submitted"><value>Ставка записана.</value></data>
|
||||
<data name="Journal.Resolve.None"><value>Ожидающих ставок к расчёту нет.</value></data>
|
||||
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
|
||||
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</value></data>
|
||||
@@ -445,6 +454,7 @@
|
||||
<data name="Backtest.StakeRule.Kelly"><value>Келли</value></data>
|
||||
<data name="Backtest.Action.Run"><value>Запустить</value></data>
|
||||
<data name="Backtest.Action.Running"><value>Симуляция…</value></data>
|
||||
<data name="Backtest.Action.Cancel"><value>Отмена</value></data>
|
||||
<data name="Backtest.Stat.FinalBankroll"><value>Итоговый банк</value></data>
|
||||
<data name="Backtest.Stat.NetProfit"><value>Чистая прибыль</value></data>
|
||||
<data name="Backtest.Stat.Roi"><value>ROI</value></data>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
using Marathon.Application.Abstractions;
|
||||
using Marathon.Domain.ValueObjects;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Repository-backed implementation of <see cref="IDashboardSummaryService"/>.
|
||||
/// Composes server-side counts (no full-table materialisation) with the top few
|
||||
/// anomaly signals and the worker-toggle state. Scoped — captures the per-circuit
|
||||
/// repository scope like the other browsing services.
|
||||
/// </summary>
|
||||
public sealed class DashboardSummaryService : IDashboardSummaryService
|
||||
{
|
||||
private const int LatestSignalCount = 5;
|
||||
|
||||
private readonly IEventRepository _events;
|
||||
private readonly ISnapshotRepository _snapshots;
|
||||
private readonly IAnomalyRepository _anomalies;
|
||||
private readonly IAnomalyBrowsingService _anomalyBrowsing;
|
||||
private readonly IOptionsMonitor<WorkerOptions> _workers;
|
||||
|
||||
public DashboardSummaryService(
|
||||
IEventRepository events,
|
||||
ISnapshotRepository snapshots,
|
||||
IAnomalyRepository anomalies,
|
||||
IAnomalyBrowsingService anomalyBrowsing,
|
||||
IOptionsMonitor<WorkerOptions> workers)
|
||||
{
|
||||
_events = events ?? throw new ArgumentNullException(nameof(events));
|
||||
_snapshots = snapshots ?? throw new ArgumentNullException(nameof(snapshots));
|
||||
_anomalies = anomalies ?? throw new ArgumentNullException(nameof(anomalies));
|
||||
_anomalyBrowsing = anomalyBrowsing ?? throw new ArgumentNullException(nameof(anomalyBrowsing));
|
||||
_workers = workers ?? throw new ArgumentNullException(nameof(workers));
|
||||
}
|
||||
|
||||
public async Task<DashboardSummary> GetAsync(CancellationToken ct)
|
||||
{
|
||||
var todayStart = new DateTimeOffset(MoscowTime.Now.Date, MoscowTime.Offset);
|
||||
|
||||
var eventsTracked = await _events.CountAsync(ct).ConfigureAwait(false);
|
||||
var snapshotsToday = await _snapshots.CountSinceAsync(todayStart, ct).ConfigureAwait(false);
|
||||
// DateTimeOffset.MinValue lower bound counts every row (year dominates the
|
||||
// lexical comparison regardless of offset).
|
||||
var anomaliesTotal = await _anomalies.CountSinceAsync(DateTimeOffset.MinValue, ct).ConfigureAwait(false);
|
||||
var anomaliesToday = await _anomalies.CountSinceAsync(todayStart, ct).ConfigureAwait(false);
|
||||
var sports = await _events.ListDistinctSportCodesAsync(ct).ConfigureAwait(false);
|
||||
|
||||
var latest = anomaliesTotal == 0
|
||||
? (IReadOnlyList<AnomalyListItem>)Array.Empty<AnomalyListItem>()
|
||||
: (await _anomalyBrowsing.ListAsync(new AnomalyFilter(), ct).ConfigureAwait(false))
|
||||
.Take(LatestSignalCount)
|
||||
.ToList();
|
||||
|
||||
var w = _workers.CurrentValue;
|
||||
|
||||
return new DashboardSummary(
|
||||
EventsTracked: eventsTracked,
|
||||
SnapshotsToday: snapshotsToday,
|
||||
AnomaliesTotal: anomaliesTotal,
|
||||
AnomaliesToday: anomaliesToday,
|
||||
SportsCovered: sports.Count,
|
||||
LatestSignals: latest,
|
||||
ScheduleStatus: StageStatus(w.UpcomingPollerEnabled, eventsTracked > 0),
|
||||
SnapshotStatus: StageStatus(w.LivePollerEnabled, snapshotsToday > 0),
|
||||
DetectorStatus: StageStatus(w.AnomalyDetectionEnabled, anomaliesTotal > 0),
|
||||
ExportStatus: eventsTracked > 0 ? "ok" : "idle");
|
||||
}
|
||||
|
||||
// Maps a worker stage to the PipelineStep token: disabled → idle, enabled but
|
||||
// not yet producing → warn (waiting), enabled and producing → ok.
|
||||
private static string StageStatus(bool enabled, bool producing) =>
|
||||
!enabled ? "idle" : producing ? "ok" : "warn";
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
namespace Marathon.UI.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Live figures for the dashboard (Home) page: real counts, the most recent
|
||||
/// anomaly signals, and a per-stage capture-pipeline health read derived from the
|
||||
/// worker toggles + whether each stage is actually producing data.
|
||||
/// </summary>
|
||||
public interface IDashboardSummaryService
|
||||
{
|
||||
Task<DashboardSummary> GetAsync(CancellationToken ct);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshot of dashboard figures. <see cref="HasAnyData"/> drives the first-run
|
||||
/// empty state. Pipeline statuses use the PipelineStep token vocabulary
|
||||
/// (<c>ok</c> / <c>warn</c> / <c>idle</c> / <c>error</c>).
|
||||
/// </summary>
|
||||
public sealed record DashboardSummary(
|
||||
int EventsTracked,
|
||||
int SnapshotsToday,
|
||||
int AnomaliesTotal,
|
||||
int AnomaliesToday,
|
||||
int SportsCovered,
|
||||
IReadOnlyList<AnomalyListItem> LatestSignals,
|
||||
string ScheduleStatus,
|
||||
string SnapshotStatus,
|
||||
string DetectorStatus,
|
||||
string ExportStatus)
|
||||
{
|
||||
/// <summary>True once anything has been captured — gates the welcome/empty state.</summary>
|
||||
public bool HasAnyData => EventsTracked > 0 || AnomaliesTotal > 0;
|
||||
|
||||
/// <summary>An empty summary used as the initial render state before data loads.</summary>
|
||||
public static DashboardSummary Empty { get; } = new(
|
||||
EventsTracked: 0,
|
||||
SnapshotsToday: 0,
|
||||
AnomaliesTotal: 0,
|
||||
AnomaliesToday: 0,
|
||||
SportsCovered: 0,
|
||||
LatestSignals: Array.Empty<AnomalyListItem>(),
|
||||
ScheduleStatus: "idle",
|
||||
SnapshotStatus: "idle",
|
||||
DetectorStatus: "idle",
|
||||
ExportStatus: "idle");
|
||||
}
|
||||
@@ -61,6 +61,7 @@ public static class UiServicesExtensions
|
||||
services.AddScoped<IResultsBrowsingService, ResultsBrowsingService>();
|
||||
services.AddScoped<IBetJournalService, BetJournalService>();
|
||||
services.AddScoped<IBacktestService, BacktestService>();
|
||||
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
|
||||
|
||||
// Settings writer — file path is host-resolved.
|
||||
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
||||
|
||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Localization;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
using Microsoft.Extensions.Options;
|
||||
using MudBlazor.Services;
|
||||
|
||||
namespace Marathon.UI.Tests.Support;
|
||||
@@ -37,6 +38,11 @@ public abstract class MarathonTestContext : TestContext
|
||||
Services.AddSingleton<IAnomalyBrowsingService>(AnomalyBrowsing);
|
||||
Services.AddSingleton<IResultsBrowsingService>(Results);
|
||||
|
||||
// WorkerOptions monitor backs the MainLayout capture-status pill (defaults
|
||||
// to all pollers enabled). Tests needing a specific state can re-register.
|
||||
Services.AddSingleton<IOptionsMonitor<WorkerOptions>>(
|
||||
new TestOptionsMonitor<WorkerOptions>(new WorkerOptions()));
|
||||
|
||||
Services.AddSingleton(typeof(IStringLocalizer<>), typeof(TestLocalizer<>));
|
||||
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||
Services.AddLogging();
|
||||
|
||||
Reference in New Issue
Block a user