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
@@ -1848,6 +1848,63 @@ public class ManagementActorTests : TestKit, IDisposable
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
}
private void RegisterPendingUpdates()
{
var evaluator = Substitute.For<ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesEvaluator>();
evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).Returns(Task.FromResult(
new ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesSnapshot(
DateTimeOffset.UtcNow,
[
new(1, "S1", "Site 1", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.OutOfDate, null, 10, 4, 0),
new(2, "S2", "Site 2", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.Current, null, 10, 6, 0),
])));
_services.AddScoped(_ => evaluator);
}
/// <summary>Gitea #63: a system-wide Deployer gets fleet totals.</summary>
[Fact]
public void GetPendingUpdates_SystemWideDeployer_ReturnsFleetTotals()
{
RegisterPendingUpdates();
var actor = CreateActor();
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var doc = JsonDocument.Parse(response.JsonData);
Assert.Equal(10, doc.RootElement.GetProperty("staleInstanceCount").GetInt32());
Assert.Equal(1, doc.RootElement.GetProperty("staleArtifactSiteCount").GetInt32());
Assert.Equal(2, doc.RootElement.GetProperty("sites").GetArrayLength());
Assert.Equal("OutOfDate", doc.RootElement.GetProperty("sites")[0].GetProperty("artifactState").GetString());
}
/// <summary>Gitea #63: a site-scoped Deployer's totals exclude other sites.</summary>
[Fact]
public void GetPendingUpdates_SiteScopedDeployer_SeesOnlyPermittedSites()
{
RegisterPendingUpdates();
var actor = CreateActor();
actor.Tell(ScopedEnvelope(new GetPendingUpdatesCommand(), new[] { "1" }, "Deployer"));
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
using var doc = JsonDocument.Parse(response.JsonData);
Assert.Equal(4, doc.RootElement.GetProperty("staleInstanceCount").GetInt32());
Assert.Equal(1, doc.RootElement.GetProperty("sites").GetArrayLength());
}
/// <summary>Gitea #63: a non-Deployer is refused.</summary>
[Fact]
public void GetPendingUpdates_Viewer_IsUnauthorized()
{
RegisterPendingUpdates();
var actor = CreateActor();
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Viewer"));
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
}
/// <summary>Verifies that query parked messages, when out of scope for site scoped user, returns unauthorized.</summary>
[Fact]
public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()