From 0cebe5895c001cf3bbdbf42f444794138f1b7723 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 15 Jul 2026 21:09:02 +0000 Subject: [PATCH] feat: name containers in prune summary; badge when cleanup is available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The prune confirmation dialog now names which containers a dangling image came from, not just a count/size. Docker itself doesn't preserve that lineage once an image is untagged, but DockPull already tracks it in the rollback_points table (written on every update, for the revert feature) — GET /api/images/dangling now cross-references each dangling image's ID against it and attaches a fromContainer field, null when unknown (images predating the current rollback point, or pulled outside DockPull). The daily background check now also looks for prunable image layers and persists the result, exposed via GET /api/status. The client picks this up once on load and shows a small dot next to "Settings" in both nav bars and next to "Maintenance" in Settings itself, so cleanup being available is visible without opening the section. Clears immediately on a successful prune rather than waiting for the next scheduled check. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_013Lj6nYJQDtLaZFvvEQJGM4 --- API_CONTRACT.md | 22 +++++++++++++++-- client/src/App.jsx | 22 ++++++++++++++--- client/src/components/BottomNav.jsx | 7 ++++-- client/src/components/Header.jsx | 3 ++- client/src/pages/SettingsPage.jsx | 38 ++++++++++++++++++++++++----- client/src/styles/app.css | 12 +++++++++ server/src/db.js | 13 ++++++++++ server/src/docker.js | 14 ++++++++++- server/src/routes/api.js | 22 +++++++++++++++-- server/src/scheduler.js | 16 +++++++++++- server/test/docker.test.js | 20 +++++++++++++++ 11 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 server/test/docker.test.js diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 09d4477..ec678fc 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -40,6 +40,20 @@ All request/response bodies are JSON unless noted otherwise. - Auth: cookie (optional — never errors, reports status). - Response: `200 { "authenticated": boolean }` +### `GET /api/status` + +- Auth: cookie. +- App version + background info for the About panel and nav badges. +- Response: `200` — + `{ "version": string, "lastCheck": object|null, "danglingImages": { "count": number, "totalSize": number, "checkedAt": number }|null, "serverTime": string, "timeZone": string, "serverLocalTime": string }` + - `lastCheck` is the summary of the most recent update check (manual or + scheduled), or `null` before the first one runs. + - `danglingImages` is set once a day by the scheduled background check + (see `POST /api/check` scheduling in Settings) — `null` until the first + scheduled run has happened. `checkedAt` is a `Date.now()` epoch ms. + Best-effort: a failed lookup (e.g. Docker unavailable) just leaves the + previous value in place rather than clearing it. + ### `GET /api/containers` - Auth: cookie. @@ -149,8 +163,12 @@ All request/response bodies are JSON unless noted otherwise. deleting anything, for a confirmation dialog to summarize before the user commits to pruning. - Response: `200` — - `{ "count": number, "totalSize": number, "images": [{ "id": string, "size": number, "created": number|null }] }` - where `totalSize` is in bytes and `id` is a short (12-char) image ID. + `{ "count": number, "totalSize": number, "images": [{ "id": string, "size": number, "created": number|null, "fromContainer": string|null }] }` + where `totalSize` is in bytes, `id` is a short (12-char) image ID, and + `fromContainer` is the name of the container this image was replaced on + (via its remembered rollback point), or `null` when that's unknown — + images left over from before the container's most recent update, or + pulled outside DockPull, aren't attributed. - `503 { "error": "docker_unavailable" }` when the Docker daemon is unreachable. diff --git a/client/src/App.jsx b/client/src/App.jsx index a73af05..4f873ba 100644 --- a/client/src/App.jsx +++ b/client/src/App.jsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Routes, Route, Navigate, useNavigate } from 'react-router-dom'; -import { getMe, setUnauthorizedHandler } from './api.js'; +import { getMe, getStatus, setUnauthorizedHandler } from './api.js'; import { useTheme } from './hooks/useTheme.js'; import AuthPage from './AuthPage.jsx'; import Dashboard from './Dashboard.jsx'; @@ -18,6 +18,7 @@ export default function App() { const [loading, setLoading] = useState(true); const [authenticated, setAuthenticated] = useState(false); const [pendingCount, setPendingCount] = useState(0); + const [needsPruning, setNeedsPruning] = useState(false); const checkSession = useCallback(async () => { try { @@ -33,6 +34,16 @@ export default function App() { checkSession().finally(() => setLoading(false)); }, [checkSession]); + // Once-daily background check also looks for prunable image layers; pick + // that up on load so the Settings nav/section can show a badge without + // hitting the Docker API itself. + useEffect(() => { + if (!authenticated) return; + getStatus() + .then((s) => setNeedsPruning(Boolean(s?.danglingImages?.count))) + .catch(() => {}); + }, [authenticated]); + // If any authenticated request 401s (session expired mid-use), drop straight // back to the sign-in gate instead of stranding the user on a broken page. useEffect(() => { @@ -74,16 +85,19 @@ export default function App() { return (
-
+
} /> } /> - } /> + setNeedsPruning(false)} />} + /> } />
- +
); } diff --git a/client/src/components/BottomNav.jsx b/client/src/components/BottomNav.jsx index 11a82e4..ac2fe95 100644 --- a/client/src/components/BottomNav.jsx +++ b/client/src/components/BottomNav.jsx @@ -48,7 +48,7 @@ const TABS = [ /** * Fixed bottom tab bar, mobile-only (hidden at >=768px via CSS in app.css). */ -export default function BottomNav() { +export default function BottomNav({ needsPruning = false }) { return ( diff --git a/client/src/components/Header.jsx b/client/src/components/Header.jsx index 27f7562..39c4954 100644 --- a/client/src/components/Header.jsx +++ b/client/src/components/Header.jsx @@ -45,7 +45,7 @@ const MoonIcon = () => ( * `onLoggedOut` is called after the server session is cleared so App can * flip back to the AuthPage. */ -export default function Header({ pendingCount = 0, onLoggedOut }) { +export default function Header({ pendingCount = 0, needsPruning = false, onLoggedOut }) { const [loggingOut, setLoggingOut] = useState(false); const { theme, toggle } = useTheme(); @@ -78,6 +78,7 @@ export default function Header({ pendingCount = 0, onLoggedOut }) { `header-nav-link${isActive ? ' is-active' : ''}`}> Settings + {needsPruning &&
diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx index add09d2..041a366 100644 --- a/client/src/pages/SettingsPage.jsx +++ b/client/src/pages/SettingsPage.jsx @@ -26,6 +26,15 @@ function formatBytes(n) { return `${value.toFixed(1)} ${units[i]}`; } +// Container names a set of dangling images came from (deduped, sorted), plus +// how many have no known source — dangling images that predate the current +// rollback point, or were pulled outside DockPull, can't be attributed. +function describePruneSources(images) { + const names = [...new Set(images.map((img) => img.fromContainer).filter(Boolean))].sort(); + const unknownCount = images.length - images.filter((img) => img.fromContainer).length; + return { names, unknownCount }; +} + // Per-target label/description/placeholder for the notification URL field. const NOTIFY_META = { discord: { @@ -50,7 +59,7 @@ const NOTIFY_META = { }, }; -export default function SettingsPage() { +export default function SettingsPage({ onPruneComplete } = {}) { const { theme, toggle } = useTheme(); const [pinned, setPinned] = useState([]); @@ -174,13 +183,17 @@ export default function SettingsPage() { ? `Freed ${formatBytes(spaceReclaimed)} (${deleted} layer${deleted === 1 ? '' : 's'} removed).` : 'Nothing to prune — no dangling layers found.' ); + if (deleted > 0) { + onPruneComplete?.(); + setStatus((prev) => (prev ? { ...prev, danglingImages: { ...prev.danglingImages, count: 0 } } : prev)); + } } catch (err) { setPruneStatus(err.message || 'Prune failed'); } finally { setPruning(false); setPruneSummary(null); } - }, []); + }, [onPruneComplete]); const handleUnpin = useCallback( async (ref) => { @@ -447,7 +460,10 @@ export default function SettingsPage() {
-

Maintenance

+

+ Maintenance + {Boolean(status?.danglingImages?.count) &&

Prune unused image layers @@ -474,9 +490,19 @@ export default function SettingsPage() { {confirmPrune && pruneSummary && ( { + const { names, unknownCount } = describePruneSources(pruneSummary.images || []); + const sourceNote = names.length + ? ` — from ${names.join(', ')}${ + unknownCount + ? `, plus ${unknownCount} layer${unknownCount === 1 ? '' : 's'} from an untracked source` + : '' + }` + : ''; + return `This removes ${pruneSummary.count} dangling image layer${ + pruneSummary.count === 1 ? '' : 's' + } (${formatBytes(pruneSummary.totalSize)}) left behind after updates${sourceNote}. Tagged images and anything in use are never touched.`; + })()} confirmLabel="Prune" confirming={pruning} onConfirm={handlePrune} diff --git a/client/src/styles/app.css b/client/src/styles/app.css index 8201aa8..10c7cd3 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -322,6 +322,18 @@ a { color: var(--color-text-muted); } +/* Small unlabelled dot — "there's something here worth a look" (nav links, + section headings) as opposed to `.badge`'s numeric count. */ +.badge-dot { + display: inline-block; + width: 8px; + height: 8px; + margin-left: 6px; + border-radius: 999px; + background: var(--color-accent); + vertical-align: middle; +} + /* ---------- Dashboard ---------- */ .dashboard-header { diff --git a/server/src/db.js b/server/src/db.js index 045afac..6e33c7a 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -124,6 +124,9 @@ const stmts = { getRollbackPoint: db.prepare(` SELECT * FROM rollback_points WHERE container_name = ? LIMIT 1 `), + getAllRollbackPoints: db.prepare(` + SELECT container_name, image_id FROM rollback_points + `), deleteRollbackPoint: db.prepare(` DELETE FROM rollback_points WHERE container_name = ? `), @@ -249,6 +252,16 @@ export function deleteRollbackPoint(container_name) { return stmts.deleteRollbackPoint.run(container_name); } +/** + * Every container's remembered previous image ID (container_name, image_id + * pairs only) — used to attribute a dangling image back to the container it + * was replaced on, for the prune preview. One row per container (the most + * recent update only); see setRollbackPoint. + */ +export function getAllRollbackPoints() { + return stmts.getAllRollbackPoints.all(); +} + export function recordUpdate({ container_name, image, old_digest, new_digest, status, message }) { return stmts.recordUpdate.run({ container_name, diff --git a/server/src/docker.js b/server/src/docker.js index 60e25d9..d16b74b 100644 --- a/server/src/docker.js +++ b/server/src/docker.js @@ -796,6 +796,18 @@ export async function getContainerImageMeta(name) { return { image, currentVersion: version, sourceUrl: source }; } +/** + * Normalizes a Docker image ID (`sha256:<64-hex>` or already-short) down to + * the 12-char short form used for display and for matching a dangling image + * back to the rollback point that recorded it (see routes/api.js). + * + * @param {string} id + * @returns {string} + */ +export function shortImageId(id) { + return (id || '').replace(/^sha256:/, '').slice(0, 12); +} + /** * List dangling images (untagged layers no container references) without * deleting anything — a dry-run preview for the prune confirmation dialog, @@ -806,7 +818,7 @@ export async function getContainerImageMeta(name) { export async function listDanglingImages() { const images = await docker.listImages({ filters: { dangling: ['true'] } }); const list = images.map((img) => ({ - id: (img.Id || '').replace(/^sha256:/, '').slice(0, 12), + id: shortImageId(img.Id), size: img.Size ?? 0, created: img.Created ?? null, })); diff --git a/server/src/routes/api.js b/server/src/routes/api.js index dfa5d02..e1fbf35 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -11,7 +11,13 @@ import { readFileSync } from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import express from 'express'; -import { listContainers, getContainerImageMeta, listDanglingImages, pruneDanglingImages } from '../docker.js'; +import { + listContainers, + getContainerImageMeta, + listDanglingImages, + pruneDanglingImages, + shortImageId, +} from '../docker.js'; import { buildContainerItems } from '../containers-service.js'; import { normalizeRef } from '../reconcile.js'; import { runCheck } from '../checker.js'; @@ -48,6 +54,7 @@ apiRouter.get('/api/status', (req, res) => { return res.status(200).json({ version: APP_VERSION, lastCheck: db.getMeta('lastCheck'), + danglingImages: db.getMeta('danglingImages'), serverTime: now.toISOString(), timeZone, // Local HH:MM as the server sees it (what the scheduled scan compares to). @@ -161,7 +168,18 @@ apiRouter.get('/api/images/dangling', async (req, res) => { console.error(`api.js: GET /api/images/dangling failed: ${err.message}`); return res.status(500).json({ error: 'dangling_list_failed' }); } - return res.status(200).json(result); + // Attribute each dangling image back to the container it was replaced on, + // if we have a rollback point for it — so the confirmation dialog can name + // names instead of just a count. Images that predate the current rollback + // point (or were pulled outside DockPull) stay unattributed (null). + const sourceByImageId = new Map( + db.getAllRollbackPoints().map((r) => [shortImageId(r.image_id), r.container_name]) + ); + const images = result.images.map((img) => ({ + ...img, + fromContainer: sourceByImageId.get(img.id) ?? null, + })); + return res.status(200).json({ ...result, images }); }); // Remove dangling image layers (post-update leftovers). diff --git a/server/src/scheduler.js b/server/src/scheduler.js index d08e423..3589fae 100644 --- a/server/src/scheduler.js +++ b/server/src/scheduler.js @@ -11,7 +11,7 @@ import { getSettings } from './settings.js'; import { runCheck } from './checker.js'; -import { listContainers } from './docker.js'; +import { listContainers, listDanglingImages } from './docker.js'; import { buildContainerItems } from './containers-service.js'; import { normalizeRef } from './reconcile.js'; import { sendUpdates } from './notify.js'; @@ -69,6 +69,20 @@ export function selectNotifyTargets(items, unnotifiedRefs, normalizeRefFn) { export async function runScheduledCheck() { await runCheck(); + // Best-effort: note whether there's anything to prune, so the client can + // show a badge without hitting the Docker API on every page load. Never + // let this block the update-check/notify flow below it. + try { + const dangling = await listDanglingImages(); + db.setMeta('danglingImages', { + count: dangling.count, + totalSize: dangling.totalSize, + checkedAt: Date.now(), + }); + } catch (err) { + console.warn(`scheduler: dangling-image check failed: ${err.message}`); + } + const settings = getSettings(); if (!settings.discordEnabled || !settings.discordWebhookUrl) { return; // checks still keep the dashboard fresh; just no notification diff --git a/server/test/docker.test.js b/server/test/docker.test.js new file mode 100644 index 0000000..4556dae --- /dev/null +++ b/server/test/docker.test.js @@ -0,0 +1,20 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { shortImageId } from '../src/docker.js'; + +test('shortImageId: strips the sha256: prefix and truncates to 12 chars', () => { + assert.equal( + shortImageId('sha256:a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718293a4b5c6d7e8f9'), + 'a1b2c3d4e5f6' + ); +}); + +test('shortImageId: already-short IDs pass through unchanged', () => { + assert.equal(shortImageId('a1b2c3d4e5f6'), 'a1b2c3d4e5f6'); +}); + +test('shortImageId: empty / missing input returns an empty string', () => { + assert.equal(shortImageId(''), ''); + assert.equal(shortImageId(null), ''); + assert.equal(shortImageId(undefined), ''); +});