Bug bash 3: solid HUD dialogs, remote stacks deployed from the hub's shell, stopped-on-purpose is not down, the New stack dialog keeps what you typed

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 12:30:15 -07:00
co-authored by Claude Fable 5.1
parent 634e286796
commit a446a03b7d
7 changed files with 130 additions and 21 deletions
+20 -6
View File
@@ -4,10 +4,11 @@
// page, where a bare `docker run` could only ever be thrown away and typed again.
import { useEffect, useMemo, useState } from 'react'
import { api, StackHome } from './api'
import { Btn, Loading, useEscape } from './ui'
import { askYes, Btn, Loading, useEscape } from './ui'
interface Form { image: string; ports: string; volumes: string; env: string; restart: string; command: string; host: boolean }
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', restart: 'unless-stopped', command: '', host: false }
const START = 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n'
const lines = (t: string) => t.split('\n').map((x) => x.trim()).filter(Boolean)
const q = (v: string) => JSON.stringify(v) // a JSON string is a valid YAML string, with every awkward character already dealt with
@@ -30,10 +31,14 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
const [name, setName] = useState('')
const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose')
const [form, setForm] = useState<Form>(EMPTY)
const [text, setText] = useState('services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n')
const [text, setText] = useState(START)
const [env, setEnv] = useState('')
const [err, setErr] = useState('')
useEscape(onClose)
const [seeded, setSeeded] = useState('') // what the form last wrote into the compose box
// a stray tap outside, or Escape, must not throw away a compose file someone has typed
const typed = !!name || !!env || text !== START || JSON.stringify(form) !== JSON.stringify(EMPTY)
const close = async () => { if (!typed || (await askYes('Discard this new ' + mode + '? What you have typed is lost.', 'Discard'))) onClose() }
useEscape(close)
useEffect(() => { api.stackHomes().then((r) => { const hs = r.homes ?? []; setHomes(hs); setHome(hs.find((h) => h.default)?.dir ?? hs[0]?.dir ?? '') }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
const okName = /^[a-z0-9][a-z0-9_-]{0,62}$/.test(name)
const built = useMemo(() => composeOf(name, form), [name, form])
@@ -42,9 +47,18 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
const create = (deploy: boolean) => async () => {
try { await api.createStack({ name, text: compose, env, home }); onCreated(name, deploy) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
// The form writes the compose box only when the form has something new to say; text edited by
// hand since is asked about, never silently replaced.
const toCompose = async () => {
if (how === 'form' && form.image.trim() && built !== seeded) {
const mine = text !== START && text !== seeded
if (!mine || (await askYes('Replace the compose text you edited with what the form says now?', 'Replace'))) { setText(built); setSeeded(built) }
}
setHow('compose')
}
const set = (k: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [k]: e.target.value })
return (
<div className="modal-back" onClick={onClose}>
<div className="modal-back" onClick={close}>
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}>
<h3>{mode === 'container' ? 'New container' : 'New stack'}</h3>
{mode === 'container' && <div className="muted small">Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.</div>}
@@ -59,7 +73,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{h.dir}{h.held_by ? ` (on ${h.held_by})` : ''} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
</label>}
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
<div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={() => { if (how === 'form' && form.image.trim()) setText(built); setHow('compose') }}>Compose</button></div>
<div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>
{how === 'form' ? <>
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
<div className="two">
@@ -83,7 +97,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
<div className="svc-actions">
<Btn className="btn on" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(true)}>Create and deploy</Btn>
<Btn className="btn" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(false)}>Create only</Btn>
<span className="spacer" /><button className="btn" onClick={onClose}>Cancel</button>
<span className="spacer" /><button className="btn" onClick={close}>Cancel</button>
</div>
</>}
{err && !homes && <div className="error">{err}</div>}