Files
ScadaBridge/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Layout/PendingUpdatesIndicator.razor
T

382 lines
15 KiB
Plaintext

@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
@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.
Roles and site scope are resolved from the CASCADED authentication state the
router already supplies (AddCascadingAuthenticationState), once per auth state
— never per poll. That matters because this component lives in MainLayout, so
it is on every page of every circuit: note that on this app's
CookieAuthenticationStateProvider BOTH entry points revalidate the session
against the database (they differ only in failClosed, not in whether they
refresh), so the saving is not "use the cheap overload" — it is that the
component never asks the provider at all on a tick, and reuses the principal
the router resolved rather than triggering a refresh of its own. Visibility
still follows the session — it recomputes on AuthenticationStateChanged, so a
mid-session role revocation takes effect immediately instead of persisting for
the life of the circuit. *@
@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" aria-hidden="true"></i> Updates unavailable</span>
}
else if (_snapshot is null)
{
<span class="rail-updates-muted"><i class="bi bi-arrow-repeat" aria-hidden="true"></i> Checking for updates…</span>
}
else if (!_snapshot.HasPendingUpdates)
{
<span class="rail-updates-muted"><i class="bi bi-check2-circle" aria-hidden="true"></i> Up to date</span>
}
else
{
<span class="rail-updates-head">
<i class="bi bi-exclamation-triangle" aria-hidden="true"></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 readonly object _timerLock = new();
private Timer? _timer;
private volatile bool _disposed;
private volatile bool _visible;
private bool _isDeployer;
private bool _isAdministrator;
// null => system-wide (no narrowing). A single reference field rather than a
// (bool, set) pair so the poll thread reads one consistent value even if an
// auth-state change is applying concurrently.
private volatile IReadOnlySet<int>? _scopedSiteIds;
// Result of the provider FALLBACK below, kept so it is resolved at most once per
// auth state instead of on every parameter set.
private ClaimsPrincipal? _fallbackPrincipal;
private PendingUpdatesSnapshot? _snapshot;
private string? _error;
private string? _lastLoggedError;
/// <summary>
/// The ambient authentication state supplied by the app's
/// <c>CascadingAuthenticationState</c> (registered by
/// <c>AddCascadingAuthenticationState</c>, so it is always present in the running
/// app). Preferred over asking the provider because this task is ALREADY resolved
/// for the page: reusing it means this component never triggers a session
/// revalidation of its own, whereas every provider entry point does refresh.
/// </summary>
[CascadingParameter]
private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
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 void OnInitialized()
{
// A mid-session role change (revocation, or a re-login as someone else on
// the same circuit) must move this block, not leave it showing what the
// previous principal was allowed to see.
AuthStateProvider.AuthenticationStateChanged += OnAuthenticationStateChanged;
PendingUpdates.Invalidated += OnPendingUpdatesInvalidated;
}
// Set when the cache reports a finished deployment. A reload that arrives while a poll is
// already running is dropped by the PollGate, so the running poll re-checks this flag and
// goes round once more — otherwise the post-deploy count could wait out a full interval.
private int _reloadRequested;
private void OnPendingUpdatesInvalidated()
{
if (_disposed || !_visible)
{
return;
}
Interlocked.Exchange(ref _reloadRequested, 1);
_ = RunLoadAsync();
}
protected override async Task OnParametersSetAsync()
{
ClaimsPrincipal user;
if (AuthenticationStateTask is not null)
{
user = (await AuthenticationStateTask).User;
}
else
{
// Fallback for a host with no cascade. AddCascadingAuthenticationState() is
// registered, so this is expected to be dead in production — and it is a
// REAL session refresh (non-forced differs only in failClosed), which is why
// it is taken at most once and then reused until the state changes.
_fallbackPrincipal ??= (await AuthStateProvider.GetAuthenticationStateAsync()).User;
user = _fallbackPrincipal;
}
ApplyPrincipal(user);
// MUST be here, not only in OnAfterRenderAsync: the await above can suspend, in
// which case the FIRST render runs with _visible still false and skips the
// OnAfterRender(firstRender: true) start. The render that follows the resolve is
// firstRender: false, so nothing else would ever start the poll and the block
// would sit on "Checking for updates…" for the life of the circuit. Idempotent.
if (_visible)
{
EnsurePollingStarted();
}
}
// Roles and site scope are both pure functions of the principal's claims, so
// one resolution answers "do I render?", "do I link?" and "which sites count?".
private void ApplyPrincipal(ClaimsPrincipal user)
{
// 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. The comparison is
// OrdinalIgnoreCase to match SiteScopeService/ManagementActor.
var roles = 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;
var (isSystemWide, sites) = SiteScopeService.Resolve(user);
_scopedSiteIds = isSystemWide ? null : sites;
}
private void OnAuthenticationStateChanged(Task<AuthenticationState> task)
=> _ = ApplyAuthenticationStateAsync(task);
private async Task ApplyAuthenticationStateAsync(Task<AuthenticationState> task)
{
if (_disposed)
{
return;
}
try
{
var state = await task;
if (_disposed)
{
return;
}
_fallbackPrincipal = state.User;
ApplyPrincipal(state.User);
// A role GRANTED mid-session has to start the poll that first render
// skipped; a role revoked just stops rendering (LoadAsync no-ops).
if (_visible)
{
EnsurePollingStarted();
}
}
catch (Exception ex)
{
// Never let an auth-state notification tear down the circuit: the worst
// case is that this block keeps its previous visibility.
Logger.LogWarning(ex, "Pending-updates indicator could not apply an authentication state change.");
return;
}
await RenderStateAsync();
}
protected override Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender && _visible && !_disposed)
{
EnsurePollingStarted();
}
return Task.CompletedTask;
}
private void EnsurePollingStarted()
{
lock (_timerLock)
{
if (_disposed || _timer is not null)
{
return;
}
_timer = new Timer(_ => _ = RunLoadAsync(), null, PollInterval, PollInterval);
}
// Deliberately NOT awaited and deliberately not done 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. The
// first load goes through the same gate as a tick so nothing can escape as
// an unobserved faulted task.
_ = RunLoadAsync();
}
/// <summary>
/// Test seam: runs one poll cycle exactly as the timer tick does, through the
/// same gate. Exists so a test can pin the load-bearing cost property — that a
/// poll resolves no authentication state — without waiting out the interval.
/// </summary>
internal Task PollOnceAsync() => RunLoadAsync();
private async Task RunLoadAsync()
{
await _pollGate.RunAsync(
LoadAsync,
error => Logger.LogDebug(error, "Pending-updates indicator poll tick failed."));
// A reload requested while that poll was running was dropped by the gate: run once more.
if (!_disposed && Interlocked.Exchange(ref _reloadRequested, 0) == 1)
{
await _pollGate.RunAsync(
LoadAsync,
error => Logger.LogDebug(error, "Pending-updates indicator reload failed."));
}
}
private async Task LoadAsync()
{
if (_disposed || !_visible)
{
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. The scope was
// resolved from the principal, so narrowing costs nothing here.
var scope = _scopedSiteIds;
if (scope is not null)
{
snapshot = snapshot.ForSites(scope);
}
// These writes happen off the renderer's synchronization context by
// design: the fleet read must not occupy the UI thread, PollGate's
// single-flight guarantees there is never a second concurrent writer,
// and only the RE-RENDER is marshalled back via InvokeAsync below.
_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)
{
// Same race, earlier throw site: dispatching to a renderer whose circuit
// is already tearing down throws this before the ObjectDisposedException
// path is reachable.
}
}
public void Dispose()
{
_disposed = true;
AuthStateProvider.AuthenticationStateChanged -= OnAuthenticationStateChanged;
PendingUpdates.Invalidated -= OnPendingUpdatesInvalidated;
lock (_timerLock)
{
_timer?.Dispose();
_timer = null;
}
}
}