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:
@@ -19,6 +19,7 @@ public static class HealthCommands
|
||||
var command = new Command("health") { Description = "Health monitoring" };
|
||||
|
||||
command.Add(BuildSummary(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildPendingUpdates(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildSite(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildEventLog(urlOption, formatOption, usernameOption, passwordOption));
|
||||
command.Add(BuildParkedMessages(urlOption, formatOption, usernameOption, passwordOption));
|
||||
@@ -37,6 +38,20 @@ public static class HealthCommands
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildPendingUpdates(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var cmd = new Command("pending-updates")
|
||||
{
|
||||
Description = "Get fleet-wide pending-updates counts (stale instances, sites with stale artifacts)"
|
||||
};
|
||||
cmd.SetAction(async (ParseResult result) =>
|
||||
{
|
||||
return await CommandHelpers.ExecuteCommandAsync(
|
||||
result, urlOption, formatOption, usernameOption, passwordOption, new GetPendingUpdatesCommand());
|
||||
});
|
||||
return cmd;
|
||||
}
|
||||
|
||||
private static Command BuildSite(Option<string> urlOption, Option<string> formatOption, Option<string> usernameOption, Option<string> passwordOption)
|
||||
{
|
||||
var identifierOption = new Option<string>("--identifier") { Description = "Site identifier", Required = true };
|
||||
|
||||
@@ -1810,6 +1810,14 @@ Return the current health state for all known sites as a JSON object keyed by si
|
||||
scadabridge --url <url> health summary
|
||||
```
|
||||
|
||||
#### `health pending-updates`
|
||||
|
||||
Return the fleet-wide pending-updates counts — instances whose deployed configuration has drifted from their current flatten, and sites running out-of-date system-wide artifacts — with a per-site breakdown. Requires the Deployer role; a site-scoped caller sees only their permitted sites. Each call evaluates the fleet (the Central UI's cached read is not shared with the CLI), so poll it at a scripting cadence, not a tight loop.
|
||||
|
||||
```sh
|
||||
scadabridge --url <url> health pending-updates
|
||||
```
|
||||
|
||||
#### `health site`
|
||||
|
||||
Return the health state for a single site.
|
||||
|
||||
+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">
|
||||
|
||||
@@ -2,3 +2,10 @@ namespace ZB.MOM.WW.ScadaBridge.Commons.Messages.Management;
|
||||
|
||||
public record GetHealthSummaryCommand;
|
||||
public record GetSiteHealthCommand(string SiteIdentifier);
|
||||
|
||||
/// <summary>
|
||||
/// Fleet-wide "is anything waiting to be deployed?" read (Gitea #63): stale-instance and
|
||||
/// stale-artifact-site counts plus a per-site breakdown. Deployer-gated and narrowed to a
|
||||
/// site-scoped caller's permitted sites, like the Central UI rail indicator.
|
||||
/// </summary>
|
||||
public record GetPendingUpdatesCommand;
|
||||
|
||||
@@ -32,6 +32,7 @@ using ZB.MOM.WW.ScadaBridge.Commons.Types.InboundApi;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Types.Transport;
|
||||
using ZB.MOM.WW.ScadaBridge.Commons.Validation;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
|
||||
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
|
||||
using ZB.MOM.WW.ScadaBridge.HealthMonitoring;
|
||||
using ZB.MOM.WW.ScadaBridge.Communication;
|
||||
using ZB.MOM.WW.ScadaBridge.Security;
|
||||
@@ -354,6 +355,7 @@ public class ManagementActor : ReceiveActor
|
||||
or GetDeploymentDiffCommand
|
||||
or MgmtDeployArtifactsCommand
|
||||
or QueryDeploymentsCommand
|
||||
or GetPendingUpdatesCommand
|
||||
or RetryParkedMessageCommand or DiscardParkedMessageCommand
|
||||
or DebugSnapshotCommand => DeployerOnly,
|
||||
|
||||
@@ -550,6 +552,7 @@ public class ManagementActor : ReceiveActor
|
||||
// Health
|
||||
GetHealthSummaryCommand => HandleGetHealthSummary(sp),
|
||||
GetSiteHealthCommand cmd => HandleGetSiteHealth(sp, cmd),
|
||||
GetPendingUpdatesCommand => await HandleGetPendingUpdates(sp, user),
|
||||
|
||||
// Remote Queries
|
||||
QueryEventLogsCommand cmd => await HandleQueryEventLogs(sp, cmd, user),
|
||||
@@ -3174,6 +3177,45 @@ public class ManagementActor : ReceiveActor
|
||||
return aggregator.GetAllSiteStates();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pending-updates counts (Gitea #63). Reads the evaluator directly — the Central UI's
|
||||
/// process-wide TTL cache lives in the CentralUI project, which this project cannot
|
||||
/// reference — so each call is one fleet flatten; fine for a CLI/scripting cadence.
|
||||
/// A site-scoped, non-Administrator caller sees only their permitted sites, so the
|
||||
/// totals never include a site they cannot see.
|
||||
/// </summary>
|
||||
private static async Task<object?> HandleGetPendingUpdates(IServiceProvider sp, AuthenticatedUser user)
|
||||
{
|
||||
var evaluator = sp.GetRequiredService<IPendingUpdatesEvaluator>();
|
||||
var snapshot = await evaluator.EvaluateAsync();
|
||||
if (user.PermittedSiteIds.Length > 0 && !user.Roles.Contains(Roles.Administrator, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var permitted = new HashSet<string>(user.PermittedSiteIds);
|
||||
snapshot = snapshot.ForSites(snapshot.Sites
|
||||
.Where(s => permitted.Contains(s.SiteId.ToString()))
|
||||
.Select(s => s.SiteId).ToHashSet());
|
||||
}
|
||||
|
||||
return new
|
||||
{
|
||||
computedAt = snapshot.ComputedAt,
|
||||
hasPendingUpdates = snapshot.HasPendingUpdates,
|
||||
staleInstanceCount = snapshot.StaleInstanceCount,
|
||||
staleArtifactSiteCount = snapshot.StaleArtifactSiteCount,
|
||||
sites = snapshot.Sites.Select(s => new
|
||||
{
|
||||
siteId = s.SiteId,
|
||||
siteIdentifier = s.SiteIdentifier,
|
||||
siteName = s.SiteName,
|
||||
artifactState = s.ArtifactState.ToString(),
|
||||
artifactsDeployedAt = s.ArtifactsDeployedAt,
|
||||
deployedInstanceCount = s.DeployedInstanceCount,
|
||||
staleInstanceCount = s.StaleInstanceCount,
|
||||
unknownInstanceCount = s.UnknownInstanceCount,
|
||||
}).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static object? HandleGetSiteHealth(IServiceProvider sp, GetSiteHealthCommand cmd)
|
||||
{
|
||||
var aggregator = sp.GetRequiredService<ICentralHealthAggregator>();
|
||||
|
||||
Reference in New Issue
Block a user