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 && (
)}
- {!loading && !error && containers.length > 0 && totalVisible === 0 && (
+ {!loading && !error && visible.length > 0 && mainCount === 0 && (
Everything's up to date. 🎉
-
setFilterPersisted('all')}>
- Show all containers
-
+ {filter === 'updates' && (
+
setFilterPersisted('all')}>
+ Show all containers
+
+ )}
)}
- {!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 && }
+
{
@@ -18,7 +34,17 @@ export default function SettingsPage() {
const data = await getPinned();
setPinned(Array.isArray(data) ? data : []);
} catch (err) {
- setPinnedError(err.message || 'Failed to load pinned images');
+ setPinnedError(err.message || 'Failed to load pinned versions');
+ }
+ }, []);
+
+ const loadHidden = useCallback(async () => {
+ setHiddenError('');
+ try {
+ const data = await getHidden();
+ setHidden(Array.isArray(data) ? data : []);
+ } catch (err) {
+ setHiddenError(err.message || 'Failed to load hidden containers');
}
}, []);
@@ -27,12 +53,38 @@ export default function SettingsPage() {
loadPinned().finally(() => setPinnedLoading(false));
}, [loadPinned]);
+ useEffect(() => {
+ setHiddenLoading(true);
+ loadHidden().finally(() => setHiddenLoading(false));
+ }, [loadHidden]);
+
+ useEffect(() => {
+ getSettings()
+ .then((s) => setSettings(s))
+ .catch((err) => setSettingsError(err.message || 'Failed to load settings'));
+ }, []);
+
useEffect(() => {
get('/health')
.then((data) => setHealth(!!(data && data.ok)))
.catch(() => setHealth(false));
}, []);
+ const saveSetting = useCallback(
+ async (patch) => {
+ // optimistic
+ setSettings((prev) => ({ ...prev, ...patch }));
+ setSettingsError('');
+ try {
+ const updated = await updateSettings(patch);
+ setSettings(updated);
+ } catch (err) {
+ setSettingsError(err.message || 'Failed to save settings');
+ }
+ },
+ []
+ );
+
const handleUnpin = useCallback(
async (ref) => {
setUnpinningRef(ref);
@@ -41,7 +93,7 @@ export default function SettingsPage() {
await unpin(ref);
await loadPinned();
} catch (err) {
- setPinnedError(err.message || 'Failed to unpin image');
+ setPinnedError(err.message || 'Failed to unpin version');
} finally {
setUnpinningRef('');
}
@@ -49,6 +101,22 @@ export default function SettingsPage() {
[loadPinned]
);
+ const handleUnhide = useCallback(
+ async (name) => {
+ setUnhidingName(name);
+ setHiddenError('');
+ try {
+ await unhideContainer(name);
+ await loadHidden();
+ } catch (err) {
+ setHiddenError(err.message || 'Failed to unhide container');
+ } finally {
+ setUnhidingName('');
+ }
+ },
+ [loadHidden]
+ );
+
return (
Settings
@@ -78,6 +146,57 @@ export default function SettingsPage() {
+
+ Behaviour
+ {settingsError && {settingsError}
}
+
+
+ Default view
+ Which containers the dashboard shows first.
+
+
+ saveSetting({ defaultFilter: 'updates' })}
+ disabled={!settings}
+ >
+ Updates only
+
+ saveSetting({ defaultFilter: 'all' })}
+ disabled={!settings}
+ >
+ All
+
+
+
+
+
+ Check on open
+
+ Automatically check for updates when you open the app.
+
+
+
saveSetting({ autoCheckOnOpen: !settings?.autoCheckOnOpen })}
+ disabled={!settings}
+ >
+
+
+
+ {settings?.autoCheckOnOpen ? 'On' : 'Off'}
+
+
+
+
Pinned versions
{pinnedLoading && (
@@ -124,6 +243,51 @@ export default function SettingsPage() {
)}
+
+ Hidden containers
+ {hiddenLoading && (
+
+ )}
+
+ {!hiddenLoading && hiddenError && (
+
+
{hiddenError}
+
+ Retry
+
+
+ )}
+
+ {!hiddenLoading && !hiddenError && hidden.length === 0 && (
+
+
No hidden containers.
+
+ )}
+
+ {!hiddenLoading && !hiddenError && hidden.length > 0 && (
+
+ {hidden.map((name) => (
+
+
+ {name}
+
+ handleUnhide(name)}
+ disabled={unhidingName === name}
+ >
+ {unhidingName === name && }
+ Unhide
+
+
+ ))}
+
+ )}
+
+
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);
+});