Switch Docker update tracking to Portainer API (all endpoints)
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-16 17:05:37 +02:00
parent 627b238dcc
commit cea1a05af7
3 changed files with 63 additions and 43 deletions
+1 -1
View File
@@ -19,4 +19,4 @@ steps:
- sed "s/='\(.*\)'$/=\1/; s/=\"\(.*\)\"$/=\1/" /opt/docker/dashboard/.env > /tmp/dashboard.env - sed "s/='\(.*\)'$/=\1/; s/=\"\(.*\)\"$/=\1/" /opt/docker/dashboard/.env > /tmp/dashboard.env
- docker stop dashboard || true - docker stop dashboard || true
- docker rm 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
+38 -19
View File
@@ -15,9 +15,8 @@ function fromCache<T>(key: string, ttlMs: number): T | null {
function toCache<T>(key: string, data: T) { cache[key] = { data, ts: Date.now() } } function toCache<T>(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 const HUB_TTL = 60 * 60 * 1000 // 1 hour
interface ContainerInfo { interface ContainerInfo {
@@ -27,10 +26,14 @@ interface ContainerInfo {
repoDigest: string | null repoDigest: string | null
upToDate: boolean | null // null = couldn't determine upToDate: boolean | null // null = couldn't determine
registry: string registry: string
endpoint: string
}
function portainerHeaders() {
return { 'X-API-Key': process.env.PORTAINER_TOKEN ?? '' }
} }
function parseImage(fullImage: string): { registry: string; name: string; tag: string } { function parseImage(fullImage: string): { registry: string; name: string; tag: string } {
// Strip digest if present
const [imgPart] = fullImage.split('@') const [imgPart] = fullImage.split('@')
const colonIdx = imgPart.lastIndexOf(':') const colonIdx = imgPart.lastIndexOf(':')
const slashIdx = imgPart.indexOf('/') const slashIdx = imgPart.indexOf('/')
@@ -42,12 +45,10 @@ function parseImage(fullImage: string): { registry: string; name: string; tag: s
img = imgPart.slice(0, colonIdx) img = imgPart.slice(0, colonIdx)
} }
// Detect registry
const parts = img.split('/') const parts = img.split('/')
const isCustomRegistry = parts[0].includes('.') || parts[0].includes(':') const isCustomRegistry = parts[0].includes('.') || parts[0].includes(':')
const registry = isCustomRegistry ? parts[0] : 'docker.io' const registry = isCustomRegistry ? parts[0] : 'docker.io'
// Normalise name for Docker Hub (no namespace → library/...)
let name = isCustomRegistry ? parts.slice(1).join('/') : img let name = isCustomRegistry ? parts.slice(1).join('/') : img
if (registry === 'docker.io' && !name.includes('/')) name = `library/${name}` if (registry === 'docker.io' && !name.includes('/')) name = `library/${name}`
@@ -92,26 +93,42 @@ async function getLatestDigest(image: string, tag: string): Promise<string | nul
} }
router.get('/docker', async (_req, res) => { router.get('/docker', async (_req, res) => {
const host = process.env.PORTAINER_HOST
if (!host) {
res.status(503).json({ error: 'PORTAINER_HOST not configured' })
return
}
try { try {
// List running containers const headers = portainerHeaders()
const containersRes = await axios.get('http://localhost/v1.41/containers/json', {
socketPath: DOCKER_SOCKET, // Get all endpoints
timeout: 5000, const endpointsRes = await axios.get(`${host}/api/endpoints`, { headers, timeout: 8000 })
}) const endpoints: { Id: number; Name: string }[] = endpointsRes.data
const containers: ContainerInfo[] = [] const containers: ContainerInfo[] = []
for (const c of containersRes.data) { for (const endpoint of endpoints) {
const rawImage: string = c.Image ?? '' let endpointContainers: Record<string, unknown>[] = []
const containerName: string = (c.Names?.[0] ?? '').replace(/^\//, '') try {
const cRes = await axios.get(
`${host}/api/endpoints/${endpoint.Id}/docker/containers/json`,
{ headers, timeout: 8000 }
)
endpointContainers = cRes.data
} catch { continue }
// Get repo digests from image inspect 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 let repoDigest: string | null = null
try { try {
const imgRes = await axios.get(`http://localhost/v1.41/images/${c.ImageID}/json`, { const imgRes = await axios.get(
socketPath: DOCKER_SOCKET, `${host}/api/endpoints/${endpoint.Id}/docker/images/${c.ImageID}/json`,
timeout: 5000, { headers, timeout: 8000 }
}) )
const digests: string[] = imgRes.data.RepoDigests ?? [] const digests: string[] = imgRes.data.RepoDigests ?? []
const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0])) const matched = digests.find(d => d.split('@')[0].includes(rawImage.split(':')[0]))
repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null repoDigest = matched?.split('@')[1] ?? digests[0]?.split('@')[1] ?? null
@@ -128,13 +145,15 @@ router.get('/docker', async (_req, res) => {
containers.push({ containers.push({
name: containerName, name: containerName,
image: rawImage.split(':')[0].split('/').slice(-1)[0], // short name image: rawImage.split(':')[0].split('/').slice(-1)[0],
tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest', tag: rawImage.includes(':') ? rawImage.split(':')[1].split('@')[0] : 'latest',
repoDigest, repoDigest,
upToDate, upToDate,
registry, registry,
endpoint: endpoint.Name,
}) })
} }
}
res.json({ containers }) res.json({ containers })
} catch (err: unknown) { } catch (err: unknown) {
@@ -6,6 +6,7 @@ interface ContainerInfo {
tag: string tag: string
upToDate: boolean | null upToDate: boolean | null
registry: string registry: string
endpoint: string
} }
export function DockerUpdatesWidget() { export function DockerUpdatesWidget() {
@@ -45,10 +46,10 @@ export function DockerUpdatesWidget() {
<div className="progress-group"> <div className="progress-group">
{containers.map(c => ( {containers.map(c => (
<div key={c.name} className="list-item"> <div key={c.name} className="list-item">
<div className="list-item-left"> <div className="list-item-left" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
<span>{c.name}</span> <span>{c.name}</span>
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)' }}> <span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)' }}>
{c.image}:{c.tag} {c.image}:{c.tag} · {c.endpoint}
</span> </span>
</div> </div>
{c.registry !== 'docker.io' ? ( {c.registry !== 'docker.io' ? (