From 8f414db3b4bd60dd0bf16e2751e8f6d08faa770b Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 09:03:24 +0000 Subject: [PATCH 1/2] wip: tag updates, health timing, scheduling, failure notify, sign-out-all, backups, self-update banner Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KiL5R3bRpvGk6QP3E21eHF --- client/src/App.jsx | 2 + client/src/Dashboard.jsx | 18 +- client/src/api.js | 26 ++ client/src/components/SelfUpdateBanner.jsx | 117 +++++++ client/src/components/UpdateCard.jsx | 71 +++- client/src/hooks/useUpdateRunner.js | 8 +- client/src/pages/SettingsPage.jsx | 359 +++++++++++++++++---- client/src/styles/app.css | 110 +++++++ server/src/auth.js | 46 ++- server/src/backup.js | 123 +++++++ server/src/checker.js | 44 ++- server/src/compose-file.js | 149 +++++++++ server/src/containers-service.js | 11 + server/src/db.js | 100 +++++- server/src/docker.js | 211 ++++++++++-- server/src/index.js | 18 +- server/src/known-sources.js | 84 +++++ server/src/notify.js | 43 +++ server/src/registry.js | 52 ++- server/src/routes/api.js | 58 ++++ server/src/routes/update.js | 91 +++++- server/src/scheduler.js | 65 +++- server/src/self-update.js | 63 ++++ server/src/settings.js | 38 ++- server/src/version.js | 74 ++++- server/test/auth.test.js | 29 ++ server/test/backup.test.js | 71 ++++ server/test/checker.test.js | 164 ++++++++++ server/test/compose-file.test.js | 55 ++++ server/test/containers-service.test.js | 24 ++ server/test/docker.test.js | 22 ++ server/test/known-sources.test.js | 22 ++ server/test/notify.test.js | 33 ++ server/test/registry.test.js | 13 + server/test/scheduler.test.js | 25 ++ server/test/self-update.test.js | 17 + server/test/settings.test.js | 12 + server/test/version.test.js | 28 ++ 38 files changed, 2374 insertions(+), 122 deletions(-) create mode 100644 client/src/components/SelfUpdateBanner.jsx create mode 100644 server/src/backup.js create mode 100644 server/src/compose-file.js create mode 100644 server/src/known-sources.js create mode 100644 server/src/self-update.js create mode 100644 server/test/backup.test.js create mode 100644 server/test/checker.test.js create mode 100644 server/test/compose-file.test.js create mode 100644 server/test/known-sources.test.js create mode 100644 server/test/self-update.test.js diff --git a/client/src/App.jsx b/client/src/App.jsx index 4f873ba..31c4ee4 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -6,6 +6,7 @@ import AuthPage from './AuthPage.jsx'; import Dashboard from './Dashboard.jsx'; import Header from './components/Header.jsx'; import BottomNav from './components/BottomNav.jsx'; +import SelfUpdateBanner from './components/SelfUpdateBanner.jsx'; import HistoryPage from './pages/HistoryPage.jsx'; import SettingsPage from './pages/SettingsPage.jsx'; @@ -87,6 +88,7 @@ export default function App() {
+ } /> } /> diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index aad7b54..290f3ec 100644 --- a/client/src/Dashboard.jsx +++ b/client/src/Dashboard.jsx @@ -19,7 +19,14 @@ function timeAgo(ts) { return `${Math.round(h / 24)}d ago`; } +// Anything actionable: a newer build of the same tag, or a newer version tag. function hasUpdate(c) { + return (c.updateAvailable || Boolean(c.newerTag || c.newerMajorTag)) && !c.pinned; +} + +// What "Update all" applies: same-tag updates only. Switching to a newer tag +// (especially a new major) is a deliberate, per-container choice. +function hasDigestUpdate(c) { return c.updateAvailable && !c.pinned; } @@ -211,13 +218,14 @@ export default function Dashboard({ onPendingCountChange }) { const mainItems = useMemo(() => visible.filter((c) => !c.pinned), [visible]); const pendingTargets = useMemo( - () => mainItems.filter(hasUpdate).map((c) => ({ name: c.name, project: c.project })), + () => mainItems.filter(hasDigestUpdate).map((c) => ({ name: c.name, project: c.project })), [mainItems] ); + const pendingCount = useMemo(() => mainItems.filter(hasUpdate).length, [mainItems]); useEffect(() => { - if (onPendingCountChange) onPendingCountChange(pendingTargets.length); - }, [pendingTargets, onPendingCountChange]); + if (onPendingCountChange) onPendingCountChange(pendingCount); + }, [pendingCount, onPendingCountChange]); // Apply the filter chip + search needle, then group by stack (compose // project); groups with updates come first. @@ -268,8 +276,8 @@ export default function Dashboard({ onPendingCountChange }) {

Containers

- {pendingTargets.length > 0 ? ( - {pendingTargets.length} + {pendingCount > 0 ? ( + {pendingCount} ) : ( !loading && 0 )} diff --git a/client/src/api.js b/client/src/api.js index f3b6a55..40f9651 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -95,6 +95,11 @@ export function logout() { return post('/auth/logout'); } +// Sign out every other session (this one gets a fresh cookie). +export function logoutAll() { + return post('/auth/logout-all'); +} + // --- Containers / updates --- export function getContainers() { @@ -174,6 +179,27 @@ export function unskipUpdate(ref) { return del(`/skip/${encodeURIComponent(ref)}`); } +// Move a container to a newer version tag the last check found. +export function switchTag(name, tag) { + return post(`/update/${encodeURIComponent(name)}/switch-tag`, { tag }); +} + +// Hide an offered newer tag until an even newer one appears (tag null clears). +export function skipTag(ref, tag) { + return post('/skip-tag', { ref, tag }); +} + +// --- Backup / restore --- (download is a plain link to `${API_BASE}/backup`) + +export function restoreBackup(backup) { + return post('/restore', backup); +} + +// Is a newer DockPull out? { current, available, latest?, releaseUrl?, releases? } +export function getSelfUpdate() { + return get('/self-update'); +} + // --- Settings --- export function getSettings() { diff --git a/client/src/components/SelfUpdateBanner.jsx b/client/src/components/SelfUpdateBanner.jsx new file mode 100644 index 0000000..0973a7e --- /dev/null +++ b/client/src/components/SelfUpdateBanner.jsx @@ -0,0 +1,117 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { getSelfUpdate } from '../api.js'; + +const DISMISS_KEY = 'dockpull.selfUpdate.dismissed'; +const UPDATE_COMMAND = 'docker compose pull dockpull && docker compose up -d dockpull'; + +/** + * "A newer DockPull is available" banner. Read-only on purpose: DockPull never + * updates its own container (that would recreate the process doing the + * update), so this explains what changed and how to update by hand. Dismissed + * per version; any failure to check just shows nothing. + */ +export default function SelfUpdateBanner() { + const [info, setInfo] = useState(null); + const [open, setOpen] = useState(null); // null | 'notes' | 'how' + const [copied, setCopied] = useState(false); + const [dismissed, setDismissed] = useState(() => { + try { + return localStorage.getItem(DISMISS_KEY) || ''; + } catch { + return ''; + } + }); + + useEffect(() => { + let cancelled = false; + getSelfUpdate() + .then((d) => { + if (!cancelled && d?.available) setInfo(d); + }) + .catch(() => {}); + return () => { + cancelled = true; + }; + }, []); + + const dismiss = useCallback(() => { + if (!info) return; + try { + localStorage.setItem(DISMISS_KEY, info.latest); + } catch { + // private mode etc. — just hide for this page view + } + setDismissed(info.latest); + }, [info]); + + const copy = useCallback(async () => { + try { + await navigator.clipboard.writeText(UPDATE_COMMAND); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // clipboard blocked (http, permissions) — the command is visible to copy by hand + } + }, []); + + if (!info || dismissed === info.latest) return null; + + return ( +
+
+ + DockPull {info.latest} is available — you're on {info.current}. + + + + + + +
+ + {open === 'how' && ( +
+

+ DockPull doesn't update itself (replacing the container it runs in would cut the update + off half-way). On your server, in the folder with DockPull's compose file, run: +

+
+ {UPDATE_COMMAND} + +
+

+ Using Dockge? Open DockPull's stack and press Update. Built from source? + Run git pull && docker compose up -d --build. Your settings and history + are kept. +

+
+ )} + + {open === 'notes' && ( +
+ {info.releases.map((r) => ( +
+
+ + {r.tag} + + {r.publishedAt && ( + {new Date(r.publishedAt).toLocaleDateString()} + )} +
+ {r.body &&
{r.body}
} +
+ ))} +
+ )} +
+ ); +} diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index d65da43..e54bc40 100644 --- a/client/src/components/UpdateCard.jsx +++ b/client/src/components/UpdateCard.jsx @@ -1,5 +1,5 @@ import React, { useCallback, useEffect, useState } from 'react'; -import { pin, unpin, skipUpdate, unskipUpdate, getChangelog } from '../api.js'; +import { pin, unpin, skipUpdate, unskipUpdate, skipTag, getChangelog } from '../api.js'; import { useUpdateRunner } from '../hooks/useUpdateRunner.js'; import StatusMessage from './StatusMessage.jsx'; import StreamLog from './StreamLog.jsx'; @@ -147,7 +147,7 @@ function ChangelogContent({ data }) { * - registerRunner(name, runFn) — handle for "Update all" */ export default function UpdateCard({ container, onSettled, onPinChange, registerRunner }) { - const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, breakingRisk, skipped, pinned, sourceUrl, canRevert, rollbackVersion, checkError, state } = + const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, breakingRisk, skipped, newerTag, newerMajorTag, pinned, sourceUrl, canRevert, rollbackVersion, checkError, state, composeFile } = container; const [pinBusy, setPinBusy] = useState(false); @@ -159,7 +159,8 @@ export default function UpdateCard({ container, onSettled, onPinChange, register const [clData, setClData] = useState(null); const [clError, setClError] = useState(''); - const { run, revert, busy, startError, status, lines } = useUpdateRunner(name, onSettled); + const { run, revert, switchTo, busy, startError, status, lines } = useUpdateRunner(name, onSettled); + const [confirmTag, setConfirmTag] = useState(null); useEffect(() => { if (registerRunner) registerRunner(name, run); @@ -215,6 +216,22 @@ export default function UpdateCard({ container, onSettled, onPinChange, register } }, [skipped, image, onPinChange]); + const dismissTag = useCallback( + async (tag) => { + setPinBusy(true); + setActionError(''); + try { + await skipTag(image, tag); + if (onPinChange) onPinChange(); + } catch (err) { + setActionError(err.message || 'Failed to skip version'); + } finally { + setPinBusy(false); + } + }, + [image, onPinChange] + ); + const toggleChangelog = useCallback(async () => { const next = !clOpen; setClOpen(next); @@ -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} + + + + + +
+ ))} +
+ )} + {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. + +
+ +

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}
- 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(() => {})} +
+ {settings?.scheduleMode === 'interval' ? ( +
+
+ Scan every + Hours between scans (1–168). +
+ +
+ ) : ( +
+
+ 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.
+
+
+ Notify on failures + + Also send a message when an update or revert fails, or comes up unhealthy. + +
+ +