Add Docker and GitHub release update tracker widgets
ci/woodpecker/push/woodpecker Pipeline was successful

This commit is contained in:
2026-05-16 17:00:07 +02:00
parent c1e75f2b19
commit 627b238dcc
7 changed files with 364 additions and 1 deletions
+197
View File
@@ -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<T> { data: T; ts: number }
const cache: Record<string, CacheEntry<unknown>> = {}
function fromCache<T>(key: string, ttlMs: number): T | null {
const entry = cache[key] as CacheEntry<T> | undefined
if (entry && Date.now() - entry.ts < ttlMs) return entry.data
return null
}
function toCache<T>(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<string> {
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<string | null> {
const cacheKey = `digest:${image}:${tag}`
const cached = fromCache<string>(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<string, string> = {
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<GithubRelease>(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