From 2d7e84b691200d53ba325e1ccaec2aef44ffed10 Mon Sep 17 00:00:00 2001 From: strandedturtle Date: Thu, 25 Jun 2026 20:43:36 +0000 Subject: [PATCH] Phase 1: drop Diun, fix update bug, dashboard UX overhaul Make the app self-contained and fix the blocking update failure. - Fix the compose-mount bug: docker compose runs inside this container and reads the compose file from its own filesystem, so check fs.existsSync up front and return an actionable "mount your stacks at the same path" error instead of a cryptic "no such file or directory". Add GET /api/diagnostics + a dashboard banner that warns when STACKS_DIR isn't mounted, before an update is even attempted. - Drop Diun entirely: remove the webhook ingest, the /api/diun/webhook route, and the DIUN_WEBHOOK_TOKEN requirement. The active registry check (checker.js + registry.js) is now the sole source of update info. Scrub Diun from README, docker-compose.yml, .env.example, API_CONTRACT.md. - Collapse "Check" + "Refresh" into one "Check for updates" action and auto-run it on first open each session. - Group containers by stack (compose project) in collapsible sections that remember open/closed; sort containers with updates to the top. - Default to showing only containers that need an update, with an All/Updates-only filter. - Show real version numbers (org.opencontainers.image.version label, else the tag) instead of digest hashes; demote the digest to a tooltip. - Relabel pinning as "Pin Version". Server tests 60/60; client builds clean. --- .env.example | 5 - API_CONTRACT.md | 90 +++---- README.md | 353 +++++++++---------------- client/src/Dashboard.jsx | 237 +++++++++++++---- client/src/api.js | 6 + client/src/components/StackGroup.jsx | 69 +++++ client/src/components/UpdateCard.jsx | 62 +++-- client/src/pages/SettingsPage.jsx | 10 +- client/src/styles/app.css | 169 +++++++++++- docker-compose.yml | 17 +- server/src/checker.js | 5 +- server/src/config.js | 6 +- server/src/containers-service.js | 2 + server/src/docker.js | 118 +++++++-- server/src/index.js | 16 +- server/src/registry.js | 2 +- server/src/routes/api.js | 13 +- server/src/sse.js | 2 +- server/src/webhook.js | 82 ------ server/test/containers-service.test.js | 4 + 20 files changed, 759 insertions(+), 509 deletions(-) create mode 100644 client/src/components/StackGroup.jsx delete mode 100644 server/src/webhook.js diff --git a/.env.example b/.env.example index e728908..e2d5248 100644 --- a/.env.example +++ b/.env.example @@ -22,11 +22,6 @@ ADMIN_PASSWORD=change-me # Generate one with: openssl rand -hex 32 SESSION_SECRET= -# Bearer token Diun must send in the Authorization header when posting -# webhook events to /api/diun/webhook. -# Generate one with: openssl rand -hex 32 -DIUN_WEBHOOK_TOKEN= - # Session cookie lifetime, in seconds. Default is 7 days. SESSION_TTL=604800 diff --git a/API_CONTRACT.md b/API_CONTRACT.md index 07fef7a..65e8cbc 100644 --- a/API_CONTRACT.md +++ b/API_CONTRACT.md @@ -12,13 +12,10 @@ All request/response bodies are JSON unless noted otherwise. - On successful login, the server sets a signed, httpOnly cookie named `diun_session` (`SameSite=Lax`, `Secure` when served over HTTPS, `Max-Age` = `SESSION_TTL` seconds). -- Protected routes (everything except `/api/auth/login`, `/api/health`, and - `/api/diun/webhook`) require a valid `diun_session` cookie. If it is - missing, invalid, or expired, the server responds `401 Unauthorized` with +- Protected routes (everything except `/api/auth/login` and `/api/health`) + require a valid `diun_session` cookie. If it is missing, invalid, or + expired, the server responds `401 Unauthorized` with `{ "error": "unauthorized" }`. -- The Diun webhook route uses a separate auth mechanism: a static bearer - token (`DIUN_WEBHOOK_TOKEN`) in the `Authorization` header. It does not - use the session cookie. ## Endpoints @@ -43,19 +40,20 @@ All request/response bodies are JSON unless noted otherwise. - Auth: cookie (optional — never errors, reports status). - Response: `200 { "authenticated": boolean }` -### `POST /api/diun/webhook` - -- Auth: token — header `Authorization: Bearer `. `401` - if missing/invalid. -- Body: Diun webhook payload (see below). -- Response: `204 No Content` on successful ingest. `400` if the payload is - malformed. - ### `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. + ### `POST /api/check` - Auth: cookie. @@ -162,13 +160,15 @@ always returns normalized refs. "project": "web", "service": "nginx", "image": "nginx:latest", + "tag": "latest", + "currentVersion": "1.27.3", "currentDigest": "sha256:...", "updateAvailable": true, "availableDigest": "sha256:...", "pinned": false, "state": "running", - "composeFile": "/stacks/web/docker-compose.yml", - "workingDir": "/stacks/web" + "composeFile": "/opt/stacks/web/compose.yaml", + "workingDir": "/opt/stacks/web" } ``` @@ -178,56 +178,30 @@ Field notes: - `project` / `service` — derived from the `com.docker.compose.project` / `com.docker.compose.service` labels. - `image` — image ref as configured (tag, not digest). +- `tag` — the tag portion of `image` (e.g. `latest`, `1.27`), or `null` if + 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`). - `currentDigest` — digest of the image the running container was created from. -- `updateAvailable` — `true` if the most recent unresolved Diun event for - this image's normalized ref reports a digest different from - `currentDigest`. +- `updateAvailable` — `true` if the most recent unresolved update event + (from the registry check) for this image's normalized ref reports a digest + different from `currentDigest`. - `availableDigest` — the digest from that unresolved event, if any (else `null`). -- `pinned` — `true` if the image ref is in the `pinned` table (update - indicator is suppressed, but manual update is still allowed). +- `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). - `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. -## Diun webhook payload - -Diun's webhook notifier posts a JSON body shaped roughly like: - -```json -{ - "status": "update", - "image": "nginx:latest", - "digest": "sha256:abc123...", - "provider": "docker", - "hub_link": "https://hub.docker.com/_/nginx", - "platform": "linux/amd64", - "metadata": { - "hostname": "docker-host-1", - "container": "nginx", - "...": "additional Diun metadata fields" - } -} -``` +## Update events -Fields we read: - -- `status` — `"new"` (first time Diun sees this image) or `"update"` (a - newer digest was found). Both are recorded; only `"update"` events are - meaningful for the update indicator. -- `image` — the image ref Diun checked, used to derive `normalized_ref` - (registry/repo without tag-specific noise, used to key - `update_events.normalized_ref`). -- `digest` — the new digest Diun observed. -- `provider` — Diun provider (`docker`, `swarm`, etc.) — stored for - reference. -- `hub_link` — informational link, stored for reference. -- `platform` — image platform string, stored for reference. -- `metadata` — passthrough object with additional Diun-provided context; - stored as part of `raw_json`, not parsed individually. - -The full raw payload is stored as `raw_json` in `update_events` for -debugging/audit, regardless of which fields are explicitly parsed. +`update_events` rows are produced solely by the active registry check +(`POST /api/check` and the background scheduler). Each records the +`normalized_ref` and the registry-reported `digest`; a row is `resolved` once +the running container's digest matches it (the update was applied). There is +no external notifier — the app queries registries directly. diff --git a/README.md b/README.md index 46e389a..dbf95b6 100644 --- a/README.md +++ b/README.md @@ -1,17 +1,21 @@ # Diun Web Updater A small, self-hosted, mobile-first web UI for updating Docker containers that -are managed by `docker compose` (e.g. via [Dockge](https://github.com/louislam/dockge)), -with "update available" signals supplied by [Diun](https://crazymax.dev/diun/) -webhooks. +are managed by `docker compose` (e.g. via [Dockge](https://github.com/louislam/dockge)). +It checks your images' registries for newer versions and lets you apply updates +with one tap — **manually, never automatically** (no watchtower-style surprise +upgrades). -It exists to replace this workflow: *Diun pings Discord at 9am → you open -Dockge on your phone (which is awkward on mobile) → you cross-reference which -stack to update → you click update.* Instead you get one screen that lists your -containers, shows which have updates, and updates them with one tap — **manually, -never automatically** (no watchtower-style surprise upgrades). +It exists to replace this workflow: *something pings you that an update exists → +you open Dockge on your phone (which is awkward on mobile) → you cross-reference +which stack to update → you click update.* Instead you get one screen that lists +your containers grouped by stack, shows which have updates, and updates them with +one tap. -![one container per card, badge when an update is available, tap Update to pull + recreate] +> **Self-contained.** This app talks to your Docker socket and queries image +> registries directly. It does **not** require Diun (or any external notifier). + +![one card per container, grouped by stack, badge when an update is available, tap Update to pull + recreate] --- @@ -24,11 +28,9 @@ never automatically** (no watchtower-style surprise upgrades). - [Step-by-step setup](#step-by-step-setup) - [1. Get the code onto your server](#1-get-the-code-onto-your-server) - [2. Create your `.env`](#2-create-your-env) - - [3. Configure the compose file](#3-configure-the-compose-file) + - [3. Configure the compose file (the same-path mount)](#3-configure-the-compose-file-the-same-path-mount) - [4. Build and start](#4-build-and-start) - - [5. Point Diun at the app (the webhook)](#5-point-diun-at-the-app-the-webhook) - - [6. Put them on the same network](#6-put-them-on-the-same-network) - - [7. (Optional) Expose it with a Cloudflare Tunnel](#7-optional-expose-it-with-a-cloudflare-tunnel) + - [5. (Optional) Expose it with a Cloudflare Tunnel](#5-optional-expose-it-with-a-cloudflare-tunnel) - [Using the app](#using-the-app) - [Configuration reference](#configuration-reference) - [Security notes](#security-notes-read-this) @@ -40,26 +42,22 @@ never automatically** (no watchtower-style surprise upgrades). ## How it works -1. **Diun watches your images.** When a tracked image gets a new digest, Diun - POSTs a webhook event to `POST /api/diun/webhook` on this app (authenticated - with a bearer token). The event (image, normalized ref, digest, status) is - stored in SQLite. *Your existing Diun notifiers — Discord, etc. — keep - working; this is just an additional notifier.* -2. **The dashboard reconciles against live Docker state.** When you open the - app, it lists your containers from the Docker socket and reads each one's - **currently-running image digest**. If there's an unresolved Diun event for - that image whose digest differs from what's running, the container is flagged - **Update available**. Because the running digest is the source of truth, the - badge is self-correcting: if you update a container elsewhere (e.g. Dockge), - the badge clears on the next refresh. +1. **You open the app and it checks.** On load (and whenever you tap **Check for + updates**), the app lists your containers from the Docker socket, reads each + one's **currently-running image digest**, and asks each image's registry for + the current digest of its tag — without pulling anything. Anything whose + registry digest differs from what's running is flagged **Update available**. +2. **Live Docker state is the source of truth.** The badge is self-correcting: + if you update a container elsewhere (e.g. Dockge), the badge clears on the + next check, because the app always reconciles against the digest that's + actually running. 3. **You click Update.** The app runs `docker compose pull` then `docker compose up -d` for that one service, using the compose file recorded in the container's own labels, and streams the live output back to your browser. On success it records the update in history and clears the badge. - **There is no scheduler and no auto-update** — nothing changes until you tap - Update. + **There is no auto-update** — nothing changes until you tap Update. -The full endpoint/payload reference is in [`API_CONTRACT.md`](./API_CONTRACT.md). +The full endpoint/field reference is in [`API_CONTRACT.md`](./API_CONTRACT.md). --- @@ -67,16 +65,15 @@ The full endpoint/payload reference is in [`API_CONTRACT.md`](./API_CONTRACT.md) - A Linux host with **Docker** and the **`docker compose` v2** plugin. - Your stacks are managed by `docker compose` (Dockge counts — it's compose - under the hood). Containers must carry the standard - `com.docker.compose.*` labels, which compose adds automatically. -- **Diun** already running (or willing to run) with a `docker` provider. -- Your compose stacks live in one directory on the host (e.g. - `/home/youruser/docker/stacks` or Dockge's `/opt/stacks`). + under the hood). Containers must carry the standard `com.docker.compose.*` + labels, which compose adds automatically. +- Your compose stacks live in one directory on the host (Dockge's default is + `/opt/stacks`). > **One hard rule:** the stacks directory must be bind-mounted into this > container at the **same absolute path** it has on the host. See -> [step 3](#3-configure-the-compose-file) and [security notes](#security-notes-read-this) -> for why. +> [step 3](#3-configure-the-compose-file-the-same-path-mount) and +> [security notes](#security-notes-read-this) for why. --- @@ -87,18 +84,15 @@ The full endpoint/payload reference is in [`API_CONTRACT.md`](./API_CONTRACT.md) git clone diupdater && cd diupdater cp .env.example .env -# generate two secrets and a password, paste them into .env -openssl rand -hex 32 # -> SESSION_SECRET -openssl rand -hex 32 # -> DIUN_WEBHOOK_TOKEN +# fill in .env: +openssl rand -hex 32 # -> paste as SESSION_SECRET # set ADMIN_PASSWORD to something strong -# set STACKS_DIR to the absolute host path of your compose stacks +# set STACKS_DIR to the absolute host path of your compose stacks (e.g. /opt/stacks) docker compose up -d --build ``` -Then add the webhook notifier to Diun ([step 5](#5-point-diun-at-the-app-the-webhook)), -put both services on the same Docker network ([step 6](#6-put-them-on-the-same-network)), -and open `http://:5000`. +Then open `http://:5000` and log in with `ADMIN_PASSWORD`. --- @@ -106,8 +100,9 @@ and open `http://:5000`. If you already manage stacks with Docker Compose (or Dockge), the quickest path is the **prebuilt image** — no cloning, no building. Drop this service into an -existing compose file (e.g. the `management` stack alongside Diun) and fill in -the three secrets: +existing compose file (e.g. a `management` stack) and fill in the two secrets. +**Set both the `STACKS_DIR` env and the stacks volume to your real stacks path** +(Dockge users: `/opt/stacks`): ```yaml services: @@ -118,43 +113,40 @@ services: ports: - "5000:5000" environment: - - ADMIN_PASSWORD=change-me # your login password - - SESSION_SECRET=REPLACE_ME # openssl rand -hex 32 - - DIUN_WEBHOOK_TOKEN=REPLACE_ME # openssl rand -hex 32 - - STACKS_DIR=/home/youruser/docker/stacks # absolute host path to your stacks + - ADMIN_PASSWORD=change-me # your login password + - SESSION_SECRET=REPLACE_ME # openssl rand -hex 32 + - STACKS_DIR=/opt/stacks # absolute host path to your stacks volumes: - /var/run/docker.sock:/var/run/docker.sock - # ⚠️ SAME absolute path on the host AND inside the container — otherwise - # relative bind mounts in your other stacks break on recreate. - - /home/youruser/docker/stacks:/home/youruser/docker/stacks + # ⚠️ SAME absolute path on the host AND inside the container. This MUST + # match STACKS_DIR above, or updates fail with "compose file not found" + # and relative bind mounts in your other stacks break on recreate. + - /opt/stacks:/opt/stacks - diun-updater-data:/data volumes: diun-updater-data: ``` -Generate the two secrets (`openssl rand -hex 32` each), then start just this -service: +Generate the secret (`openssl rand -hex 32`), then start just this service: ```bash docker compose up -d diun-updater ``` -Finish by adding the Diun webhook notifier ([step 5](#5-point-diun-at-the-app-the-webhook)), -making sure Diun and `diun-updater` share a Docker network ([step 6](#6-put-them-on-the-same-network)), -then open `http://:5000`. +Then open `http://:5000`. **Image tags:** `:edge` tracks the latest commit on `main`; cutting a release tag (`git tag v0.1.0 && git push origin v0.1.0`) also publishes `:latest` and semver tags (`:0.1.0`, `:0.1`). Pin to a version for stability. > **Can't pull the image?** The GHCR package inherits the repo's visibility. To -> let other hosts/people pull it without auth, make the package public: GitHub → -> your avatar → **Packages** → `diupdater` → **Package settings** → **Change +> let other hosts pull it without auth, make the package public: GitHub → your +> avatar → **Packages** → `diupdater` → **Package settings** → **Change > visibility** → *Public*. Otherwise run `docker login ghcr.io` (with a PAT that > has `read:packages`) on each host first. -The [same-path mount](#3-configure-the-compose-file) and +The [same-path mount](#3-configure-the-compose-file-the-same-path-mount) and [Docker-socket](#security-notes-read-this) warnings apply here too. Prefer to build from source? Use [Step-by-step setup](#step-by-step-setup) below instead. @@ -164,9 +156,6 @@ build from source? Use [Step-by-step setup](#step-by-step-setup) below instead. ### 1. Get the code onto your server -Clone (or copy) this repository onto the Docker host. The whole app builds from -this directory. - ```bash git clone diupdater cd diupdater @@ -178,74 +167,54 @@ cd diupdater cp .env.example .env ``` -Edit `.env` and set every required value: +Edit `.env` and set the required values: ```ini # --- required --- ADMIN_PASSWORD=choose-a-strong-password SESSION_SECRET= -DIUN_WEBHOOK_TOKEN= -# Absolute host path of your compose stacks (Dockge users: usually /opt/stacks) -STACKS_DIR=/home/youruser/docker/stacks +# Absolute host path of your compose stacks (Dockge users: /opt/stacks) +STACKS_DIR=/opt/stacks # --- optional (defaults shown) --- PORT=5000 DOCKER_SOCKET=/var/run/docker.sock DATA_DIR=/data SESSION_TTL=604800 -BASE_URL=http://localhost:5000 # set to your real URL if behind a tunnel/proxy (https) -``` - -Generate the two secrets: - -```bash -openssl rand -hex 32 # SESSION_SECRET (signs the login cookie) -openssl rand -hex 32 # DIUN_WEBHOOK_TOKEN (Diun must present this to post events) +BASE_URL=http://localhost:5000 # set to your real https URL if behind a tunnel/proxy ``` > If `BASE_URL` starts with `https`, the login cookie is marked `Secure` (only -> sent over HTTPS). Keep it `http://...` for plain LAN access, set it to your +> sent over HTTPS). Keep it `http://...` for plain LAN access; set it to your > `https://...` hostname when serving over a tunnel/reverse proxy. -### 3. Configure the compose file +### 3. Configure the compose file (the same-path mount) The provided [`docker-compose.yml`](./docker-compose.yml) is ready to use. The critical part is the **same-path stacks mount**: ```yaml -services: - diun-updater: - build: - context: . - dockerfile: server/Dockerfile - container_name: diun-updater - restart: unless-stopped - ports: - - "5000:5000" - environment: - - ADMIN_PASSWORD=${ADMIN_PASSWORD} - - SESSION_SECRET=${SESSION_SECRET} - - DIUN_WEBHOOK_TOKEN=${DIUN_WEBHOOK_TOKEN} - - STACKS_DIR=${STACKS_DIR} volumes: - /var/run/docker.sock:/var/run/docker.sock # ⚠️ SAME PATH on host and in container — do not change one side only: - ${STACKS_DIR}:${STACKS_DIR} - diun-updater-data:/data # persistent SQLite (events/history/pins) - -volumes: - diun-updater-data: ``` **Why same-path?** This app calls `docker compose` against the *host* Docker -daemon over the socket. The daemon resolves relative paths in your stacks' -compose files (volumes like `./data:/data`, build contexts, `env_file`) against -the path it sees on the host. If this container saw the stacks at `/stacks` but -the host has them at `/home/youruser/docker/stacks`, every relative bind mount -would resolve to a path that doesn't exist on the host and your volumes would -break on recreate. Mounting at the identical path keeps them correct. (This is -the same constraint Dockge imposes, for the same reason.) +daemon over the socket, but the `docker compose` CLI reads the compose file from +*this container's* filesystem, and the daemon resolves relative paths in your +stacks' compose files (volumes like `./data:/data`, build contexts, `env_file`) +against the path it sees on the host. If this container saw the stacks at +`/stacks` but the host has them at `/opt/stacks`, the CLI couldn't find the +compose file (you'd get `open /opt/stacks//compose.yaml: no such file or +directory`) and any relative bind mount would resolve to a non-existent host +path. Mounting at the identical path keeps both correct. (This is the same +constraint Dockge imposes, for the same reason.) + +> If the stacks dir isn't mounted at the right path, the app shows a warning +> banner at the top of the dashboard so you can fix it before an update fails. ### 4. Build and start @@ -262,62 +231,14 @@ docker logs diun-updater # -> "...server listening at ..." 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`. +enter `ADMIN_PASSWORD`, and the dashboard will run an initial update check. -> **Prefer a prebuilt image?** Tagged releases publish a multi-arch image +> **Prefer a prebuilt image?** 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 -existing Discord notifier — keep both). In Diun's `diun.yml`: +> compose service at `image: ghcr.io/strandedturtle/diupdater:edge` (keep the +> same environment + volumes) and `docker compose up -d` (no `--build`). -```yaml -notif: - # ... your existing discord notifier stays here ... - webhook: - endpoint: http://diun-updater:5000/api/diun/webhook - method: POST - headers: - Authorization: "Bearer " -``` - -Use the **same token** you put in `.env`. Then restart Diun -(`docker compose up -d diun`). - -> Diun only fires a webhook **when a digest changes** — it does not re-send on a -> schedule. So a brand-new install won't show any badges until Diun next detects -> an update. To test immediately, see [Troubleshooting → "No badges appear"](#troubleshooting). - -### 6. Put them on the same network - -For `http://diun-updater:5000` to resolve from the Diun container, both -containers must share a Docker network. If they're in the same compose project, -that's automatic. If Diun is in a different project, attach both to a shared -external network, e.g.: - -```yaml -# in both diun's and diun-updater's compose files -networks: - default: - name: management - external: true -``` - -Alternatively, point Diun's `endpoint` at the host IP/port instead of the -service name (e.g. `http://192.168.1.10:5000/api/diun/webhook`). - -### 7. (Optional) Expose it with a Cloudflare Tunnel +### 5. (Optional) Expose it with a Cloudflare Tunnel Add a hostname to your tunnel config pointing at the app, then set `BASE_URL=https://updates.example.org` in `.env` and restart so the login cookie @@ -336,52 +257,44 @@ For extra safety you can also put **Cloudflare Access** in front of it. Open `http://:5000` (or your tunnel URL) and log in with `ADMIN_PASSWORD`. -**Updates tab (home).** One card per container: -- **Update available** cards show a highlighted badge and the - `Current → Available` short digests. -- Tap **Update** to pull the new image and recreate that service. The card shows - a spinner; tap **Show logs** to watch the live `docker compose pull` / `up -d` - output stream in. When it finishes you get a success/error message and the - badge clears. +**Updates tab (home).** Containers are **grouped by stack** (their compose +project / Dockge folder) in collapsible sections, with anything that has an +update sorted to the top. By default the list shows **only containers that need +an update**; flip the filter to **All** to see everything. + +- When you open the app it automatically runs a check. **Check for updates** + re-runs it on demand (queries each image's registry for a newer digest). +- Each card shows the image, its **version** (from the image's + `org.opencontainers.image.version` label when present, otherwise its tag), and + whether an update is available. +- Tap **Update** to pull the new image and recreate that service. Tap **Show + logs** to watch the live `docker compose pull` / `up -d` output. When it + finishes you get a success/error message and the 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. +- The dashboard **updates itself live** — when a check runs or an update + finishes, the list refreshes automatically. +- **Pin Version** holds a container at its current version (it stops being + flagged for updates and moves to a separate section). You can still update it + manually. **History tab.** A log of past updates (container, image, old→new digest, -success/failure, relative time). Tap a row to expand full details; "Load more" -pages through older entries. +success/failure, relative time). Tap a row to expand; "Load more" pages older +entries. -**Settings tab.** -- **Appearance** — dark/light theme toggle (also in the header); the choice - persists across reloads. -- **Pinned images** — list of pinned refs with an Unpin button. -- **About** — app info and a server-health indicator. +**Settings tab.** Theme (dark/light), pinned-version management, and a +server-health indicator. **Install as a mobile app (PWA).** In your phone's browser, use "Add to Home -Screen". It installs as a standalone, full-screen app with an icon — this is the -mobile experience that replaces fiddling with Dockge. +Screen". It installs as a standalone, full-screen app with an icon. -### Active update checks +### About the update check -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. +The check queries registries directly for each running image's current digest +and flags anything out of date. It 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`). --- @@ -393,7 +306,6 @@ All configuration is via environment variables (see `.env.example`). |---|---|---|---| | `ADMIN_PASSWORD` | — | ✅ | Single shared login password. | | `SESSION_SECRET` | — | ✅ | Signs the session cookie. `openssl rand -hex 32`. | -| `DIUN_WEBHOOK_TOKEN` | — | ✅ | Bearer token Diun must present to post events. `openssl rand -hex 32`. | | `STACKS_DIR` | `/stacks` | ✅ (effectively) | Host path of your compose stacks. **Must be mounted at the identical path in the container.** | | `PORT` | `5000` | | Server listen port. | | `DOCKER_SOCKET` | `/var/run/docker.sock` | | Docker socket path. | @@ -402,7 +314,7 @@ All configuration is via environment variables (see `.env.example`). | `BASE_URL` | `http://localhost:5000` | | Public URL; if `https`, the cookie is set `Secure`. | | `SELF_CONTAINER_NAME` | `diun-updater` | | This app's own container name, excluded from the dashboard so it can't update itself. | -The three required vars are enforced at startup — the server refuses to boot +The two required vars are enforced at startup — the server refuses to boot without them (a `SKIP_CONFIG_CHECK=1` escape hatch exists for skeleton smoke-tests only; never use it in production). @@ -414,12 +326,9 @@ smoke-tests only; never use it in production). gives this app full control over every container, image, network, and volume on the host — effectively root on the host. Run it only on hosts you trust, keep it on an internal network, and keep it behind the login (and ideally a - reverse proxy with TLS or Cloudflare Access). Note that mounting the socket - `:ro` does **not** restrict this — `:ro` only makes the socket *file* - read-only; the Docker API still allows writes. -- **The webhook endpoint is the one public, cookie-less route.** It's protected - by `DIUN_WEBHOOK_TOKEN` (constant-time compared). Treat that token like a - password and don't expose the app publicly without a proxy if you can avoid it. + reverse proxy with TLS or Cloudflare Access). Mounting the socket `:ro` does + **not** restrict this — `:ro` only makes the socket *file* read-only; the + Docker API still allows writes. - **Auth** is a single password compared in constant time, issuing a signed, `httpOnly`, `SameSite=Lax` cookie (`Secure` when `BASE_URL` is https). Failed logins are rate-limited per client IP (lockout after repeated failures) to @@ -433,40 +342,28 @@ smoke-tests only; never use it in production). ## Troubleshooting -**"No badges appear" / nothing shows as updatable.** -Diun only sends a webhook when a digest *changes*, so a fresh setup is quiet -until then. Confirm the pipe works by posting a fake event for an image you're -running an older version of (replace the token and image): - -```bash -curl -i -X POST http://localhost:5000/api/diun/webhook \ - -H "Authorization: Bearer " \ - -H "Content-Type: application/json" \ - -d '{"status":"update","image":"nginx:latest","digest":"sha256:deadbeef"}' -# -> HTTP/1.1 204 -``` - -Refresh the dashboard; the matching container should now show **Update -available**. (Use a real newer digest, or just any different digest, to test the -indicator.) - -**Webhook returns 401.** The `Authorization: Bearer ...` token in Diun's config -doesn't match `DIUN_WEBHOOK_TOKEN`. Re-copy it and restart Diun. +**Update fails with `compose file not found` / `no such file or directory`.** +This is the **same-path mount**. The `docker compose` CLI runs inside this +container and reads your compose file from this container's filesystem, so your +stacks dir must be mounted at the identical absolute path on both sides +(`${STACKS_DIR}:${STACKS_DIR}`), and `STACKS_DIR` must match where your compose +files actually live on the host (Dockge: `/opt/stacks`). The dashboard shows a +warning banner when it detects the stacks dir isn't mounted. **`GET /api/containers` returns 503 `docker_unavailable`.** The app can't reach the Docker daemon. Check the socket is mounted (`/var/run/docker.sock`) and the path matches `DOCKER_SOCKET`. -**Update fails with "compose file not found" or volumes break after update.** -Almost always the **same-path mount**. Verify the stacks directory is mounted at -the identical absolute path on both sides (`${STACKS_DIR}:${STACKS_DIR}`), and -that `STACKS_DIR` matches where your compose files actually live on the host. +**A check reports updates under `errors` / some images never flag.** Those +images are on registries that need credentials (private images), which the +anonymous check can't query. Public images on Docker Hub / GHCR / lscr.io / +quay.io work. **Badge won't clear after a successful update.** A successful update resolves the pending event automatically (this also covers multi-arch images, where the registry digest and the running digest legitimately differ). If a badge sticks, -hit **Refresh**; if it persists, there may be a genuinely newer event — check -the History tab and `docker logs diun-updater`. +tap **Check for updates** again; if it persists, there may be a genuinely newer +image — check the History tab and `docker logs diun-updater`. **Can't log in / cookie not sticking.** If you're on `https`, make sure `BASE_URL` is your `https://` URL (otherwise the `Secure` cookie won't be set @@ -476,10 +373,8 @@ appropriately). Clear the cookie and retry. ## Known limitations -- **Missed webhooks aren't re-sent.** If the app is down when Diun fires, that - event is lost until the image changes again. The dashboard self-heals on the - next change because it always reconciles against live Docker state, but there's - no active "re-check registries now" button in this version. +- **The update check needs registries reachable anonymously.** Private images + that require credentials are skipped for now. - **Standalone (non-compose) containers** are updated on a best-effort basis (pull + recreate preserving config); compose-managed containers are the supported path and what you should rely on. @@ -496,7 +391,7 @@ Run the server and client separately (two terminals): cd server npm install # provide the required env vars (or SKIP_CONFIG_CHECK=1 for a no-secrets boot) -ADMIN_PASSWORD=dev SESSION_SECRET=dev DIUN_WEBHOOK_TOKEN=dev DATA_DIR=./.data npm start +ADMIN_PASSWORD=dev SESSION_SECRET=dev DATA_DIR=./.data npm start # Terminal 2 — Vite dev server on :5173 (proxies /api to :5000) cd client @@ -510,7 +405,7 @@ Open `http://localhost:5173`. Without a Docker daemon, `/api/containers` returns Run the server test suite and build the client: ```bash -cd server && npm test # node --test (reconcile, containers-service, auth) +cd server && npm test # node --test (reconcile, containers-service, auth, registry) cd client && npm run build # production bundle -> client/dist/ ``` diff --git a/client/src/Dashboard.jsx b/client/src/Dashboard.jsx index 3bc9a6c..ef7e0bf 100644 --- a/client/src/Dashboard.jsx +++ b/client/src/Dashboard.jsx @@ -1,15 +1,40 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { getContainers, checkNow } from './api.js'; +import { getContainers, checkNow, getDiagnostics } 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'; + +function hasUpdate(c) { + return c.updateAvailable && !c.pinned; +} + +// Sort: containers needing an update first, then by name. +function byUpdateThenName(a, b) { + const au = hasUpdate(a) ? 0 : 1; + const bu = hasUpdate(b) ? 0 : 1; + if (au !== bu) return au - bu; + return a.name.localeCompare(b.name); +} export default function Dashboard({ onPendingCountChange }) { const [containers, setContainers] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(''); - const [refreshing, setRefreshing] = useState(false); 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'; + } + }); // name -> run() function, populated by each UpdateCard so "Update all" // can drive the same start+SSE flow the per-card button uses. @@ -25,19 +50,8 @@ export default function Dashboard({ onPendingCountChange }) { } }, []); - useEffect(() => { - setLoading(true); - load().finally(() => setLoading(false)); - }, [load]); - - const handleRefresh = useCallback(async () => { - setRefreshing(true); - await load(); - setRefreshing(false); - }, [load]); - // Actively ask the server to re-check registries, then refresh the list. - const handleCheck = useCallback(async () => { + const check = useCallback(async () => { setChecking(true); setCheckMsg(''); try { @@ -58,8 +72,57 @@ export default function Dashboard({ onPendingCountChange }) { } }, [load]); - // Live updates: refresh automatically when the server signals a change - // (a Diun webhook arrived, a check ran, or an update finished). + // Initial load + auto-check on first open this session. + useEffect(() => { + let cancelled = false; + (async () => { + setLoading(true); + await load(); + if (cancelled) return; + setLoading(false); + + let autoCheck = true; + try { + autoCheck = localStorage.getItem(AUTOCHECK_KEY) !== '0'; + } catch { + autoCheck = true; + } + let alreadyChecked = false; + try { + alreadyChecked = sessionStorage.getItem(AUTOCHECK_SESSION) === '1'; + } catch { + alreadyChecked = false; + } + if (autoCheck && !alreadyChecked) { + try { + sessionStorage.setItem(AUTOCHECK_SESSION, '1'); + } catch { + // ignore + } + check(); + } + })(); + return () => { + cancelled = true; + }; + // 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; let debounce; @@ -78,7 +141,7 @@ export default function Dashboard({ onPendingCountChange }) { } }; } catch { - // EventSource unavailable — manual Refresh/Check still work. + // EventSource unavailable — manual Check still works. } return () => { clearTimeout(debounce); @@ -86,8 +149,6 @@ export default function Dashboard({ onPendingCountChange }) { }; }, [load]); - // Called by UpdateCard once its update settles (success/error/stream - // error). Re-fetch so digests/updateAvailable/pinned reflect server state. const handleSettled = useCallback(() => { load(); }, [load]); @@ -106,15 +167,53 @@ export default function Dashboard({ onPendingCountChange }) { return runFn(); }, []); - const pendingTargets = useMemo( - () => containers.filter((c) => c.updateAvailable && !c.pinned).map((c) => c.name), - [containers] - ); + const setFilterPersisted = useCallback((value) => { + setFilter(value); + try { + localStorage.setItem(FILTER_KEY, value); + } catch { + // ignore + } + }, []); + + const pendingTargets = useMemo(() => containers.filter(hasUpdate).map((c) => c.name), [containers]); 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. + const groups = useMemo(() => { + const visible = filter === 'updates' ? containers.filter(hasUpdate) : containers; + const byProject = new Map(); + for (const c of visible) { + 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, + }); + } + out.sort((a, b) => { + const au = a.updateCount > 0 ? 0 : 1; + const bu = b.updateCount > 0 ? 0 : 1; + if (au !== bu) return au - bu; + if (a.project === UNGROUPED) return 1; + if (b.project === UNGROUPED) return -1; + return a.project.localeCompare(b.project); + }); + return out; + }, [containers, filter]); + + const totalVisible = useMemo(() => groups.reduce((n, g) => n + g.items.length, 0), [groups]); + return (
@@ -127,23 +226,49 @@ export default function Dashboard({ onPendingCountChange }) { )}
- - - +
+ +

+ Queries each image's registry for a newer version — nothing is pulled until you tap Update. +

+ +
+ + +
+ {checkMsg &&

{checkMsg}

} + {stacksWarning && ( +
+ 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}) + and set STACKS_DIR to match. See the README. +
+ )} + {loading && (
@@ -155,7 +280,7 @@ export default function Dashboard({ onPendingCountChange }) { {!loading && error && (

{error}

-
@@ -167,16 +292,38 @@ export default function Dashboard({ onPendingCountChange }) {
)} - {!loading && !error && containers.length > 0 && ( -
- {containers.map((container) => ( - + {!loading && !error && containers.length > 0 && totalVisible === 0 && ( +
+

Everything's up to date. 🎉

+ +
+ )} + + {!loading && !error && totalVisible > 0 && ( +
+ {groups.map((g) => ( + 0 || filter === 'updates'} + > +
+ {g.items.map((container) => ( + + ))} +
+
))}
)} diff --git a/client/src/api.js b/client/src/api.js index 9772d13..c8b806b 100644 --- a/client/src/api.js +++ b/client/src/api.js @@ -82,6 +82,12 @@ 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)}`); } diff --git a/client/src/components/StackGroup.jsx b/client/src/components/StackGroup.jsx new file mode 100644 index 0000000..75ce15f --- /dev/null +++ b/client/src/components/StackGroup.jsx @@ -0,0 +1,69 @@ +import React, { useCallback, useState } from 'react'; + +const Chevron = ({ open }) => ( + +); + +/** + * A collapsible section grouping a stack's containers. Open/closed state is + * remembered per-group in localStorage so it survives reloads. + * + * props: + * - title: stack/group name shown in the header + * - count: number of containers in the group + * - updateCount: number with an update available (shown as a badge when > 0) + * - storageKey: stable key for persisting open/closed + * - defaultOpen: initial state when nothing is stored + * - children: the cards + */ +export default function StackGroup({ title, count, updateCount = 0, storageKey, defaultOpen = true, children }) { + const key = `diun.group.${storageKey}`; + const [open, setOpen] = useState(() => { + try { + const stored = localStorage.getItem(key); + return stored === null ? defaultOpen : stored === '1'; + } catch { + return defaultOpen; + } + }); + + const toggle = useCallback(() => { + setOpen((prev) => { + const next = !prev; + try { + localStorage.setItem(key, next ? '1' : '0'); + } catch { + // ignore storage failures (private mode etc.) + } + return next; + }); + }, [key]); + + return ( +
+ + {open &&
{children}
} +
+ ); +} diff --git a/client/src/components/UpdateCard.jsx b/client/src/components/UpdateCard.jsx index f40831c..4ef69e0 100644 --- a/client/src/components/UpdateCard.jsx +++ b/client/src/components/UpdateCard.jsx @@ -10,6 +10,15 @@ function shortDigest(digest) { return clean.slice(0, 12); } +// Prefer a human version (OCI version label), then the tag, then a short +// digest as a last resort, so the card shows "1.27.3" / "latest" rather than a +// meaningless hash. +function displayVersion({ currentVersion, tag, currentDigest }) { + if (currentVersion) return currentVersion; + if (tag) return tag; + return shortDigest(currentDigest); +} + const PinIcon = ({ filled }) => (
{name}
+
+ {image} +
- {(project || service) && ( + {service && ( - {project} - {project && service ? '/' : ''} {service} )} - {pinned && Pinned} + {pinned && Version pinned}
-
-
- Current - - {shortDigest(currentDigest)} - -
-
- Available - - {updateAvailable ? shortDigest(availableDigest) : '—'} +
+
+ Running + + {displayVersion(container)}
+ {showUpdateAvailable && ( +
+ Available + + {availableVersion || 'newer image'} + +
+ )}
{pinError && } diff --git a/client/src/pages/SettingsPage.jsx b/client/src/pages/SettingsPage.jsx index 70a1f16..3df50fb 100644 --- a/client/src/pages/SettingsPage.jsx +++ b/client/src/pages/SettingsPage.jsx @@ -79,9 +79,9 @@ export default function SettingsPage() {
-

Pinned images

+

Pinned versions

{pinnedLoading && ( -
+
@@ -98,7 +98,7 @@ export default function SettingsPage() { {!pinnedLoading && !pinnedError && pinned.length === 0 && (
-

No pinned images.

+

No pinned versions.

)} @@ -128,8 +128,8 @@ export default function SettingsPage() {

About

Diun Updater

- A small dashboard for reviewing Diun image-update notifications and applying - container updates by hand. + A small dashboard for checking your containers' images for updates and applying + them by hand.

Updates are always manual — this app never pulls or recreates a container on its diff --git a/client/src/styles/app.css b/client/src/styles/app.css index c708bd9..5fa685f 100644 --- a/client/src/styles/app.css +++ b/client/src/styles/app.css @@ -934,5 +934,172 @@ a { .check-msg { margin: 0 0 12px; font-size: 0.85rem; - color: var(--text-secondary); + color: var(--color-text-muted); +} + +/* ---------- Dashboard subtitle + filter ---------- */ + +.dashboard-subtitle { + margin: -6px 0 12px; + font-size: 0.8rem; + color: var(--color-text-faint); +} + +.filter-row { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +.chip { + appearance: none; + border: 1px solid var(--color-border); + background: var(--color-card); + color: var(--color-text-muted); + padding: 6px 14px; + border-radius: 999px; + font-size: 0.8rem; + font-weight: 600; + cursor: pointer; +} + +.chip.is-active { + background: var(--color-accent); + border-color: var(--color-accent); + color: var(--color-accent-contrast); +} + +/* ---------- Warning banner ---------- */ + +.banner { + margin: 0 0 14px; + padding: 10px 12px; + border-radius: var(--radius-md); + font-size: 0.85rem; + line-height: 1.4; +} + +.banner-warn { + background: rgba(245, 158, 11, 0.12); + border: 1px solid rgba(245, 158, 11, 0.4); + color: var(--color-text); +} + +.banner code { + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.78rem; + background: var(--color-elevated); + padding: 1px 4px; + border-radius: 4px; + word-break: break-all; +} + +/* ---------- Stack groups ---------- */ + +.dashboard-groups { + display: flex; + flex-direction: column; + gap: 14px; +} + +.stack-group { + border: 1px solid var(--color-border); + border-radius: var(--radius-lg); + background: var(--color-bg); + overflow: hidden; +} + +.stack-group-header { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 12px; + background: var(--color-elevated); + border: none; + color: var(--color-text); + font-size: 0.9rem; + font-weight: 700; + cursor: pointer; + text-align: left; +} + +.stack-chevron { + flex-shrink: 0; + color: var(--color-text-muted); + transition: transform 0.15s ease; +} + +.stack-chevron.is-open { + transform: rotate(90deg); +} + +.stack-group-title { + flex: 1; + min-width: 0; +} + +.stack-group-badge { + flex-shrink: 0; +} + +.stack-group-count { + flex-shrink: 0; + color: var(--color-text-faint); + font-weight: 600; + font-size: 0.78rem; +} + +.stack-group-body { + padding: 10px; +} + +/* ---------- Card image + version rows ---------- */ + +.card-image { + margin-top: 2px; + font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', Menlo, monospace; + font-size: 0.76rem; + color: var(--color-text-faint); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.card-versions { + margin-top: 10px; + display: grid; + grid-template-columns: 1fr; + gap: 6px; + font-size: 0.82rem; +} + +@media (min-width: 480px) { + .card-versions { + grid-template-columns: 1fr 1fr; + } +} + +.version-row { + display: flex; + align-items: baseline; + gap: 6px; + min-width: 0; +} + +.version-label { + color: var(--color-text-faint); + flex-shrink: 0; +} + +.version-value { + color: var(--color-text); + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.version-value.is-available { + color: var(--color-success); } diff --git a/docker-compose.yml b/docker-compose.yml index 7875f3a..5f04041 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -10,7 +10,6 @@ services: environment: - ADMIN_PASSWORD=${ADMIN_PASSWORD} - SESSION_SECRET=${SESSION_SECRET} - - DIUN_WEBHOOK_TOKEN=${DIUN_WEBHOOK_TOKEN} - STACKS_DIR=${STACKS_DIR} volumes: # Required so dockerode/the docker CLI inside the container can talk @@ -35,17 +34,7 @@ volumes: diun-updater-data: # --------------------------------------------------------------------------- -# Diun webhook notifier configuration +# No external notifier needed. This app checks your images' registries for +# updates on its own (when you open it, and on a background schedule). Diun is +# not required. # --------------------------------------------------------------------------- -# Add a webhook notifier to your Diun config so it pushes update events to -# this app: -# -# notif: -# webhook: -# endpoint: http://diun-updater:5000/api/diun/webhook -# method: POST -# headers: -# Authorization: "Bearer ${DIUN_WEBHOOK_TOKEN}" -# -# (Use the diun-updater service/container name or its reachable host/IP in -# place of `diun-updater` if Diun runs on a different Docker network.) diff --git a/server/src/checker.js b/server/src/checker.js index 34830ba..36b823e 100644 --- a/server/src/checker.js +++ b/server/src/checker.js @@ -3,9 +3,8 @@ * 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. + * This is the app's sole source of update information: it queries each image's + * registry directly, with no dependency on any external notifier. */ import { listContainers } from './docker.js'; diff --git a/server/src/config.js b/server/src/config.js index 6890138..717446b 100644 --- a/server/src/config.js +++ b/server/src/config.js @@ -16,7 +16,6 @@ export const config = { DATA_DIR: process.env.DATA_DIR || '/data', ADMIN_PASSWORD: process.env.ADMIN_PASSWORD || '', SESSION_SECRET: process.env.SESSION_SECRET || '', - DIUN_WEBHOOK_TOKEN: process.env.DIUN_WEBHOOK_TOKEN || '', SESSION_TTL: envInt('SESSION_TTL', 604800), BASE_URL: process.env.BASE_URL || 'http://localhost:5000', // Name of this app's own container, excluded from the dashboard so it @@ -28,11 +27,10 @@ export const config = { /** * Throws a clear error listing any required env vars that are missing. * Required at runtime (but not enforced here so this module can be - * imported freely, e.g. in tests): ADMIN_PASSWORD, SESSION_SECRET, - * DIUN_WEBHOOK_TOKEN. + * imported freely, e.g. in tests): ADMIN_PASSWORD, SESSION_SECRET. */ export function assertRequiredConfig() { - const required = ['ADMIN_PASSWORD', 'SESSION_SECRET', 'DIUN_WEBHOOK_TOKEN']; + const required = ['ADMIN_PASSWORD', 'SESSION_SECRET']; const missing = required.filter((key) => !config[key]); if (missing.length > 0) { throw new Error( diff --git a/server/src/containers-service.js b/server/src/containers-service.js index 276321b..6e422d7 100644 --- a/server/src/containers-service.js +++ b/server/src/containers-service.js @@ -54,6 +54,8 @@ export function buildContainerItems({ containers, lookupEvent, isPinned }) { project: c.project, service: c.service, image: c.image, + tag: c.tag ?? null, + currentVersion: c.currentVersion ?? null, currentDigest: c.currentDigest, updateAvailable, availableDigest, diff --git a/server/src/docker.js b/server/src/docker.js index 20eb13c..adb8f27 100644 --- a/server/src/docker.js +++ b/server/src/docker.js @@ -16,7 +16,7 @@ import path from 'node:path'; import { spawn } from 'node:child_process'; import Docker from 'dockerode'; import { config } from './config.js'; -import { normalizeRef } from './reconcile.js'; +import { normalizeRef, parseRef } from './reconcile.js'; // Best-effort identity of this app's own container, so listContainers can // exclude it (you can't safely update the updater from within itself). By @@ -56,33 +56,21 @@ function stripLeadingSlash(rawName) { } /** - * Resolves the digest the running container's image was created from, by - * inspecting the image (by image id) and matching `RepoDigests` entries - * against the configured image ref's repo. Returns null if no match (e.g. - * the image was built locally and has no RepoDigests, or it was pulled - * under a different ref than `image`). + * Pure: picks the digest from an image's `RepoDigests` that matches the + * configured image ref's repo, to disambiguate when an image was pulled/ + * tagged under several refs. Falls back to the sole RepoDigest if there's + * exactly one. Returns null when there's no usable match. * - * @param {string} imageIdOrName - `Image` field from container inspect. + * @param {string[]|undefined} repoDigests - image inspect `RepoDigests`. * @param {string} image - configured image ref, e.g. "nginx:latest". - * @returns {Promise} + * @returns {string|null} */ -async function resolveCurrentDigest(imageIdOrName, image) { - let imageInfo; - try { - imageInfo = await docker.getImage(imageIdOrName).inspect(); - } catch (err) { - console.warn(`docker.js: failed to inspect image ${imageIdOrName}: ${err.message}`); - return null; - } - - const repoDigests = imageInfo?.RepoDigests; +function pickRepoDigest(repoDigests, image) { if (!Array.isArray(repoDigests) || repoDigests.length === 0) { return null; } - // Determine the repo (registry/repo, no tag) we're looking for, to - // disambiguate when an image has multiple RepoDigests (e.g. it was - // pulled/tagged under several refs). + // Determine the repo (registry/repo, no tag) we're looking for. let wantedRepo = null; try { const normalized = normalizeRef(image); @@ -114,9 +102,8 @@ async function resolveCurrentDigest(imageIdOrName, image) { } } - // No repo match found; fall back to the first RepoDigest's digest part - // so we still surface *something* rather than null, but only if there's - // exactly one (otherwise it's ambiguous which one applies). + // No repo match found; fall back to the sole RepoDigest's digest part, but + // only if there's exactly one (otherwise it's ambiguous which applies). if (repoDigests.length === 1) { const atIdx = repoDigests[0].lastIndexOf('@'); return atIdx === -1 ? null : repoDigests[0].slice(atIdx + 1); @@ -125,6 +112,43 @@ async function resolveCurrentDigest(imageIdOrName, image) { return null; } +/** + * Inspects an image once and returns both the running digest (matched to the + * configured ref) and the human-readable version from the + * `org.opencontainers.image.version` label, if the image sets it. Returns + * nulls if the image can't be inspected. + * + * @param {string} imageIdOrName - `Image` field from container inspect. + * @param {string} image - configured image ref, e.g. "nginx:latest". + * @returns {Promise<{ digest: string|null, version: string|null }>} + */ +async function inspectImageMeta(imageIdOrName, image) { + let imageInfo; + try { + imageInfo = await docker.getImage(imageIdOrName).inspect(); + } catch (err) { + console.warn(`docker.js: failed to inspect image ${imageIdOrName}: ${err.message}`); + return { digest: null, version: null }; + } + + const labels = imageInfo?.Config?.Labels || {}; + const version = labels['org.opencontainers.image.version'] || null; + const digest = pickRepoDigest(imageInfo?.RepoDigests, image); + return { digest, version }; +} + +/** + * Resolves the digest the running container's image was created from. Thin + * wrapper over inspectImageMeta for callers that only need the digest. + * + * @param {string} imageIdOrName - `Image` field from container inspect. + * @param {string} image - configured image ref, e.g. "nginx:latest". + * @returns {Promise} + */ +async function resolveCurrentDigest(imageIdOrName, image) { + return (await inspectImageMeta(imageIdOrName, image)).digest; +} + /** * Resolves compose project/service/composeFile/workingDir for a container * from its labels, falling back to a STACKS_DIR scan if the @@ -262,7 +286,10 @@ export async function listContainers() { continue; } - const currentDigest = await resolveCurrentDigest(inspectData.Image, image); + const { digest: currentDigest, version: currentVersion } = await inspectImageMeta( + inspectData.Image, + image + ); const labels = inspectData.Config?.Labels; const labelInfo = composeInfoFromLabels(labels); @@ -279,8 +306,10 @@ export async function listContainers() { } let normalizedRef; + let tag = null; try { normalizedRef = normalizeRef(image); + tag = parseRef(image).tag; } catch (err) { console.warn(`docker.js: failed to normalize ref "${image}" for ${name}: ${err.message}`); continue; @@ -289,6 +318,8 @@ export async function listContainers() { results.push({ name, image, + tag, + currentVersion, currentDigest, project: project || null, service: service || null, @@ -422,6 +453,25 @@ export async function updateContainer(name, onLine) { if (isComposeManaged) { const { composeFile, workingDir, service } = composeInfo; + + // The `docker compose` CLI runs inside THIS container but reads the + // 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. + if (!fs.existsSync(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.`, + oldDigest, + newDigest: null, + }; + } + const baseArgs = ['compose', '-f', composeFile, '--project-directory', workingDir]; let pullResult; @@ -562,4 +612,22 @@ 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/index.js b/server/src/index.js index 4b8980b..fdaff67 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -6,7 +6,6 @@ import cookieParser from 'cookie-parser'; import { config, assertRequiredConfig } from './config.js'; // Importing db creates the data dir + tables as a side effect on load. import db from './db.js'; -import { webhookRouter } from './webhook.js'; import { authRouter, requireAuth } from './auth.js'; import { apiRouter } from './routes/api.js'; import { updateRouter } from './routes/update.js'; @@ -34,20 +33,15 @@ app.get('/api/health', (req, res) => { res.json({ ok: true }); }); -// WP2/WP3: routes mounted here, in order: -// 1. Diun webhook route (POST /api/diun/webhook) — public, its own -// bearer-token auth, no session cookie. -// 2. Auth routes (POST /api/auth/login, POST /api/auth/logout, GET +// Routes mounted here, in order: +// 1. Auth routes (POST /api/auth/login, POST /api/auth/logout, GET // /api/auth/me) — public; login/me must be reachable without a // session, and logout is harmless without one. -// 3. `requireAuth` — session-cookie gate for everything under `/api/*` +// 2. `requireAuth` — session-cookie gate for everything under `/api/*` // mounted after this point; passes through non-`/api/*` requests // (static assets, SPA fallback) untouched. -// 4. Container listing + history + pin routes (GET /api/containers, GET -// /api/history(/:name), GET /api/pinned, POST /api/pin, DELETE -// /api/pin/:ref) and update routes (POST /api/update/:name, GET -// /api/update/:name/stream) — now protected by `requireAuth` above. -app.use(webhookRouter); +// 3. Container listing + check + history + pin routes and update routes — +// all protected by `requireAuth` above. app.use(authRouter); app.use(requireAuth); app.use(apiRouter); diff --git a/server/src/registry.js b/server/src/registry.js index 42190a4..1a2e894 100644 --- a/server/src/registry.js +++ b/server/src/registry.js @@ -1,7 +1,7 @@ /** * Minimal Docker Registry v2 client: resolve the current manifest digest for * an image tag WITHOUT pulling the image, so the app can actively check for - * updates (independently of Diun webhooks). + * updates by querying registries directly. * * Supports anonymous access to registries that use the standard * `WWW-Authenticate: Bearer ...` token flow — Docker Hub, GHCR, lscr.io, diff --git a/server/src/routes/api.js b/server/src/routes/api.js index 273a7c1..8d6cdcc 100644 --- a/server/src/routes/api.js +++ b/server/src/routes/api.js @@ -8,7 +8,7 @@ */ import express from 'express'; -import { listContainers } from '../docker.js'; +import { listContainers, stacksDirStatus } from '../docker.js'; import { buildContainerItems } from '../containers-service.js'; import { normalizeRef } from '../reconcile.js'; import { runCheck } from '../checker.js'; @@ -54,7 +54,14 @@ apiRouter.get('/api/containers', async (req, res) => { return res.status(200).json(items); }); -// Actively check registries for newer digests (independent of Diun webhooks). +// 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; try { @@ -68,7 +75,7 @@ apiRouter.post('/api/check', async (req, res) => { }); // Global SSE channel: emits {"type":"containers-changed"} when server state -// changes (webhook event, manual check, finished update) so dashboards can +// changes (a manual/scheduled check or a finished update) so dashboards can // refresh without a manual reload. apiRouter.get('/api/events', (req, res) => { subscribeGlobal(res, req); diff --git a/server/src/sse.js b/server/src/sse.js index 0d6ffb8..5121b91 100644 --- a/server/src/sse.js +++ b/server/src/sse.js @@ -191,7 +191,7 @@ function writeToSubscribers(session, evt) { // --- Global event channel ------------------------------------------------- // A lightweight broadcast channel, separate from per-update sessions, used to // nudge connected dashboards to refresh when something changes server-side: a -// new Diun webhook event, a manual "check now", or a finished update. +// manual or scheduled check, or a finished update. const globalClients = new Set(); export function subscribeGlobal(res, req) { diff --git a/server/src/webhook.js b/server/src/webhook.js deleted file mode 100644 index 7542206..0000000 --- a/server/src/webhook.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Diun webhook ingest: POST /api/diun/webhook - * - * Auth is a static bearer token (DIUN_WEBHOOK_TOKEN), compared in constant - * time — separate from the session-cookie auth used by the rest of the API - * (see API_CONTRACT.md "Auth model"). - */ - -import crypto from 'node:crypto'; -import express from 'express'; -import { config } from './config.js'; -import { normalizeRef } from './reconcile.js'; -import { recordEvent } from './db.js'; -import { broadcastGlobal } from './sse.js'; - -export const webhookRouter = express.Router(); - -const DEFAULT_STATUS = 'update'; - -/** - * Constant-time comparison of the bearer token against the configured - * DIUN_WEBHOOK_TOKEN. Guards against length mismatches (timingSafeEqual - * throws if buffers differ in length) without leaking timing information - * about the length itself beyond the unavoidable minimum. - * - * @param {string} provided - * @param {string} expected - * @returns {boolean} - */ -function isValidToken(provided, expected) { - if (typeof provided !== 'string' || typeof expected !== 'string' || expected === '') { - return false; - } - const providedBuf = Buffer.from(provided, 'utf8'); - const expectedBuf = Buffer.from(expected, 'utf8'); - if (providedBuf.length !== expectedBuf.length) { - return false; - } - return crypto.timingSafeEqual(providedBuf, expectedBuf); -} - -webhookRouter.post('/api/diun/webhook', (req, res) => { - const authHeader = req.get('authorization') || ''; - const match = authHeader.match(/^Bearer (.+)$/); - const token = match ? match[1] : null; - - if (!isValidToken(token, config.DIUN_WEBHOOK_TOKEN)) { - return res.status(401).json({ error: 'unauthorized' }); - } - - const body = req.body || {}; - const image = body.image; - if (typeof image !== 'string' || image.trim() === '') { - return res.status(400).json({ error: 'invalid_payload' }); - } - - const status = body.status || DEFAULT_STATUS; - const digest = body.digest ?? null; - - let normalizedRef; - try { - normalizedRef = normalizeRef(image); - } catch { - return res.status(400).json({ error: 'invalid_payload' }); - } - - recordEvent({ - image, - normalized_ref: normalizedRef, - status, - digest, - raw_json: JSON.stringify(req.body), - }); - - // Nudge any connected dashboards to refresh so the badge appears without a - // manual reload. - broadcastGlobal({ type: 'containers-changed' }); - - return res.status(204).end(); -}); - -export default webhookRouter; diff --git a/server/test/containers-service.test.js b/server/test/containers-service.test.js index 8e3f463..4024d93 100644 --- a/server/test/containers-service.test.js +++ b/server/test/containers-service.test.js @@ -6,6 +6,8 @@ function makeContainer(overrides = {}) { return { name: 'nginx', image: 'nginx:latest', + tag: 'latest', + currentVersion: null, currentDigest: 'sha256:aaa', project: 'web', service: 'nginx', @@ -78,6 +80,8 @@ describe('buildContainerItems', () => { project: 'web', service: 'nginx', image: 'nginx:latest', + tag: 'latest', + currentVersion: null, currentDigest: 'sha256:aaa', updateAvailable: false, availableDigest: null,