Docker: new stacks and containers, stack down/delete, container remove, image pull/remove/prune

A new stack is a folder in a home (where stacks are already kept: Dockhand's group here,
~/stacks elsewhere) with compose.yaml and an optional root-only .env, validated by compose
before it is kept and deployed the way its neighbours were — inside dockhand, or to a node
over tcp from the hub. New container is the same dialog on a form that writes the compose.
Homes are scanned, so undeployed stacks stay listed. Delete moves a stack's files to
.hamlab-trash; nothing is forced, and volumes and data are never touched.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-20 07:50:43 -07:00
co-authored by Claude Fable 5.1
parent df6c1dcebf
commit c017b422d5
8 changed files with 605 additions and 17 deletions
+93
View File
@@ -0,0 +1,93 @@
// A new stack — written as compose, or filled in as a form that writes the compose for you.
// "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.
import { useEffect, useMemo, useState } from 'react'
import { api, StackHome } from './api'
import { 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 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
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')
else if (lines(f.ports).length) out.push(' ports:', ...lines(f.ports).map((p) => ` - ${q(p)}`))
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, '$$$$'))}`))
return out.join('\n') + '\n'
}
export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' | 'container'; onClose: () => void; onCreated: (name: string, deploy: boolean) => void }) {
const [homes, setHomes] = useState<StackHome[] | null>(null)
const [home, setHome] = useState('')
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 [env, setEnv] = useState('')
const [err, setErr] = useState('')
useEscape(onClose)
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])
const compose = how === 'form' ? built : text
const picked = homes?.find((h) => h.dir === home)
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') }
}
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 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>}
{!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 && <>
<label className="muted">Name
<input autoFocus className="mono" value={name} onChange={(e) => { setName(e.target.value.toLowerCase()); setErr('') }} placeholder={mode === 'container' ? 'whoami' : 'my-stack'} maxLength={63} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
</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>
</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>
{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">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">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>
</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>
<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
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
</label>
</>}
{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>
<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>
</div>
</>}
{err && !homes && <div className="error">{err}</div>}
</div>
</div>
)
}