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:
Joseph Doherty
2026-10-22 14:22:47 -04:00
parent bcd087efe8
commit e029dc1ccb
4 changed files with 56 additions and 11 deletions
@@ -79,7 +79,7 @@
<button class="btn btn-outline-secondary" @onclick="OpenCreateAreaDialogRoot">+ Area</button>
<button class="btn btn-outline-secondary"
@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="Collapse all areas" @onclick="() => _tree?.CollapseAll()">Collapse</button>
</div>
@@ -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)
{
<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
{
@@ -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<DeploymentService>().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<DeploymentService>();
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}");
@@ -13,12 +13,12 @@
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")
<span class="fw-semibold">@Total</span> instance@(Total == 1 ? "" : "s") in scope@(Disabled > 0 ? "" : ".")
@if (Disabled > 0)
{
<span class="text-muted"> (@Disabled disabled &mdash; 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>
<div class="form-check mb-1">
<input type="checkbox" class="form-check-input" id="deploy-all-force" data-test="deploy-all-force"