Bug bash 3: solid HUD dialogs, remote stacks deployed from the hub's shell, stopped-on-purpose is not down, the New stack dialog keeps what you typed
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Fable 5.1
parent
634e286796
commit
a446a03b7d
+3
-3
@@ -154,9 +154,9 @@ function StackDetail({ name, startTask, onBack, onChanged }: { name: string; sta
|
||||
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}
|
||||
<span className="spacer" />
|
||||
<Btn className="btn" disabled={dirty} title={dirty ? 'save first' : 'recreate what changed: docker compose up -d'} confirm={`Deploy ${st.name}? Containers whose definition changed are recreated (a few seconds down each).`} onClick={run('deploy')}>Deploy</Btn>
|
||||
<Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>
|
||||
<Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>
|
||||
<Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>
|
||||
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="pull newer images, then deploy" confirm={`Pull newer images for ${st.name} and redeploy? Anything with a new image is recreated.`} onClick={run('update')}>Update</Btn>}
|
||||
{st.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
|
||||
{st.running > 0 && <Btn className="btn danger" confirm={`Stop every container in ${st.name}? They stay stopped until deployed or started again.`} onClick={run('stop')}>Stop</Btn>}
|
||||
</div>
|
||||
<div className="svc-actions">
|
||||
<span className="muted small">{st.containers.length === 0 ? 'Not deployed: no containers exist for this stack.' : ''}</span><span className="spacer" />
|
||||
|
||||
+20
-6
@@ -4,10 +4,11 @@
|
||||
// page, where a bare `docker run` could only ever be thrown away and typed again.
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { api, StackHome } from './api'
|
||||
import { 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 }
|
||||
const EMPTY: Form = { image: '', ports: '', volumes: '', env: '', restart: 'unless-stopped', command: '', host: false }
|
||||
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
|
||||
|
||||
@@ -30,10 +31,14 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
|
||||
const [name, setName] = useState('')
|
||||
const [how, setHow] = useState<'form' | 'compose'>(mode === 'container' ? 'form' : 'compose')
|
||||
const [form, setForm] = useState<Form>(EMPTY)
|
||||
const [text, setText] = useState('services:\n app:\n image: nginx:alpine\n restart: unless-stopped\n ports:\n - "8080:80"\n')
|
||||
const [text, setText] = useState(START)
|
||||
const [env, setEnv] = useState('')
|
||||
const [err, setErr] = useState('')
|
||||
useEscape(onClose)
|
||||
const [seeded, setSeeded] = useState('') // what the form last wrote into the compose box
|
||||
// a stray tap outside, or Escape, must not throw away a compose file someone has typed
|
||||
const typed = !!name || !!env || text !== START || JSON.stringify(form) !== JSON.stringify(EMPTY)
|
||||
const 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])
|
||||
@@ -42,9 +47,18 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
|
||||
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') }
|
||||
}
|
||||
// The form writes the compose box only when the form has something new to say; text edited by
|
||||
// hand since is asked about, never silently replaced.
|
||||
const toCompose = async () => {
|
||||
if (how === 'form' && form.image.trim() && built !== seeded) {
|
||||
const mine = text !== START && text !== seeded
|
||||
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 })
|
||||
return (
|
||||
<div className="modal-back" onClick={onClose}>
|
||||
<div className="modal-back" onClick={close}>
|
||||
<div className="modal stack-new" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>{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>}
|
||||
@@ -59,7 +73,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
|
||||
<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>
|
||||
</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>}
|
||||
<div className="tabs"><button className={how === 'form' ? 'on' : ''} onClick={() => setHow('form')}>Form</button><button className={how === 'compose' ? 'on' : ''} onClick={() => { if (how === 'form' && form.image.trim()) setText(built); setHow('compose') }}>Compose</button></div>
|
||||
<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">
|
||||
@@ -83,7 +97,7 @@ export default function StackNew({ mode, onClose, onCreated }: { mode: 'stack' |
|
||||
<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={onClose}>Cancel</button>
|
||||
<span className="spacer" /><button className="btn" onClick={close}>Cancel</button>
|
||||
</div>
|
||||
</>}
|
||||
{err && !homes && <div className="error">{err}</div>}
|
||||
|
||||
+7
-1
@@ -431,6 +431,8 @@ h2.section { margin: 0; font-size: 13px; font-weight: 600; color: var(--muted);
|
||||
a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
|
||||
/* in-app dialogs, gallery search */
|
||||
/* a question is asked by something, often another dialog: it goes on top of whatever asked */
|
||||
.modal-back.ask-back { z-index: 70; }
|
||||
.modal.ask { width: min(440px, 100%); } .ask-msg { white-space: pre-wrap; overflow-wrap: anywhere; font-size: 14px; }
|
||||
.gallery-find { flex: 1 1 200px; max-width: 340px; padding: 7px 10px; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; color: var(--fg); font-size: 14px; }
|
||||
.gallery .dash-bar { flex-wrap: wrap; gap: 8px; }
|
||||
@@ -448,7 +450,10 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
.themes { grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
|
||||
|
||||
/* HUD: light drawn on glass — hairlines, no fills, thin type */
|
||||
[data-theme="hud"] .tile, [data-theme="hud"] .card, [data-theme="hud"] .box, [data-theme="hud"] .modal { background: rgba(53, 214, 255, .03); border-color: rgba(53, 214, 255, .28); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .05), inset 0 0 24px rgba(53, 214, 255, .04); }
|
||||
[data-theme="hud"] .tile, [data-theme="hud"] .card, [data-theme="hud"] .box { background: rgba(53, 214, 255, .03); border-color: rgba(53, 214, 255, .28); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .05), inset 0 0 24px rgba(53, 214, 255, .04); }
|
||||
/* anything that floats over the page is solid: glass on glass is two layers of text */
|
||||
[data-theme="hud"] .modal { background: #061019; border-color: rgba(53, 214, 255, .45); border-radius: 2px; box-shadow: 0 0 0 1px rgba(53, 214, 255, .08), 0 0 40px rgba(53, 214, 255, .12), inset 0 0 24px rgba(53, 214, 255, .04); }
|
||||
[data-theme="hud"] .modal-back { background: rgba(0, 3, 6, .82); }
|
||||
[data-theme="hud"] .tile::before, [data-theme="hud"] .card::before { content: ''; position: absolute; top: -1px; left: -1px; width: 12px; height: 12px; border-top: 2px solid var(--accent); border-left: 2px solid var(--accent); }
|
||||
[data-theme="hud"] .tile, [data-theme="hud"] .card { position: relative; }
|
||||
[data-theme="hud"] body { font-weight: 300; letter-spacing: .2px; }
|
||||
@@ -456,6 +461,7 @@ a.btn { text-decoration: none; display: inline-flex; align-items: center; }
|
||||
[data-theme="hud"] .tile .label, [data-theme="hud"] .card h2, [data-theme="hud"] h2.section { letter-spacing: 2px; color: var(--accent); opacity: .8; }
|
||||
[data-theme="hud"] .meter { height: 3px; border-radius: 0; } [data-theme="hud"] .meter .fill { border-radius: 0; box-shadow: 0 0 8px var(--s1); }
|
||||
[data-theme="hud"] .rail, [data-theme="hud"] .header, [data-theme="hud"] .footer { background: rgba(3, 7, 12, .92); border-color: rgba(53, 214, 255, .22); }
|
||||
@media (max-width: 800px) { [data-theme="hud"] .rail { background: #03070c; } }
|
||||
[data-theme="hud"] .group-head.active { background: linear-gradient(90deg, rgba(53, 214, 255, .16), transparent); box-shadow: inset 2px 0 0 var(--accent); }
|
||||
[data-theme="hud"] .btn, [data-theme="hud"] .chip { background: transparent; border-color: rgba(53, 214, 255, .35); border-radius: 2px; } [data-theme="hud"] .btn.on, [data-theme="hud"] .chip.on { background: rgba(53, 214, 255, .16); color: var(--accent2); border-color: var(--accent); }
|
||||
[data-theme="hud"] .app { background: radial-gradient(ellipse at 50% -20%, rgba(53, 214, 255, .10), transparent 60%), var(--bg); }
|
||||
|
||||
+10
-4
@@ -70,11 +70,17 @@ export function NetBar() {
|
||||
}
|
||||
|
||||
// useEscape runs fn when Escape is pressed while active — every menu and dialog closes that way.
|
||||
export function useEscape(fn: () => void, active = true) {
|
||||
// A question (askYes/askText) sits on top of whatever asked it: Escape answers the question and
|
||||
// goes no further, or one key would cancel the question and close the dialog underneath as well.
|
||||
let escapeTaken: Event | null = null
|
||||
export function useEscape(fn: () => void, active = true, isAsk = false) {
|
||||
const ref = useRef(fn); ref.current = fn
|
||||
useEffect(() => {
|
||||
if (!active) return
|
||||
const h = (e: KeyboardEvent) => { if (e.key === 'Escape') ref.current() }
|
||||
const h = (e: KeyboardEvent) => {
|
||||
if (e.key !== 'Escape') return
|
||||
if (isAsk) { escapeTaken = e; ref.current() } else if (asks.length === 0 && escapeTaken !== e) ref.current()
|
||||
}
|
||||
window.addEventListener('keydown', h)
|
||||
return () => window.removeEventListener('keydown', h)
|
||||
}, [active])
|
||||
@@ -103,9 +109,9 @@ function AskBox({ a }: { a: Ask }) {
|
||||
const [val, setVal] = useState(a.input ?? '')
|
||||
const cancel = () => a.done(a.input !== undefined ? null : false)
|
||||
const accept = () => a.done(a.input !== undefined ? val : true)
|
||||
useEscape(cancel)
|
||||
useEscape(cancel, true, true)
|
||||
return (
|
||||
<div className="modal-back" onClick={cancel}>
|
||||
<div className="modal-back ask-back" onClick={cancel}>
|
||||
<form className="modal ask" onClick={(e) => e.stopPropagation()} onSubmit={(e) => { e.preventDefault(); accept() }}>
|
||||
<div className="ask-msg">{a.msg}</div>
|
||||
{a.input !== undefined && <input autoFocus value={val} onChange={(e) => setVal(e.target.value)} onFocus={(e) => e.target.select()} spellCheck={false} autoCapitalize="off" autoCorrect="off" />}
|
||||
|
||||
Reference in New Issue
Block a user