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:
@@ -122,7 +122,7 @@ scadabridge instance deploy --id 7
|
||||
| `bundle` | `export`, `preview`, `import` | Designer export; Administrator preview/import |
|
||||
| `audit` | `query`, `export`, `tree`, `backfill-source-node`, `verify-chain` | Audit roles; Administrator backfill; verify-chain is a local placeholder |
|
||||
| `audit-config` | `query` (config-change audit trail; was `audit-log` pre-M8) | `Admin` |
|
||||
| `health` | `summary`, `site`, `event-log`, `parked-messages` | Authenticated reads with server-side scope checks |
|
||||
| `health` | `summary`, `pending-updates`, `site`, `event-log`, `parked-messages` | Authenticated reads with server-side scope checks |
|
||||
| `debug` | `snapshot`, `stream` | `Deployer` |
|
||||
|
||||
### Selected examples
|
||||
|
||||
@@ -179,7 +179,7 @@ There is **no** out-of-process actor path. The actor was advertised via `Cluster
|
||||
| Database connections | `ListDatabaseConnections`, `GetDatabaseConnection`, `CreateDatabaseConnectionDef`, `UpdateDatabaseConnectionDef`, `DeleteDatabaseConnectionDef` | Designer (mutations) |
|
||||
| Inbound API methods | `ListApiMethods`, `GetApiMethod`, `CreateApiMethod`, `UpdateApiMethod`, `DeleteApiMethod` | Designer (mutations) |
|
||||
| Security | `ListRoleMappings`, `CreateRoleMapping`, `UpdateRoleMapping`, `DeleteRoleMapping`, `ListApiKeys`, `CreateApiKey`, `UpdateApiKey`, `DeleteApiKey`, `SetApiKeyMethods`, `ListScopeRules`, `AddScopeRule`, `DeleteScopeRule` | Administrator |
|
||||
| Deployments | `MgmtDeployArtifacts`, `QueryDeployments`, `GetDeploymentDiff` | Deployer |
|
||||
| Deployments | `MgmtDeployArtifacts`, `QueryDeployments`, `GetDeploymentDiff`, `GetPendingUpdates` | Deployer |
|
||||
| Health | `GetHealthSummary`, `GetSiteHealth` | Any authenticated user |
|
||||
| Remote queries | `QueryEventLogsCommand`, `QueryParkedMessagesCommand` (any authenticated user); `RetryParkedMessageCommand`, `DiscardParkedMessageCommand`, `DebugSnapshotCommand` (Deployer) | Varies |
|
||||
| Audit (legacy) | `QueryAuditLog` | Administrator |
|
||||
|
||||
@@ -346,6 +346,7 @@ scadabridge audit verify-chain --month <YYYY-MM>
|
||||
### Health Commands
|
||||
```
|
||||
scadabridge health summary
|
||||
scadabridge health pending-updates
|
||||
scadabridge health site --identifier <site-identifier>
|
||||
scadabridge health event-log --site <site-identifier> [--event-type <type>] [--severity <level>] [--keyword <term>] [--from <date>] [--to <date>] [--page <n>] [--page-size <n>] [--instance-name <name>]
|
||||
scadabridge health parked-messages --site <site-identifier> [--page <n>] [--page-size <n>]
|
||||
|
||||
@@ -42,6 +42,8 @@ Site clusters (metric collection and reporting). Central cluster (aggregation an
|
||||
| `SecretMirrorStatuses` | Site Runtime (`DeployedSecretsMirror` via the replicated `secret_mirror_status` table) | Every site node's deployed-secrets mirror status — node name, when the node last wrote the row (on a failure-set change, at start-up, or on the 10-min refresh), and its current failures (`{SecretName, Message}`; names and messages only, never values). Review finding M1: the **standby's** failures ride the **active** node's report, because each node writes its own row to a LocalDb-replicated table and the active node's `SecretMirrorStatusReporter` reads them all. **Nullable — null = no data** (central self-report, a pre-M1 site, reporter not yet run); an empty list = wired, no row yet; a node with an empty `Failures` list is healthy. Point-in-time (not drained on collect). Wire: `SiteHealthReportDto.secret_mirror_statuses` (field **33**, a presence wrapper so null ≠ empty). Drives the *Secret mirror failing* dashboard badge + per-node failure list. On an unreplicated pair only the active node's own row appears. A node that has not refreshed its row for `SiteRuntime:SecretMirrorStatusStaleAfter` (default 1 h — decommissioned, renamed or down) is left off and its row deleted. |
|
||||
| `LocalDbOplogBacklog` | Consolidated site LocalDb | Unacked oplog depth (Prometheus `localdb_oplog_depth`). **Nullable — null means "no reading", NOT "connected with an empty backlog"** (a failed poll rendered as 0 would report a pair that cannot read its own oplog as perfectly healthy). **Expected transient spikes:** the daily `site_events` retention purge and the storage-cap trim are CDC-captured like any other write, so the backlog jumps by the batch size at purge time and drains as the peer acks it — see `Component-SiteEventLogging.md` → Storage and `docs/deployment/topology-guide.md` → *Reading the replication backlog* for the healthy-versus-faulty signature. |
|
||||
|
||||
**Pending-updates tiles (Gitea #63).** The Health dashboard also shows two central-computed counts — instances out of date and sites with stale artifacts — read from the same process-wide pending-updates cache as the rail indicator (no extra fleet flatten). Visible to Deployer/Administrator only and narrowed to a site-scoped operator's permitted sites. The CLI equivalent is `scadabridge health pending-updates` (`GetPendingUpdates`, Deployer role), which evaluates the fleet directly rather than through the UI cache.
|
||||
|
||||
## Reporting Protocol
|
||||
|
||||
- Sites send a **health report message** to central at a configurable interval (default: **30 seconds**).
|
||||
|
||||
@@ -227,6 +227,7 @@ The two-person authorization workflow for writes through the MxAccess Gateway. B
|
||||
|
||||
- **GetHealthSummary**: Query current health status of all sites.
|
||||
- **GetSiteHealth**: Query detailed health for a specific site.
|
||||
- **GetPendingUpdates**: Fleet-wide pending-updates counts (stale instances / sites with stale artifacts) with a per-site breakdown. Deployer role; site-scoped callers see only their permitted sites (Gitea #63).
|
||||
|
||||
### Remote Queries
|
||||
|
||||
|
||||
@@ -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>();
|
||||
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
using System.Security.Claims;
|
||||
using Bunit;
|
||||
using Microsoft.AspNetCore.Components.Authorization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using NSubstitute;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Auth;
|
||||
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Health;
|
||||
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.Monitoring;
|
||||
|
||||
/// <summary>Gitea #63: pending-updates tiles on the Health dashboard.</summary>
|
||||
public class PendingUpdatesHealthTilesTests : BunitContext
|
||||
{
|
||||
private static SitePendingUpdates Row(int id, int stale, ArtifactDeploymentState a = ArtifactDeploymentState.Current) =>
|
||||
new(id, $"S{id}", $"Site {id}", a, DateTimeOffset.UtcNow, 10, stale, 0);
|
||||
|
||||
private IRenderedComponent<PendingUpdatesHealthTiles> RenderFor(IPendingUpdatesCache cache, params Claim[] claims)
|
||||
{
|
||||
var user = new ClaimsPrincipal(new ClaimsIdentity(claims, "TestAuth"));
|
||||
Services.AddScoped<SiteScopeService>();
|
||||
Services.AddSingleton(cache);
|
||||
return Render<PendingUpdatesHealthTiles>(ps =>
|
||||
ps.AddCascadingValue(Task.FromResult(new AuthenticationState(user))));
|
||||
}
|
||||
|
||||
private static IPendingUpdatesCache CacheReturning(params SitePendingUpdates[] rows)
|
||||
{
|
||||
var cache = Substitute.For<IPendingUpdatesCache>();
|
||||
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, rows)));
|
||||
return cache;
|
||||
}
|
||||
|
||||
private static Claim Role(string r) => new(JwtTokenService.RoleClaimType, r);
|
||||
|
||||
/// <summary>A Deployer sees both counts, highlighted when non-zero.</summary>
|
||||
[Fact]
|
||||
public void Deployer_SeesBothCounts()
|
||||
{
|
||||
var cut = RenderFor(
|
||||
CacheReturning(Row(1, 4), Row(2, 3, ArtifactDeploymentState.OutOfDate)),
|
||||
Role(Roles.Deployer));
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("7", cut.Find("[data-test='pending-updates-instances']").TextContent);
|
||||
Assert.Contains("1", cut.Find("[data-test='pending-updates-artifact-sites']").TextContent);
|
||||
Assert.Contains("border-warning", cut.Find("[data-test='pending-updates-instances']").ClassName);
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>A Viewer sees nothing and triggers no fleet read.</summary>
|
||||
[Fact]
|
||||
public void Viewer_SeesNothing_AndNoFleetRead()
|
||||
{
|
||||
var cache = CacheReturning(Row(1, 4));
|
||||
var cut = RenderFor(cache, Role("Viewer"));
|
||||
|
||||
Assert.True(string.IsNullOrWhiteSpace(cut.Markup));
|
||||
cache.DidNotReceive().GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>());
|
||||
}
|
||||
|
||||
/// <summary>A site-scoped Deployer's totals exclude sites outside the scope.</summary>
|
||||
[Fact]
|
||||
public void SiteScopedDeployer_CountsOnlyPermittedSites()
|
||||
{
|
||||
var cut = RenderFor(
|
||||
CacheReturning(Row(1, 4), Row(2, 9)),
|
||||
Role(Roles.Deployer), new Claim(JwtTokenService.SiteIdClaimType, "1"));
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
Assert.Equal("4", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
|
||||
}
|
||||
|
||||
/// <summary>A failed read is shown as unavailable, never as zero.</summary>
|
||||
[Fact]
|
||||
public void ReadFailure_ShowsUnavailable_NotZero()
|
||||
{
|
||||
var cache = Substitute.For<IPendingUpdatesCache>();
|
||||
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
|
||||
.Returns(Task.FromException<PendingUpdatesSnapshot>(new InvalidOperationException("boom")));
|
||||
var cut = RenderFor(cache, Role(Roles.Administrator));
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Contains("boom", cut.Find("[data-test='pending-updates-error']").TextContent);
|
||||
Assert.Equal("—", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim());
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>An invalidation re-reads the cache.</summary>
|
||||
[Fact]
|
||||
public void Invalidated_Rereads()
|
||||
{
|
||||
var cache = Substitute.For<IPendingUpdatesCache>();
|
||||
cache.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(
|
||||
Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [Row(1, 5)])),
|
||||
Task.FromResult(new PendingUpdatesSnapshot(DateTimeOffset.UtcNow, [Row(1, 0)])));
|
||||
var cut = RenderFor(cache, Role(Roles.Deployer));
|
||||
cut.WaitForAssertion(() => Assert.Equal("5", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
|
||||
|
||||
cache.Invalidated += NSubstitute.Raise.Event<Action>();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Equal("0", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim()));
|
||||
}
|
||||
}
|
||||
@@ -133,6 +133,16 @@ public class HealthPageTests : BunitContext
|
||||
// control itself is covered by HealthFailoverButtonTests.
|
||||
Services.AddSingleton(Substitute.For<IManualFailoverService>());
|
||||
Services.AddSingleton(Substitute.For<IDialogService>());
|
||||
|
||||
// Gitea #63: the pending-updates tiles read the shared fleet cache and size scope via
|
||||
// SiteScopeService. The signed-in test user is an Administrator.
|
||||
Services.AddScoped<ZB.MOM.WW.ScadaBridge.CentralUI.Auth.SiteScopeService>();
|
||||
var pending = Substitute.For<IPendingUpdatesCache>();
|
||||
pending.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>()).Returns(Task.FromResult(
|
||||
new ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
[new(1, "site-a", "Site A", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.OutOfDate, null, 10, 3, 0)])));
|
||||
Services.AddSingleton(pending);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -649,6 +659,52 @@ public class HealthPageTests : BunitContext
|
||||
Assert.Equal(expectBadge, cut.Markup.Contains("data-test=\"audit-rejected-badge\""));
|
||||
}
|
||||
|
||||
/// <summary>Gitea #88: the three site write-failure badges render on the page only when their counter is above zero.</summary>
|
||||
/// <param name="eventLog">Event-log write failures.</param>
|
||||
/// <param name="audit">Audit write failures.</param>
|
||||
/// <param name="redaction">Audit redaction failures.</param>
|
||||
[Theory]
|
||||
[InlineData(0, 0, 0)]
|
||||
[InlineData(2, 0, 0)]
|
||||
[InlineData(0, 5, 0)]
|
||||
[InlineData(0, 0, 1)]
|
||||
[InlineData(2, 5, 1)]
|
||||
public void WriteFailureBadges_RenderOnlyWhenCounterAboveZero(long eventLog, int audit, int redaction)
|
||||
{
|
||||
SeedStates(new SiteHealthState
|
||||
{
|
||||
SiteId = "site-a",
|
||||
IsOnline = true,
|
||||
LastHeartbeatAt = DateTimeOffset.UtcNow,
|
||||
LatestReport = ReportWithReconciler("site-a", active: true) with
|
||||
{
|
||||
SiteEventLogWriteFailures = eventLog,
|
||||
SiteAuditWriteFailures = audit,
|
||||
AuditRedactionFailure = redaction,
|
||||
},
|
||||
});
|
||||
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() => Assert.Contains("site-a", cut.Markup));
|
||||
Assert.Equal(eventLog > 0, cut.Markup.Contains("data-test=\"event-log-write-failures-badge\""));
|
||||
Assert.Equal(audit > 0, cut.Markup.Contains("data-test=\"audit-write-failures-badge\""));
|
||||
Assert.Equal(redaction > 0, cut.Markup.Contains("data-test=\"audit-redaction-failures-badge\""));
|
||||
}
|
||||
|
||||
/// <summary>Gitea #63: the dashboard shows the pending-updates counts for an Administrator.</summary>
|
||||
[Fact]
|
||||
public void Renders_PendingUpdatesTiles_ForAdministrator()
|
||||
{
|
||||
var cut = RenderHealthPage();
|
||||
|
||||
cut.WaitForAssertion(() =>
|
||||
{
|
||||
Assert.Equal("3", cut.Find("[data-test='pending-updates-instances'] h3").TextContent.Trim());
|
||||
Assert.Equal("1", cut.Find("[data-test='pending-updates-artifact-sites'] h3").TextContent.Trim());
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Verifies that no secret mirror warning renders when no node is failing.</summary>
|
||||
/// <param name="reported">Whether the site reports secret mirror status at all.</param>
|
||||
[Theory]
|
||||
|
||||
@@ -1848,6 +1848,63 @@ public class ManagementActorTests : TestKit, IDisposable
|
||||
Assert.Equal(envelope.CorrelationId, response.CorrelationId);
|
||||
}
|
||||
|
||||
private void RegisterPendingUpdates()
|
||||
{
|
||||
var evaluator = Substitute.For<ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.IPendingUpdatesEvaluator>();
|
||||
evaluator.EvaluateAsync(Arg.Any<CancellationToken>()).Returns(Task.FromResult(
|
||||
new ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.PendingUpdatesSnapshot(
|
||||
DateTimeOffset.UtcNow,
|
||||
[
|
||||
new(1, "S1", "Site 1", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.OutOfDate, null, 10, 4, 0),
|
||||
new(2, "S2", "Site 2", ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates.ArtifactDeploymentState.Current, null, 10, 6, 0),
|
||||
])));
|
||||
_services.AddScoped(_ => evaluator);
|
||||
}
|
||||
|
||||
/// <summary>Gitea #63: a system-wide Deployer gets fleet totals.</summary>
|
||||
[Fact]
|
||||
public void GetPendingUpdates_SystemWideDeployer_ReturnsFleetTotals()
|
||||
{
|
||||
RegisterPendingUpdates();
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
using var doc = JsonDocument.Parse(response.JsonData);
|
||||
Assert.Equal(10, doc.RootElement.GetProperty("staleInstanceCount").GetInt32());
|
||||
Assert.Equal(1, doc.RootElement.GetProperty("staleArtifactSiteCount").GetInt32());
|
||||
Assert.Equal(2, doc.RootElement.GetProperty("sites").GetArrayLength());
|
||||
Assert.Equal("OutOfDate", doc.RootElement.GetProperty("sites")[0].GetProperty("artifactState").GetString());
|
||||
}
|
||||
|
||||
/// <summary>Gitea #63: a site-scoped Deployer's totals exclude other sites.</summary>
|
||||
[Fact]
|
||||
public void GetPendingUpdates_SiteScopedDeployer_SeesOnlyPermittedSites()
|
||||
{
|
||||
RegisterPendingUpdates();
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(ScopedEnvelope(new GetPendingUpdatesCommand(), new[] { "1" }, "Deployer"));
|
||||
|
||||
var response = ExpectMsg<ManagementSuccess>(TimeSpan.FromSeconds(5));
|
||||
using var doc = JsonDocument.Parse(response.JsonData);
|
||||
Assert.Equal(4, doc.RootElement.GetProperty("staleInstanceCount").GetInt32());
|
||||
Assert.Equal(1, doc.RootElement.GetProperty("sites").GetArrayLength());
|
||||
}
|
||||
|
||||
/// <summary>Gitea #63: a non-Deployer is refused.</summary>
|
||||
[Fact]
|
||||
public void GetPendingUpdates_Viewer_IsUnauthorized()
|
||||
{
|
||||
RegisterPendingUpdates();
|
||||
var actor = CreateActor();
|
||||
|
||||
actor.Tell(Envelope(new GetPendingUpdatesCommand(), "Viewer"));
|
||||
|
||||
ExpectMsg<ManagementUnauthorized>(TimeSpan.FromSeconds(5));
|
||||
}
|
||||
|
||||
/// <summary>Verifies that query parked messages, when out of scope for site scoped user, returns unauthorized.</summary>
|
||||
[Fact]
|
||||
public void QueryParkedMessages_OutOfScopeForSiteScopedUser_ReturnsUnauthorized()
|
||||
|
||||
@@ -148,6 +148,7 @@ public class RequiredRoleMatrixTests
|
||||
["SetInstanceNativeAlarmSourceOverrides"] = [Roles.Deployer],
|
||||
["DeleteInstanceNativeAlarmSourceOverride"] = [Roles.Deployer],
|
||||
["GetDeploymentDiff"] = [Roles.Deployer],
|
||||
["GetPendingUpdates"] = [Roles.Deployer],
|
||||
["MgmtDeployArtifacts"] = [Roles.Deployer],
|
||||
["QueryDeployments"] = [Roles.Deployer],
|
||||
["RetryParkedMessage"] = [Roles.Deployer],
|
||||
|
||||
Reference in New Issue
Block a user