From c724ed6ee2f99887447b3269c032f14b9a51d016 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 14 Sep 2026 14:00:16 -0400 Subject: [PATCH] feat(central-ui): rail-footer pending-updates indicator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds PendingUpdatesIndicator to MainLayout's RailFooter, above .rail-user. Self-gating on the Deployer/Administrator role claims, it polls the process-wide single-flight IPendingUpdatesCache every 30s through PollGate and renders four states: checking / up to date / pending (warning pill plus an 'N instances · M sites' breakdown) / unavailable. Each half of the breakdown links only for the role that can act on it (Topology => Deployer, Sites => Administrator), and a site-scoped operator's totals are narrowed through SiteScopeService so they never count a site they cannot see. Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB --- .../Components/Layout/MainLayout.razor | 3 + .../Layout/PendingUpdatesIndicator.razor | 205 ++++++++++++++++ .../wwwroot/css/site.css | 11 + .../Layout/PendingUpdatesIndicatorTests.cs | 232 ++++++++++++++++++ 4 files changed, 451 insertions(+) create mode 100644 src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/PendingUpdatesIndicator.razor create mode 100644 tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor index d3359bda..cfd5c550 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/MainLayout.razor @@ -19,6 +19,9 @@ + @* Fleet "pending updates" block. Self-gating — it renders nothing + unless the signed-in user holds Deployer or Administrator. *@ + @* CentralUI-024: claim type resolved via JwtTokenService. *@ @context.User.GetDisplayName()
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/PendingUpdatesIndicator.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/PendingUpdatesIndicator.razor new file mode 100644 index 00000000..31cf4156 --- /dev/null +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/PendingUpdatesIndicator.razor @@ -0,0 +1,205 @@ +@implements IDisposable +@using ZB.MOM.WW.ScadaBridge.CentralUI.Services +@using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates +@using ZB.MOM.WW.ScadaBridge.Security +@inject IPendingUpdatesCache PendingUpdates +@inject SiteScopeService SiteScope +@inject AuthenticationStateProvider AuthStateProvider +@inject ILogger Logger + +@* Rail-footer "is anything waiting to be deployed?" block (design §3.4). + Rendered only for Deployer/Administrator — the two roles that can act on it — + and quiet by default: one muted line when the fleet is current, two lines with + a warning pill when something is pending. Reads the process-wide single-flight + IPendingUpdatesCache, so N circuits polling here cost at most one fleet + flatten per cache TTL. *@ +@if (_visible) +{ +
+ @if (_error is not null) + { + @* A read failure is shown as a failure, never as a clean bill of health; + the exception message is the tooltip so it stays out of the rail. *@ + Updates unavailable + } + else if (_snapshot is null) + { + Checking for updates… + } + else if (!_snapshot.HasPendingUpdates) + { + Up to date + } + else + { + + Pending updates + @(StaleInstances + StaleArtifactSites) + + + @if (StaleInstances > 0) + { + if (_isDeployer) + { + @Plural(StaleInstances, "instance") + } + else + { + @Plural(StaleInstances, "instance") + } + } + @if (StaleInstances > 0 && StaleArtifactSites > 0) + { + · + } + @if (StaleArtifactSites > 0) + { + if (_isAdministrator) + { + @Plural(StaleArtifactSites, "site") + } + else + { + @Plural(StaleArtifactSites, "site") + } + } + + } +
+} + +@code { + private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30); + + private readonly PollGate _pollGate = new(); + + private Timer? _timer; + private volatile bool _disposed; + + private bool _visible; + private bool _isDeployer; + private bool _isAdministrator; + + private PendingUpdatesSnapshot? _snapshot; + private string? _error; + private string? _lastLoggedError; + + private int StaleInstances => _snapshot?.StaleInstanceCount ?? 0; + + private int StaleArtifactSites => _snapshot?.StaleArtifactSiteCount ?? 0; + + // Tooltip is the snapshot's own instant, so a stalled poll is visible rather + // than silently passing off an old reading as current. Null (⇒ no attribute) + // until the first snapshot lands. + private string? AsOfCaption => _snapshot is null + ? null + : $"as of {_snapshot.ComputedAt.UtcDateTime:HH:mm} UTC"; + + // "1 instance" / "3 instances" — the counts are read at a glance, so the + // noun has to agree with them. + private static string Plural(int count, string noun) + => count == 1 ? $"{count} {noun}" : $"{count} {noun}s"; + + protected override async Task OnInitializedAsync() + { + // Role gating is a plain claim check rather than an AuthorizeView: the whole + // component is hidden, and the two breakdown links are gated independently, + // so there is no policy to evaluate — just two booleans resolved once. + var state = await AuthStateProvider.GetAuthoritativeStateAsync(); + var roles = state.User.FindAll(JwtTokenService.RoleClaimType).ToList(); + + _isDeployer = roles.Any(c => string.Equals(c.Value, Roles.Deployer, StringComparison.OrdinalIgnoreCase)); + _isAdministrator = roles.Any(c => string.Equals(c.Value, Roles.Administrator, StringComparison.OrdinalIgnoreCase)); + _visible = _isDeployer || _isAdministrator; + } + + protected override Task OnAfterRenderAsync(bool firstRender) + { + if (!firstRender || !_visible || _disposed) + { + return Task.CompletedTask; + } + + // Deliberately NOT awaited here and deliberately not in OnInitializedAsync: + // a cold fleet flatten takes seconds, and this component sits in the layout — + // awaiting it would hold the first render of every page behind it. The rail + // paints "Checking for updates…" and fills in when the snapshot lands. + _ = LoadAsync(); + + _timer = new Timer( + _ => _ = _pollGate.RunAsync( + LoadAsync, + error => Logger.LogDebug(error, "Pending-updates indicator poll tick failed.")), + null, + PollInterval, + PollInterval); + + return Task.CompletedTask; + } + + private async Task LoadAsync() + { + if (_disposed) + { + return; + } + + try + { + var snapshot = await PendingUpdates.GetAsync(); + + // A site-scoped operator's totals must never include a site they cannot + // see — same rule Topology and the Sites page apply. + if (!await SiteScope.IsSystemWideAsync()) + { + snapshot = snapshot.ForSites(await SiteScope.PermittedSiteIdsAsync()); + } + + _snapshot = snapshot; + _error = null; + _lastLoggedError = null; + } + catch (Exception ex) + { + // Log on the TRANSITION only: a site that stays unreachable would + // otherwise write one Warning every 30s per open circuit. + if (!string.Equals(_lastLoggedError, ex.Message, StringComparison.Ordinal)) + { + _lastLoggedError = ex.Message; + Logger.LogWarning(ex, "Pending-updates indicator could not read the fleet snapshot."); + } + + _error = ex.Message; + } + + await RenderStateAsync(); + } + + private async Task RenderStateAsync() + { + if (_disposed) + { + return; + } + + try + { + await InvokeAsync(StateHasChanged); + } + catch (ObjectDisposedException) + { + // Circuit torn down between the _disposed check and the dispatch. + } + catch (InvalidOperationException) + { + // Renderer already disposed — same race, different throw site. + } + } + + public void Dispose() + { + _disposed = true; + _timer?.Dispose(); + _timer = null; + } +} diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css index 83d2595c..0f3d2986 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/wwwroot/css/site.css @@ -191,3 +191,14 @@ overscroll-behavior: contain; } } + +/* ── Rail pending-updates indicator ────────────────────────────────────── + Sits in .rail-foot just above .rail-user. One line when quiet, two when + something is pending; tokens only so dark mode follows for free. */ +.rail-updates { display: flex; flex-direction: column; gap: 0.15rem; padding: 0 0.6rem 0.5rem; font-size: 0.8rem; line-height: 1.3; } +.rail-updates-muted { color: var(--ink-faint); } +.rail-updates-head { display: flex; align-items: center; gap: 0.35rem; font-weight: 600; color: var(--ink-soft); } +.rail-updates-head .badge { font-size: 0.7rem; } +.rail-updates-detail { color: var(--ink-faint); } +.rail-updates a { color: inherit; text-decoration: underline dotted; } +.rail-updates a:hover { color: var(--accent); } diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs new file mode 100644 index 00000000..ba2f4c82 --- /dev/null +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Layout/PendingUpdatesIndicatorTests.cs @@ -0,0 +1,232 @@ +using System.Security.Claims; +using Bunit; +using Microsoft.AspNetCore.Components.Authorization; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using NSubstitute; +using ZB.MOM.WW.ScadaBridge.CentralUI.Auth; +using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Layout; +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.Layout; + +/// +/// bUnit rendering tests for , the rail-footer +/// "is anything waiting to be deployed?" block. Three behaviours are load-bearing and +/// pinned here: it is invisible to anyone who is neither Deployer nor Administrator +/// (it advertises fleet state), its two breakdown links are gated independently by the +/// role that can actually act on each (Topology ⇒ Deployer, Sites ⇒ Administrator), and +/// a site-scoped operator's totals count only their permitted sites. +/// +public class PendingUpdatesIndicatorTests : BunitContext +{ + private static Claim Role(string role) => new(JwtTokenService.RoleClaimType, role); + + private static Claim SiteClaim(int siteId) => new(JwtTokenService.SiteIdClaimType, siteId.ToString()); + + private static SitePendingUpdates SiteRow( + int siteId, + int staleInstances, + ArtifactDeploymentState artifacts = ArtifactDeploymentState.Current) + => new( + siteId, + $"SITE-{siteId}", + $"Site {siteId}", + artifacts, + DateTimeOffset.UtcNow, + DeployedInstanceCount: 10, + StaleInstanceCount: staleInstances, + UnknownInstanceCount: 0); + + private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites) + => new(DateTimeOffset.UtcNow, sites); + + /// Builds a cache substitute answering every GetAsync with the given snapshot. + private static IPendingUpdatesCache CacheReturning(PendingUpdatesSnapshot snapshot) + { + var cache = Substitute.For(); + cache.GetAsync(Arg.Any(), Arg.Any()).Returns(Task.FromResult(snapshot)); + return cache; + } + + /// Builds a cache substitute whose every GetAsync faults. + private static IPendingUpdatesCache CacheFaulting(string message) + { + var cache = Substitute.For(); + cache.GetAsync(Arg.Any(), Arg.Any()) + .Returns(Task.FromException(new InvalidOperationException(message))); + return cache; + } + + private IRenderedComponent RenderFor(IPendingUpdatesCache cache, params Claim[] claims) + { + // "TestAuth" authentication type => IsAuthenticated == true, so SiteScopeService + // collects the SiteId claims instead of short-circuiting to "no scope". + var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth")); + Services.AddSingleton(new TestAuthStateProvider(user)); + Services.AddScoped(); + Services.AddSingleton(cache); + Services.AddSingleton>(NullLogger.Instance); + + return Render(); + } + + [Fact] + public void Viewer_RendersNothing() + { + var cut = RenderFor(CacheReturning(Snapshot(SiteRow(1, staleInstances: 3))), Role("Viewer")); + + Assert.True(string.IsNullOrWhiteSpace(cut.Markup)); + } + + [Fact] + public void Deployer_WithNothingPending_ShowsUpToDate() + { + var cut = RenderFor( + CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))), + Role(Roles.Deployer)); + + cut.WaitForAssertion(() => + { + Assert.Contains("Up to date", cut.Markup); + Assert.DoesNotContain("Pending updates", cut.Markup); + Assert.DoesNotContain("Checking for updates", cut.Markup); + }); + } + + [Fact] + public void Snapshot_IsCaptionedWithItsComputedInstant() + { + var cut = RenderFor( + CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))), + Role(Roles.Deployer)); + + cut.WaitForAssertion(() => + { + // The block's tooltip is the snapshot's as-of time, not "now". + Assert.Contains("as of ", cut.Markup); + Assert.Contains(" UTC", cut.Markup); + }); + } + + [Fact] + public void DeployerAndAdministrator_WithPendingWork_ShowsTotalAndBothLinks() + { + // 3 stale instances at site 1 + site 2 running out-of-date artifacts => total 4. + var cut = RenderFor( + CacheReturning(Snapshot( + SiteRow(1, staleInstances: 3), + SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))), + Role(Roles.Deployer), + Role(Roles.Administrator)); + + cut.WaitForAssertion(() => + { + Assert.Contains("Pending updates", cut.Markup); + var badge = cut.Find("span.badge"); + Assert.Equal("4", badge.TextContent.Trim()); + + Assert.Contains("3 instances", cut.Markup); + Assert.Contains("1 site", cut.Markup); + Assert.DoesNotContain("1 sites", cut.Markup); + // Both halves present => they are separated, not run together. + Assert.Contains("·", cut.Find("span.rail-updates-detail").TextContent); + + Assert.NotNull(cut.Find("a[href='/deployment/topology']")); + Assert.NotNull(cut.Find("a[href='/admin/sites']")); + }); + } + + [Fact] + public void DeployerWithoutAdministrator_ShowsSiteCountAsPlainText() + { + var cut = RenderFor( + CacheReturning(Snapshot( + SiteRow(1, staleInstances: 1), + SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))), + Role(Roles.Deployer)); + + cut.WaitForAssertion(() => + { + // Singular noun for a count of one, and the instance half still links. + Assert.Contains("1 instance", cut.Markup); + Assert.DoesNotContain("1 instances", cut.Markup); + Assert.NotNull(cut.Find("a[href='/deployment/topology']")); + + // Administrator-only destination is not offered to a plain Deployer. + Assert.Contains("1 site", cut.Markup); + Assert.Empty(cut.FindAll("a[href='/admin/sites']")); + }); + } + + [Fact] + public void AdministratorWithoutDeployer_ShowsInstanceCountAsPlainText() + { + var cut = RenderFor( + CacheReturning(Snapshot( + SiteRow(1, staleInstances: 2), + SiteRow(2, staleInstances: 0, ArtifactDeploymentState.OutOfDate))), + Role(Roles.Administrator)); + + cut.WaitForAssertion(() => + { + Assert.Contains("2 instances", cut.Markup); + Assert.Empty(cut.FindAll("a[href='/deployment/topology']")); + Assert.NotNull(cut.Find("a[href='/admin/sites']")); + }); + } + + [Fact] + public void CacheFault_ShowsUnavailableWithTheMessageAsTooltip() + { + var cut = RenderFor(CacheFaulting("evaluator exploded"), Role(Roles.Deployer)); + + cut.WaitForAssertion(() => + { + Assert.Contains("Updates unavailable", cut.Markup); + Assert.Contains("evaluator exploded", cut.Markup); + // A failed read must never be rendered as a clean bill of health. + Assert.DoesNotContain("Up to date", cut.Markup); + }); + } + + [Fact] + public void SiteScopedDeployer_CountsOnlyPermittedSites() + { + // Permitted site 1 has 3 stale instances; site 2 (out of scope) has 5 and + // out-of-date artifacts. Only site 1's tally may reach the badge. + var cut = RenderFor( + CacheReturning(Snapshot( + SiteRow(1, staleInstances: 3), + SiteRow(2, staleInstances: 5, ArtifactDeploymentState.OutOfDate))), + Role(Roles.Deployer), + SiteClaim(1)); + + cut.WaitForAssertion(() => + { + var badge = cut.Find("span.badge"); + Assert.Equal("3", badge.TextContent.Trim()); + Assert.Contains("3 instances", cut.Markup); + // Unscoped, the same snapshot would read "8 instances · 1 site". + Assert.DoesNotContain("8 instances", cut.Markup); + Assert.DoesNotContain("1 site", cut.Markup); + }); + } + + [Fact] + public void Dispose_AfterRender_DoesNotThrow() + { + var cut = RenderFor( + CacheReturning(Snapshot(SiteRow(1, staleInstances: 0))), + Role(Roles.Deployer)); + + cut.WaitForAssertion(() => Assert.Contains("Up to date", cut.Markup)); + + var instance = cut.Instance; + instance.Dispose(); + instance.Dispose(); + } +}