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",
|
"chainSelfLabel": "This",
|
||||||
"chainChildrenLabel": "Children",
|
"chainChildrenLabel": "Children",
|
||||||
"chainPromoteButton": "Promote from parent",
|
"chainPromoteButton": "Promote from parent",
|
||||||
|
"chainPromoteToChild": "Promote to this",
|
||||||
"chainPromoting": "Promoting…",
|
"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.",
|
"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": {
|
"previews": {
|
||||||
"title": "Preview environments",
|
"title": "Preview environments",
|
||||||
|
|||||||
@@ -1929,7 +1929,13 @@
|
|||||||
"chainSelfLabel": "Эта",
|
"chainSelfLabel": "Эта",
|
||||||
"chainChildrenLabel": "Дочерние",
|
"chainChildrenLabel": "Дочерние",
|
||||||
"chainPromoteButton": "Продвинуть от родителя",
|
"chainPromoteButton": "Продвинуть от родителя",
|
||||||
|
"chainPromoteToChild": "Продвинуть сюда",
|
||||||
"chainPromoting": "Продвижение…",
|
"chainPromoting": "Продвижение…",
|
||||||
|
"chainPromoteConfirmTitle": "Продвинуть версию?",
|
||||||
|
"chainPromoteConfirmMessage": "Продвинуть запущенную версию {source} в {target} и развернуть её?",
|
||||||
|
"chainPromoteConfirmYes": "Продвинуть и развернуть",
|
||||||
|
"chainPromoteOk": "Версия {tag} продвинута в {target}",
|
||||||
|
"chainPromoteFailed": "Не удалось продвинуть",
|
||||||
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
|
"chainHint": "Задайте <code>parent_workload_id</code> у нагрузки, чтобы построить цепочку. Дочерние image-источники могут одним кликом продвинуть текущий запущенный тег родителя.",
|
||||||
"previews": {
|
"previews": {
|
||||||
"title": "Превью-окружения",
|
"title": "Превью-окружения",
|
||||||
|
|||||||
@@ -28,6 +28,7 @@
|
|||||||
} from '$lib/components/icons';
|
} from '$lib/components/icons';
|
||||||
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
import ForgeHero from '$lib/components/ForgeHero.svelte';
|
||||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||||
|
import { toasts } from '$lib/stores/toast';
|
||||||
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
import EventLogEntryComponent from '$lib/components/EventLogEntry.svelte';
|
||||||
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
import ContainerLogs from '$lib/components/ContainerLogs.svelte';
|
||||||
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
import ContainerStats from '$lib/components/ContainerStats.svelte';
|
||||||
@@ -405,7 +406,15 @@
|
|||||||
// ── Chain (parent / self / children) ──────────────────────
|
// ── Chain (parent / self / children) ──────────────────────
|
||||||
let chain = $state<api.WorkloadChain | null>(null);
|
let chain = $state<api.WorkloadChain | null>(null);
|
||||||
let chainError = $state('');
|
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 ───────────────────────────
|
// ── Branch preview environments ───────────────────────────
|
||||||
// Preview children are the chain children the backend flagged
|
// 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 = '';
|
chainError = '';
|
||||||
promoting = sourceID;
|
promoting = p.targetID;
|
||||||
try {
|
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();
|
await load();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
chainError = e instanceof Error ? e.message : 'Promote failed';
|
chainError = e instanceof Error ? e.message : $t('apps.detail.chainPromoteFailed');
|
||||||
} finally {
|
} finally {
|
||||||
promoting = null;
|
promoting = null;
|
||||||
}
|
}
|
||||||
@@ -2671,9 +2693,10 @@
|
|||||||
<button
|
<button
|
||||||
class="forge-btn-ghost"
|
class="forge-btn-ghost"
|
||||||
disabled={promoting !== null}
|
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>
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -2700,17 +2723,30 @@
|
|||||||
<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
|
<span class="chain-label">{$t('apps.detail.chainChildrenLabel')}</span>
|
||||||
<div class="chain-children-list">
|
<div class="chain-children-list">
|
||||||
{#each chain.children as child (child.id)}
|
{#each chain.children as child (child.id)}
|
||||||
<a class="chain-card" href={`/apps/${child.id}`}>
|
<div class="chain-child-item">
|
||||||
<span class="chain-name">
|
<a class="chain-card" href={`/apps/${child.id}`}>
|
||||||
{child.name}
|
<span class="chain-name">
|
||||||
{#if child.is_preview}
|
{child.name}
|
||||||
<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
|
{#if child.is_preview}
|
||||||
>{$t('apps.detail.previews.tag')}</span
|
<span class="preview-tag" title={$t('apps.detail.previews.tagTitle')}
|
||||||
>
|
>{$t('apps.detail.previews.tag')}</span
|
||||||
{/if}
|
>
|
||||||
</span>
|
{/if}
|
||||||
<span class="mono muted">{child.source_kind}</span>
|
</span>
|
||||||
</a>
|
<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}
|
{/each}
|
||||||
</div>
|
</div>
|
||||||
</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
|
<ConfirmDialog
|
||||||
open={confirmUnbindId !== null}
|
open={confirmUnbindId !== null}
|
||||||
title={$t('apps.detail.bindings.unbindTitle')}
|
title={$t('apps.detail.bindings.unbindTitle')}
|
||||||
@@ -4428,6 +4477,12 @@
|
|||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
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) {
|
@media (max-width: 600px) {
|
||||||
.chain-row {
|
.chain-row {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Reference in New Issue
Block a user