feat(centralui): Topology 'Deploy all…' on site and area nodes with confirm dialog, Force and summary toast (Gitea #40)

This commit is contained in:
Joseph Doherty
2026-10-22 14:16:08 -04:00
parent d71f61e71b
commit bcd087efe8
6 changed files with 314 additions and 4 deletions
+1 -1
View File
@@ -28,7 +28,7 @@ Central cluster only. Sites have no user interface.
- Manual failover goes through a scoped `AuthorizedManualFailoverService` that refreshes the circuit's Administrator grant before calling the host singleton. The component's visibility policy is an additional presentation guard.
- Flow Debugger retry/discard validates the current Deployer grant and resolves site scope inside its service. Scoped callers cannot relay an unknown site. Refresh merges preserve Replay's event ordering and append only rows known exclusively to the live run.
- Health and Alarm Summary observe the whole posted refresh task and release their poll gate even when renderer dispatch fails during disposal.
- The Topology initially loads permitted sites **and their area metadata** (Gitea #39 — every permitted site's areas are read at open, out-of-scope sites never) and opens with each site expanded to its top-level areas (`TreeView.InitiallyExpanded`; a browser's saved expansion state always wins). Instances stay lazy: select **Load instances** to load the first root-instance page of a site; each expanded area has its own loader. **Previous** and **Next** replace that branch's 50-instance page. Queries are untracked and omit member collections; templates are not loaded. Live refresh and staleness checks cover loaded pages. Search and counts explicitly describe loaded data.
- The Topology initially loads permitted sites **and their area metadata** (Gitea #39 — every permitted site's areas are read at open, out-of-scope sites never) and opens with each site expanded to its top-level areas (`TreeView.InitiallyExpanded`; a browser's saved expansion state always wins). Instances stay lazy: select **Load instances** to load the first root-instance page of a site; each expanded area has its own loader. **Previous** and **Next** replace that branch's 50-instance page. Queries are untracked and omit member collections; templates are not loaded. Live refresh and staleness checks cover loaded pages. Search and counts explicitly describe loaded data. **Deploy all…** (Gitea #40): the Site and Area node context menus offer a subtree bulk deploy (Deployer role + current site scope re-checked at click). The page asks `DeploymentService.PreviewSubtreeDeployAsync` for the instance count and Disabled count (a cheap read, no staleness probe); a zero-instance subtree shows an info toast and no dialog; otherwise `DeployAllDialog` (via `IDialogService.ShowAsync`) shows the counts and a **Force redeploy up-to-date instances** checkbox with confirm label **Deploy all**. Confirm calls `DeploySiteAsync` (site node) or `DeployAreaAsync` (area node — the area plus all descendant areas) with the Force choice — same rules as the CLI/management bulk deploy (Disabled always skipped, up-to-date skipped unless forced, not all-or-nothing) — then toasts `N deployed, M skipped, K failed` (an error toast naming the first three failures when any failed) and reloads the tree. The menu item is disabled while another page action is in flight.
- `TemplateFolderTree` compares immutable value snapshots of folder/template hierarchy and extra children before rebuilding. In-place renames, moves, derived-state changes, and composition edits remain visible; filter and reveal changes are applied independently.
- Navigation grants Flow Debugger its own policy and hides Notifications unless the principal holds a role that admits a child. Route-policy coverage freezes every CentralUI route and the external Secrets route.
@@ -676,6 +676,9 @@
@onclick='() => NavigationManager.NavigateTo($"/deployment/instances/create?siteId={node.EntityId}")'>
Create Instance here
</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @onclick="() => DeployAll(node)"
disabled="@_actionInProgress">Deploy all…</button>
break;
case TopoNodeKind.Area:
@@ -687,6 +690,9 @@
<button class="dropdown-item" @onclick="() => OpenMoveAreaDialog(node)">Move to Area…</button>
<button class="dropdown-item" @onclick="() => BeginRename(node)">Rename…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item" @onclick="() => DeployAll(node)"
disabled="@_actionInProgress">Deploy all…</button>
<div class="dropdown-divider"></div>
<button class="dropdown-item text-danger" @onclick="() => DeleteArea(node)" disabled="@_actionInProgress">Delete</button>
break;
@@ -1069,6 +1075,86 @@
_actionInProgress = false;
}
// ---- Deploy all (site / area subtree, Gitea #40) ----
private async Task DeployAll(TopoNode node)
{
if (_actionInProgress) return;
_actionInProgress = true;
try
{
var isArea = node.Kind == TopoNodeKind.Area;
var siteId = isArea ? node.SiteId : node.EntityId;
int? areaId = isArea ? node.EntityId : null;
var user = await GetSiteDeployerAsync(siteId);
if (user is null) return;
var preview = await DeploymentService.PreviewSubtreeDeployAsync(siteId, areaId);
if (!preview.IsSuccess)
{
_toast.ShowError($"Deploy all failed: {preview.Error}");
return;
}
if (preview.Value.Total == 0)
{
_toast.ShowInfo($"No instances under '{node.Label}' to deploy.");
return;
}
var scopeLabel = (isArea ? "area " : "site ") + node.Label;
var total = preview.Value.Total;
var disabled = preview.Value.Disabled;
var choice = await Dialog.ShowAsync<DeployAllDialog.DeployAllResult>(
"Deploy all",
ctx => @<DeployAllDialog Context="ctx" ScopeLabel="@scopeLabel" Total="total" Disabled="disabled" />);
if (choice is null) return;
var result = isArea
? await DeploymentService.DeployAreaAsync(node.EntityId, user, choice.Force)
: await DeploymentService.DeploySiteAsync(siteId, user, choice.Force);
if (!result.IsSuccess)
{
_toast.ShowError($"Deploy all failed: {result.Error}");
return;
}
var summary = result.Value;
var skipped = summary.SkippedInstances?.Count ?? 0;
var text = $"Deploy all under '{node.Label}': {summary.SuccessCount} deployed, {skipped} skipped, {summary.FailureCount} failed.";
if (summary.FailureCount > 0)
{
var failures = summary.InstanceResults.Where(r => !r.Success).Take(3)
.Select(r => $"{r.UniqueName}: {r.ErrorMessage}");
var more = summary.FailureCount > 3 ? $" (+{summary.FailureCount - 3} more)" : string.Empty;
_toast.ShowError($"{text} {string.Join("; ", failures)}{more}", "Deploy all");
}
else
{
_toast.ShowSuccess(text, "Deploy all");
}
await LoadDataAsync();
}
catch (Exception ex)
{
_toast.ShowError($"Deploy all failed: {ex.Message}");
}
finally
{
_actionInProgress = false;
}
}
// Deployer role + current site scope for a site-level (bulk) action.
private async Task<string?> GetSiteDeployerAsync(int siteId)
{
var user = await AuthStateProvider.RequireRoleAsync(ZB.MOM.WW.ScadaBridge.Security.Roles.Deployer);
if (!await SiteScope.IsSiteAllowedAsync(siteId))
{
_toast.ShowError("This site is no longer available within your permitted sites.");
return null;
}
return user;
}
// ---- Diff modal ----
private async Task ShowDiff(Instance inst)
{
@@ -0,0 +1,61 @@
@*
Body component for the Topology "Deploy all…" confirmation (Gitea #40), hosted by
IDialogService.ShowAsync. Shows how many instances the site/area subtree holds and
how many of those are Disabled (always skipped), and offers the Force switch that
also redeploys up-to-date instances. Closes with DeployAllResult; Cancel resolves
to null, so a cancel is distinguishable from a confirm (the MoveTemplateDialog /
TemplatePickerDialog contract).
*@
@using ZB.MOM.WW.ScadaBridge.CentralUI.Services
<div data-test="deploy-all-body">
<p class="mb-2">
Deploy all instances under <strong data-test="deploy-all-scope">@ScopeLabel</strong>?
</p>
<p class="mb-2 small" data-test="deploy-all-counts">
<span class="fw-semibold">@Total</span> instance@(Total == 1 ? "" : "s")
@if (Disabled > 0)
{
<span class="text-muted"> (@Disabled disabled &mdash; will be skipped)</span>
}
. Instances already running the current configuration are skipped.
</p>
<div class="form-check mb-1">
<input type="checkbox" class="form-check-input" id="deploy-all-force" data-test="deploy-all-force"
@bind="_force" />
<label class="form-check-label small" for="deploy-all-force">Force redeploy up-to-date instances</label>
</div>
<div class="form-text">
A redeploy resets an instance's static attribute overrides and clears its native alarm state.
Disabled instances are never deployed or re-enabled.
</div>
</div>
<div class="modal-footer px-0 pb-0 mt-3">
<div class="btn-group btn-group-sm" role="group">
<button class="btn btn-outline-secondary" type="button" data-test="deploy-all-cancel"
@onclick="() => Context.Cancel()">Cancel</button>
<button class="btn btn-primary" type="button" data-test="deploy-all-confirm"
@onclick="() => Context.Close(new DeployAllResult(_force))">Deploy all</button>
</div>
</div>
@code {
/// <summary>Supplied by <c>IDialogService.ShowAsync</c>; closes/cancels the modal.</summary>
[Parameter] public DialogContext<DeployAllResult> Context { get; set; } = default!;
/// <summary>Human label of the subtree being deployed, e.g. "site Plant" or "area Line A".</summary>
[Parameter] public string ScopeLabel { get; set; } = string.Empty;
/// <summary>Instances in the subtree.</summary>
[Parameter] public int Total { get; set; }
/// <summary>Of those, the Disabled ones a bulk deploy always skips.</summary>
[Parameter] public int Disabled { get; set; }
private bool _force;
/// <summary>Operator's confirmed choice; <c>null</c> from ShowAsync means Cancel.</summary>
/// <param name="Force">Also redeploy instances that are already up to date.</param>
public sealed record DeployAllResult(bool Force);
}
@@ -302,6 +302,42 @@ public class TopologyAreaTests : IClassFixture<DeploymentFixture>
}
}
/// <summary>Verifies that right-click area, Deploy all, confirm raises the summary toast (Gitea #40).</summary>
/// <returns>A task that represents the asynchronous operation.</returns>
[SkippableFact]
public async Task DeployAll_OnArea_ShowsSummaryToast()
{
Skip.IfNot(_cluster.Available, ClusterAvailability.SkipReason);
var areaName = CliRunner.UniqueName("dpall");
var areaId = await CliRunner.CreateAreaAsync(_cluster.SiteAId, areaName);
var instanceId = await CliRunner.CreateInstanceAsync(
CliRunner.UniqueName("zztest-inst-dpall"), _cluster.TemplateId, _cluster.SiteAId, areaId);
try
{
var page = await OpenStableTopologyAsync(_pw);
var row = page.Locator("div.tv-row", new() { HasText = areaName });
await Assertions.Expect(row.First).ToBeVisibleAsync(new() { Timeout = 10_000 });
await row.First.ClickAsync(new() { Button = MouseButton.Right });
await page.Locator(".dropdown-menu.show button.dropdown-item:has-text('Deploy all')").ClickAsync();
var dialog = page.Locator(".modal.show:has([data-test='deploy-all-body'])");
await Assertions.Expect(dialog).ToBeVisibleAsync(new() { Timeout = 10_000 });
await Assertions.Expect(dialog.Locator("[data-test='deploy-all-counts']")).ToContainTextAsync("1 instance");
await dialog.Locator("[data-test='deploy-all-confirm']").ClickAsync();
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");
}
finally
{
await CliRunner.DeleteInstanceAsync(instanceId);
await CliRunner.DeleteAreaAsync(areaId);
}
}
private static Task<IPage> OpenStableTopologyAsync(PlaywrightFixture pw) =>
TopologyPageHelper.OpenStableTopologyAsync(pw);
}
@@ -138,9 +138,7 @@ public class TopologyDisableConfirmLabelTests : BunitContext
cut.InvokeAsync(() => cut.FindComponent<TopologyPage>().Instance.LoadBranchAsync(1, null)).GetAwaiter().GetResult();
cut.FindComponent<TopologyPage>().Render();
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Plant-A")
.QuerySelector(".tv-toggle")!.Click();
// Sites open pre-expanded (Gitea #39), so no toggle click is needed.
var row = cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == "Pump-001");
row.ContextMenu();
@@ -244,6 +244,135 @@ public class TopologyPageTests : BunitContext
_repo.DidNotReceive().GetAllTemplatesAsync(Arg.Any<CancellationToken>());
}
// ---- Deploy all (Gitea #40) ----
// One site (id 1) with one area (id 10) holding one Disabled instance — a bulk deploy
// skips Disabled instances before any flatten, so the real DeploymentService runs
// end to end against the substituted repositories.
private IRenderedComponent<DialogHost> SeedDeployAllTopology(int instanceCount = 1)
{
var site = new Site("Plant-A", "plant-a") { Id = 1 };
var area = new Area("Line-1") { Id = 10, SiteId = 1 };
var instances = Enumerable.Range(1, instanceCount)
.Select(i => new Instance($"Pump-{i:000}") { Id = 100 + i, SiteId = 1, AreaId = 10, State = InstanceState.Disabled })
.ToList();
SeedRepos(sites: [site], instances: instances, areasBySite: new() { [1] = new List<Area> { area } });
_siteRepo.GetSiteByIdAsync(1, Arg.Any<CancellationToken>()).Returns(site);
_siteRepo.GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>())
.Returns(Task.FromResult<IReadOnlyList<Instance>>(instances));
_repo.GetAreaByIdAsync(10, Arg.Any<CancellationToken>()).Returns(area);
_pipeline.CreateSession().Returns(_ => new FlattenSession(new ZB.MOM.WW.ScadaBridge.Commons.Types.Templates.TemplateGraphWatermark()));
JSInterop.Mode = JSRuntimeMode.Loose;
return Render<DialogHost>();
}
private static void OpenMenu(IRenderedComponent<TopologyPage> cut, string label) =>
cut.FindAll(".tv-row").First(r => r.QuerySelector(".tv-label")?.TextContent == label).ContextMenu();
/// <summary>Deploy all appears on site and area context menus, not instance menus.</summary>
[Fact]
public void DeployAll_MenuItem_OnSiteAndAreaNodes_NotInstances()
{
SeedDeployAllTopology();
var cut = RenderLoadedTopology();
FindToggleForLabel(cut, "Line-1")!.Click();
OpenMenu(cut, "Pump-001");
Assert.DoesNotContain(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
OpenMenu(cut, "Plant-A");
Assert.Contains(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
OpenMenu(cut, "Line-1");
Assert.Contains(cut.FindAll("button.dropdown-item"), b => b.TextContent.Trim() == "Deploy all…");
}
/// <summary>Confirming on an area calls the area deploy and toasts the summary, then reloads.</summary>
[Fact]
public void DeployAll_Area_Confirm_DeploysAndShowsSummaryToast()
{
var host = SeedDeployAllTopology();
var cut = RenderLoadedTopology();
OpenMenu(cut, "Line-1");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
var dialog = host.WaitForElement("[data-test='deploy-all-body']");
Assert.Contains("1 instance", host.Find("[data-test='deploy-all-counts']").TextContent);
Assert.Contains("1 disabled", host.Find("[data-test='deploy-all-counts']").TextContent);
host.Find("[data-test='deploy-all-confirm']").Click();
cut.WaitForAssertion(() => Assert.Contains("0 deployed, 1 skipped, 0 failed", cut.Markup));
// preview + deploy each resolve the area.
_repo.Received(2).GetAreaByIdAsync(10, Arg.Any<CancellationToken>());
}
/// <summary>Confirming on a site calls the site deploy.</summary>
[Fact]
public void DeployAll_Site_Confirm_DeploysWholeSite()
{
var host = SeedDeployAllTopology(instanceCount: 2);
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
host.Render();
Assert.Contains("2 instances", host.WaitForElement("[data-test='deploy-all-counts']").TextContent);
host.Find("[data-test='deploy-all-confirm']").Click();
cut.WaitForAssertion(() => Assert.True(cut.Markup.Contains("0 deployed, 2 skipped, 0 failed"), "toast missing"));
_siteRepo.Received(2).GetSiteByIdAsync(1, Arg.Any<CancellationToken>());
_repo.DidNotReceive().GetAreaByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
}
/// <summary>Cancel performs no deploy (only the preview read happened).</summary>
[Fact]
public void DeployAll_Cancel_DoesNothing()
{
var host = SeedDeployAllTopology();
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-cancel']").Click();
cut.WaitForAssertion(() => Assert.DoesNotContain("skipped", cut.Markup));
// Only the preview's single read of the site's instances; a deploy would add a second.
_siteRepo.Received(1).GetInstancesBySiteIdAsync(1, Arg.Any<CancellationToken>());
Assert.Empty(host.FindAll("[data-test='deploy-all-body']"));
}
/// <summary>A subtree with no instances shows an info toast and never opens the dialog.</summary>
[Fact]
public void DeployAll_ZeroInstances_InfoToast_NoDialog()
{
var host = SeedDeployAllTopology(instanceCount: 0);
var cut = RenderLoadedTopology();
OpenMenu(cut, "Plant-A");
cut.FindAll("button.dropdown-item").First(b => b.TextContent.Trim() == "Deploy all…").Click();
cut.WaitForAssertion(() => Assert.Contains("No instances under", cut.Markup));
host.Render();
Assert.Empty(host.FindAll("[data-test='deploy-all-body']"));
}
/// <summary>The dialog's Force checkbox is carried on the confirmed result.</summary>
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task DeployAllDialog_ConfirmCarriesForceChoice(bool force)
{
JSInterop.Mode = JSRuntimeMode.Loose;
var host = Render<DialogHost>();
var dialogs = Services.GetRequiredService<IDialogService>();
var pending = dialogs.ShowAsync<DeployAllDialog.DeployAllResult>("Deploy all", ctx => builder =>
{
builder.OpenComponent<DeployAllDialog>(0);
builder.AddAttribute(1, "Context", ctx);
builder.AddAttribute(2, "ScopeLabel", "site Plant-A");
builder.AddAttribute(3, "Total", 4);
builder.AddAttribute(4, "Disabled", 1);
builder.CloseComponent();
});
host.Render();
if (force) host.WaitForElement("[data-test='deploy-all-force']").Change(true);
host.WaitForElement("[data-test='deploy-all-confirm']").Click();
var result = await pending.WaitAsync(TimeSpan.FromSeconds(5));
Assert.NotNull(result);
Assert.Equal(force, result!.Force);
}
/// <summary>Gitea #39: the first layer (areas of every permitted site) is loaded at open; instances stay lazy.</summary>
[Fact]
public void InitialLoad_PreloadsAreasForPermittedSites_ButNoInstances()