34cc72fd2d
Adds a detector-kind chip row to the anomaly feed (SuspensionFlip / SteamMove / SuspensionFreeze / OverroundCompression), multi-select like the sport filter — so with four detectors live you can slice the feed to a single signal type. The kind set lives on AnomalyFilter and filters in-memory alongside severity/sport, persisted via AnomalyBrowsingState like the other filters. - AnomalyFilter.Kinds + AnomalyBrowsingService in-memory Where clause; feed chip row + ToggleKind/KindLabel; en/ru resx (Anomaly.Filter.Kind). - 2 tests: kind-filtered subset + no-filter returns all kinds.
340 lines
12 KiB
Plaintext
340 lines
12 KiB
Plaintext
@page "/anomalies"
|
|
@using Marathon.UI.Components
|
|
@implements IDisposable
|
|
@inject IStringLocalizer<SharedResource> L
|
|
@inject IAnomalyBrowsingService Anomalies
|
|
@inject AnomalyBrowsingState State
|
|
@inject NavigationManager Nav
|
|
|
|
<PageTitle>@L["App.Title"] · @L["Anomaly.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" style="color: var(--m-c-anomaly); border-color: var(--m-c-anomaly);">
|
|
@L["Nav.Section.Analysis"]
|
|
</span>
|
|
<h1 class="m-display" style="font-size: clamp(2rem, 4vw, 3rem);">@L["Anomaly.Title"]</h1>
|
|
<p style="color: var(--m-c-ink-soft); max-width: 60ch;">@L["Anomaly.Lede"]</p>
|
|
|
|
<dl class="m-anomaly-feed__stats" aria-label="@L["Anomaly.Title"]">
|
|
<div class="m-anomaly-feed__stat">
|
|
<dt>@L["Anomaly.Stat.Total"]</dt>
|
|
<dd class="m-mono">@_items.Count</dd>
|
|
</div>
|
|
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--high">
|
|
<dt>@L["Anomaly.Severity.High"]</dt>
|
|
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.High)</dd>
|
|
</div>
|
|
<div class="m-anomaly-feed__stat m-anomaly-feed__stat--medium">
|
|
<dt>@L["Anomaly.Severity.Medium"]</dt>
|
|
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Medium)</dd>
|
|
</div>
|
|
<div class="m-anomaly-feed__stat">
|
|
<dt>@L["Anomaly.Severity.Low"]</dt>
|
|
<dd class="m-mono">@_items.Count(i => i.Severity == AnomalySeverity.Low)</dd>
|
|
</div>
|
|
</dl>
|
|
</header>
|
|
|
|
<div class="m-list-toolbar m-rise m-rise-2" role="toolbar" aria-label="@L["PreMatch.Filter.Toolbar"]">
|
|
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
|
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Severity"]</span>
|
|
@foreach (var severity in _severityOptions)
|
|
{
|
|
var current = severity;
|
|
var active = _filter.MinSeverity == current;
|
|
<button type="button"
|
|
class="m-chip @(active ? "is-active" : null)"
|
|
aria-pressed="@active"
|
|
data-test="severity-chip"
|
|
data-severity="@current"
|
|
@onclick="() => ToggleSeverity(current)">
|
|
@SeverityLabel(current)
|
|
</button>
|
|
}
|
|
<button type="button"
|
|
class="m-chip @(_filter.MinSeverity is null ? "is-active" : null)"
|
|
aria-pressed="@(_filter.MinSeverity is null)"
|
|
data-test="severity-chip-any"
|
|
@onclick="ClearSeverity">
|
|
@L["Anomaly.Filter.AnySeverity"]
|
|
</button>
|
|
</div>
|
|
|
|
@if (_availableSports.Count > 0)
|
|
{
|
|
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
|
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Sport"]</span>
|
|
@foreach (var sportCode in _availableSports)
|
|
{
|
|
var localCode = sportCode;
|
|
var active = _filter.SportCodes is { Count: > 0 } sc && sc.Contains(localCode);
|
|
var sportLabel = SportLabel(localCode);
|
|
<button type="button"
|
|
class="m-chip @(active ? "is-active" : null)"
|
|
aria-pressed="@active"
|
|
data-test="sport-chip"
|
|
@onclick="() => ToggleSport(localCode)">
|
|
<SportIcon Code="@localCode" Label="@sportLabel" ClassName="m-chip__icon" />
|
|
<span>@sportLabel</span>
|
|
</button>
|
|
}
|
|
</div>
|
|
}
|
|
|
|
<div class="m-list-toolbar__row m-list-toolbar__chips">
|
|
<span class="m-list-toolbar__label">@L["Anomaly.Filter.Kind"]</span>
|
|
@foreach (var kind in _kindOptions)
|
|
{
|
|
var localKind = kind;
|
|
var active = _filter.Kinds is { Count: > 0 } ks && ks.Contains(localKind);
|
|
<button type="button"
|
|
class="m-chip @(active ? "is-active" : null)"
|
|
aria-pressed="@active"
|
|
data-test="kind-chip"
|
|
data-kind="@localKind"
|
|
@onclick="() => ToggleKind(localKind)">
|
|
@KindLabel(localKind)
|
|
</button>
|
|
}
|
|
</div>
|
|
|
|
<div class="m-list-toolbar__row">
|
|
<div class="m-list-toolbar__group">
|
|
<label class="m-list-toolbar__label">@L["Anomaly.Filter.From"]</label>
|
|
<input class="m-input" type="date" value="@FormatDate(_filter.From)"
|
|
aria-label="@L["Anomaly.Filter.From"]"
|
|
@onchange="OnFromChanged" />
|
|
</div>
|
|
<div class="m-list-toolbar__group">
|
|
<label class="m-list-toolbar__label">@L["Anomaly.Filter.To"]</label>
|
|
<input class="m-input" type="date" value="@FormatDate(_filter.To)"
|
|
aria-label="@L["Anomaly.Filter.To"]"
|
|
@onchange="OnToChanged" />
|
|
</div>
|
|
<button type="button" class="m-chip" @onclick="MarkAllRead" data-test="mark-read">
|
|
@L["Anomaly.Filter.MarkRead"]
|
|
</button>
|
|
<button type="button" class="m-chip" @onclick="OpenInsights" data-test="open-insights">
|
|
@L["Nav.Insights"]
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="m-anomaly-feed m-rise m-rise-3" role="region" aria-label="@L["Anomaly.Title"]" data-test="anomaly-feed">
|
|
@if (_loading && _items.Count == 0)
|
|
{
|
|
<div class="m-list-empty">
|
|
<MudProgressCircular Indeterminate="true" Size="Size.Small" />
|
|
<span class="m-mono">@L["Common.Loading"]</span>
|
|
</div>
|
|
}
|
|
else if (_items.Count == 0)
|
|
{
|
|
<div class="m-list-empty" data-test="anomaly-empty">
|
|
<span class="m-kicker" style="border-color: var(--m-c-ink-soft); color: var(--m-c-ink-soft);">
|
|
@L["Common.Empty"]
|
|
</span>
|
|
<p style="color: var(--m-c-ink-soft); margin-top: var(--m-space-3); max-width: 50ch;">
|
|
@L["Anomaly.Empty.NoneInRange"]
|
|
</p>
|
|
</div>
|
|
}
|
|
else
|
|
{
|
|
<div class="m-anomaly-feed__list">
|
|
@foreach (var item in _items)
|
|
{
|
|
<AnomalyCard Item="item" OnClick="HandleClick" />
|
|
}
|
|
</div>
|
|
}
|
|
</div>
|
|
</section>
|
|
|
|
<style>
|
|
.m-anomaly-feed__stats {
|
|
display: flex;
|
|
gap: var(--m-space-5);
|
|
margin: var(--m-space-3) 0 0;
|
|
padding: 0;
|
|
flex-wrap: wrap;
|
|
}
|
|
.m-anomaly-feed__stat {
|
|
display: grid;
|
|
gap: 2px;
|
|
margin: 0;
|
|
padding-right: var(--m-space-4);
|
|
border-right: 1px solid var(--m-c-rule);
|
|
}
|
|
.m-anomaly-feed__stat:last-child { border-right: 0; padding-right: 0; }
|
|
.m-anomaly-feed__stat dt {
|
|
font-family: var(--m-font-mono);
|
|
font-size: 0.6875rem;
|
|
text-transform: uppercase;
|
|
letter-spacing: 0.16em;
|
|
color: var(--m-c-ink-soft);
|
|
}
|
|
.m-anomaly-feed__stat dd {
|
|
margin: 0;
|
|
font-size: 1.5rem;
|
|
font-weight: 500;
|
|
color: var(--m-c-ink);
|
|
font-feature-settings: var(--m-num-feature);
|
|
}
|
|
.m-anomaly-feed__stat--high dd { color: var(--m-c-anomaly); }
|
|
.m-anomaly-feed__stat--medium dd { color: var(--m-c-accent); }
|
|
|
|
.m-anomaly-feed__list {
|
|
display: grid;
|
|
gap: var(--m-space-3);
|
|
}
|
|
</style>
|
|
|
|
@code {
|
|
private static readonly AnomalySeverity[] _severityOptions =
|
|
{ AnomalySeverity.Low, AnomalySeverity.Medium, AnomalySeverity.High };
|
|
|
|
private static readonly AnomalyKind[] _kindOptions =
|
|
{ AnomalyKind.SuspensionFlip, AnomalyKind.SteamMove, AnomalyKind.SuspensionFreeze, AnomalyKind.OverroundCompression };
|
|
|
|
private List<AnomalyListItem> _items = new();
|
|
private IReadOnlyList<int> _availableSports = Array.Empty<int>();
|
|
private bool _loading = true;
|
|
private CancellationTokenSource? _loadCts;
|
|
private AnomalyFilter _filter = new();
|
|
|
|
protected override async Task OnInitializedAsync()
|
|
{
|
|
_filter = State.Filter;
|
|
State.OnChange += OnStateChanged;
|
|
|
|
try
|
|
{
|
|
_availableSports = await Anomalies.ListKnownSportCodesAsync(CancellationToken.None);
|
|
}
|
|
catch
|
|
{
|
|
_availableSports = Array.Empty<int>();
|
|
}
|
|
|
|
await LoadAsync();
|
|
}
|
|
|
|
private void OnStateChanged()
|
|
{
|
|
InvokeAsync(StateHasChanged);
|
|
}
|
|
|
|
private async Task LoadAsync()
|
|
{
|
|
_loadCts?.Cancel();
|
|
_loadCts = new CancellationTokenSource();
|
|
var ct = _loadCts.Token;
|
|
_loading = true;
|
|
try
|
|
{
|
|
var rows = await Anomalies.ListAsync(_filter, ct);
|
|
if (ct.IsCancellationRequested) return;
|
|
_items = rows.ToList();
|
|
var unread = await Anomalies.GetUnreadCountAsync(State.LastSeenUtc, ct);
|
|
State.SetUnreadCount(unread);
|
|
}
|
|
catch (OperationCanceledException) { /* superseded */ }
|
|
catch
|
|
{
|
|
_items = new List<AnomalyListItem>();
|
|
}
|
|
finally
|
|
{
|
|
_loading = false;
|
|
StateHasChanged();
|
|
}
|
|
}
|
|
|
|
private async Task UpdateFilter(AnomalyFilter next)
|
|
{
|
|
_filter = next;
|
|
State.UpdateFilter(next);
|
|
await LoadAsync();
|
|
}
|
|
|
|
private Task ToggleSeverity(AnomalySeverity severity)
|
|
=> UpdateFilter(_filter with { MinSeverity = _filter.MinSeverity == severity ? null : severity });
|
|
|
|
private Task ClearSeverity() => UpdateFilter(_filter with { MinSeverity = null });
|
|
|
|
private Task ToggleSport(int code)
|
|
{
|
|
var existing = _filter.SportCodes?.ToList() ?? new List<int>();
|
|
if (!existing.Remove(code)) existing.Add(code);
|
|
return UpdateFilter(_filter with { SportCodes = existing.Count == 0 ? null : existing });
|
|
}
|
|
|
|
private Task ToggleKind(AnomalyKind kind)
|
|
{
|
|
var existing = _filter.Kinds?.ToList() ?? new List<AnomalyKind>();
|
|
if (!existing.Remove(kind)) existing.Add(kind);
|
|
return UpdateFilter(_filter with { Kinds = existing.Count == 0 ? null : existing });
|
|
}
|
|
|
|
private string KindLabel(AnomalyKind kind) => kind switch
|
|
{
|
|
AnomalyKind.SuspensionFlip => L["Anomaly.Kind.SuspensionFlip"],
|
|
AnomalyKind.SteamMove => L["Anomaly.Kind.SteamMove"],
|
|
AnomalyKind.SuspensionFreeze => L["Anomaly.Kind.SuspensionFreeze"],
|
|
AnomalyKind.OverroundCompression => L["Anomaly.Kind.OverroundCompression"],
|
|
_ => kind.ToString(),
|
|
};
|
|
|
|
private async Task OnFromChanged(ChangeEventArgs e)
|
|
{
|
|
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
|
|
{
|
|
await UpdateFilter(_filter with { From = new DateTimeOffset(v.Date, MoscowTime.Offset) });
|
|
}
|
|
}
|
|
|
|
private async Task OnToChanged(ChangeEventArgs e)
|
|
{
|
|
if (DateTimeOffset.TryParse(e.Value?.ToString(), out var v))
|
|
{
|
|
await UpdateFilter(_filter with { To = MoscowTime.EndOfMoscowDay(DateOnly.FromDateTime(v.Date)) });
|
|
}
|
|
}
|
|
|
|
private void MarkAllRead()
|
|
{
|
|
State.MarkAllSeen(DateTimeOffset.UtcNow);
|
|
}
|
|
|
|
private void OpenInsights()
|
|
{
|
|
Nav.NavigateTo("/anomalies/insights");
|
|
}
|
|
|
|
private void HandleClick(AnomalyListItem item)
|
|
{
|
|
Nav.NavigateTo($"/anomalies/{item.Id}");
|
|
}
|
|
|
|
private string SeverityLabel(AnomalySeverity s) => s switch
|
|
{
|
|
AnomalySeverity.High => L["Anomaly.Severity.High"],
|
|
AnomalySeverity.Medium => L["Anomaly.Severity.Medium"],
|
|
_ => L["Anomaly.Severity.Low"],
|
|
};
|
|
|
|
private string SportLabel(int code) => SportLabels.Resolve(L, code);
|
|
|
|
private static string FormatDate(DateTimeOffset? value)
|
|
=> value?.ToString("yyyy-MM-dd") ?? string.Empty;
|
|
|
|
public void Dispose()
|
|
{
|
|
State.OnChange -= OnStateChanged;
|
|
_loadCts?.Cancel();
|
|
_loadCts?.Dispose();
|
|
}
|
|
}
|