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:
+109
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user