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
@@ -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>();