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,109 @@
using System.Security.Claims;
using Bunit;
using Microsoft.AspNetCore.Components.Authorization;
using Microsoft.Extensions.DependencyInjection;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using ZB.MOM.WW.ScadaBridge.Security;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Monitoring;
/// <summary>Gitea #63: pending-updates tiles on the Health dashboard.</summary>
public class PendingUpdatesHealthTilesTests : BunitContext
{
private static SitePendingUpdates Row(int id, int stale, ArtifactDeploymentState a = ArtifactDeploymentState.Current) =>
new(id, $"S{id}", $"Site {id}", a, DateTimeOffset.UtcNow, 10, stale, 0);
private IRenderedComponent<PendingUpdatesHealthTiles> RenderFor(IPendingUpdatesCache cache, params Claim[] claims)
{
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
Services.AddScoped<SiteScopeService>();
Services.AddSingleton(cache);
return Render<PendingUpdatesHealthTiles>(ps =>
ps.AddCascadingValue(Task.FromResult(new AuthenticationState(user))));
}
private static IPendingUpdatesCache CacheReturning(params SitePendingUpdates[] rows)
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, rows)));
return cache;
}
private static Claim Role(string r) => new(JwtTokenService.RoleClaimType, r);
/// <summary>A Deployer sees both counts, highlighted when non-zero.</summary>
[Fact]
public void Deployer_SeesBothCounts()
{
var cut = RenderFor(
CacheReturning(Row(1, 4), Row(2, 3, ArtifactDeploymentState.OutOfDate)),
Role(Roles.Deployer));
cut.WaitForAssertion(() =>
{
Assert.Contains("7", cut.Find("[data-test='pending-updates-instances']").TextContent);
Assert.Contains("1", cut.Find("[data-test='pending-updates-artifact-sites']").TextContent);
Assert.Contains("border-warning", cut.Find("[data-test='pending-updates-instances']").ClassName);
});
}
/// <summary>A Viewer sees nothing and triggers no fleet read.</summary>
[Fact]
public void Viewer_SeesNothing_AndNoFleetRead()
{
var cache = CacheReturning(Row(1, 4));
var cut = RenderFor(cache, Role("Viewer"));
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
cache.DidNotReceive().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
}
/// <summary>A site-scoped Deployer's totals exclude sites outside the scope.</summary>
[Fact]
public void SiteScopedDeployer_CountsOnlyPermittedSites()
{
var cut = RenderFor(
CacheReturning(Row(1, 4), Row(2, 9)),
Role(Roles.Deployer), new Claim(JwtTokenService.SiteIdClaimType, "1"));
cut.WaitForAssertion(() =>
Assert.Equal("4", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
}
/// <summary>A failed read is shown as unavailable, never as zero.</summary>
[Fact]
public void ReadFailure_ShowsUnavailable_NotZero()
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(Task.FromException<PendingUpdatesSnapshot>(new InvalidOperationException("boom")));
var cut = RenderFor(cache, Role(Roles.Administrator));
cut.WaitForAssertion(() =>
{
Assert.Contains("boom", cut.Find("[data-test='pending-updates-error']").TextContent);
Assert.Equal("—", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim());
});
}
/// <summary>An invalidation re-reads the cache.</summary>
[Fact]
public void Invalidated_Rereads()
{
var cache = Substitute.For<IPendingUpdatesCache>();
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(
Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [Row(1, 5)])),
Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [Row(1, 0)])));
var cut = RenderFor(cache, Role(Roles.Deployer));
cut.WaitForAssertion(() => Assert.Equal("5", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
cache.Invalidated += NSubstitute.Raise.Event<Action>();
cut.WaitForAssertion(() => Assert.Equal("0", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
}
}
@@ -133,6 +133,16 @@ public class HealthPageTests : BunitContext
// control itself is covered by HealthFailoverButtonTests.
Services.AddSingleton(Substitute.For<IManualFailoverService>());
Services.AddSingleton(Substitute.For<IDialogService>());
// Gitea #63: the pending-updates tiles read the shared fleet cache and size scope via
// SiteScopeService. The signed-in test user is an Administrator.
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
var pending = Substitute.For<IPendingUpdatesCache>();
pending.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(
new ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesSnapshot(
DateTimeOffset.UtcNow,
[new(1, "site-a", "Site A", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.OutOfDate, null, 10, 3, 0)])));
Services.AddSingleton(pending);
}
/// <summary>
@@ -649,6 +659,52 @@ public class HealthPageTests : BunitContext
Assert.Equal(expectBadge, cut.Markup.Contains("data-test=\"audit-rejected-badge\""));
}
/// <summary>Gitea #88: the three site write-failure badges render on the page only when their counter is above zero.</summary>
/// <param name="eventLog">Event-log write failures.</param>
/// <param name="audit">Audit write failures.</param>
/// <param name="redaction">Audit redaction failures.</param>
[Theory]
[InlineData(0, 0, 0)]
[InlineData(2, 0, 0)]
[InlineData(0, 5, 0)]
[InlineData(0, 0, 1)]
[InlineData(2, 5, 1)]
public void WriteFailureBadges_RenderOnlyWhenCounterAboveZero(long eventLog, int audit, int redaction)
{
SeedStates(new SiteHealthState
{
SiteId = "site-a",
IsOnline = true,
LastHeartbeatAt = DateTimeOffset.UtcNow,
LatestReport = ReportWithReconciler("site-a", active: true) with
{
SiteEventLogWriteFailures = eventLog,
SiteAuditWriteFailures = audit,
AuditRedactionFailure = redaction,
},
});
var cut = RenderHealthPage();
cut.WaitForAssertion(() => Assert.Contains("site-a", cut.Markup));
Assert.Equal(eventLog > 0, cut.Markup.Contains("data-test=\"event-log-write-failures-badge\""));
Assert.Equal(audit > 0, cut.Markup.Contains("data-test=\"audit-write-failures-badge\""));
Assert.Equal(redaction > 0, cut.Markup.Contains("data-test=\"audit-redaction-failures-badge\""));
}
/// <summary>Gitea #63: the dashboard shows the pending-updates counts for an Administrator.</summary>
[Fact]
public void Renders_PendingUpdatesTiles_ForAdministrator()
{
var cut = RenderHealthPage();
cut.WaitForAssertion(() =>
{
Assert.Equal("3", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim());
Assert.Equal("1", cut.Find("[data-test='pending-updates-artifact-sites'] h3").TextContent.Trim());
});
}
/// <summary>Verifies that no secret mirror warning renders when no node is failing.</summary>
/// <param name="reported">Whether the site reports secret mirror status at all.</param>
[Theory]
@@ -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()
@@ -148,6 +148,7 @@ public class RequiredRoleMatrixTests
["SetInstanceNativeAlarmSourceOverrides"] = [Roles.Deployer],
["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
["GetDeploymentDiff"] = [Roles.Deployer],
["GetPendingUpdates"] = [Roles.Deployer],
["MgmtDeployArtifacts"] = [Roles.Deployer],
["QueryDeployments"] = [Roles.Deployer],
["RetryParkedMessage"] = [Roles.Deployer],