@page "/settings" @using Marathon.Application.Storage @using LocalizationOptions = Marathon.UI.Services.LocalizationOptions @inject IStringLocalizer L @inject IOptionsMonitor ScrapingOpts @inject IOptionsMonitor WorkerOpts @inject IOptionsMonitor StorageOpts @inject IOptionsMonitor AnomalyOpts @inject IOptionsMonitor LocaleOpts @inject ISettingsWriter Writer @inject IDialogService Dialogs @inject ISnackbar Snackbar @inject ILogger Logger @L["App.Title"] · @L["Settings.Title"]
@L["Settings.Kicker"]

@L["Settings.Title"]

@L["Settings.Lede"]


@* SCRAPING *@

@L["Settings.Section.Scraping"]

@L["Settings.Action.Reset"]
@* WORKERS *@

@L["Settings.Section.Workers"]

@L["Settings.Action.Reset"]
@* STORAGE *@

@L["Settings.Section.Storage"]

@L["Settings.Action.Reset"]
@* ANOMALY *@

@L["Settings.Section.Anomaly"]

@L["Settings.Action.Reset"]
@* LOCALIZATION *@

@L["Settings.Section.Localization"]

@L["Settings.Action.Reset"]
@L["Locale.Russian"] · ru-RU @L["Locale.English"] · en-US
@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()); _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(string section, T payload) where T : class { // Section-level validation — fails fast before disk write so the // user sees the snackbar immediately and the JSON file isn't touched. if (payload is StorageOptions storage) { var errorKey = StoragePathValidator.Validate(storage); if (errorKey is not null) { Snackbar.Add(L[errorKey], Severity.Error); return; } } if (payload is ScrapingSettingsForm scraping && !(Uri.TryCreate(scraping.BaseUrl, UriKind.Absolute, out var baseUri) && (baseUri.Scheme == Uri.UriSchemeHttp || baseUri.Scheme == Uri.UriSchemeHttps))) { Snackbar.Add(L["Settings.Scraping.BaseUrl.Invalid"], Severity.Error); return; } if (payload is WorkerOptions workers && !IsPlausibleCron(workers.UpcomingScheduleCron)) { Snackbar.Add(L["Settings.Workers.Cron.Invalid"], Severity.Error); return; } 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); } } // Lightweight 5- or 6-field cron sanity check — avoids a Cronos dependency in the // UI layer; the worker still does the authoritative parse at startup. private static bool IsPlausibleCron(string? expression) { if (string.IsNullOrWhiteSpace(expression)) return false; var fields = expression.Split(' ', StringSplitOptions.RemoveEmptyEntries); return fields.Length is 5 or 6; } 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 ConfirmAsync() { 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; } }