From cea1a05af76195acff78f17f3d8e5ad78507fb3e Mon Sep 17 00:00:00 2001 From: Syco21 Date: Sat, 16 May 2026 17:05:37 +0200 Subject: [PATCH] Switch Docker update tracking to Portainer API (all endpoints) --- .woodpecker.yml | 2 +- server/routes/updates.ts | 99 +++++++++++-------- .../widgets/DockerUpdatesWidget.tsx | 5 +- 3 files changed, 63 insertions(+), 43 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 866bf56..d66f0ee 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -19,4 +19,4 @@ steps: - sed "s/='\(.*\)'$/=\1/; s/=\"\(.*\)\"$/=\1/" /opt/docker/dashboard/.env > /tmp/dashboard.env - docker stop dashboard || true - docker rm dashboard || true - - docker run -d --name dashboard --restart unless-stopped -p 3002:3001 --env-file /tmp/dashboard.env -v /var/run/docker.sock:/var/run/docker.sock dashboard:latest + - docker run -d --name dashboard --restart unless-stopped -p 3002:3001 --env-file /tmp/dashboard.env dashboard:latest diff --git a/server/routes/updates.ts b/server/routes/updates.ts index befd331..ce72534 100644 --- a/server/routes/updates.ts +++ b/server/routes/updates.ts @@ -15,9 +15,8 @@ function fromCache(key: string, ttlMs: number): T | null { function toCache(key: string, data: T) { cache[key] = { data, ts: Date.now() } } /* ──────────────────────────────────────────────────────────────────────────── - Docker image updates + Docker image updates via Portainer ─────────────────────────────────────────────────────────────────────────── */ -const DOCKER_SOCKET = '/var/run/docker.sock' const HUB_TTL = 60 * 60 * 1000 // 1 hour interface ContainerInfo { @@ -27,10 +26,14 @@ interface ContainerInfo { repoDigest: string | null upToDate: boolean | null // null = couldn't determine registry: string + endpoint: string +} + +function portainerHeaders() { + return { 'X-API-Key': process.env.PORTAINER_TOKEN ?? '' } } function parseImage(fullImage: string): { registry: string; name: string; tag: string } { - // Strip digest if present const [imgPart] = fullImage.split('@') const colonIdx = imgPart.lastIndexOf(':') const slashIdx = imgPart.indexOf('/') @@ -42,12 +45,10 @@ function parseImage(fullImage: string): { registry: string; name: string; tag: s img = imgPart.slice(0, colonIdx) } - // Detect registry const parts = img.split('/') const isCustomRegistry = parts[0].includes('.') || parts[0].includes(':') const registry = isCustomRegistry ? parts[0] : 'docker.io' - // Normalise name for Docker Hub (no namespace → library/...) let name = isCustomRegistry ? parts.slice(1).join('/') : img if (registry === 'docker.io' && !name.includes('/')) name = `library/${name}` @@ -92,48 +93,66 @@ async function getLatestDigest(image: string, tag: string): Promise { + const host = process.env.PORTAINER_HOST + if (!host) { + res.status(503).json({ error: 'PORTAINER_HOST not configured' }) + return + } + try { - // List running containers - const containersRes = await axios.get('http://localhost/v1.41/containers/json', { - socketPath: DOCKER_SOCKET, - timeout: 5000, - }) + const headers = portainerHeaders() + + // Get all endpoints + const endpointsRes = await axios.get(`${host}/api/endpoints`, { headers, timeout: 8000 }) + const endpoints: { Id: number; Name: string }[] = endpointsRes.data const containers: ContainerInfo[] = [] - for (const c of containersRes.data) { - const rawImage: string = c.Image ?? '' - const containerName: string = (c.Names?.[0] ?? '').replace(/^\//, '') - - // Get repo digests from image inspect - let repoDigest: string | null = null + for (const endpoint of endpoints) { + let endpointContainers: Record[] = [] try { - const imgRes = await axios.get(`http://localhost/v1.41/images/${c.ImageID}/json`, { - socketPath: DOCKER_SOCKET, - timeout: 5000, + const cRes = await axios.get( + `${host}/api/endpoints/${endpoint.Id}/docker/containers/json`, + { headers, timeout: 8000 } + ) + endpointContainers = cRes.data + } catch { continue } + + for (const c of endpointContainers) { + const rawImage = (c.Image as string) ?? '' + const containerName = ((c.Names as string[])?.[0] ?? '').replace(/^\//, '') + + // Get repo digests via image inspect + let repoDigest: string | null = null + try { + const imgRes = await axios.get( + `${host}/api/endpoints/${endpoint.Id}/docker/images/${c.ImageID}/json`, + { headers, timeout: 8000 } + ) + const digests: string[] = imgRes.data.RepoDigests ?? [] + const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0])) + repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null + } catch { /* ignore */ } + + const { registry, name, tag } = parseImage(rawImage) + const isDockerHub = registry === 'docker.io' + + let upToDate: boolean | null = null + if (isDockerHub && repoDigest) { + const latest = await getLatestDigest(name, tag) + if (latest) upToDate = latest === repoDigest + } + + containers.push({ + name: containerName, + image: rawImage.split(':')[0].split('/').slice(-1)[0], + tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest', + repoDigest, + upToDate, + registry, + endpoint: endpoint.Name, }) - const digests: string[] = imgRes.data.RepoDigests ?? [] - const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0])) - repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null - } catch { /* ignore */ } - - const { registry, name, tag } = parseImage(rawImage) - const isDockerHub = registry === 'docker.io' - - let upToDate: boolean | null = null - if (isDockerHub && repoDigest) { - const latest = await getLatestDigest(name, tag) - if (latest) upToDate = latest === repoDigest } - - containers.push({ - name: containerName, - image: rawImage.split(':')[0].split('/').slice(-1)[0], // short name - tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest', - repoDigest, - upToDate, - registry, - }) } res.json({ containers }) diff --git a/src/components/widgets/DockerUpdatesWidget.tsx b/src/components/widgets/DockerUpdatesWidget.tsx index 4574cb8..b2c30d1 100644 --- a/src/components/widgets/DockerUpdatesWidget.tsx +++ b/src/components/widgets/DockerUpdatesWidget.tsx @@ -6,6 +6,7 @@ interface ContainerInfo { tag: string upToDate: boolean | null registry: string + endpoint: string } export function DockerUpdatesWidget() { @@ -45,10 +46,10 @@ export function DockerUpdatesWidget() {
{containers.map(c => (
-
+
{c.name} - {c.image}:{c.tag} + {c.image}:{c.tag} · {c.endpoint}
{c.registry !== 'docker.io' ? (