From 148aabd3efd0fe68cf8396f87242767250a44df8 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 30 Sep 2026 23:01:38 +0000 Subject: [PATCH] fix: stop false "update available" for same-version images, plus review fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The checker compared the registry digest against only the FIRST of the image's RepoDigests. Docker keeps several digests for one repo when the same image is known under more than one manifest (tag re-pushed with a new index, or pulled via two tags), listed lexically rather than newest-first — so an up-to-date container could be flagged forever, showing the same version on both sides, and updating it changed nothing. All matching repo digests are now tracked and compared. Also: - sse: a finished session's cleanup timer could delete a NEW session started within 30s (e.g. Revert right after a failed update), hanging its log. - notify: ntfy titles contain emoji and were sent as a header, which fetch rejects — every real ntfy notification threw (the ASCII test passed). Title/tags now go in query params. Messages show "current → available". - standalone recreate/revert: no longer pins the old image's ENV/CMD/labels onto the new image; keeps anonymous volumes and a custom hostname; renames the old container aside and restores it if create/start fails. - checker: check each ref once but evaluate every container running it; skip containers with no registry digest; single-flight concurrent checks; breaking-change scan uses the remembered running version. - scheduler: notifications use remembered versions; dashboards refresh after the daily scan. - client: brief SSE disconnects no longer mark an update failed; "Update all" runs one container at a time per stack; same-version rebuilds are labelled. feat: "Skip" an offered update until a newer build is published Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KiL5R3bRpvGk6QP3E21eHF --- API_CONTRACT.md | 19 ++ README.md | 8 +- client/src/Dashboard.jsx | 5 +- client/src/api.js | 11 + client/src/components/UpdateAllButton.jsx | 47 ++-- client/src/components/UpdateCard.jsx | 71 +++++- client/src/hooks/useSSE.js | 24 +- client/src/styles/app.css | 6 + server/src/checker.js | 60 ++++- server/src/containers-service.js | 21 +- server/src/db.js | 21 ++ server/src/docker.js | 297 ++++++++++++++++------ server/src/notify.js | 50 +++- server/src/reconcile.js | 16 ++ server/src/routes/api.js | 32 +++ server/src/scheduler.js | 6 + server/src/sse.js | 11 +- server/test/containers-service.test.js | 32 +++ server/test/db-skip.test.js | 52 ++++ server/test/docker.test.js | 114 +++++++++ server/test/notify.test.js | 47 ++++ server/test/sse.test.js | 60 +++++ 22 files changed, 870 insertions(+), 140 deletions(-) create mode 100644 server/test/db-skip.test.js create mode 100644 server/test/sse.test.js diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 9f2c575..16613ae 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -215,6 +215,22 @@ always returns normalized refs. Pinning ("Pin Version") holds a container at its current version: it's never flagged for updates and is grouped into a separate section, but can still be updated by hand. +### `POST /api/skip` + +- Auth: cookie. +- Body: `{ "ref": "string" }` (normalized like `/api/pin`). +- Skips the currently offered update for that image ("not this build"): the + item reports `updateAvailable: false, skipped: true` and it's left out of + notifications, until a newer build is found (which is offered as usual). +- Response: `200 { "ok": true }`; `404 { "error": "no_pending_update" }` if + there's no pending update for the ref. + +### `DELETE /api/skip/:ref` + +- Auth: cookie. +- Un-skips the pending update for `ref` (URL-encoded). Same responses as + `POST /api/skip`. + ### `GET /api/settings` - Auth: cookie. @@ -313,6 +329,9 @@ Field notes: the running and available versions mention breaking changes (best-effort, GitHub-sourced images only; scanned when the update event is recorded). `false` otherwise, including when no update is available. +- `skipped` — `true` when an update exists but the user skipped that exact + build (`POST /api/skip`); `updateAvailable` is then `false`, while + `availableDigest`/`availableVersion` still describe the skipped build. - `pinned` — `true` if the image ref is in the `pinned` table ("Pin Version": update indicator is suppressed and the container is grouped separately, but a manual update is still allowed). diff --git a/README.md b/README.md index 8249231..eaa2de7 100644 --- a/README.md +++ b/README.md @@ -75,9 +75,13 @@ If the paths don't match you'll get `compose file not found` and broken bind mou - **Updates tab** — containers grouped by stack, update-available ones on top. Defaults to showing only what needs updating; flip to **All** to see everything. Tap **Update** to pull + recreate that service (watch live logs), or **Update all** - to run them one at a time. After an update DockPull verifies the container actually + to run them (one at a time within each stack). After an update DockPull verifies the container actually comes up healthy (catching crash-loops), and offers a one-click **Revert** to the - previous image if it doesn't. **Pin Version** holds a container at its current version. + previous image if it doesn't. **Pin Version** holds a container at its current version; + **Skip** dismisses just the update on offer, and the card returns when a newer + build is published. An update marked **(rebuilt)** has the same version number + but a new image — the publisher re-pushed the tag, usually for base-image or + security patches. - **History tab** — a log of past updates. **Clear history** wipes it (with a confirm). - **Settings tab** — theme, default view, auto-check on open, the **daily background scan** + **notifications** (Discord, ntfy, Gotify, or a generic webhook — with a diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index 160f3c1..aad7b54 100644 --- a/client/src/Dashboard.jsx +++ b/client/src/Dashboard.jsx @@ -210,7 +210,10 @@ export default function Dashboard({ onPendingCountChange }) { ); const mainItems = useMemo(() => visible.filter((c) => !c.pinned), [visible]); - const pendingTargets = useMemo(() => mainItems.filter(hasUpdate).map((c) => c.name), [mainItems]); + const pendingTargets = useMemo( + () => mainItems.filter(hasUpdate).map((c) => ({ name: c.name, project: c.project })), + [mainItems] + ); useEffect(() => { if (onPendingCountChange) onPendingCountChange(pendingTargets.length); diff --git a/client/src/api.js b/client/src/api.js index 01228f3..72c754b 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -157,6 +157,17 @@ export function unpin(ref) { return del(`/pin/${encodeURIComponent(ref)}`); } +// --- Skipping a specific update --- + +// Dismiss the currently offered build for an image until a newer one appears. +export function skipUpdate(ref) { + return post('/skip', { ref }); +} + +export function unskipUpdate(ref) { + return del(`/skip/${encodeURIComponent(ref)}`); +} + // --- Settings --- export function getSettings() { diff --git a/client/src/components/UpdateAllButton.jsx b/client/src/components/UpdateAllButton.jsx index 739590c..3771ff4 100644 --- a/client/src/components/UpdateAllButton.jsx +++ b/client/src/components/UpdateAllButton.jsx @@ -1,13 +1,15 @@ import React, { useCallback, useState } from 'react'; /** - * Updates every container with `updateAvailable && !pinned`, all at once: - * each is started immediately and its own SSE stream runs concurrently - * (handled by `runUpdate`). A failure on one container does not affect the - * others — `runUpdate` resolves (not rejects) even on failure, and - * `Promise.allSettled` waits for them all regardless. + * Updates every container with `updateAvailable && !pinned`. Containers in the + * same stack (compose project) run one at a time — concurrent `docker compose + * up` calls against one project can race on its shared networks and + * dependencies — while different stacks proceed in parallel. A failure on one + * container does not stop the others: `runUpdate` resolves (never rejects) + * with the outcome. * - * Disabled when there are no eligible targets or any update is in flight. + * `targets` is `[{ name, project }]`. Disabled when there are no eligible + * targets or any update is in flight. */ export default function UpdateAllButton({ targets, runUpdate, disabled, onBatchDone }) { const [running, setRunning] = useState(false); @@ -15,17 +17,28 @@ export default function UpdateAllButton({ targets, runUpdate, disabled, onBatchD const handleClick = useCallback(async () => { if (running || disabled || targets.length === 0) return; setRunning(true); - // Fire them all immediately, then wait for the whole batch to settle. - // Each run() resolves (never rejects) with { success, message }, so the - // dashboard can show one aggregate summary instead of making the user - // scroll every card to find what failed. - const outcomes = await Promise.all( - targets.map((name) => - Promise.resolve(runUpdate(name)) - .then((r) => ({ name, success: !!(r && r.success), message: (r && r.message) || '' })) - .catch((err) => ({ name, success: false, message: err?.message || '' })) - ) + const runOne = (name) => + Promise.resolve(runUpdate(name)) + .then((r) => ({ name, success: !!(r && r.success), message: (r && r.message) || '' })) + .catch((err) => ({ name, success: false, message: err?.message || '' })); + + // One sequential lane per stack; standalone containers each get their own. + const lanes = new Map(); + for (const t of targets) { + const key = t.project ? `p:${t.project}` : `c:${t.name}`; + if (!lanes.has(key)) lanes.set(key, []); + lanes.get(key).push(t.name); + } + // Each run() resolves with { success, message }, so the dashboard can show + // one aggregate summary instead of making the user scroll every card. + const laneOutcomes = await Promise.all( + [...lanes.values()].map(async (names) => { + const out = []; + for (const name of names) out.push(await runOne(name)); + return out; + }) ); + const outcomes = laneOutcomes.flat(); setRunning(false); if (onBatchDone) onBatchDone(outcomes); }, [running, disabled, targets, runUpdate, onBatchDone]); @@ -33,7 +46,7 @@ export default function UpdateAllButton({ targets, runUpdate, disabled, onBatchD return ( )} + {(showUpdateAvailable || (skipped && !pinned)) && ( + + )} {canRevert && (