From 97eba1e8325d6013124dd2cf63919ca80e39c930 Mon Sep 17 00:00:00 2001 From: acoshift Date: Thu, 11 Jun 2026 13:10:21 +0700 Subject: [PATCH 1/3] Show what's being rolled back in the rollback confirmation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rollback confirm was a bare SweetAlert title ("Rollback web to revision 6") with no detail. Replace it with an in-page modal that shows the currently active revision and the rollback target side by side — revision number, image, deploy time, and author — plus a note that rollback redeploys the target's image and configuration as a new revision without deleting history. In-page modal (not modal.confirm html) so the user-provided image refs render through Svelte interpolation. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../deployment/(detail)/revision/+page.svelte | 185 ++++++++++++++++-- 1 file changed, 166 insertions(+), 19 deletions(-) diff --git a/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte b/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte index 9f3b241..85d497b 100644 --- a/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte +++ b/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte @@ -29,25 +29,55 @@ return `${Math.floor(diff / 86_400_000)}d ago` } - /** @param {number} toRevision */ - function rollback (toRevision) { - modal.confirm({ - title: `Rollback ${deployment.name} to revision ${toRevision}`, - yes: 'Rollback', - callback: async () => { - const resp = await api.invoke('deployment.rollback', { - project: deployment.project, - location: deployment.location, - name: deployment.name, - revision: toRevision - }, fetch) - if (!resp.ok) { - modal.error({ error: resp.error }) - return - } - goto(`/deployment/detail?project=${deployment.project}&location=${deployment.location}&name=${deployment.name}`) + // The currently running revision — the page renders revisions newest-first + // and marks index 0 as Active. + const activeRevision = $derived(revisions[0]) + + // Rollback confirmation modal. An in-page modal (not modal.confirm) so the + // user sees exactly what changes hands: the active revision they're leaving + // and the revision whose image + configuration will be redeployed. Both + // carry user-provided content (image refs), which Svelte interpolation + // escapes for free. + let rollbackTarget = $state(/** @type {?Api.Deployment} */ (null)) + let rollingBack = $state(false) + + /** @param {Api.Deployment} it */ + function rollback (it) { + rollbackTarget = it + } + + function closeRollback () { + if (rollingBack) return + rollbackTarget = null + } + + /** + * Close only on a true backdrop click, not on clicks inside the panel. + * @param {MouseEvent} e + */ + function onRollbackBackdrop (e) { + if (e.target === e.currentTarget) closeRollback() + } + + async function confirmRollback () { + if (rollingBack || !rollbackTarget) return + rollingBack = true + try { + const resp = await api.invoke('deployment.rollback', { + project: deployment.project, + location: deployment.location, + name: deployment.name, + revision: rollbackTarget.revision + }, fetch) + if (!resp.ok) { + modal.error({ error: resp.error }) + return } - }) + rollbackTarget = null + goto(`/deployment/detail?project=${deployment.project}&location=${deployment.location}&name=${deployment.name}`) + } finally { + rollingBack = false + } } @@ -262,6 +292,72 @@ border-color: hsl(var(--hsl-content) / 0.2); } + .rb-compare { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: stretch; + gap: 0.75rem; + } + + .rb-side { + min-width: 0; + padding: 0.75rem 0.9rem; + border: 1px solid hsl(var(--hsl-content) / 0.1); + border-radius: 8px; + background: hsl(var(--hsl-content) / 0.03); + } + + .rb-side[data-kind='to'] { + border-color: hsl(var(--hsl-primary) / 0.35); + background: hsl(var(--hsl-primary) / 0.06); + } + + .rb-side__label { + font-size: 0.6875rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + color: hsl(var(--hsl-content) / 0.5); + } + + .rb-side[data-kind='to'] .rb-side__label { + color: hsl(var(--hsl-primary)); + } + + .rb-side__rev { + margin-top: 0.25rem; + font-family: var(--ffml-mono); + font-size: 1.25rem; + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + .rb-side[data-kind='to'] .rb-side__rev { + color: hsl(var(--hsl-primary)); + } + + .rb-side__image { + margin-top: 0.35rem; + font-size: 0.8125rem; + overflow-wrap: anywhere; + } + + .rb-side__meta { + margin-top: 0.35rem; + font-size: 0.6875rem; + color: hsl(var(--hsl-content) / 0.5); + } + + .rb-arrow { + align-self: center; + color: hsl(var(--hsl-content) / 0.35); + } + + @media (max-width: 640px) { + .rb-compare { grid-template-columns: 1fr; } + .rb-arrow { transform: rotate(90deg); justify-self: center; } + } + @media (max-width: 768px) { .rev-row { grid-template-columns: @@ -316,7 +412,7 @@ {#if i > 0} @@ -326,3 +422,54 @@ {/each} + + + From cd73a5f86a5aed22e528ca45bacdc02baaf84655 Mon Sep 17 00:00:00 2001 From: acoshift Date: Thu, 11 Jun 2026 13:22:43 +0700 Subject: [PATCH 2/3] Match rollback note to actual apiserver semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit deployment.rollback re-deploys the target revision's image, env (+ groups), scaling, type/port, command/args, workload identity, pull secret, disks, schedule, and resources — but passes no sidecars or mountData, which Deploy treats as "keep current". Say so in the modal instead of implying everything is restored. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../(project)/deployment/(detail)/revision/+page.svelte | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte b/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte index 85d497b..8fa5cd6 100644 --- a/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte +++ b/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte @@ -458,7 +458,9 @@

This redeploys revision #{rollbackTarget.revision}’s image and - configuration (env, scaling, command) as a new revision. No history is deleted. + configuration (env, scaling, command, disks, schedule) as a new revision. + Sidecars and mounted files keep their current configuration. No history is + deleted.

{/if} From bd5968b0809ef303f512e133dbfde16322055050 Mon Sep 17 00:00:00 2001 From: acoshift Date: Thu, 11 Jun 2026 13:30:10 +0700 Subject: [PATCH 3/3] Widen rollback modal and show the configuration diff Fetch the target revision's full spec (deployment.get with revision) when the modal opens and list what actually changes hands: settings rows (scaling, port, schedule, command, args, env groups, workload identity, pull secret, disk, memory) and a per-key env diff with added/removed/changed badges, current vs after-rollback. Falls back gracefully while loading, on fetch failure, and when only the image differs. The panel grows to 56rem, scrolls when the diff is long, and the comparison cards stack on small screens. Mock deployment.get now honors the revision arg with per-revision differences so the diff renders in offline dev. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/lib/server/mock.js | 33 ++- .../deployment/(detail)/revision/+page.svelte | 193 ++++++++++++++++++ 2 files changed, 219 insertions(+), 7 deletions(-) diff --git a/src/lib/server/mock.js b/src/lib/server/mock.js index 01bbdb7..0d16595 100644 --- a/src/lib/server/mock.js +++ b/src/lib/server/mock.js @@ -759,18 +759,37 @@ const handlers = { 'location.get': (args) => ok(locations.find((l) => l.id === args?.location) ?? locations[0]), 'deployment.list': () => list(deployments), - 'deployment.get': (args) => ok({ ...deployment(args?.project), name: args?.name ?? 'web', location: args?.location ?? LOCATION_ID }), + // revision > 0 returns that revision's historical spec (like the real + // deployment.get), with per-revision differences so the rollback modal's + // config diff has something to show. + 'deployment.get': (args) => { + const base = { ...deployment(args?.project), name: args?.name ?? 'web', location: args?.location ?? LOCATION_ID } + const revision = Number(args?.revision ?? 0) + if (!revision || revision >= base.revision) return ok(base) + return ok({ + ...base, + revision, + image: base.image.replace(/:latest$/, `:v${revision}`), + env: { NODE_ENV: 'production', PORT: '8080', LOG_LEVEL: 'debug', FEATURE_FLAGS: 'beta-checkout' }, + maxReplicas: 2, + command: revision === 5 ? ['node', 'server.js'] : base.command + }) + }, 'deployment.deploy': () => ok({}), 'deployment.delete': () => ok({}), 'deployment.pause': () => ok({}), 'deployment.resume': () => ok({}), 'deployment.rollback': () => ok({}), - 'deployment.revisions': (args) => list([7, 6, 5].map((revision) => ({ - ...deployment(args?.project), - name: args?.name ?? 'web', - location: args?.location ?? LOCATION_ID, - revision - }))), + 'deployment.revisions': (args) => list([7, 6, 5].map((revision) => { + const base = deployment(args?.project) + return { + ...base, + name: args?.name ?? 'web', + location: args?.location ?? LOCATION_ID, + revision, + image: revision === base.revision ? base.image : base.image.replace(/:latest$/, `:v${revision}`) + } + })), 'deployment.metrics': () => ok({ cpuUsage: metricLine('web', 0.3), cpuLimit: metricLine('limit', 0.5), diff --git a/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte b/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte index 8fa5cd6..08d92fe 100644 --- a/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte +++ b/src/routes/(auth)/(project)/deployment/(detail)/revision/+page.svelte @@ -41,14 +41,105 @@ let rollbackTarget = $state(/** @type {?Api.Deployment} */ (null)) let rollingBack = $state(false) + // Full spec of the rollback target (deployment.get with revision returns + // the historical spec), fetched when the modal opens so the diff below the + // comparison cards can show exactly which configuration changes hands. + let targetSpec = $state(/** @type {?Api.Deployment} */ (null)) + let targetLoading = $state(false) + /** @param {Api.Deployment} it */ function rollback (it) { rollbackTarget = it + loadTargetSpec(it.revision) + } + + /** @param {number} revision */ + async function loadTargetSpec (revision) { + targetSpec = null + targetLoading = true + try { + /** @type {Api.Response} */ + const resp = await api.invoke('deployment.get', { + project: deployment.project, + location: deployment.location, + name: deployment.name, + revision + }, fetch) + // Stale guard: the user may have switched targets while this was in + // flight. + if (resp.ok && rollbackTarget?.revision === revision) { + targetSpec = resp.result ?? null + } + } finally { + targetLoading = false + } } + /** + * @param {string[]} [xs] + * @returns {string} + */ + function joinOrDash (xs) { + return xs?.length ? xs.join(' ') : '—' + } + + // Settings that rollback will change, current → target. Image is omitted — + // the comparison cards above already show it side by side. + const specChanges = $derived.by(() => { + const t = targetSpec + if (!t) return [] + /** @type {{ label: string, from: string, to: string }[]} */ + const rows = [] + /** + * @param {string} label + * @param {string} from + * @param {string} to + */ + const add = (label, from, to) => { + if (from !== to) rows.push({ label, from, to }) + } + add('Type', deployment.type, t.type) + add('Port', String(deployment.port || '—'), String(t.port || '—')) + add('Scaling', `${deployment.minReplicas}–${deployment.maxReplicas} replicas`, `${t.minReplicas}–${t.maxReplicas} replicas`) + add('Schedule', deployment.schedule || '—', t.schedule || '—') + add('Command', joinOrDash(deployment.command), joinOrDash(t.command)) + add('Args', joinOrDash(deployment.args), joinOrDash(t.args)) + add('Env groups', deployment.envGroups?.join(', ') || '—', t.envGroups?.join(', ') || '—') + add('Workload identity', deployment.workloadIdentity || '—', t.workloadIdentity || '—') + add('Pull secret', deployment.pullSecret || '—', t.pullSecret || '—') + add('Disk', + deployment.disk ? `${deployment.disk.name} @ ${deployment.disk.mountPath}` : '—', + t.disk ? `${t.disk.name} @ ${t.disk.mountPath}` : '—') + add('Memory', deployment.resources?.requests?.memory || '—', t.resources?.requests?.memory || '—') + return rows + }) + + // Per-key env diff, current → target. + const envChanges = $derived.by(() => { + const t = targetSpec + if (!t) return [] + const from = deployment.env ?? {} + const to = t.env ?? {} + const keys = [...new Set([...Object.keys(from), ...Object.keys(to)])].sort() + /** @type {{ key: string, kind: 'added' | 'removed' | 'changed', from: string, to: string }[]} */ + const rows = [] + for (const key of keys) { + const a = from[key] + const b = to[key] + if (a === b) continue + if (a === undefined) rows.push({ key, kind: 'added', from: '', to: b }) + else if (b === undefined) rows.push({ key, kind: 'removed', from: a, to: '' }) + else rows.push({ key, kind: 'changed', from: a, to: b }) + } + return rows + }) + + const hasChanges = $derived(specChanges.length > 0 || envChanges.length > 0) + function closeRollback () { if (rollingBack) return rollbackTarget = null + targetSpec = null } /** @@ -358,6 +449,55 @@ .rb-arrow { transform: rotate(90deg); justify-self: center; } } + /* Roomier panel: the diff table needs width, and the whole panel scrolls + when the diff is long. */ + .modal-panel { + width: 100%; + max-width: 56rem; + max-height: calc(100dvh - 3rem); + overflow-y: auto; + } + + .rb-diff__head { margin-bottom: 0.75rem; } + + .rb-diff__table { + max-height: 19rem; + overflow-y: auto; + } + + .rb-diff__field { + font-weight: 600; + white-space: nowrap; + } + + .rb-diff__from { color: hsl(var(--hsl-content) / 0.55); } + .rb-diff__from, .rb-diff__to { overflow-wrap: anywhere; } + + .rb-env-badge { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.1rem; + height: 1.1rem; + margin-right: 0.4rem; + border-radius: 4px; + font-family: var(--ffml-mono); + font-size: 0.75rem; + font-weight: 700; + color: hsl(var(--hsl-content) / 0.6); + background: hsl(var(--hsl-content) / 0.08); + } + + .rb-env-badge[data-kind='added'] { + color: hsl(var(--hsl-positive)); + background: hsl(var(--hsl-positive) / 0.12); + } + + .rb-env-badge[data-kind='removed'] { + color: hsl(var(--hsl-negative)); + background: hsl(var(--hsl-negative) / 0.12); + } + @media (max-width: 768px) { .rev-row { grid-template-columns: @@ -456,6 +596,59 @@ +
+
+
Configuration changes
+

+ What changes when revision #{rollbackTarget.revision}’s configuration is reapplied. +

+
+ + {#if targetLoading} +

Loading revision #{rollbackTarget.revision}…

+ {:else if !targetSpec} +

Couldn’t load revision #{rollbackTarget.revision}’s configuration — only the image comparison above is shown.

+ {:else if !hasChanges} +

+ + Same configuration — only the image differs. +

+ {:else} +
+ + + + + + + + + + {#each specChanges as c (c.label)} + + + + + + {/each} + {#each envChanges as c (c.key)} + + + + + + {/each} + +
SettingCurrentAfter rollback
{c.label}{c.from}{c.to}
+ + {c.kind === 'added' ? '+' : c.kind === 'removed' ? '−' : '±'} + + {c.key} + {c.kind === 'added' ? '—' : c.from}{c.kind === 'removed' ? '—' : c.to}
+
+ {/if} +
+

This redeploys revision #{rollbackTarget.revision}’s image and configuration (env, scaling, command, disks, schedule) as a new revision.