diff --git a/BUGS.md b/BUGS.md index 57bf14c..8b4bdcd 100644 --- a/BUGS.md +++ b/BUGS.md @@ -379,3 +379,39 @@ container can't be removed (409); a volume needs its name twice. A read-only tok file is refused. Shaped, fleet and streamed sensor queries, `/metrics`, the ten alert rules, terminal typing, the file editor's discard question and service deep links are unchanged. No native browser dialogs anywhere. + +## Quality of life — four chosen by Mitchell, ✅ built and verified 2026-09-20 + +1. ✅ **"Where" fits on a phone**: `…/stacks/core · 12 stacks` instead of the whole path (which + is still written out underneath). Two homes whose last two folders match fall back to the + full path. Measured at 390 px: no option is wider than the box. +2. ✅ **How a task ended is a badge**: green `finished`, red `failed · exit 1` with the box + outlined in red, grey `cancelled`. Verified on a deploy of an image that doesn't exist. +5. ✅ **Duplicate…** on a stack: the New stack dialog opened on a copy of its compose file, + named `-copy`. The original's `.env` is copied **on the machine** (`env_from`), mode + 600, its values never sent to the browser — checked: the secret is nowhere in the page. + Anything that would collide with what is running is named before you press Create + ("a container called X already exists; port 18222 is already published by X") — for + every new stack, not only duplicates — and clears as you fix it. Disabled while the + original has unsaved edits. Verified: copy deployed beside the original, which never + stopped. +6. ✅ **The form has Labels and Network.** Labels one per line (`$`, quotes and spaces arrive + intact). Network: its own, the machine's (`host`, which turns Ports off), or **join** one + that already exists — written as an `external` network so compose doesn't make a second + one. Verified on the container: both labels, attached to the chosen network only. + +Found while building them, ✅ fixed: + +- **Cancel on a stack task cancelled nothing.** It killed the `sh` that ran compose, not + compose; and as compose still held the output pipe, the task stayed "running" and the + stack "busy" until compose finished by itself — for a deploy waiting on a health check, + minutes. Tasks now run in their own process group and the whole group is stopped (TERM, + then KILL after 4 s); for stacks deployed inside Dockhand, where the process is not the + task's child at all, the cancel goes into the container after it. Verified both ways: + ended in ~100 ms, compose gone, the stack free for the next action. +- **Phone: a stack's task output was squeezed to a line** under the buttons (the same fault + as the dialog: a scrolling column whose parts were allowed to shrink), which also put + Cancel out of reach. Detail panes keep their parts' heights; services, containers, stacks + and cron rechecked on both sizes. +- The Duplicate dialog and the stack page under it shared a React key, so the dialog + multiplied. An undeployed stack's dot was green ("0 of 0 running"); now neutral. diff --git a/internal/api/stacks.go b/internal/api/stacks.go index 783fac4..f67db27 100644 --- a/internal/api/stacks.go +++ b/internal/api/stacks.go @@ -753,6 +753,16 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { jsonErr(w, 500, err.Error()) return } + if st.Via != "" { + // compose runs inside st.Via, started by `docker exec`: stopping the exec client does not + // stop what it started in there, so a cancel has to go in after it. + via, pattern := st.Via, "compose -p "+regexp.QuoteMeta(st.Name)+" -f " + t.OnCancel(func() { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _ = exec.CommandContext(ctx, dockerTool(), "exec", via, "pkill", "-TERM", "-f", pattern).Run() + }) + } stackUpdatesForget(r.PathValue("node") + "/" + st.Name) log.Printf("stack %s: %s started as task %s (from %s)", st.Name, r.PathValue("action"), t.ID, clientIP(r)) jsonOK(w, 202, t.Snapshot(false)) @@ -929,7 +939,10 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) { ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second) defer cancel() raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) - var body struct{ Name, Text, Env, Home string } + var body struct { + Name, Text, Env, Home string + EnvFrom string `json:"env_from"` // a stack being duplicated: its .env is copied here, on the machine, unseen + } if err != nil || json.Unmarshal(raw, &body) != nil { jsonErr(w, 400, "bad request") return @@ -975,6 +988,24 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) { return } } + if body.EnvFrom != "" && strings.TrimSpace(body.Env) == "" { + var from *stack + for i := range all { + if all[i].Name == body.EnvFrom && all[i].Editable && all[i].HasEnv { + from = &all[i] + } + } + if from == nil { + jsonErr(w, 400, body.EnvFrom+" has no .env this console can read") + return + } + b, err := readAsRoot(ctx, from.envPath()) + if err != nil { + jsonErr(w, 500, "reading "+body.EnvFrom+"'s .env: "+err.Error()) + return + } + body.Env = string(b) + } dir := filepath.Join(home.Dir, name) if readable(dir) || exec.Command("test", "-e", dir).Run() == nil { jsonErr(w, 409, dir+" already exists") diff --git a/internal/tasks/tasks.go b/internal/tasks/tasks.go index 6aab193..9c668cb 100644 --- a/internal/tasks/tasks.go +++ b/internal/tasks/tasks.go @@ -12,6 +12,7 @@ import ( "os/exec" "sort" "sync" + "syscall" "time" ) @@ -31,10 +32,11 @@ type Task struct { ExitCode int Err string - mu sync.Mutex - buf bytes.Buffer - subs map[chan []byte]struct{} - cancel context.CancelFunc + mu sync.Mutex + buf bytes.Buffer + subs map[chan []byte]struct{} + cancel context.CancelFunc + onCancel func() } type Snapshot struct { @@ -71,6 +73,15 @@ func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) { subs: map[chan []byte]struct{}{}, cancel: cancel} w := &writer{t: t} cmd.Stdout, cmd.Stderr = w, w + // Its own process group: a task is usually `sh -c "a && b"`, and cancelling must reach a and + // b, not just the shell — killing only the shell left compose running and, because compose + // still held the output pipe, left the task "running" too. WaitDelay is the backstop for + // anything that escapes the group and keeps the pipe open. + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + cmd.SysProcAttr.Setpgid = true + cmd.WaitDelay = 5 * time.Second if err := cmd.Start(); err != nil { cancel() return nil, err @@ -87,8 +98,20 @@ func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) { select { case err = <-done: case <-ctx.Done(): - _ = cmd.Process.Kill() - err = <-done + t.mu.Lock() + hook := t.onCancel + t.mu.Unlock() + if hook != nil { + go hook() + } + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGTERM) // asked first: compose and apt both stop cleanly + select { + case err = <-done: + case <-time.After(4 * time.Second): + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + _ = cmd.Process.Kill() + err = <-done + } t.mu.Lock() t.Err = "cancelled" t.mu.Unlock() @@ -207,6 +230,14 @@ func (t *Task) Subscribe() (backlog []byte, ch chan []byte, unsub func()) { func (t *Task) Cancel() { t.cancel() } +// OnCancel sets something to do, besides stopping the task's own processes, when the task is +// cancelled: whatever it started that isn't its child (a command inside a container). +func (t *Task) OnCancel(fn func()) { + t.mu.Lock() + t.onCancel = fn + t.mu.Unlock() +} + type writer struct{ t *Task } func (w *writer) Write(p []byte) (int, error) { diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index 5eaf63e..d915c9f 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -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(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
{err}
if (!st) return
return (
-
{st.name}{st.running}/{st.containers.length} running
+
{st.name}{st.running}/{st.containers.length} running
{file === 'env' ? (st.host_file.replace(/[^/]+$/, '') + '.env') : (st.host_files?.[file] ?? st.host_file)}{st.held_by && · kept on {st.held_by}, deployed to this machine over {st.remote}}{st.via && · compose runs inside {st.via}, as it was deployed}{st.has_env && ' · has a .env beside it'}
{((st.host_files?.length ?? 0) > 1 || st.has_env) &&
{st.host_files!.map((f, i) => )}{st.has_env && }
} {file === 'env' && !reveal ?
{maskEnv(text)}
: @@ -160,6 +161,7 @@ function StackDetail({ name, startTask, onBack, onChanged }: { name: string; sta
{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''} + {st.containers.length > 0 && Down}
@@ -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(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 = {}; 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
{err ?
{err === 'not found' ? "This machine's Ham Lab is older than the Docker tab. Update it from Machines." : err}
: }
if (!d.available) return
{d.message}
const selStack = sel.startsWith('stack:') ? sel.slice(6) : '' @@ -260,8 +266,8 @@ export default function Docker() { {shownTab === 'containers' && !cur && sel && !selStack &&
There is no container called {sel} on this machine. It may have been removed, renamed or recreated under another name.
} {shownTab === 'containers' && (cur ? setSel('')} onChanged={load} /> : (sel && !selStack ? null :
Select a container.
))} - {making && 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 ? setSel('')} onChanged={load} /> :
Select a stack to edit its compose file.
)} + {making && { 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 ? setSel('')} onChanged={load} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> :
Select a stack to edit its compose file.
)} ) } diff --git a/web/src/StackNew.tsx b/web/src/StackNew.tsx index 9c075cc..100253d 100644 --- a/web/src/StackNew.tsx +++ b/web/src/StackNew.tsx @@ -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 } // 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(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
(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 (
e.stopPropagation()}> -

{mode === 'container' ? 'New container' : 'New stack'}

+

{from ? `Duplicate ${from.name}` : mode === 'container' ? 'New container' : 'New stack'}

{mode === 'container' &&
Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.
} + {from &&
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 shared with the original unless you change them.
} {!homes && !err && } {homes && homes.length === 0 &&
Docker isn't installed on this machine.
} {homes && homes.length > 0 && <> @@ -70,30 +98,35 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' | {name && !okName &&
lower-case letters, digits, - and _ — starting with a letter or digit
} {homes.length > 1 && } {picked &&
{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`}
} -
+ {!from &&
} {how === 'form' ? <>
-