feat(audit): M5.2 per-node stuck-count KPIs (T6) — repo per-node aggregation, actor message pair, CentralUI tiles

This commit is contained in:
Joseph Doherty
2026-06-16 21:34:14 -04:00
parent a07ff28f10
commit 209f368cb5
25 changed files with 840 additions and 6 deletions
@@ -69,6 +69,51 @@
</div>
}
@* ── Per-node breakdown (T6: additive) ── *@
<h5 class="mb-2">Per-node breakdown</h5>
@if (_perNodeError != null)
{
<div class="alert alert-warning py-2">Per-node KPIs unavailable: @_perNodeError</div>
}
else if (_perNode.Count == 0)
{
<div class="card mb-3">
<div class="card-body text-center text-muted py-3">
<div class="small">No per-node activity (rows may have a null SourceNode).</div>
</div>
</div>
}
else
{
<div class="table-responsive mb-3">
<table class="table table-sm table-hover align-middle">
<thead class="table-light">
<tr>
<th>Node</th>
<th class="text-end">Queue Depth</th>
<th class="text-end">Stuck</th>
<th class="text-end">Parked</th>
<th class="text-end">Delivered (last interval)</th>
<th class="text-end">Oldest Pending Age</th>
</tr>
</thead>
<tbody>
@foreach (var n in _perNode)
{
<tr @key="n.SourceNode" class="@(n.StuckCount > 0 ? "table-warning" : "")">
<td><code>@n.SourceNode</code></td>
<td class="text-end font-monospace">@n.QueueDepth</td>
<td class="text-end font-monospace @(n.StuckCount > 0 ? "text-warning" : "")">@n.StuckCount</td>
<td class="text-end font-monospace @(n.ParkedCount > 0 ? "text-danger" : "")">@n.ParkedCount</td>
<td class="text-end font-monospace text-success">@n.DeliveredLastInterval</td>
<td class="text-end font-monospace">@FormatAge(n.OldestPendingAge)</td>
</tr>
}
</tbody>
</table>
</div>
}
@* ── Per-site breakdown ── *@
<h5 class="mb-2">Per-site breakdown</h5>
@if (_perSiteError != null)
@@ -124,6 +169,10 @@
private IReadOnlyList<SiteNotificationKpiSnapshot> _perSite = Array.Empty<SiteNotificationKpiSnapshot>();
private string? _perSiteError;
// ── Per-node (T6: M5.2 per-node stuck-count KPIs) ──
private IReadOnlyList<NodeNotificationKpiSnapshot> _perNode = Array.Empty<NodeNotificationKpiSnapshot>();
private string? _perNodeError;
private bool _loading;
protected override async Task OnInitializedAsync()
@@ -144,9 +193,9 @@
private async Task RefreshAll()
{
_loading = true;
// Race-free despite both tasks mutating component fields: Blazor Server runs
// Race-free despite all tasks mutating component fields: Blazor Server runs
// every continuation on the circuit's single-threaded synchronization context.
await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis());
await Task.WhenAll(LoadGlobalKpis(), LoadPerSiteKpis(), LoadPerNodeKpis());
_loading = false;
}
@@ -194,6 +243,28 @@
}
}
private async Task LoadPerNodeKpis()
{
try
{
var response = await CommunicationService.GetPerNodeNotificationKpisAsync(
new PerNodeNotificationKpiRequest(Guid.NewGuid().ToString("N")));
if (response.Success)
{
_perNode = response.Nodes;
_perNodeError = null;
}
else
{
_perNodeError = response.ErrorMessage ?? "Per-node KPI query failed.";
}
}
catch (Exception ex)
{
_perNodeError = $"Per-node KPI query failed: {ex.Message}";
}
}
private string SiteName(string siteId) =>
_sites.FirstOrDefault(s => s.SiteIdentifier == siteId)?.Name ?? siteId;