382 lines
15 KiB
Plaintext
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;
|
|
}
|
|
}
|
|
}
|