Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 54 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
name: Publish image

on:
push:
branches: [main]
tags: ["v*"]

jobs:
publish:
name: Build and push to GHCR
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
steps:
- uses: actions/checkout@v4

# GHCR image names must be lowercase; the owner may not be.
- name: Compute lowercase image name
id: img
run: echo "name=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_OUTPUT"

- name: Docker metadata (tags/labels)
id: meta
uses: docker/metadata-action@v5
with:
images: ${{ steps.img.outputs.name }}
tags: |
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
type=raw,value=edge,enable={{is_default_branch}}
type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }}

- uses: docker/setup-qemu-action@v3
- uses: docker/setup-buildx-action@v3

- name: Log in to GHCR
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push
uses: docker/build-push-action@v6
with:
context: .
file: server/Dockerfile
platforms: linux/amd64,linux/arm64
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
21 changes: 21 additions & 0 deletions API_CONTRACT.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,27 @@ All request/response bodies are JSON unless noted otherwise.
- Auth: cookie.
- Response: `200` — array of container items (shape below).

### `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.
- Response:
- `200 { "total": n, "checked": n, "updatesFound": n, "errors": n }`
- `503 { "error": "docker_unavailable" }` if the Docker daemon is
unreachable.

### `GET /api/events`

- 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.

### `POST /api/update/:name`

- Auth: cookie.
Expand Down
32 changes: 32 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,19 @@ The SQLite database is created automatically in the `diun-updater-data` volume
on first start. The first time you load the UI you'll get the login screen —
enter `ADMIN_PASSWORD`.

> **Prefer a prebuilt image?** Tagged releases publish a multi-arch image
> (`linux/amd64` + `linux/arm64`) to GHCR. Instead of `build:`, point the
> compose service at it and skip the build:
>
> ```yaml
> services:
> diun-updater:
> image: ghcr.io/strandedturtle/diupdater:latest
> # ...keep the same environment + volumes as above...
> ```
>
> Then `docker compose up -d` (no `--build`).

### 5. Point Diun at the app (the webhook)

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

### Active update checks

The **Check** button (and `POST /api/check`) makes the app query the registries
directly for each running image's current digest and flag anything out of date —
independent of Diun. This is useful for a first run (Diun only sends a webhook
*when a digest changes*, so a fresh install is otherwise quiet), to recover from
a webhook that was missed while the app was down, or if you'd rather not depend
on Diun at all.

It currently supports registries reachable **anonymously** over the standard
token flow — Docker Hub, GHCR, lscr.io, quay.io, etc. for public images. Private
images that require credentials are skipped (counted under `errors`) and still
rely on Diun's webhook for their signal.

---

## Configuration reference
Expand Down
59 changes: 58 additions & 1 deletion client/src/Dashboard.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getContainers } from './api.js';
import { getContainers, checkNow } from './api.js';
import UpdateCard from './components/UpdateCard.jsx';
import UpdateAllButton from './components/UpdateAllButton.jsx';

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

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

// Actively ask the server to re-check registries, then refresh the list.
const handleCheck = useCallback(async () => {
setChecking(true);
setCheckMsg('');
try {
const r = await checkNow();
await load();
const checked = r?.checked ?? 0;
const found = r?.updatesFound ?? 0;
const errs = r?.errors ?? 0;
setCheckMsg(
`Checked ${checked} image${checked === 1 ? '' : 's'} — ${found} update${found === 1 ? '' : 's'} found` +
(errs ? `, ${errs} couldn't be checked` : '') +
'.'
);
} catch (err) {
setCheckMsg(err.message || 'Check failed');
} finally {
setChecking(false);
}
}, [load]);

// Live updates: refresh automatically when the server signals a change
// (a Diun webhook arrived, a check ran, or an update finished).
useEffect(() => {
let es;
let debounce;
try {
es = new EventSource('/api/events');
es.onmessage = (e) => {
let payload;
try {
payload = JSON.parse(e.data);
} catch {
return;
}
if (payload && payload.type === 'containers-changed') {
clearTimeout(debounce);
debounce = setTimeout(() => load(), 400);
}
};
} catch {
// EventSource unavailable — manual Refresh/Check still work.
}
return () => {
clearTimeout(debounce);
if (es) es.close();
};
}, [load]);

// Called by UpdateCard once its update settles (success/error/stream
// error). Re-fetch so digests/updateAvailable/pinned reflect server state.
const handleSettled = useCallback(() => {
Expand Down Expand Up @@ -75,6 +127,10 @@ export default function Dashboard({ onPendingCountChange }) {
)}
</div>
<div className="dashboard-actions">
<button type="button" className="btn btn-sm" onClick={handleCheck} disabled={checking || loading}>
{checking && <span className="spinner" aria-hidden="true" />}
Check
</button>
<button type="button" className="btn btn-sm" onClick={handleRefresh} disabled={refreshing || loading}>
{refreshing && <span className="spinner" aria-hidden="true" />}
Refresh
Expand All @@ -86,6 +142,7 @@ export default function Dashboard({ onPendingCountChange }) {
/>
</div>
</div>
{checkMsg && <p className="check-msg">{checkMsg}</p>}

{loading && (
<div className="dashboard-list" aria-busy="true" aria-label="Loading containers">
Expand Down
6 changes: 6 additions & 0 deletions client/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,12 @@ export function getContainers() {
return get('/containers');
}

// Actively re-check registries for newer digests. Returns
// { total, checked, updatesFound, errors }.
export function checkNow() {
return post('/check');
}

export function startUpdate(name) {
return post(`/update/${encodeURIComponent(name)}`);
}
Expand Down
8 changes: 8 additions & 0 deletions client/src/styles/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -928,3 +928,11 @@ a {
text-overflow: ellipsis;
white-space: nowrap;
}

/* ---------- Check result message ---------- */

.check-msg {
margin: 0 0 12px;
font-size: 0.85rem;
color: var(--text-secondary);
}
21 changes: 17 additions & 4 deletions server/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,28 @@
# (docker-compose.yml at the repo root is already configured this way.)

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

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

# ---- Stage 3: server runtime ----
FROM node:22-alpine

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

WORKDIR /app

COPY --from=server-deps /app/node_modules ./node_modules
COPY server/package*.json ./
RUN npm ci --omit=dev

COPY server/src ./src
COPY --from=client-builder /app/client/dist ./client/dist

Expand Down
81 changes: 81 additions & 0 deletions server/src/checker.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* Active update check: for each running container, ask the registry for the
* current digest of its tag and reconcile against what's running — recording
* an update event when they differ, or resolving stale events when they match.
*
* This makes the dashboard work even if a Diun webhook was missed or never
* fired (Diun only notifies on change). It complements, and does not replace,
* the webhook path.
*/

import { listContainers } from './docker.js';
import { getRemoteDigest } from './registry.js';
import { digestsEqual } from './reconcile.js';
import * as db from './db.js';

const CONCURRENCY = 4;

/**
* @returns {Promise<{ total: number, checked: number, updatesFound: number, errors: number }>}
* @throws if the Docker daemon can't be reached (caller maps to 503).
*/
export async function runCheck() {
const containers = await listContainers();

// De-dupe by normalized ref so we hit each image once even if several
// containers run it.
const byRef = new Map();
for (const c of containers) {
if (!byRef.has(c.normalizedRef)) byRef.set(c.normalizedRef, c);
}
const items = [...byRef.values()];

let checked = 0;
let updatesFound = 0;
let errors = 0;

let idx = 0;
async function worker() {
while (idx < items.length) {
const c = items[idx];
idx += 1;
try {
const remote = await getRemoteDigest(c.image);
checked += 1;
if (!remote) continue; // digest-pinned or registry gave no digest

if (c.currentDigest && digestsEqual(remote, c.currentDigest)) {
// Up to date — clear any stale unresolved event.
db.resolveEventsForRef(c.normalizedRef);
continue;
}

// Differs from what's running: flag it, unless we already have an
// unresolved event for this exact digest (avoid duplicate rows on
// repeated checks).
const existing = db.latestUnresolvedEventForRef(c.normalizedRef);
if (existing && digestsEqual(existing.digest, remote)) continue;

db.recordEvent({
image: c.image,
normalized_ref: c.normalizedRef,
status: 'update',
digest: remote,
raw_json: JSON.stringify({ source: 'check' }),
});
updatesFound += 1;
} catch (err) {
errors += 1;
console.warn(`checker: failed to check ${c.image}: ${err.message}`);
}
}
}

await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, items.length) }, () => worker())
);

return { total: items.length, checked, updatesFound, errors };
}

export default { runCheck };
Loading
Loading