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
+1 -1
View File
@@ -122,7 +122,7 @@ scadabridge instance deploy --id 7
| `bundle` | `export`, `preview`, `import` | Designer export; Administrator preview/import |
| `audit` | `query`, `export`, `tree`, `backfill-source-node`, `verify-chain` | Audit roles; Administrator backfill; verify-chain is a local placeholder |
| `audit-config` | `query` (config-change audit trail; was `audit-log` pre-M8) | `Admin` |
| `health` | `summary`, `site`, `event-log`, `parked-messages` | Authenticated reads with server-side scope checks |
| `health` | `summary`, `pending-updates`, `site`, `event-log`, `parked-messages` | Authenticated reads with server-side scope checks |
| `debug` | `snapshot`, `stream` | `Deployer` |
### Selected examples
+1 -1
View File
@@ -179,7 +179,7 @@ There is **no** out-of-process actor path. The actor was advertised via `Cluster
| Database connections | `ListDatabaseConnections`, `GetDatabaseConnection`, `CreateDatabaseConnectionDef`, `UpdateDatabaseConnectionDef`, `DeleteDatabaseConnectionDef` | Designer (mutations) |
| Inbound API methods | `ListApiMethods`, `GetApiMethod`, `CreateApiMethod`, `UpdateApiMethod`, `DeleteApiMethod` | Designer (mutations) |
| Security | `ListRoleMappings`, `CreateRoleMapping`, `UpdateRoleMapping`, `DeleteRoleMapping`, `ListApiKeys`, `CreateApiKey`, `UpdateApiKey`, `DeleteApiKey`, `SetApiKeyMethods`, `ListScopeRules`, `AddScopeRule`, `DeleteScopeRule` | Administrator |
| Deployments | `MgmtDeployArtifacts`, `QueryDeployments`, `GetDeploymentDiff` | Deployer |
| Deployments | `MgmtDeployArtifacts`, `QueryDeployments`, `GetDeploymentDiff`, `GetPendingUpdates` | Deployer |
| Health | `GetHealthSummary`, `GetSiteHealth` | Any authenticated user |
| Remote queries | `QueryEventLogsCommand`, `QueryParkedMessagesCommand` (any authenticated user); `RetryParkedMessageCommand`, `DiscardParkedMessageCommand`, `DebugSnapshotCommand` (Deployer) | Varies |
| Audit (legacy) | `QueryAuditLog` | Administrator |
+1
View File
@@ -346,6 +346,7 @@ scadabridge audit verify-chain --month <YYYY-MM>
### Health Commands
```
scadabridge health summary
scadabridge health pending-updates
scadabridge health site --identifier <site-identifier>
scadabridge health event-log --site <site-identifier> [--event-type <type>] [--severity <level>] [--keyword <term>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>] [--instance-name <name>]
scadabridge health parked-messages --site <site-identifier> [--page <n>] [--page-size <n>]
@@ -42,6 +42,8 @@ Site clusters (metric collection and reporting). Central cluster (aggregation an
| `SecretMirrorStatuses` | Site Runtime (`DeployedSecretsMirror` via the replicated `secret_mirror_status` table) | Every site node's deployed-secrets mirror status — node name, when the node last wrote the row (on a failure-set change, at start-up, or on the 10-min refresh), and its current failures (`{SecretName, Message}`; names and messages only, never values). Review finding M1: the **standby's** failures ride the **active** node's report, because each node writes its own row to a LocalDb-replicated table and the active node's `SecretMirrorStatusReporter` reads them all. **Nullable — null = no data** (central self-report, a pre-M1 site, reporter not yet run); an empty list = wired, no row yet; a node with an empty `Failures` list is healthy. Point-in-time (not drained on collect). Wire: `SiteHealthReportDto.secret_mirror_statuses` (field **33**, a presence wrapper so null ≠ empty). Drives the *Secret mirror failing* dashboard badge + per-node failure list. On an unreplicated pair only the active node's own row appears. A node that has not refreshed its row for `SiteRuntime:SecretMirrorStatusStaleAfter` (default 1 h — decommissioned, renamed or down) is left off and its row deleted. |
| `LocalDbOplogBacklog` | Consolidated site LocalDb | Unacked oplog depth (Prometheus `localdb_oplog_depth`). **Nullable — null means "no reading", NOT "connected with an empty backlog"** (a failed poll rendered as 0 would report a pair that cannot read its own oplog as perfectly healthy). **Expected transient spikes:** the daily `site_events` retention purge and the storage-cap trim are CDC-captured like any other write, so the backlog jumps by the batch size at purge time and drains as the peer acks it — see `Component-SiteEventLogging.md` → Storage and `docs/deployment/topology-guide.md` → *Reading the replication backlog* for the healthy-versus-faulty signature. |
**Pending-updates tiles (Gitea #63).** The Health dashboard also shows two central-computed counts — instances out of date and sites with stale artifacts — read from the same process-wide pending-updates cache as the rail indicator (no extra fleet flatten). Visible to Deployer/Administrator only and narrowed to a site-scoped operator's permitted sites. The CLI equivalent is `scadabridge health pending-updates` (`GetPendingUpdates`, Deployer role), which evaluates the fleet directly rather than through the UI cache.
## Reporting Protocol
- Sites send a **health report message** to central at a configurable interval (default: **30 seconds**).
@@ -227,6 +227,7 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
- **GetHealthSummary**: Query current health status of all sites.
- **GetSiteHealth**: Query detailed health for a specific site.
- **GetPendingUpdates**: Fleet-wide pending-updates counts (stale instances / sites with stale artifacts) with a per-site breakdown. Deployer role; site-scoped callers see only their permitted sites (Gitea #63).
### Remote Queries
@@ -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>();
@@ -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],