Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
8 changes: 6 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion client/src/Dashboard.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
11 changes: 11 additions & 0 deletions client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
47 changes: 30 additions & 17 deletions client/src/components/UpdateAllButton.jsx
Original file line number Diff line number Diff line change
@@ -1,39 +1,52 @@
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);

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]);

return (
<button
type="button"
className="btn btn-primary"
className="btn btn-primary btn-sm"
onClick={handleClick}
disabled={disabled || running || targets.length === 0}
>
Expand Down
71 changes: 67 additions & 4 deletions client/src/components/UpdateCard.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { pin, unpin, getChangelog } from '../api.js';
import { pin, unpin, skipUpdate, unskipUpdate, getChangelog } from '../api.js';
import { useUpdateRunner } from '../hooks/useUpdateRunner.js';
import StatusMessage from './StatusMessage.jsx';
import StreamLog from './StreamLog.jsx';
Expand Down Expand Up @@ -37,6 +37,14 @@ function displayVersion({ currentVersion, tag, currentDigest }) {
return shortDigest(currentDigest);
}

// Same release, different build? Compare versions ignoring a leading "v"
// ("v1.2.3" and "1.2.3" are the same release).
function sameVersion(a, b) {
if (!isMeaningfulVersion(a) || !isMeaningfulVersion(b)) return false;
const norm = (v) => v.trim().replace(/^v/i, '').toLowerCase();
return norm(a) === norm(b);
}

// Build a "Changelog"/"Source" link from the image's OCI source label. GitHub
// repos get pointed at their releases page (best place for a changelog).
function sourceLink(sourceUrl) {
Expand Down Expand Up @@ -139,7 +147,7 @@ function ChangelogContent({ data }) {
* - registerRunner(name, runFn) — handle for "Update all"
*/
export default function UpdateCard({ container, onSettled, onPinChange, registerRunner }) {
const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, breakingRisk, pinned, sourceUrl, canRevert, rollbackVersion, checkError, state } =
const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, breakingRisk, skipped, pinned, sourceUrl, canRevert, rollbackVersion, checkError, state } =
container;

const [pinBusy, setPinBusy] = useState(false);
Expand Down Expand Up @@ -188,6 +196,25 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
}
}, [pinned, image, onPinChange]);

// Skip just the offered build (or undo that). Reuses the pin busy flag and
// refresh callback — both are "change what this card offers" actions.
const toggleSkip = useCallback(async () => {
setPinBusy(true);
setActionError('');
try {
if (skipped) {
await unskipUpdate(image);
} else {
await skipUpdate(image);
}
if (onPinChange) onPinChange();
} catch (err) {
setActionError(err.message || 'Failed to skip update');
} finally {
setPinBusy(false);
}
}, [skipped, image, onPinChange]);

const toggleChangelog = useCallback(async () => {
const next = !clOpen;
setClOpen(next);
Expand All @@ -207,6 +234,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register

const showUpdateAvailable = updateAvailable && !pinned;
const link = sourceLink(sourceUrl);
const runningLabel = displayVersion(container);
// The tag was re-published with a new image but the same version number —
// typically a base-image/security refresh. Say so, rather than showing a
// confusing "1.2.3 → 1.2.3".
const isRebuild = showUpdateAvailable && sameVersion(runningLabel, availableVersion);

return (
<div className={`update-card${showUpdateAvailable ? ' has-update' : ''}`}>
Expand All @@ -225,6 +257,11 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
</span>
)}
{pinned && <span className="pill pill-pinned">Version pinned</span>}
{skipped && !pinned && (
<span className="pill" title={availableDigest || ''}>
Skipped {isMeaningfulVersion(availableVersion) ? availableVersion : 'update'}
</span>
)}
{state && state !== 'running' && (
<span className="pill pill-state" title={`Container is ${state} — updating it will start it`}>
{state}
Expand All @@ -249,14 +286,18 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
<div className="version-row">
<span className="version-label">Running</span>
<span className="version-value" title={currentDigest || ''}>
{displayVersion(container)}
{runningLabel}
</span>
</div>
{showUpdateAvailable && (
<div className="version-row">
<span className="version-label">Available</span>
<span className="version-value is-available" title={availableDigest || ''}>
{isMeaningfulVersion(availableVersion) ? availableVersion : 'newer image'}
{isRebuild
? `${availableVersion} (rebuilt)`
: isMeaningfulVersion(availableVersion)
? availableVersion
: 'newer image'}
</span>
{breakingRisk && (
<span className="breaking-flag" title="Release notes mention possible breaking changes">⚠️</span>
Expand All @@ -265,6 +306,13 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
)}
</div>

{isRebuild && (
<p className="card-hint">
Same version, new build ({shortDigest(currentDigest)} → {shortDigest(availableDigest)}). The
publisher re-pushed this tag — usually base-image or security patches.
</p>
)}

{checkError && (
<p className="card-check-error" title={checkError}>
⚠ Couldn't check for updates (e.g. private registry or rate limit).
Expand All @@ -287,6 +335,21 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
{clOpen ? 'Hide changes' : showUpdateAvailable ? "What's changed" : 'Release notes'}
</button>
)}
{(showUpdateAvailable || (skipped && !pinned)) && (
<button
type="button"
className="btn-ghost"
onClick={toggleSkip}
disabled={pinBusy || busy}
title={
skipped
? 'Offer this update again'
: 'Hide this update until a newer one is released (unlike pinning, newer updates still show)'
}
>
{skipped ? 'Unskip' : 'Skip'}
</button>
)}
{canRevert && (
<button
type="button"
Expand Down
24 changes: 23 additions & 1 deletion client/src/hooks/useSSE.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import { API_BASE } from '../api.js';
* The stream is keyed by container name (not the streamId returned by
* POST /api/update/:name — that value is informational only).
*/
const RECONNECT_GRACE_MS = 30_000;

export function useSSE(name, active) {
const [lines, setLines] = useState([]);
const [result, setResult] = useState(null);
Expand All @@ -34,9 +36,16 @@ export function useSSE(name, active) {

const es = new EventSource(`${API_BASE}/update/${encodeURIComponent(name)}/stream`);
esRef.current = es;
// Pending "give up reconnecting" timer while the browser retries.
let giveUp = null;

es.onopen = () => {
clearTimeout(giveUp);
giveUp = null;
setConnected(true);
// The server replays the whole buffered log to every (re)connection, so
// start fresh rather than duplicating what we already showed.
setLines([]);
};

es.onmessage = (event) => {
Expand All @@ -61,10 +70,23 @@ export function useSSE(name, active) {

es.onerror = () => {
setConnected(false);
setError('Connection lost');
// A blip (phone sleeping, proxy hiccup) is not a failed update: the
// browser reconnects on its own and the update keeps running server-side.
// Only report failure if the stream is closed for good, or stays down.
if (es.readyState === EventSource.CLOSED) {
setError('Connection lost');
return;
}
if (!giveUp) {
giveUp = setTimeout(() => {
es.close();
setError('Connection lost — the update may still be running. Refresh to check.');
}, RECONNECT_GRACE_MS);
}
};

return () => {
clearTimeout(giveUp);
es.close();
esRef.current = null;
};
Expand Down
6 changes: 6 additions & 0 deletions client/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -1554,6 +1554,12 @@ a {
color: var(--color-pending);
}

.card-hint {
margin: 8px 0 0;
font-size: 0.8rem;
color: var(--color-text-muted);
}

.about-version {
color: var(--color-text-muted);
font-weight: 400;
Expand Down
Loading
Loading