diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor index 3c94acda..a2667865 100644 --- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor +++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Admin/Sites.razor @@ -242,6 +242,10 @@ } _loading = false; + // Nothing to badge: the page is showing an error banner, not a card grid, so a + // whole-fleet flatten would buy nothing. + if (_errorMessage != null) return; + // Render the cards now — the pending-updates evaluation below is a whole-fleet // flatten and must never hold the list behind it. StateHasChanged(); @@ -268,6 +272,10 @@ } catch (Exception ex) { + // The error wins over whatever a previous evaluation said: a failed re-check + // must not leave a stale "as of" caption standing next to unavailable badges. + _updates = null; + _updatesBySite = new Dictionary(); _updatesError = ex.Message; Logger.LogWarning(ex, "Pending-updates evaluation failed; Sites page badges show unavailable."); } @@ -301,6 +309,7 @@ private async Task DeployArtifacts(Site site) { + var succeeded = false; _deploying = true; try { @@ -308,12 +317,9 @@ var result = await ArtifactDeploymentService.RetryForSiteAsync( site.Id, site.SiteIdentifier, user); - if (result.IsSuccess) - { + succeeded = result.IsSuccess; + if (succeeded) _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}"); } @@ -325,23 +331,32 @@ { _deploying = false; } + + if (succeeded) + { + // Re-enable the deploy controls FIRST: the forced re-evaluation is a whole-fleet + // flatten, and the operator should not sit behind it once the deploy has landed. + // Its own progress shows on the Re-check button's spinner. + StateHasChanged(); + await LoadUpdatesAsync(force: true); + } } private async Task DeployArtifactsToAllSites() { + var succeeded = false; _deploying = true; try { var user = await GetCurrentUserAsync(); var result = await ArtifactDeploymentService.DeployToAllSitesAsync(user); - if (result.IsSuccess) + succeeded = result.IsSuccess; + if (succeeded) { 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 { @@ -356,6 +371,13 @@ { _deploying = false; } + + if (succeeded) + { + // Re-enable the deploy controls FIRST — see DeployArtifacts. + StateHasChanged(); + await LoadUpdatesAsync(force: true); + } } // Per-site Artifacts badge. Bootstrap .badge markup, matching the Topology page's @@ -381,7 +403,7 @@ break; case ArtifactDeploymentState.OutOfDate: Artifacts out of date break; case ArtifactDeploymentState.NeverDeployed: @@ -396,9 +418,11 @@ // the rendered text stays contiguous (Razor would interleave layout whitespace). private static string InstanceSummary(SitePendingUpdates u) { + var noun = u.DeployedInstanceCount == 1 ? "instance" : "instances"; + var text = u.StaleInstanceCount == 0 - ? $"All {u.DeployedInstanceCount} instances current" - : $"{u.StaleInstanceCount} of {u.DeployedInstanceCount} instances out of date"; + ? $"All {u.DeployedInstanceCount} {noun} current" + : $"{u.StaleInstanceCount} of {u.DeployedInstanceCount} {noun} out of date"; if (u.UnknownInstanceCount > 0) { 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 836cc4bb..68f17a21 100644 --- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs +++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/Admin/SitesPageTests.cs @@ -179,6 +179,27 @@ public class SitesPageTests : BunitContext }); } + [Fact] + public void InstanceLine_PluralisesTheSingleInstanceCase() + { + RegisterServices(); + _siteRepo.GetAllSitesAsync().Returns(Sites(1, 2)); + _siteRepo.GetAllDataConnectionsAsync().Returns(new List()); + _pendingUpdates.GetAsync(Arg.Any(), Arg.Any()) + .Returns(_ => Task.FromResult(Snapshot( + Pending(1, ArtifactDeploymentState.Current, deployed: 1, stale: 1), + Pending(2, ArtifactDeploymentState.Current, deployed: 1)))); + + var cut = Render(); + + cut.WaitForAssertion(() => + { + Assert.Contains("1 of 1 instance out of date", cut.Markup); + Assert.Contains("All 1 instance current", cut.Markup); + Assert.DoesNotContain("1 instances", cut.Markup); + }); + } + [Fact] public void Recheck_ForcesRefresh() { @@ -193,6 +214,46 @@ public class SitesPageTests : BunitContext _pendingUpdates.Received().GetAsync(true, Arg.Any())); } + [Fact] + public void SiteLoadFailure_SkipsTheFleetEvaluation() + { + RegisterServices(); + _siteRepo.GetAllSitesAsync() + .Returns>>(_ => throw new InvalidOperationException("db down")); + + var cut = Render(); + + cut.WaitForAssertion(() => Assert.Contains("Failed to load sites", cut.Markup)); + // No cards to badge, so the whole-fleet flatten must not run at all. + _pendingUpdates.DidNotReceive().GetAsync(Arg.Any(), Arg.Any()); + } + + [Fact] + public void FailedRecheck_ClearsTheStaleAsOfCaption() + { + 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)))); + + var cut = Render(); + cut.WaitForAssertion(() => Assert.Contains("Updates as of", cut.Markup)); + + // The re-check now faults: the error wins, so the caption from the earlier good + // evaluation must not stand next to "unavailable" badges. + _pendingUpdates.GetAsync(Arg.Any(), Arg.Any()) + .Returns>(_ => throw new InvalidOperationException("evaluator down")); + cut.Find("button[aria-label=\"Re-check pending updates\"]").Click(); + + cut.WaitForAssertion(() => + { + Assert.DoesNotContain("Updates as of", cut.Markup); + Assert.Contains("Artifacts: unavailable", cut.Markup); + Assert.Contains("Site1", cut.Markup); + }); + } + [Fact] public void CacheFault_ShowsUnavailable_AndSitesStillRender() {