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
+36
View File
@@ -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 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 rules, terminal typing, the file editor's discard question and service deep links are
unchanged. No native browser dialogs anywhere. 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 `<name>-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.
+32 -1
View File
@@ -753,6 +753,16 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
jsonErr(w, 500, err.Error()) jsonErr(w, 500, err.Error())
return 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) 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)) 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)) 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) ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel() defer cancel()
raw, err := io.ReadAll(io.LimitReader(r.Body, 1<<20)) 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 { if err != nil || json.Unmarshal(raw, &body) != nil {
jsonErr(w, 400, "bad request") jsonErr(w, 400, "bad request")
return return
@@ -975,6 +988,24 @@ func (s *Server) stackCreate(w http.ResponseWriter, r *http.Request) {
return 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) dir := filepath.Join(home.Dir, name)
if readable(dir) || exec.Command("test", "-e", dir).Run() == nil { if readable(dir) || exec.Command("test", "-e", dir).Run() == nil {
jsonErr(w, 409, dir+" already exists") jsonErr(w, 409, dir+" already exists")
+37 -6
View File
@@ -12,6 +12,7 @@ import (
"os/exec" "os/exec"
"sort" "sort"
"sync" "sync"
"syscall"
"time" "time"
) )
@@ -31,10 +32,11 @@ type Task struct {
ExitCode int ExitCode int
Err string Err string
mu sync.Mutex mu sync.Mutex
buf bytes.Buffer buf bytes.Buffer
subs map[chan []byte]struct{} subs map[chan []byte]struct{}
cancel context.CancelFunc cancel context.CancelFunc
onCancel func()
} }
type Snapshot struct { 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} subs: map[chan []byte]struct{}{}, cancel: cancel}
w := &writer{t: t} w := &writer{t: t}
cmd.Stdout, cmd.Stderr = w, w 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 { if err := cmd.Start(); err != nil {
cancel() cancel()
return nil, err return nil, err
@@ -87,8 +98,20 @@ func (m *Manager) Start(kind, title string, cmd *exec.Cmd) (*Task, error) {
select { select {
case err = <-done: case err = <-done:
case <-ctx.Done(): case <-ctx.Done():
_ = cmd.Process.Kill() t.mu.Lock()
err = <-done 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.mu.Lock()
t.Err = "cancelled" t.Err = "cancelled"
t.mu.Unlock() t.mu.Unlock()
@@ -207,6 +230,14 @@ func (t *Task) Subscribe() (backlog []byte, ch chan []byte, unsub func()) {
func (t *Task) Cancel() { t.cancel() } 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 } type writer struct{ t *Task }
func (w *writer) Write(p []byte) (int, error) { func (w *writer) Write(p []byte) (int, error) {
+11 -5
View File
@@ -4,7 +4,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api' import { api, DockerContainer, DockerInfo, ImageCheck, Stack, StackDiff } from './api'
import TaskOutput from './TaskOutput' import TaskOutput from './TaskOutput'
import StackNew from './StackNew' import StackNew, { DupFrom } from './StackNew'
import { Sensor, fmtValue, useSensorStream } from './sensors' import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel' import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs' 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 // 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. // 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 [st, setSt] = useState<Stack | null>(null)
const [text, setText] = useState(''); const [orig, setOrig] = useState('') const [text, setText] = useState(''); const [orig, setOrig] = useState('')
const [err, setErr] = useState(''); const [task, setTask] = 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 } 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') } 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') } } 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 (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> if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return ( return (
<div className="svc-detail"> <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> <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>} {((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> : {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>
<div className="svc-actions"> <div className="svc-actions">
<span className="muted small">{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''}</span><span className="spacer" /> <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>} {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> <button className="btn danger" onClick={remove}>Delete…</button>
</div> </div>
@@ -183,6 +185,7 @@ export default function Docker() {
const [filter, setFilter] = useState('') const [filter, setFilter] = useState('')
const [sel, setSel] = useHashSel() const [sel, setSel] = useHashSel()
const [making, setMaking] = useState<'' | 'stack' | 'container'>('') const [making, setMaking] = useState<'' | 'stack' | 'container'>('')
const [dup, setDup] = useState<DupFrom | null>(null)
const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null) const [startTask, setStartTask] = useState<{ name: string; id: string } | null>(null)
const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('') const [pull, setPull] = useState(''); const [pullTask, setPullTask] = useState('')
const rate = useRefreshMs() 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]) } 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 return out
}, [d, filter]) }, [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) 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> if (!d.available) return <div className="dash"><div className="notice">{d.message}</div></div>
const selStack = sel.startsWith('stack:') ? sel.slice(6) : '' const selStack = sel.startsWith('stack:') ? sel.slice(6) : ''
@@ -260,8 +266,8 @@ export default function Docker() {
</div> </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 && 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>))} {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) }} />} {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} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)} {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> </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 // "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 // 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. // 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 { useEffect, useMemo, useState } from 'react'
import { api, StackHome } from './api' import { api, StackHome } from './api'
import { askYes, 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 } 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: '', restart: 'unless-stopped', command: '', host: false } 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 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 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 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 { export function composeOf(name: string, f: Form): string {
const svc = name || 'app' const svc = name || 'app'
const out = ['services:', ` ${svc}:`, ` image: ${q(f.image.trim() || 'nginx:alpine')}`, ` container_name: ${q(svc)}`] 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.restart !== 'no') out.push(` restart: ${f.restart}`)
if (f.command.trim()) out.push(` command: ${q(f.command.trim())}`) 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)}`)) 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)}`)) 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(dollars(e))}`))
if (lines(f.env).length) out.push(' environment:', ...lines(f.env).map((e) => ` - ${q(e.replace(/\$/g, '$$$$'))}`)) 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' 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 [homes, setHomes] = useState<StackHome[] | null>(null)
const [home, setHome] = useState('') const [home, setHome] = useState('')
const [name, setName] = useState('') const [name, setName] = useState(startName)
const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose') const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose')
const [form, setForm] = useState<Form>(EMPTY) const [form, setForm] = useState<Form>(EMPTY)
const [text, setText] = useState(START) const [text, setText] = useState(startText)
const [env, setEnv] = useState('') const [env, setEnv] = useState('')
const [copyEnv, setCopyEnv] = useState(!!from?.hasEnv)
const [err, setErr] = useState('') const [err, setErr] = useState('')
const [seeded, setSeeded] = useState('') // what the form last wrote into the compose box 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 // 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() } const close = async () => { if (!typed || (await askYes('Discard this new ' + mode + '? What you have typed is lost.', 'Discard'))) onClose() }
useEscape(close) 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')) }, []) 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 built = useMemo(() => composeOf(name, form), [name, form])
const compose = how === 'form' ? built : text const compose = how === 'form' ? built : text
const picked = homes?.find((h) => h.dir === home) 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 () => { 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 // 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. // hand since is asked about, never silently replaced.
const toCompose = async () => { const toCompose = async () => {
if (how === 'form' && form.image.trim() && built !== seeded) { 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) } if (!mine || (await askYes('Replace the compose text you edited with what the form says now?', 'Replace'))) { setText(built); setSeeded(built) }
} }
setHow('compose') setHow('compose')
} }
const set = (k: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [k]: e.target.value }) 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 ( return (
<div className="modal-back" onClick={close}> <div className="modal-back" onClick={close}>
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}> <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>} {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 && !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 && <div className="notice">Docker isn't installed on this machine.</div>}
{homes && homes.length > 0 && <> {homes && homes.length > 0 && <>
@@ -70,30 +98,35 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
</label> </label>
{name && !okName && <div className="error">lower-case letters, digits, - and _ — starting with a letter or digit</div>} {name && !okName && <div className="error">lower-case letters, digits, - and _ — starting with a letter or digit</div>}
{homes.length > 1 && <label className="muted">Where {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>} </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>} {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' ? <> {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> <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"> <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> <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> </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"> <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">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> </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> <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" /> <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} /> <textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
</label> </label>}
</>} </>}
<div className="modal-foot"> <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>} {err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}</div>}
<div className="svc-actions"> <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 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 running = !task || task.running
const failed = !!task && !task.running && (task.exit_code !== 0 || !!task.error) 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 ( return (
<div className={'task' + (running ? ' running' : failed ? ' failed' : ' done')}> <div className={'task' + (running ? ' running' : cancelled ? ' done' : failed ? ' failed' : ' done')}>
<div className="logs-bar"> <div className="logs-bar">
{running ? <Spinner /> : <span className={'dot ' + (failed ? 'bad' : 'ok')} />} {running ? <Spinner /> : <span className={'dot ' + (failed ? 'bad' : 'ok')} />}
<span>{task?.title ?? '…'}</span> <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" /> <span className="spacer" />
{task?.running && <Btn className="btn" onClick={() => api.cancelTask(id)}>Cancel</Btn>} {task?.running && <Btn className="btn" onClick={() => api.cancelTask(id)}>Cancel</Btn>}
</div> </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`), dockerInspect: (id: string) => req<Record<string, unknown>>('GET', `/api/docker/containers/${encodeURIComponent(id)}/inspect`),
stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'), stacks: () => req<{ stacks: Stack[] }>('GET', '/api/docker/stacks'),
stackHomes: () => req<{ homes: StackHome[] }>('GET', '/api/docker/stacks/homes'), 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 }), 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 }), pullImage: (image: string) => req<TaskInfo>('POST', '/api/docker/images/pull', { image }),
removeImage: (image: string) => req<void>('POST', '/api/docker/images/remove', { 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); } .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-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; } .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-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-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; } .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); } .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 { 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-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; } } @media (max-width: 800px) { .pkg { grid-template-columns: auto 1fr; } .pkg-ver { grid-column: 2; white-space: normal; } }
/* power + modal */ /* 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 .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-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; } .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; } } @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; } .dtable td.row-end { text-align: right; white-space: nowrap; width: 1%; } .dtable .badge { margin-left: 0; }