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