From 627b238dccaf321f85deaed79d080cfeecf5ccf0 Mon Sep 17 00:00:00 2001 From: Syco21 Date: Sat, 16 May 2026 17:00:07 +0200 Subject: [PATCH] Add Docker and GitHub release update tracker widgets --- .woodpecker.yml | 2 +- server/config/watchlist.ts | 5 + server/index.ts | 2 + server/routes/updates.ts | 197 ++++++++++++++++++ .../widgets/DockerUpdatesWidget.tsx | 74 +++++++ .../widgets/GithubReleasesWidget.tsx | 79 +++++++ src/config/dashboard.ts | 6 + 7 files changed, 364 insertions(+), 1 deletion(-) create mode 100644 server/config/watchlist.ts create mode 100644 server/routes/updates.ts create mode 100644 src/components/widgets/DockerUpdatesWidget.tsx create mode 100644 src/components/widgets/GithubReleasesWidget.tsx diff --git a/.woodpecker.yml b/.woodpecker.yml index d66f0ee..866bf56 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 dashboard:latest + - 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 diff --git a/server/config/watchlist.ts b/server/config/watchlist.ts new file mode 100644 index 0000000..d579841 --- /dev/null +++ b/server/config/watchlist.ts @@ -0,0 +1,5 @@ +// ── GitHub release watchlist ────────────────────────────────────────────────── +// Add repos in "owner/repo" format. Releases are checked on page load. +export const githubRepos: string[] = [ + 'theovilardo/PixelPlayer', +] diff --git a/server/index.ts b/server/index.ts index 12dfbff..7bd963d 100644 --- a/server/index.ts +++ b/server/index.ts @@ -18,6 +18,7 @@ import navidrome from './routes/navidrome' import romm from './routes/romm' import weather from './routes/weather' import transit from './routes/transit' +import updates from './routes/updates' const app = express() const PORT = Number(process.env.PORT ?? 3001) @@ -41,6 +42,7 @@ app.use('/api/navidrome', navidrome) app.use('/api/romm', romm) app.use('/api/weather', weather) app.use('/api/transit', transit) +app.use('/api/updates', updates) // Serve built frontend in production only if (process.env.NODE_ENV === 'production') { diff --git a/server/routes/updates.ts b/server/routes/updates.ts new file mode 100644 index 0000000..befd331 --- /dev/null +++ b/server/routes/updates.ts @@ -0,0 +1,197 @@ +import { Router } from 'express' +import axios from 'axios' +import { githubRepos } from '../config/watchlist' + +const router = Router() + +/* ── Simple in-memory cache ── */ +interface CacheEntry { data: T; ts: number } +const cache: Record> = {} +function fromCache(key: string, ttlMs: number): T | null { + const entry = cache[key] as CacheEntry | undefined + if (entry && Date.now() - entry.ts < ttlMs) return entry.data + return null +} +function toCache(key: string, data: T) { cache[key] = { data, ts: Date.now() } } + +/* ──────────────────────────────────────────────────────────────────────────── + Docker image updates + ─────────────────────────────────────────────────────────────────────────── */ +const DOCKER_SOCKET = '/var/run/docker.sock' +const HUB_TTL = 60 * 60 * 1000 // 1 hour + +interface ContainerInfo { + name: string + image: string + tag: string + repoDigest: string | null + upToDate: boolean | null // null = couldn't determine + registry: string +} + +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('/') + + let tag = 'latest' + let img = imgPart + if (colonIdx > slashIdx) { + tag = imgPart.slice(colonIdx + 1) + 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}` + + return { registry, name, tag } +} + +async function getRegistryToken(image: string): Promise { + const res = await axios.get( + `https://auth.docker.io/token?service=registry.docker.io&scope=repository:${image}:pull`, + { timeout: 8000 } + ) + return res.data.token as string +} + +async function getLatestDigest(image: string, tag: string): Promise { + const cacheKey = `digest:${image}:${tag}` + const cached = fromCache(cacheKey, HUB_TTL) + if (cached) return cached + + try { + const token = await getRegistryToken(image) + const res = await axios.head( + `https://registry-1.docker.io/v2/${image}/manifests/${tag}`, + { + headers: { + Authorization: `Bearer ${token}`, + Accept: [ + 'application/vnd.docker.distribution.manifest.v2+json', + 'application/vnd.docker.distribution.manifest.list.v2+json', + 'application/vnd.oci.image.index.v1+json', + ].join(', '), + }, + timeout: 10000, + } + ) + const digest = res.headers['docker-content-digest'] as string | undefined + if (digest) toCache(cacheKey, digest) + return digest ?? null + } catch { + return null + } +} + +router.get('/docker', async (_req, res) => { + try { + // List running containers + const containersRes = await axios.get('http://localhost/v1.41/containers/json', { + socketPath: DOCKER_SOCKET, + timeout: 5000, + }) + + 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 + try { + const imgRes = await axios.get(`http://localhost/v1.41/images/${c.ImageID}/json`, { + socketPath: DOCKER_SOCKET, + timeout: 5000, + }) + 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 }) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Unknown error' + res.status(500).json({ error: msg }) + } +}) + +/* ──────────────────────────────────────────────────────────────────────────── + GitHub release tracking + ─────────────────────────────────────────────────────────────────────────── */ +const GH_TTL = 15 * 60 * 1000 // 15 min + +interface GithubRelease { + repo: string + version: string + name: string + publishedAt: string + url: string +} + +router.get('/github', async (_req, res) => { + try { + const token = process.env.GITHUB_TOKEN + const headers: Record = { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', + } + if (token) headers.Authorization = `Bearer ${token}` + + const releases: GithubRelease[] = await Promise.all( + githubRepos.map(async repo => { + const cacheKey = `gh:${repo}` + const cached = fromCache(cacheKey, GH_TTL) + if (cached) return cached + + const r = await axios.get( + `https://api.github.com/repos/${repo}/releases/latest`, + { headers, timeout: 8000 } + ) + const release: GithubRelease = { + repo, + version: r.data.tag_name, + name: r.data.name || r.data.tag_name, + publishedAt: r.data.published_at, + url: r.data.html_url, + } + toCache(cacheKey, release) + return release + }) + ) + + res.json({ releases }) + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Unknown error' + res.status(500).json({ error: msg }) + } +}) + +export default router diff --git a/src/components/widgets/DockerUpdatesWidget.tsx b/src/components/widgets/DockerUpdatesWidget.tsx new file mode 100644 index 0000000..4574cb8 --- /dev/null +++ b/src/components/widgets/DockerUpdatesWidget.tsx @@ -0,0 +1,74 @@ +import { useEffect, useState } from 'react' + +interface ContainerInfo { + name: string + image: string + tag: string + upToDate: boolean | null + registry: string +} + +export function DockerUpdatesWidget() { + const [containers, setContainers] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/updates/docker') + .then(r => r.json()) + .then(d => { + if (d.error) setError(d.error) + else setContainers(d.containers) + }) + .catch(() => setError('Failed to connect')) + .finally(() => setLoading(false)) + }, []) + + const outdated = containers.filter(c => c.upToDate === false).length + const unknown = containers.filter(c => c.upToDate === null).length + + return ( +
+
+
Docker
+ {!loading && !error && ( + 0 ? { background: 'rgba(248,113,113,0.15)', color: 'var(--red)' } : {}}> + {outdated > 0 ? `${outdated} outdated` : 'all current'} + + )} +
+ + {loading &&
Checking images…
} + {error &&
⚠ {error}
} + + {!loading && !error && ( +
+ {containers.map(c => ( +
+
+ {c.name} + + {c.image}:{c.tag} + +
+ {c.registry !== 'docker.io' ? ( + ext + ) : c.upToDate === true ? ( + + ) : c.upToDate === false ? ( + ↑ update + ) : ( + ? + )} +
+ ))} + {unknown > 0 && ( +
+ {unknown} non-Docker Hub image{unknown > 1 ? 's' : ''} not checked +
+ )} +
+ )} +
+ ) +} diff --git a/src/components/widgets/GithubReleasesWidget.tsx b/src/components/widgets/GithubReleasesWidget.tsx new file mode 100644 index 0000000..838b3e5 --- /dev/null +++ b/src/components/widgets/GithubReleasesWidget.tsx @@ -0,0 +1,79 @@ +import { useEffect, useState } from 'react' + +interface Release { + repo: string + version: string + name: string + publishedAt: string + url: string +} + +function timeAgo(iso: string): string { + const diff = Date.now() - new Date(iso).getTime() + const days = Math.floor(diff / 86400000) + if (days === 0) return 'today' + if (days === 1) return 'yesterday' + if (days < 30) return `${days}d ago` + const months = Math.floor(days / 30) + if (months < 12) return `${months}mo ago` + return `${Math.floor(months / 12)}y ago` +} + +export function GithubReleasesWidget() { + const [releases, setReleases] = useState([]) + const [error, setError] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + fetch('/api/updates/github') + .then(r => r.json()) + .then(d => { + if (d.error) setError(d.error) + else setReleases(d.releases) + }) + .catch(() => setError('Failed to fetch releases')) + .finally(() => setLoading(false)) + }, []) + + return ( +
+
+
GitHub Releases
+ {!loading && !error && ( + {releases.length} tracked + )} +
+ + {loading &&
Fetching releases…
} + {error &&
⚠ {error}
} + + {!loading && !error && ( +
+ {releases.map(r => ( +
+
+ + {r.repo.split('/')[1]} + + + {r.repo.split('/')[0]} + +
+
+
{r.version}
+
+ {timeAgo(r.publishedAt)} +
+
+
+ ))} +
+ )} +
+ ) +} diff --git a/src/config/dashboard.ts b/src/config/dashboard.ts index c269e9e..00c4f0d 100644 --- a/src/config/dashboard.ts +++ b/src/config/dashboard.ts @@ -17,6 +17,8 @@ import { PrometheusWidget } from '../components/widgets/PrometheusWidget' import { LokiWidget } from '../components/widgets/LokiWidget' import { AuthentikWidget } from '../components/widgets/AuthentikWidget' import { VaultwardenWidget } from '../components/widgets/VaultwardenWidget' +import { DockerUpdatesWidget } from '../components/widgets/DockerUpdatesWidget' +import { GithubReleasesWidget } from '../components/widgets/GithubReleasesWidget' export interface DashboardSection { label: string @@ -40,4 +42,8 @@ export const dashboardSections: DashboardSection[] = [ label: 'Access', widgets: [AuthentikWidget, VaultwardenWidget], }, + { + label: 'Updates', + widgets: [DockerUpdatesWidget, GithubReleasesWidget], + }, ]