feat(web): bidirectional stage promotion + confirm dialog

Completes env-promotion #5. The promote-from endpoint (admin, tested) and the
parent->self chain UI already shipped; the endpoint is direction-agnostic, so
this is frontend-only:
- self->child promotion: each eligible child (image source, non-preview) in
  the stage chain gets a "Promote to this" button that pushes this workload's
  running version to that child.
- both directions route through a ConfirmDialog (promote copies the source's
  running tag onto the target AND deploys it) with a success toast.
- i18n apps.detail.chain* in en + ru (parity 1810/1810).
This commit is contained in:
2026-06-22 16:04:28 +03:00
parent 7733e64b08
commit 8a5f69af87
3 changed files with 85 additions and 18 deletions
+6
View File
@@ -1929,7 +1929,13 @@
"chainSelfLabel": "This",
"chainChildrenLabel": "Children",
"chainPromoteButton": "Promote from parent",
"chainPromoteToChild": "Promote to this",
"chainPromoting": "Promoting…",
"chainPromoteConfirmTitle": "Promote version?",
"chainPromoteConfirmMessage": "Promote the running version of {source} to {target} and deploy it?",
"chainPromoteConfirmYes": "Promote & deploy",
"chainPromoteOk": "Promoted {tag} to {target}",
"chainPromoteFailed": "Promotion failed",
"chainHint": "Set <code>parent_workload_id</code> on a workload to build a chain. Image-source children can promote the parent's currently-running tag with one click.",
"previews": {
"title": "Preview environments",
+6
View File
@@ -1929,7 +1929,13 @@
"chainSelfLabel": "Эта",
"chainChildrenLabel": "Дочерние",
"chainPromoteButton": "Продвинуть от родителя",
"chainPromoteToChild": "Продвинуть сюда",
"chainPromoting": "Продвижение…",
"chainPromoteConfirmTitle": "Продвинуть версию?",
"chainPromoteConfirmMessage": "Продвинуть запущенную версию {source} в {target} и развернуть её?",
"chainPromoteConfirmYes": "Продвинуть и развернуть",
"chainPromoteOk": "Версия {tag} продвинута в {target}",
"chainPromoteFailed": "Не удалось продвинуть",
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
"previews": {
"title": "Превью-окружения",
+73 -18
View File
@@ -28,6 +28,7 @@
} from '$lib/components/icons';
import ForgeHero from '$lib/components/ForgeHero.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import { toasts } from '$lib/stores/toast';
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
import ContainerStats from '$lib/components/ContainerStats.svelte';
@@ -405,7 +406,15 @@
// ── Chain (parent / self / children) ──────────────────────
let chain = $state<api.WorkloadChain | null>(null);
let chainError = $state('');
let promoting = $state<string | null>(null); // sourceID we're promoting FROM, for UI lock
let promoting = $state<string | null>(null); // active targetID, for UI lock
// Pending promotion awaiting confirmation. Promote copies the source's
// running image tag onto the target and deploys it, so it's confirmed first.
let confirmPromote = $state<{
targetID: string;
sourceID: string;
targetName: string;
sourceName: string;
} | null>(null);
// ── Branch preview environments ───────────────────────────
// Preview children are the chain children the backend flagged
@@ -1083,14 +1092,27 @@
}
}
async function promoteFrom(sourceID: string) {
// Open the confirm dialog for a promotion. Direction is encoded by which
// (target, source) pair is passed: parent→self pulls the parent's version
// into this workload; self→child pushes this workload's version to a child.
function askPromote(targetID: string, sourceID: string, targetName: string, sourceName: string) {
confirmPromote = { targetID, sourceID, targetName, sourceName };
}
async function doPromote() {
const p = confirmPromote;
confirmPromote = null;
if (!p) return;
chainError = '';
promoting = sourceID;
promoting = p.targetID;
try {
await api.promoteFromWorkload(id, sourceID, { deploy: true });
const res = await api.promoteFromWorkload(p.targetID, p.sourceID, { deploy: true });
toasts.success(
$t('apps.detail.chainPromoteOk', { tag: res.promoted_tag, target: p.targetName })
);
await load();
} catch (e) {
chainError = e instanceof Error ? e.message : 'Promote failed';
chainError = e instanceof Error ? e.message : $t('apps.detail.chainPromoteFailed');
} finally {
promoting = null;
}
@@ -2671,9 +2693,10 @@
<button
class="forge-btn-ghost"
disabled={promoting !== null}
onclick={() => promoteFrom(chain!.parent!.id)}
onclick={() =>
askPromote(id, chain!.parent!.id, workload?.name ?? '', chain!.parent!.name)}
>
{promoting === chain.parent.id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
{promoting === id ? $t('apps.detail.chainPromoting') : $t('apps.detail.chainPromoteButton')}
</button>
{/if}
</div>
@@ -2700,17 +2723,30 @@
<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
<div class="chain-children-list">
{#each chain.children as child (child.id)}
<a class="chain-card" href={`/apps/${child.id}`}>
<span class="chain-name">
{child.name}
{#if child.is_preview}
<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
>{$t('apps.detail.previews.tag')}</span
>
{/if}
</span>
<span class="mono muted">{child.source_kind}</span>
</a>
<div class="chain-child-item">
<a class="chain-card" href={`/apps/${child.id}`}>
<span class="chain-name">
{child.name}
{#if child.is_preview}
<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
>{$t('apps.detail.previews.tag')}</span
>
{/if}
</span>
<span class="mono muted">{child.source_kind}</span>
</a>
{#if workload?.source_kind === 'image' && child.source_kind === 'image' && !child.is_preview}
<button
class="forge-btn-ghost"
disabled={promoting !== null}
onclick={() => askPromote(child.id, id, child.name, workload?.name ?? '')}
>
{promoting === child.id
? $t('apps.detail.chainPromoting')
: $t('apps.detail.chainPromoteToChild')}
</button>
{/if}
</div>
{/each}
</div>
</div>
@@ -3283,6 +3319,19 @@
}}
/>
<ConfirmDialog
open={confirmPromote !== null}
title={$t('apps.detail.chainPromoteConfirmTitle')}
message={$t('apps.detail.chainPromoteConfirmMessage', {
source: confirmPromote?.sourceName ?? '',
target: confirmPromote?.targetName ?? ''
})}
confirmLabel={$t('apps.detail.chainPromoteConfirmYes')}
confirmVariant="primary"
onconfirm={doPromote}
oncancel={() => (confirmPromote = null)}
/>
<ConfirmDialog
open={confirmUnbindId !== null}
title={$t('apps.detail.bindings.unbindTitle')}
@@ -4428,6 +4477,12 @@
flex-wrap: wrap;
gap: 0.5rem;
}
/* A child entry pairs its card with an optional "Promote to this" button. */
.chain-child-item {
display: flex;
align-items: center;
gap: 0.5rem;
}
@media (max-width: 600px) {
.chain-row {
flex-direction: column;