Switch Docker update tracking to Portainer API (all endpoints)
ci/woodpecker/push/woodpecker Pipeline was successful
ci/woodpecker/push/woodpecker Pipeline was successful
This commit is contained in:
+1
-1
@@ -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
@@ -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' ? (
|
||||||
|
|||||||
Reference in New Issue
Block a user