From 4e3c817b2a66f63de2859d233529167409d64b7d Mon Sep 17 00:00:00 2001 From: Mitchell-Johnson Date: Sat, 26 Sep 2026 13:14:18 -0700 Subject: [PATCH] A Recreate action, and the Updates tab uses the whole window MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- PLAN.md | 8 ++++++++ internal/api/stacks.go | 8 +++++++- web/src/Docker.tsx | 5 +++-- web/src/api.ts | 2 +- web/src/styles.css | 8 ++++++++ 5 files changed, 27 insertions(+), 4 deletions(-) diff --git a/PLAN.md b/PLAN.md index 3171e81..0e0fab5 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 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. +- **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 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 diff --git a/internal/api/stacks.go b/internal/api/stacks.go index d2ee5ea..ec74085 100644 --- a/internal/api/stacks.go +++ b/internal/api/stacks.go @@ -825,6 +825,12 @@ func (s *Server) stackAction(w http.ResponseWriter, r *http.Request) { script = base("up -d --remove-orphans") + remember case "update": 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": script = base("restart") 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 script = base("down --remove-orphans") default: - jsonErr(w, 400, "deploy, update, restart, stop or down") + jsonErr(w, 400, "deploy, update, recreate, restart, stop or down") return } if t, busy := s.tasks.RunningOf("stack:" + st.Name); busy { diff --git a/web/src/Docker.tsx b/web/src/Docker.tsx index ee002ad..6841eff 100644 --- a/web/src/Docker.tsx +++ b/web/src/Docker.tsx @@ -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') } } 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
{err}
if (!st) return
return ( @@ -366,6 +366,7 @@ function StackDetail({ name, startTask, onBack, onChanged, onDuplicate }: { name Deploy {st.containers.length > 0 && Update} {st.containers.length > 0 && Restart} + {st.containers.length > 0 && Recreate} {st.running > 0 && Stop}
@@ -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 imageCols: [string, string][] = [['Repository', 'Repository'], ['Tag', 'Tag'], ['Size', 'Size'], ['CreatedSince', 'Created']] return ( -
+
{(['containers', 'stacks', 'updates', 'images', 'volumes', 'networks'] as Tab[]).map((t) => )}
diff --git a/web/src/api.ts b/web/src/api.ts index dbdcbcd..2f1af82 100644 --- a/web/src/api.ts +++ b/web/src/api.ts @@ -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' : ''}`), 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('PUT', `/api/docker/stacks/${encodeURIComponent(name)}?file=${file}`, { text, sum }), - stackAction: (name: string, action: 'deploy' | 'update' | 'restart' | 'stop' | 'down') => req('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`), + stackAction: (name: string, action: 'deploy' | 'update' | 'recreate' | 'restart' | 'stop' | 'down') => req('POST', `/api/docker/stacks/${encodeURIComponent(name)}/${action}`), alerts: () => req('GET', '/api/alerts', undefined, true), saveAlerts: (p: { rules?: AlertRule[]; channels?: AlertChannel[] }) => req('PUT', '/api/alerts', p, true), testAlerts: () => req('POST', '/api/alerts/test', undefined, true), diff --git a/web/src/styles.css b/web/src/styles.css index 702241e..a3ab1d3 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -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-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-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; }