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:
@@ -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",
|
||||
|
||||
@@ -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": "Превью-окружения",
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user