feat(central-ui): Sites page shows whether a site's deployed artifacts are current

Each site card carries an Artifacts badge (current / out of date / never
deployed) plus an "N of M instances out of date" line linking to Topology,
both read from the shared pending-updates snapshot. The snapshot loads after
the site list so a slow fleet evaluation never holds the cards, and a failure
degrades the badge to "unavailable" instead of taking the page down. Header
gains a Re-check button and an as-of caption; a successful Deploy Artifacts
forces a refresh so the badge flips immediately.

Claude-Session: https://claude.ai/code/session_01Y5ef34iUUMjmwnMpL9xgmB
This commit is contained in:
Joseph Doherty
2026-09-14 13:59:54 -04:00
parent 3d22f272d3
commit 5d6bd03868
2 changed files with 231 additions and 2 deletions
@@ -8,8 +8,10 @@
@using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories
@using ZB.MOM.WW.ScadaBridge.Communication
@using ZB.MOM.WW.ScadaBridge.DeploymentManager
@using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates
@attribute [Authorize(Policy = AuthorizationPolicies.RequireAdmin)]
@inject ISiteRepository SiteRepository
@inject IPendingUpdatesCache PendingUpdates
@inject ArtifactDeploymentService ArtifactDeploymentService
@inject CommunicationService CommunicationService
@inject AuthenticationStateProvider AuthStateProvider
@@ -20,8 +22,23 @@
<div class="container-fluid mt-3">
<div class="d-flex justify-content-between align-items-center mb-3">
<h4 class="mb-0">Site Management</h4>
<div class="d-flex align-items-baseline">
<h4 class="mb-0">Site Management</h4>
@if (_updates is not null)
{
<span class="small text-muted ms-2">Updates as of @_updates.ComputedAt.ToString("HH:mm:ss") UTC</span>
}
</div>
<div class="d-flex gap-2">
<button class="btn btn-outline-secondary btn-sm" aria-label="Re-check pending updates"
title="Re-evaluate artifact and instance staleness for every site"
@onclick="() => LoadUpdatesAsync(true)" disabled="@_recheckingUpdates">
@if (_recheckingUpdates)
{
<span class="spinner-border spinner-border-sm me-1" role="status" aria-hidden="true"></span>
}
Re-check
</button>
<div class="dropdown">
<button class="btn btn-outline-secondary btn-sm dropdown-toggle"
data-bs-toggle="dropdown" disabled="@_deploying">
@@ -90,7 +107,7 @@
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
<h5 class="card-title mb-1">@site.Name</h5>
<code class="small">@site.SiteIdentifier</code>
<code class="small">@site.SiteIdentifier</code>@ArtifactBadge(site.Id)
</div>
<div class="d-flex gap-1">
<button class="btn btn-outline-primary btn-sm"
@@ -124,6 +141,11 @@
@(string.IsNullOrWhiteSpace(site.Description) ? "—" : site.Description)
</p>
@if (_updatesBySite.GetValueOrDefault(site.Id) is { DeployedInstanceCount: > 0 } u)
{
<div class="small text-muted mb-2">@InstanceSummary(u) — <a href="/deployment/topology">Topology</a></div>
}
<div class="small text-muted mb-1">Data connections</div>
@if (conns is { Count: > 0 })
{
@@ -178,6 +200,14 @@
private bool _deploying;
private string _search = "";
// Fleet-wide pending-updates snapshot behind the per-card Artifacts badge and the
// "N of M instances out of date" line. Loaded AFTER the site list (§3.3): the first
// evaluation flattens every site and can take seconds, and the cards must not wait.
private PendingUpdatesSnapshot? _updates;
private string? _updatesError;
private bool _recheckingUpdates;
private Dictionary<int, SitePendingUpdates> _updatesBySite = new();
private ToastNotification _toast = default!;
private IEnumerable<Site> FilteredSites =>
@@ -211,6 +241,40 @@
_errorMessage = $"Failed to load sites: {ex.Message}";
}
_loading = false;
// Render the cards now — the pending-updates evaluation below is a whole-fleet
// flatten and must never hold the list behind it.
StateHasChanged();
await LoadUpdatesAsync(force: false);
StateHasChanged();
}
// Loads (or re-evaluates) the fleet-wide pending-updates snapshot. Never throws:
// a failure degrades the badges to "unavailable" and leaves the site list intact.
private async Task LoadUpdatesAsync(bool force)
{
if (force)
{
_recheckingUpdates = true;
}
try
{
var snapshot = await PendingUpdates.GetAsync(force);
_updates = snapshot;
_updatesBySite = snapshot.Sites.ToDictionary(s => s.SiteId);
_updatesError = null;
}
catch (Exception ex)
{
_updatesError = ex.Message;
Logger.LogWarning(ex, "Pending-updates evaluation failed; Sites page badges show unavailable.");
}
finally
{
_recheckingUpdates = false;
}
}
private async Task DeleteSite(Site site)
@@ -245,7 +309,11 @@
site.Id, site.SiteIdentifier, user);
if (result.IsSuccess)
{
_toast.ShowSuccess($"Artifacts deployed to '{site.Name}'.");
// Force a re-evaluation so the badge flips to current immediately.
await LoadUpdatesAsync(force: true);
}
else
_toast.ShowError($"Deploy to '{site.Name}' failed: {result.Error}");
}
@@ -272,6 +340,8 @@
var summary = result.Value!;
_toast.ShowSuccess(
$"Artifacts deployed: {summary.SuccessCount} succeeded, {summary.FailureCount} failed.");
// Force a re-evaluation so the badges flip to current immediately.
await LoadUpdatesAsync(force: true);
}
else
{
@@ -288,6 +358,56 @@
}
}
// Per-site Artifacts badge. Bootstrap .badge markup, matching the Topology page's
// Stale/Current convention. A site absent from the snapshot (created after the
// evaluation ran) reads as "checking…" rather than claiming a state it has not seen.
private RenderFragment ArtifactBadge(int siteId) => __builder =>
{
if (_updatesError is not null)
{
<span class="badge bg-light text-dark ms-2" title="@_updatesError"
aria-label="Artifacts: unavailable">Artifacts: unavailable</span>
}
else if (_updatesBySite.GetValueOrDefault(siteId) is not { } u)
{
<span class="badge bg-light text-dark ms-2" aria-label="Artifacts: checking">Artifacts: checking…</span>
}
else
{
switch (u.ArtifactState)
{
case ArtifactDeploymentState.Current:
<span class="badge bg-light text-dark ms-2" aria-label="Artifacts: current">Artifacts current</span>
break;
case ArtifactDeploymentState.OutOfDate:
<span class="badge bg-warning text-dark ms-2"
title="@(u.ArtifactsDeployedAt is { } at ? $"Artifacts last deployed {at.ToString("u")}" : null)"
aria-label="Artifacts: out of date"><i class="bi bi-exclamation-triangle me-1"></i>Artifacts out of date</span>
break;
case ArtifactDeploymentState.NeverDeployed:
default:
<span class="badge bg-secondary ms-2" aria-label="Artifacts: never deployed">Artifacts never deployed</span>
break;
}
}
};
// "2 of 5 instances out of date, 1 could not be evaluated" — built as one string so
// the rendered text stays contiguous (Razor would interleave layout whitespace).
private static string InstanceSummary(SitePendingUpdates u)
{
var text = u.StaleInstanceCount == 0
? $"All {u.DeployedInstanceCount} instances current"
: $"{u.StaleInstanceCount} of {u.DeployedInstanceCount} instances out of date";
if (u.UnknownInstanceCount > 0)
{
text += $", {u.UnknownInstanceCount} could not be evaluated";
}
return text;
}
private RenderFragment ClusterRow(string label, string? address) => __builder =>
{
<div class="row g-1 align-items-center mb-1">
@@ -6,11 +6,13 @@ using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using NSubstitute;
using ZB.MOM.WW.ScadaBridge.CentralUI.Components.Shared;
using ZB.MOM.WW.ScadaBridge.CentralUI.Services;
using ZB.MOM.WW.ScadaBridge.Commons.Entities.Sites;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Repositories;
using ZB.MOM.WW.ScadaBridge.Commons.Interfaces.Services;
using ZB.MOM.WW.ScadaBridge.Communication;
using ZB.MOM.WW.ScadaBridge.DeploymentManager;
using ZB.MOM.WW.ScadaBridge.DeploymentManager.PendingUpdates;
using SitesPage = ZB.MOM.WW.ScadaBridge.CentralUI.Components.Pages.Admin.Sites;
namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
@@ -25,11 +27,18 @@ namespace ZB.MOM.WW.ScadaBridge.CentralUI.Tests.Admin;
public class SitesPageTests : BunitContext
{
private readonly ISiteRepository _siteRepo = Substitute.For<ISiteRepository>();
private readonly IPendingUpdatesCache _pendingUpdates = Substitute.For<IPendingUpdatesCache>();
private void RegisterServices()
{
Services.AddSingleton(_siteRepo);
// Default: an empty snapshot, so the page's badges resolve without a stub.
// Individual tests re-stub GetAsync before rendering.
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot()));
Services.AddSingleton(_pendingUpdates);
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger<CommunicationService>.Instance);
@@ -71,6 +80,27 @@ public class SitesPageTests : BunitContext
private static DataConnection Conn(int siteId, string name)
=> new(name, "OpcUa", siteId);
private static PendingUpdatesSnapshot Snapshot(params SitePendingUpdates[] sites)
=> new(new DateTimeOffset(2026, 9, 14, 10, 30, 0, TimeSpan.Zero), sites);
private static SitePendingUpdates Pending(
int siteId,
ArtifactDeploymentState state,
int deployed = 0,
int stale = 0,
int unknown = 0)
=> new(
siteId,
$"SITE-{siteId}",
$"Site{siteId}",
state,
state == ArtifactDeploymentState.NeverDeployed
? null
: new DateTimeOffset(2026, 9, 13, 8, 0, 0, TimeSpan.Zero),
deployed,
stale,
unknown);
[Fact]
public void LoadData_FetchesAllConnectionsInOneQuery_NoPerSiteQueries()
{
@@ -103,4 +133,83 @@ public class SitesPageTests : BunitContext
Assert.Contains("alpha-conn", cut.Markup);
Assert.Contains("beta-conn", cut.Markup);
}
[Fact]
public void ArtifactBadge_ShowsCurrent_OutOfDate_NeverDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2, 3));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current),
Pending(2, ArtifactDeploymentState.OutOfDate),
Pending(3, ArtifactDeploymentState.NeverDeployed))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("aria-label=\"Artifacts: current\"", cut.Markup);
Assert.Contains("Artifacts current", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: out of date\"", cut.Markup);
Assert.Contains("Artifacts out of date", cut.Markup);
Assert.Contains("aria-label=\"Artifacts: never deployed\"", cut.Markup);
Assert.Contains("Artifacts never deployed", cut.Markup);
});
}
[Fact]
public void InstanceLine_ShowsStaleOfDeployed()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(_ => Task.FromResult(Snapshot(
Pending(1, ArtifactDeploymentState.Current, deployed: 5, stale: 2, unknown: 1))));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
Assert.Contains("2 of 5 instances out of date", cut.Markup);
Assert.Contains("1 could not be evaluated", cut.Markup);
Assert.Contains("/deployment/topology", cut.Markup);
});
}
[Fact]
public void Recheck_ForcesRefresh()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
var cut = Render<SitesPage>();
cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
cut.WaitForAssertion(() =>
_pendingUpdates.Received().GetAsync(true, Arg.Any<CancellationToken>()));
}
[Fact]
public void CacheFault_ShowsUnavailable_AndSitesStillRender()
{
RegisterServices();
_siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
_siteRepo.GetAllDataConnectionsAsync().Returns(new List<DataConnection>());
_pendingUpdates.GetAsync(Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns<Task<PendingUpdatesSnapshot>>(_ => throw new InvalidOperationException("evaluator down"));
var cut = Render<SitesPage>();
cut.WaitForAssertion(() =>
{
// The snapshot failing must never take the site list down with it.
Assert.Contains("Site1", cut.Markup);
Assert.Contains("Site2", cut.Markup);
Assert.Contains("Artifacts: unavailable", cut.Markup);
});
}
}