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:
@@ -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>
|
||||
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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}",
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user