feat(web): deploy-strategy selector UI for image/dockerfile/static sources

Phase-2 UI for the per-workload deploy_strategy shipped in e3d140c (which
was only reachable via the advanced-JSON editor). Adds DeployStrategyField,
a two-card radiogroup (recreate vs zero-downtime/blue-green) with CSS-only
motion glyphs that animate the deploy semantics — recreate shows the
downtime gap between versions, blue-green shows the overlapping cutover.
WAI-ARIA radiogroup with roving tabindex + arrow-key selection; respects
prefers-reduced-motion.

The field rides inside each source's *FormState via the shared sourceForms
module, so /apps/new and /apps/[id] need no changes:
- seed reads deploy_strategy; serialize is conditional-emit — the key is
  written ONLY when the operator deviates from the source default, so an
  untouched source_config stays byte-identical ('' is the canonical
  default, resolved by the backend's effectiveStrategy).
- dockerfile owns the key (form value wins, stale value scrubbed on clear).
- image defaults to blue-green; dockerfile/static default to recreate;
  static surfaces a caveat that storage-backed Deno sites fall back to
  recreate. Compose has no selector (recreate-only, blue-green rejected).

i18n apps.new.deployStrategy.* added to en+ru (parity 1750/1750). Extends
sourceForms.test.ts with seed/conditional-emit/owned-key/round-trip cases.
Verified: svelte-check 0 errors, 26/26 unit tests, build green.
This commit is contained in:
2026-06-19 17:09:17 +03:00
parent e3d140c57a
commit 5b51bbbd7f
8 changed files with 604 additions and 11 deletions
@@ -0,0 +1,468 @@
<!--
Deploy-strategy selector — phase-2 UI for the per-workload `deploy_strategy`
(backend: internal/workload/plugin/strategy.go). A two-card radiogroup that
picks between `recreate` (stop old → start new, brief downtime) and
`blue-green` (start new alongside old, health-check, swap traffic, retire
old — no downtime). Each card carries a CSS-only motion glyph that animates
the actual deploy semantics: recreate shows a downtime GAP between versions;
blue-green shows the two versions OVERLAP while traffic cuts over.
Empty-string canonical default
──────────────────────────────
`strategy === ''` means "use the source's historical default". We keep it as
`''` (rather than writing the explicit value) whenever the operator picks the
source default, so an untouched `source_config` stays byte-identical — see
DeployStrategy in $lib/workload/sourceForms. The card matching the source
default therefore reads as selected when `strategy` is empty, and clicking it
clears `strategy` back to `''`.
-->
<script lang="ts">
import type { DeployStrategy } from '$lib/workload/sourceForms';
import { t } from '$lib/i18n';
interface Props {
/** Bound strategy. "" = source default (kept canonical for byte-shape). */
strategy: DeployStrategy;
/** The source's effective default — labels the "default" pill and is
* the value `strategy === ''` resolves to visually. */
defaultStrategy: 'recreate' | 'blue-green';
/** Static-only: warn that storage-backed Deno sites fall back to recreate. */
denoCaveat?: boolean;
/** Unique id stem so multiple instances don't collide on the DOM. */
idPrefix?: string;
}
let {
strategy = $bindable(),
defaultStrategy,
denoCaveat = false,
idPrefix = 'deploy-strategy'
}: Props = $props();
type Concrete = 'recreate' | 'blue-green';
const OPTIONS: Concrete[] = ['recreate', 'blue-green'];
// What the backend will actually do given the current (possibly empty) value.
const effective = $derived<Concrete>(strategy === '' ? defaultStrategy : strategy);
function pick(value: Concrete) {
// Selecting the source default collapses back to "" so we never persist
// a redundant explicit value (keeps existing configs byte-identical).
strategy = value === defaultStrategy ? '' : value;
}
// WAI-ARIA radiogroup keyboard pattern: arrows move + select, roving
// tabindex keeps a single tab stop.
function onKey(e: KeyboardEvent, index: number) {
const next =
e.key === 'ArrowRight' || e.key === 'ArrowDown'
? (index + 1) % OPTIONS.length
: e.key === 'ArrowLeft' || e.key === 'ArrowUp'
? (index - 1 + OPTIONS.length) % OPTIONS.length
: -1;
if (next === -1) return;
e.preventDefault();
pick(OPTIONS[next]);
// Move focus to the newly-selected radio (roving tabindex).
const el = document.getElementById(`${idPrefix}-${OPTIONS[next]}`);
el?.focus();
}
function nameFor(o: Concrete): string {
return o === 'recreate'
? $t('apps.new.deployStrategy.recreateName')
: $t('apps.new.deployStrategy.blueGreenName');
}
function descFor(o: Concrete): string {
return o === 'recreate'
? $t('apps.new.deployStrategy.recreateDesc')
: $t('apps.new.deployStrategy.blueGreenDesc');
}
const showDenoCaveat = $derived(denoCaveat && effective === 'blue-green');
</script>
<section class="strategy-field">
<div class="strategy-head">
<span class="forge-eyebrow">
<span class="forge-ember"></span>
<span>{$t('apps.new.deployStrategy.label')}</span>
</span>
</div>
<div
class="strategy-grid"
role="radiogroup"
aria-label={$t('apps.new.deployStrategy.label')}
>
{#each OPTIONS as opt, i (opt)}
{@const selected = effective === opt}
<button
type="button"
id={`${idPrefix}-${opt}`}
role="radio"
aria-checked={selected}
tabindex={selected ? 0 : -1}
class="opt"
class:selected
class:is-recreate={opt === 'recreate'}
class:is-bluegreen={opt === 'blue-green'}
onclick={() => pick(opt)}
onkeydown={(e) => onKey(e, i)}
>
<!-- Motion glyph: animates only when the card is selected. -->
<span class="glyph" aria-hidden="true">
{#if opt === 'recreate'}
<span class="rc-bar v1"></span>
<span class="rc-gap"><span class="rc-gap-mark"></span></span>
<span class="rc-bar v2"></span>
{:else}
<span class="bg-bar v1"></span>
<span class="bg-bar v2"></span>
<span class="bg-traffic"></span>
{/if}
</span>
<span class="opt-meta">
<span class="opt-name">
{nameFor(opt)}
{#if opt === defaultStrategy}
<span class="opt-default">{$t('apps.new.deployStrategy.defaultBadge')}</span>
{/if}
</span>
<span class="opt-desc">{descFor(opt)}</span>
</span>
<span class="opt-tick" aria-hidden="true">
<svg viewBox="0 0 16 16" width="13" height="13">
<path
d="M3.5 8.5l3 3 6-7"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</span>
</button>
{/each}
</div>
{#if showDenoCaveat}
<p class="strategy-caveat" role="note">{$t('apps.new.deployStrategy.denoCaveat')}</p>
{/if}
</section>
<style>
.strategy-field {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.strategy-head {
display: flex;
align-items: center;
}
.strategy-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.6rem;
}
@media (max-width: 560px) {
.strategy-grid {
grid-template-columns: 1fr;
}
}
/* ── Option card ──────────────────────────────────────────── */
.opt {
position: relative;
display: grid;
grid-template-rows: auto auto;
gap: 0.55rem;
text-align: left;
padding: 0.75rem 0.8rem 0.7rem;
border: 1px solid var(--border-primary);
border-radius: var(--radius-lg);
background: var(--surface-card);
color: var(--text-secondary);
cursor: pointer;
transition:
border-color 140ms ease,
background 140ms ease,
box-shadow 140ms ease,
transform 140ms ease;
}
.opt:hover:not(.selected) {
border-color: color-mix(in srgb, var(--forge-accent) 45%, var(--border-primary));
background: var(--surface-card-hover);
}
.opt:focus-visible {
outline: none;
border-color: var(--forge-accent);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
.opt.selected {
border-color: var(--forge-accent);
background: var(--surface-card-hover);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
}
/* ── Meta (title + description) ───────────────────────────── */
.opt-meta {
display: flex;
flex-direction: column;
gap: 0.22rem;
min-width: 0;
}
.opt-name {
display: inline-flex;
align-items: center;
gap: 0.4rem;
font-weight: 600;
font-size: 0.9rem;
color: var(--text-primary);
letter-spacing: -0.01em;
}
.opt-default {
font-family: var(--forge-mono);
font-size: 0.54rem;
font-weight: 700;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.12rem 0.34rem;
border-radius: var(--radius-sm);
color: var(--text-tertiary);
background: color-mix(in srgb, var(--text-tertiary) 12%, transparent);
border: 1px solid var(--border-primary);
}
.opt.selected .opt-default {
color: var(--forge-accent);
background: var(--forge-accent-soft);
border-color: color-mix(in srgb, var(--forge-accent) 35%, transparent);
}
.opt-desc {
font-size: 0.76rem;
line-height: 1.45;
color: var(--text-tertiary);
}
/* ── Selected tick ────────────────────────────────────────── */
.opt-tick {
position: absolute;
top: 0.6rem;
right: 0.6rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
border-radius: 50%;
color: #fff;
background: var(--forge-accent);
opacity: 0;
transform: scale(0.6);
transition:
opacity 140ms ease,
transform 160ms cubic-bezier(0.34, 1.56, 0.64, 1);
}
.opt.selected .opt-tick {
opacity: 1;
transform: scale(1);
}
/* ── Motion glyph shell ───────────────────────────────────── */
.glyph {
position: relative;
display: block;
height: 30px;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--text-tertiary) 7%, transparent);
border: 1px solid color-mix(in srgb, var(--border-primary) 70%, transparent);
overflow: hidden;
}
/* Version-bar fills (amber = v1/old, green = v2/new). The amber stays on
brand; green reads as "the new one going live", anchoring blue-green. */
.v1 {
--bar: var(--forge-accent);
}
.v2 {
--bar: var(--color-success);
}
/* ── Recreate glyph: v1 | gap | v2 laid out as a timeline ─── */
.is-recreate .glyph {
display: flex;
align-items: center;
gap: 0;
padding: 0 0.45rem;
}
.rc-bar {
height: 9px;
border-radius: 999px;
background: var(--bar);
flex: 1 1 0;
}
.rc-bar.v1 {
opacity: 0.85;
}
.rc-bar.v2 {
opacity: 0.85;
}
.rc-gap {
flex: 0 0 22%;
display: flex;
align-items: center;
justify-content: center;
align-self: stretch;
}
/* The "downtime" notch between versions — a dashed seam tinted with the
warning hue. This visible gap is the whole point of recreate. */
.rc-gap-mark {
width: 100%;
height: 0;
border-top: 2px dashed color-mix(in srgb, var(--color-warning) 65%, transparent);
opacity: 0.7;
}
/* ── Blue-green glyph: stacked bars + a traffic dot cutting over ── */
.is-bluegreen .glyph {
padding: 0.45rem 0.5rem;
}
.bg-bar {
position: absolute;
left: 0.5rem;
right: 1.4rem;
height: 8px;
border-radius: 999px;
background: var(--bar);
}
.bg-bar.v1 {
top: 0.5rem;
opacity: 0.85;
}
.bg-bar.v2 {
bottom: 0.5rem;
opacity: 0.85;
}
/* Traffic indicator: a brand dot parked on whichever bar serves requests.
Static = on v2 (new). When selected it travels v1 → v2 (the cutover). */
.bg-traffic {
position: absolute;
right: 0.55rem;
width: 9px;
height: 9px;
border-radius: 50%;
background: var(--forge-accent);
box-shadow: 0 0 0 3px var(--forge-accent-soft);
bottom: 0.45rem;
}
/* ── Animation — only the SELECTED card moves (less visual noise) ── */
.opt.selected.is-recreate .rc-bar.v1 {
animation: rc-v1 2.6s ease-in-out infinite;
}
.opt.selected.is-recreate .rc-bar.v2 {
animation: rc-v2 2.6s ease-in-out infinite;
}
.opt.selected.is-recreate .rc-gap-mark {
animation: rc-gap 2.6s ease-in-out infinite;
}
@keyframes rc-v1 {
0%,
25% {
opacity: 0.9;
transform: scaleX(1);
}
45%,
100% {
opacity: 0.12;
transform: scaleX(0.96);
}
}
@keyframes rc-v2 {
0%,
55% {
opacity: 0.12;
transform: scaleX(0.96);
}
78%,
100% {
opacity: 0.9;
transform: scaleX(1);
}
}
@keyframes rc-gap {
0%,
30% {
opacity: 0.25;
}
45%,
60% {
opacity: 1;
}
80%,
100% {
opacity: 0.25;
}
}
.opt.selected.is-bluegreen .bg-bar.v1 {
animation: bg-v1 2.8s ease-in-out infinite;
}
.opt.selected.is-bluegreen .bg-traffic {
animation: bg-traffic 2.8s ease-in-out infinite;
}
@keyframes bg-v1 {
0%,
55% {
opacity: 0.9;
}
/* v1 dims to "retired" AFTER traffic has moved — never a service gap. */
80%,
100% {
opacity: 0.3;
}
}
@keyframes bg-traffic {
0%,
20% {
top: 0.45rem;
bottom: auto;
}
50%,
100% {
top: auto;
bottom: 0.45rem;
}
}
/* Respect reduced-motion: drop the keyframes, keep the legible end-state
(recreate keeps its visible gap; blue-green keeps both bars + traffic). */
@media (prefers-reduced-motion: reduce) {
.opt .rc-bar,
.opt .rc-gap-mark,
.opt .bg-bar,
.opt .bg-traffic {
animation: none !important;
}
.opt {
transition: none;
}
}
/* ── Deno fallback caveat ─────────────────────────────────── */
.strategy-caveat {
margin: 0;
font-size: 0.74rem;
line-height: 1.45;
color: var(--color-warning);
padding: 0.4rem 0.55rem;
border-radius: var(--radius-md);
background: color-mix(in srgb, var(--color-warning) 9%, transparent);
border: 1px solid color-mix(in srgb, var(--color-warning) 28%, transparent);
}
</style>
@@ -15,6 +15,7 @@
import type { DockerfileFormState } from '$lib/workload/sourceForms';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
import { IconX } from '$lib/components/icons';
import { t } from '$lib/i18n';
@@ -147,6 +148,7 @@
{@html $t('apps.new.sourceReportCommitStatusDesc')}
</span>
</label>
<DeployStrategyField bind:strategy={form.deployStrategy} defaultStrategy="recreate" idPrefix="app-df-strategy" />
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
</div>
@@ -26,6 +26,7 @@
import * as api from '$lib/api';
import { IconSearch, IconLoader } from '$lib/components/icons';
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
import { t } from '$lib/i18n';
interface Props {
@@ -388,6 +389,7 @@
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
</label>
</div>
<DeployStrategyField bind:strategy={form.deployStrategy} defaultStrategy="blue-green" idPrefix="app-image-strategy" />
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
</div>
@@ -15,6 +15,7 @@
import type { FolderEntry } from '$lib/api';
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
import { t } from '$lib/i18n';
interface Props {
@@ -118,6 +119,12 @@
{@html $t('apps.new.sourceReportCommitStatusDesc')}
</span>
</label>
<DeployStrategyField
bind:strategy={form.deployStrategy}
defaultStrategy="recreate"
denoCaveat={form.mode === 'deno'}
idPrefix="app-static-strategy"
/>
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
</div>
+9
View File
@@ -1501,6 +1501,15 @@
"staticRenderMarkdownDesc": "— auto-render <code>.md</code> files as HTML pages.",
"sourceReportCommitStatus": "Report commit status",
"sourceReportCommitStatusDesc": "— report deploy status back to the Git provider as a commit status on the deployed commit.",
"deployStrategy": {
"label": "Deploy strategy",
"recreateName": "Recreate",
"recreateDesc": "Stop the old container, then start the new one. A brief window of downtime during the swap.",
"blueGreenName": "Zero-downtime",
"blueGreenDesc": "Start the new container, health-check it, then switch traffic and retire the old one. No downtime.",
"defaultBadge": "default",
"denoCaveat": "Deno sites with persistent storage fall back to recreate to avoid two writers on the same volume."
},
"staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.",
"staticDetectProvider": "Detect",
"staticDetectedOk": "Detected: {provider}",
+9
View File
@@ -1501,6 +1501,15 @@
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как HTML-страницы.",
"sourceReportCommitStatus": "Отправлять статус коммита",
"sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.",
"deployStrategy": {
"label": "Стратегия деплоя",
"recreateName": "Пересоздание",
"recreateDesc": "Остановить старый контейнер, затем запустить новый. Короткий простой во время замены.",
"blueGreenName": "Без простоя",
"blueGreenDesc": "Запустить новый контейнер, проверить health-check, переключить трафик и убрать старый. Без простоя.",
"defaultBadge": "по умолчанию",
"denoCaveat": "Deno-сайты с постоянным хранилищем используют пересоздание, чтобы избежать двух писателей на одном томе."
},
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
"staticDetectProvider": "Определить",
"staticDetectedOk": "Определено: {provider}",
+55 -1
View File
@@ -46,7 +46,8 @@ describe('image source', () => {
registryName: 'docker.io',
cpuLimit: 2,
memoryLimit: 512,
maxInstances: 3
maxInstances: 3,
deployStrategy: ''
});
});
@@ -294,3 +295,56 @@ describe('dockerfile source', () => {
expect(isDockerfileValid({ ...base, port: -1 })).toBe(false);
});
});
describe('deploy_strategy (cross-source)', () => {
it('seeds recognized strategies and drops junk to ""', () => {
expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'recreate' })).deployStrategy).toBe('recreate');
expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'blue-green' })).deployStrategy).toBe('blue-green');
expect(seedImageState(JSON.stringify({ image: 'x', deploy_strategy: 'rolling' })).deployStrategy).toBe('');
expect(seedDockerfileState(JSON.stringify({ deploy_strategy: 'blue-green' })).deployStrategy).toBe('blue-green');
expect(seedStaticState(JSON.stringify({ deploy_strategy: 'recreate' })).deployStrategy).toBe('recreate');
expect(seedStaticState(JSON.stringify({ deploy_strategy: 'nope' })).deployStrategy).toBe('');
});
it('omits deploy_strategy when empty so existing configs stay byte-identical', () => {
expect('deploy_strategy' in imageToConfig(emptyImageState(), '{}')).toBe(false);
expect('deploy_strategy' in dockerfileToConfig(emptyDockerfileState(), '{}')).toBe(false);
expect('deploy_strategy' in staticToConfig(emptyStaticState(), '{}')).toBe(false);
});
it('emits deploy_strategy at the end of the owned block when set', () => {
const img = imageToConfig({ ...emptyImageState(), deployStrategy: 'recreate' }, '{}');
expect(img.deploy_strategy).toBe('recreate');
expect(Object.keys(img).at(-1)).toBe('deploy_strategy');
const df = dockerfileToConfig({ ...emptyDockerfileState(), deployStrategy: 'blue-green' }, '{}');
expect(df.deploy_strategy).toBe('blue-green');
expect(Object.keys(df).at(-1)).toBe('deploy_strategy');
const st = staticToConfig({ ...emptyStaticState(), deployStrategy: 'blue-green' }, '{}');
expect(st.deploy_strategy).toBe('blue-green');
expect(Object.keys(st).at(-1)).toBe('deploy_strategy');
});
it('dockerfile owns deploy_strategy: form value wins, stale value scrubbed', () => {
// Form value overrides an existing stored strategy (owned key).
const overridden = dockerfileToConfig(
{ ...emptyDockerfileState(), deployStrategy: 'blue-green' },
JSON.stringify({ deploy_strategy: 'recreate', healthcheck: '/up' })
);
expect(overridden.deploy_strategy).toBe('blue-green');
expect(overridden.healthcheck).toBe('/up'); // unknown key still preserved
// Clearing back to default scrubs the stored key (no orphan recreate).
const cleared = dockerfileToConfig(
emptyDockerfileState(),
JSON.stringify({ deploy_strategy: 'blue-green' })
);
expect('deploy_strategy' in cleared).toBe(false);
});
it('round-trips an explicit strategy through serialize -> seed', () => {
const s = seedImageState(JSON.stringify({ image: 'app', deploy_strategy: 'recreate' }));
expect(seedImageState(stringifyConfig(imageToConfig(s, '{}'))).deployStrategy).toBe('recreate');
});
});
+52 -10
View File
@@ -21,6 +21,18 @@
export type GitProvider = 'gitea' | 'github' | 'gitlab';
/**
* Per-workload deploy strategy.
*
* `''` means "use the source's historical default" (image → blue-green,
* dockerfile / static → recreate). It is the canonical value we persist
* whenever the operator has NOT deviated from that default, so existing
* `source_config` blobs stay byte-identical and the key is only ever
* written when it actually differs. The backend's `effectiveStrategy()`
* resolves `''` to the same per-source default, so the two are equivalent.
*/
export type DeployStrategy = '' | 'recreate' | 'blue-green';
/** Image source: deploy a pre-built image from a registry. */
export interface ImageFormState {
ref: string;
@@ -31,6 +43,8 @@ export interface ImageFormState {
cpuLimit: number;
memoryLimit: number;
maxInstances: number;
/** "" = source default (blue-green for image); else explicit. */
deployStrategy: DeployStrategy;
}
/** Compose source: a docker-compose stack. */
@@ -61,6 +75,8 @@ export interface StaticFormState extends GitSourceState {
renderMarkdown: boolean;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
/** "" = source default (recreate for static); else explicit. */
deployStrategy: DeployStrategy;
}
/** Dockerfile source: build an image from a Dockerfile in a repo. */
@@ -70,6 +86,8 @@ export interface DockerfileFormState extends GitSourceState {
port: number;
/** Report deploy outcome back to the git provider as a commit status. */
reportCommitStatus: boolean;
/** "" = source default (recreate for dockerfile); else explicit. */
deployStrategy: DeployStrategy;
}
// ── Defaults ────────────────────────────────────────────────────────
@@ -83,7 +101,8 @@ export function emptyImageState(): ImageFormState {
registryName: '',
cpuLimit: 0,
memoryLimit: 0,
maxInstances: 1
maxInstances: 1,
deployStrategy: ''
};
}
@@ -108,7 +127,8 @@ export function emptyStaticState(): StaticFormState {
folderPath: '',
mode: 'static',
renderMarkdown: false,
reportCommitStatus: false
reportCommitStatus: false,
deployStrategy: ''
};
}
@@ -118,7 +138,8 @@ export function emptyDockerfileState(): DockerfileFormState {
contextPath: '',
dockerfilePath: 'Dockerfile',
port: 0,
reportCommitStatus: false
reportCommitStatus: false,
deployStrategy: ''
};
}
@@ -167,6 +188,11 @@ function normProvider(value: unknown): GitProvider {
return value === 'github' || value === 'gitlab' ? value : 'gitea';
}
/** Recognized explicit strategies; anything else (incl. absent) -> "". */
function normStrategy(value: unknown): DeployStrategy {
return value === 'recreate' || value === 'blue-green' ? value : '';
}
// ── Seed: source_config JSON -> form state ──────────────────────────
export function seedImageState(jsonText: string): ImageFormState {
@@ -179,7 +205,8 @@ export function seedImageState(jsonText: string): ImageFormState {
registryName: strOr(o.registry_name, ''),
cpuLimit: numOr(o.cpu_limit, 0),
memoryLimit: numOr(o.memory_limit, 0),
maxInstances: numOr(o.max_instances, 1)
maxInstances: numOr(o.max_instances, 1),
deployStrategy: normStrategy(o.deploy_strategy)
};
}
@@ -204,7 +231,8 @@ export function seedStaticState(jsonText: string): StaticFormState {
mode: o.mode === 'deno' ? 'deno' : 'static',
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
reportCommitStatus:
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
deployStrategy: normStrategy(o.deploy_strategy)
};
}
@@ -221,7 +249,8 @@ export function seedDockerfileState(jsonText: string): DockerfileFormState {
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
port: numOr(o.port, 0),
reportCommitStatus:
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false
typeof o.report_commit_status === 'boolean' ? o.report_commit_status : false,
deployStrategy: normStrategy(o.deploy_strategy)
};
}
@@ -252,7 +281,7 @@ function preserveEnvVolumes(existingJson: string): {
export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
const { env, volumes } = preserveEnvVolumes(existingJson);
return {
const out: Record<string, unknown> = {
image: s.ref,
registry_name: s.registryName,
port: s.port,
@@ -264,6 +293,10 @@ export function imageToConfig(s: ImageFormState, existingJson: string): Record<s
default_tag: s.defaultTag,
max_instances: s.maxInstances
};
// Only written when the operator deviates from the source default, so an
// untouched workload's config stays byte-identical (see DeployStrategy).
if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
return out;
}
export function composeToConfig(s: ComposeFormState): Record<string, unknown> {
@@ -286,6 +319,10 @@ export function staticToConfig(s: StaticFormState, existingJson: string): Record
// only when present in the existing config) trail this on edit.
report_commit_status: s.reportCommitStatus
};
// deploy_strategy only when the operator deviated from the static default
// (recreate). Backend force-downgrades blue-green for storage-backed deno
// sites; we still persist the operator's choice and surface a UI caveat.
if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
// Preserve storage_* keys set via the raw JSON editor (not yet surfaced
// as form controls) so a form round-trip doesn't silently drop them.
const existing = tryParse(existingJson);
@@ -314,6 +351,7 @@ const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
'dockerfile_path',
'port',
'report_commit_status',
'deploy_strategy',
'folder_path',
'mode',
'render_markdown',
@@ -332,7 +370,7 @@ export function dockerfileToConfig(
if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
}
}
return {
const out: Record<string, unknown> = {
provider: s.provider,
base_url: s.baseURL,
repo_owner: s.repoOwner,
@@ -344,9 +382,13 @@ export function dockerfileToConfig(
port: s.port || 0,
// New owned key appended at the END of the owned block (before any
// preserved unknown keys) so existing byte-shape assertions hold.
report_commit_status: s.reportCommitStatus,
...preserved
report_commit_status: s.reportCommitStatus
};
// Owned (see DOCKERFILE_OWNED_KEYS) so it's never double-written as a
// preserved unknown; emitted only when the operator picked a non-default.
if (s.deployStrategy) out.deploy_strategy = s.deployStrategy;
Object.assign(out, preserved);
return out;
}
/** Pretty-print a config object for the Advanced-JSON editor view. */