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 ThemeState ThemeState
|
||||||
@inject LocaleState LocaleState
|
@inject LocaleState LocaleState
|
||||||
@inject IStringLocalizer<SharedResource> L
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IOptionsMonitor<WorkerOptions> Workers
|
||||||
|
|
||||||
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
<MudThemeProvider IsDarkMode="@ThemeState.IsDark" Theme="@_theme" />
|
||||||
<MudPopoverProvider />
|
<MudPopoverProvider />
|
||||||
@@ -24,6 +25,12 @@
|
|||||||
<div class="m-appbar__spacer"></div>
|
<div class="m-appbar__spacer"></div>
|
||||||
|
|
||||||
<div class="m-appbar__tools m-rise m-rise-2">
|
<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 />
|
<LocaleSwitcher />
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
@@ -123,11 +130,20 @@
|
|||||||
@code {
|
@code {
|
||||||
private bool _drawerOpen = true;
|
private bool _drawerOpen = true;
|
||||||
private MudBlazor.MudTheme _theme = Theme.MarathonTheme.Build();
|
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()
|
protected override void OnInitialized()
|
||||||
{
|
{
|
||||||
ThemeState.OnChange += StateHasChanged;
|
ThemeState.OnChange += StateHasChanged;
|
||||||
LocaleState.OnChange += StateHasChanged;
|
LocaleState.OnChange += StateHasChanged;
|
||||||
|
// Reflect Settings toggles live without requiring a navigation.
|
||||||
|
_workerOptionsListener = Workers.OnChange(_ => InvokeAsync(StateHasChanged));
|
||||||
}
|
}
|
||||||
|
|
||||||
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
private void ToggleDrawer() => _drawerOpen = !_drawerOpen;
|
||||||
@@ -136,5 +152,6 @@
|
|||||||
{
|
{
|
||||||
ThemeState.OnChange -= StateHasChanged;
|
ThemeState.OnChange -= StateHasChanged;
|
||||||
LocaleState.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 class="m-backtest__submit-glyph @(_running ? "is-spinning" : null)" aria-hidden="true">▶</span>
|
||||||
<span>@(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"])</span>
|
<span>@(_running ? L["Backtest.Action.Running"] : L["Backtest.Action.Run"])</span>
|
||||||
</button>
|
</button>
|
||||||
|
@if (_running)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="m-chip"
|
||||||
|
@onclick="CancelRun"
|
||||||
|
data-test="backtest-cancel">
|
||||||
|
<span>@L["Backtest.Action.Cancel"]</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</section>
|
</section>
|
||||||
@@ -650,6 +659,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void CancelRun() => _runCts?.Cancel();
|
||||||
|
|
||||||
private void OnStakeRuleChanged(StakeRule next)
|
private void OnStakeRuleChanged(StakeRule next)
|
||||||
{
|
{
|
||||||
_form.StakeRule = next;
|
_form.StakeRule = next;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
@page "/"
|
@page "/"
|
||||||
@inject IStringLocalizer<SharedResource> L
|
@inject IStringLocalizer<SharedResource> L
|
||||||
|
@inject IDashboardSummaryService Dashboard
|
||||||
|
@inject ILogger<Home> Logger
|
||||||
|
|
||||||
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
|
<PageTitle>@L["App.Title"] · @L["Nav.Dashboard"]</PageTitle>
|
||||||
|
|
||||||
@@ -15,10 +17,13 @@
|
|||||||
<hr class="m-rule--double" />
|
<hr class="m-rule--double" />
|
||||||
|
|
||||||
<div class="m-grid--three m-rise m-rise-2">
|
<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.Events"]" Value="@_summary.EventsTracked.ToString("N0")" />
|
||||||
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_snapshotsToday.ToString("N0")" Delta="+318" />
|
<StatCard Label="@L["Home.Stat.Snapshots"]" Value="@_summary.SnapshotsToday.ToString("N0")" />
|
||||||
<StatCard Label="@L["Home.Stat.Anomalies"]" Value="@_anomalies.ToString()" Delta="3 NEW" Anomaly="true" />
|
<StatCard Label="@L["Home.Stat.Anomalies"]"
|
||||||
<StatCard Label="@L["Home.Stat.Coverage"]" Value="4" Delta="BSK · FBL · TNS · HKY" />
|
Value="@_summary.AnomaliesTotal.ToString("N0")"
|
||||||
|
Delta="@AnomaliesDelta"
|
||||||
|
Anomaly="true" />
|
||||||
|
<StatCard Label="@L["Home.Stat.Coverage"]" Value="@_summary.SportsCovered.ToString()" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="m-grid--asym m-rise m-rise-3" style="margin-top: var(--m-space-6);">
|
<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"]
|
@L["Anomaly.Kind.SuspensionFlip"]
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<div style="display: grid; gap: var(--m-space-4);">
|
@if (!_summary.HasAnyData)
|
||||||
@foreach (var item in _placeholderFeed)
|
{
|
||||||
{
|
@* First-run: nothing captured yet. Make the next step unmissable. *@
|
||||||
<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 data-test="home-empty" style="display: grid; gap: var(--m-space-4); padding: var(--m-space-4) 0;">
|
||||||
<div class="m-mono" style="font-size: 0.75rem; color: var(--m-c-ink-soft); text-transform: uppercase; letter-spacing: 0.1em;">
|
<span class="m-mono" style="font-size: 0.6875rem; text-transform: uppercase; letter-spacing: 0.16em; color: var(--m-c-accent);">
|
||||||
@item.Time
|
@L["Home.Empty.Heading"]
|
||||||
</div>
|
</span>
|
||||||
<div>
|
<p style="color: var(--m-c-ink-soft); max-width: 48ch; margin: 0;">
|
||||||
<div style="font-weight: 500;">@item.Match</div>
|
@L["Home.Empty"]
|
||||||
<div style="color: var(--m-c-ink-soft); font-size: 0.8125rem;">@item.Detail</div>
|
</p>
|
||||||
</div>
|
<div>
|
||||||
<span class="m-anomaly">
|
<a href="/settings" data-test="home-empty-cta"
|
||||||
<span class="m-anomaly__pulse"></span>
|
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;">
|
||||||
@($"{item.Score:0.00}")
|
@L["Home.Empty.Cta"] →
|
||||||
</span>
|
</a>
|
||||||
</article>
|
</div>
|
||||||
}
|
</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;">
|
<div style="margin-top: var(--m-space-5); padding-top: var(--m-space-3); border-top: 1px solid var(--m-c-rule);">
|
||||||
@L["Home.Empty"]
|
<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;">
|
||||||
</div>
|
@L["Home.ViewAll"] →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<aside class="m-card m-card--accented">
|
<aside class="m-card m-card--accented">
|
||||||
<span class="m-kicker">@L["Home.Section.Pipeline"]</span>
|
<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;">
|
<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="01" Label="@L["Home.Pipeline.Step1"]" Status="@_summary.ScheduleStatus" />
|
||||||
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="ok" />
|
<PipelineStep Index="02" Label="@L["Home.Pipeline.Step2"]" Status="@_summary.SnapshotStatus" />
|
||||||
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="warn" />
|
<PipelineStep Index="03" Label="@L["Home.Pipeline.Step3"]" Status="@_summary.DetectorStatus" />
|
||||||
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="idle" />
|
<PipelineStep Index="04" Label="@L["Home.Pipeline.Step4"]" Status="@_summary.ExportStatus" />
|
||||||
</ol>
|
</ol>
|
||||||
</aside>
|
</aside>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
// Mock data — Phase 6+ will replace with live queries.
|
private DashboardSummary _summary = DashboardSummary.Empty;
|
||||||
private readonly int _eventsTracked = 0;
|
|
||||||
private readonly int _snapshotsToday = 0;
|
|
||||||
private readonly int _anomalies = 0;
|
|
||||||
|
|
||||||
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);
|
await Service.AddAsync(_form, ct);
|
||||||
_form = new AddBetForm();
|
_form = new AddBetForm();
|
||||||
_formError = null;
|
_formError = null;
|
||||||
|
Snackbar.Add(L["Journal.Submitted"].Value, Severity.Success);
|
||||||
await LoadAsync();
|
await LoadAsync();
|
||||||
}
|
}
|
||||||
catch (ArgumentException ex)
|
catch (ArgumentException ex)
|
||||||
|
|||||||
@@ -79,6 +79,14 @@
|
|||||||
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
|
<data name="Home.Pipeline.Step3"><value>Flip detector</value></data>
|
||||||
<data name="Home.Pipeline.Step4"><value>XLSX export</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"><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.Kicker"><value>Configuration</value></data>
|
||||||
<data name="Settings.Title"><value>Settings</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.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.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.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.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.Resolve.Done"><value>Graded {0} pending bet(s).</value></data>
|
||||||
<data name="Journal.Confirm.Delete"><value>Delete this bet permanently?</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.StakeRule.Kelly"><value>Kelly</value></data>
|
||||||
<data name="Backtest.Action.Run"><value>Run simulation</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.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.FinalBankroll"><value>Final bankroll</value></data>
|
||||||
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
|
<data name="Backtest.Stat.NetProfit"><value>Net profit</value></data>
|
||||||
<data name="Backtest.Stat.Roi"><value>ROI</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.Step3"><value>Детектор разворота</value></data>
|
||||||
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
|
<data name="Home.Pipeline.Step4"><value>Экспорт XLSX</value></data>
|
||||||
<data name="Home.Empty"><value>Пока пусто. Запустите фоновые сборщики на странице «Настройки», чтобы пошёл поток данных.</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 -->
|
<!-- Settings — sections -->
|
||||||
<data name="Settings.Kicker"><value>Конфигурация</value></data>
|
<data name="Settings.Kicker"><value>Конфигурация</value></data>
|
||||||
@@ -420,6 +428,7 @@
|
|||||||
<data name="Journal.Empty.None"><value>Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка.</value></data>
|
<data name="Journal.Empty.None"><value>Ставок пока нет. Запишите свою ставку через форму выше — после окончания матча журнал авто-проставит результат и посчитает CLV против последнего пре-матч снимка.</value></data>
|
||||||
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
<data name="Journal.Empty.NotApplicable"><value>—</value></data>
|
||||||
<data name="Journal.Error.Generic"><value>Не удалось сохранить ставку — проверьте ID события и повторите.</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.None"><value>Ожидающих ставок к расчёту нет.</value></data>
|
||||||
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
|
<data name="Journal.Resolve.Done"><value>Рассчитано ожидающих: {0}.</value></data>
|
||||||
<data name="Journal.Confirm.Delete"><value>Удалить эту ставку безвозвратно?</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.StakeRule.Kelly"><value>Келли</value></data>
|
||||||
<data name="Backtest.Action.Run"><value>Запустить</value></data>
|
<data name="Backtest.Action.Run"><value>Запустить</value></data>
|
||||||
<data name="Backtest.Action.Running"><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.FinalBankroll"><value>Итоговый банк</value></data>
|
||||||
<data name="Backtest.Stat.NetProfit"><value>Чистая прибыль</value></data>
|
<data name="Backtest.Stat.NetProfit"><value>Чистая прибыль</value></data>
|
||||||
<data name="Backtest.Stat.Roi"><value>ROI</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<IResultsBrowsingService, ResultsBrowsingService>();
|
||||||
services.AddScoped<IBetJournalService, BetJournalService>();
|
services.AddScoped<IBetJournalService, BetJournalService>();
|
||||||
services.AddScoped<IBacktestService, BacktestService>();
|
services.AddScoped<IBacktestService, BacktestService>();
|
||||||
|
services.AddScoped<IDashboardSummaryService, DashboardSummaryService>();
|
||||||
|
|
||||||
// Settings writer — file path is host-resolved.
|
// Settings writer — file path is host-resolved.
|
||||||
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
services.AddSingleton<ISettingsWriter>(_ => new JsonSettingsWriter(settingsLocalPath));
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.DependencyInjection;
|
|||||||
using Microsoft.Extensions.Localization;
|
using Microsoft.Extensions.Localization;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
using Microsoft.Extensions.Options;
|
||||||
using MudBlazor.Services;
|
using MudBlazor.Services;
|
||||||
|
|
||||||
namespace Marathon.UI.Tests.Support;
|
namespace Marathon.UI.Tests.Support;
|
||||||
@@ -37,6 +38,11 @@ public abstract class MarathonTestContext : TestContext
|
|||||||
Services.AddSingleton<IAnomalyBrowsingService>(AnomalyBrowsing);
|
Services.AddSingleton<IAnomalyBrowsingService>(AnomalyBrowsing);
|
||||||
Services.AddSingleton<IResultsBrowsingService>(Results);
|
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(IStringLocalizer<>), typeof(TestLocalizer<>));
|
||||||
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
Services.AddSingleton(typeof(ILogger<>), typeof(NullLogger<>));
|
||||||
Services.AddLogging();
|
Services.AddLogging();
|
||||||
|
|||||||
Reference in New Issue
Block a user