feat(health): surface pending-updates counts on the Health dashboard and CLI (ScadaBridge#63)
Health page gains PendingUpdatesHealthTiles (stale instances / sites with stale artifacts, Deployer+Administrator only, site-scope narrowed, shared cache). New GetPendingUpdates management command (Deployer) and 'health pending-updates' CLI leaf. Flatten/cache computation untouched; the command reads IPendingUpdatesEvaluator directly because the UI cache is not reachable from ManagementService. Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
This commit is contained in:
+142
@@ -0,0 +1,142 @@
|
||||
@namespace ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health
|
||||
@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
|
||||
|
||||
@* Gitea #63: pending-updates KPI tiles on the Health dashboard. Two counts read from the
|
||||
same process-wide IPendingUpdatesCache the rail indicator and Sites page use, so this
|
||||
costs no extra fleet flatten. Visible only to Deployer/Administrator (the roles that can
|
||||
act on it, same gate as the rail indicator) and narrowed to a site-scoped operator's
|
||||
permitted sites. A read failure is shown as unavailable, never as a clean "0". *@
|
||||
@if (_visible)
|
||||
{
|
||||
<div class="card mb-3" data-test="pending-updates-tiles">
|
||||
<div class="card-body pb-0">
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<h6 class="text-muted mb-0">Pending Updates</h6>
|
||||
@if (_snapshot is not null)
|
||||
{
|
||||
<small class="text-muted">as of @_snapshot.ComputedAt.UtcDateTime.ToString("HH:mm") UTC</small>
|
||||
}
|
||||
</div>
|
||||
<div class="row g-3 mb-3">
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="card h-100 @(StaleInstances > 0 ? "border-warning" : "")" data-test="pending-updates-instances">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @(StaleInstances > 0 ? "text-warning" : "")">@Display(StaleInstances)</h3>
|
||||
<small class="text-muted">Instances out of date</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-6 col-12">
|
||||
<div class="card h-100 @(StaleArtifactSites > 0 ? "border-warning" : "")" data-test="pending-updates-artifact-sites">
|
||||
<div class="card-body text-center">
|
||||
<h3 class="mb-0 @(StaleArtifactSites > 0 ? "text-warning" : "")">@Display(StaleArtifactSites)</h3>
|
||||
<small class="text-muted">Sites with stale artifacts</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@if (_error is not null)
|
||||
{
|
||||
<div class="text-muted small mb-3" data-test="pending-updates-error">Pending-updates counts unavailable: @_error</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@code {
|
||||
private static readonly TimeSpan PollInterval = TimeSpan.FromSeconds(30);
|
||||
|
||||
private Timer? _timer;
|
||||
private volatile bool _disposed;
|
||||
private bool _visible;
|
||||
private IReadOnlySet<int>? _scopedSiteIds;
|
||||
private PendingUpdatesSnapshot? _snapshot;
|
||||
private string? _error;
|
||||
|
||||
[CascadingParameter] private Task<AuthenticationState>? AuthenticationStateTask { get; set; }
|
||||
|
||||
private int StaleInstances => _snapshot?.StaleInstanceCount ?? 0;
|
||||
private int StaleArtifactSites => _snapshot?.StaleArtifactSiteCount ?? 0;
|
||||
|
||||
// "—" until the first snapshot lands or when the read failed: never a fake zero.
|
||||
private string Display(int count) => _snapshot is null ? "—" : count.ToString();
|
||||
|
||||
protected override async Task OnInitializedAsync()
|
||||
{
|
||||
if (AuthenticationStateTask is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var user = (await AuthenticationStateTask).User;
|
||||
var roles = user.FindAll(JwtTokenService.RoleClaimType).Select(c => c.Value).ToList();
|
||||
_visible = roles.Any(r => string.Equals(r, Roles.Deployer, StringComparison.OrdinalIgnoreCase)
|
||||
|| string.Equals(r, Roles.Administrator, StringComparison.OrdinalIgnoreCase));
|
||||
if (!_visible)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var (isSystemWide, sites) = SiteScopeService.Resolve(user);
|
||||
_scopedSiteIds = isSystemWide ? null : sites;
|
||||
|
||||
PendingUpdates.Invalidated += OnInvalidated;
|
||||
await LoadAsync();
|
||||
_timer = new Timer(_ => _ = ReloadAsync(), null, PollInterval, PollInterval);
|
||||
}
|
||||
|
||||
private void OnInvalidated() => _ = ReloadAsync();
|
||||
|
||||
private async Task ReloadAsync()
|
||||
{
|
||||
if (_disposed)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await LoadAsync();
|
||||
try
|
||||
{
|
||||
await InvokeAsync(StateHasChanged);
|
||||
}
|
||||
catch (Exception ex) when (ex is ObjectDisposedException or InvalidOperationException)
|
||||
{
|
||||
// Circuit tearing down.
|
||||
}
|
||||
}
|
||||
|
||||
private async Task LoadAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
var snapshot = await PendingUpdates.GetAsync();
|
||||
if (_scopedSiteIds is { } scope)
|
||||
{
|
||||
snapshot = snapshot.ForSites(scope);
|
||||
}
|
||||
|
||||
_snapshot = snapshot;
|
||||
_error = null;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_error = ex.Message;
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_disposed = true;
|
||||
if (_visible)
|
||||
{
|
||||
PendingUpdates.Invalidated -= OnInvalidated;
|
||||
}
|
||||
|
||||
_timer?.Dispose();
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,10 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@* Gitea #63 — fleet-wide pending-updates counts (Deployer/Administrator only; the
|
||||
component gates itself). *@
|
||||
<PendingUpdatesHealthTiles />
|
||||
|
||||
@* Three KPI tiles for the Audit channel
|
||||
(volume / error rate / backlog). Refreshed alongside the site states. *@
|
||||
<div class="card mb-3">
|
||||
|
||||
Reference in New Issue
Block a user