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)
+ {
+ Artifacts: unavailable
+ }
+ else if (_updatesBySite.GetValueOrDefault(siteId) is not { } u)
+ {
+ Artifacts: checking…
+ }
+ else
+ {
+ switch (u.ArtifactState)
+ {
+ case ArtifactDeploymentState.Current:
+ Artifacts current
+ break;
+ case ArtifactDeploymentState.OutOfDate:
+ Artifacts out of date
+ break;
+ case ArtifactDeploymentState.NeverDeployed:
+ default:
+ Artifacts never deployed
+ 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 =>
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs
index a171122c..836cc4bb 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs
@@ -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();
+ private readonly IPendingUpdatesCache _pendingUpdates = Substitute.For();
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(), Arg.Any())
+ .Returns(_ => Task.FromResult(Snapshot()));
+ Services.AddSingleton(_pendingUpdates);
+
var comms = new CommunicationService(
Options.Create(new CommunicationOptions()),
NullLogger.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());
+ _pendingUpdates.GetAsync(Arg.Any(), Arg.Any())
+ .Returns(_ => Task.FromResult(Snapshot(
+ Pending(1, ArtifactDeploymentState.Current),
+ Pending(2, ArtifactDeploymentState.OutOfDate),
+ Pending(3, ArtifactDeploymentState.NeverDeployed))));
+
+ var cut = Render();
+
+ 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());
+ _pendingUpdates.GetAsync(Arg.Any(), Arg.Any())
+ .Returns(_ => Task.FromResult(Snapshot(
+ Pending(1, ArtifactDeploymentState.Current, deployed: 5, stale: 2, unknown: 1))));
+
+ var cut = Render();
+
+ 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());
+
+ var cut = Render();
+ cut.Find("button[aria-label=\"Re-check pending updates\"]").Click();
+
+ cut.WaitForAssertion(() =>
+ _pendingUpdates.Received().GetAsync(true, Arg.Any()));
+ }
+
+ [Fact]
+ public void CacheFault_ShowsUnavailable_AndSitesStillRender()
+ {
+ RegisterServices();
+ _siteRepo.GetAllSitesAsync().Returns(Sites(1, 2));
+ _siteRepo.GetAllDataConnectionsAsync().Returns(new List());
+ _pendingUpdates.GetAsync(Arg.Any(), Arg.Any())
+ .Returns>(_ => throw new InvalidOperationException("evaluator down"));
+
+ var cut = Render();
+
+ 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);
+ });
+ }
}