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)
This commit is contained in:
@@ -79,7 +79,7 @@
|
|||||||
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
|
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
|
||||||
<button class="btn btn-outline-secondary"
|
<button class="btn btn-outline-secondary"
|
||||||
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
|
@onclick='() => NavigationManager.NavigateTo("/deployment/instances/create")'>+ Instance</button>
|
||||||
<button class="btn btn-outline-secondary" aria-label="Refresh topology" @onclick="LoadDataAsync">Refresh</button>
|
<button class="btn btn-outline-secondary" aria-label="Refresh topology" @onclick="LoadDataAsync" disabled="@_actionInProgress">Refresh</button>
|
||||||
<button class="btn btn-outline-secondary" aria-label="Expand all areas" @onclick="() => _tree?.ExpandAll()">Expand</button>
|
<button class="btn btn-outline-secondary" aria-label="Expand all areas" @onclick="() => _tree?.ExpandAll()">Expand</button>
|
||||||
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
|
<button class="btn btn-outline-secondary" aria-label="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -249,7 +249,9 @@
|
|||||||
_ = _pollGate.RunAsync(
|
_ = _pollGate.RunAsync(
|
||||||
() => InvokeAsync(async () =>
|
() => 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
|
// Staleness is deliberately OFF the live loop: it only changes when a
|
||||||
// template/instance is edited or redeployed, and recomputing it (a
|
// template/instance is edited or redeployed, and recomputing it (a
|
||||||
// full re-flatten per deployed instance) every 15s is the page's
|
// full re-flatten per deployed instance) every 15s is the page's
|
||||||
@@ -396,6 +398,8 @@
|
|||||||
_branchLoading = true;
|
_branchLoading = true;
|
||||||
try
|
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))
|
if (!_loadedSites.Contains(siteId))
|
||||||
{
|
{
|
||||||
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
|
_allAreas.AddRange(await TemplateEngineRepository.GetAreasBySiteIdAsync(siteId));
|
||||||
@@ -601,7 +605,7 @@
|
|||||||
var loaded = _loadedPages.TryGetValue((node.SiteId, areaId), out var offset);
|
var loaded = _loadedPages.TryGetValue((node.SiteId, areaId), out var offset);
|
||||||
@if (!loaded)
|
@if (!loaded)
|
||||||
{
|
{
|
||||||
<button class="btn btn-link btn-sm" disabled="@_branchLoading" @onclick="() => LoadBranchAsync(node.SiteId, areaId)">Load instances</button>
|
<button class="btn btn-link btn-sm" disabled="@(_branchLoading || _actionInProgress)" @onclick="() => LoadBranchAsync(node.SiteId, areaId)">Load instances</button>
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -1052,7 +1056,8 @@
|
|||||||
{
|
{
|
||||||
var user = await GetCurrentUserAsync(inst.Id);
|
var user = await GetCurrentUserAsync(inst.Id);
|
||||||
if (user is null) return;
|
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<DeploymentService>().DeployInstanceAsync(inst.Id, user);
|
||||||
if (result.IsSuccess)
|
if (result.IsSuccess)
|
||||||
{
|
{
|
||||||
// Revision hashes are normally 64 hex chars, but the slice must
|
// Revision hashes are normally 64 hex chars, but the slice must
|
||||||
@@ -1088,7 +1093,11 @@
|
|||||||
var user = await GetSiteDeployerAsync(siteId);
|
var user = await GetSiteDeployerAsync(siteId);
|
||||||
if (user is null) return;
|
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<DeploymentService>();
|
||||||
|
var preview = await deployment.PreviewSubtreeDeployAsync(siteId, areaId);
|
||||||
if (!preview.IsSuccess)
|
if (!preview.IsSuccess)
|
||||||
{
|
{
|
||||||
_toast.ShowError($"Deploy all failed: {preview.Error}");
|
_toast.ShowError($"Deploy all failed: {preview.Error}");
|
||||||
@@ -1109,8 +1118,8 @@
|
|||||||
if (choice is null) return;
|
if (choice is null) return;
|
||||||
|
|
||||||
var result = isArea
|
var result = isArea
|
||||||
? await DeploymentService.DeployAreaAsync(node.EntityId, user, choice.Force)
|
? await deployment.DeployAreaAsync(node.EntityId, user, choice.Force)
|
||||||
: await DeploymentService.DeploySiteAsync(siteId, user, choice.Force);
|
: await deployment.DeploySiteAsync(siteId, user, choice.Force);
|
||||||
if (!result.IsSuccess)
|
if (!result.IsSuccess)
|
||||||
{
|
{
|
||||||
_toast.ShowError($"Deploy all failed: {result.Error}");
|
_toast.ShowError($"Deploy all failed: {result.Error}");
|
||||||
|
|||||||
@@ -13,12 +13,12 @@
|
|||||||
Deploy all instances under <strong data-test="deploy-all-scope">@ScopeLabel</strong>?
|
Deploy all instances under <strong data-test="deploy-all-scope">@ScopeLabel</strong>?
|
||||||
</p>
|
</p>
|
||||||
<p class="mb-2 small" data-test="deploy-all-counts">
|
<p class="mb-2 small" data-test="deploy-all-counts">
|
||||||
<span class="fw-semibold">@Total</span> instance@(Total == 1 ? "" : "s")
|
<span class="fw-semibold">@Total</span> instance@(Total == 1 ? "" : "s") in scope@(Disabled > 0 ? "" : ".")
|
||||||
@if (Disabled > 0)
|
@if (Disabled > 0)
|
||||||
{
|
{
|
||||||
<span class="text-muted"> (@Disabled disabled — will be skipped)</span>
|
<span>, @Disabled disabled (always skipped).</span>
|
||||||
}
|
}
|
||||||
. Instances already running the current configuration are skipped.
|
Unchanged instances are skipped unless Force is checked.
|
||||||
</p>
|
</p>
|
||||||
<div class="form-check mb-1">
|
<div class="form-check mb-1">
|
||||||
<input type="checkbox" class="form-check-input" id="deploy-all-force" data-test="deploy-all-force"
|
<input type="checkbox" class="form-check-input" id="deploy-all-force" data-test="deploy-all-force"
|
||||||
|
|||||||
+1
-1
@@ -329,7 +329,7 @@ public class TopologyAreaTests : IClassFixture<DeploymentFixture>
|
|||||||
|
|
||||||
var toast = page.Locator(".toast", new() { HasText = $"Deploy all under '{areaName}'" });
|
var toast = page.Locator(".toast", new() { HasText = $"Deploy all under '{areaName}'" });
|
||||||
await Assertions.Expect(toast).ToBeVisibleAsync(new() { Timeout = 60_000 });
|
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
|
finally
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -318,6 +318,42 @@ public class TopologyPageTests : BunitContext
|
|||||||
_repo.DidNotReceive().GetAreaByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
_repo.DidNotReceive().GetAreaByIdAsync(Arg.Any<int>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>A failing instance yields an error toast listing the failure, not the success toast.</summary>
|
||||||
|
[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<CancellationToken>())
|
||||||
|
.Returns(Task.FromResult<IReadOnlyList<Instance>>(new List<Instance> { failing }));
|
||||||
|
_pipeline.FlattenAndValidateAsync(Arg.Any<int>(), Arg.Any<CancellationToken>(), Arg.Any<bool>())
|
||||||
|
.Returns(Task.FromResult(Result<FlatteningPipelineResult>.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);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>A saved (collapsed) expansion state wins over InitiallyExpanded for sites.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void InitialLoad_SavedCollapsedState_WinsOverInitiallyExpanded()
|
||||||
|
{
|
||||||
|
SeedRepos(sites: [new Site("Plant", "plant") { Id = 1 }],
|
||||||
|
areasBySite: new() { [1] = new List<Area> { new("Line A") { Id = 10, SiteId = 1 } } });
|
||||||
|
JSInterop.Setup<string?>("treeviewStorage.load", _ => true).SetResult("[]");
|
||||||
|
var cut = Render<TopologyPage>();
|
||||||
|
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"));
|
||||||
|
}
|
||||||
|
|
||||||
/// <summary>Cancel performs no deploy (only the preview read happened).</summary>
|
/// <summary>Cancel performs no deploy (only the preview read happened).</summary>
|
||||||
[Fact]
|
[Fact]
|
||||||
public void DeployAll_Cancel_DoesNothing()
|
public void DeployAll_Cancel_DoesNothing()
|
||||||
|
|||||||
Reference in New Issue
Block a user