12208a4762
Frontend portion of Phase 7. Backend (commit a6ff368) had already shipped
the AnomalyDetector, DetectAnomaliesUseCase, AnomalyDetectionPoller, and
all DI wiring. This commit adds the user-facing surfaces.
New surfaces (Option A routing — folder-per-feature):
- Pages/Anomalies/AnomalyFeed.razor (@page /anomalies) — replaces the
Phase 5 placeholder with a severity-coded card stream, filter chips
(severity / sport / date), unread-count summary, 'Mark all read' action.
- Pages/Anomalies/Detail.razor (@page /anomalies/{id:guid}) — m-detail-header
lockup + AnomalyEvidence panel + back link to /events/{eventCode}.
New components:
- AnomalyCard.razor — severity-tinted left border (signal-red on High,
amber on Medium, neutral on Low) + SeverityBadge pill + sport icon +
pre→post tabular-mono rate strip + relative time. Click navigates.
- SeverityBadge.razor — small pill mapping score → bucket per backend
handoff (Low <0.45, Medium <0.60, High ≥0.60).
- AnomalyEvidence.razor — two-column pre/post panel with implied-prob
bars + raw rates; favourite-swap callout when argmax(p_pre) ≠ argmax(p_post);
signal-red 3px left border on the post column. Handles 2-way (no draw).
State + service split mirrors Phase 6's pattern:
- AnomalyViewModels.cs — AnomalyListItem / AnomalyDetailVm / Severity enum
/ AnomalyEvidenceSnapshot record. Severity computed in the view-model
from Score.
- IAnomalyBrowsingService / AnomalyBrowsingService — wraps IAnomalyRepository,
parses Anomaly.EvidenceJson into typed view-models, applies filters
client-side. Methods: ListAsync(filter, ct), GetByIdAsync(id, ct),
GetUnreadCountAsync(since, ct).
- AnomalyBrowsingState — Singleton holding AnomalyFilter (severity threshold,
sport set, date range) + LastSeenUtc + cached UnreadCount. OnChange event.
Nav badge:
- NavBody.razor subscribes to AnomalyBrowsingState.OnChange, renders a
pulsing red m-nav__badge when UnreadCount > 0. Badge resets when the
user clicks 'Mark all read' on the feed toolbar.
Settings toggle:
- Settings.razor — added Workers:AnomalyDetectionEnabled toggle (backend
added the flag). Localized via Settings.Worker.AnomalyDetectionEnabled.
- Marathon.UI.Services.WorkerOptions mirror — added AnomalyDetectionEnabled
(default true).
Localization: +30 RU/EN keys following the dot-segmented convention
(Anomaly.*, Settings.Worker.AnomalyDetectionEnabled). Full key parity verified.
Tests (+31 bUnit, all passing):
- AnomalyFeedTests, AnomalyDetailTests
- AnomalyCardTests, SeverityBadgeTests, AnomalyEvidenceTests
- FakeAnomalyBrowsingService support fake registered in MarathonTestContext.
Routing: deleted the Phase 5 Pages/Anomalies.razor placeholder; new feed
page lives at Pages/Anomalies/AnomalyFeed.razor.
Build: 0 warnings, 0 errors.
Tests: Domain 109 + Application 19 + Infrastructure 80 + UI 68 = 276/276
(baseline 245, +31 new bUnit tests, no regressions).
Phase 7 status: ✅ Done (backend + frontend both complete, awaiting review).
Known deferral: AnomalyBrowsingState.LastSeenUtc is in-memory only; the
unread-count badge resets on app restart. Acceptable for now; Phase 9 may
extend ISettingsWriter or add an ILastSeenStore.
289 lines
14 KiB
Plaintext
289 lines
14 KiB
Plaintext
@page "/settings"
|
|
@using Marathon.Application.Storage
|
|
@using LocalizationOptions = Marathon.UI.Services.LocalizationOptions
|
|
@inject IStringLocalizer<SharedResource> L
|
|
@inject IOptionsMonitor<ScrapingSettingsForm> ScrapingOpts
|
|
@inject IOptionsMonitor<WorkerOptions> WorkerOpts
|
|
@inject IOptionsMonitor<StorageOptions> StorageOpts
|
|
@inject IOptionsMonitor<AnomalyOptions> AnomalyOpts
|
|
@inject IOptionsMonitor<Marathon.UI.Services.LocalizationOptions> LocaleOpts
|
|
@inject ISettingsWriter Writer
|
|
@inject IDialogService Dialogs
|
|
@inject ISnackbar Snackbar
|
|
@inject ILogger<Settings> Logger
|
|
|
|
<PageTitle>@L["App.Title"] · @L["Settings.Title"]</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["Settings.Kicker"]</span>
|
|
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Settings.Title"]</h1>
|
|
<p style="color: var(--m-c-ink-soft); max-width: 70ch;">@L["Settings.Lede"]</p>
|
|
</header>
|
|
|
|
<hr class="m-rule--double" />
|
|
|
|
@* SCRAPING *@
|
|
<article class="m-section m-rise m-rise-2">
|
|
<header class="m-section__head">
|
|
<h2>@L["Settings.Section.Scraping"]</h2>
|
|
<MudButton Variant="Variant.Text"
|
|
Size="Size.Small"
|
|
OnClick="@(() => ResetSectionAsync(ScrapingSettingsForm.SectionName))">
|
|
@L["Settings.Action.Reset"]
|
|
</MudButton>
|
|
</header>
|
|
<div class="m-section__body">
|
|
<Field Label="@L["Settings.Scraping.PollingIntervalSeconds"]" Hint="@L["Settings.Scraping.PollingIntervalSeconds.Hint"]">
|
|
<MudNumericField T="int" @bind-Value="_scraping.PollingIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.MaxConcurrentRequests"]" Hint="@L["Settings.Scraping.MaxConcurrentRequests.Hint"]">
|
|
<MudNumericField T="int" @bind-Value="_scraping.MaxConcurrentRequests" Min="1" Max="16" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.RateLimitRps"]" Hint="@L["Settings.Scraping.RateLimitRps.Hint"]">
|
|
<MudNumericField T="int" @bind-Value="_scraping.RateLimit.RequestsPerSecond" Min="1" Max="20" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.RetryMaxAttempts"]">
|
|
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.MaxAttempts" Min="0" Max="10" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.RetryBaseDelayMs"]">
|
|
<MudNumericField T="int" @bind-Value="_scraping.RetryPolicy.BaseDelayMs" Min="100" Max="60000" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.BaseUrl"]">
|
|
<MudTextField T="string" @bind-Value="_scraping.BaseUrl" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.RequestTimeoutSeconds"]">
|
|
<MudNumericField T="int" @bind-Value="_scraping.RequestTimeoutSeconds" Min="5" Max="600" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.UserAgents"]" Hint="@L["Settings.Scraping.UserAgents.Hint"]">
|
|
<MudTextField T="string"
|
|
Value="@_userAgentsRaw"
|
|
ValueChanged="@OnUserAgentsChanged"
|
|
Lines="4"
|
|
Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Scraping.UsePlaywright"]">
|
|
<MudSwitch T="bool" @bind-Value="_scraping.UsePlaywright" Color="Color.Primary" />
|
|
</Field>
|
|
|
|
<SectionFooter OnSave="@(() => SaveSectionAsync(ScrapingSettingsForm.SectionName, _scraping))" />
|
|
</div>
|
|
</article>
|
|
|
|
@* WORKERS *@
|
|
<article class="m-section m-rise m-rise-3">
|
|
<header class="m-section__head">
|
|
<h2>@L["Settings.Section.Workers"]</h2>
|
|
<MudButton Variant="Variant.Text" Size="Size.Small"
|
|
OnClick="@(() => ResetSectionAsync(WorkerOptions.SectionName))">
|
|
@L["Settings.Action.Reset"]
|
|
</MudButton>
|
|
</header>
|
|
<div class="m-section__body">
|
|
<Field Label="@L["Settings.Workers.UpcomingScheduleCron"]" Hint="@L["Settings.Workers.UpcomingScheduleCron.Hint"]">
|
|
<MudTextField T="string" @bind-Value="_workers.UpcomingScheduleCron" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Workers.UpcomingPollerEnabled"]">
|
|
<MudSwitch T="bool" @bind-Value="_workers.UpcomingPollerEnabled" Color="Color.Primary" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Workers.LivePollerEnabled"]">
|
|
<MudSwitch T="bool" @bind-Value="_workers.LivePollerEnabled" Color="Color.Primary" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Workers.LivePollIntervalSeconds"]" Hint="@L["Settings.Workers.LivePollIntervalSeconds.Hint"]">
|
|
<MudNumericField T="int" @bind-Value="_workers.LivePollIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Workers.ResultsPollerEnabled"]" Hint="@L["Settings.Workers.ResultsPollerEnabled.Hint"]">
|
|
<MudSwitch T="bool" @bind-Value="_workers.ResultsPollerEnabled" Color="Color.Primary" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Workers.ResultsPollIntervalSeconds"]">
|
|
<MudNumericField T="int" @bind-Value="_workers.ResultsPollIntervalSeconds" Min="60" Max="7200" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Workers.AnomalyDetectionEnabled"]" Hint="@L["Settings.Workers.AnomalyDetectionEnabled.Hint"]">
|
|
<MudSwitch T="bool" @bind-Value="_workers.AnomalyDetectionEnabled" Color="Color.Primary" />
|
|
</Field>
|
|
|
|
<SectionFooter OnSave="@(() => SaveSectionAsync(WorkerOptions.SectionName, _workers))" />
|
|
</div>
|
|
</article>
|
|
|
|
@* STORAGE *@
|
|
<article class="m-section m-rise m-rise-4">
|
|
<header class="m-section__head">
|
|
<h2>@L["Settings.Section.Storage"]</h2>
|
|
<MudButton Variant="Variant.Text" Size="Size.Small"
|
|
OnClick="@(() => ResetSectionAsync(StorageOptions.SectionName))">
|
|
@L["Settings.Action.Reset"]
|
|
</MudButton>
|
|
</header>
|
|
<div class="m-section__body">
|
|
<Field Label="@L["Settings.Storage.DatabasePath"]">
|
|
<MudTextField T="string" @bind-Value="_storage.DatabasePath" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Storage.ExportDirectory"]">
|
|
<MudTextField T="string" @bind-Value="_storage.ExportDirectory" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Storage.SnapshotRetentionDays"]">
|
|
<MudNumericField T="int" @bind-Value="_storage.SnapshotRetentionDays" Min="1" Max="3650" Variant="Variant.Outlined" />
|
|
</Field>
|
|
|
|
<SectionFooter OnSave="@(() => SaveSectionAsync(StorageOptions.SectionName, _storage))" />
|
|
</div>
|
|
</article>
|
|
|
|
@* ANOMALY *@
|
|
<article class="m-section m-rise m-rise-5">
|
|
<header class="m-section__head">
|
|
<h2>@L["Settings.Section.Anomaly"]</h2>
|
|
<MudButton Variant="Variant.Text" Size="Size.Small"
|
|
OnClick="@(() => ResetSectionAsync(AnomalyOptions.SectionName))">
|
|
@L["Settings.Action.Reset"]
|
|
</MudButton>
|
|
</header>
|
|
<div class="m-section__body">
|
|
<Field Label="@L["Settings.Anomaly.SuspensionGapSeconds"]">
|
|
<MudNumericField T="int" @bind-Value="_anomaly.SuspensionGapSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Anomaly.OddsFlipThreshold"]">
|
|
<MudNumericField T="decimal" @bind-Value="_anomaly.OddsFlipThreshold" Min="0.01m" Max="1m" Step="0.01m" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Anomaly.MinSnapshotCount"]">
|
|
<MudNumericField T="int" @bind-Value="_anomaly.MinSnapshotCount" Min="2" Max="100" Variant="Variant.Outlined" />
|
|
</Field>
|
|
<Field Label="@L["Settings.Anomaly.DetectionIntervalSeconds"]">
|
|
<MudNumericField T="int" @bind-Value="_anomaly.DetectionIntervalSeconds" Min="5" Max="3600" Variant="Variant.Outlined" />
|
|
</Field>
|
|
|
|
<SectionFooter OnSave="@(() => SaveSectionAsync(AnomalyOptions.SectionName, _anomaly))" />
|
|
</div>
|
|
</article>
|
|
|
|
@* LOCALIZATION *@
|
|
<article class="m-section m-rise m-rise-5">
|
|
<header class="m-section__head">
|
|
<h2>@L["Settings.Section.Localization"]</h2>
|
|
<MudButton Variant="Variant.Text" Size="Size.Small"
|
|
OnClick="@(() => ResetSectionAsync(LocalizationOptions.SectionName))">
|
|
@L["Settings.Action.Reset"]
|
|
</MudButton>
|
|
</header>
|
|
<div class="m-section__body">
|
|
<Field Label="@L["Settings.Localization.DefaultCulture"]">
|
|
<MudSelect T="string" @bind-Value="_locale.DefaultCulture" Variant="Variant.Outlined">
|
|
<MudSelectItem T="string" Value="@LocaleState.Russian">@L["Locale.Russian"] · ru-RU</MudSelectItem>
|
|
<MudSelectItem T="string" Value="@LocaleState.English">@L["Locale.English"] · en-US</MudSelectItem>
|
|
</MudSelect>
|
|
</Field>
|
|
|
|
<SectionFooter OnSave="@(() => SaveSectionAsync(LocalizationOptions.SectionName, _locale))" />
|
|
</div>
|
|
</article>
|
|
</section>
|
|
|
|
@code {
|
|
private ScrapingSettingsForm _scraping = new();
|
|
private WorkerOptions _workers = new();
|
|
private StorageOptions _storage = new();
|
|
private AnomalyOptions _anomaly = new();
|
|
private LocalizationOptions _locale = new();
|
|
private string _userAgentsRaw = string.Empty;
|
|
|
|
protected override void OnInitialized()
|
|
{
|
|
_scraping = ScrapingOpts.CurrentValue.Clone();
|
|
_userAgentsRaw = string.Join('\n', _scraping.UserAgents ?? Array.Empty<string>());
|
|
|
|
_workers = new WorkerOptions
|
|
{
|
|
UpcomingScheduleCron = WorkerOpts.CurrentValue.UpcomingScheduleCron,
|
|
LivePollerEnabled = WorkerOpts.CurrentValue.LivePollerEnabled,
|
|
UpcomingPollerEnabled = WorkerOpts.CurrentValue.UpcomingPollerEnabled,
|
|
LivePollIntervalSeconds = WorkerOpts.CurrentValue.LivePollIntervalSeconds,
|
|
ResultsPollerEnabled = WorkerOpts.CurrentValue.ResultsPollerEnabled,
|
|
ResultsPollIntervalSeconds = WorkerOpts.CurrentValue.ResultsPollIntervalSeconds,
|
|
AnomalyDetectionEnabled = WorkerOpts.CurrentValue.AnomalyDetectionEnabled,
|
|
};
|
|
|
|
_storage = new StorageOptions
|
|
{
|
|
DatabasePath = StorageOpts.CurrentValue.DatabasePath,
|
|
ExportDirectory = StorageOpts.CurrentValue.ExportDirectory,
|
|
SnapshotRetentionDays = StorageOpts.CurrentValue.SnapshotRetentionDays,
|
|
};
|
|
|
|
_anomaly = new AnomalyOptions
|
|
{
|
|
SuspensionGapSeconds = AnomalyOpts.CurrentValue.SuspensionGapSeconds,
|
|
OddsFlipThreshold = AnomalyOpts.CurrentValue.OddsFlipThreshold,
|
|
MinSnapshotCount = AnomalyOpts.CurrentValue.MinSnapshotCount,
|
|
DetectionIntervalSeconds = AnomalyOpts.CurrentValue.DetectionIntervalSeconds,
|
|
};
|
|
|
|
_locale = new LocalizationOptions { DefaultCulture = LocaleOpts.CurrentValue.DefaultCulture };
|
|
}
|
|
|
|
private void OnUserAgentsChanged(string raw)
|
|
{
|
|
_userAgentsRaw = raw;
|
|
_scraping.UserAgents = (raw ?? string.Empty)
|
|
.Split('\n', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
|
|
}
|
|
|
|
private async Task SaveSectionAsync<T>(string section, T payload) where T : class
|
|
{
|
|
var confirmed = await ConfirmAsync();
|
|
if (!confirmed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await Writer.SaveSectionAsync(section, payload);
|
|
Snackbar.Add(L["Settings.Saved"], Severity.Success);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to save section {Section}", section);
|
|
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
|
|
}
|
|
}
|
|
|
|
private async Task ResetSectionAsync(string section)
|
|
{
|
|
var confirmed = await ConfirmAsync();
|
|
if (!confirmed)
|
|
{
|
|
return;
|
|
}
|
|
|
|
try
|
|
{
|
|
await Writer.ResetSectionAsync(section);
|
|
Snackbar.Add(L["Settings.Saved"], Severity.Success);
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
Logger.LogError(ex, "Failed to reset section {Section}", section);
|
|
Snackbar.Add(L["Settings.SaveFailed"], Severity.Error);
|
|
}
|
|
}
|
|
|
|
private async Task<bool> ConfirmAsync()
|
|
{
|
|
var parameters = new DialogParameters
|
|
{
|
|
["ContentText"] = L["Settings.Confirm.Body"].Value,
|
|
["ButtonText"] = L["Settings.Action.Save"].Value,
|
|
["CancelText"] = L["Common.Cancel"].Value,
|
|
};
|
|
|
|
var result = await Dialogs.ShowMessageBox(
|
|
title: L["Settings.Confirm.Title"],
|
|
message: L["Settings.Confirm.Body"],
|
|
yesText: L["Settings.Action.Save"],
|
|
cancelText: L["Common.Cancel"]);
|
|
|
|
return result == true;
|
|
}
|
|
}
|