A Recreate action, and the Updates tab uses the whole window

Recreate is `up -d --force-recreate`. It exists because Deploy and Update
both leave a container alone when its definition has not changed, which is
precisely the state a container is in when Docker will not start it — pinned
to a network that has since been recreated, answering "network … not found"
to everything. siyuan landed there today and there was no way out from the
console.

The Updates tab is a page, not a list with a detail pane, so on a wide window
it was being squeezed into the right-hand half with an empty list column
taking the left — which read as a phone layout on a desktop. It now sits
under the tab strip at full width. The list-and-detail tabs are unchanged.
The rule needed :not(.has-sel) to outrank the wide-window rule that grows the
list to fill the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Mitchell-Johnson
2026-09-26 13:14:18 -07:00
co-authored by Claude Opus 5
parent 94800b8207
commit 4e3c817b2a
5 changed files with 27 additions and 4 deletions
+8
View File
@@ -184,6 +184,14 @@ something long in one, switch away and back, refresh — it's all still there.
several stacks run through a queue one at a time rather than recreating half the machine at several stacks run through a queue one at a time rather than recreating half the machine at
once, each with its task output on screen. A row that succeeds says "updated just now" once, each with its task output on screen. A row that succeeds says "updated just now"
instead of re-checking, since a check is a registry request. instead of re-checking, since a check is a registry request.
- **Recreate, and the Updates tab at full width** (2026-09-26): a stack's actions gained
**Recreate** (`up -d --force-recreate`), the only way out of a container Docker refuses to
start — one pinned to a network that has since been recreated answers "network … not
found" to Deploy and Update alike, because as far as compose is concerned nothing about it
changed. Found the hard way when a SiYuan update landed in exactly that state. The Updates
tab also stopped rendering as a list-and-detail pair: it is a page, not a list, so on a
wide window it now sits under the tab strip at full width instead of being squeezed into
the right-hand half beside an empty column.
- **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather - **Cron in English** (`crontext.ts`, 2026-09-26): schedules read "daily at 3:15am" rather
than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day than `15 3 * * *`, with the expression kept beside it. Handles lists, ranges, steps, day
and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines and month names, `@daily` and friends, weekdays/weekends, midnight and noon — and declines
+7 -1
View File
@@ -825,6 +825,12 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
script = base("up -d --remove-orphans") + remember script = base("up -d --remove-orphans") + remember
case "update": case "update":
script = base("pull") + " && " + base("up -d --remove-orphans") + remember script = base("pull") + " && " + base("up -d --remove-orphans") + remember
case "recreate":
// The way out of a container Docker will no longer start — most often one pinned to a
// network that has since been recreated, which answers "network … not found" to every
// ordinary deploy. `up -d` leaves such a container alone because its definition has
// not changed; only --force-recreate builds it again.
script = base("up -d --force-recreate --remove-orphans") + remember
case "restart": case "restart":
script = base("restart") script = base("restart")
case "stop": case "stop":
@@ -832,7 +838,7 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) {
case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay case "down": // remove its containers and networks; its files, volumes and bind-mounted data stay
script = base("down --remove-orphans") script = base("down --remove-orphans")
default: default:
jsonErr(w, 400, "deploy, update, restart, stop or down") jsonErr(w, 400, "deploy, update, recreate, restart, stop or down")
return return
} }
if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy { if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy {
+3 -2
View File
@@ -347,7 +347,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') } try { const r = await api.deleteStack(name); toast(`${name} deleted — its files are in ${r.trash}`); setUnsaved(''); onChanged(); onBack() } catch (e) { toast(e instanceof Error ? e.message : 'failed', 'err') }
} }
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 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') } } const run = (a: 'deploy' | 'update' | 'recreate' | '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"><CloseBtn onClick={onBack} /><button className="btn back" onClick={onBack}>‹ Back</button><div className="notice">{err}</div></div> if (err && !st) return <div className="svc-detail"><CloseBtn onClick={onBack} /><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> if (!st) return <div className="svc-detail"><Loading label="Reading the compose file…" /></div>
return ( return (
@@ -366,6 +366,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name
<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={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>
{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" 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.containers.length > 0 && <Btn className="btn" confirm={`Restart every container in ${st.name}?`} onClick={run('restart')}>Restart</Btn>}
{st.containers.length > 0 && <Btn className="btn" disabled={dirty} title="build the containers again from scratch: docker compose up -d --force-recreate" confirm={`Recreate every container in ${st.name}?\n\nThis is the way out of a container Docker refuses to start — one pinned to a network that no longer exists, say, which answers "network … not found" to an ordinary Deploy. Nothing in a volume or a bind mount is touched.`} onClick={run('recreate')}>Recreate</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>} {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>
<div className="svc-actions"> <div className="svc-actions">
@@ -425,7 +426,7 @@ export default function Docker() {
const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`] const sid = (c: DockerContainer, k: string) => sensors[`container.${c.name.toLowerCase().replace(/[^a-z0-9-]+/g, '_').replace(/^_|_$/g, '')}.${k}`]
const imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']] const imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']]
return ( return (
<div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '')}> <div className={'svc docker' + (((cur || sel) && shownTab === 'containers') || selStack ? ' has-sel' : '') + (shownTab === 'updates' ? ' wide' : '')}>
<div className="svc-list"> <div className="svc-list">
<div className="svc-tools"> <div className="svc-tools">
<div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div> <div className="chips">{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => <button key={t} className={'chip' + (shownTab === t ? ' on' : '')} onClick={() => { setTab(t); if (sel) setSel('') }}>{t}{t === 'containers' ? ` · ${up}/${d.containers.length}` : t === 'stacks' ? ` · ${stacks?.length ?? '…'}` : t === 'updates' ? (behind > 0 ? ` · ${behind}` : '') : ` · ${d[t].length}`}</button>)}</div>
+1 -1
View File
@@ -306,7 +306,7 @@ export const api = {
stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`), stackUpdates: (name: string, refresh = false) => req<{ images: ImageCheck[]; checked: number }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}/updates${refresh ? '?refresh=1' : ''}`),
stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`), stackFile: (name: string, file: number | 'env' = 0) => req<{ stack: Stack; text: string; sum: string }>('GET', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`),
saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }), saveStack: (name: string, file: number | 'env', text: string, sum: string) => req<void>('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }),
stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`), stackAction: (name: string, action: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => req<TaskInfo>('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`),
alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true), alerts: () => req<AlertsInfo>('GET', '/api/alerts', undefined, true),
saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true), saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req<void>('PUT', '/api/alerts', p, true),
testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true), testAlerts: () => req<void>('POST', '/api/alerts/test', undefined, true),
+8
View File
@@ -167,6 +167,14 @@ button { font: inherit; color: inherit; background: none; border: 0; cursor: poi
.svc:not(.has-sel) .svc-list, .cron:not(.has-sel) .cron-list { width: auto; flex: 1 1 auto; border-right: 0; } .svc:not(.has-sel) .svc-list, .cron:not(.has-sel) .cron-list { width: auto; flex: 1 1 auto; border-right: 0; }
.svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; } .svc:not(.has-sel) .svc-empty, .cron:not(.has-sel) .svc-empty { display: none; }
} }
/* A tab that is a page rather than a list-and-detail — Docker's Updates — stacks: the tab
strip on top, the page below at full width. Otherwise the empty list column takes half
the window and the page is squeezed into the other half, which looks like a phone. */
.svc.wide { flex-direction: column; }
/* :not(.has-sel) to outrank the wide-window rule above, which grows the list to fill */
.svc.wide:not(.has-sel) .svc-list { width: auto; flex: 0 0 auto; border-right: 0; border-bottom: 1px solid var(--border); }
.svc.wide .svc-rows { display: none; }
.svc.wide > .svc-detail { flex: 1 1 auto; min-height: 0; }
.svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; } .svc-head { display: flex; align-items: center; gap: 10px; } .svc-head h2 { margin: 0; font-size: 18px; font-weight: 600; word-break: break-all; }
.svc-actions { display: flex; flex-wrap: wrap; gap: 6px; } .svc-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; } .props { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 4px 16px; font-size: 13px; }