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 type { DockerfileFormState } from '$lib/workload/sourceForms';
|
||||||
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
|
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
|
||||||
import { IconX } from '$lib/components/icons';
|
import { IconX } from '$lib/components/icons';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
@@ -147,6 +148,7 @@
|
|||||||
{@html $t('apps.new.sourceReportCommitStatusDesc')}
|
{@html $t('apps.new.sourceReportCommitStatusDesc')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</label>
|
||||||
|
<DeployStrategyField bind:strategy={form.deployStrategy} defaultStrategy="recreate" idPrefix="app-df-strategy" />
|
||||||
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
|
<p class="hint image-form-foot">{$t('apps.new.dockerfileFoot')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
import * as api from '$lib/api';
|
import * as api from '$lib/api';
|
||||||
import { IconSearch, IconLoader } from '$lib/components/icons';
|
import { IconSearch, IconLoader } from '$lib/components/icons';
|
||||||
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
|
import RegistryImagePicker from '$lib/components/RegistryImagePicker.svelte';
|
||||||
|
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -388,6 +389,7 @@
|
|||||||
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
|
<p class="hint">{$t('apps.new.imageMaxHint')}</p>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<DeployStrategyField bind:strategy={form.deployStrategy} defaultStrategy="blue-green" idPrefix="app-image-strategy" />
|
||||||
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
|
<p class="hint image-form-foot">{$t('apps.new.imageFoot')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,7 @@
|
|||||||
import type { FolderEntry } from '$lib/api';
|
import type { FolderEntry } from '$lib/api';
|
||||||
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
import ToggleSwitch from '$lib/components/ToggleSwitch.svelte';
|
||||||
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
import StaticDiscoveryWizard from '$lib/components/workload/StaticDiscoveryWizard.svelte';
|
||||||
|
import DeployStrategyField from '$lib/components/workload/DeployStrategyField.svelte';
|
||||||
import { t } from '$lib/i18n';
|
import { t } from '$lib/i18n';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -118,6 +119,12 @@
|
|||||||
{@html $t('apps.new.sourceReportCommitStatusDesc')}
|
{@html $t('apps.new.sourceReportCommitStatusDesc')}
|
||||||
</span>
|
</span>
|
||||||
</label>
|
</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>
|
<p class="hint image-form-foot">{$t('apps.new.staticFoot')}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1501,6 +1501,15 @@
|
|||||||
"staticRenderMarkdownDesc": "— auto-render <code>.md</code> files as HTML pages.",
|
"staticRenderMarkdownDesc": "— auto-render <code>.md</code> files as HTML pages.",
|
||||||
"sourceReportCommitStatus": "Report commit status",
|
"sourceReportCommitStatus": "Report commit status",
|
||||||
"sourceReportCommitStatusDesc": "— report deploy status back to the Git provider as a commit status on the deployed commit.",
|
"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.",
|
"staticFoot": "The webhook secret for git push triggers lives on the workload's Webhook panel after creation.",
|
||||||
"staticDetectProvider": "Detect",
|
"staticDetectProvider": "Detect",
|
||||||
"staticDetectedOk": "Detected: {provider}",
|
"staticDetectedOk": "Detected: {provider}",
|
||||||
|
|||||||
@@ -1501,6 +1501,15 @@
|
|||||||
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как HTML-страницы.",
|
"staticRenderMarkdownDesc": "— автоматически отдавать <code>.md</code> файлы как HTML-страницы.",
|
||||||
"sourceReportCommitStatus": "Отправлять статус коммита",
|
"sourceReportCommitStatus": "Отправлять статус коммита",
|
||||||
"sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.",
|
"sourceReportCommitStatusDesc": "— отправлять статус деплоя обратно в Git-провайдер как статус коммита для развёрнутого коммита.",
|
||||||
|
"deployStrategy": {
|
||||||
|
"label": "Стратегия деплоя",
|
||||||
|
"recreateName": "Пересоздание",
|
||||||
|
"recreateDesc": "Остановить старый контейнер, затем запустить новый. Короткий простой во время замены.",
|
||||||
|
"blueGreenName": "Без простоя",
|
||||||
|
"blueGreenDesc": "Запустить новый контейнер, проверить health-check, переключить трафик и убрать старый. Без простоя.",
|
||||||
|
"defaultBadge": "по умолчанию",
|
||||||
|
"denoCaveat": "Deno-сайты с постоянным хранилищем используют пересоздание, чтобы избежать двух писателей на одном томе."
|
||||||
|
},
|
||||||
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
|
"staticFoot": "Секрет вебхука для git push-триггеров появляется в панели вебхука нагрузки после создания.",
|
||||||
"staticDetectProvider": "Определить",
|
"staticDetectProvider": "Определить",
|
||||||
"staticDetectedOk": "Определено: {provider}",
|
"staticDetectedOk": "Определено: {provider}",
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ describe('image source', () => {
|
|||||||
registryName: 'docker.io',
|
registryName: 'docker.io',
|
||||||
cpuLimit: 2,
|
cpuLimit: 2,
|
||||||
memoryLimit: 512,
|
memoryLimit: 512,
|
||||||
maxInstances: 3
|
maxInstances: 3,
|
||||||
|
deployStrategy: ''
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -294,3 +295,56 @@ describe('dockerfile source', () => {
|
|||||||
expect(isDockerfileValid({ ...base, port: -1 })).toBe(false);
|
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';
|
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. */
|
/** Image source: deploy a pre-built image from a registry. */
|
||||||
export interface ImageFormState {
|
export interface ImageFormState {
|
||||||
ref: string;
|
ref: string;
|
||||||
@@ -31,6 +43,8 @@ export interface ImageFormState {
|
|||||||
cpuLimit: number;
|
cpuLimit: number;
|
||||||
memoryLimit: number;
|
memoryLimit: number;
|
||||||
maxInstances: number;
|
maxInstances: number;
|
||||||
|
/** "" = source default (blue-green for image); else explicit. */
|
||||||
|
deployStrategy: DeployStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Compose source: a docker-compose stack. */
|
/** Compose source: a docker-compose stack. */
|
||||||
@@ -61,6 +75,8 @@ export interface StaticFormState extends GitSourceState {
|
|||||||
renderMarkdown: boolean;
|
renderMarkdown: boolean;
|
||||||
/** Report deploy outcome back to the git provider as a commit status. */
|
/** Report deploy outcome back to the git provider as a commit status. */
|
||||||
reportCommitStatus: boolean;
|
reportCommitStatus: boolean;
|
||||||
|
/** "" = source default (recreate for static); else explicit. */
|
||||||
|
deployStrategy: DeployStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Dockerfile source: build an image from a Dockerfile in a repo. */
|
/** Dockerfile source: build an image from a Dockerfile in a repo. */
|
||||||
@@ -70,6 +86,8 @@ export interface DockerfileFormState extends GitSourceState {
|
|||||||
port: number;
|
port: number;
|
||||||
/** Report deploy outcome back to the git provider as a commit status. */
|
/** Report deploy outcome back to the git provider as a commit status. */
|
||||||
reportCommitStatus: boolean;
|
reportCommitStatus: boolean;
|
||||||
|
/** "" = source default (recreate for dockerfile); else explicit. */
|
||||||
|
deployStrategy: DeployStrategy;
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Defaults ────────────────────────────────────────────────────────
|
// ── Defaults ────────────────────────────────────────────────────────
|
||||||
@@ -83,7 +101,8 @@ export function emptyImageState(): ImageFormState {
|
|||||||
registryName: '',
|
registryName: '',
|
||||||
cpuLimit: 0,
|
cpuLimit: 0,
|
||||||
memoryLimit: 0,
|
memoryLimit: 0,
|
||||||
maxInstances: 1
|
maxInstances: 1,
|
||||||
|
deployStrategy: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -108,7 +127,8 @@ export function emptyStaticState(): StaticFormState {
|
|||||||
folderPath: '',
|
folderPath: '',
|
||||||
mode: 'static',
|
mode: 'static',
|
||||||
renderMarkdown: false,
|
renderMarkdown: false,
|
||||||
reportCommitStatus: false
|
reportCommitStatus: false,
|
||||||
|
deployStrategy: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +138,8 @@ export function emptyDockerfileState(): DockerfileFormState {
|
|||||||
contextPath: '',
|
contextPath: '',
|
||||||
dockerfilePath: 'Dockerfile',
|
dockerfilePath: 'Dockerfile',
|
||||||
port: 0,
|
port: 0,
|
||||||
reportCommitStatus: false
|
reportCommitStatus: false,
|
||||||
|
deployStrategy: ''
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,6 +188,11 @@ function normProvider(value: unknown): GitProvider {
|
|||||||
return value === 'github' || value === 'gitlab' ? value : 'gitea';
|
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 ──────────────────────────
|
// ── Seed: source_config JSON -> form state ──────────────────────────
|
||||||
|
|
||||||
export function seedImageState(jsonText: string): ImageFormState {
|
export function seedImageState(jsonText: string): ImageFormState {
|
||||||
@@ -179,7 +205,8 @@ export function seedImageState(jsonText: string): ImageFormState {
|
|||||||
registryName: strOr(o.registry_name, ''),
|
registryName: strOr(o.registry_name, ''),
|
||||||
cpuLimit: numOr(o.cpu_limit, 0),
|
cpuLimit: numOr(o.cpu_limit, 0),
|
||||||
memoryLimit: numOr(o.memory_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',
|
mode: o.mode === 'deno' ? 'deno' : 'static',
|
||||||
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
|
renderMarkdown: typeof o.render_markdown === 'boolean' ? o.render_markdown : false,
|
||||||
reportCommitStatus:
|
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'),
|
dockerfilePath: strOrTruthy(o.dockerfile_path, 'Dockerfile'),
|
||||||
port: numOr(o.port, 0),
|
port: numOr(o.port, 0),
|
||||||
reportCommitStatus:
|
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> {
|
export function imageToConfig(s: ImageFormState, existingJson: string): Record<string, unknown> {
|
||||||
const { env, volumes } = preserveEnvVolumes(existingJson);
|
const { env, volumes } = preserveEnvVolumes(existingJson);
|
||||||
return {
|
const out: Record<string, unknown> = {
|
||||||
image: s.ref,
|
image: s.ref,
|
||||||
registry_name: s.registryName,
|
registry_name: s.registryName,
|
||||||
port: s.port,
|
port: s.port,
|
||||||
@@ -264,6 +293,10 @@ export function imageToConfig(s: ImageFormState, existingJson: string): Record<s
|
|||||||
default_tag: s.defaultTag,
|
default_tag: s.defaultTag,
|
||||||
max_instances: s.maxInstances
|
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> {
|
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.
|
// only when present in the existing config) trail this on edit.
|
||||||
report_commit_status: s.reportCommitStatus
|
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
|
// 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.
|
// as form controls) so a form round-trip doesn't silently drop them.
|
||||||
const existing = tryParse(existingJson);
|
const existing = tryParse(existingJson);
|
||||||
@@ -314,6 +351,7 @@ const DOCKERFILE_OWNED_KEYS: ReadonlySet<string> = new Set([
|
|||||||
'dockerfile_path',
|
'dockerfile_path',
|
||||||
'port',
|
'port',
|
||||||
'report_commit_status',
|
'report_commit_status',
|
||||||
|
'deploy_strategy',
|
||||||
'folder_path',
|
'folder_path',
|
||||||
'mode',
|
'mode',
|
||||||
'render_markdown',
|
'render_markdown',
|
||||||
@@ -332,7 +370,7 @@ export function dockerfileToConfig(
|
|||||||
if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
|
if (!DOCKERFILE_OWNED_KEYS.has(k)) preserved[k] = v;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return {
|
const out: Record<string, unknown> = {
|
||||||
provider: s.provider,
|
provider: s.provider,
|
||||||
base_url: s.baseURL,
|
base_url: s.baseURL,
|
||||||
repo_owner: s.repoOwner,
|
repo_owner: s.repoOwner,
|
||||||
@@ -344,9 +382,13 @@ export function dockerfileToConfig(
|
|||||||
port: s.port || 0,
|
port: s.port || 0,
|
||||||
// New owned key appended at the END of the owned block (before any
|
// New owned key appended at the END of the owned block (before any
|
||||||
// preserved unknown keys) so existing byte-shape assertions hold.
|
// preserved unknown keys) so existing byte-shape assertions hold.
|
||||||
report_commit_status: s.reportCommitStatus,
|
report_commit_status: s.reportCommitStatus
|
||||||
...preserved
|
|
||||||
};
|
};
|
||||||
|
// 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. */
|
/** Pretty-print a config object for the Advanced-JSON editor view. */
|
||||||
|
|||||||
Reference in New Issue
Block a user