From e029dc1ccb1a431b67b91368fc5d695e0bc3a572 Mon Sep 17 00:00:00 2001
From: Joseph Doherty
Date: Thu, 22 Oct 2026 14:22:47 -0400
Subject: [PATCH] fix(centralui): Deploy all runs in its own DI scope, live
tick/Refresh/loaders pause during actions, dialog wording,
failure/saved-state tests (Gitea #40)
---
.../Pages/Deployment/Topology.razor | 23 ++++++++----
.../Components/Shared/DeployAllDialog.razor | 6 ++--
.../Deployment/TopologyAreaTests.cs | 2 +-
.../TopologyPageTests.cs | 36 +++++++++++++++++++
4 files changed, 56 insertions(+), 11 deletions(-)
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor
index 755beacc..64bb4dfc 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Pages/Deployment/Topology.razor
@@ -79,7 +79,7 @@
-
+
@@ -249,7 +249,9 @@
_ = _pollGate.RunAsync(
() => InvokeAsync(async () =>
{
- if (_disposed || !_liveUpdates) return;
+ // Also skip while a page action (e.g. Deploy all) is running: it has its own DI scope, but
+ // a refresh landing mid-action only churns the tree under the operator.
+ if (_disposed || !_liveUpdates || _actionInProgress) return;
// Staleness is deliberately OFF the live loop: it only changes when a
// template/instance is edited or redeployed, and recomputing it (a
// full re-flatten per deployed instance) every 15s is the page's
@@ -396,6 +398,8 @@
_branchLoading = true;
try
{
+ // Since #39 every permitted site's areas are pre-loaded each tick, so this guard is
+ // only reachable for a site created between ticks.
if (!_loadedSites.Contains(siteId))
{
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
@@ -601,7 +605,7 @@
var loaded = _loadedPages.TryGetValue((node.SiteId, areaId), out var offset);
@if (!loaded)
{
-
+
}
else
{
@@ -1052,7 +1056,8 @@
{
var user = await GetCurrentUserAsync(inst.Id);
if (user is null) return;
- var result = await DeploymentService.DeployInstanceAsync(inst.Id, user);
+ await using var scope = ScopeFactory.CreateAsyncScope();
+ var result = await scope.ServiceProvider.GetRequiredService().DeployInstanceAsync(inst.Id, user);
if (result.IsSuccess)
{
// Revision hashes are normally 64 hex chars, but the slice must
@@ -1088,7 +1093,11 @@
var user = await GetSiteDeployerAsync(siteId);
if (user is null) return;
- var preview = await DeploymentService.PreviewSubtreeDeployAsync(siteId, areaId);
+ // Own DI scope (like ComputeOneStalenessAsync): the circuit-scoped DbContext is shared
+ // with the live-update tick, and a deploy must not die with the circuit's scope.
+ await using var scope = ScopeFactory.CreateAsyncScope();
+ var deployment = scope.ServiceProvider.GetRequiredService();
+ var preview = await deployment.PreviewSubtreeDeployAsync(siteId, areaId);
if (!preview.IsSuccess)
{
_toast.ShowError($"Deploy all failed: {preview.Error}");
@@ -1109,8 +1118,8 @@
if (choice is null) return;
var result = isArea
- ? await DeploymentService.DeployAreaAsync(node.EntityId, user, choice.Force)
- : await DeploymentService.DeploySiteAsync(siteId, user, choice.Force);
+ ? await deployment.DeployAreaAsync(node.EntityId, user, choice.Force)
+ : await deployment.DeploySiteAsync(siteId, user, choice.Force);
if (!result.IsSuccess)
{
_toast.ShowError($"Deploy all failed: {result.Error}");
diff --git a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DeployAllDialog.razor b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DeployAllDialog.razor
index 12c1c5d5..b783a87b 100644
--- a/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DeployAllDialog.razor
+++ b/src/ZB.MOM.WW.ScadaBridge.CentralUI/Components/Shared/DeployAllDialog.razor
@@ -13,12 +13,12 @@
Deploy all instances under @ScopeLabel?
- @Total instance@(Total == 1 ? "" : "s")
+ @Total instance@(Total == 1 ? "" : "s") in scope@(Disabled > 0 ? "" : ".")
@if (Disabled > 0)
{
- (@Disabled disabled — will be skipped)
+ , @Disabled disabled (always skipped).
}
- . Instances already running the current configuration are skipped.
+ Unchanged instances are skipped unless Force is checked.
var toast = page.Locator(".toast", new() { HasText = $"Deploy all under '{areaName}'" });
await Assertions.Expect(toast).ToBeVisibleAsync(new() { Timeout = 60_000 });
- await Assertions.Expect(toast).ToContainTextAsync("deployed");
+ await Assertions.Expect(toast).ToContainTextAsync(new System.Text.RegularExpressions.Regex(@"\d+ deployed, \d+ skipped, 0 failed"));
}
finally
{
diff --git a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs
index d3a70cbe..608513c3 100644
--- a/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs
+++ b/tests/ZB.MOM.WW.ScadaBridge.CentralUI.Tests/TopologyPageTests.cs
@@ -318,6 +318,42 @@ public class TopologyPageTests : BunitContext
_repo.DidNotReceive().GetAreaByIdAsync(Arg.Any(), Arg.Any());
}
+ /// A failing instance yields an error toast listing the failure, not the success toast.
+ [Fact]
+ public void DeployAll_FailedInstance_ShowsErrorToastWithFailureList()
+ {
+ var host = SeedDeployAllTopology();
+ var failing = new Instance("Pump-001") { Id = 101, SiteId = 1, AreaId = 10, State = InstanceState.NotDeployed };
+ _siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any())
+ .Returns(Task.FromResult>(new List { failing }));
+ _pipeline.FlattenAndValidateAsync(Arg.Any(), Arg.Any(), Arg.Any())
+ .Returns(Task.FromResult(Result.Failure("flatten boom")));
+ var cut = RenderLoadedTopology();
+ OpenMenu(cut, "Plant-A");
+ cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
+ host.Render();
+ host.WaitForElement("[data-test='deploy-all-confirm']").Click();
+ cut.WaitForAssertion(() =>
+ {
+ var toast = cut.Find(".toast-body").TextContent;
+ Assert.Contains("0 deployed, 0 skipped, 1 failed", toast);
+ Assert.Contains("Pump-001", toast);
+ });
+ }
+
+ /// A saved (collapsed) expansion state wins over InitiallyExpanded for sites.
+ [Fact]
+ public void InitialLoad_SavedCollapsedState_WinsOverInitiallyExpanded()
+ {
+ SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
+ areasBySite: new() { [1] = new List { new("Line A") { Id = 10, SiteId = 1 } } });
+ JSInterop.Setup("treeviewStorage.load", _ => true).SetResult("[]");
+ var cut = Render();
+ cut.WaitForAssertion(() => Assert.Contains("Plant", cut.Markup));
+ var siteLi = cut.FindAll("li[role='treeitem']").First(li => li.QuerySelector(".tv-label")?.TextContent == "Plant");
+ Assert.Equal("false", siteLi.GetAttribute("aria-expanded"));
+ }
+
/// Cancel performs no deploy (only the preview read happened).
[Fact]
public void DeployAll_Cancel_DoesNothing()