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
@@ -19,6 +19,7 @@ public static class HealthCommands
var command = new Command("health") { Description = "Health monitoring" };
command.Add(BuildSummary(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildPendingUpdates(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildSite(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildEventLog(urlOption, formatOption, usernameOption, passwordOption));
command.Add(BuildParkedMessages(urlOption, formatOption, usernameOption, passwordOption));
@@ -37,6 +38,20 @@ public static class HealthCommands
return cmd;
}
private static Command BuildPendingUpdates(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var cmd = new Command("pending-updates")
{
Description = "Get fleet-wide pending-updates counts (stale instances, sites with stale artifacts)"
};
cmd.SetAction(async (ParseResult result) =>
{
return await CommandHelpers.ExecuteCommandAsync(
result, urlOption, formatOption, usernameOption, passwordOption, new GetPendingUpdatesCommand());
});
return cmd;
}
private static Command BuildSite(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
{
var identifierOption = new Option<string>("--identifier") { Description = "Site identifier", Required = true };
+8
View File
@@ -1810,6 +1810,14 @@ Return the current health state for all known sites as a JSON object keyed by si
scadabridge --url <url> health summary
```
#### `health pending-updates`
Return the fleet-wide pending-updates counts — instances whose deployed configuration has drifted from their current flatten, and sites running out-of-date system-wide artifacts — with a per-site breakdown. Requires the Deployer role; a site-scoped caller sees only their permitted sites. Each call evaluates the fleet (the Central UI's cached read is not shared with the CLI), so poll it at a scripting cadence, not a tight loop.
```sh
scadabridge --url <url> health pending-updates
```
#### `health site`
Return the health state for a single site.
@@ -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">
@@ -2,3 +2,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
public record GetHealthSummaryCommand;
public record GetSiteHealthCommand(string SiteIdentifier);
/// <summary>
/// Fleet-wide "is anything waiting to be deployed?" read (Gitea #63): stale-instance and
/// stale-artifact-site counts plus a per-site breakdown. Deployer-gated and narrowed to a
/// site-scoped caller's permitted sites, like the Central UI rail indicator.
/// </summary>
public record GetPendingUpdatesCommand;
@@ -32,6 +32,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
using ZB.MOM.WW.ScadaBridge.Commons.Validation;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.Security;
@@ -354,6 +355,7 @@ public class ManagementActor : ReceiveActor
or GetDeploymentDiffCommand
or MgmtDeployArtifactsCommand
or QueryDeploymentsCommand
or GetPendingUpdatesCommand
or RetryParkedMessageCommand or DiscardParkedMessageCommand
or DebugSnapshotCommand => DeployerOnly,
@@ -550,6 +552,7 @@ public class ManagementActor : ReceiveActor
// Health
GetHealthSummaryCommand => HandleGetHealthSummary(sp),
GetSiteHealthCommand cmd => HandleGetSiteHealth(sp, cmd),
GetPendingUpdatesCommand => await HandleGetPendingUpdates(sp, user),
// Remote Queries
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user),
@@ -3174,6 +3177,45 @@ public class ManagementActor : ReceiveActor
return aggregator.GetAllSiteStates();
}
/// <summary>
/// Pending-updates counts (Gitea #63). Reads the evaluator directly — the Central UI's
/// process-wide TTL cache lives in the CentralUI project, which this project cannot
/// reference — so each call is one fleet flatten; fine for a CLI/scripting cadence.
/// A site-scoped, non-Administrator caller sees only their permitted sites, so the
/// totals never include a site they cannot see.
/// </summary>
private static async Task<object?> HandleGetPendingUpdates(IServiceProvider sp, AuthenticatedUser user)
{
var evaluator = sp.GetRequiredService<IPendingUpdatesEvaluator>();
var snapshot = await evaluator.EvaluateAsync();
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
{
var permitted = new HashSet<string>(user.PermittedSiteIds);
snapshot = snapshot.ForSites(snapshot.Sites
.Where(s => permitted.Contains(s.SiteId.ToString()))
.Select(s => s.SiteId).ToHashSet());
}
return new
{
computedAt = snapshot.ComputedAt,
hasPendingUpdates = snapshot.HasPendingUpdates,
staleInstanceCount = snapshot.StaleInstanceCount,
staleArtifactSiteCount = snapshot.StaleArtifactSiteCount,
sites = snapshot.Sites.Select(s => new
{
siteId = s.SiteId,
siteIdentifier = s.SiteIdentifier,
siteName = s.SiteName,
artifactState = s.ArtifactState.ToString(),
artifactsDeployedAt = s.ArtifactsDeployedAt,
deployedInstanceCount = s.DeployedInstanceCount,
staleInstanceCount = s.StaleInstanceCount,
unknownInstanceCount = s.UnknownInstanceCount,
}).ToList(),
};
}
private static object? HandleGetSiteHealth(IServiceProvider sp, GetSiteHealthCommand cmd)
{
var aggregator = sp.GetRequiredService<ICentralHealthAggregator>();