A × closes a detail pane on a wide screen: Docker, Services, Cron

On a phone the list gives way to the pane and Back returns; on a desktop the list stays
beside it and there was no way to put the pane away.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-22 19:52:35 -07:00
co-authored by Claude Fable 5.1
parent 027a2a575c
commit b8ea7abcc7
4 changed files with 15 additions and 3 deletions
+3
View File
@@ -48,6 +48,7 @@ function SourceEditor({ name, onClose, onSaved }: { name: string; onClose: () =>
}
return (
<div className="cron-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onClose}>×</button>
<button className="btn back" onClick={onClose}>‹ Back</button>
<h2>{srcLabel(name)}</h2>
{!editable && <div className="notice">Read-only: writing this file needs passwordless sudo on the host.</div>}
@@ -98,6 +99,7 @@ function JobDetail({ job, logging, onChanged, onBack, onEdit }: { job: CronJob;
return (
<div className="cron-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
<button className="btn back" onClick={onBack}>‹ Cron</button>
<h2>{job.command}</h2>
<div className="props">
@@ -150,6 +152,7 @@ function TimerDetail({ t, onChanged, onBack }: { t: CronTimer; onChanged: () =>
const B = ({ a, label }: { a: string; label: string }) => <button className="btn" disabled={!!busy} onClick={() => act(a)}>{busy === a && <Spinner />}{label}</button>
return (
<div className="cron-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
<button className="btn back" onClick={onBack}>‹ Cron</button>
<h2>{t.unit}</h2>
<div className="muted">{t.description}</div>
+4 -2
View File
@@ -60,6 +60,7 @@ function Detail({ c, live, onBack, onChanged }: { c: DockerContainer; live: Reco
const mounts = (info?.mounts as { Source: string; Destination: string; RW: boolean }[] | undefined) ?? []
return (
<div className="svc-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
<div className="svc-head"><button className="btn back" onClick={onBack}>‹ Back</button><span className={'sdot ' + dot(c)} /><span className="dname">{c.name}</span></div>
<div className="props">
<div><span className="k">Image</span><span className="mono small">{c.image}</span></div>
@@ -140,10 +141,11 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
}
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') } }
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="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button><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">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
<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>
{((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>}
@@ -264,7 +266,7 @@ 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="icon-btn detail-close" title="Close" aria-label="close" onClick={() => setSel('')}>×</button><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>))}
{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} onDuplicate={(f) => { setDup(f); setMaking('stack') }} /> : <div className="svc-empty muted">Select a stack to edit its compose file.</div>)}
+1
View File
@@ -137,6 +137,7 @@ function Detail({ name, onChanged, onBack }: { name: string; onChanged: () => vo
return (
<div className="svc-detail">
<button className="icon-btn detail-close" title="Close" aria-label="close" onClick={onBack}>×</button>
<button className="btn back" onClick={onBack}>‹ Services</button>
<div className="svc-head">
<span className={'sdot ' + stateClass(d)} />
+7 -1
View File
@@ -149,6 +149,12 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
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; }
/* The × that closes a detail pane on a wide screen, where the list stays beside it and there is
no Back. Sits in the pane's top-right corner; the header keeps clear of it. */
.svc-detail, .cron-detail { position: relative; }
.detail-close { position: absolute; top: 8px; right: 10px; z-index: 1; width: 28px; height: 28px; line-height: 28px; font-size: 20px; color: var(--muted); border-radius: 6px; }
.detail-close:hover { color: var(--fg); background: var(--bg3); }
.svc-detail > .svc-head, .cron-detail > h2 { padding-right: 36px; }
/* Nothing selected: the list has the window to itself rather than a 360px column beside an
empty pane. The names, descriptions and commands that get an ellipsis are what it's for.
Picking something puts the list back at its own width, beside the detail. A phone shows one
@@ -174,7 +180,7 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.log.err .lm { color: var(--bad); } .log.warn .lm { color: var(--s2); } .log.debug .lm { color: var(--muted); }
@media (max-width: 800px) {
.svc-list { width: 100%; border-right: 0; } .has-sel .svc-list { display: none; } .svc-empty { display: none; }
.svc-detail .back { display: inline-block; } .props { grid-template-columns: 1fr; }
.svc-detail .back { display: inline-block; } .props { grid-template-columns: 1fr; } .detail-close { display: none; }
}
/* cron */