Bug bash 2: 11 bugs in Stacks, Alerts, Docker, skins and the installer, fixed and verified (BUGS.md)

Stacks: deploy with every compose file (labels from inspect, not comma-joined ps);
refuse a stale overwrite; empty list without Docker. Alerts: firing state survives a
restart without re-announcing; vanished alerts are recorded as cleared with the reason;
rule values validated; 200 rules. Docker: 404 for a removed container's logs. Skins: four
palettes lifted to readable contrast. Phone: bigger toggles and swatches. Installer:
deploy republishes; an older published build is never installed silently.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-19 15:59:51 -07:00
co-authored by Claude Fable 5.1
parent 9c4f9dfa9f
commit eddba4250b
10 changed files with 320 additions and 68 deletions
+7 -5
View File
@@ -7,7 +7,7 @@ import TaskOutput from './TaskOutput'
import { Sensor, fmtValue, useSensorStream } from './sensors'
import { useHashSel } from './hashsel'
import { useRefreshMs } from './prefs'
import { Btn, Loading, setUnsaved, toast } from './ui'
import { Btn, Loading, askYes, setUnsaved, toast } from './ui'
type Tab = 'containers' | 'stacks' | 'images' | 'volumes' | 'networks'
const dot = (c: DockerContainer) => (c.state !== 'running' ? (c.state === 'exited' || c.state === 'dead' ? 'bad' : 'warn') : c.health === 'unhealthy' ? 'bad' : c.health === 'starting' ? 'warn' : 'ok')
@@ -95,20 +95,22 @@ function StackDetail({ name, onBack, onChanged }: { name: string; onBack: () =>
const [st, setSt] = useState<Stack | null>(null)
const [text, setText] = useState(''); const [orig, setOrig] = useState('')
const [err, setErr] = useState(''); const [task, setTask] = useState('')
const load = useCallback(async () => { try { const r = await api.stackFile(name); setSt(r.stack); setText(r.text); setOrig(r.text); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name])
const [file, setFile] = useState(0); const [sum, setSum] = useState('')
const load = useCallback(async () => { try { const r = await api.stackFile(name, file); setSt(r.stack); setText(r.text); setOrig(r.text); setSum(r.sum); setErr('') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }, [name, file])
useEffect(() => { setSt(null); setTask(''); load() }, [load])
const dirty = text !== orig
useEffect(() => { setUnsaved(dirty ? `${name}'s compose file` : ''); return () => setUnsaved('') }, [dirty, name])
const save = async () => { try { await api.saveStack(name, text); setOrig(text); setErr(''); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
const save = async () => { try { await api.saveStack(name, file, text, sum); setOrig(text); setErr(''); api.stackFile(name, file).then((r) => setSum(r.sum)).catch(() => {}); toast('saved — the running containers are unchanged until you deploy') } catch (e) { setErr(e instanceof Error ? e.message : 'failed') } }
const run = (a: 'deploy' | 'update' | 'restart' | 'stop') => 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 (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return (
<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="muted small mono" style={{ overflowWrap: 'anywhere' }}>{st.host_file}{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' }}>{st.host_files?.[file] ?? st.host_file}{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 && <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>)}</div>}
<textarea className="editor stack-editor" value={text} onChange={(e) => setText(e.target.value)} spellCheck={false} autoCapitalize="off" autoCorrect="off" />
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}</div>}
{err && <div className="error" style={{ whiteSpace: 'pre-wrap' }}>{err}{/changed on disk/.test(err) && <> <button className="link" onClick={async () => { if (await askYes('Reload the file from disk? Your text in the box is replaced.', 'Reload')) load() }}>reload it</button></>}</div>}
<div className="svc-actions">
<Btn className="btn on" disabled={!dirty} onClick={save}>Save</Btn>
{dirty && <button className="btn" onClick={() => { setText(orig); setErr('') }}>Revert</button>}