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:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user