Stacks: Duplicate, Labels and Network in the form, task verdict badges, a short Where; Cancel really cancels

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 13:12:54 -07:00
co-authored by Claude Fable 5.1
parent a229f2b597
commit 1e992ca1eb
8 changed files with 183 additions and 36 deletions
+11 -5
View File
@@ -4,7 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
import TaskOutput from './TaskOutput'
import StackNew from './StackNew'
import StackNew, { DupFrom } from './StackNew'
import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
@@ -119,7 +119,7 @@ const maskEnv = (text: string) => text.split('\n').map((l) => { const i = l.inde
// One stack: its compose file to edit, and the four things to do with it. Saving validates
// with compose itself and keeps the previous file beside it; deploying is a separate, asked-for act.
function StackDetail({ name, startTask, onBack, onChanged }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void }) {
function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name: string; startTask?: string; onBack: () => void; onChanged: () => void; onDuplicate: (from: DupFrom) => void }) {
const [st, setSt] = useState<Stack | null>(null)
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
const [err, setErr] = useState(''); const [task, setTask] = useState('')
@@ -138,12 +138,13 @@ function StackDetail({ name, startTask, onBack, onChanged }: { name: string; sta
if (typed !== name) { if (typed !== null) toast('that is not its name — nothing was deleted', 'info'); return }
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
}
const duplicate = async () => { if (!st) return; try { onDuplicate({ name, text: file === 0 ? orig : (await api.stackFile(name, 0)).text, hasEnv: !!st.has_env, files: st.host_files?.length ?? 1 }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
const run = (a: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => async () => { try { setTask((await api.stackAction(name, a)).id) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } }
if (err && !st) return <div className="svc-detail"><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div>
if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return (
<div className="svc-detail">
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + (st.containers.length === 0 ? '' : st.running === st.containers.length ? 'ok' : st.running ? 'warn' : 'bad')} /><span className="dname">{st.name}</span><span className="muted small">{st.running}/{st.containers.length} running</span></div>
<div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && <span className="muted"> · kept on <b>{st.held_by}</b>, deployed to this machine over {st.remote}</span>}{st.via && <span className="muted"> · compose runs inside <b>{st.via}</b>, as it was deployed</span>}{st.has_env && ' · has a .env beside it'}</div>
{((st.host_files?.length ?? 0) > 1 || st.has_env) && <div className="chips" title="this stack is deployed from several files, in this order; Deploy uses all of them">{st.host_files!.map((f, i) => <button key={f} className={'chip' + (i === file ? ' on' : '')} onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile(i) }}>{f.split('/').pop()}</button>)}{st.has_env && <button className={'chip' + (file === 'env' ? ' on' : '')} title="the .env beside the compose file: variables compose substitutes" onClick={async () => { if (dirty && !(await askYes('Discard unsaved changes to this file?', 'Discard'))) return; setFile('env') }}>.env</button>}</div>}
{file === 'env' && !reveal ? <div className="env-masked"><pre className="editor stack-editor">{maskEnv(text)}</pre><button className="btn" onClick={() => setReveal(true)}>Reveal and edit</button></div> :
@@ -160,6 +161,7 @@ function StackDetail({ name, startTask, onBack, onChanged }: { name: string; sta
</div>
<div className="svc-actions">
<span className="muted small">{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''}</span><span className="spacer" />
<button className="btn" disabled={dirty} title={dirty ? 'save first' : 'a new stack that starts as a copy of this one'} onClick={duplicate}>Duplicate…</button>
{st.containers.length > 0 && <Btn className="btn danger" title="docker compose down" confirm={`Take ${st.name} down? Its ${st.containers.length} container${st.containers.length === 1 ? ' is' : 's are'} removed (not just stopped). The compose file, volumes and bind-mounted data stay, and Deploy brings it back.`} onClick={run('down')}>Down</Btn>}
<button className="btn danger" onClick={remove}>Delete…</button>
</div>
@@ -183,6 +185,7 @@ export default function Docker() {
const [filter, setFilter] = useState('')
const [sel, setSel] = useHashSel()
const [making, setMaking] = useState<'' | 'stack' | 'container'>('')
const [dup, setDup] = useState<DupFrom | null>(null)
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
const rate = useRefreshMs()
@@ -195,6 +198,9 @@ export default function Docker() {
for (const c of d?.containers ?? []) { if (f && !`${c.name} ${c.image} ${c.stack}`.toLowerCase().includes(f)) continue; const k = c.stack || 'started by hand'; out.set(k, [...(out.get(k) ?? []), c]) }
return out
}, [d, filter])
// what a new stack could join, and what it could collide with
const nets = useMemo(() => (d?.networks ?? []).map((n) => String(n.Name)).filter((n) => !['bridge', 'host', 'none'].includes(n)).sort(), [d])
const taken = useMemo(() => { const ports: Record<string, string> = {}; for (const c of d?.containers ?? []) for (const m of (c.ports ?? '').matchAll(/:(\d+)->/g)) ports[m[1]] = c.name; return { names: (d?.containers ?? []).map((c) => c.name), ports } }, [d])
if (!d) return <div className="dash">{err ? <div className="notice">{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}</div> : <Loading label="Asking Docker…" />}</div>
if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
@@ -260,8 +266,8 @@ export default function Docker() {
</div>
{shownTab === 'containers' && !cur && sel && !selStack && <div className="svc-detail"><button className="btn back" onClick={() => setSel('')}>‹ Back</button><div className="notice">There is no container called <b className="mono">{sel}</b> on this machine. It may have been removed, renamed or recreated under another name. <button className="link" onClick={() => setSel('')}>Show them all</button></div></div>}
{shownTab === 'containers' && (cur ? <Detail c={cur} live={sensors} onBack={() => setSel('')} onChanged={load} /> : (sel && !selStack ? null : <div className="svc-empty muted">Select a container.</div>))}
{making && <StackNew mode={making} onClose={() => setMaking('')} onCreated={async (name, deploy) => { setMaking(''); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
{making && <StackNew key={'new:' + (dup?.name ?? making)} mode={making} from={dup ?? undefined} networks={nets} taken={taken} onClose={() => { setMaking(''); setDup(null) }} onCreated={async (name, deploy) => { setMaking(''); setDup(null); setTab('stacks'); if (deploy) { try { setStartTask({ name, id: (await api.stackAction(name, 'deploy')).id }) } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } } else setStartTask(null); await load(); setSel('stack:' + name) }} />}
{shownTab === 'stacks' && (selStack ? <StackDetail key={selStack + (startTask?.name === selStack ? startTask.id : '')} name={selStack} startTask={startTask?.name === selStack ? startTask.id : undefined} onBack={() => setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
</div>
)
}
+53 -20
View File
@@ -2,41 +2,58 @@
// "New container" is the same dialog opened on the form: a container made here is a
// one-service stack, so it can be edited, redeployed and updated like everything else on the
// page, where a bare `docker run` could only ever be thrown away and typed again.
// "Duplicate" is the same dialog again, opened on an existing stack's compose text.
import { useEffect, useMemo, useState } from 'react'
import { api, StackHome } from './api'
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 }
interface Form { image: string; ports: string; volumes: string; env: string; labels: string; restart: string; command: string; network: string }
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
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
const dollars = (v: string) => v.replace(/\$/g, '$$$$') // a literal $ would be read by compose as a variable: doubled, it is a dollar again
// network: '' is compose's default (a network of the stack's own), 'host' is the machine's, anything
// else is a network that already exists — joined, and declared external so compose doesn't make another.
export function composeOf(name: string, f: Form): string {
const svc = name || 'app'
const out = ['services:', ` ${svc}:`, ` image: ${q(f.image.trim() || 'nginx:alpine')}`, ` container_name: ${q(svc)}`]
if (f.restart !== 'no') out.push(` restart: ${f.restart}`)
if (f.command.trim()) out.push(` command: ${q(f.command.trim())}`)
if (f.host) out.push(' network_mode: host')
if (f.network === 'host') out.push(' network_mode: host')
else if (lines(f.ports).length) out.push(' ports:', ...lines(f.ports).map((p) => ` - ${q(p)}`))
if (f.network && f.network !== 'host') out.push(' networks:', ` - ${q(f.network)}`)
if (lines(f.volumes).length) out.push(' volumes:', ...lines(f.volumes).map((v) => ` - ${q(v)}`))
// a literal $ would be read by compose as a variable: doubled, it is a dollar again
if (lines(f.env).length) out.push(' environment:', ...lines(f.env).map((e) => ` - ${q(e.replace(/\$/g, '$$$$'))}`))
if (lines(f.env).length) out.push(' environment:', ...lines(f.env).map((e) => ` - ${q(dollars(e))}`))
if (lines(f.labels).length) out.push(' labels:', ...lines(f.labels).map((l) => ` - ${q(dollars(l))}`))
if (f.network && f.network !== 'host') out.push('networks:', ` ${q(f.network)}:`, ' external: true')
return out.join('\n') + '\n'
}
export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' | 'container'; onClose: () => void; onCreated: (name: string, deploy: boolean) => void }) {
// the last two folders say which home it is; the whole path is written out under the box
const tail = (dir: string) => '…/' + dir.split('/').filter(Boolean).slice(-2).join('/')
export interface DupFrom { name: string; text: string; hasEnv: boolean; files: number }
export interface Taken { names: string[]; ports: Record<string, string> } // container names in use · published host port → who has it
interface Props { mode: 'stack' | 'container'; from?: DupFrom; networks?: string[]; taken?: Taken; onClose: () => void; onCreated: (name: string, deploy: boolean) => void }
export default function StackNew({ mode, from, networks = [], taken, onClose, onCreated }: Props) {
const startName = from ? `${from.name}-copy`.slice(0, 63) : ''
const startText = from?.text ?? START
const [homes, setHomes] = useState<StackHome[] | null>(null)
const [home, setHome] = useState('')
const [name, setName] = useState('')
const [name, setName] = useState(startName)
const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose')
const [form, setForm] = useState<Form>(EMPTY)
const [text, setText] = useState(START)
const [text, setText] = useState(startText)
const [env, setEnv] = useState('')
const [copyEnv, setCopyEnv] = useState(!!from?.hasEnv)
const [err, setErr] = useState('')
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 typed = name !== startName || !!env || text !== startText || 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')) }, [])
@@ -44,24 +61,35 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
const built = useMemo(() => composeOf(name, form), [name, form])
const compose = how === 'form' ? built : text
const picked = homes?.find((h) => h.dir === home)
// What would collide with something already running. A warning, not a refusal: the stack may be
// meant to replace that container, and "Create only" collides with nothing.
const clashes = useMemo(() => {
if (!taken) return []
const out: string[] = []
for (const m of compose.matchAll(/^\s*container_name:\s*["']?([^"'\s#]+)/gm)) if (taken.names.includes(m[1])) out.push(`a container called ${m[1]} already exists`)
if (!/^\s*network_mode:\s*["']?host/m.test(compose)) for (const m of compose.matchAll(/^\s*-\s*["']?(?:\d{1,3}(?:\.\d{1,3}){3}:)?(\d+):\d+/gm)) if (taken.ports[m[1]]) out.push(`port ${m[1]} is already published by ${taken.ports[m[1]]}`)
return [...new Set(out)]
}, [compose, taken])
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') }
try { await api.createStack({ name, text: compose, env: from && copyEnv ? '' : env, home, env_from: from && copyEnv ? from.name : undefined }); 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
const mine = text !== startText && 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 })
const sameTail = (h: StackHome) => (homes ?? []).filter((o) => tail(o.dir) === tail(h.dir)).length > 1
return (
<div className="modal-back" onClick={close}>
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}>
<h3>{mode === 'container' ? 'New container' : 'New stack'}</h3>
<h3>{from ? `Duplicate ${from.name}` : 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>}
{from && <div className="muted small">A new stack that starts as a copy of {from.name}'s compose file{from.files > 1 ? ` (its first file only: ${from.name} is made of ${from.files})` : ''}. {from.name} itself is not changed. Volumes and bind-mounted folders named in it would be <b>shared</b> with the original unless you change them.</div>}
{!homes && !err && <Loading label="Finding where this machine keeps its stacks…" />}
{homes && homes.length === 0 && <div className="notice">Docker isn't installed on this machine.</div>}
{homes && homes.length > 0 && <>
@@ -70,30 +98,35 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
</label>
{name && !okName && <div className="error">lower-case letters, digits, - and _ — starting with a letter or digit</div>}
{homes.length > 1 && <label className="muted">Where
<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>
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{sameTail(h) ? h.dir : tail(h.dir)} · {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={toCompose}>Compose</button></div>
{!from && <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">
<label className="muted">Ports — host:container, one per line<textarea className="mono" rows={2} value={form.ports} onChange={set('ports')} placeholder={'8080:80'} disabled={form.host} spellCheck={false} /></label>
<label className="muted">Ports — host:container, one per line<textarea className="mono" rows={2} value={form.ports} onChange={set('ports')} placeholder={'8080:80'} disabled={form.network === 'host'} spellCheck={false} /></label>
<label className="muted">Volumes — host path or name : container path<textarea className="mono" rows={2} value={form.volumes} onChange={set('volumes')} placeholder={'/home/core/apps/whoami:/data'} spellCheck={false} /></label>
</div>
<label className="muted">Environment — KEY=value, one per line<textarea className="mono" rows={2} value={form.env} onChange={set('env')} placeholder={'TZ=America/Los_Angeles'} spellCheck={false} /></label>
<div className="two">
<label className="muted">Environment — KEY=value, one per line<textarea className="mono" rows={2} value={form.env} onChange={set('env')} placeholder={'TZ=America/Los_Angeles'} spellCheck={false} /></label>
<label className="muted">Labels — key=value, one per line<textarea className="mono" rows={2} value={form.labels} onChange={set('labels')} placeholder={'com.example.owner=mitchell'} spellCheck={false} /></label>
</div>
<div className="two">
<label className="muted">Restart<select value={form.restart} onChange={set('restart')}><option value="unless-stopped">unless-stopped</option><option value="always">always</option><option value="on-failure">on-failure</option><option value="no">no</option></select></label>
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
<label className="muted">Network<select value={form.network} onChange={set('network')}><option value="">its own (the default)</option><option value="host">host — this machine's network; ports don't apply</option>{networks.map((n) => <option key={n} value={n}>join {n}</option>)}</select></label>
</div>
<label className="check"><input type="checkbox" checked={form.host} onChange={(e) => setForm({ ...form, host: e.target.checked })} /> <span>Host networking <span className="muted">— the container uses this machine's network directly; ports don't apply</span></span></label>
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
<pre className="unit-file stack-preview">{built}</pre>
</> : <>
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
<label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
{from?.hasEnv && <label className="check"><input type="checkbox" checked={copyEnv} onChange={(e) => setCopyEnv(e.target.checked)} /> <span>Copy {from.name}'s .env too <span className="muted">— copied on the machine itself; its values are never shown here</span></span></label>}
{!(from && copyEnv) && <label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
</label>
</label>}
</>}
<div className="modal-foot">
{clashes.length > 0 && <div className="clash">Deploying this as it stands would fail: {clashes.join('; ')}.</div>}
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(true)}>Create and deploy</Btn>
+7 -3
View File
@@ -32,13 +32,17 @@ export default function TaskOutput({ id, onDone }: { id: string; onDone?: (t: Ta
const running = !task || task.running
const failed = !!task && !task.running && (task.exit_code !== 0 || !!task.error)
const status = !task ? 'starting…' : task.running ? 'running…' : task.error ? `failed: ${task.error}` : task.exit_code === 0 ? 'finished' : `exited ${task.exit_code}`
const cancelled = task?.error === 'cancelled'
// how it ended is a badge, legible at a glance: nobody should have to read compose's output to learn a deploy failed
const verdict = !task || task.running ? '' : cancelled ? 'cancelled' : task.error ? 'failed' : task.exit_code === 0 ? 'finished' : `failed · exit ${task.exit_code}`
const status = !task ? 'starting…' : task.running ? 'running…' : task.error && !cancelled ? task.error : ''
return (
<div className={'task' + (running ? ' running' : failed ? ' failed' : ' done')}>
<div className={'task' + (running ? ' running' : cancelled ? ' done' : failed ? ' failed' : ' done')}>
<div className="logs-bar">
{running ? <Spinner /> : <span className={'dot ' + (failed ? 'bad' : 'ok')} />}
<span>{task?.title ?? '…'}</span>
<span className="muted">· {status}</span>
{verdict && <span className={'badge ' + (failed && !cancelled ? 'bad' : cancelled ? '' : 'ok')}>{verdict}</span>}
{status && <span className="muted">· {status}</span>}
<span className="spacer" />
{task?.running && <Btn className="btn" onClick={() => api.cancelTask(id)}>Cancel</Btn>}
</div>
+1 -1
View File
@@ -198,7 +198,7 @@ export const api = {
dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'),
createStack: (p: { name: string; text: string; env: string; home: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
createStack: (p: { name: string; text: string; env: string; home: string; env_from?: string }) => req<{ stack: Stack }>('POST', '/api/docker/stacks', p),
deleteStack: (name: string) => req<{ trash: string; moved: number }>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/delete`, { confirm: name }),
pullImage: (image: string) => req<TaskInfo>('POST', '/api/docker/images/pull', { image }),
removeImage: (image: string) => req<void>('POST', '/api/docker/images/remove', { image }),
+6
View File
@@ -144,6 +144,10 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.sdot.ok { background: var(--ok); } .sdot.bad { background: var(--bad); } .sdot.warn { background: var(--s2); } .sdot.exited { background: var(--accent2); }
.svc-empty { flex: 1; display: flex; align-items: center; justify-content: center; }
.svc-detail { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 10px; padding: 12px; overflow-y: auto; }
/* The pane scrolls; its parts keep their height. Left to shrink, whatever clips its own overflow
(a task's output box) is squeezed to a line the moment the pane is full — on a phone, always.
What is meant to give way (.logs, .stack-editor) says so itself, further down. */
.svc-detail > *, .cron-detail > * { flex-shrink: 0; }
.svc-detail .back { display: none; align-self: flex-start; }
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
@@ -201,6 +205,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.notice.attn { border-color: var(--s2); color: var(--fg); }
.task { display: flex; flex-direction: column; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.task-out { border: 0; border-radius: 0; max-height: 45vh; min-height: 120px; }
.task.failed { border-color: var(--bad); } .task .badge { margin-left: 0; flex-shrink: 0; } .badge.ok { color: var(--ok); border-color: var(--ok); }
@media (max-width: 800px) { .pkg { grid-template-columns: auto 1fr; } .pkg-ver { grid-column: 2; white-space: normal; } }
/* power + modal */
@@ -518,5 +523,6 @@ pre.diff .add { color: var(--ok); background: color-mix(in srgb, var(--ok) 12%,
.stack-new .two { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; } .stack-new .stack-editor { min-height: 32vh; }
.stack-new .check { display: flex; flex-direction: row; align-items: flex-start; gap: 8px; font-size: 13px; } .stack-new .check input { margin-top: 3px; }
.stack-preview { max-height: 180px; overflow: auto; opacity: .85; }
.clash { color: var(--s2); font-size: 12px; overflow-wrap: anywhere; }
@media (max-width: 800px) { .stack-new .two { grid-template-columns: 1fr; } }
.dtable td.row-end { text-align: right; white-space: nowrap; width: 1%; } .dtable .badge { margin-left: 0; }