Add Docker and GitHub release update tracker widgets
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 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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
]
|
||||||
@@ -18,6 +18,7 @@ import navidrome from './routes/navidrome'
|
|||||||
import romm from './routes/romm'
|
import romm from './routes/romm'
|
||||||
import weather from './routes/weather'
|
import weather from './routes/weather'
|
||||||
import transit from './routes/transit'
|
import transit from './routes/transit'
|
||||||
|
import updates from './routes/updates'
|
||||||
|
|
||||||
const app = express()
|
const app = express()
|
||||||
const PORT = Number(process.env.PORT ?? 3001)
|
const PORT = Number(process.env.PORT ?? 3001)
|
||||||
@@ -41,6 +42,7 @@ app.use('/api/navidrome', navidrome)
|
|||||||
app.use('/api/romm', romm)
|
app.use('/api/romm', romm)
|
||||||
app.use('/api/weather', weather)
|
app.use('/api/weather', weather)
|
||||||
app.use('/api/transit', transit)
|
app.use('/api/transit', transit)
|
||||||
|
app.use('/api/updates', updates)
|
||||||
|
|
||||||
// Serve built frontend in production only
|
// Serve built frontend in production only
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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<ContainerInfo[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="widget-header">
|
||||||
|
<div className="widget-title"><span className="dot" />Docker</div>
|
||||||
|
{!loading && !error && (
|
||||||
|
<span className="widget-badge" style={outdated > 0 ? { background: 'rgba(248,113,113,0.15)', color: 'var(--red)' } : {}}>
|
||||||
|
{outdated > 0 ? `${outdated} outdated` : 'all current'}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="widget-loading">Checking images…</div>}
|
||||||
|
{error && <div className="widget-error">⚠ {error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="progress-group">
|
||||||
|
{containers.map(c => (
|
||||||
|
<div key={c.name} className="list-item">
|
||||||
|
<div className="list-item-left">
|
||||||
|
<span>{c.name}</span>
|
||||||
|
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)' }}>
|
||||||
|
{c.image}:{c.tag}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{c.registry !== 'docker.io' ? (
|
||||||
|
<span className="pill pill-blue">ext</span>
|
||||||
|
) : c.upToDate === true ? (
|
||||||
|
<span className="pill pill-green">✓</span>
|
||||||
|
) : c.upToDate === false ? (
|
||||||
|
<span className="pill pill-red">↑ update</span>
|
||||||
|
) : (
|
||||||
|
<span className="pill" style={{ background: 'var(--surface2)', color: 'var(--muted)' }}>?</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{unknown > 0 && (
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)', marginTop: 6 }}>
|
||||||
|
{unknown} non-Docker Hub image{unknown > 1 ? 's' : ''} not checked
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<Release[]>([])
|
||||||
|
const [error, setError] = useState<string | null>(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 (
|
||||||
|
<div className="card">
|
||||||
|
<div className="widget-header">
|
||||||
|
<div className="widget-title"><span className="dot" />GitHub Releases</div>
|
||||||
|
{!loading && !error && (
|
||||||
|
<span className="widget-badge">{releases.length} tracked</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="widget-loading">Fetching releases…</div>}
|
||||||
|
{error && <div className="widget-error">⚠ {error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<div className="progress-group">
|
||||||
|
{releases.map(r => (
|
||||||
|
<div key={r.repo} className="list-item">
|
||||||
|
<div className="list-item-left" style={{ flexDirection: 'column', alignItems: 'flex-start', gap: 2 }}>
|
||||||
|
<a
|
||||||
|
href={r.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
style={{ color: 'var(--text)', textDecoration: 'none', fontSize: 12 }}
|
||||||
|
>
|
||||||
|
{r.repo.split('/')[1]}
|
||||||
|
</a>
|
||||||
|
<span style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)' }}>
|
||||||
|
{r.repo.split('/')[0]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div style={{ textAlign: 'right' }}>
|
||||||
|
<div className="pill pill-green" style={{ marginBottom: 3 }}>{r.version}</div>
|
||||||
|
<div style={{ fontFamily: 'JetBrains Mono, monospace', fontSize: 10, color: 'var(--muted)' }}>
|
||||||
|
{timeAgo(r.publishedAt)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,8 @@ import { PrometheusWidget } from '../components/widgets/PrometheusWidget'
|
|||||||
import { LokiWidget } from '../components/widgets/LokiWidget'
|
import { LokiWidget } from '../components/widgets/LokiWidget'
|
||||||
import { AuthentikWidget } from '../components/widgets/AuthentikWidget'
|
import { AuthentikWidget } from '../components/widgets/AuthentikWidget'
|
||||||
import { VaultwardenWidget } from '../components/widgets/VaultwardenWidget'
|
import { VaultwardenWidget } from '../components/widgets/VaultwardenWidget'
|
||||||
|
import { DockerUpdatesWidget } from '../components/widgets/DockerUpdatesWidget'
|
||||||
|
import { GithubReleasesWidget } from '../components/widgets/GithubReleasesWidget'
|
||||||
|
|
||||||
export interface DashboardSection {
|
export interface DashboardSection {
|
||||||
label: string
|
label: string
|
||||||
@@ -40,4 +42,8 @@ export const dashboardSections: DashboardSection[] = [
|
|||||||
label: 'Access',
|
label: 'Access',
|
||||||
widgets: [AuthentikWidget, VaultwardenWidget],
|
widgets: [AuthentikWidget, VaultwardenWidget],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Updates',
|
||||||
|
widgets: [DockerUpdatesWidget, GithubReleasesWidget],
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user