|
|
|
@@ -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>
|