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()