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 && (