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
- 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
+59 -40
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() } }
/* ────────────────────────────────────────────────────────────────────────────
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<string | nul
}
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 {
// 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<string, unknown>[] = []
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 })
@@ -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() {
<div className="progress-group">
{containers.map(c => (
<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 style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)' }}>
{c.image}:{c.tag}
{c.image}:{c.tag} · {c.endpoint}
</span>
</div>
{c.registry !== 'docker.io' ? (