feat(central-ui): rail-footer pending-updates indicator

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
This commit is contained in:
Joseph Doherty
2026-09-14 14:00:16 -04:00
parent 5d6bd03868
commit c724ed6ee2
4 changed files with 451 additions and 0 deletions
@@ -19,6 +19,9 @@
<DarkModeToggle />
<AuthorizeView>
<Authorized>
@* Fleet "pending updates" block. Self-gating — it renders nothing
unless the signed-in user holds Deployer or Administrator. *@
<PendingUpdatesIndicator />
@* CentralUI-024: claim type resolved via JwtTokenService. *@
<span class="rail-user">@context.User.GetDisplayName()</span>
<form method="post" action="/auth/logout" data-enhance="false">
@@ -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<PendingUpdatesIndicator> 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)
{
<div class="rail-updates" title="@AsOfCaption">
@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. *@
<span class="rail-updates-muted" title="@_error"><i class="bi bi-question-circle"></i> Updates unavailable</span>
}
else if (_snapshot is null)
{
<span class="rail-updates-muted"><i class="bi bi-arrow-repeat"></i> Checking for updates…</span>
}
else if (!_snapshot.HasPendingUpdates)
{
<span class="rail-updates-muted"><i class="bi bi-check2-circle"></i> Up to date</span>
}
else
{
<span class="rail-updates-head">
<i class="bi bi-exclamation-triangle"></i> Pending updates
<span class="badge bg-warning text-dark">@(StaleInstances + StaleArtifactSites)</span>
</span>
<span class="rail-updates-detail">
@if (StaleInstances > 0)
{
if (_isDeployer)
{
<a href="/deployment/topology">@Plural(StaleInstances, "instance")</a>
}
else
{
<text>@Plural(StaleInstances, "instance")</text>
}
}
@if (StaleInstances > 0 && StaleArtifactSites > 0)
{
<text> · </text>
}
@if (StaleArtifactSites > 0)
{
if (_isAdministrator)
{
<a href="/admin/sites">@Plural(StaleArtifactSites, "site")</a>
}
else
{
<text>@Plural(StaleArtifactSites, "site")</text>
}
}
</span>
}
</div>
}
@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;
}
}
@@ -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); }