250a93e718
- 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.
147 lines
7.4 KiB
Plaintext
147 lines
7.4 KiB
Plaintext
@page "/"
|
|
@inject IStringLocalizer<SharedResource> L
|
|
@inject IDashboardSummaryService Dashboard
|
|
@inject ILogger<Home> Logger
|
|
|
|
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</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["Home.Kicker"]</span>
|
|
<h1 class="m-display" style="font-size: clamp(2.5rem, 5vw, 4rem);">@L["Home.Title"]</h1>
|
|
<p style="font-size: 1.0625rem; line-height: 1.5; color: var(--m-c-ink-soft); max-width: 60ch;">
|
|
@L["Home.Lede"]
|
|
</p>
|
|
</header>
|
|
|
|
<hr class="m-rule--double" />
|
|
|
|
<div class="m-grid--three m-rise m-rise-2">
|
|
<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);">
|
|
<div class="m-card">
|
|
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
|
@L["Home.Section.Latest"]
|
|
</span>
|
|
<h2 style="font-family: var(--m-font-display); font-weight: 400; font-size: 1.625rem; margin: var(--m-space-3) 0 var(--m-space-5);">
|
|
@L["Anomaly.Kind.SuspensionFlip"]
|
|
</h2>
|
|
|
|
@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);">
|
|
<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="@_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 {
|
|
private DashboardSummary _summary = DashboardSummary.Empty;
|
|
|
|
private string? AnomaliesDelta => _summary.AnomaliesToday > 0
|
|
? string.Format(CultureInfo.CurrentCulture, L["Home.Stat.NewToday"], _summary.AnomaliesToday)
|
|
: null;
|
|
|
|
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"],
|
|
};
|
|
}
|