Skip to content

Commit e9727d1

Browse files
strandedturtleclaude
andcommitted
feat: GHCR publish, active update check, live dashboard updates
Three requested features for a complete, shippable build: - Publish image to GHCR: .github/workflows/release.yml builds a multi-arch (amd64+arm64) image on pushes to main (edge) and version tags (semver + latest). README documents running the prebuilt image instead of building. - Active "Check now": registry.js resolves the current manifest digest for a tag without pulling (anonymous Docker Hub/GHCR/quay/lscr token flow); checker.js reconciles every running image against the registry and records or clears events; POST /api/check exposes it; a Check button drives it from the dashboard. Makes the app work without (or alongside) Diun and recovers from missed webhooks. - Live dashboard updates: a global SSE channel (GET /api/events) broadcasts containers-changed on webhook receipt, manual check, and finished update; the dashboard subscribes and auto-refreshes (debounced). Also fixes a latent build bug: better-sqlite3 has no musl prebuilt, so the Dockerfile now compiles it in a dedicated server-deps stage (python3/make/g++) and copies node_modules into a clean runtime image; the client stage pins --platform=$BUILDPLATFORM so it isn't rebuilt under emulation. New pure-logic tests: parseRef (6) and parseWwwAuthenticate (2) — 60 total passing. API_CONTRACT and README updated. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013Lj6nYJQDtLaZFvvEQJGM4
1 parent 323a4a1 commit e9727d1

16 files changed

Lines changed: 584 additions & 36 deletions

File tree

.github/workflows/release.yml

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
name: Publish image
2+
3+
on:
4+
push:
5+
branches: [main]
6+
tags: ["v*"]
7+
8+
jobs:
9+
publish:
10+
name: Build and push to GHCR
11+
runs-on: ubuntu-latest
12+
permissions:
13+
contents: read
14+
packages: write
15+
steps:
16+
- uses: actions/checkout@v4
17+
18+
# GHCR image names must be lowercase; the owner may not be.
19+
- name: Compute lowercase image name
20+
id: img
21+
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"
22+
23+
- name: Docker metadata (tags/labels)
24+
id: meta
25+
uses: docker/metadata-action@v5
26+
with:
27+
images: ${{ steps.img.outputs.name }}
28+
tags: |
29+
type=semver,pattern={{version}}
30+
type=semver,pattern={{major}}.{{minor}}
31+
type=raw,value=edge,enable={{is_default_branch}}
32+
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}
33+
34+
- uses: docker/setup-qemu-action@v3
35+
- uses: docker/setup-buildx-action@v3
36+
37+
- name: Log in to GHCR
38+
uses: docker/login-action@v3
39+
with:
40+
registry: ghcr.io
41+
username: ${{ github.actor }}
42+
password: ${{ secrets.GITHUB_TOKEN }}
43+
44+
- name: Build and push
45+
uses: docker/build-push-action@v6
46+
with:
47+
context: .
48+
file: server/Dockerfile
49+
platforms: linux/amd64,linux/arm64
50+
push: true
51+
tags: ${{ steps.meta.outputs.tags }}
52+
labels: ${{ steps.meta.outputs.labels }}
53+
cache-from: type=gha
54+
cache-to: type=gha,mode=max

API_CONTRACT.md

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,27 @@ All request/response bodies are JSON unless noted otherwise.
5656
- Auth: cookie.
5757
- Response: `200` — array of container items (shape below).
5858

59+
### `POST /api/check`
60+
61+
- Auth: cookie.
62+
- Body: none.
63+
- Actively queries the registry for each running image's current digest
64+
(independent of Diun webhooks) and records/clears update events
65+
accordingly.
66+
- Response:
67+
- `200 { "total": n, "checked": n, "updatesFound": n, "errors": n }`
68+
- `503 { "error": "docker_unavailable" }` if the Docker daemon is
69+
unreachable.
70+
71+
### `GET /api/events`
72+
73+
- Auth: cookie.
74+
- Response: `text/event-stream` (SSE). Emits
75+
`data: {"type":"containers-changed"}` whenever server state changes (a Diun
76+
webhook arrived, a manual check ran, or an update finished) so dashboards
77+
can refresh without a manual reload. Comment lines (`: ...`) are sent as
78+
keepalives.
79+
5980
### `POST /api/update/:name`
6081

6182
- Auth: cookie.

README.md

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -205,6 +205,19 @@ The SQLite database is created automatically in the `diun-updater-data` volume
205205
on first start. The first time you load the UI you'll get the login screen —
206206
enter `ADMIN_PASSWORD`.
207207

208+
> **Prefer a prebuilt image?** Tagged releases publish a multi-arch image
209+
> (`linux/amd64` + `linux/arm64`) to GHCR. Instead of `build:`, point the
210+
> compose service at it and skip the build:
211+
>
212+
> ```yaml
213+
> services:
214+
> diun-updater:
215+
> image: ghcr.io/strandedturtle/diupdater:latest
216+
> # ...keep the same environment + volumes as above...
217+
> ```
218+
>
219+
> Then `docker compose up -d` (no `--build`).
220+
208221
### 5. Point Diun at the app (the webhook)
209222

210223
Add a `webhook` notifier to your Diun config (this is **in addition to** your
@@ -273,7 +286,12 @@ Open `http://<host>:5000` (or your tunnel URL) and log in with `ADMIN_PASSWORD`.
273286
badge clears.
274287
- **Update all** runs every eligible container one at a time (a failure on one
275288
doesn't stop the rest).
289+
- **Check** actively queries the registries for newer digests right now, instead
290+
of waiting for Diun (see [Active update checks](#active-update-checks)).
276291
- **Refresh** re-reads live state from Docker.
292+
- The dashboard also **updates itself live** — when a Diun webhook arrives, a
293+
check runs, or an update finishes, the list refreshes automatically (no need to
294+
hit Refresh).
277295
- The **pin** icon hides a container's update badge (useful to "ignore this one
278296
for now"). Pinned items can still be updated manually; manage/unpin them from
279297
Settings.
@@ -292,6 +310,20 @@ pages through older entries.
292310
Screen". It installs as a standalone, full-screen app with an icon — this is the
293311
mobile experience that replaces fiddling with Dockge.
294312

313+
### Active update checks
314+
315+
The **Check** button (and `POST /api/check`) makes the app query the registries
316+
directly for each running image's current digest and flag anything out of date —
317+
independent of Diun. This is useful for a first run (Diun only sends a webhook
318+
*when a digest changes*, so a fresh install is otherwise quiet), to recover from
319+
a webhook that was missed while the app was down, or if you'd rather not depend
320+
on Diun at all.
321+
322+
It currently supports registries reachable **anonymously** over the standard
323+
token flow — Docker Hub, GHCR, lscr.io, quay.io, etc. for public images. Private
324+
images that require credentials are skipped (counted under `errors`) and still
325+
rely on Diun's webhook for their signal.
326+
295327
---
296328

297329
## Configuration reference

client/src/Dashboard.jsx

Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2-
import { getContainers } from './api.js';
2+
import { getContainers, checkNow } from './api.js';
33
import UpdateCard from './components/UpdateCard.jsx';
44
import UpdateAllButton from './components/UpdateAllButton.jsx';
55

@@ -8,6 +8,8 @@ export default function Dashboard({ onPendingCountChange }) {
88
const [loading, setLoading] = useState(true);
99
const [error, setError] = useState('');
1010
const [refreshing, setRefreshing] = useState(false);
11+
const [checking, setChecking] = useState(false);
12+
const [checkMsg, setCheckMsg] = useState('');
1113

1214
// name -> run() function, populated by each UpdateCard so "Update all"
1315
// can drive the same start+SSE flow the per-card button uses.
@@ -34,6 +36,56 @@ export default function Dashboard({ onPendingCountChange }) {
3436
setRefreshing(false);
3537
}, [load]);
3638

39+
// Actively ask the server to re-check registries, then refresh the list.
40+
const handleCheck = useCallback(async () => {
41+
setChecking(true);
42+
setCheckMsg('');
43+
try {
44+
const r = await checkNow();
45+
await load();
46+
const checked = r?.checked ?? 0;
47+
const found = r?.updatesFound ?? 0;
48+
const errs = r?.errors ?? 0;
49+
setCheckMsg(
50+
`Checked ${checked} image${checked === 1 ? '' : 's'}${found} update${found === 1 ? '' : 's'} found` +
51+
(errs ? `, ${errs} couldn't be checked` : '') +
52+
'.'
53+
);
54+
} catch (err) {
55+
setCheckMsg(err.message || 'Check failed');
56+
} finally {
57+
setChecking(false);
58+
}
59+
}, [load]);
60+
61+
// Live updates: refresh automatically when the server signals a change
62+
// (a Diun webhook arrived, a check ran, or an update finished).
63+
useEffect(() => {
64+
let es;
65+
let debounce;
66+
try {
67+
es = new EventSource('/api/events');
68+
es.onmessage = (e) => {
69+
let payload;
70+
try {
71+
payload = JSON.parse(e.data);
72+
} catch {
73+
return;
74+
}
75+
if (payload && payload.type === 'containers-changed') {
76+
clearTimeout(debounce);
77+
debounce = setTimeout(() => load(), 400);
78+
}
79+
};
80+
} catch {
81+
// EventSource unavailable — manual Refresh/Check still work.
82+
}
83+
return () => {
84+
clearTimeout(debounce);
85+
if (es) es.close();
86+
};
87+
}, [load]);
88+
3789
// Called by UpdateCard once its update settles (success/error/stream
3890
// error). Re-fetch so digests/updateAvailable/pinned reflect server state.
3991
const handleSettled = useCallback(() => {
@@ -75,6 +127,10 @@ export default function Dashboard({ onPendingCountChange }) {
75127
)}
76128
</div>
77129
<div className="dashboard-actions">
130+
<button type="button" className="btn btn-sm" onClick={handleCheck} disabled={checking || loading}>
131+
{checking && <span className="spinner" aria-hidden="true" />}
132+
Check
133+
</button>
78134
<button type="button" className="btn btn-sm" onClick={handleRefresh} disabled={refreshing || loading}>
79135
{refreshing && <span className="spinner" aria-hidden="true" />}
80136
Refresh
@@ -86,6 +142,7 @@ export default function Dashboard({ onPendingCountChange }) {
86142
/>
87143
</div>
88144
</div>
145+
{checkMsg && <p className="check-msg">{checkMsg}</p>}
89146

90147
{loading && (
91148
<div className="dashboard-list" aria-busy="true" aria-label="Loading containers">

client/src/api.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,12 @@ export function getContainers() {
7676
return get('/containers');
7777
}
7878

79+
// Actively re-check registries for newer digests. Returns
80+
// { total, checked, updatesFound, errors }.
81+
export function checkNow() {
82+
return post('/check');
83+
}
84+
7985
export function startUpdate(name) {
8086
return post(`/update/${encodeURIComponent(name)}`);
8187
}

client/src/styles/app.css

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -928,3 +928,11 @@ a {
928928
text-overflow: ellipsis;
929929
white-space: nowrap;
930930
}
931+
932+
/* ---------- Check result message ---------- */
933+
934+
.check-msg {
935+
margin: 0 0 12px;
936+
font-size: 0.85rem;
937+
color: var(--text-secondary);
938+
}

server/Dockerfile

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,28 @@
33
# (docker-compose.yml at the repo root is already configured this way.)
44

55
# ---- Stage 1: build the client SPA ----
6-
FROM node:22-alpine AS client-builder
6+
# Pin to the build platform: the client output is static, arch-independent JS,
7+
# so there's no need to run the Vite build under emulation for each target
8+
# platform during a multi-arch build.
9+
FROM --platform=$BUILDPLATFORM node:22-alpine AS client-builder
710
WORKDIR /app/client
811
COPY client/package*.json ./
912
RUN npm ci
1013
COPY client/ ./
1114
RUN npm run build
1215

13-
# ---- Stage 2: server runtime ----
16+
# ---- Stage 2: server dependencies ----
17+
# better-sqlite3 is a native module with no musl (Alpine) prebuilt binary, so
18+
# it must be compiled from source here — which needs a toolchain. Doing it in a
19+
# dedicated stage keeps python3/make/g++ out of the final image. This stage
20+
# runs on the TARGET platform, so the compiled binary matches the runtime arch.
21+
FROM node:22-alpine AS server-deps
22+
RUN apk add --no-cache python3 make g++
23+
WORKDIR /app
24+
COPY server/package*.json ./
25+
RUN npm ci --omit=dev
26+
27+
# ---- Stage 3: server runtime ----
1428
FROM node:22-alpine
1529

1630
# docker-cli + the v2 compose plugin so `docker compose ...` works.
@@ -20,9 +34,8 @@ RUN apk add --no-cache docker-cli docker-cli-compose
2034

2135
WORKDIR /app
2236

37+
COPY --from=server-deps /app/node_modules ./node_modules
2338
COPY server/package*.json ./
24-
RUN npm ci --omit=dev
25-
2639
COPY server/src ./src
2740
COPY --from=client-builder /app/client/dist ./client/dist
2841

server/src/checker.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
/**
2+
* Active update check: for each running container, ask the registry for the
3+
* current digest of its tag and reconcile against what's running — recording
4+
* an update event when they differ, or resolving stale events when they match.
5+
*
6+
* This makes the dashboard work even if a Diun webhook was missed or never
7+
* fired (Diun only notifies on change). It complements, and does not replace,
8+
* the webhook path.
9+
*/
10+
11+
import { listContainers } from './docker.js';
12+
import { getRemoteDigest } from './registry.js';
13+
import { digestsEqual } from './reconcile.js';
14+
import * as db from './db.js';
15+
16+
const CONCURRENCY = 4;
17+
18+
/**
19+
* @returns {Promise<{ total: number, checked: number, updatesFound: number, errors: number }>}
20+
* @throws if the Docker daemon can't be reached (caller maps to 503).
21+
*/
22+
export async function runCheck() {
23+
const containers = await listContainers();
24+
25+
// De-dupe by normalized ref so we hit each image once even if several
26+
// containers run it.
27+
const byRef = new Map();
28+
for (const c of containers) {
29+
if (!byRef.has(c.normalizedRef)) byRef.set(c.normalizedRef, c);
30+
}
31+
const items = [...byRef.values()];
32+
33+
let checked = 0;
34+
let updatesFound = 0;
35+
let errors = 0;
36+
37+
let idx = 0;
38+
async function worker() {
39+
while (idx < items.length) {
40+
const c = items[idx];
41+
idx += 1;
42+
try {
43+
const remote = await getRemoteDigest(c.image);
44+
checked += 1;
45+
if (!remote) continue; // digest-pinned or registry gave no digest
46+
47+
if (c.currentDigest && digestsEqual(remote, c.currentDigest)) {
48+
// Up to date — clear any stale unresolved event.
49+
db.resolveEventsForRef(c.normalizedRef);
50+
continue;
51+
}
52+
53+
// Differs from what's running: flag it, unless we already have an
54+
// unresolved event for this exact digest (avoid duplicate rows on
55+
// repeated checks).
56+
const existing = db.latestUnresolvedEventForRef(c.normalizedRef);
57+
if (existing && digestsEqual(existing.digest, remote)) continue;
58+
59+
db.recordEvent({
60+
image: c.image,
61+
normalized_ref: c.normalizedRef,
62+
status: 'update',
63+
digest: remote,
64+
raw_json: JSON.stringify({ source: 'check' }),
65+
});
66+
updatesFound += 1;
67+
} catch (err) {
68+
errors += 1;
69+
console.warn(`checker: failed to check ${c.image}: ${err.message}`);
70+
}
71+
}
72+
}
73+
74+
await Promise.all(
75+
Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker())
76+
);
77+
78+
return { total: items.length, checked, updatesFound, errors };
79+
}
80+
81+
export default { runCheck };

0 commit comments

Comments
 (0)