diff --git a/docs/components/CLI.md b/docs/components/CLI.md index 5ab38780..38439778 100644 --- a/docs/components/CLI.md +++ b/docs/components/CLI.md @@ -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 diff --git a/docs/components/ManagementService.md b/docs/components/ManagementService.md index 834ebdb2..e6d76237 100644 --- a/docs/components/ManagementService.md +++ b/docs/components/ManagementService.md @@ -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 | diff --git a/docs/requirements/Component-CLI.md b/docs/requirements/Component-CLI.md index 10991605..0e68bb87 100644 --- a/docs/requirements/Component-CLI.md +++ b/docs/requirements/Component-CLI.md @@ -346,6 +346,7 @@ scadabridge audit verify-chain --month ### Health Commands ``` scadabridge health summary +scadabridge health pending-updates scadabridge health site --identifier scadabridge health event-log --site [--event-type ] [--severity ] [--keyword ] [--from ] [--to ] [--page ] [--page-size ] [--instance-name ] scadabridge health parked-messages --site [--page ] [--page-size ] diff --git a/docs/requirements/Component-HealthMonitoring.md b/docs/requirements/Component-HealthMonitoring.md index 092ce097..03c728b7 100644 --- a/docs/requirements/Component-HealthMonitoring.md +++ b/docs/requirements/Component-HealthMonitoring.md @@ -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**). diff --git a/docs/requirements/Component-ManagementService.md b/docs/requirements/Component-ManagementService.md index 56950f98..e343e359 100644 --- a/docs/requirements/Component-ManagementService.md +++ b/docs/requirements/Component-ManagementService.md @@ -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 diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/HealthCommands.cs b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/HealthCommands.cs index 770435bb..0962cbae 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/HealthCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/Commands/HealthCommands.cs @@ -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 urlOption, Option formatOption, Option usernameOption, Option 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 urlOption, Option formatOption, Option usernameOption, Option passwordOption) { var identifierOption = new Option("--identifier") { Description = "Site identifier", Required = true }; diff --git a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md index 3dcdf534..2fcdd81b 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CLI/README.md +++ b/src/ZB.MOM.WW.ScadaBridge.CLI/README.md @@ -1810,6 +1810,14 @@ Return the current health state for all known sites as a JSON object keyed by si scadabridge --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 health pending-updates +``` + #### `health site` Return the health state for a single site. diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/PendingUpdatesHealthTiles.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/PendingUpdatesHealthTiles.razor new file mode 100644 index 00000000..b8d5204e --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Health/PendingUpdatesHealthTiles.razor @@ -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) +{ +
+
+
+
Pending Updates
+ @if (_snapshot is not null) + { + as of @_snapshot.ComputedAt.UtcDateTime.ToString("HH:mm") UTC + } +
+
+
+
+
+

@Display(StaleInstances)

+ Instances out of date +
+
+
+
+
+
+

@Display(StaleArtifactSites)

+ Sites with stale artifacts +
+
+
+
+ @if (_error is not null) + { +
Pending-updates counts unavailable: @_error
+ } +
+
+} + +@code { + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); + + private Timer? _timer; + private volatile bool _disposed; + private bool _visible; + private IReadOnlySet? _scopedSiteIds; + private PendingUpdatesSnapshot? _snapshot; + private string? _error; + + [CascadingParameter] private Task? 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(); + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor index 748e4aab..22d8b66f 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Monitoring/Health.razor @@ -92,6 +92,10 @@ + @* Gitea #63 — fleet-wide pending-updates counts (Deployer/Administrator only; the + component gates itself). *@ + + @* Three KPI tiles for the Audit channel (volume / error rate / backlog). Refreshed alongside the site states. *@
diff --git a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/HealthCommands.cs b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/HealthCommands.cs index a9bb024a..a8e8fe58 100644 --- a/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/HealthCommands.cs +++ b/src/ZB.MOM.WW.ScadaBridge.Commons/Messages/Management/HealthCommands.cs @@ -2,3 +2,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management; public record GetHealthSummaryCommand; public record GetSiteHealthCommand(string SiteIdentifier); + +/// +/// 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. +/// +public record GetPendingUpdatesCommand; diff --git a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs index d21b2e14..de28e0af 100644 --- a/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs +++ b/src/ZB.MOM.WW.ScadaBridge.ManagementService/ManagementActor.cs @@ -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(); } + /// + /// 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. + /// + private static async Task HandleGetPendingUpdates(IServiceProvider sp, AuthenticatedUser user) + { + var evaluator = sp.GetRequiredService(); + var snapshot = await evaluator.EvaluateAsync(); + if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase)) + { + var permitted = new HashSet(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(); diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/PendingUpdatesHealthTilesTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/PendingUpdatesHealthTilesTests.cs new file mode 100644 index 00000000..03eac36a --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Monitoring/PendingUpdatesHealthTilesTests.cs @@ -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; + +/// Gitea #63: pending-updates tiles on the Health dashboard. +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 RenderFor(IPendingUpdatesCache cache, params Claim[] claims) + { + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddScoped(); + Services.AddSingleton(cache); + return Render(ps => + ps.AddCascadingValue(Task.FromResult(new AuthenticationState(user)))); + } + + private static IPendingUpdatesCache CacheReturning(params SitePendingUpdates[] rows) + { + var cache = Substitute.For(); + cache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, rows))); + return cache; + } + + private static Claim Role(string r) => new(JwtTokenService.RoleClaimType, r); + + /// A Deployer sees both counts, highlighted when non-zero. + [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); + }); + } + + /// A Viewer sees nothing and triggers no fleet read. + [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(), Arg.Any()); + } + + /// A site-scoped Deployer's totals exclude sites outside the scope. + [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())); + } + + /// A failed read is shown as unavailable, never as zero. + [Fact] + public void ReadFailure_ShowsUnavailable_NotZero() + { + var cache = Substitute.For(); + cache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(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()); + }); + } + + /// An invalidation re-reads the cache. + [Fact] + public void Invalidated_Rereads() + { + var cache = Substitute.For(); + cache.GetAsync(Arg.Any(), Arg.Any()).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(); + + cut.WaitForAssertion(() => Assert.Equal("0", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim())); + } +} diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs index 0e6c58b2..437c11a9 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Pages/HealthPageTests.cs @@ -133,6 +133,16 @@ public class HealthPageTests : BunitContext // control itself is covered by HealthFailoverButtonTests. Services.AddSingleton(Substitute.For()); Services.AddSingleton(Substitute.For()); + + // 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(); + var pending = Substitute.For(); + pending.GetAsync(Arg.Any(), Arg.Any()).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); } /// @@ -649,6 +659,52 @@ public class HealthPageTests : BunitContext Assert.Equal(expectBadge, cut.Markup.Contains("data-test=\"audit-rejected-badge\"")); } + /// Gitea #88: the three site write-failure badges render on the page only when their counter is above zero. + /// Event-log write failures. + /// Audit write failures. + /// Audit redaction failures. + [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\"")); + } + + /// Gitea #63: the dashboard shows the pending-updates counts for an Administrator. + [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()); + }); + } + /// Verifies that no secret mirror warning renders when no node is failing. /// Whether the site reports secret mirror status at all. [Theory] diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs index 3182fd66..6d8fdcbf 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/ManagementActorTests.cs @@ -1848,6 +1848,63 @@ public class ManagementActorTests : TestKit, IDisposable Assert.Equal(envelope.CorrelationId, response.CorrelationId); } + private void RegisterPendingUpdates() + { + var evaluator = Substitute.For(); + evaluator.EvaluateAsync(Arg.Any()).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); + } + + /// Gitea #63: a system-wide Deployer gets fleet totals. + [Fact] + public void GetPendingUpdates_SystemWideDeployer_ReturnsFleetTotals() + { + RegisterPendingUpdates(); + var actor = CreateActor(); + + actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer")); + + var response = ExpectMsg(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()); + } + + /// Gitea #63: a site-scoped Deployer's totals exclude other sites. + [Fact] + public void GetPendingUpdates_SiteScopedDeployer_SeesOnlyPermittedSites() + { + RegisterPendingUpdates(); + var actor = CreateActor(); + + actor.Tell(ScopedEnvelope(new GetPendingUpdatesCommand(), new[] { "1" }, "Deployer")); + + var response = ExpectMsg(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()); + } + + /// Gitea #63: a non-Deployer is refused. + [Fact] + public void GetPendingUpdates_Viewer_IsUnauthorized() + { + RegisterPendingUpdates(); + var actor = CreateActor(); + + actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Viewer")); + + ExpectMsg(TimeSpan.FromSeconds(5)); + } + /// Verifies that query parked messages, when out of scope for site scoped user, returns unauthorized. [Fact] public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized() diff --git a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs index 02051cc1..64e0461c 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.ManagementService.Tests/RequiredRoleMatrixTests.cs @@ -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],