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:
@@ -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); }
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// bUnit rendering tests for <see cref="PendingUpdatesIndicator"/>, 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.
|
||||
/// </summary>
|
||||
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);
|
||||
|
||||
/// <summary>Builds a cache substitute answering every <c>GetAsync</c> with the given snapshot.</summary>
|
||||
private static IPendingUpdatesCache CacheReturning(PendingUpdatesSnapshot snapshot)
|
||||
{
|
||||
var cache = Substitute.For<IPendingUpdatesCache>();
|
||||
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(snapshot));
|
||||
return cache;
|
||||
}
|
||||
|
||||
/// <summary>Builds a cache substitute whose every <c>GetAsync</c> faults.</summary>
|
||||
private static IPendingUpdatesCache CacheFaulting(string message)
|
||||
{
|
||||
var cache = Substitute.For<IPendingUpdatesCache>();
|
||||
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<PendingUpdatesSnapshot>(new InvalidOperationException(message)));
|
||||
return cache;
|
||||
}
|
||||
|
||||
private IRenderedComponent<PendingUpdatesIndicator> 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<AuthenticationStateProvider>(new TestAuthStateProvider(user));
|
||||
Services.AddScoped<SiteScopeService>();
|
||||
Services.AddSingleton(cache);
|
||||
Services.AddSingleton<ILogger<PendingUpdatesIndicator>>(NullLogger<PendingUpdatesIndicator>.Instance);
|
||||
|
||||
return Render<PendingUpdatesIndicator>();
|
||||
}
|
||||
|
||||
[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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user