// 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 } // container names in use · published host port → who has it interface Props { mode: 'stack' | 'container'; from?: DupFrom; networks?: string[]; taken?: Taken; onClose: () => void; onCreated: (name: string, deploy: boolean) => void } export default function StackNew({ mode, from, networks = [], taken, onClose, onCreated }: Props) { const startName = from ? `${from.name}-copy`.slice(0, 63) : '' const startText = from?.text ?? START const [homes, setHomes] = useState(null) const [home, setHome] = useState('') const [name, setName] = useState(startName) const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose') const [form, setForm] = useState
(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 (
e.stopPropagation()}>

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

{mode === 'container' &&
Made as a one-service stack, so you can edit it, redeploy it and update its image later from the Stacks tab.
} {from &&
A new stack that starts as a copy of {from.name}'s compose file{from.files > 1 ? ` (its first file only: ${from.name} is made of ${from.files})` : ''}. {from.name} itself is not changed. Volumes and bind-mounted folders named in it would be shared with the original unless you change them.
} {!homes && !err && } {homes && homes.length === 0 &&
Docker isn't installed on this machine.
} {homes && homes.length > 0 && <> {name && !okName &&
lower-case letters, digits, - and _ — starting with a letter or digit
} {homes.length > 1 && } {picked &&
{picked.dir}/{name || '…'}/compose.yaml{picked.held_by && ` — kept on ${picked.held_by}, deployed here over ${picked.remote}`}{picked.via && ` — compose runs inside ${picked.via}, like its neighbours`}
} {!from &&
} {how === 'form' ? <>