feat(dashboard): vervang SECTIONS-grid door 5 live status-widgets

Parallel server-side fetches via Promise.allSettled voor Docker, Caddy,
systemd, Git en Audit. Iedere widget toont geaggregeerde status en
refresht elke 30s client-side onafhankelijk van de andere widgets.

- lib/agent-fetch.ts: gedeelde client-side streaming helper
- app/api/audit/latest/route.ts: GET endpoint voor AuditWidget refresh
- app/_components/DockerWidget.tsx: running/total containers
- app/_components/CaddyWidget.tsx: soonest cert expiry in dagen
- app/_components/SystemdWidget.tsx: healthy/total units (of niet geconfigureerd)
- app/_components/GitWidget.tsx: dirty repo count (of niet geconfigureerd)
- app/_components/AuditWidget.tsx: laatste FlowRun status + relatief tijdstip
- app/page.tsx: vervangt SECTIONS-grid, doet parallel fetches, rendert widgets

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-13 22:06:00 +02:00
parent ae63876f21
commit faa1463cd7
8 changed files with 523 additions and 22 deletions

View file

@ -0,0 +1,78 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { apiFetch } from '@/lib/csrf'
type LatestRun = { id: string; flow_key: string; status: string; started_at: string }
export type AuditInitial =
| { data: LatestRun | null; error: null }
| { data: null; error: string }
const STATUS_STYLES: Record<string, string> = {
pending: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
running: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400',
success: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
failed: 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400',
cancelled: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
}
function relativeTime(isoString: string): string {
const diff = Date.now() - new Date(isoString).getTime()
const minutes = Math.floor(diff / 60_000)
if (minutes < 1) return 'zojuist'
if (minutes < 60) return `${minutes}m geleden`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}u geleden`
return `${Math.floor(hours / 24)}d geleden`
}
async function fetchLatestRun(): Promise<LatestRun | null> {
const res = await apiFetch('/api/audit/latest')
if (!res.ok) throw new Error(`${res.status}`)
const json = (await res.json()) as { run: LatestRun | null }
return json.run
}
export default function AuditWidget({ initial }: { initial: AuditInitial }) {
const [data, setData] = useState<LatestRun | null>(initial.data)
const [error, setError] = useState<string | null>(initial.error)
const refresh = useCallback(async () => {
try {
const run = await fetchLatestRun()
setData(run)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh])
return (
<Link href="/audit" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Audit</h2>
{error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_STYLES[data.status] ?? ''}`}
>
{data.status}
</span>
<span className="text-xs text-muted-foreground">{relativeTime(data.started_at)}</span>
</div>
<p className="font-mono text-xs text-muted-foreground truncate">{data.flow_key}</p>
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">geen runs</p>
)}
</Link>
)
}

View file

@ -0,0 +1,68 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseCertList } from '@/lib/parse-caddy'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type CaddyData = { soonestExpiryMs: number | null; count: number }
export type CaddyInitial = { data: CaddyData; error: null } | { data: null; error: string }
async function refreshCaddy(): Promise<CaddyData> {
const output = await fetchAgentOutput('caddy_list_certs')
const certs = parseCertList(output)
const expiryTimes = certs
.filter((c) => c.notAfter)
.map((c) => new Date(c.notAfter).getTime())
const soonestExpiryMs = expiryTimes.length > 0 ? Math.min(...expiryTimes) : null
return { soonestExpiryMs, count: certs.length }
}
function daysUntil(ms: number): number {
return Math.floor((ms - Date.now()) / (1000 * 60 * 60 * 24))
}
export default function CaddyWidget({ initial }: { initial: CaddyInitial }) {
const [data, setData] = useState<CaddyData | null>(initial.data)
const [error, setError] = useState<string | null>(initial.error)
const refresh = useCallback(async () => {
try {
const d = await refreshCaddy()
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh])
return (
<Link href="/caddy" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Caddy / TLS</h2>
{error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<div className="mt-2">
{data.soonestExpiryMs !== null ? (
<p className="text-2xl font-semibold">
{daysUntil(data.soonestExpiryMs)}
<span className="text-sm font-normal text-muted-foreground"> days to expiry</span>
</p>
) : (
<p className="text-sm text-muted-foreground">no certs</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{data.count} cert{data.count !== 1 ? 's' : ''}
</p>
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}

View file

@ -0,0 +1,54 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseDockerPs } from '@/lib/parse-docker'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type DockerData = { running: number; total: number }
export type DockerInitial = { data: DockerData; error: null } | { data: null; error: string }
async function refreshDocker(): Promise<DockerData> {
const output = await fetchAgentOutput('docker_ps')
const containers = parseDockerPs(output)
return {
running: containers.filter((c) => c.status.toLowerCase().startsWith('up')).length,
total: containers.length,
}
}
export default function DockerWidget({ initial }: { initial: DockerInitial }) {
const [data, setData] = useState<DockerData | null>(initial.data)
const [error, setError] = useState<string | null>(initial.error)
const refresh = useCallback(async () => {
try {
const d = await refreshDocker()
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh])
return (
<Link href="/docker" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Docker</h2>
{error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<p className="mt-2 text-2xl font-semibold">
{data.running}
<span className="text-sm font-normal text-muted-foreground"> / {data.total} running</span>
</p>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}

View file

@ -0,0 +1,72 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseGitStatus } from '@/lib/parse-git'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type GitData = { dirty: number; total: number }
export type GitInitial =
| { configured: false }
| { data: GitData; error: null }
| { data: null; error: string }
async function refreshGit(repos: string[]): Promise<GitData> {
const results = await Promise.allSettled(
repos.map(async (path) => {
const output = await fetchAgentOutput('git_status', [path])
return parseGitStatus(output)
}),
)
const dirty = results.filter(
(r) => r.status === 'fulfilled' && r.value.dirty,
).length
return { dirty, total: repos.length }
}
export default function GitWidget({ initial, repos }: { initial: GitInitial; repos: string[] }) {
const notConfigured = 'configured' in initial && initial.configured === false
const [data, setData] = useState<GitData | null>(
!notConfigured && 'data' in initial ? initial.data : null,
)
const [error, setError] = useState<string | null>(
!notConfigured && 'error' in initial ? initial.error : null,
)
const refresh = useCallback(async () => {
if (notConfigured || repos.length === 0) return
try {
const d = await refreshGit(repos)
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [notConfigured, repos])
useEffect(() => {
if (notConfigured) return
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh, notConfigured])
return (
<Link href="/git" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Git</h2>
{notConfigured ? (
<p className="mt-2 text-sm text-muted-foreground">niet geconfigureerd</p>
) : error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<p className="mt-2 text-2xl font-semibold">
{data.dirty}
<span className="text-sm font-normal text-muted-foreground">
{' '}repo{data.dirty !== 1 ? 's' : ''} dirty
</span>
</p>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}

View file

@ -0,0 +1,70 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseSystemctlStatus } from '@/lib/parse-systemd'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type SystemdData = { healthy: number; total: number }
export type SystemdInitial =
| { configured: false }
| { data: SystemdData; error: null }
| { data: null; error: string }
async function refreshSystemd(units: string[]): Promise<SystemdData> {
const results = await Promise.allSettled(
units.map(async (unit) => {
const output = await fetchAgentOutput('systemctl_status', [unit])
return parseSystemctlStatus(output, unit)
}),
)
const healthy = results.filter(
(r) => r.status === 'fulfilled' && r.value.activeState === 'active',
).length
return { healthy, total: units.length }
}
export default function SystemdWidget({ initial, units }: { initial: SystemdInitial; units: string[] }) {
const notConfigured = 'configured' in initial && initial.configured === false
const [data, setData] = useState<SystemdData | null>(
!notConfigured && 'data' in initial ? initial.data : null,
)
const [error, setError] = useState<string | null>(
!notConfigured && 'error' in initial ? initial.error : null,
)
const refresh = useCallback(async () => {
if (notConfigured || units.length === 0) return
try {
const d = await refreshSystemd(units)
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [notConfigured, units])
useEffect(() => {
if (notConfigured) return
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh, notConfigured])
return (
<Link href="/systemd" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">systemd</h2>
{notConfigured ? (
<p className="mt-2 text-sm text-muted-foreground">niet geconfigureerd</p>
) : error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<p className="mt-2 text-2xl font-semibold">
{data.healthy}/{data.total}
<span className="text-sm font-normal text-muted-foreground"> healthy</span>
</p>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}