From 5b51bbbd7f2826327ae15403532c917dd69432cd Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 19 Jun 2026 17:09:17 +0300 Subject: [PATCH] feat(web): deploy-strategy selector UI for image/dockerfile/static sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../workload/DeployStrategyField.svelte | 468 ++++++++++++++++++ .../workload/DockerfileSourceForm.svelte | 2 + .../workload/ImageSourceForm.svelte | 2 + .../workload/StaticSourceForm.svelte | 7 + web/src/lib/i18n/en.json | 9 + web/src/lib/i18n/ru.json | 9 + web/src/lib/workload/sourceForms.test.ts | 56 ++- web/src/lib/workload/sourceForms.ts | 62 ++- 8 files changed, 604 insertions(+), 11 deletions(-) create mode 100644 web/src/lib/components/workload/DeployStrategyField.svelte diff --git a/web/src/lib/components/workload/DeployStrategyField.svelte b/web/src/lib/components/workload/DeployStrategyField.svelte new file mode 100644 index 0000000..2cad36b --- /dev/null +++ b/web/src/lib/components/workload/DeployStrategyField.svelte @@ -0,0 +1,468 @@ + + + +
+
+ + + {$t('apps.new.deployStrategy.label')} + +
+ +
+ {#each OPTIONS as opt, i (opt)} + {@const selected = effective === opt} + + {/each} +
+ + {#if showDenoCaveat} +

{$t('apps.new.deployStrategy.denoCaveat')}

+ {/if} +
+ + diff --git a/web/src/lib/components/workload/DockerfileSourceForm.svelte b/web/src/lib/components/workload/DockerfileSourceForm.svelte index 8d8f2c0..86f5ead 100644 --- a/web/src/lib/components/workload/DockerfileSourceForm.svelte +++ b/web/src/lib/components/workload/DockerfileSourceForm.svelte @@ -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')} +

{$t('apps.new.dockerfileFoot')}

diff --git a/web/src/lib/components/workload/ImageSourceForm.svelte b/web/src/lib/components/workload/ImageSourceForm.svelte index da58168..819c82f 100644 --- a/web/src/lib/components/workload/ImageSourceForm.svelte +++ b/web/src/lib/components/workload/ImageSourceForm.svelte @@ -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 @@

{$t('apps.new.imageMaxHint')}

+

{$t('apps.new.imageFoot')}

diff --git a/web/src/lib/components/workload/StaticSourceForm.svelte b/web/src/lib/components/workload/StaticSourceForm.svelte index 4fa8a49..de5c851 100644 --- a/web/src/lib/components/workload/StaticSourceForm.svelte +++ b/web/src/lib/components/workload/StaticSourceForm.svelte @@ -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')} +

{$t('apps.new.staticFoot')}

diff --git a/web/src/lib/i18n/en.json b/web/src/lib/i18n/en.json index f3d74ef..2d65bdd 100644 --- a/web/src/lib/i18n/en.json +++ b/web/src/lib/i18n/en.json @@ -1501,6 +1501,15 @@ "staticRenderMarkdownDesc": "— auto-render .md 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}", diff --git a/web/src/lib/i18n/ru.json b/web/src/lib/i18n/ru.json index 664907f..52e1f1a 100644 --- a/web/src/lib/i18n/ru.json +++ b/web/src/lib/i18n/ru.json @@ -1501,6 +1501,15 @@ "staticRenderMarkdownDesc": "— автоматически отдавать .md файлы как HTML-страницы.", "sourceReportCommitStatus": "Отправлять статус коммита", "sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.", + "deployStrategy": { + "label": "Стратегия деплоя", + "recreateName": "Пересоздание", + "recreateDesc": "Остановить старый контейнер, затем запустить новый. Короткий простой во время замены.", + "blueGreenName": "Без простоя", + "blueGreenDesc": "Запустить новый контейнер, проверить health-check, переключить трафик и убрать старый. Без простоя.", + "defaultBadge": "по умолчанию", + "denoCaveat": "Deno-сайты с постоянным хранилищем используют пересоздание, чтобы избежать двух писателей на одном томе." + }, "staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.", "staticDetectProvider": "Определить", "staticDetectedOk": "Определено: {provider}", diff --git a/web/src/lib/workload/sourceForms.test.ts b/web/src/lib/workload/sourceForms.test.ts index c9af273..85a44a6 100644 --- a/web/src/lib/workload/sourceForms.test.ts +++ b/web/src/lib/workload/sourceForms.test.ts @@ -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'); + }); +}); diff --git a/web/src/lib/workload/sourceForms.ts b/web/src/lib/workload/sourceForms.ts index e715325..fbe5e02 100644 --- a/web/src/lib/workload/sourceForms.ts +++ b/web/src/lib/workload/sourceForms.ts @@ -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 { const { env, volumes } = preserveEnvVolumes(existingJson); - return { + const out: Record = { image: s.ref, registry_name: s.registryName, port: s.port, @@ -264,6 +293,10 @@ export function imageToConfig(s: ImageFormState, existingJson: string): Record { @@ -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 = 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 = { 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. */