Files
hamlab/web/src/StackNew.tsx
T

143 lines
12 KiB
TypeScript

// A new stack — written as compose, or filled in as a form that writes the compose for you.
// "New container" is the same dialog opened on the form: a container made here is a
// one-service stack, so it can be edited, redeployed and updated like everything else on the
// page, where a bare `docker run` could only ever be thrown away and typed again.
// "Duplicate" is the same dialog again, opened on an existing stack's compose text.
import { useEffect, useMemo, useState } from 'react'
import { api, StackHome } from './api'
import { askYes, Btn, Loading, useEscape } from './ui'
interface Form { image: string; ports: string; volumes: string; env: string; labels: string; restart: string; command: string; network: string }
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', labels: '', restart: 'unless-stopped', command: '', network: '' }
const START = 'services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n'
const lines = (t: string) => t.split('\n').map((x) => x.trim()).filter(Boolean)
const q = (v: string) => JSON.stringify(v) // a JSON string is a valid YAML string, with every awkward character already dealt with
const dollars = (v: string) => v.replace(/\$/g, '$$$$') // a literal $ would be read by compose as a variable: doubled, it is a dollar again
// network: '' is compose's default (a network of the stack's own), 'host' is the machine's, anything
// else is a network that already exists — joined, and declared external so compose doesn't make another.
export function composeOf(name: string, f: Form): string {
const svc = name || 'app'
const out = ['services:', ` ${svc}:`, ` image: ${q(f.image.trim() || 'nginx:alpine')}`, ` container_name: ${q(svc)}`]
if (f.restart !== 'no') out.push(` restart: ${f.restart}`)
if (f.command.trim()) out.push(` command: ${q(f.command.trim())}`)
if (f.network === 'host') out.push(' network_mode: host')
else if (lines(f.ports).length) out.push(' ports:', ...lines(f.ports).map((p) => ` - ${q(p)}`))
if (f.network && f.network !== 'host') out.push(' networks:', ` - ${q(f.network)}`)
if (lines(f.volumes).length) out.push(' volumes:', ...lines(f.volumes).map((v) => ` - ${q(v)}`))
if (lines(f.env).length) out.push(' environment:', ...lines(f.env).map((e) => ` - ${q(dollars(e))}`))
if (lines(f.labels).length) out.push(' labels:', ...lines(f.labels).map((l) => ` - ${q(dollars(l))}`))
if (f.network && f.network !== 'host') out.push('networks:', ` ${q(f.network)}:`, ' external: true')
return out.join('\n') + '\n'
}
// the last two folders say which home it is; the whole path is written out under the box
const tail = (dir: string) => '…/' + dir.split('/').filter(Boolean).slice(-2).join('/')
export interface DupFrom { name: string; text: string; hasEnv: boolean; files: number }
export interface Taken { names: string[]; ports: Record<string, string> } // container names in use · published host port → who has it
interface Props { mode: 'stack' | 'container'; from?: DupFrom; networks?: string[]; taken?: Taken; onClose: () => void; onCreated: (name: string, deploy: boolean) => void }
export default function StackNew({ mode, from, networks = [], taken, onClose, onCreated }: Props) {
const startName = from ? `${from.name}-copy`.slice(0, 63) : ''
const startText = from?.text ?? START
const [homes, setHomes] = useState<StackHome[] | null>(null)
const [home, setHome] = useState('')
const [name, setName] = useState(startName)
const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose')
const [form, setForm] = useState<Form>(EMPTY)
const [text, setText] = useState(startText)
const [env, setEnv] = useState('')
const [copyEnv, setCopyEnv] = useState(!!from?.hasEnv)
const [err, setErr] = useState('')
const [seeded, setSeeded] = useState('') // what the form last wrote into the compose box
// a stray tap outside, or Escape, must not throw away a compose file someone has typed
const typed = name !== startName || !!env || text !== startText || JSON.stringify(form) !== JSON.stringify(EMPTY)
const close = async () => { if (!typed || (await askYes('Discard this new ' + mode + '? What you have typed is lost.', 'Discard'))) onClose() }
useEscape(close)
useEffect(() => { api.stackHomes().then((r) => { const hs = r.homes ?? []; setHomes(hs); setHome(hs.find((h) => h.default)?.dir ?? hs[0]?.dir ?? '') }).catch((e) => setErr(e instanceof Error ? e.message : 'failed')) }, [])
const okName = /^[a-z0-9][a-z0-9_-]{0,62}$/.test(name)
const built = useMemo(() => composeOf(name, form), [name, form])
const compose = how === 'form' ? built : text
const picked = homes?.find((h) => h.dir === home)
// What would collide with something already running. A warning, not a refusal: the stack may be
// meant to replace that container, and "Create only" collides with nothing.
const clashes = useMemo(() => {
if (!taken) return []
const out: string[] = []
for (const m of compose.matchAll(/^\s*container_name:\s*["']?([^"'\s#]+)/gm)) if (taken.names.includes(m[1])) out.push(`a container called ${m[1]} already exists`)
if (!/^\s*network_mode:\s*["']?host/m.test(compose)) for (const m of compose.matchAll(/^\s*-\s*["']?(?:\d{1,3}(?:\.\d{1,3}){3}:)?(\d+):\d+/gm)) if (taken.ports[m[1]]) out.push(`port ${m[1]} is already published by ${taken.ports[m[1]]}`)
return [...new Set(out)]
}, [compose, taken])
const create = (deploy: boolean) => async () => {
try { await api.createStack({ name, text: compose, env: from && copyEnv ? '' : env, home, env_from: from && copyEnv ? from.name : undefined }); onCreated(name, deploy) } catch (e) { setErr(e instanceof Error ? e.message : 'failed') }
}
// The form writes the compose box only when the form has something new to say; text edited by
// hand since is asked about, never silently replaced.
const toCompose = async () => {
if (how === 'form' && form.image.trim() && built !== seeded) {
const mine = text !== startText && text !== seeded
if (!mine || (await askYes('Replace the compose text you edited with what the form says now?', 'Replace'))) { setText(built); setSeeded(built) }
}
setHow('compose')
}
const set = (k: keyof Form) => (e: { target: { value: string } }) => setForm({ ...form, [k]: e.target.value })
const sameTail = (h: StackHome) => (homes ?? []).filter((o) => tail(o.dir) === tail(h.dir)).length > 1
return (
<div className="modal-back" onClick={close}>
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}>
<h3>{from ? `Duplicate ${from.name}` : mode === 'container' ? 'New container' : 'New stack'}</h3>
{mode === 'container' && <div className="muted small">Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.</div>}
{from && <div className="muted small">A new stack that starts as a copy of {from.name}'s compose file{from.files > 1 ? ` (its first file only: ${from.name} is made of ${from.files})` : ''}. {from.name} itself is not changed. Volumes and bind-mounted folders named in it would be <b>shared</b> with the original unless you change them.</div>}
{!homes && !err && <Loading label="Finding where this machine keeps its stacks…" />}
{homes && homes.length === 0 && <div className="notice">Docker isn't installed on this machine.</div>}
{homes && homes.length > 0 && <>
<label className="muted">Name
<input autoFocus className="mono" value={name} onChange={(e) => { setName(e.target.value.toLowerCase()); setErr('') }} placeholder={mode === 'container' ? 'whoami' : 'my-stack'} maxLength={63} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
</label>
{name && !okName && <div className="error">lower-case letters, digits, - and _ — starting with a letter or digit</div>}
{homes.length > 1 && <label className="muted">Where
<select value={home} onChange={(e) => setHome(e.target.value)}>{homes.map((h) => <option key={h.dir + h.via} value={h.dir}>{sameTail(h) ? h.dir : tail(h.dir)} · {h.stacks} stack{h.stacks === 1 ? '' : 's'}</option>)}</select>
</label>}
{picked && <div className="muted small mono" style={{ overflowWrap: 'anywhere' }}>{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}</div>}
{!from && <div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={toCompose}>Compose</button></div>}
{how === 'form' ? <>
<label className="muted">Image<input className="mono" value={form.image} onChange={set('image')} placeholder="traefik/whoami:latest" spellCheck={false} autoCapitalize="off" autoCorrect="off" /></label>
<div className="two">
<label className="muted">Ports — host:container, one per line<textarea className="mono" rows={2} value={form.ports} onChange={set('ports')} placeholder={'8080:80'} disabled={form.network === 'host'} spellCheck={false} /></label>
<label className="muted">Volumes — host path or name : container path<textarea className="mono" rows={2} value={form.volumes} onChange={set('volumes')} placeholder={'/home/core/apps/whoami:/data'} spellCheck={false} /></label>
</div>
<div className="two">
<label className="muted">Environment — KEY=value, one per line<textarea className="mono" rows={2} value={form.env} onChange={set('env')} placeholder={'TZ=America/Los_Angeles'} spellCheck={false} /></label>
<label className="muted">Labels — key=value, one per line<textarea className="mono" rows={2} value={form.labels} onChange={set('labels')} placeholder={'com.example.owner=mitchell'} spellCheck={false} /></label>
</div>
<div className="two">
<label className="muted">Restart<select value={form.restart} onChange={set('restart')}><option value="unless-stopped">unless-stopped</option><option value="always">always</option><option value="on-failure">on-failure</option><option value="no">no</option></select></label>
<label className="muted">Network<select value={form.network} onChange={set('network')}><option value="">its own (the default)</option><option value="host">host — this machine's network; ports don't apply</option>{networks.map((n) => <option key={n} value={n}>join {n}</option>)}</select></label>
</div>
<label className="muted">Command (optional)<input className="mono" value={form.command} onChange={set('command')} spellCheck={false} autoCapitalize="off" /></label>
<pre className="unit-file stack-preview">{built}</pre>
</> : <>
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
{from?.hasEnv && <label className="check"><input type="checkbox" checked={copyEnv} onChange={(e) => setCopyEnv(e.target.checked)} /> <span>Copy {from.name}'s .env too <span className="muted">— copied on the machine itself; its values are never shown here</span></span></label>}
{!(from && copyEnv) && <label className="muted">.env (optional) — variables the compose file reads; kept readable by root only
<textarea className="mono" rows={2} value={env} onChange={(e) => setEnv(e.target.value)} placeholder={'SECRET=…'} spellCheck={false} />
</label>}
</>}
<div className="modal-foot">
{clashes.length > 0 && <div className="clash">Deploying this as it stands would fail: {clashes.join('; ')}.</div>}
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(true)}>Create and deploy</Btn>
<Btn className="btn" disabled={!okName || (how === 'form' && !form.image.trim())} onClick={create(false)}>Create only</Btn>
<span className="spacer" /><button className="btn" onClick={close}>Cancel</button>
</div>
</div>
</>}
{err && !homes && <div className="error">{err}</div>}
</div>
</div>
)
}