feat(health): surface pending-updates counts on the Health dashboard and CLI (ScadaBridge#63)

Health page gains PendingUpdatesHealthTiles (stale instances / sites with stale
artifacts, Deployer+Administrator only, site-scope narrowed, shared cache). New
GetPendingUpdates management command (Deployer) and 'health pending-updates' CLI
leaf. Flatten/cache computation untouched; the command reads
IPendingUpdatesEvaluator directly because the UI cache is not reachable from
ManagementService.

Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
Joseph Doherty
2026-11-09 13:18:54 -05:00
parent 004bf3cf06
commit d50433410d
15 changed files with 447 additions and 2 deletions
@@ -0,0 +1,142 @@
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health
@implements IDisposable
@using System.Security.Claims
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
@using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates
@using ZB.MOM.WW.ScadaBridge.Security
@inject IPendingUpdatesCache PendingUpdates
@* Gitea #63: pending-updates KPI tiles on the Health dashboard. Two counts read from the
same process-wide IPendingUpdatesCache the rail indicator and Sites page use, so this
costs no extra fleet flatten. Visible only to Deployer/Administrator (the roles that can
act on it, same gate as the rail indicator) and narrowed to a site-scoped operator's
permitted sites. A read failure is shown as unavailable, never as a clean "0". *@
@if (_visible)
{
<div class="card mb-3" data-test="pending-updates-tiles">
<div class="card-body pb-0">
<div class="d-flex justify-content-between align-items-center mb-2">
<h6 class="text-muted mb-0">Pending Updates</h6>
@if (_snapshot is not null)
{
<small class="text-muted">as of @_snapshot.ComputedAt.UtcDateTime.ToString("HH:mm") UTC</small>
}
</div>
<div class="row g-3 mb-3">
<div class="col-lg-6 col-12">
<div class="card h-100 @(StaleInstances > 0 ? "border-warning" : "")" data-test="pending-updates-instances">
<div class="card-body text-center">
<h3 class="mb-0 @(StaleInstances > 0 ? "text-warning" : "")">@Display(StaleInstances)</h3>
<small class="text-muted">Instances out of date</small>
</div>
</div>
</div>
<div class="col-lg-6 col-12">
<div class="card h-100 @(StaleArtifactSites > 0 ? "border-warning" : "")" data-test="pending-updates-artifact-sites">
<div class="card-body text-center">
<h3 class="mb-0 @(StaleArtifactSites > 0 ? "text-warning" : "")">@Display(StaleArtifactSites)</h3>
<small class="text-muted">Sites with stale artifacts</small>
</div>
</div>
</div>
</div>
@if (_error is not null)
{
<div class="text-muted small mb-3" data-test="pending-updates-error">Pending-updates counts unavailable: @_error</div>
}
</div>
</div>
}
@code {
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30);
private Timer? _timer;
private volatile bool _disposed;
private bool _visible;
private IReadOnlySet<int>? _scopedSiteIds;
private PendingUpdatesSnapshot? _snapshot;
private string? _error;
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
private int StaleInstances => _snapshot?.StaleInstanceCount ?? 0;
private int StaleArtifactSites => _snapshot?.StaleArtifactSiteCount ?? 0;
// "—" until the first snapshot lands or when the read failed: never a fake zero.
private string Display(int count) => _snapshot is null ? "—" : count.ToString();
protected override async Task OnInitializedAsync()
{
if (AuthenticationStateTask is null)
{
return;
}
var user = (await AuthenticationStateTask).User;
var roles = user.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
_visible = roles.Any(r => string.Equals(r, Roles.Deployer, StringComparison.OrdinalIgnoreCase)
|| string.Equals(r, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
if (!_visible)
{
return;
}
var (isSystemWide, sites) = SiteScopeService.Resolve(user);
_scopedSiteIds = isSystemWide ? null : sites;
PendingUpdates.Invalidated += OnInvalidated;
await LoadAsync();
_timer = new Timer(_ => _ = ReloadAsync(), null, PollInterval, PollInterval);
}
private void OnInvalidated() => _ = ReloadAsync();
private async Task ReloadAsync()
{
if (_disposed)
{
return;
}
await LoadAsync();
try
{
await InvokeAsync(StateHasChanged);
}
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException)
{
// Circuit tearing down.
}
}
private async Task LoadAsync()
{
try
{
var snapshot = await PendingUpdates.GetAsync();
if (_scopedSiteIds is { } scope)
{
snapshot = snapshot.ForSites(scope);
}
_snapshot = snapshot;
_error = null;
}
catch (Exception ex)
{
_error = ex.Message;
}
}
public void Dispose()
{
_disposed = true;
if (_visible)
{
PendingUpdates.Invalidated -= OnInvalidated;
}
_timer?.Dispose();
}
}
@@ -92,6 +92,10 @@
</div>
</div>
@* Gitea #63 — fleet-wide pending-updates counts (Deployer/Administrator only; the
component gates itself). *@
<PendingUpdatesHealthTiles />
@* Three KPI tiles for the Audit channel
(volume / error rate / backlog). Refreshed alongside the site states. *@
<div class="card mb-3">