Files
maraphon-app/src/Marathon.UI/Pages/Settings.razor
T
alexei.dolgolyov 2acbaa5b77 feat(phase-4): application layer + background workers — 202/202 tests green
Use cases (Marathon.Application/UseCases/):
- PullUpcomingEventsUseCase: scrape + persist new events + capture pre-match snapshots
- PullLiveOddsUseCase: refresh live snapshots for all stored events
- PullResultsUseCase: Phase 4 scaffold; delegates to ScrapeResultsAsync (Phase 3 no-op);
  Phase 8 will replace with watch-list polling
- ExportToExcelUseCase: resolves export dir from StorageOptions, delegates to IExcelExporter

ApplicationModule.AddMarathonApplication(IServiceCollection) — no IConfiguration needed.

Background workers (Marathon.Infrastructure/Workers/):
- UpcomingEventsPoller: Cronos 6-field cron schedule (default every 6 h)
- LiveOddsPoller: fixed interval (WorkerOptions.LivePollIntervalSeconds, default 30 s)
- ResultsWatchListPoller: scaffold, disabled by default (WorkerOptions.ResultsPollerEnabled=false)
All three: exception-swallowing, cancellation-aware, scoped DI via CreateAsyncScope().

InfrastructureModule.AddMarathonInfrastructure(IServiceCollection, IConfiguration):
- Composes AddMarathonPersistence + AddMarathonScraping + WorkerOptions + 3 hosted services

App.xaml.cs: replace reflection-based TryAddApplicationAndInfrastructure with direct
AddMarathonApplication() + AddMarathonInfrastructure(config) calls.

Resolved Phase 3 TODO: bind Sports:Basketball:QuarterMode from config in ScrapingModule.

appsettings.json: add Workers.LivePollIntervalSeconds, ResultsPollIntervalSeconds,
ResultsPollerEnabled; add Sports.Basketball.QuarterMode.

Settings.razor + WorkerOptions (UI) + SharedResource.*.resx: surface new Workers fields.

Tests: +14 Application use-case tests, +3 Infrastructure worker tests (185 → 202 total).
2026-05-05 12:28:15 +03:00

285 lines
13 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>
<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,
};
_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;
}
}