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>