@@ -313,6 +330,32 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
)}
+ {!pinned && (newerTag || newerMajorTag) && (
+
+ {[newerTag, newerMajorTag].filter(Boolean).map((tag) => (
+
+
+ {tag === newerMajorTag ? 'New major version' : 'Newer version'}{' '}
+ {tag}
+
+
+ dismissTag(tag)} disabled={busy || pinBusy}>
+ Skip
+
+ setConfirmTag(tag)}
+ disabled={busy}
+ >
+ Switch
+
+
+
+ ))}
+
+ )}
+
{checkError && (
⚠ Couldn't check for updates (e.g. private registry or rate limit).
@@ -385,6 +428,28 @@ export default function UpdateCard({ container, onSettled, onPinChange, register
/>
)}
+ {confirmTag && (
+ {
+ const tag = confirmTag;
+ setConfirmTag(null);
+ if (!busy) switchTo(tag);
+ }}
+ onCancel={() => setConfirmTag(null)}
+ />
+ )}
+
{clOpen && (
{breakingRisk && clData?.type === 'github' && (
diff --git a/client/src/hooks/useUpdateRunner.js b/client/src/hooks/useUpdateRunner.js
index 593ffd2..1087362 100644
--- a/client/src/hooks/useUpdateRunner.js
+++ b/client/src/hooks/useUpdateRunner.js
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useRef, useState } from 'react';
-import { startUpdate, revertUpdate } from '../api.js';
+import { startUpdate, revertUpdate, switchTag } from '../api.js';
import { useSSE } from './useSSE.js';
/**
@@ -82,6 +82,10 @@ export function useUpdateRunner(name, onSettled) {
const run = useCallback(() => start(startUpdate, 'Update started…'), [start]);
const revert = useCallback(() => start(revertUpdate, 'Reverting…'), [start]);
+ const switchTo = useCallback(
+ (tag) => start((n) => switchTag(n, tag), `Switching to ${tag}…`),
+ [start]
+ );
useEffect(() => {
if (!result) return;
@@ -105,5 +109,5 @@ export function useUpdateRunner(name, onSettled) {
const busy = starting || streamActive;
- return { run, revert, busy, starting, startError, status, lines };
+ return { run, revert, switchTo, busy, starting, startError, status, lines };
}
diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx
index 73bd237..0f37b2f 100644
--- a/client/src/pages/SettingsPage.jsx
+++ b/client/src/pages/SettingsPage.jsx
@@ -9,6 +9,9 @@ import {
getStatus,
getDanglingImages,
pruneImages,
+ logoutAll,
+ restoreBackup,
+ API_BASE,
} from '../api.js';
import ConfirmDialog from '../components/ConfirmDialog.jsx';
import { useTheme } from '../hooks/useTheme.js';
@@ -26,6 +29,19 @@ function formatBytes(n) {
return `${value.toFixed(1)} ${units[i]}`;
}
+// "in 3h", "in 25m", "at 09:00 tomorrow"-ish relative time for a future epoch ms.
+function formatWhen(ts) {
+ const ms = ts - Date.now();
+ if (!Number.isFinite(ms)) return '';
+ if (ms < 60_000) return 'in under a minute';
+ const m = Math.round(ms / 60_000);
+ if (m < 60) return `in ${m}m`;
+ const h = Math.floor(m / 60);
+ const rest = m % 60;
+ if (h < 24) return `in ${h}h${rest ? ` ${rest}m` : ''}`;
+ return `on ${new Date(ts).toLocaleString([], { weekday: 'short', hour: '2-digit', minute: '2-digit' })}`;
+}
+
// Rough relative age for an image's creation time (Docker's `created` is Unix
// seconds). Helps explain why some dangling layers show "Untracked source" —
// e.g. an old layer from a container's earlier update, before its rollback
@@ -87,10 +103,12 @@ export default function SettingsPage({ onPruneComplete } = {}) {
const [pruning, setPruning] = useState(false);
const [pruneStatus, setPruneStatus] = useState('');
const [pruneSummaryLoading, setPruneSummaryLoading] = useState(false);
- // The dangling images still selected for pruning. Starts as the full fetched
- // list; the user can drop rows (which then just reappear next time, since
- // they're never removed). Drives both the table and what gets pruned.
- const [pruneSelection, setPruneSelection] = useState([]);
+ // Every prunable image from the preview, and the IDs the user has left out.
+ // Revert points (images named after a container) start excluded: pruning
+ // one removes that container's undo button, so it's opt-in.
+ const [pruneCandidates, setPruneCandidates] = useState([]);
+ const [pruneExcluded, setPruneExcluded] = useState(() => new Set());
+ const pruneSelection = pruneCandidates.filter((img) => !pruneExcluded.has(img.id));
const [health, setHealth] = useState(null); // null = unknown, true/false once checked
const [status, setStatus] = useState(null); // { version, serverLocalTime, timeZone }
@@ -130,12 +148,73 @@ export default function SettingsPage({ onPruneComplete } = {}) {
.catch(() => setHealth(false));
}, []);
- useEffect(() => {
+ const refreshStatus = useCallback(() => {
getStatus()
.then((s) => setStatus(s || null))
.catch(() => {});
}, []);
+ useEffect(() => {
+ refreshStatus();
+ }, [refreshStatus]);
+
+ // --- Account & data ---
+ const [accountBusy, setAccountBusy] = useState(false);
+ const [accountStatus, setAccountStatus] = useState('');
+ const [confirmLogoutAll, setConfirmLogoutAll] = useState(false);
+ const [pendingRestore, setPendingRestore] = useState(null);
+
+ const handleLogoutAll = useCallback(async () => {
+ setConfirmLogoutAll(false);
+ setAccountBusy(true);
+ setAccountStatus('');
+ try {
+ await logoutAll();
+ setAccountStatus('Signed out everywhere else. This device stays signed in.');
+ } catch (err) {
+ setAccountStatus(err.message || 'Failed to sign out other sessions');
+ } finally {
+ setAccountBusy(false);
+ }
+ }, []);
+
+ const handleRestoreFile = useCallback(async (e) => {
+ const file = e.target.files?.[0];
+ e.target.value = ''; // allow picking the same file again
+ if (!file) return;
+ setAccountStatus('');
+ try {
+ const data = JSON.parse(await file.text());
+ if (data?.format !== 'dockpull-backup') throw new Error("That file isn't a DockPull backup.");
+ setPendingRestore(data);
+ } catch (err) {
+ setAccountStatus(err instanceof SyntaxError ? "That file isn't valid JSON." : err.message);
+ }
+ }, []);
+
+ const handleRestoreConfirm = useCallback(async () => {
+ const backup = pendingRestore;
+ setPendingRestore(null);
+ setAccountBusy(true);
+ try {
+ const r = await restoreBackup(backup);
+ setAccountStatus(
+ `Restored ${r.settings} settings, ${r.pinned} pinned, ${r.history} history entries` +
+ (r.historySkippedBecauseNotEmpty ? ' (history kept as is — it already had entries)' : '') +
+ (r.skipped ? `; skipped ${r.skipped} invalid entr${r.skipped === 1 ? 'y' : 'ies'}` : '') +
+ '.'
+ );
+ const fresh = await getSettings();
+ setSettings(fresh);
+ setWebhookDraft(fresh?.discordWebhookUrl || '');
+ refreshStatus();
+ } catch (err) {
+ setAccountStatus(err.message || 'Restore failed');
+ } finally {
+ setAccountBusy(false);
+ }
+ }, [pendingRestore, refreshStatus]);
+
const saveSetting = useCallback(async (patch) => {
setSettings((prev) => ({ ...prev, ...patch })); // optimistic
setSettingsError('');
@@ -174,7 +253,9 @@ export default function SettingsPage({ onPruneComplete } = {}) {
setPruneStatus('Nothing to prune — no dangling layers found.');
return;
}
- setPruneSelection(summary.images || []);
+ const images = summary.images || [];
+ setPruneCandidates(images);
+ setPruneExcluded(new Set(images.filter((img) => img.fromContainer).map((img) => img.id)));
setConfirmPrune(true);
} catch (err) {
setPruneStatus(err.message || 'Failed to check for dangling layers');
@@ -185,8 +266,13 @@ export default function SettingsPage({ onPruneComplete } = {}) {
// Drop a layer from this prune. It isn't removed, so it reappears the next
// time the dialog is opened (which re-fetches the current dangling set).
- const excludePruneImage = useCallback((id) => {
- setPruneSelection((sel) => sel.filter((img) => img.id !== id));
+ const togglePruneImage = useCallback((id) => {
+ setPruneExcluded((prev) => {
+ const next = new Set(prev);
+ if (next.has(id)) next.delete(id);
+ else next.add(id);
+ return next;
+ });
}, []);
const handlePrune = useCallback(async () => {
@@ -212,7 +298,7 @@ export default function SettingsPage({ onPruneComplete } = {}) {
setPruneStatus(err.message || 'Prune failed');
} finally {
setPruning(false);
- setPruneSelection([]);
+ setPruneCandidates([]);
}
}, [onPruneComplete, pruneSelection]);
@@ -310,15 +396,39 @@ export default function SettingsPage({ onPruneComplete } = {}) {
{settings?.autoCheckOnOpen ? 'On' : 'Off'}
+
+
+ Newer version tags
+
+ For containers on a version tag (e.g. postgres:16.3), offer newer
+ versions like 16.4 — and optionally the next major version.
+
+
+
saveSetting({ tagUpdates: e.target.value }).catch(() => {})}
+ disabled={!settings}
+ aria-label="Newer version tags"
+ >
+ Off
+ Same major
+ Include major
+
+
Background checks & notifications
- Daily scan
+ Background scan
- Run a scan once a day even when the app is closed.
+ Check for updates on a schedule, even when the app is closed. A scan missed while
+ the server was off runs shortly after it starts.
+ {settings?.backgroundCheckEnabled && status?.nextScanAt ? (
+ <> Next scan {formatWhen(status.nextScanAt)}.>
+ ) : null}
- saveSetting({ backgroundCheckEnabled: !settings?.backgroundCheckEnabled }).catch(() => {})
+ saveSetting({ backgroundCheckEnabled: !settings?.backgroundCheckEnabled })
+ .then(refreshStatus)
+ .catch(() => {})
}
disabled={!settings}
>
@@ -340,28 +452,68 @@ export default function SettingsPage({ onPruneComplete } = {}) {
- Daily scan time
-
- When the daily scan runs, on the server's clock
- {status?.timeZone ? (
- <>
- {' '}
- — currently {status.serverLocalTime} {status.timeZone}. If that's off, set the
- container's TZ (e.g. TZ=Europe/London).
- >
- ) : (
- '.'
- )}
-
+ Schedule
+ Once a day at a set time, or every few hours.
-
saveSetting({ scheduledCheckTime: e.target.value }).catch(() => {})}
+
saveSetting({ scheduleMode: e.target.value }).then(refreshStatus).catch(() => {})}
disabled={!settings || !settings?.backgroundCheckEnabled}
- />
+ aria-label="Scan schedule"
+ >
+ Daily
+ Every N hours
+
+ {settings?.scheduleMode === 'interval' ? (
+
+
+ Scan every
+ Hours between scans (1–168).
+
+
+ saveSetting({ scheduleIntervalHours: Number(e.target.value) }).then(refreshStatus).catch(() => {})
+ }
+ disabled={!settings || !settings?.backgroundCheckEnabled}
+ aria-label="Hours between scans"
+ >
+ {[1, 2, 3, 4, 6, 8, 12, 24, 48, 168].map((h) => (
+
+ {h === 168 ? '1 week' : h === 48 ? '2 days' : `${h} hour${h === 1 ? '' : 's'}`}
+
+ ))}
+
+
+ ) : (
+
+
+ Daily scan time
+
+ When the daily scan runs, on the server's clock
+ {status?.timeZone ? (
+ <>
+ {' '}
+ — currently {status.serverLocalTime} {status.timeZone}. If that's off, set the
+ container's TZ (e.g. TZ=Europe/London).
+ >
+ ) : (
+ '.'
+ )}
+
+
+
saveSetting({ scheduledCheckTime: e.target.value }).then(refreshStatus).catch(() => {})}
+ disabled={!settings || !settings?.backgroundCheckEnabled}
+ />
+
+ )}
Notify via
@@ -403,7 +555,7 @@ export default function SettingsPage({ onPruneComplete } = {}) {
Send notifications
- Notify on the daily scan when updates are found.
+ Notify after a background scan when updates are found.
{settings?.discordEnabled ? 'On' : 'Off'}
+
+
+ Notify on failures
+
+ Also send a message when an update or revert fails, or comes up unhealthy.
+
+
+
saveSetting({ notifyOnFailure: !settings?.notifyOnFailure }).catch(() => {})}
+ disabled={!settings || !settings?.discordEnabled}
+ >
+
+
+
+ {settings?.notifyOnFailure ? 'On' : 'Off'}
+
+
{
setConfirmPrune(false);
- setPruneSelection([]);
+ setPruneCandidates([]);
}}
>
- Leftover layers from image updates. Remove any row with ✕ to keep that layer —
- it'll reappear here next time. Tagged images and anything in use are never touched.
- Sizes are what removing each one should free (layers shared with images you still
- use aren't counted).
+ Leftover layers from image updates. Untick a row to keep it — it'll reappear here
+ next time. Tagged images and anything in use are never touched. Sizes are what
+ removing each one should free (layers shared with images you still use aren't
+ counted).
- {pruneSelection.some((img) => img.fromContainer) && (
+ {pruneCandidates.some((img) => img.fromContainer) && (
- ⚠ Rows named after a container are its previous version — pruning one removes the
- option to revert that container's last update.
+ Rows named after a container are its previous version, kept so you can revert its
+ last update. They're left out unless you tick them — pruning one removes that
+ container's Revert option.
)}
- {pruneSelection.length === 0 ? (
- All layers excluded — nothing will be pruned.
- ) : (
+ {pruneSelection.length === 0 && (
+ Nothing selected — nothing will be pruned.
+ )}
+ {pruneCandidates.length > 0 && (
@@ -549,12 +725,12 @@ export default function SettingsPage({ onPruneComplete } = {}) {
Layer
Size
Created
-
+
- {pruneSelection.map((img) => (
-
+ {pruneCandidates.map((img) => (
+
{img.fromContainer || 'Untracked source'}
@@ -573,23 +749,15 @@ export default function SettingsPage({ onPruneComplete } = {}) {
{formatAge(img.created)}
- excludePruneImage(img.id)}
+ togglePruneImage(img.id)}
disabled={pruning}
- aria-label={`Exclude ${img.fromContainer || img.id} from prune`}
- title="Exclude from prune"
- >
-
-
-
-
+ aria-label={`Prune ${img.fromContainer || img.id}`}
+ title={pruneExcluded.has(img.id) ? 'Include in prune' : 'Keep this layer'}
+ />
))}
@@ -601,6 +769,73 @@ export default function SettingsPage({ onPruneComplete } = {}) {
)}
+
+ Account & data
+
+
+ Sign out everywhere
+
+ Signs out every other browser and device. This one stays signed in.
+
+
+
setConfirmLogoutAll(true)} disabled={accountBusy}>
+ Sign out others
+
+
+
+
+ Backup
+
+ Settings, pinned versions and update history as a file — for moving DockPull to a
+ new host. It includes your notification URL, so keep it private.
+
+
+
+ Download
+
+
+
+
+ Restore
+
+ Load a backup file. Replaces these settings; history is only restored into an empty
+ history.
+
+
+
+ Choose file…
+
+
+
+ {accountStatus && {accountStatus}
}
+ {confirmLogoutAll && (
+ setConfirmLogoutAll(false)}
+ />
+ )}
+ {pendingRestore && (
+ setPendingRestore(null)}
+ />
+ )}
+
+
About
diff --git a/client/src/styles/app.css b/client/src/styles/app.css
index 2852a4b..f28084c 100644
--- a/client/src/styles/app.css
+++ b/client/src/styles/app.css
@@ -1572,3 +1572,113 @@ a {
.breaking-flag { margin-left: 6px; cursor: help; }
.breaking-note { margin: 0 0 10px; font-size: 0.82rem; color: var(--color-pending); }
+
+.prune-table tr.is-excluded td {
+ opacity: 0.5;
+}
+
+.prune-row-check {
+ width: 18px;
+ height: 18px;
+ cursor: pointer;
+ accent-color: var(--color-error);
+}
+
+.newer-tags {
+ margin: 10px 0 0;
+ display: flex;
+ flex-direction: column;
+ gap: 6px;
+}
+
+.newer-tag-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+ padding: 8px 10px;
+ border: 1px solid var(--color-border);
+ border-radius: 10px;
+ font-size: 0.85rem;
+}
+
+.newer-tag-label {
+ color: var(--color-text-muted);
+ min-width: 0;
+ overflow-wrap: anywhere;
+}
+
+.newer-tag-label strong {
+ color: var(--color-text);
+}
+
+.newer-tag-actions {
+ display: flex;
+ gap: 6px;
+ align-items: center;
+}
+
+/* ---------- DockPull self-update banner ---------- */
+.self-update {
+ margin: 0 0 16px;
+ padding: 10px 12px;
+ border: 1px solid var(--color-accent);
+ border-radius: 12px;
+ background: var(--color-card);
+ font-size: 0.9rem;
+}
+
+.self-update-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+ flex-wrap: wrap;
+}
+
+.self-update-text {
+ min-width: 0;
+}
+
+.self-update-actions {
+ display: flex;
+ align-items: center;
+ gap: 4px;
+ margin-left: auto;
+}
+
+.self-update-panel {
+ margin-top: 10px;
+ padding-top: 10px;
+ border-top: 1px solid var(--color-border);
+ max-height: 50vh;
+ overflow-y: auto;
+}
+
+.self-update-panel p {
+ margin: 0 0 8px;
+}
+
+.self-update-cmd {
+ display: flex;
+ gap: 8px;
+ align-items: center;
+ flex-wrap: wrap;
+ margin-bottom: 8px;
+}
+
+.self-update-cmd code {
+ flex: 1 1 220px;
+ min-width: 0;
+ padding: 8px 10px;
+ border-radius: 8px;
+ background: var(--color-bg);
+ overflow-wrap: anywhere;
+ font-size: 0.8rem;
+}
+
+.self-update-note {
+ color: var(--color-text-muted);
+ font-size: 0.8rem;
+}
diff --git a/server/src/auth.js b/server/src/auth.js
index ee8dfdf..7b6eb5a 100644
--- a/server/src/auth.js
+++ b/server/src/auth.js
@@ -94,11 +94,44 @@ function isValidPassword(provided) {
function passwordFingerprint() {
return crypto
.createHmac('sha256', config.SESSION_SECRET || '')
- .update(`dockpull-session:${config.ADMIN_PASSWORD || ''}`)
+ .update(`dockpull-session:${config.ADMIN_PASSWORD || ''}:${sessionGeneration.get()}`)
.digest('hex')
.slice(0, 16);
}
+// "Sign out everywhere" bumps this number; it's part of every cookie's
+// fingerprint, so all cookies issued before the bump stop matching. Persisted
+// via an injected store (index.js wires it to the DB) so this module stays
+// importable without a database (tests).
+let sessionGeneration = { get: () => 0, set: () => {} };
+
+export function setSessionGenerationStore(store) {
+ sessionGeneration = store;
+}
+
+function setSessionCookie(res) {
+ const expiry = Date.now() + config.SESSION_TTL * 1000;
+ res.cookie(SESSION_COOKIE, sessionCookieValue(expiry), {
+ signed: true,
+ httpOnly: true,
+ sameSite: 'lax',
+ secure: config.BASE_URL.startsWith('https'),
+ maxAge: config.SESSION_TTL * 1000,
+ path: config.BASE_PATH || '/',
+ });
+}
+
+/**
+ * POST /api/auth/logout-all — invalidate every session, then give the caller a
+ * fresh cookie so only THIS device stays signed in. Requires a valid session.
+ */
+export function logoutAllHandler(req, res) {
+ if (!isValidSession(req)) return res.status(401).json({ error: 'unauthorized' });
+ sessionGeneration.set(sessionGeneration.get() + 1);
+ setSessionCookie(res);
+ return res.status(200).json({ ok: true });
+}
+
/** Session cookie value: `.`. */
export function sessionCookieValue(expiry) {
return `${expiry}.${passwordFingerprint()}`;
@@ -145,15 +178,7 @@ export function loginHandler(req, res) {
}
clearLoginFailures(ip);
- const expiry = Date.now() + config.SESSION_TTL * 1000;
- res.cookie(SESSION_COOKIE, sessionCookieValue(expiry), {
- signed: true,
- httpOnly: true,
- sameSite: 'lax',
- secure: config.BASE_URL.startsWith('https'),
- maxAge: config.SESSION_TTL * 1000,
- path: config.BASE_PATH || '/',
- });
+ setSessionCookie(res);
return res.status(200).json({ ok: true });
}
@@ -193,5 +218,6 @@ export function requireAuth(req, res, next) {
authRouter.post('/api/auth/login', loginHandler);
authRouter.post('/api/auth/logout', logoutHandler);
authRouter.get('/api/auth/me', meHandler);
+authRouter.post('/api/auth/logout-all', logoutAllHandler);
export default authRouter;
diff --git a/server/src/backup.js b/server/src/backup.js
new file mode 100644
index 0000000..2dfc67e
--- /dev/null
+++ b/server/src/backup.js
@@ -0,0 +1,123 @@
+/**
+ * Settings backup / restore, for moving DockPull to a new host or recovering
+ * a lost data volume. The backup is plain JSON: settings, pinned images, and
+ * update history. Short-lived state (pending updates, skips, revert points)
+ * is left out — it's rebuilt by the next check or meaningless on another host.
+ *
+ * Restore validates everything through the same paths as normal API writes
+ * (updateSettings, normalizeRef) and is safe to run twice: history is only
+ * imported into an empty history.
+ */
+
+import * as db from './db.js';
+import { getSettings, updateSettings } from './settings.js';
+import { normalizeRef } from './reconcile.js';
+
+export const BACKUP_FORMAT = 'dockpull-backup';
+export const BACKUP_VERSION = 1;
+const MAX_HISTORY = 5000;
+
+export function buildBackup({ appVersion = null } = {}) {
+ return {
+ format: BACKUP_FORMAT,
+ version: BACKUP_VERSION,
+ appVersion,
+ exportedAt: new Date().toISOString(),
+ settings: getSettings(),
+ pinned: db.getPinned(),
+ history: db.getHistory({ limit: MAX_HISTORY, offset: 0 }).map((r) => ({
+ container_name: r.container_name,
+ image: r.image,
+ old_digest: r.old_digest,
+ new_digest: r.new_digest,
+ old_version: r.old_version ?? null,
+ new_version: r.new_version ?? null,
+ status: r.status,
+ message: r.message,
+ created_at: r.created_at,
+ })),
+ };
+}
+
+class RestoreError extends Error {
+ constructor(message) {
+ super(message);
+ this.code = 'invalid_backup';
+ }
+}
+
+const str = (v, max = 2000) => (typeof v === 'string' ? v.slice(0, max) : null);
+
+function cleanHistoryRow(r) {
+ if (!r || typeof r !== 'object') return null;
+ const name = str(r.container_name, 255);
+ const image = str(r.image, 512);
+ const status = r.status === 'success' || r.status === 'failure' ? r.status : null;
+ const created = str(r.created_at, 40);
+ if (!name || !image || !status || !created || !/^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}/.test(created)) return null;
+ return {
+ container_name: name,
+ image,
+ old_digest: str(r.old_digest, 100),
+ new_digest: str(r.new_digest, 100),
+ old_version: str(r.old_version, 100),
+ new_version: str(r.new_version, 100),
+ status,
+ message: str(r.message, 4000),
+ created_at: created,
+ };
+}
+
+/**
+ * Apply a backup. Settings are validated as a whole first (all-or-nothing);
+ * pins and history rows that don't validate are skipped and counted.
+ *
+ * @returns {{ settings: number, pinned: number, history: number, skipped: number, historySkippedBecauseNotEmpty: boolean }}
+ * @throws {RestoreError} if it isn't a DockPull backup or settings are invalid.
+ */
+export function restoreBackup(backup) {
+ if (!backup || typeof backup !== 'object' || backup.format !== BACKUP_FORMAT) {
+ throw new RestoreError("That file isn't a DockPull backup.");
+ }
+ if (backup.version !== BACKUP_VERSION) {
+ throw new RestoreError(`Unsupported backup version ${backup.version}.`);
+ }
+
+ let skipped = 0;
+ let settingsCount = 0;
+ if (backup.settings && typeof backup.settings === 'object') {
+ try {
+ updateSettings(backup.settings); // validates every known key; ignores unknown
+ settingsCount = Object.keys(backup.settings).length;
+ } catch (err) {
+ throw new RestoreError(`Backup has an invalid setting: ${err.message}`);
+ }
+ }
+
+ let pinned = 0;
+ for (const ref of Array.isArray(backup.pinned) ? backup.pinned : []) {
+ try {
+ db.pin(normalizeRef(String(ref)));
+ pinned += 1;
+ } catch {
+ skipped += 1;
+ }
+ }
+
+ let history = 0;
+ const historySkippedBecauseNotEmpty = db.countHistory() > 0;
+ if (!historySkippedBecauseNotEmpty && Array.isArray(backup.history)) {
+ const rows = [];
+ for (const r of backup.history.slice(0, MAX_HISTORY)) {
+ const clean = cleanHistoryRow(r);
+ if (clean) rows.push(clean);
+ else skipped += 1;
+ }
+ db.importHistory(rows.reverse()); // exported newest-first; insert oldest-first
+ history = rows.length;
+ }
+
+ return { settings: settingsCount, pinned, history, skipped, historySkippedBecauseNotEmpty };
+}
+
+export default { buildBackup, restoreBackup, BACKUP_FORMAT, BACKUP_VERSION };
diff --git a/server/src/checker.js b/server/src/checker.js
index aba48d7..c897fbd 100644
--- a/server/src/checker.js
+++ b/server/src/checker.js
@@ -8,9 +8,11 @@
*/
import { listContainers } from './docker.js';
-import { getRemoteDigest, getRemoteVersion } from './registry.js';
+import { getRemoteDigest, getRemoteVersion, listTags } from './registry.js';
import { digestsEqual, isRunningDigest } from './reconcile.js';
-import { isMeaningfulVersion } from './version.js';
+import { isMeaningfulVersion, parseVersionTag, findNewerTags } from './version.js';
+import { parseRef } from './reconcile.js';
+import { getSettings } from './settings.js';
import {
parseGitHubRepo,
getLatestReleaseTag,
@@ -127,12 +129,50 @@ async function doCheck() {
let errors = 0;
const errored = []; // { ref, image, message } per failed container check
+ // Newer-tag detection: one tag listing per repository per check, shared by
+ // every tag of it that's running. Best-effort — a failure here never counts
+ // as a check error (the digest check above is what matters).
+ const tagPolicy = getSettings().tagUpdates;
+ const tagListCache = new Map();
+ async function checkNewerTags(c) {
+ if (tagPolicy === 'off') return;
+ let parsed;
+ try {
+ parsed = parseRef(c.image);
+ } catch {
+ return;
+ }
+ if (!parsed.tag || !parseVersionTag(parsed.tag)) {
+ db.setTagUpdate({ normalized_ref: c.normalizedRef, current_tag: parsed.tag || '' }); // clears any stale row
+ return;
+ }
+ const repoKey = `${parsed.registry}/${parsed.repository}`;
+ if (!tagListCache.has(repoKey)) tagListCache.set(repoKey, listTags(c.image).catch((err) => err));
+ const tags = await tagListCache.get(repoKey);
+ if (tags instanceof Error) {
+ console.warn(`checker: couldn't list tags for ${repoKey}: ${tags.message}`);
+ return;
+ }
+ const { sameMajor, nextMajor } = findNewerTags(parsed.tag, tags);
+ db.setTagUpdate({
+ normalized_ref: c.normalizedRef,
+ current_tag: parsed.tag,
+ same_major: sameMajor,
+ next_major: nextMajor,
+ });
+ }
+
let idx = 0;
async function worker() {
while (idx < items.length) {
const group = items[idx];
idx += 1;
let c = group[0];
+ try {
+ await checkNewerTags(c);
+ } catch (err) {
+ console.warn(`checker: tag check failed for ${c.image}: ${err.message}`);
+ }
try {
const remote = await getRemoteDigest(c.image);
checked += 1;
diff --git a/server/src/compose-file.js b/server/src/compose-file.js
new file mode 100644
index 0000000..19bf60c
--- /dev/null
+++ b/server/src/compose-file.js
@@ -0,0 +1,149 @@
+/**
+ * Minimal, conservative compose-file editing: change ONE service's `image:`
+ * tag in place, leaving every other byte (comments, quoting, ordering,
+ * formatting) untouched. Used by "Switch to ".
+ *
+ * Deliberately not a YAML round-trip (that would reformat the user's file).
+ * Instead it finds the service block by indentation and refuses anything it
+ * can't change with certainty — the image coming from a variable, an anchor
+ * or `extends`, a digest pin, or a value that isn't the image the container
+ * actually runs. Pure (string in, string out) so it's fully unit-testable.
+ */
+
+import { normalizeRef, parseRef } from './reconcile.js';
+
+export class ComposeEditError extends Error {
+ constructor(code, message) {
+ super(message);
+ this.code = code;
+ }
+}
+
+const indentOf = (line) => line.length - line.trimStart().length;
+const isBlankOrComment = (line) => /^\s*(#.*)?$/.test(line);
+const unquote = (v) => v.replace(/^(['"])(.*)\1$/, '$2');
+
+/**
+ * Locate a service's `image:` line.
+ *
+ * @returns {{ index: number, value: string, quote: string, before: string, after: string }|null}
+ */
+export function findServiceImage(text, service) {
+ const lines = text.split('\n');
+ const servicesIdx = lines.findIndex((l) => /^services:\s*(#.*)?$/.test(l));
+ if (servicesIdx === -1) return null;
+
+ const escaped = service.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+ const keyRe = new RegExp(`^(\\s+)(?:${escaped}|"${escaped}"|'${escaped}'):\\s*(#.*)?$`);
+ let svcIdx = -1;
+ let svcIndent = -1;
+ let serviceKeyIndent = -1; // indentation of the service names under services:
+ for (let i = servicesIdx + 1; i < lines.length; i += 1) {
+ const line = lines[i];
+ if (isBlankOrComment(line)) continue;
+ const ind = indentOf(line);
+ if (ind === 0) break; // left the services: block
+ if (serviceKeyIndent === -1) serviceKeyIndent = ind;
+ if (ind !== serviceKeyIndent) continue; // a setting inside some service
+ const m = keyRe.exec(line);
+ if (m) {
+ svcIdx = i;
+ svcIndent = ind;
+ break;
+ }
+ }
+ if (svcIdx === -1) return null;
+
+ let childIndent = -1;
+ for (let i = svcIdx + 1; i < lines.length; i += 1) {
+ const line = lines[i];
+ if (isBlankOrComment(line)) continue;
+ const ind = indentOf(line);
+ if (ind <= svcIndent) break; // end of this service
+ if (childIndent === -1) childIndent = ind;
+ if (ind !== childIndent) continue; // nested deeper (environment entries etc.)
+ const m = /^(\s*image:\s*)(.*?)(\s*(?:#.*)?)$/.exec(line);
+ if (m) {
+ const raw = m[2];
+ const quote = /^(['"]).*\1$/.test(raw) ? raw[0] : '';
+ return { index: i, value: unquote(raw), quote, before: m[1], after: m[3] };
+ }
+ }
+ return null;
+}
+
+/**
+ * Pure: rewrite `service`'s image to use `newTag`, keeping the user's own
+ * spelling of the repository (e.g. `postgres`, not `docker.io/library/postgres`).
+ *
+ * @param {string} text - compose file contents.
+ * @param {string} service
+ * @param {string} expectedImage - the image ref the container runs now; the
+ * file must name the same image (normalized), or we refuse.
+ * @param {string} newTag
+ * @returns {{ text: string, oldValue: string, newValue: string }}
+ * @throws {ComposeEditError}
+ */
+export function rewriteServiceImageTag(text, service, expectedImage, newTag) {
+ const found = findServiceImage(text, service);
+ if (!found) {
+ throw new ComposeEditError(
+ 'image_not_found',
+ `Couldn't find an "image:" line for service "${service}" in the compose file (it may come from an anchor or "extends").`
+ );
+ }
+ const { value } = found;
+ if (value.includes('$')) {
+ throw new ComposeEditError(
+ 'image_uses_variable',
+ `Service "${service}" sets its image from a variable (${value}); change the variable instead.`
+ );
+ }
+ let parsed;
+ try {
+ parsed = parseRef(value);
+ } catch {
+ throw new ComposeEditError('image_unparseable', `Couldn't understand the image "${value}".`);
+ }
+ if (parsed.digest) {
+ throw new ComposeEditError('image_pinned_by_digest', `Service "${service}" pins its image by digest (${value}).`);
+ }
+ let same = false;
+ try {
+ same = normalizeRef(value) === normalizeRef(expectedImage);
+ } catch {
+ same = false;
+ }
+ if (!same) {
+ throw new ComposeEditError(
+ 'image_mismatch',
+ `The compose file says "${value}" but the container runs "${expectedImage}" — not changing it.`
+ );
+ }
+
+ // Keep the user's spelling of the repo; swap only the tag.
+ const lastSlash = value.lastIndexOf('/');
+ const colon = value.lastIndexOf(':');
+ const repoPart = colon > lastSlash ? value.slice(0, colon) : value;
+ const newValue = `${repoPart}:${newTag}`;
+
+ const lines = text.split('\n');
+ lines[found.index] = `${found.before}${found.quote}${newValue}${found.quote}${found.after}`;
+ return { text: lines.join('\n'), oldValue: value, newValue };
+}
+
+/**
+ * Pure: put a service's image back to `oldValue` (undo a tag switch), but only
+ * if the file still names `newValue` there — never clobber a later hand edit.
+ *
+ * @returns {string|null} the restored text, or null if the file changed since.
+ */
+export function restoreServiceImage(text, service, newValue, oldValue) {
+ const found = findServiceImage(text, service);
+ if (!found || found.value !== newValue) return null;
+ const lines = text.split('\n');
+ lines[found.index] = `${found.before}${found.quote}${oldValue}${found.quote}${found.after}`;
+ return lines.join('\n');
+}
+
+export default { findServiceImage, rewriteServiceImageTag, restoreServiceImage, ComposeEditError };
diff --git a/server/src/containers-service.js b/server/src/containers-service.js
index 3268f24..347afb0 100644
--- a/server/src/containers-service.js
+++ b/server/src/containers-service.js
@@ -38,6 +38,8 @@ export function buildContainerItems({
lookupVersion = () => null,
getRollback = () => null,
getCheckError = () => null,
+ getTagUpdate = () => null,
+ tagPolicy = 'minor',
}) {
const items = [];
const refsToResolve = [];
@@ -82,6 +84,13 @@ export function buildContainerItems({
const rollback = getRollback(c.name);
+ // Newer version tags (postgres:16.3 -> 16.4 / 17.1), minus a skipped one
+ // and filtered by the "tag updates" setting.
+ const tagRow = tagPolicy === 'off' ? null : getTagUpdate(c.normalizedRef);
+ const visibleTag = (t) => (t && t !== tagRow?.dismissed_tag ? t : null);
+ const newerTag = visibleTag(tagRow?.same_major);
+ const newerMajorTag = tagPolicy === 'major' ? visibleTag(tagRow?.next_major) : null;
+
items.push({
name: c.name,
project: c.project,
@@ -98,6 +107,8 @@ export function buildContainerItems({
// must not leak through once the digests match again.
breakingRisk: !!(updateAvailable && event?.breaking),
skipped,
+ newerTag,
+ newerMajorTag,
pinned: isPinned(c.normalizedRef),
canRevert: !!rollback,
rollbackVersion: rollback?.old_version ?? null,
diff --git a/server/src/db.js b/server/src/db.js
index 155623f..ed17c6a 100644
--- a/server/src/db.js
+++ b/server/src/db.js
@@ -57,6 +57,15 @@ CREATE TABLE IF NOT EXISTS rollback_points (
old_version TEXT,
created_at TEXT DEFAULT (datetime('now'))
);
+CREATE TABLE IF NOT EXISTS tag_updates (
+ normalized_ref TEXT PRIMARY KEY,
+ current_tag TEXT NOT NULL,
+ same_major TEXT,
+ next_major TEXT,
+ dismissed_tag TEXT,
+ notified_key TEXT,
+ checked_at TEXT DEFAULT (datetime('now'))
+);
CREATE INDEX IF NOT EXISTS idx_events_ref ON update_events(normalized_ref, resolved);
CREATE INDEX IF NOT EXISTS idx_history_created ON update_history(created_at DESC);
`);
@@ -83,6 +92,13 @@ CREATE INDEX IF NOT EXISTS idx_history_created ON update_history(created_at DESC
}
}
+// rollback_points: a tag switch also changed the compose file; remember how,
+// so reverting can put the old tag back.
+{
+ const cols = db.prepare('PRAGMA table_info(rollback_points)').all().map((col) => col.name);
+ if (!cols.includes('compose_edit')) db.exec('ALTER TABLE rollback_points ADD COLUMN compose_edit TEXT');
+}
+
// update_history: remember versions on the row itself, for when an image's
// digest is unknown (so the digest→version lookup can't recover them later).
{
@@ -92,6 +108,25 @@ CREATE INDEX IF NOT EXISTS idx_history_created ON update_history(created_at DESC
}
const stmts = {
+ importHistoryRow: db.prepare(`
+ INSERT INTO update_history (container_name, image, old_digest, new_digest, old_version, new_version, status, message, created_at)
+ VALUES (@container_name, @image, @old_digest, @new_digest, @old_version, @new_version, @status, @message, @created_at)
+ `),
+ countHistory: db.prepare(`SELECT COUNT(*) AS n FROM update_history`),
+ setTagUpdate: db.prepare(`
+ INSERT INTO tag_updates (normalized_ref, current_tag, same_major, next_major, checked_at)
+ VALUES (@normalized_ref, @current_tag, @same_major, @next_major, datetime('now'))
+ ON CONFLICT(normalized_ref) DO UPDATE SET
+ current_tag = excluded.current_tag,
+ same_major = excluded.same_major,
+ next_major = excluded.next_major,
+ checked_at = excluded.checked_at
+ `),
+ getTagUpdate: db.prepare(`SELECT * FROM tag_updates WHERE normalized_ref = ? LIMIT 1`),
+ getAllTagUpdates: db.prepare(`SELECT * FROM tag_updates`),
+ deleteTagUpdate: db.prepare(`DELETE FROM tag_updates WHERE normalized_ref = ?`),
+ dismissTag: db.prepare(`UPDATE tag_updates SET dismissed_tag = ? WHERE normalized_ref = ?`),
+ markTagNotified: db.prepare(`UPDATE tag_updates SET notified_key = ? WHERE normalized_ref = ?`),
recordEvent: db.prepare(`
INSERT INTO update_events (image, normalized_ref, status, digest, available_version, breaking, raw_json)
VALUES (@image, @normalized_ref, @status, @digest, @available_version, @breaking, @raw_json)
@@ -133,13 +168,14 @@ const stmts = {
SELECT value FROM app_meta WHERE key = ? LIMIT 1
`),
setRollbackPoint: db.prepare(`
- INSERT INTO rollback_points (container_name, image_id, image_ref, old_digest, old_version, created_at)
- VALUES (@container_name, @image_id, @image_ref, @old_digest, @old_version, datetime('now'))
+ INSERT INTO rollback_points (container_name, image_id, image_ref, old_digest, old_version, compose_edit, created_at)
+ VALUES (@container_name, @image_id, @image_ref, @old_digest, @old_version, @compose_edit, datetime('now'))
ON CONFLICT(container_name) DO UPDATE SET
image_id = excluded.image_id,
image_ref = excluded.image_ref,
old_digest = excluded.old_digest,
old_version = excluded.old_version,
+ compose_edit = excluded.compose_edit,
created_at = excluded.created_at
`),
getRollbackPoint: db.prepare(`
@@ -228,6 +264,32 @@ export function setLatestEventSkipped(normalized_ref, skipped) {
return stmts.setLatestEventSkipped.run(skipped ? 1 : 0, normalized_ref).changes > 0;
}
+/**
+ * Newer version TAGS for a ref (e.g. running postgres:16.3, registry has 16.4
+ * and 17.1). Rows with nothing newer are removed. The dismissed tag survives
+ * re-checks so a skipped tag stays hidden until an even newer one appears.
+ */
+export function setTagUpdate({ normalized_ref, current_tag, same_major, next_major }) {
+ if (!same_major && !next_major) return stmts.deleteTagUpdate.run(normalized_ref);
+ return stmts.setTagUpdate.run({ normalized_ref, current_tag, same_major: same_major ?? null, next_major: next_major ?? null });
+}
+
+export function getTagUpdate(normalized_ref) {
+ return stmts.getTagUpdate.get(normalized_ref) || null;
+}
+
+export function getAllTagUpdates() {
+ return stmts.getAllTagUpdates.all();
+}
+
+export function dismissTag(normalized_ref, tag) {
+ return stmts.dismissTag.run(tag ?? null, normalized_ref).changes > 0;
+}
+
+export function markTagNotified(normalized_ref, key) {
+ return stmts.markTagNotified.run(key ?? null, normalized_ref);
+}
+
export function updateEventAvailableVersion(normalized_ref, digest, available_version) {
return stmts.updateEventAvailableVersion.run(available_version ?? null, normalized_ref, digest);
}
@@ -267,14 +329,29 @@ export function getMeta(key) {
* Remember how to undo the most recent update of a container (the previous
* local image ID + what it was), so the dashboard can offer a one-click revert.
*/
-export function setRollbackPoint({ container_name, image_id, image_ref = null, old_digest = null, old_version = null }) {
+export function setRollbackPoint({ container_name, image_id, image_ref = null, old_digest = null, old_version = null, compose_edit = null }) {
if (!container_name || !image_id) return undefined;
- return stmts.setRollbackPoint.run({ container_name, image_id, image_ref, old_digest, old_version });
+ return stmts.setRollbackPoint.run({
+ container_name,
+ image_id,
+ image_ref,
+ old_digest,
+ old_version,
+ compose_edit: compose_edit ? JSON.stringify(compose_edit) : null,
+ });
}
export function getRollbackPoint(container_name) {
if (!container_name) return null;
- return stmts.getRollbackPoint.get(container_name) || null;
+ const row = stmts.getRollbackPoint.get(container_name);
+ if (!row) return null;
+ let composeEdit = null;
+ try {
+ composeEdit = row.compose_edit ? JSON.parse(row.compose_edit) : null;
+ } catch {
+ composeEdit = null;
+ }
+ return { ...row, compose_edit: composeEdit };
}
export function deleteRollbackPoint(container_name) {
@@ -327,6 +404,19 @@ export function getHistory({ containerName, limit = 50, offset = 0 } = {}) {
return stmts.getHistoryAll.all(limit, offset);
}
+export function countHistory() {
+ return stmts.countHistory.get().n;
+}
+
+const importHistoryTxn = db.transaction((rows) => {
+ for (const r of rows) stmts.importHistoryRow.run(r);
+});
+
+/** Insert history rows from a backup (already validated), in one transaction. */
+export function importHistory(rows) {
+ importHistoryTxn(rows);
+}
+
/** Delete all update-history rows. Returns the better-sqlite3 run info. */
export function clearHistory() {
return stmts.clearHistory.run();
diff --git a/server/src/docker.js b/server/src/docker.js
index 081ed11..2da3394 100644
--- a/server/src/docker.js
+++ b/server/src/docker.js
@@ -17,6 +17,8 @@ import { spawn } from 'node:child_process';
import Docker from 'dockerode';
import { config } from './config.js';
import { normalizeRef, parseRef } from './reconcile.js';
+import { rewriteServiceImageTag, restoreServiceImage, ComposeEditError } from './compose-file.js';
+import { knownSourceFor } from './known-sources.js';
// Best-effort identity of this app's own container, so listContainers can
// exclude it (you can't safely update the updater from within itself). By
@@ -413,7 +415,7 @@ export async function listContainers() {
image,
tag,
currentVersion,
- sourceUrl: sourceUrl || null,
+ sourceUrl: sourceUrl || knownSourceFor(image),
currentDigest,
currentDigests,
project: project || null,
@@ -702,18 +704,55 @@ async function imageIdForContainerName(name) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
+const NS_PER_MS = 1e6;
+
+/**
+ * Pure: how long to watch a container after (re)creating it, from its own
+ * healthcheck settings. With a healthcheck, Docker only marks it unhealthy
+ * after `start_period` plus `retries` failed probes (each up to `interval` +
+ * `timeout` apart), so a fixed 30s wrongly failed slow starters (databases,
+ * anything with a long start_period). Without one, there's nothing to wait
+ * for — instead the container must stay up for `stableMs` so an app that
+ * crashes a few seconds after starting isn't reported as a good update.
+ * Docker defaults: interval 30s, timeout 30s, retries 3, start_period 0.
+ *
+ * @param {object|null|undefined} healthcheck - container `Config.Healthcheck`.
+ * @returns {{ hasHealthcheck: boolean, timeoutMs: number, stableMs: number }}
+ */
+export function healthWaitPlan(healthcheck) {
+ const test = healthcheck?.Test;
+ const disabled = !Array.isArray(test) || test.length === 0 || test[0] === 'NONE';
+ if (disabled) return { hasHealthcheck: false, timeoutMs: 30_000, stableMs: 8_000 };
+ const ms = (ns, dflt) => (Number.isFinite(ns) && ns > 0 ? ns / NS_PER_MS : dflt);
+ const interval = ms(healthcheck.Interval, 30_000);
+ const probeTimeout = ms(healthcheck.Timeout, 30_000);
+ const startPeriod = ms(healthcheck.StartPeriod, 0);
+ const retries = Number.isFinite(healthcheck.Retries) && healthcheck.Retries > 0 ? healthcheck.Retries : 3;
+ const worstCase = startPeriod + (interval + probeTimeout) * (retries + 1) + 10_000;
+ return {
+ hasHealthcheck: true,
+ timeoutMs: Math.min(Math.max(worstCase, 30_000), 15 * 60_000), // 30s – 15min
+ stableMs: 0,
+ };
+}
+
/**
* Poll a container after an update to confirm it actually comes up, so we don't
- * report a green "updated" for an image that immediately crash-loops. Returns
- * as soon as the state is decisive; gives up (unhealthy) after `timeoutMs`.
+ * report a green "updated" for an image that immediately crash-loops. Waits as
+ * long as the container's own healthcheck needs (see healthWaitPlan); returns
+ * as soon as the state is decisive, or gives up (unhealthy) at the deadline.
*
* @param {string} name
+ * @param {(line: string) => void} [log] - progress notes for the live log.
* @returns {Promise<{ healthy: boolean, state: string, health: string|null, timedOut?: boolean }>}
*/
-export async function verifyContainerHealth(name, { timeoutMs = 30000, intervalMs = 2000 } = {}) {
- const deadline = Date.now() + timeoutMs;
+export async function verifyContainerHealth(name, { intervalMs = 2000, log, plan: planOverride } = {}) {
+ let plan = planOverride;
let state = 'unknown';
let health = null;
+ let runningSince = null;
+ let restartCount = null;
+ let deadline = null;
for (;;) {
let data;
try {
@@ -721,14 +760,34 @@ export async function verifyContainerHealth(name, { timeoutMs = 30000, intervalM
} catch {
return { healthy: false, state: 'missing', health: null };
}
+ if (!plan) {
+ plan = healthWaitPlan(data.Config?.Healthcheck);
+ if (plan.hasHealthcheck && plan.timeoutMs > 30_000) {
+ log?.(`Waiting for the healthcheck (up to ${Math.round(plan.timeoutMs / 1000)}s)…`);
+ }
+ }
+ if (deadline === null) deadline = Date.now() + plan.timeoutMs;
+
state = data.State?.Status || 'unknown';
health = data.State?.Health?.Status || null; // healthy|unhealthy|starting|null
+ // A restart since we started watching means it crashed and came back.
+ if (restartCount !== null && (data.RestartCount ?? 0) > restartCount) {
+ runningSince = null;
+ }
+ restartCount = data.RestartCount ?? 0;
+
if (state === 'running') {
- if (!health || health === 'healthy') return { healthy: true, state, health };
if (health === 'unhealthy') return { healthy: false, state, health };
+ if (health === 'healthy') return { healthy: true, state, health };
+ if (!health) {
+ // No healthcheck: healthy once it has stayed up for stableMs.
+ if (runningSince === null) runningSince = Date.now();
+ if (Date.now() - runningSince >= plan.stableMs) return { healthy: true, state, health };
+ }
// 'starting' — healthcheck still warming up; keep waiting.
- } else if (state === 'exited' || state === 'dead') {
- return { healthy: false, state, health };
+ } else {
+ runningSince = null;
+ if (state === 'exited' || state === 'dead') return { healthy: false, state, health };
}
// restarting / created / paused — keep polling until decisive or timeout.
if (Date.now() >= deadline) return { healthy: false, state, health, timedOut: true };
@@ -751,9 +810,9 @@ function describeUnhealthy(h) {
* container doesn't come up healthy, downgrade to a failure with an actionable
* message (the new image is recorded so the user can revert).
*/
-async function withHealthCheck(name, result, expectRunning) {
+async function withHealthCheck(name, result, expectRunning, onLine) {
if (!result.success || !expectRunning) return result;
- const h = await verifyContainerHealth(name);
+ const h = await verifyContainerHealth(name, { log: (l) => onLine?.(l, 'stdout') });
if (h.healthy) return { ...result, healthy: true, healthState: h.state };
return {
...result,
@@ -777,7 +836,7 @@ async function withHealthCheck(name, result, expectRunning) {
* @param {(line: string, stream: 'stdout'|'stderr') => void} [onLine]
* @returns {Promise<{ success: boolean, message: string, oldDigest: string|null, newDigest: string|null }>}
*/
-export async function updateContainer(name, onLine) {
+export async function updateContainer(name, onLine, { targetImage = null } = {}) {
let inspectData;
try {
inspectData = await docker.getContainer(name).inspect();
@@ -882,7 +941,7 @@ export async function updateContainer(name, onLine) {
try {
const [runningId, latestId] = await Promise.all([
imageIdForContainerName(name),
- docker.getImage(image).inspect().then((i) => i.Id),
+ docker.getImage(targetImage || image).inspect().then((i) => i.Id),
]);
if (runningId && latestId && runningId !== latestId) {
onLine?.('Container is not on the pulled image yet — forcing a recreate…', 'stdout');
@@ -913,7 +972,8 @@ export async function updateContainer(name, onLine) {
newImageId,
oldVersion,
},
- true
+ true,
+ onLine
);
}
@@ -933,7 +993,7 @@ export async function updateContainer(name, onLine) {
let pullResult;
try {
- pullResult = await spawnAndStream('docker', ['pull', image], onLine);
+ pullResult = await spawnAndStream('docker', ['pull', targetImage || image], onLine);
} catch (err) {
return {
success: false,
@@ -955,7 +1015,7 @@ export async function updateContainer(name, onLine) {
const wasRunning = inspectData.State?.Running === true;
try {
const baseImageConfig = await imageConfigOf(inspectData.Image);
- const createOpts = buildRecreateOptions(inspectData, baseImageConfig, image);
+ const createOpts = buildRecreateOptions(inspectData, baseImageConfig, targetImage || image);
await replaceContainer(name, inspectData, createOpts, (l) => onLine?.(l, 'stdout'));
} catch (err) {
return {
@@ -979,10 +1039,125 @@ export async function updateContainer(name, onLine) {
newImageId,
oldVersion,
},
- wasRunning
+ wasRunning,
+ onLine
);
}
+/** Every compose file a container was created from (`-f a.yml -f b.yml`). */
+function composeConfigFiles(inspectData, composeInfo) {
+ const labels = inspectData.Config?.Labels || {};
+ const workingDir = composeInfo?.workingDir || labels[COMPOSE_WORKING_DIR_LABEL] || null;
+ const raw = labels[COMPOSE_CONFIG_FILES_LABEL];
+ const files = raw
+ ? raw
+ .split(',')
+ .map((f) => f.trim())
+ .filter(Boolean)
+ .map((f) => (workingDir && !path.isAbsolute(f) ? path.resolve(workingDir, f) : f))
+ : [];
+ if (files.length === 0 && composeInfo?.composeFile) files.push(composeInfo.composeFile);
+ return files;
+}
+
+/**
+ * Move a container to a newer version TAG (postgres:16.3 -> 16.4).
+ *
+ * Compose-managed: rewrites that service's `image:` line in the compose file
+ * (the last of its -f files that sets it, since later files override), keeping
+ * a `.dockpull.bak` copy, then runs the normal update (pull + up + health
+ * check). If the pull/up fails before the container changed, the file is put
+ * back. Standalone: pulls the new tag and recreates the container on it.
+ *
+ * @returns {Promise} updateContainer's result plus `composeEdit`
+ * ({ file, service, oldValue, newValue }) when a file was changed.
+ */
+export async function switchContainerTag(name, newTag, onLine) {
+ const log = (l) => onLine?.(l, 'stdout');
+ const inspectData = await docker.getContainer(name).inspect();
+ const image = trackedImageRef(inspectData);
+ const { registry, repository } = parseRef(image);
+ const targetImage = `${registry === 'docker.io' ? repository.replace(/^library\//, '') : `${registry}/${repository}`}:${newTag}`;
+ const composeInfo = await getComposeInfo(inspectData);
+
+ if (!(composeInfo?.composeFile && composeInfo?.service)) {
+ log(`Switching to ${targetImage}…`);
+ return updateContainer(name, onLine, { targetImage });
+ }
+
+ const { service } = composeInfo;
+ let edit = null;
+ let lastError = null;
+ for (const file of composeConfigFiles(inspectData, composeInfo).reverse()) {
+ let text;
+ try {
+ text = fs.readFileSync(file, 'utf8');
+ } catch (err) {
+ lastError = new ComposeEditError('file_unreadable', `Couldn't read ${file}: ${err.message}`);
+ continue;
+ }
+ try {
+ const r = rewriteServiceImageTag(text, service, image, newTag);
+ edit = { file, service, original: text, ...r };
+ break;
+ } catch (err) {
+ lastError = err;
+ if (err.code !== 'image_not_found') break; // found it but can't safely change it
+ }
+ }
+ if (!edit) {
+ return { success: false, message: lastError?.message || 'Couldn\'t update the compose file.', oldDigest: null, newDigest: null };
+ }
+
+ try {
+ fs.writeFileSync(`${edit.file}.dockpull.bak`, edit.original);
+ fs.writeFileSync(edit.file, edit.text);
+ } catch (err) {
+ return { success: false, message: `Couldn't write ${edit.file}: ${err.message}`, oldDigest: null, newDigest: null };
+ }
+ log(`Changed ${path.basename(edit.file)}: ${edit.oldValue} → ${edit.newValue}`);
+
+ const result = await updateContainer(name, onLine, { targetImage });
+ if (!result.newImageId) {
+ // Pull/up failed before the container moved: put the file back as it was.
+ try {
+ const current = fs.readFileSync(edit.file, 'utf8');
+ const restored = restoreServiceImage(current, service, edit.newValue, edit.oldValue);
+ if (restored !== null) {
+ fs.writeFileSync(edit.file, restored);
+ log(`Restored ${path.basename(edit.file)} to ${edit.oldValue}.`);
+ }
+ } catch (err) {
+ log(`Couldn't restore ${edit.file} (backup at ${edit.file}.dockpull.bak): ${err.message}`);
+ }
+ return result;
+ }
+ return {
+ ...result,
+ composeEdit: { file: edit.file, service, oldValue: edit.oldValue, newValue: edit.newValue },
+ };
+}
+
+/**
+ * Undo a switchContainerTag compose edit (used by revert), if the file still
+ * has the switched value. Returns true if the file was restored.
+ */
+export function restoreComposeEdit({ file, service, oldValue, newValue }, log) {
+ try {
+ const restored = restoreServiceImage(fs.readFileSync(file, 'utf8'), service, newValue, oldValue);
+ if (restored === null) {
+ log?.(`${path.basename(file)} no longer says ${newValue}; leaving it as is.`);
+ return false;
+ }
+ fs.writeFileSync(file, restored);
+ log?.(`Restored ${path.basename(file)}: ${newValue} → ${oldValue}`);
+ return true;
+ } catch (err) {
+ log?.(`Couldn't restore ${file}: ${err.message}`);
+ return false;
+ }
+}
+
/**
* Revert a container to a previous local image by ID: recreate it from that
* image (no pull), preserving its config/host config/networks, and start it.
@@ -1030,7 +1205,7 @@ export async function revertContainer(name, imageId, onLine, { imageRef = null,
}
const newDigest = await currentDigestForContainerName(name);
- const health = await verifyContainerHealth(name);
+ const health = await verifyContainerHealth(name, { log });
if (!health.healthy) {
return { success: false, message: `Reverted, but ${describeUnhealthy(health)}.`, oldDigest, newDigest };
}
@@ -1049,7 +1224,7 @@ export async function getContainerImageMeta(name) {
const image = trackedImageRef(inspectData);
if (!image) return { image: null, currentVersion: null, sourceUrl: null };
const { version, source } = await inspectImageMeta(inspectData.Image, image);
- return { image, currentVersion: version, sourceUrl: source };
+ return { image, currentVersion: version, sourceUrl: source || knownSourceFor(image) };
}
/**
diff --git a/server/src/index.js b/server/src/index.js
index 8fcb001..e2ed09b 100644
--- a/server/src/index.js
+++ b/server/src/index.js
@@ -6,7 +6,8 @@ import cookieParser from 'cookie-parser';
import { config, assertRequiredConfig } from './config.js';
// Importing db creates the data dir + tables as a side effect on load.
import db from './db.js';
-import { authRouter, requireAuth } from './auth.js';
+import { authRouter, requireAuth, setSessionGenerationStore } from './auth.js';
+import { getMeta, setMeta } from './db.js';
import { apiRouter } from './routes/api.js';
import { updateRouter } from './routes/update.js';
import { securityHeaders, requireCsrfHeader } from './security.js';
@@ -25,6 +26,18 @@ if (process.env.SKIP_CONFIG_CHECK !== '1') {
console.warn('SKIP_CONFIG_CHECK=1 set — skipping required env var validation.');
}
+// Persist the "sign out everywhere" counter (cached; read on every request).
+{
+ let generation = Number(getMeta('sessionGeneration')) || 0;
+ setSessionGenerationStore({
+ get: () => generation,
+ set: (n) => {
+ generation = n;
+ setMeta('sessionGeneration', n);
+ },
+ });
+}
+
const app = express();
app.disable('x-powered-by');
@@ -47,7 +60,8 @@ if (config.BASE_PATH) {
// Security headers for every response (no external dependency).
app.use(securityHeaders({ https: config.BASE_URL.startsWith('https') }));
-app.use(express.json());
+// Backups (settings + up to 5000 history rows) can exceed the 100kb default.
+app.use(express.json({ limit: '5mb' }));
app.use(cookieParser(config.SESSION_SECRET));
// Before every router (login included): unsafe /api methods need X-DockPull.
app.use(requireCsrfHeader);
diff --git a/server/src/known-sources.js b/server/src/known-sources.js
new file mode 100644
index 0000000..62f55c9
--- /dev/null
+++ b/server/src/known-sources.js
@@ -0,0 +1,84 @@
+/**
+ * Where to find release notes for popular images that don't declare an
+ * `org.opencontainers.image.source` label. Without it DockPull could only link
+ * to Docker Hub; with it the card gets real GitHub release notes, version
+ * fallbacks and breaking-change hints. Every entry was checked to be a real
+ * GitHub repo with version tags.
+ *
+ * Keys are `registry/repository` as parseRef() normalizes them.
+ */
+
+import { parseRef } from './reconcile.js';
+
+const KNOWN = {
+ // Docker Hub official images whose upstream publishes GitHub releases.
+ 'docker.io/library/redis': 'redis/redis',
+ 'docker.io/library/traefik': 'traefik/traefik',
+ 'docker.io/library/nginx': 'nginx/nginx',
+ 'docker.io/library/caddy': 'caddyserver/caddy',
+ 'docker.io/library/nextcloud': 'nextcloud/server',
+ 'docker.io/library/ghost': 'TryGhost/Ghost',
+ 'docker.io/library/registry': 'distribution/distribution',
+ 'docker.io/library/eclipse-mosquitto': 'eclipse/mosquitto',
+ 'docker.io/library/influxdb': 'influxdata/influxdb',
+
+ // Popular homelab images.
+ 'docker.io/jellyfin/jellyfin': 'jellyfin/jellyfin',
+ 'docker.io/portainer/portainer-ce': 'portainer/portainer',
+ 'docker.io/portainer/portainer-ee': 'portainer/portainer',
+ 'docker.io/louislam/uptime-kuma': 'louislam/uptime-kuma',
+ 'docker.io/louislam/dockge': 'louislam/dockge',
+ 'docker.io/homeassistant/home-assistant': 'home-assistant/core',
+ 'ghcr.io/home-assistant/home-assistant': 'home-assistant/core',
+ 'docker.io/vaultwarden/server': 'dani-garcia/vaultwarden',
+ 'docker.io/pihole/pihole': 'pi-hole/docker-pi-hole',
+ 'docker.io/adguard/adguardhome': 'AdguardTeam/AdGuardHome',
+ 'docker.io/containrrr/watchtower': 'containrrr/watchtower',
+ 'docker.io/grafana/grafana': 'grafana/grafana',
+ 'docker.io/grafana/grafana-oss': 'grafana/grafana',
+ 'docker.io/prom/prometheus': 'prometheus/prometheus',
+ 'docker.io/n8nio/n8n': 'n8n-io/n8n',
+ 'docker.io/jc21/nginx-proxy-manager': 'NginxProxyManager/nginx-proxy-manager',
+ 'docker.io/syncthing/syncthing': 'syncthing/syncthing',
+ 'docker.io/filebrowser/filebrowser': 'filebrowser/filebrowser',
+ 'docker.io/gitea/gitea': 'go-gitea/gitea',
+ 'ghcr.io/immich-app/immich-server': 'immich-app/immich',
+ 'ghcr.io/immich-app/immich-machine-learning': 'immich-app/immich',
+ 'docker.io/binwiederhier/ntfy': 'binwiederhier/ntfy',
+ 'docker.io/gotify/server': 'gotify/server',
+ 'docker.io/authelia/authelia': 'authelia/authelia',
+ 'docker.io/qmcgaw/gluetun': 'qdm12/gluetun',
+ 'docker.io/esphome/esphome': 'esphome/esphome',
+ 'docker.io/koenkk/zigbee2mqtt': 'Koenkk/zigbee2mqtt',
+ 'docker.io/nodered/node-red': 'node-red/node-red',
+};
+
+/**
+ * Pure: a GitHub URL with release notes for `image`, or null.
+ *
+ * Besides the table: linuxserver.io images (lscr.io/linuxserver/x,
+ * linuxserver/x on Docker Hub, ghcr.io/linuxserver/x) publish releases in
+ * github.com/linuxserver/docker-x, matching their image tags.
+ *
+ * @param {string} image
+ * @returns {string|null}
+ */
+export function knownSourceFor(image) {
+ let parsed;
+ try {
+ parsed = parseRef(image);
+ } catch {
+ return null;
+ }
+ const { registry, repository } = parsed;
+ const known = KNOWN[`${registry}/${repository}`];
+ if (known) return `https://github.com/${known}`;
+
+ const ls = /^linuxserver\/([a-z0-9._-]+)$/.exec(repository);
+ if (ls && ['lscr.io', 'docker.io', 'ghcr.io'].includes(registry)) {
+ return `https://github.com/linuxserver/docker-${ls[1]}`;
+ }
+ return null;
+}
+
+export default { knownSourceFor };
diff --git a/server/src/notify.js b/server/src/notify.js
index 1d2dc4c..c50ee1e 100644
--- a/server/src/notify.js
+++ b/server/src/notify.js
@@ -152,6 +152,47 @@ export function sendUpdates(type, url, items, opts) {
}
}
+/**
+ * Pure: a "something went wrong" notification for a failed update, revert or
+ * tag switch (including "updated, but it came up unhealthy").
+ *
+ * @param {{ name: string, image?: string|null, action?: string, message?: string }} f
+ * @returns {{ title: string, body: string }}
+ */
+export function buildFailureMessage({ name, image, action = 'update', message }) {
+ const detail = String(message || 'Unknown error')
+ .split('\n')
+ .slice(0, 6)
+ .join('\n')
+ .slice(0, 800);
+ return {
+ title: `⚠️ ${action === 'revert' ? 'Revert' : 'Update'} of ${name} failed`,
+ body: `${image ? `${image}\n` : ''}${detail}`,
+ };
+}
+
+/** Send a failure notification to the configured target. */
+export function sendFailure(type, url, failure, opts) {
+ const { title: t, body } = buildFailureMessage(failure);
+ switch (type) {
+ case 'ntfy':
+ return postNtfy(url, { title: t, tags: 'warning', body }, opts);
+ case 'gotify':
+ return postJson(url, { title: t, message: body, priority: 8 }, opts);
+ case 'webhook':
+ return postJson(
+ url,
+ { title: t, message: body, event: 'update_failed', container: failure.name, image: failure.image ?? null },
+ opts
+ );
+ case 'discord':
+ default: {
+ const fence = '```';
+ return postJson(url, { content: `**${t}**\n${fence}\n${body.replaceAll(fence, '``')}\n${fence}` }, opts);
+ }
+ }
+}
+
/** Send a one-off test message so the user can confirm their target works. */
export function sendTest(type, url, opts) {
const text = '✅ DockPull test — your notifications are configured correctly.';
@@ -179,4 +220,6 @@ export default {
buildWebhookPayload,
sendUpdates,
sendTest,
+ sendFailure,
+ buildFailureMessage,
};
diff --git a/server/src/registry.js b/server/src/registry.js
index 43f5c99..3bb2232 100644
--- a/server/src/registry.js
+++ b/server/src/registry.js
@@ -235,4 +235,54 @@ export async function getRemoteVersion(imageRef, { timeoutMs = 10000 } = {}) {
}
}
-export default { registryBaseUrl, getRemoteDigest, getRemoteVersion, parseWwwAuthenticate, pickPlatformManifest };
+/**
+ * Pure: the next-page URL from a registry `Link: <...>; rel="next"` header,
+ * resolved against `base`, or null.
+ */
+export function nextPageUrl(linkHeader, base) {
+ if (!linkHeader) return null;
+ const m = /<([^>]+)>\s*;\s*rel="?next"?/i.exec(linkHeader);
+ if (!m) return null;
+ try {
+ const next = new URL(m[1], base);
+ // Never follow pagination to a different host (or downgrade the scheme).
+ return next.origin === new URL(base).origin ? next.toString() : null;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * Every tag of an image's repository (`GET /v2//tags/list`), following
+ * pagination up to `maxPages` × 1000 tags. Same auth flow as digest checks.
+ *
+ * @param {string} imageRef
+ * @returns {Promise}
+ * @throws if the registry is unreachable or refuses.
+ */
+export async function listTags(imageRef, { timeoutMs = 15000, maxPages = 10 } = {}) {
+ const { registry, repository } = parseRef(imageRef);
+ const base = registryBaseUrl(registry);
+ let url = `${base}/v2/${repository}/tags/list?n=1000`;
+ let authHeader = null;
+ const tags = [];
+ for (let page = 0; url && page < maxPages; page += 1) {
+ const get = () =>
+ fetch(url, {
+ headers: { Accept: 'application/json', ...(authHeader ? { Authorization: authHeader } : {}) },
+ signal: AbortSignal.timeout(timeoutMs),
+ });
+ let res = await get();
+ if (res.status === 401) {
+ authHeader = await resolveAuthHeader(res, registry, repository, timeoutMs);
+ if (authHeader) res = await get();
+ }
+ if (!res.ok) throw new Error(`registry returned ${res.status} listing tags for ${imageRef}`);
+ const body = await res.json().catch(() => null);
+ if (Array.isArray(body?.tags)) tags.push(...body.tags);
+ url = nextPageUrl(res.headers.get('link'), base);
+ }
+ return tags;
+}
+
+export default { registryBaseUrl, listTags, nextPageUrl, getRemoteDigest, getRemoteVersion, parseWwwAuthenticate, pickPlatformManifest };
diff --git a/server/src/routes/api.js b/server/src/routes/api.js
index 40f92ed..700dbcb 100644
--- a/server/src/routes/api.js
+++ b/server/src/routes/api.js
@@ -29,6 +29,8 @@ import { sendTest } from '../notify.js';
import { getChangelog } from '../changelog.js';
import { isValidNotifyUrl } from '../urlguard.js';
import * as db from '../db.js';
+import { buildBackup, restoreBackup } from '../backup.js';
+import { getSelfUpdate } from '../self-update.js';
import { validateContainerNameParam } from '../security.js';
export const apiRouter = express.Router();
@@ -60,6 +62,7 @@ apiRouter.get('/api/status', (req, res) => {
version: APP_VERSION,
lastCheck: db.getMeta('lastCheck'),
danglingImages: db.getMeta('danglingImages'),
+ nextScanAt: scheduler.getNextRunAt(),
serverTime: now.toISOString(),
timeZone,
// Local HH:MM as the server sees it (what the scheduled scan compares to).
@@ -101,6 +104,8 @@ apiRouter.get('/api/containers', async (req, res) => {
lookupVersion: (digest) => db.getImageVersion(digest),
getRollback: (name) => db.getRollbackPoint(name),
getCheckError: (ref) => errorByRef.get(ref) || null,
+ getTagUpdate: (ref) => db.getTagUpdate(ref),
+ tagPolicy: getSettings().tagUpdates,
});
for (const ref of refsToResolve) {
@@ -298,6 +303,59 @@ apiRouter.delete('/api/skip/:ref', (req, res) => {
return setSkipped(ref, false, res);
});
+// Hide one offered newer TAG (e.g. "not 17.0 yet") until an even newer one
+// appears. `{ ref, tag: null }` clears it.
+apiRouter.post('/api/skip-tag', (req, res) => {
+ const { ref, tag } = req.body || {};
+ if (typeof ref !== 'string' || (tag !== null && typeof tag !== 'string')) {
+ return res.status(400).json({ error: 'invalid_payload' });
+ }
+ let normalized;
+ try {
+ normalized = normalizeRef(ref);
+ } catch {
+ return res.status(400).json({ error: 'invalid_payload' });
+ }
+ if (!db.dismissTag(normalized, tag)) {
+ return res.status(404).json({ error: 'no_pending_update' });
+ }
+ broadcastGlobal({ type: 'containers-changed' });
+ return res.status(200).json({ ok: true });
+});
+
+// Is a newer DockPull release out? Read-only: DockPull never updates itself.
+apiRouter.get('/api/self-update', async (req, res) => {
+ try {
+ return res.status(200).json(await getSelfUpdate(APP_VERSION));
+ } catch {
+ return res.status(200).json({ current: APP_VERSION, available: false });
+ }
+});
+
+// --- Backup / restore ---
+
+apiRouter.get('/api/backup', (req, res) => {
+ const stamp = new Date().toISOString().slice(0, 10);
+ res.set('Content-Disposition', `attachment; filename="dockpull-backup-${stamp}.json"`);
+ res.set('Cache-Control', 'no-store');
+ return res.status(200).json(buildBackup({ appVersion: APP_VERSION }));
+});
+
+apiRouter.post('/api/restore', (req, res) => {
+ let summary;
+ try {
+ summary = restoreBackup(req.body);
+ } catch (err) {
+ if (err.code === 'invalid_backup') {
+ return res.status(400).json({ error: 'invalid_backup', message: err.message });
+ }
+ throw err;
+ }
+ scheduler.reschedule();
+ broadcastGlobal({ type: 'containers-changed' });
+ return res.status(200).json({ ok: true, ...summary });
+});
+
// --- Settings ---
apiRouter.get('/api/settings', (req, res) => {
diff --git a/server/src/routes/update.js b/server/src/routes/update.js
index 38bac14..82edd95 100644
--- a/server/src/routes/update.js
+++ b/server/src/routes/update.js
@@ -14,9 +14,19 @@
*/
import express from 'express';
-import { docker, updateContainer, revertContainer, imageExists, trackedImageRef } from '../docker.js';
+import {
+ docker,
+ updateContainer,
+ revertContainer,
+ imageExists,
+ trackedImageRef,
+ switchContainerTag,
+ restoreComposeEdit,
+} from '../docker.js';
import { normalizeRef } from '../reconcile.js';
import * as sse from '../sse.js';
+import { getSettings } from '../settings.js';
+import { sendFailure } from '../notify.js';
import * as db from '../db.js';
import { validateContainerNameParam } from '../security.js';
@@ -25,6 +35,24 @@ export const updateRouter = express.Router();
// Every :name route talks to Docker; reject anything that isn't a container name.
updateRouter.param('name', validateContainerNameParam);
+/**
+ * Best-effort "update failed" notification. Never throws or delays the result.
+ */
+function notifyFailure(failure) {
+ let s;
+ try {
+ s = getSettings();
+ } catch {
+ return;
+ }
+ if (!s.discordEnabled || !s.discordWebhookUrl || !s.notifyOnFailure) return;
+ sendFailure(s.notifyType, s.discordWebhookUrl, failure)
+ .then((r) => {
+ if (!r.ok) console.warn(`update.js: failure notification returned ${r.status}`);
+ })
+ .catch((err) => console.warn(`update.js: failure notification failed: ${err.message}`));
+}
+
/**
* Runs the update + records history + finishes the SSE session, detached
* from the request lifecycle (the POST handler responds before this
@@ -33,9 +61,9 @@ updateRouter.param('name', validateContainerNameParam);
* @param {string} name
* @param {string|null} image - configured image ref, for the history row.
*/
-async function runUpdate(name, image) {
+async function runUpdate(name, image, { run = updateContainer } = {}) {
try {
- const result = await updateContainer(name, (line, stream) => sse.pushLog(name, line, stream));
+ const result = await run(name, (line, stream) => sse.pushLog(name, line, stream));
const oldVersion = db.getImageVersion(result.oldDigest) ?? result.oldVersion ?? null;
db.recordUpdate({
container_name: name,
@@ -72,9 +100,11 @@ async function runUpdate(name, image) {
image_ref: image,
old_digest: result.oldDigest,
old_version: oldVersion,
+ compose_edit: result.composeEdit ?? null,
});
}
sse.finish(name, { success: result.success, message: result.message });
+ if (!result.success) notifyFailure({ name, image, action: 'update', message: result.message });
} catch (err) {
db.recordUpdate({
container_name: name,
@@ -85,6 +115,7 @@ async function runUpdate(name, image) {
message: err.message,
});
sse.finish(name, { success: false, message: err.message });
+ notifyFailure({ name, image, action: 'update', message: err.message });
} finally {
// Let other connected dashboards refresh their list/badges.
sse.broadcastGlobal({ type: 'containers-changed' });
@@ -127,6 +158,11 @@ updateRouter.post('/api/update/:name', async (req, res) => {
*/
async function runRevert(name, image, rollback) {
try {
+ // Undo a tag switch's compose-file edit too, so the file and the container
+ // agree (and a later `compose up` doesn't move it forward again).
+ if (rollback.compose_edit) {
+ restoreComposeEdit(rollback.compose_edit, (l) => sse.pushLog(name, l, 'stdout'));
+ }
const result = await revertContainer(name, rollback.image_id, (line, stream) => sse.pushLog(name, line, stream), {
imageRef: image,
digest: rollback.old_digest,
@@ -146,6 +182,7 @@ async function runRevert(name, image, rollback) {
// re-detected as available on the next check.
if (result.success) db.deleteRollbackPoint(name);
sse.finish(name, { success: result.success, message: result.message });
+ if (!result.success) notifyFailure({ name, image, action: 'revert', message: result.message });
} catch (err) {
db.recordUpdate({
container_name: name,
@@ -156,6 +193,7 @@ async function runRevert(name, image, rollback) {
message: err.message,
});
sse.finish(name, { success: false, message: err.message });
+ notifyFailure({ name, image, action: 'revert', message: err.message });
} finally {
sse.broadcastGlobal({ type: 'containers-changed' });
}
@@ -206,6 +244,53 @@ updateRouter.post('/api/update/:name/revert', async (req, res) => {
return res.status(200).json({ streamId: name });
});
+// Docker tag grammar: [A-Za-z0-9_][A-Za-z0-9_.-]{0,127}
+const TAG_RE = /^[A-Za-z0-9_][A-Za-z0-9_.-]{0,127}$/;
+
+/**
+ * Move a container to a newer version tag the last check found (see
+ * tag_updates). Only tags DockPull itself offered are accepted.
+ */
+updateRouter.post('/api/update/:name/switch-tag', async (req, res) => {
+ const { name } = req.params;
+ const tag = req.body?.tag;
+ if (typeof tag !== 'string' || !TAG_RE.test(tag)) {
+ return res.status(400).json({ error: 'invalid_tag' });
+ }
+
+ let inspectData;
+ try {
+ inspectData = await docker.getContainer(name).inspect();
+ } catch (err) {
+ if (err.statusCode === 404) return res.status(404).json({ error: 'not_found' });
+ return res.status(503).json({ error: 'docker_unavailable' });
+ }
+ const image = trackedImageRef(inspectData);
+ let ref;
+ try {
+ ref = normalizeRef(image);
+ } catch {
+ return res.status(400).json({ error: 'invalid_image' });
+ }
+ const offered = db.getTagUpdate(ref);
+ if (!offered || (offered.same_major !== tag && offered.next_major !== tag)) {
+ return res.status(409).json({
+ error: 'tag_not_offered',
+ message: `${tag} isn't a newer version DockPull found for this image. Run a check first.`,
+ });
+ }
+
+ if (sse.isActive(name)) {
+ return res.status(409).json({ error: 'update_in_progress' });
+ }
+ sse.startSession(name);
+ // The tag_updates row belongs to the OLD tag and may still apply to other
+ // containers running it, so it's left alone; the switched container now has
+ // a new ref, whose newer tags the next check works out.
+ void runUpdate(name, image, { run: (n, onLine) => switchContainerTag(n, tag, onLine) });
+ return res.status(200).json({ streamId: name });
+});
+
updateRouter.get('/api/update/:name/stream', (req, res) => {
sse.subscribe(req.params.name, res, req);
});
diff --git a/server/src/scheduler.js b/server/src/scheduler.js
index 86a3e6d..dd8b7b2 100644
--- a/server/src/scheduler.js
+++ b/server/src/scheduler.js
@@ -20,6 +20,7 @@ import * as db from './db.js';
let timer = null;
let running = false;
+let nextRunAt = null;
/**
* Milliseconds until the next occurrence of a daily HH:MM (server local time).
@@ -35,6 +36,40 @@ export function msUntilNext(timeStr, now = new Date()) {
return next.getTime() - now.getTime();
}
+const STARTUP_CATCHUP_DELAY_MS = 60_000; // let the app settle before a catch-up scan
+
+/** The most recent daily HH:MM at or before `now` (server local time). */
+function previousOccurrence(timeStr, now) {
+ return new Date(now.getTime() + msUntilNext(timeStr, now) - 24 * 3600 * 1000);
+}
+
+/**
+ * Pure: milliseconds until the next background scan.
+ *
+ * - `daily`: at scheduledCheckTime. On startup, if that time passed since the
+ * last check (the server was off or restarting then), catch up shortly
+ * instead of waiting up to a day.
+ * - `interval`: every scheduleIntervalHours, counted from the last check
+ * (manual ones included, so a fresh manual check pushes it back). Overdue or
+ * never-checked runs shortly.
+ *
+ * @param {{ scheduleMode: string, scheduledCheckTime: string, scheduleIntervalHours: number }} settings
+ * @param {{ lastCheckAt?: number|null, now?: Date, startup?: boolean }} [opts]
+ * @returns {number}
+ */
+export function planNextRun(settings, { lastCheckAt = null, now = new Date(), startup = false } = {}) {
+ if (settings.scheduleMode === 'interval') {
+ const every = Math.max(1, settings.scheduleIntervalHours || 6) * 3600 * 1000;
+ if (!lastCheckAt) return STARTUP_CATCHUP_DELAY_MS;
+ return Math.max(STARTUP_CATCHUP_DELAY_MS, lastCheckAt + every - now.getTime());
+ }
+ if (startup) {
+ const due = previousOccurrence(settings.scheduledCheckTime, now).getTime();
+ if (!lastCheckAt || lastCheckAt < due) return STARTUP_CATCHUP_DELAY_MS;
+ }
+ return msUntilNext(settings.scheduledCheckTime, now);
+}
+
/**
* Decide what a notification run should announce. The message lists every
* container that currently has an unapplied, unpinned update -- not just the
@@ -129,25 +164,41 @@ async function tick() {
async function fire() {
await tick();
- reschedule(); // arm for the next day
+ reschedule(); // arm the next run
+}
+
+function lastCheckAt() {
+ try {
+ return db.getMeta('lastCheck')?.at ?? null;
+ } catch {
+ return null;
+ }
}
/**
- * (Re)arm the daily timer from current settings. Clears any existing timer
- * first; no-op when background checks are disabled.
+ * (Re)arm the background timer from current settings. Clears any existing
+ * timer first; no-op when background checks are disabled.
*/
-export function reschedule() {
+export function reschedule({ startup = false } = {}) {
if (timer) {
clearTimeout(timer);
timer = null;
}
const s = getSettings();
if (!s.backgroundCheckEnabled) return;
- timer = setTimeout(fire, msUntilNext(s.scheduledCheckTime));
+ const delay = planNextRun(s, { lastCheckAt: lastCheckAt(), startup });
+ timer = setTimeout(fire, delay);
+ timer.unref?.();
+ nextRunAt = Date.now() + delay;
+}
+
+/** When the next background scan is due (ms epoch), or null if none. */
+export function getNextRunAt() {
+ return timer ? nextRunAt : null;
}
export function start() {
- reschedule();
+ reschedule({ startup: true });
}
export function stop() {
@@ -157,4 +208,4 @@ export function stop() {
}
}
-export default { start, stop, reschedule, runScheduledCheck, msUntilNext, selectNotifyTargets };
+export default { start, stop, reschedule, runScheduledCheck, msUntilNext, planNextRun, getNextRunAt, selectNotifyTargets };
diff --git a/server/src/self-update.js b/server/src/self-update.js
new file mode 100644
index 0000000..48ca3d3
--- /dev/null
+++ b/server/src/self-update.js
@@ -0,0 +1,63 @@
+/**
+ * "A newer DockPull is available" — read-only. DockPull deliberately never
+ * updates its own container (recreating the container running this process
+ * mid-update breaks it), so this only tells the user, shows what changed, and
+ * gives them the command to run.
+ *
+ * Uses the GitHub releases of the DockPull repo (cached 30 min by
+ * changelog.js, and optional GITHUB_TOKEN applies). Any failure means "no
+ * banner", never an error in the UI.
+ */
+
+import { getReleasesCached } from './changelog.js';
+import { parseVersionTag } from './version.js';
+
+export const SELF_REPO = { owner: 'StrandedTurtle', repo: 'dockpull' };
+
+function cmp(a, b) {
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
+ if (d !== 0) return d;
+ }
+ return 0;
+}
+
+/**
+ * Pure: the releases newer than `currentVersion` (stable only, newest first).
+ * Empty when current is unknown/unparseable (e.g. a dev build) — no banner.
+ */
+export function newerSelfReleases(releases, currentVersion) {
+ const cur = parseVersionTag(String(currentVersion || ''));
+ if (!cur || !Array.isArray(releases)) return [];
+ return releases
+ .filter((r) => r && !r.draft && !r.prerelease)
+ .map((r) => ({ r, v: parseVersionTag(String(r.tag_name || '')) }))
+ .filter(({ v }) => v && v.nums.length >= 2 && cmp(v.nums, cur.nums) > 0)
+ .sort((a, b) => cmp(b.v.nums, a.v.nums))
+ .map(({ r }) => r);
+}
+
+/**
+ * @param {string} currentVersion - this build's version (package.json).
+ * @returns {Promise<{ current: string, available: boolean, latest?: string, releaseUrl?: string, releases?: Array }>}
+ */
+export async function getSelfUpdate(currentVersion) {
+ const base = { current: currentVersion, available: false };
+ const releases = await getReleasesCached(SELF_REPO.owner, SELF_REPO.repo);
+ const newer = newerSelfReleases(releases, currentVersion);
+ if (newer.length === 0) return base;
+ return {
+ ...base,
+ available: true,
+ latest: newer[0].tag_name.replace(/^v/i, ''),
+ releaseUrl: newer[0].html_url,
+ releases: newer.slice(0, 10).map((r) => ({
+ tag: r.tag_name,
+ url: r.html_url,
+ publishedAt: r.published_at,
+ body: String(r.body || '').slice(0, 3000),
+ })),
+ };
+}
+
+export default { getSelfUpdate, newerSelfReleases, SELF_REPO };
diff --git a/server/src/settings.js b/server/src/settings.js
index 4867a83..5f1508e 100644
--- a/server/src/settings.js
+++ b/server/src/settings.js
@@ -27,6 +27,11 @@ function isValidTime(v) {
return typeof v === 'string' && /^([01]\d|2[0-3]):[0-5]\d$/.test(v.trim());
}
+function intInRange(v, min, max) {
+ const n = typeof v === 'number' ? v : typeof v === 'string' && /^\d+$/.test(v.trim()) ? Number(v) : NaN;
+ return Number.isInteger(n) && n >= min && n <= max ? n : undefined;
+}
+
function timeOrUndef(v) {
return isValidTime(v) ? v.trim() : undefined;
}
@@ -72,6 +77,25 @@ const SPEC = {
fromStore: (v) => (isValidTime(v) ? v.trim() : ENV_TIME),
fromInput: timeOrUndef,
},
+ // Background scan cadence: 'daily' at scheduledCheckTime, or 'interval'
+ // every scheduleIntervalHours hours.
+ scheduleMode: {
+ default: 'daily',
+ fromStore: enumOf(['daily', 'interval'], 'daily'),
+ fromInput: enumOf(['daily', 'interval'], undefined),
+ },
+ scheduleIntervalHours: {
+ default: 6,
+ fromStore: (v) => intInRange(v, 1, 168) ?? 6,
+ fromInput: (v) => intInRange(v, 1, 168),
+ },
+ // Also notify when an update fails or comes up unhealthy (uses the same
+ // notification target; only when notifications are enabled).
+ notifyOnFailure: {
+ default: true,
+ fromStore: (v) => bool(v, true),
+ fromInput: (v) => (typeof v === 'boolean' ? v : undefined),
+ },
// Master "send notifications" toggle (kept this key for back-compat).
discordEnabled: {
default: ENV_WEBHOOK !== '',
@@ -85,6 +109,14 @@ const SPEC = {
fromStore: (v) => (typeof v === 'string' ? v : ENV_WEBHOOK),
fromInput: urlOrUndef,
},
+ // Newer version TAG detection (e.g. running postgres:16.3 when 16.4 exists):
+ // 'off', 'minor' (same major version — default) or 'major' (also offer the
+ // next major version).
+ tagUpdates: {
+ default: 'minor',
+ fromStore: enumOf(['off', 'minor', 'major'], 'minor'),
+ fromInput: enumOf(['off', 'minor', 'major'], undefined),
+ },
notifyType: {
default: ENV_NOTIFY_TYPE,
fromStore: enumOf(NOTIFY_TYPES, ENV_NOTIFY_TYPE),
@@ -126,6 +158,9 @@ export function updateSettings(patch) {
err.code = 'invalid_value';
throw err;
}
+ // Validate everything first, then write: a bad value anywhere leaves the
+ // stored settings untouched (restore relies on this being all-or-nothing).
+ const toWrite = [];
for (const [key, raw] of Object.entries(patch)) {
const spec = SPEC[key];
if (!spec) continue; // ignore unknown keys
@@ -135,8 +170,9 @@ export function updateSettings(patch) {
err.code = 'invalid_value';
throw err;
}
- db.setSetting(key, typeof coerced === 'boolean' ? (coerced ? '1' : '0') : String(coerced));
+ toWrite.push([key, typeof coerced === 'boolean' ? (coerced ? '1' : '0') : String(coerced)]);
}
+ for (const [key, value] of toWrite) db.setSetting(key, value);
return getSettings();
}
diff --git a/server/src/version.js b/server/src/version.js
index 55e5647..7e20042 100644
--- a/server/src/version.js
+++ b/server/src/version.js
@@ -48,4 +48,76 @@ export function isMeaningfulVersion(v) {
return true;
}
-export default { isMeaningfulVersion };
+/**
+ * Pure: split a version-like image tag into a comparable form.
+ *
+ * "16.3" -> nums [16, 3], shape "|2|"
+ * "v1.2.3" -> nums [1, 2, 3], shape "v|3|"
+ * "16.3-alpine" -> nums [16, 3], shape "|2|-alpine"
+ * "4.0.14.2939-ls283" -> nums [4,0,14,2939, 283], shape "|4|-ls#"
+ *
+ * The "shape" (prefix, how many numeric parts, and the suffix with its digits
+ * blanked) decides which tags are comparable: "16.3-alpine" only competes
+ * with other "x.y-alpine" tags, and "-rc1"/"-beta" tags never match a stable
+ * tag. Digits inside the suffix (linuxserver's "-ls283" build number) are
+ * compared after the main version. Returns null for tags that aren't versions
+ * (latest, main, a sha…).
+ *
+ * @param {string} tag
+ * @returns {{ prefix: string, nums: number[], shape: string }|null}
+ */
+export function parseVersionTag(tag) {
+ if (typeof tag !== 'string') return null;
+ const m = /^([vV]?)(\d+(?:\.\d+)*)(.*)$/.exec(tag.trim());
+ if (!m) return null;
+ const [, prefix, core, suffix] = m;
+ if (/^[0-9a-f]{7,}$/i.test(tag)) return null; // a bare sha that happens to start with digits
+ if (suffix && !/^[-_.+]/.test(suffix)) return null; // "1abc" isn't a version
+ const nums = core.split('.').map(Number);
+ if (nums.some((n) => !Number.isSafeInteger(n))) return null;
+ const suffixNums = (suffix.match(/\d+/g) || []).map(Number);
+ return {
+ prefix: prefix.toLowerCase(),
+ nums: [...nums, ...suffixNums],
+ major: nums[0],
+ shape: `${prefix.toLowerCase()}|${nums.length}|${suffix.replace(/\d+/g, '#').toLowerCase()}`,
+ };
+}
+
+function compareNums(a, b) {
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
+ const d = (a[i] ?? 0) - (b[i] ?? 0);
+ if (d !== 0) return d;
+ }
+ return 0;
+}
+
+/**
+ * Pure: given the tag a container runs and the registry's tag list, find the
+ * newest comparable tag within the same major version (`sameMajor`) and the
+ * newest comparable tag overall when that's a higher major (`nextMajor`).
+ * Either is null when there's nothing newer.
+ *
+ * @param {string} currentTag
+ * @param {string[]} tags
+ * @returns {{ sameMajor: string|null, nextMajor: string|null }}
+ */
+export function findNewerTags(currentTag, tags) {
+ const cur = parseVersionTag(currentTag);
+ const none = { sameMajor: null, nextMajor: null };
+ if (!cur || !Array.isArray(tags)) return none;
+ let bestSame = null;
+ let bestAny = null;
+ for (const t of tags) {
+ const p = parseVersionTag(t);
+ if (!p || p.shape !== cur.shape || compareNums(p.nums, cur.nums) <= 0) continue;
+ if (p.major === cur.major && (!bestSame || compareNums(p.nums, bestSame.p.nums) > 0)) bestSame = { t, p };
+ if (!bestAny || compareNums(p.nums, bestAny.p.nums) > 0) bestAny = { t, p };
+ }
+ return {
+ sameMajor: bestSame?.t ?? null,
+ nextMajor: bestAny && bestAny.p.major > cur.major ? bestAny.t : null,
+ };
+}
+
+export default { isMeaningfulVersion, parseVersionTag, findNewerTags };
diff --git a/server/test/auth.test.js b/server/test/auth.test.js
index 66ed751..8b71030 100644
--- a/server/test/auth.test.js
+++ b/server/test/auth.test.js
@@ -93,3 +93,32 @@ describe('requireAuth', () => {
assert.equal(nextCalled, true);
});
});
+
+import { logoutAllHandler, setSessionGenerationStore } from '../src/auth.js';
+
+test('sign out everywhere: old cookies stop working, the caller gets a fresh one', () => {
+ let gen = 0;
+ setSessionGenerationStore({ get: () => gen, set: (n) => (gen = n) });
+ try {
+ const before = sessionCookieValue(Date.now() + 60_000);
+ let issued = null;
+ let status = null;
+ const res = {
+ cookie: (_n, v) => (issued = v),
+ status: (s) => ((status = s), { json: () => {} }),
+ };
+ logoutAllHandler(makeReq({ signedCookies: { dockpull_session: before } }), res);
+ assert.equal(status, 200);
+ assert.equal(gen, 1);
+ assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: before } })), false);
+ assert.equal(isValidSession(makeReq({ signedCookies: { dockpull_session: issued } })), true);
+
+ // Without a valid session it refuses and changes nothing.
+ status = null;
+ logoutAllHandler(makeReq({ signedCookies: { dockpull_session: before } }), res);
+ assert.equal(status, 401);
+ assert.equal(gen, 1);
+ } finally {
+ setSessionGenerationStore({ get: () => 0, set: () => {} });
+ }
+});
diff --git a/server/test/backup.test.js b/server/test/backup.test.js
new file mode 100644
index 0000000..31165d8
--- /dev/null
+++ b/server/test/backup.test.js
@@ -0,0 +1,71 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+
+process.env.DATA_DIR = fs.mkdtempSync(path.join(os.tmpdir(), 'dockpull-backup-'));
+const db = await import('../src/db.js');
+const { buildBackup, restoreBackup } = await import('../src/backup.js');
+const { updateSettings, getSettings } = await import('../src/settings.js');
+
+test('backup round-trips settings, pins and history into a fresh install', () => {
+ updateSettings({ scheduledCheckTime: '06:15', tagUpdates: 'major' });
+ db.pin('docker.io/library/postgres:16');
+ db.recordUpdate({ container_name: 'db', image: 'postgres:16', status: 'success', message: 'ok', old_version: '16.3', new_version: '16.4' });
+ const backup = JSON.parse(JSON.stringify(buildBackup({ appVersion: '1.2.3' })));
+ assert.equal(backup.format, 'dockpull-backup');
+ assert.equal(backup.history.length, 1);
+
+ // "New host": wipe everything, then restore.
+ db.clearHistory();
+ db.unpin('docker.io/library/postgres:16');
+ updateSettings({ scheduledCheckTime: '09:00', tagUpdates: 'minor' });
+
+ const r = restoreBackup(backup);
+ assert.equal(r.pinned, 1);
+ assert.equal(r.history, 1);
+ assert.equal(getSettings().scheduledCheckTime, '06:15');
+ assert.equal(getSettings().tagUpdates, 'major');
+ assert.deepEqual(db.getPinned(), ['docker.io/library/postgres:16']);
+ assert.equal(db.getHistory({})[0].new_version, '16.4');
+
+ // Restoring again doesn't duplicate history.
+ const again = restoreBackup(backup);
+ assert.equal(again.history, 0);
+ assert.equal(again.historySkippedBecauseNotEmpty, true);
+ assert.equal(db.countHistory(), 1);
+});
+
+test('restore rejects non-backups and invalid settings without partial writes', () => {
+ assert.throws(() => restoreBackup({ hello: 1 }), { code: 'invalid_backup' });
+ assert.throws(() => restoreBackup({ format: 'dockpull-backup', version: 99 }), { code: 'invalid_backup' });
+ const before = getSettings().scheduledCheckTime;
+ assert.throws(
+ () => restoreBackup({ format: 'dockpull-backup', version: 1, settings: { scheduledCheckTime: '99:99' }, pinned: ['x:1'] }),
+ { code: 'invalid_backup' }
+ );
+ assert.equal(getSettings().scheduledCheckTime, before);
+});
+
+test('restore skips junk pins and history rows', () => {
+ db.clearHistory();
+ const r = restoreBackup({
+ format: 'dockpull-backup',
+ version: 1,
+ pinned: ['', 'redis:7'],
+ history: [{ container_name: 'x' }, { container_name: 'a', image: 'b', status: 'success', created_at: '2026-01-01 00:00:00' }],
+ });
+ assert.equal(r.pinned, 1);
+ assert.equal(r.history, 1);
+ assert.equal(r.skipped, 2);
+});
+
+test('restore with one bad setting writes none of them', () => {
+ const before = getSettings();
+ assert.throws(
+ () => restoreBackup({ format: 'dockpull-backup', version: 1, settings: { scheduledCheckTime: '05:00', tagUpdates: 'nope' } }),
+ { code: 'invalid_backup' }
+ );
+ assert.equal(getSettings().scheduledCheckTime, before.scheduledCheckTime);
+});
diff --git a/server/test/checker.test.js b/server/test/checker.test.js
new file mode 100644
index 0000000..078c0df
--- /dev/null
+++ b/server/test/checker.test.js
@@ -0,0 +1,164 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import fs from 'node:fs';
+import os from 'node:os';
+import path from 'node:path';
+import http from 'node:http';
+
+// End-to-end-ish checker tests: the real runCheck() + listContainers() run
+// against a fake Docker Engine API (unix socket) and a fake registry on
+// 127.0.0.1 (loopback registries are spoken to over http, like Docker does).
+const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'dockpull-checker-'));
+process.env.DOCKER_SOCKET = path.join(tmp, 'docker.sock');
+process.env.DATA_DIR = tmp;
+
+const D = (c) => `sha256:${c.repeat(64)}`;
+
+// --- fake registry ------------------------------------------------------
+const registry = { digests: {}, tags: {}, hits: [] }; // digests["app:1.0.0"] = digest
+const regServer = http.createServer((req, res) => {
+ registry.hits.push(`${req.method} ${req.url}`);
+ const m = /^\/v2\/(.+)\/(manifests|tags)\/(.+?)(\?.*)?$/.exec(req.url);
+ if (!m) return res.writeHead(404).end();
+ const [, repo, kind, rest] = m;
+ if (kind === 'manifests') {
+ const digest = registry.digests[`${repo}:${decodeURIComponent(rest)}`];
+ if (!digest) return res.writeHead(404).end();
+ res.writeHead(200, { 'Docker-Content-Digest': digest, 'Content-Type': 'application/json' });
+ return res.end(req.method === 'HEAD' ? undefined : '{}');
+ }
+ res.writeHead(200, { 'Content-Type': 'application/json' });
+ return res.end(JSON.stringify({ name: repo, tags: registry.tags[repo] || [] }));
+});
+await new Promise((r) => regServer.listen(0, '127.0.0.1', r));
+const REG = `127.0.0.1:${regServer.address().port}`;
+
+// --- fake docker ----------------------------------------------------------
+// containers: { name: { image (ref), imageId } }; images: { id/ref: { RepoDigests, Labels } }
+const docker = { containers: {}, images: {} };
+const dockerServer = http.createServer((req, res) => {
+ const url = new URL(req.url, 'http://d');
+ const p = url.pathname.replace(/^\/v[\d.]+/, '');
+ const json = (code, body) => {
+ res.writeHead(code, { 'Content-Type': 'application/json' });
+ res.end(JSON.stringify(body));
+ };
+ if (p === '/containers/json') {
+ return json(200, Object.keys(docker.containers).map((n) => ({ Id: `id-${n}`, State: 'running' })));
+ }
+ let m = /^\/containers\/(?:id-)?([^/]+)\/json$/.exec(p);
+ if (m && docker.containers[m[1]]) {
+ const c = docker.containers[m[1]];
+ return json(200, {
+ Id: `id-${m[1]}`,
+ Name: `/${m[1]}`,
+ Image: c.imageId,
+ Config: { Image: c.image, Labels: c.labels || {} },
+ State: { Status: 'running', Running: true },
+ });
+ }
+ m = /^\/images\/(.+)\/json$/.exec(p);
+ if (m) {
+ const img = docker.images[decodeURIComponent(m[1])];
+ if (!img) return json(404, { message: 'no such image' });
+ return json(200, { Id: img.Id, RepoDigests: img.RepoDigests || [], Config: { Labels: img.Labels || {} } });
+ }
+ return json(404, { message: `unhandled ${p}` });
+});
+await new Promise((r) => dockerServer.listen(process.env.DOCKER_SOCKET, r));
+
+const { runCheck } = await import('../src/checker.js');
+const db = await import('../src/db.js');
+const { updateSettings } = await import('../src/settings.js');
+
+test.after(() => {
+ regServer.close();
+ dockerServer.close();
+});
+
+function reset() {
+ docker.containers = {};
+ docker.images = {};
+ registry.digests = {};
+ registry.tags = {};
+ registry.hits = [];
+ db.db.exec('DELETE FROM update_events; DELETE FROM tag_updates;');
+}
+
+function addImage(id, { repoDigests = [], labels = {}, tagRef = null } = {}) {
+ docker.images[id] = { Id: id, RepoDigests: repoDigests, Labels: labels };
+ if (tagRef) docker.images[tagRef] = docker.images[id];
+}
+
+test('up to date when the registry digest is ANY of the image\'s repo digests (re-pushed index)', async () => {
+ reset();
+ const ref = `${REG}/app:latest`;
+ addImage(D('1'), { repoDigests: [`${REG}/app@${D('a')}`, `${REG}/app@${D('b')}`], tagRef: ref });
+ docker.containers.web = { image: ref, imageId: D('1') };
+ registry.digests['app:latest'] = D('b'); // the second, lexically later digest
+ const r = await runCheck();
+ assert.equal(r.updatesFound, 0);
+ assert.equal(r.errors, 0);
+ assert.equal(db.latestUnresolvedEventForRef(`${REG}/app:latest`), undefined);
+});
+
+test('a genuinely new digest is recorded as an update, once', async () => {
+ reset();
+ const ref = `${REG}/app:latest`;
+ addImage(D('1'), { repoDigests: [`${REG}/app@${D('a')}`], tagRef: ref });
+ docker.containers.web = { image: ref, imageId: D('1') };
+ registry.digests['app:latest'] = D('c');
+ assert.equal((await runCheck()).updatesFound, 1);
+ assert.equal(db.latestUnresolvedEventForRef(`${REG}/app:latest`).digest, D('c'));
+ assert.equal((await runCheck()).updatesFound, 0, 'not re-recorded on the next check');
+});
+
+test('a container left on an untagged image (sibling updated first) is still flagged', async () => {
+ reset();
+ const ref = `${REG}/app:latest`;
+ // The tag now points at the new image; "old" lost its tag and RepoDigests
+ // (containerd image store), but container "b" still runs it.
+ addImage(D('2'), { repoDigests: [`${REG}/app@${D('n')}`], tagRef: ref });
+ addImage(D('1'), { repoDigests: [] });
+ docker.containers.a = { image: ref, imageId: D('2') };
+ docker.containers.b = { image: ref, imageId: D('1') };
+ registry.digests['app:latest'] = D('n');
+ const r = await runCheck();
+ assert.equal(r.updatesFound, 1);
+});
+
+test('a locally built image (no registry digest) is skipped without a registry call', async () => {
+ reset();
+ addImage(D('3'), { repoDigests: [], tagRef: 'mylocal:dev' });
+ docker.containers.local = { image: 'mylocal:dev', imageId: D('3') };
+ const r = await runCheck();
+ assert.deepEqual([r.total, r.checked, r.updatesFound, r.errors], [1, 1, 0, 0]);
+ assert.equal(registry.hits.length, 0);
+});
+
+test('newer version tags are found for version-tagged containers', async () => {
+ reset();
+ updateSettings({ tagUpdates: 'major' });
+ const ref = `${REG}/app:1.0.0`;
+ addImage(D('4'), { repoDigests: [`${REG}/app@${D('d')}`], tagRef: ref });
+ docker.containers.svc = { image: ref, imageId: D('4') };
+ registry.digests['app:1.0.0'] = D('d');
+ registry.tags.app = ['1.0.0', '1.0.1', '1.1.0', '1.2.0-rc1', '2.0.0', 'latest'];
+ await runCheck();
+ const row = db.getTagUpdate(`${REG}/app:1.0.0`);
+ assert.equal(row.same_major, '1.1.0');
+ assert.equal(row.next_major, '2.0.0');
+});
+
+test('concurrent checks share one run (no duplicate registry calls or events)', async () => {
+ reset();
+ const ref = `${REG}/app:latest`;
+ addImage(D('1'), { repoDigests: [`${REG}/app@${D('a')}`], tagRef: ref });
+ docker.containers.web = { image: ref, imageId: D('1') };
+ registry.digests['app:latest'] = D('e');
+ const [r1, r2] = await Promise.all([runCheck(), runCheck()]);
+ assert.equal(r1, r2);
+ assert.equal(registry.hits.filter((h) => h.startsWith('HEAD')).length, 1);
+ const n = db.db.prepare('SELECT COUNT(*) AS n FROM update_events WHERE resolved = 0').get().n;
+ assert.equal(n, 1);
+});
diff --git a/server/test/compose-file.test.js b/server/test/compose-file.test.js
new file mode 100644
index 0000000..85f24c7
--- /dev/null
+++ b/server/test/compose-file.test.js
@@ -0,0 +1,55 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { rewriteServiceImageTag, restoreServiceImage, findServiceImage } from '../src/compose-file.js';
+
+const file = `# my stack
+services:
+ db:
+ image: postgres:16.3 # keep this comment
+ environment:
+ web: not-a-service
+ POSTGRES_PASSWORD: x
+ web:
+ image: "ghcr.io/acme/web:1.2.0"
+ depends_on: [db]
+
+volumes:
+ data:
+`;
+
+test('rewrites only the target service tag, preserving everything else', () => {
+ const r = rewriteServiceImageTag(file, 'db', 'docker.io/library/postgres:16.3', '16.4');
+ assert.equal(r.oldValue, 'postgres:16.3');
+ assert.equal(r.newValue, 'postgres:16.4');
+ assert.equal(r.text, file.replace('postgres:16.3', 'postgres:16.4'));
+});
+
+test('keeps quotes and finds the real service, not a nested key with the same name', () => {
+ const r = rewriteServiceImageTag(file, 'web', 'ghcr.io/acme/web:1.2.0', '1.3.0');
+ assert.equal(r.text, file.replace('"ghcr.io/acme/web:1.2.0"', '"ghcr.io/acme/web:1.3.0"'));
+});
+
+test('refuses variables, digests, mismatches and missing image lines', () => {
+ const v = 'services:\n app:\n image: app:${TAG}\n';
+ assert.throws(() => rewriteServiceImageTag(v, 'app', 'app:1', '2'), { code: 'image_uses_variable' });
+ const d = `services:\n app:\n image: app@sha256:${'a'.repeat(64)}\n`;
+ assert.throws(() => rewriteServiceImageTag(d, 'app', 'app:1', '2'), { code: 'image_pinned_by_digest' });
+ assert.throws(() => rewriteServiceImageTag(file, 'db', 'postgres:15', '16.4'), { code: 'image_mismatch' });
+ const anchor = 'x-base: &base\n image: app:1\nservices:\n app:\n <<: *base\n';
+ assert.throws(() => rewriteServiceImageTag(anchor, 'app', 'app:1', '2'), { code: 'image_not_found' });
+ assert.throws(() => rewriteServiceImageTag(file, 'nope', 'postgres:16.3', '16.4'), { code: 'image_not_found' });
+});
+
+test('handles registries with ports and tabs-free 4-space indentation', () => {
+ const f = 'services:\n reg:\n image: localhost:5000/team/app:1.0\n';
+ const r = rewriteServiceImageTag(f, 'reg', 'localhost:5000/team/app:1.0', '1.1');
+ assert.equal(r.text, 'services:\n reg:\n image: localhost:5000/team/app:1.1\n');
+});
+
+test('restoreServiceImage: undoes the switch, but never clobbers a later edit', () => {
+ const switched = rewriteServiceImageTag(file, 'db', 'postgres:16.3', '16.4').text;
+ assert.equal(restoreServiceImage(switched, 'db', 'postgres:16.4', 'postgres:16.3'), file);
+ const handEdited = switched.replace('postgres:16.4', 'postgres:17');
+ assert.equal(restoreServiceImage(handEdited, 'db', 'postgres:16.4', 'postgres:16.3'), null);
+ assert.equal(findServiceImage(file, 'db').value, 'postgres:16.3');
+});
diff --git a/server/test/containers-service.test.js b/server/test/containers-service.test.js
index ce45617..364a261 100644
--- a/server/test/containers-service.test.js
+++ b/server/test/containers-service.test.js
@@ -159,6 +159,8 @@ describe('buildContainerItems', () => {
availableVersion: null,
breakingRisk: false,
skipped: false,
+ newerTag: null,
+ newerMajorTag: null,
pinned: false,
canRevert: false,
rollbackVersion: null,
@@ -239,3 +241,25 @@ describe('buildContainerItems: image known under several repo digests', () => {
assert.equal(items[0].updateAvailable, true);
});
});
+
+describe('buildContainerItems: newer version tags', () => {
+ const row = { same_major: '1.3.0', next_major: '2.0.0', dismissed_tag: null };
+ const build = (opts) =>
+ buildContainerItems({ containers: [makeContainer()], lookupEvent: () => undefined, isPinned: () => false, ...opts }).items[0];
+
+ test('minor policy shows only the same-major tag; major shows both; off shows none', () => {
+ assert.deepEqual(
+ [build({ getTagUpdate: () => row }).newerTag, build({ getTagUpdate: () => row }).newerMajorTag],
+ ['1.3.0', null]
+ );
+ const major = build({ getTagUpdate: () => row, tagPolicy: 'major' });
+ assert.deepEqual([major.newerTag, major.newerMajorTag], ['1.3.0', '2.0.0']);
+ const off = build({ getTagUpdate: () => row, tagPolicy: 'off' });
+ assert.deepEqual([off.newerTag, off.newerMajorTag], [null, null]);
+ });
+
+ test('a skipped tag stays hidden', () => {
+ const item = build({ getTagUpdate: () => ({ ...row, dismissed_tag: '1.3.0' }), tagPolicy: 'major' });
+ assert.deepEqual([item.newerTag, item.newerMajorTag], [null, '2.0.0']);
+ });
+});
diff --git a/server/test/docker.test.js b/server/test/docker.test.js
index 16e14ad..bc36213 100644
--- a/server/test/docker.test.js
+++ b/server/test/docker.test.js
@@ -151,3 +151,25 @@ test('buildRecreateOptions: revert labels never carry over to the next container
);
assert.deepEqual(o.Labels, { 'my.label': 'x' });
});
+
+import { healthWaitPlan } from '../src/docker.js';
+
+test('healthWaitPlan: no healthcheck -> must stay up a few seconds', () => {
+ for (const hc of [undefined, null, {}, { Test: ['NONE'] }]) {
+ const p = healthWaitPlan(hc);
+ assert.equal(p.hasHealthcheck, false);
+ assert.ok(p.stableMs >= 5000);
+ }
+});
+
+test('healthWaitPlan: waits as long as the container\'s own healthcheck needs', () => {
+ const s = 1e9; // ns per second
+ const slow = healthWaitPlan({ Test: ['CMD', 'true'], Interval: 10 * s, Timeout: 5 * s, Retries: 5, StartPeriod: 120 * s });
+ // start_period 120s + (10s + 5s) * 6 probes + 10s slack = 220s
+ assert.equal(slow.timeoutMs, 220_000);
+ // Docker defaults (interval/timeout 30s, retries 3) -> 250s
+ assert.equal(healthWaitPlan({ Test: ['CMD-SHELL', 'curl -f localhost'] }).timeoutMs, 250_000);
+ // Capped at 15 minutes, floored at 30s.
+ assert.equal(healthWaitPlan({ Test: ['CMD', 'x'], StartPeriod: 3600 * s }).timeoutMs, 900_000);
+ assert.equal(healthWaitPlan({ Test: ['CMD', 'x'], Interval: 1 * s, Timeout: 1 * s, Retries: 1 }).timeoutMs, 30_000);
+});
diff --git a/server/test/known-sources.test.js b/server/test/known-sources.test.js
new file mode 100644
index 0000000..c6dd14f
--- /dev/null
+++ b/server/test/known-sources.test.js
@@ -0,0 +1,22 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { knownSourceFor } from '../src/known-sources.js';
+
+test('knownSourceFor: table entries, any spelling of the ref', () => {
+ assert.equal(knownSourceFor('redis:7'), 'https://github.com/redis/redis');
+ assert.equal(knownSourceFor('docker.io/library/redis:7-alpine'), 'https://github.com/redis/redis');
+ assert.equal(knownSourceFor('vaultwarden/server:latest'), 'https://github.com/dani-garcia/vaultwarden');
+ assert.equal(knownSourceFor('ghcr.io/immich-app/immich-server:release'), 'https://github.com/immich-app/immich');
+});
+
+test('knownSourceFor: linuxserver images on every registry', () => {
+ for (const img of ['lscr.io/linuxserver/sonarr:latest', 'linuxserver/sonarr', 'ghcr.io/linuxserver/sonarr:4']) {
+ assert.equal(knownSourceFor(img), 'https://github.com/linuxserver/docker-sonarr', img);
+ }
+});
+
+test('knownSourceFor: unknown images get nothing', () => {
+ assert.equal(knownSourceFor('postgres:16'), null);
+ assert.equal(knownSourceFor('ghcr.io/someone/thing:1'), null);
+ assert.equal(knownSourceFor('lscr.io/other/sonarr'), null);
+});
diff --git a/server/test/notify.test.js b/server/test/notify.test.js
index 2822c4d..da7f97a 100644
--- a/server/test/notify.test.js
+++ b/server/test/notify.test.js
@@ -98,3 +98,36 @@ test('sendUpdates/sendTest (ntfy): emoji titles are delivered, not thrown', asyn
server.close();
}
});
+
+import { buildFailureMessage, sendFailure } from '../src/notify.js';
+
+test('buildFailureMessage: names the container and trims long output', () => {
+ const m = buildFailureMessage({ name: 'db', image: 'postgres:16', message: Array(20).fill('line').join('\n') });
+ assert.match(m.title, /Update of db failed/);
+ assert.equal(m.body.split('\n').length, 7); // image + 6 lines
+ assert.match(buildFailureMessage({ name: 'x', action: 'revert' }).title, /Revert of x failed/);
+});
+
+test('sendFailure: every target type delivers (ntfy emoji title via query)', async () => {
+ const got = [];
+ const server = http.createServer((req, res) => {
+ let b = '';
+ req.on('data', (c) => (b += c));
+ req.on('end', () => {
+ got.push({ url: req.url, body: b });
+ res.end('ok');
+ });
+ });
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
+ const url = `http://127.0.0.1:${server.address().port}/x`;
+ try {
+ for (const type of ['discord', 'ntfy', 'gotify', 'webhook']) {
+ const r = await sendFailure(type, url, { name: 'db', image: 'postgres:16', message: 'boom' });
+ assert.equal(r.ok, true, type);
+ }
+ assert.match(new URL(got[1].url, 'http://x').searchParams.get('title'), /Update of db failed/);
+ assert.match(got[0].body, /boom/);
+ } finally {
+ server.close();
+ }
+});
diff --git a/server/test/registry.test.js b/server/test/registry.test.js
index d7a5248..8c13f18 100644
--- a/server/test/registry.test.js
+++ b/server/test/registry.test.js
@@ -56,3 +56,16 @@ test('registryBaseUrl: loopback registries use http (like Docker), everything el
assert.equal(registryBaseUrl('registry.local:5000'), 'https://registry.local:5000');
assert.equal(registryBaseUrl('localhost.evil.com'), 'https://localhost.evil.com');
});
+
+import { nextPageUrl } from '../src/registry.js';
+
+test('nextPageUrl: follows same-origin rel=next links only', () => {
+ const base = 'https://registry-1.docker.io';
+ assert.equal(
+ nextPageUrl('; rel="next"', base),
+ 'https://registry-1.docker.io/v2/library/postgres/tags/list?last=16.3&n=1000'
+ );
+ assert.equal(nextPageUrl('; rel="next"', base), null);
+ assert.equal(nextPageUrl(null, base), null);
+ assert.equal(nextPageUrl('; rel="prev"', base), null);
+});
diff --git a/server/test/scheduler.test.js b/server/test/scheduler.test.js
index 9457cc8..fd60ee0 100644
--- a/server/test/scheduler.test.js
+++ b/server/test/scheduler.test.js
@@ -67,3 +67,28 @@ test('selectNotifyTargets: a normalizeRef failure for one item does not throw',
assert.deepEqual(toNotify.map((i) => i.image), ['bad-ref']);
assert.equal(hasNew, false);
});
+
+const { planNextRun } = await import('../src/scheduler.js');
+
+test('planNextRun: daily waits for the time; catches up on startup if a run was missed', () => {
+ const now = new Date(2026, 9, 5, 12, 0, 0); // 12:00 local
+ const daily = { scheduleMode: 'daily', scheduledCheckTime: '09:00', scheduleIntervalHours: 6 };
+ const nextNine = 21 * 3600 * 1000;
+ assert.equal(planNextRun(daily, { now, lastCheckAt: now.getTime() - 3600_000 }), nextNine);
+ // Server was down at 09:00 (last check yesterday): run a minute after startup.
+ const yesterday = new Date(2026, 9, 4, 9, 0, 5).getTime();
+ assert.equal(planNextRun(daily, { now, lastCheckAt: yesterday, startup: true }), 60_000);
+ // Already checked after 09:00 today: no catch-up.
+ const after = new Date(2026, 9, 5, 9, 30).getTime();
+ assert.equal(planNextRun(daily, { now, lastCheckAt: after, startup: true }), nextNine);
+ // Never checked: catch up.
+ assert.equal(planNextRun(daily, { now, startup: true }), 60_000);
+});
+
+test('planNextRun: interval counts from the last check, overdue runs soon', () => {
+ const now = new Date(2026, 9, 5, 12, 0, 0);
+ const every6 = { scheduleMode: 'interval', scheduledCheckTime: '09:00', scheduleIntervalHours: 6 };
+ assert.equal(planNextRun(every6, { now, lastCheckAt: now.getTime() - 2 * 3600_000 }), 4 * 3600_000);
+ assert.equal(planNextRun(every6, { now, lastCheckAt: now.getTime() - 10 * 3600_000 }), 60_000);
+ assert.equal(planNextRun(every6, { now }), 60_000);
+});
diff --git a/server/test/self-update.test.js b/server/test/self-update.test.js
new file mode 100644
index 0000000..1337f28
--- /dev/null
+++ b/server/test/self-update.test.js
@@ -0,0 +1,17 @@
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { newerSelfReleases } from '../src/self-update.js';
+
+const rel = (tag, extra = {}) => ({ tag_name: tag, html_url: `u/${tag}`, ...extra });
+
+test('newerSelfReleases: only stable releases newer than the running version, newest first', () => {
+ const releases = [rel('v1.8.0'), rel('v1.9.0-rc.1', { prerelease: true }), rel('v1.7.2'), rel('v1.7.1'), rel('v2.0.0', { draft: true })];
+ assert.deepEqual(newerSelfReleases(releases, '1.7.1').map((r) => r.tag_name), ['v1.8.0', 'v1.7.2']);
+ assert.deepEqual(newerSelfReleases(releases, '1.8.0'), []);
+});
+
+test('newerSelfReleases: unknown/dev versions and bad input never show a banner', () => {
+ assert.deepEqual(newerSelfReleases([rel('v9.9.9')], 'unknown'), []);
+ assert.deepEqual(newerSelfReleases(null, '1.0.0'), []);
+ assert.deepEqual(newerSelfReleases([rel('nightly')], '1.0.0'), []);
+});
diff --git a/server/test/settings.test.js b/server/test/settings.test.js
index 07dbf86..0fc6c89 100644
--- a/server/test/settings.test.js
+++ b/server/test/settings.test.js
@@ -17,8 +17,12 @@ test('settings: defaults when nothing stored', () => {
autoCheckOnOpen: true,
backgroundCheckEnabled: true,
scheduledCheckTime: '09:00',
+ scheduleMode: 'daily',
+ scheduleIntervalHours: 6,
+ notifyOnFailure: true,
discordEnabled: false,
discordWebhookUrl: '',
+ tagUpdates: 'minor',
notifyType: 'discord',
});
});
@@ -52,3 +56,11 @@ test('settings: webhook url validated, empty allowed', () => {
assert.throws(() => updateSettings({ discordWebhookUrl: 'not-a-url' }), /invalid value/);
assert.equal(updateSettings({ discordWebhookUrl: '' }).discordWebhookUrl, '');
});
+
+test('settings: schedule interval and tag policy are validated', () => {
+ assert.equal(updateSettings({ scheduleMode: 'interval', scheduleIntervalHours: 12 }).scheduleIntervalHours, 12);
+ assert.throws(() => updateSettings({ scheduleIntervalHours: 0 }), /invalid value/);
+ assert.throws(() => updateSettings({ scheduleIntervalHours: 2.5 }), /invalid value/);
+ assert.throws(() => updateSettings({ tagUpdates: 'all' }), /invalid value/);
+ assert.equal(updateSettings({ tagUpdates: 'major' }).tagUpdates, 'major');
+});
diff --git a/server/test/version.test.js b/server/test/version.test.js
index ba08e47..c23c265 100644
--- a/server/test/version.test.js
+++ b/server/test/version.test.js
@@ -27,3 +27,31 @@ test('isMeaningfulVersion: rejects empty / non-strings', () => {
assert.equal(isMeaningfulVersion(undefined), false);
assert.equal(isMeaningfulVersion(123), false);
});
+
+import { parseVersionTag, findNewerTags } from '../src/version.js';
+
+test('parseVersionTag: versions vs non-versions', () => {
+ assert.equal(parseVersionTag('latest'), null);
+ assert.equal(parseVersionTag('main'), null);
+ assert.equal(parseVersionTag('a1b2c3d'), null);
+ assert.equal(parseVersionTag('1234abcd'), null);
+ assert.deepEqual(parseVersionTag('v1.2.3').nums, [1, 2, 3]);
+ assert.equal(parseVersionTag('16.3-alpine').shape, parseVersionTag('16.4-alpine').shape);
+ assert.notEqual(parseVersionTag('16.3-alpine').shape, parseVersionTag('16.4').shape);
+ assert.notEqual(parseVersionTag('1.2.3').shape, parseVersionTag('1.2.4-rc1').shape);
+});
+
+test('findNewerTags: same-major update and next major, shape-matched', () => {
+ const tags = ['16.2', '16.3', '16.4', '16.10', '17.0', '17.1', '16.4-alpine', '17.0-alpine', '16.5-rc1', 'latest', '16', '17'];
+ assert.deepEqual(findNewerTags('16.3', tags), { sameMajor: '16.10', nextMajor: '17.1' });
+ assert.deepEqual(findNewerTags('16.3-alpine', tags), { sameMajor: '16.4-alpine', nextMajor: '17.0-alpine' });
+ assert.deepEqual(findNewerTags('16', tags), { sameMajor: null, nextMajor: '17' }); // floating major tag
+ assert.deepEqual(findNewerTags('17.1', tags), { sameMajor: null, nextMajor: null });
+ assert.deepEqual(findNewerTags('latest', tags), { sameMajor: null, nextMajor: null });
+});
+
+test('findNewerTags: linuxserver-style build suffixes and v-prefixes', () => {
+ const ls = ['4.0.14.2939-ls283', '4.0.14.2939-ls284', '4.0.15.2941-ls285', '5.0.0.1-ls1', 'develop'];
+ assert.deepEqual(findNewerTags('4.0.14.2939-ls283', ls), { sameMajor: '4.0.15.2941-ls285', nextMajor: '5.0.0.1-ls1' });
+ assert.deepEqual(findNewerTags('v1.2.0', ['v1.2.1', '1.3.0', 'v2.0.0']), { sameMajor: 'v1.2.1', nextMajor: 'v2.0.0' });
+});