+ @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;
+
+///