diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 65e8cbc..ad72e72 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -43,24 +43,16 @@ All request/response bodies are JSON unless noted otherwise. ### `GET /api/containers` - Auth: cookie. -- Response: `200` — array of container items (shape below). - -### `GET /api/diagnostics` - -- Auth: cookie. -- Response: `200 { "stacks": { "stacksDir": "/opt/stacks", "mounted": true } }`. - `mounted` is `false` when the configured `STACKS_DIR` isn't present inside - the container (the host stacks dir isn't mounted, or is mounted at a - different path) — which breaks compose-based updates. The dashboard uses - this to warn before an update is attempted. +- Response: `200` — array of container items (shape below). Each item carries + a `composeFileMissing` flag the dashboard uses to warn when a stack's + compose file isn't reachable inside the container (mount misconfigured). ### `POST /api/check` - Auth: cookie. - Body: none. -- Actively queries the registry for each running image's current digest - (independent of Diun webhooks) and records/clears update events - accordingly. +- Actively queries the registry for each running image's current digest and + records/clears update events accordingly. - Response: - `200 { "total": n, "checked": n, "updatesFound": n, "errors": n }` - `503 { "error": "docker_unavailable" }` if the Docker daemon is @@ -70,10 +62,9 @@ All request/response bodies are JSON unless noted otherwise. - Auth: cookie. - Response: `text/event-stream` (SSE). Emits - `data: {"type":"containers-changed"}` whenever server state changes (a Diun - webhook arrived, a manual check ran, or an update finished) so dashboards - can refresh without a manual reload. Comment lines (`: ...`) are sent as - keepalives. + `data: {"type":"containers-changed"}` whenever server state changes (a check + ran, an update finished, or a pin/hide changed) so dashboards can refresh + without a manual reload. Comment lines (`: ...`) are sent as keepalives. ### `POST /api/update/:name` @@ -142,10 +133,48 @@ All request/response bodies are JSON unless noted otherwise. - Response: `200 { "ok": true }`. Idempotent. Note: refs passed to `POST /api/pin` and `DELETE /api/pin/:ref` are -normalized server-side (via the same `normalizeRef` used for Diun events) -before being stored/looked up, so e.g. raw `nginx` and +normalized server-side (via the same `normalizeRef` used elsewhere) before +being stored/looked up, so e.g. raw `nginx` and `docker.io/library/nginx:latest` are equivalent and `GET /api/pinned` -always returns normalized refs. +always returns normalized refs. Pinning ("Pin Version") holds a container at +its current version: it's never flagged for updates and is grouped into a +separate section, but can still be updated by hand. + +### `GET /api/hidden` + +- Auth: cookie. +- Response: `200` — array of hidden container names. + +### `POST /api/hide` + +- Auth: cookie. +- Body: `{ "name": "string" }` — container name to hide from the dashboard. +- Response: `200 { "ok": true }`. Idempotent. + +### `DELETE /api/hide/:name` + +- Auth: cookie. +- Path param: `name` — container name to unhide (URL-encoded). +- Response: `200 { "ok": true }`. Idempotent. + +### `GET /api/settings` + +- Auth: cookie. +- Response: `200` — current settings, fully populated with defaults: + ```json + { "defaultFilter": "updates", "autoCheckOnOpen": true } + ``` + - `defaultFilter` — `"updates"` or `"all"`; the view the dashboard opens in. + - `autoCheckOnOpen` — whether the dashboard runs a check automatically on + first open. + +### `PUT /api/settings` + +- Auth: cookie. +- Body: a partial patch of the settings object, e.g. `{ "defaultFilter": + "all" }`. Unknown keys are ignored; invalid values for known keys return + `400 { "error": "invalid_value" }`. +- Response: `200` — the full, updated settings object. ### `GET /api/health` @@ -162,12 +191,15 @@ always returns normalized refs. "image": "nginx:latest", "tag": "latest", "currentVersion": "1.27.3", + "sourceUrl": "https://github.com/nginx/nginx", "currentDigest": "sha256:...", "updateAvailable": true, "availableDigest": "sha256:...", "pinned": false, + "hidden": false, "state": "running", "composeFile": "/opt/stacks/web/compose.yaml", + "composeFileMissing": false, "workingDir": "/opt/stacks/web" } ``` @@ -182,6 +214,9 @@ Field notes: the ref is digest-pinned. - `currentVersion` — human-readable version from the running image's `org.opencontainers.image.version` label, if it sets one (else `null`). +- `sourceUrl` — source/project URL from the image's + `org.opencontainers.image.source` (or `.url`) label, normalized to an + https web URL (else `null`); used for the per-card changelog/source link. - `currentDigest` — digest of the image the running container was created from. - `updateAvailable` — `true` if the most recent unresolved update event @@ -192,11 +227,16 @@ Field notes: - `pinned` — `true` if the image ref is in the `pinned` table ("Pin Version": update indicator is suppressed and the container is grouped separately, but a manual update is still allowed). +- `hidden` — `true` if the container name is in the `hidden` table; the + dashboard omits it (restore from Settings). - `state` — Docker container state (`running`, `exited`, etc.). - `composeFile` / `workingDir` — derived from `com.docker.compose.project.config_files` / `com.docker.compose.project.working_dir` labels; used to run `docker compose` commands for that container. +- `composeFileMissing` — `true` when `composeFile` is set but not present + inside the updater container (the same-path stacks mount is missing/wrong), + so a compose update would fail; the dashboard surfaces a warning banner. ## Update events diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index ef7e0bf..198d700 100644 --- a/client/src/Dashboard.jsx +++ b/client/src/Dashboard.jsx @@ -1,11 +1,9 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getContainers, checkNow, getDiagnostics } from './api.js'; +import { getContainers, checkNow, getSettings, updateSettings } from './api.js'; import UpdateCard from './components/UpdateCard.jsx'; import UpdateAllButton from './components/UpdateAllButton.jsx'; import StackGroup from './components/StackGroup.jsx'; -const FILTER_KEY = 'diun.filter'; -const AUTOCHECK_KEY = 'diun.autoCheckOnOpen'; const AUTOCHECK_SESSION = 'diun.autochecked'; const UNGROUPED = 'Ungrouped'; @@ -21,20 +19,25 @@ function byUpdateThenName(a, b) { return a.name.localeCompare(b.name); } +// Suggested same-path mount derived from a broken compose file path: the +// directory above the per-stack folder (e.g. /opt/stacks/web/compose.yaml -> +// /opt/stacks). +function stacksRootOf(composeFile) { + if (!composeFile) return null; + const parts = composeFile.split('/'); + parts.pop(); // file name + parts.pop(); // stack folder + const root = parts.join('/'); + return root || '/'; +} + export default function Dashboard({ onPendingCountChange }) { const [containers, setContainers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); const [checking, setChecking] = useState(false); const [checkMsg, setCheckMsg] = useState(''); - const [stacksWarning, setStacksWarning] = useState(null); // {stacksDir} when not mounted - const [filter, setFilter] = useState(() => { - try { - return localStorage.getItem(FILTER_KEY) || 'updates'; - } catch { - return 'updates'; - } - }); + const [filter, setFilter] = useState('updates'); // name -> run() function, populated by each UpdateCard so "Update all" // can drive the same start+SSE flow the per-card button uses. @@ -72,28 +75,28 @@ export default function Dashboard({ onPendingCountChange }) { } }, [load]); - // Initial load + auto-check on first open this session. + // Initial load + settings + auto-check on first open this session. useEffect(() => { let cancelled = false; (async () => { setLoading(true); - await load(); + const [settingsResult] = await Promise.allSettled([getSettings(), load()]); if (cancelled) return; setLoading(false); - let autoCheck = true; - try { - autoCheck = localStorage.getItem(AUTOCHECK_KEY) !== '0'; - } catch { - autoCheck = true; - } + const settings = + settingsResult.status === 'fulfilled' && settingsResult.value + ? settingsResult.value + : { defaultFilter: 'updates', autoCheckOnOpen: true }; + setFilter(settings.defaultFilter === 'all' ? 'all' : 'updates'); + let alreadyChecked = false; try { alreadyChecked = sessionStorage.getItem(AUTOCHECK_SESSION) === '1'; } catch { alreadyChecked = false; } - if (autoCheck && !alreadyChecked) { + if (settings.autoCheckOnOpen !== false && !alreadyChecked) { try { sessionStorage.setItem(AUTOCHECK_SESSION, '1'); } catch { @@ -108,20 +111,6 @@ export default function Dashboard({ onPendingCountChange }) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // Surface a mount-misconfig warning so the user can fix it before an update - // fails with a cryptic "compose file not found". - useEffect(() => { - getDiagnostics() - .then((d) => { - if (d?.stacks && d.stacks.mounted === false) { - setStacksWarning({ stacksDir: d.stacks.stacksDir }); - } else { - setStacksWarning(null); - } - }) - .catch(() => setStacksWarning(null)); - }, []); - // Live updates: refresh automatically when the server signals a change. useEffect(() => { let es; @@ -169,37 +158,39 @@ export default function Dashboard({ onPendingCountChange }) { const setFilterPersisted = useCallback((value) => { setFilter(value); - try { - localStorage.setItem(FILTER_KEY, value); - } catch { - // ignore - } + updateSettings({ defaultFilter: value }).catch(() => { + // non-fatal: the view still changes even if persisting the default fails + }); }, []); - const pendingTargets = useMemo(() => containers.filter(hasUpdate).map((c) => c.name), [containers]); + // Visible = not hidden. Pinned go to their own bottom section. + const visible = useMemo(() => containers.filter((c) => !c.hidden), [containers]); + const pinnedItems = useMemo( + () => visible.filter((c) => c.pinned).sort((a, b) => a.name.localeCompare(b.name)), + [visible] + ); + const mainItems = useMemo(() => visible.filter((c) => !c.pinned), [visible]); + + const pendingTargets = useMemo(() => mainItems.filter(hasUpdate).map((c) => c.name), [mainItems]); useEffect(() => { if (onPendingCountChange) onPendingCountChange(pendingTargets.length); }, [pendingTargets, onPendingCountChange]); - // Apply the filter, then group by stack (compose project), then order groups - // so those with updates come first. + // Apply the filter, then group by stack (compose project); groups with + // updates come first. const groups = useMemo(() => { - const visible = filter === 'updates' ? containers.filter(hasUpdate) : containers; + const items = filter === 'updates' ? mainItems.filter(hasUpdate) : mainItems; const byProject = new Map(); - for (const c of visible) { + for (const c of items) { const key = c.project || UNGROUPED; if (!byProject.has(key)) byProject.set(key, []); byProject.get(key).push(c); } const out = []; - for (const [project, items] of byProject) { - items.sort(byUpdateThenName); - out.push({ - project, - items, - updateCount: items.filter(hasUpdate).length, - }); + for (const [project, groupItems] of byProject) { + groupItems.sort(byUpdateThenName); + out.push({ project, items: groupItems, updateCount: groupItems.filter(hasUpdate).length }); } out.sort((a, b) => { const au = a.updateCount > 0 ? 0 : 1; @@ -210,9 +201,16 @@ export default function Dashboard({ onPendingCountChange }) { return a.project.localeCompare(b.project); }); return out; - }, [containers, filter]); + }, [mainItems, filter]); - const totalVisible = useMemo(() => groups.reduce((n, g) => n + g.items.length, 0), [groups]); + const mainCount = useMemo(() => groups.reduce((n, g) => n + g.items.length, 0), [groups]); + + // Mount/diagnostic banner: any compose file unreachable inside the container. + const mountIssue = useMemo(() => { + const broken = containers.find((c) => c.composeFileMissing && c.composeFile); + if (!broken) return null; + return { example: broken.composeFile, root: stacksRootOf(broken.composeFile) }; + }, [containers]); return (
@@ -259,12 +257,17 @@ export default function Dashboard({ onPendingCountChange }) { {checkMsg &&

{checkMsg}

} - {stacksWarning && ( + {mountIssue && (
- Stacks directory not mounted. The path{' '} - {stacksWarning.stacksDir} isn't present inside this container, so - compose-based updates will fail. Mount your stacks dir at the same absolute path on - the host and in the container (e.g. {stacksWarning.stacksDir}:{stacksWarning.stacksDir}) + Some stacks aren't mounted. The compose file{' '} + {mountIssue.example} isn't reachable inside this container, so those + updates will fail. Mount your stacks directory at the same absolute path on the host + and in the container + {mountIssue.root ? ( + <> + {' '}(e.g. {mountIssue.root}:{mountIssue.root}) + + ) : null}{' '} and set STACKS_DIR to match. See the README.
)} @@ -286,22 +289,24 @@ export default function Dashboard({ onPendingCountChange }) {
)} - {!loading && !error && containers.length === 0 && ( + {!loading && !error && visible.length === 0 && (

No containers found.

)} - {!loading && !error && containers.length > 0 && totalVisible === 0 && ( + {!loading && !error && visible.length > 0 && mainCount === 0 && (

Everything's up to date. 🎉

- + {filter === 'updates' && ( + + )}
)} - {!loading && !error && totalVisible > 0 && ( + {!loading && !error && mainCount > 0 && (
{groups.map((g) => ( ))} @@ -327,6 +333,30 @@ export default function Dashboard({ onPendingCountChange }) { ))}
)} + + {!loading && !error && pinnedItems.length > 0 && ( +
+ +
+ {pinnedItems.map((container) => ( + + ))} +
+
+
+ )} ); } diff --git a/client/src/api.js b/client/src/api.js index c8b806b..6f47fe6 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -82,12 +82,6 @@ export function checkNow() { return post('/check'); } -// Config diagnostics for the dashboard banner. Returns -// { stacks: { stacksDir, mounted } }. -export function getDiagnostics() { - return get('/diagnostics'); -} - export function startUpdate(name) { return post(`/update/${encodeURIComponent(name)}`); } @@ -117,4 +111,28 @@ export function unpin(ref) { return del(`/pin/${encodeURIComponent(ref)}`); } +// --- Hiding --- + +export function getHidden() { + return get('/hidden'); +} + +export function hideContainer(name) { + return post('/hide', { name }); +} + +export function unhideContainer(name) { + return del(`/hide/${encodeURIComponent(name)}`); +} + +// --- Settings --- + +export function getSettings() { + return get('/settings'); +} + +export function updateSettings(patch) { + return request('PUT', '/settings', patch); +} + export { ApiError }; diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index 4ef69e0..d9d2495 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 } from '../api.js'; +import { pin, unpin, hideContainer } from '../api.js'; import { useUpdateRunner } from '../hooks/useUpdateRunner.js'; import StatusMessage from './StatusMessage.jsx'; import StreamLog from './StreamLog.jsx'; @@ -19,46 +19,63 @@ function displayVersion({ currentVersion, tag, currentDigest }) { return shortDigest(currentDigest); } +// Build a "Changelog"/"Source" link from the image's OCI source label. GitHub +// repos get pointed at their releases page (best place for a changelog). +function sourceLink(sourceUrl) { + if (!sourceUrl) return null; + const isGitHub = /(^|\/\/|\.)github\.com\//i.test(sourceUrl); + return { + href: isGitHub ? `${sourceUrl.replace(/\/$/, '')}/releases` : sourceUrl, + label: isGitHub ? 'Changelog' : 'Source', + }; +} + +// A pushpin / thumbtack icon (filled when the version is pinned). const PinIcon = ({ filled }) => ( +); + +const ExternalIcon = () => ( + ); /** - * A single container's card: identity, digests, pin toggle, update button, - * and an expandable live log area for the in-flight (or most recent) update. + * A single container's card: identity, version, source/changelog link, pin + + * hide controls, update button, and an expandable live log for the in-flight + * (or most recent) update. * * props: * - container: item shape from GET /api/containers * - onSettled(name) — called once an update for this container finishes - * (success, failure, or stream error); used by the dashboard to re-fetch - * the container list and clear in-flight bookkeeping. - * - onPinChange() — called after a successful pin/unpin so the dashboard can refresh - * - registerRunner(name, runFn) — gives the parent a handle to trigger this - * card's update programmatically (used by "Update all"); pass null/no-op - * if not needed. + * - onPinChange() — called after a pin/unpin so the dashboard can refresh + * - onHidden() — called after hiding so the dashboard can refresh + * - registerRunner(name, runFn) — handle for "Update all" */ -export default function UpdateCard({ container, onSettled, onPinChange, registerRunner }) { - const { - name, - project, - service, - image, - currentDigest, - availableVersion, - availableDigest, - updateAvailable, - pinned, - } = container; +export default function UpdateCard({ container, onSettled, onPinChange, onHidden, registerRunner }) { + const { name, project, service, image, currentDigest, availableVersion, availableDigest, updateAvailable, pinned, sourceUrl } = + container; const [pinBusy, setPinBusy] = useState(false); - const [pinError, setPinError] = useState(''); + const [hideBusy, setHideBusy] = useState(false); + const [actionError, setActionError] = useState(''); const { run, busy, startError, status, lines } = useUpdateRunner(name, onSettled); @@ -76,22 +93,35 @@ export default function UpdateCard({ container, onSettled, onPinChange, register const togglePin = useCallback(async () => { setPinBusy(true); - setPinError(''); + setActionError(''); try { if (pinned) { await unpin(image); } else { await pin(image); } - onPinChange(); + if (onPinChange) onPinChange(); } catch (err) { - setPinError(err.message || 'Failed to update pin'); + setActionError(err.message || 'Failed to update pin'); } finally { setPinBusy(false); } }, [pinned, image, onPinChange]); + const handleHide = useCallback(async () => { + setHideBusy(true); + setActionError(''); + try { + await hideContainer(name); + if (onHidden) onHidden(); + } catch (err) { + setActionError(err.message || 'Failed to hide container'); + setHideBusy(false); + } + }, [name, onHidden]); + const showUpdateAvailable = updateAvailable && !pinned; + const link = sourceLink(sourceUrl); return (
@@ -142,11 +172,23 @@ export default function UpdateCard({ container, onSettled, onPinChange, register )}
- {pinError && } + {actionError && } {startError && }
+
+ {link && ( + + {link.label} + + + )} + +
+ +
+ +
+
+ Check on open + + Automatically check for updates when you open the app. + +
+ +
+ +

Pinned versions

{pinnedLoading && ( @@ -124,6 +243,51 @@ export default function SettingsPage() { )}
+
+

Hidden containers

+ {hiddenLoading && ( +
+
+
+ )} + + {!hiddenLoading && hiddenError && ( +
+

{hiddenError}

+ +
+ )} + + {!hiddenLoading && !hiddenError && hidden.length === 0 && ( +
+

No hidden containers.

+
+ )} + + {!hiddenLoading && !hiddenError && hidden.length > 0 && ( +
    + {hidden.map((name) => ( +
  • + + {name} + + +
  • + ))} +
+ )} +
+

About

Diun Updater

diff --git a/client/src/styles/app.css b/client/src/styles/app.css index 5fa685f..03fccba 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -448,11 +448,59 @@ a { .card-actions { display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: 8px; margin-top: 12px; } +.card-actions-left { + display: flex; + align-items: center; + gap: 12px; + min-width: 0; +} + +.card-link { + display: inline-flex; + align-items: center; + gap: 4px; + font-size: 0.78rem; + font-weight: 600; + color: var(--color-accent); + text-decoration: none; +} + +.card-link:hover { + text-decoration: underline; +} + +.btn-ghost { + background: none; + border: none; + padding: 6px 4px; + display: inline-flex; + align-items: center; + gap: 6px; + font-size: 0.8rem; + font-weight: 600; + color: var(--color-text-muted); + cursor: pointer; +} + +.btn-ghost:hover { + color: var(--color-text); +} + +.pinned-groups { + margin-top: 16px; +} + +.settings-error { + margin: 0 0 10px; + font-size: 0.82rem; + color: var(--color-error); +} + .pin-toggle { background: transparent; border: 1px solid var(--color-border); diff --git a/server/src/containers-service.js b/server/src/containers-service.js index 6e422d7..3bf82a1 100644 --- a/server/src/containers-service.js +++ b/server/src/containers-service.js @@ -22,12 +22,14 @@ import { isUpdateAvailable, digestsEqual } from './reconcile.js'; * - returns the latest unresolved event row for a normalized ref, or * undefined if there is none. * @param {(normalizedRef: string) => boolean} params.isPinned + * @param {(containerName: string) => boolean} [params.isHidden] - whether a + * container is hidden from the dashboard. Defaults to "never hidden". * @returns {{ * items: Array, * refsToResolve: string[] * }} */ -export function buildContainerItems({ containers, lookupEvent, isPinned }) { +export function buildContainerItems({ containers, lookupEvent, isPinned, isHidden = () => false }) { const items = []; const refsToResolve = []; @@ -56,12 +58,15 @@ export function buildContainerItems({ containers, lookupEvent, isPinned }) { image: c.image, tag: c.tag ?? null, currentVersion: c.currentVersion ?? null, + sourceUrl: c.sourceUrl ?? null, currentDigest: c.currentDigest, updateAvailable, availableDigest, pinned: isPinned(c.normalizedRef), + hidden: isHidden(c.name), state: c.state, composeFile: c.composeFile, + composeFileMissing: c.composeFileMissing ?? false, workingDir: c.workingDir, }); } diff --git a/server/src/db.js b/server/src/db.js index c288814..b713708 100644 --- a/server/src/db.js +++ b/server/src/db.js @@ -36,6 +36,14 @@ CREATE TABLE IF NOT EXISTS pinned ( ref TEXT PRIMARY KEY, created_at TEXT DEFAULT (datetime('now')) ); +CREATE TABLE IF NOT EXISTS hidden ( + container_name TEXT PRIMARY KEY, + created_at TEXT DEFAULT (datetime('now')) +); +CREATE TABLE IF NOT EXISTS settings ( + key TEXT PRIMARY KEY, + value TEXT +); 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); `); @@ -82,6 +90,29 @@ const stmts = { isPinned: db.prepare(` SELECT 1 FROM pinned WHERE ref = ? LIMIT 1 `), + hide: db.prepare(` + INSERT INTO hidden (container_name) VALUES (?) + ON CONFLICT(container_name) DO NOTHING + `), + unhide: db.prepare(` + DELETE FROM hidden WHERE container_name = ? + `), + getHidden: db.prepare(` + SELECT container_name FROM hidden ORDER BY created_at DESC + `), + isHidden: db.prepare(` + SELECT 1 FROM hidden WHERE container_name = ? LIMIT 1 + `), + getSetting: db.prepare(` + SELECT value FROM settings WHERE key = ? LIMIT 1 + `), + getAllSettings: db.prepare(` + SELECT key, value FROM settings + `), + setSetting: db.prepare(` + INSERT INTO settings (key, value) VALUES (@key, @value) + ON CONFLICT(key) DO UPDATE SET value = excluded.value + `), }; export function recordEvent({ image, normalized_ref, status, digest, raw_json }) { @@ -136,4 +167,37 @@ export function isPinned(ref) { return stmts.isPinned.get(ref) !== undefined; } +export function hide(containerName) { + return stmts.hide.run(containerName); +} + +export function unhide(containerName) { + return stmts.unhide.run(containerName); +} + +export function getHidden() { + return stmts.getHidden.all().map((row) => row.container_name); +} + +export function isHidden(containerName) { + return stmts.isHidden.get(containerName) !== undefined; +} + +export function getSetting(key) { + const row = stmts.getSetting.get(key); + return row ? row.value : undefined; +} + +export function getAllSettings() { + const out = {}; + for (const row of stmts.getAllSettings.all()) { + out[row.key] = row.value; + } + return out; +} + +export function setSetting(key, value) { + return stmts.setSetting.run({ key, value: value == null ? null : String(value) }); +} + export default db; diff --git a/server/src/docker.js b/server/src/docker.js index adb8f27..b47d9d9 100644 --- a/server/src/docker.js +++ b/server/src/docker.js @@ -133,8 +133,31 @@ async function inspectImageMeta(imageIdOrName, image) { const labels = imageInfo?.Config?.Labels || {}; const version = labels['org.opencontainers.image.version'] || null; + const source = normalizeSourceUrl( + labels['org.opencontainers.image.source'] || labels['org.opencontainers.image.url'] || null + ); const digest = pickRepoDigest(imageInfo?.RepoDigests, image); - return { digest, version }; + return { digest, version, source }; +} + +/** + * Normalizes an OCI source/url label into a plain https web URL, or null. + * Handles `git+https://`, `git@github.com:org/repo.git`, and trailing `.git`. + * + * @param {string|null} raw + * @returns {string|null} + */ +function normalizeSourceUrl(raw) { + if (typeof raw !== 'string') return null; + let url = raw.trim(); + if (url === '') return null; + url = url.replace(/^git\+/, ''); + // scp-style git remote: git@github.com:org/repo(.git) + const scp = url.match(/^git@([^:]+):(.+)$/); + if (scp) url = `https://${scp[1]}/${scp[2]}`; + url = url.replace(/\.git$/, ''); + if (!/^https?:\/\//i.test(url)) return null; + return url; } /** @@ -286,10 +309,11 @@ export async function listContainers() { continue; } - const { digest: currentDigest, version: currentVersion } = await inspectImageMeta( - inspectData.Image, - image - ); + const { + digest: currentDigest, + version: currentVersion, + source: sourceUrl, + } = await inspectImageMeta(inspectData.Image, image); const labels = inspectData.Config?.Labels; const labelInfo = composeInfoFromLabels(labels); @@ -315,15 +339,22 @@ export async function listContainers() { continue; } + // Flag compose-managed containers whose compose file isn't reachable + // from inside this container (the same-path mount is missing/wrong), so + // the dashboard can warn before an update is attempted. + const composeFileMissing = Boolean(composeFile) && !fs.existsSync(composeFile); + results.push({ name, image, tag, currentVersion, + sourceUrl: sourceUrl || null, currentDigest, project: project || null, service: service || null, composeFile: composeFile || null, + composeFileMissing, workingDir: workingDir || null, state: inspectData.State?.Status || summary.State || 'unknown', normalizedRef, @@ -458,15 +489,19 @@ export async function updateContainer(name, onLine) { // compose file from this container's filesystem. If the stacks dir isn't // mounted here at the same absolute path it has on the host, the file // won't exist and compose fails with a cryptic "no such file" error. - // Catch it up front with an actionable message instead. + // Catch it up front with an actionable message — and suggest the mount + // derived from THIS compose file's own location (the dir above the stack + // folder), not config.STACKS_DIR, which may be set to the wrong path. if (!fs.existsSync(composeFile)) { + const stacksRoot = path.dirname(path.dirname(composeFile)); return { success: false, message: `Compose file not found at "${composeFile}" inside the updater container. ` + - `Mount your stacks directory at the SAME absolute path on the host and in ` + - `this container (e.g. "${config.STACKS_DIR}:${config.STACKS_DIR}") and set ` + - `STACKS_DIR to that path. See the README "same-path mount" note.`, + `The directory holding your stacks must be bind-mounted at the SAME ` + + `absolute path on the host and in this container — add ` + + `"${stacksRoot}:${stacksRoot}" to this container's volumes (and set ` + + `STACKS_DIR=${stacksRoot}). See the README "same-path mount" note.`, oldDigest, newDigest: null, }; @@ -612,22 +647,4 @@ export async function updateContainer(name, onLine) { }; } -/** - * Diagnostic: is the configured STACKS_DIR actually present inside this - * container? When false, the host stacks dir almost certainly isn't mounted - * (or is mounted at a different path), which breaks compose-based updates. - * Used by the dashboard to warn before the user hits a failed update. - * - * @returns {{ stacksDir: string, mounted: boolean }} - */ -export function stacksDirStatus() { - let mounted = false; - try { - mounted = fs.existsSync(config.STACKS_DIR) && fs.statSync(config.STACKS_DIR).isDirectory(); - } catch { - mounted = false; - } - return { stacksDir: config.STACKS_DIR, mounted }; -} - export { docker }; diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 8d6cdcc..efc901b 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -8,11 +8,12 @@ */ import express from 'express'; -import { listContainers, stacksDirStatus } from '../docker.js'; +import { listContainers } from '../docker.js'; import { buildContainerItems } from '../containers-service.js'; import { normalizeRef } from '../reconcile.js'; import { runCheck } from '../checker.js'; import { subscribeGlobal, broadcastGlobal } from '../sse.js'; +import { getSettings, updateSettings } from '../settings.js'; import * as db from '../db.js'; export const apiRouter = express.Router(); @@ -45,6 +46,7 @@ apiRouter.get('/api/containers', async (req, res) => { containers, lookupEvent: db.latestUnresolvedEventForRef, isPinned: (ref) => db.isPinned(ref), + isHidden: (name) => db.isHidden(name), }); for (const ref of refsToResolve) { @@ -54,13 +56,6 @@ apiRouter.get('/api/containers', async (req, res) => { return res.status(200).json(items); }); -// Lightweight config/health diagnostics for the dashboard to surface -// actionable warnings (currently: whether the stacks dir is actually mounted -// inside the container, without which compose-based updates fail). -apiRouter.get('/api/diagnostics', (req, res) => { - return res.status(200).json({ stacks: stacksDirStatus() }); -}); - // Actively check registries for newer digests. apiRouter.post('/api/check', async (req, res) => { let result; @@ -113,6 +108,7 @@ apiRouter.post('/api/pin', (req, res) => { } db.pin(normalized); + broadcastGlobal({ type: 'containers-changed' }); return res.status(200).json({ ok: true }); }); @@ -125,7 +121,49 @@ apiRouter.delete('/api/pin/:ref', (req, res) => { } db.unpin(normalized); + broadcastGlobal({ type: 'containers-changed' }); + return res.status(200).json({ ok: true }); +}); + +// --- Hidden containers (keyed by container name) --- + +apiRouter.get('/api/hidden', (req, res) => { + return res.status(200).json(db.getHidden()); +}); + +apiRouter.post('/api/hide', (req, res) => { + const name = req.body?.name; + if (typeof name !== 'string' || name.trim() === '') { + return res.status(400).json({ error: 'invalid_payload' }); + } + db.hide(name.trim()); + broadcastGlobal({ type: 'containers-changed' }); return res.status(200).json({ ok: true }); }); +apiRouter.delete('/api/hide/:name', (req, res) => { + const name = decodeURIComponent(req.params.name); + db.unhide(name); + broadcastGlobal({ type: 'containers-changed' }); + return res.status(200).json({ ok: true }); +}); + +// --- Settings --- + +apiRouter.get('/api/settings', (req, res) => { + return res.status(200).json(getSettings()); +}); + +apiRouter.put('/api/settings', (req, res) => { + try { + const updated = updateSettings(req.body || {}); + return res.status(200).json(updated); + } catch (err) { + if (err.code === 'invalid_value') { + return res.status(400).json({ error: 'invalid_value', message: err.message }); + } + throw err; + } +}); + export default apiRouter; diff --git a/server/src/settings.js b/server/src/settings.js new file mode 100644 index 0000000..4b01424 --- /dev/null +++ b/server/src/settings.js @@ -0,0 +1,77 @@ +/** + * App settings: a small typed layer over the key/value `settings` table. + * + * Each setting declares a default and a coercion from the stored string. New + * settings (e.g. the background scheduler / Discord webhook in a later phase) + * just get added to SPEC. `getSettings()` always returns a fully-populated, + * typed object (defaults merged over stored values); `updateSettings()` takes + * a partial patch, validates known keys, and persists them. + */ + +import * as db from './db.js'; + +function bool(v, fallback) { + if (v === undefined || v === null) return fallback; + if (typeof v === 'boolean') return v; + return v === '1' || v === 'true'; +} + +function enumOf(allowed, fallback) { + return (v) => (allowed.includes(v) ? v : fallback); +} + +const SPEC = { + defaultFilter: { + default: 'updates', + fromStore: enumOf(['updates', 'all'], 'updates'), + fromInput: enumOf(['updates', 'all'], undefined), // undefined -> rejected + }, + autoCheckOnOpen: { + default: true, + fromStore: (v) => bool(v, true), + fromInput: (v) => (typeof v === 'boolean' ? v : undefined), + }, +}; + +/** + * @returns {{ defaultFilter: 'updates'|'all', autoCheckOnOpen: boolean }} + */ +export function getSettings() { + const stored = db.getAllSettings(); + const out = {}; + for (const [key, spec] of Object.entries(SPEC)) { + out[key] = key in stored ? spec.fromStore(stored[key]) : spec.default; + } + return out; +} + +/** + * Validate + persist a partial patch. Unknown keys are ignored; invalid values + * for known keys are rejected (the whole call fails so the client gets clear + * feedback). Returns the full, updated settings object. + * + * @param {Record} patch + * @returns {{ defaultFilter: string, autoCheckOnOpen: boolean }} + * @throws {Error} with `.code = 'invalid_value'` on a bad known value. + */ +export function updateSettings(patch) { + if (!patch || typeof patch !== 'object') { + const err = new Error('settings patch must be an object'); + err.code = 'invalid_value'; + throw err; + } + for (const [key, raw] of Object.entries(patch)) { + const spec = SPEC[key]; + if (!spec) continue; // ignore unknown keys + const coerced = spec.fromInput(raw); + if (coerced === undefined) { + const err = new Error(`invalid value for setting "${key}"`); + err.code = 'invalid_value'; + throw err; + } + db.setSetting(key, typeof coerced === 'boolean' ? (coerced ? '1' : '0') : coerced); + } + return getSettings(); +} + +export default { getSettings, updateSettings }; diff --git a/server/test/containers-service.test.js b/server/test/containers-service.test.js index 4024d93..a88809b 100644 --- a/server/test/containers-service.test.js +++ b/server/test/containers-service.test.js @@ -8,10 +8,12 @@ function makeContainer(overrides = {}) { image: 'nginx:latest', tag: 'latest', currentVersion: null, + sourceUrl: null, currentDigest: 'sha256:aaa', project: 'web', service: 'nginx', composeFile: '/stacks/web/docker-compose.yml', + composeFileMissing: false, workingDir: '/stacks/web', state: 'running', normalizedRef: 'docker.io/library/nginx:latest', @@ -82,16 +84,30 @@ describe('buildContainerItems', () => { image: 'nginx:latest', tag: 'latest', currentVersion: null, + sourceUrl: null, currentDigest: 'sha256:aaa', updateAvailable: false, availableDigest: null, pinned: false, + hidden: false, state: 'running', composeFile: '/stacks/web/docker-compose.yml', + composeFileMissing: false, workingDir: '/stacks/web', }); }); + test('isHidden marks the item hidden', () => { + const containers = [makeContainer()]; + const { items } = buildContainerItems({ + containers, + lookupEvent: () => undefined, + isPinned: () => false, + isHidden: (name) => name === 'nginx', + }); + assert.equal(items[0].hidden, true); + }); + test('handles multiple containers independently', () => { const containers = [ makeContainer({ name: 'a', normalizedRef: 'docker.io/library/a:latest', currentDigest: 'sha256:111' }), diff --git a/server/test/settings.test.js b/server/test/settings.test.js new file mode 100644 index 0000000..9b7f524 --- /dev/null +++ b/server/test/settings.test.js @@ -0,0 +1,43 @@ +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'; + +// Point DATA_DIR at a throwaway dir BEFORE importing db/settings — db.js +// creates the SQLite file from config.DATA_DIR at import time. +const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'diun-settings-')); +process.env.DATA_DIR = tmp; + +const { getSettings, updateSettings } = await import('../src/settings.js'); +const db = await import('../src/db.js'); + +test('settings: defaults when nothing stored', () => { + assert.deepEqual(getSettings(), { defaultFilter: 'updates', autoCheckOnOpen: true }); +}); + +test('settings: updateSettings persists and coerces booleans', () => { + const s = updateSettings({ defaultFilter: 'all', autoCheckOnOpen: false }); + assert.equal(s.defaultFilter, 'all'); + assert.equal(s.autoCheckOnOpen, false); + assert.deepEqual(getSettings(), { defaultFilter: 'all', autoCheckOnOpen: false }); +}); + +test('settings: rejects invalid known values', () => { + assert.throws(() => updateSettings({ defaultFilter: 'bogus' }), /invalid value/); +}); + +test('settings: ignores unknown keys', () => { + assert.doesNotThrow(() => updateSettings({ somethingUnknown: 'x' })); +}); + +test('hidden: hide/isHidden/unhide roundtrip', () => { + assert.equal(db.isHidden('cadvisor'), false); + db.hide('cadvisor'); + assert.equal(db.isHidden('cadvisor'), true); + assert.deepEqual(db.getHidden(), ['cadvisor']); + db.hide('cadvisor'); // idempotent + assert.equal(db.getHidden().length, 1); + db.unhide('cadvisor'); + assert.equal(db.isHidden('cadvisor'), false); +});