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:
co-authored by
Claude Fable 5.1
parent
a229f2b597
commit
1e992ca1eb
@@ -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
@@ -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")
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"os/exec"
|
"os/exec"
|
||||||
"sort"
|
"sort"
|
||||||
"sync"
|
"sync"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ type Task struct {
|
|||||||
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():
|
||||||
|
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()
|
_ = cmd.Process.Kill()
|
||||||
err = <-done
|
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
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+52
-19
@@ -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>
|
||||||
|
<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">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>
|
||||||
|
|||||||
@@ -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
@@ -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 }),
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user