From b604a828a1ae44fb18b4c9db679be1ae6fadc98c Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 21:54:29 +0200 Subject: [PATCH 1/7] feat(nav): voeg AppNav.tsx toe als sticky client-component met active-link state Implementeert de globale top-navbar met 8 NAV_ITEMS (Dashboard + 7 modules), actieve-link-detectie via usePathname, en Tailwind sticky/backdrop-blur styling. Co-Authored-By: Claude Sonnet 4.6 --- components/AppNav.tsx | 48 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 components/AppNav.tsx diff --git a/components/AppNav.tsx b/components/AppNav.tsx new file mode 100644 index 0000000..6d848b5 --- /dev/null +++ b/components/AppNav.tsx @@ -0,0 +1,48 @@ +'use client' + +import Link from 'next/link' +import { usePathname } from 'next/navigation' +import { cn } from '@/lib/utils' + +const NAV_ITEMS = [ + { href: '/', label: 'Dashboard' }, + { href: '/docker', label: 'Docker' }, + { href: '/git', label: 'Git' }, + { href: '/systemd', label: 'systemd' }, + { href: '/caddy', label: 'Caddy' }, + { href: '/flows', label: 'Flows' }, + { href: '/audit', label: 'Audit' }, + { href: '/settings', label: 'Settings' }, +] + +export default function AppNav() { + const pathname = usePathname() + + return ( + + ) +} From ae63876f218f41e54067282f05850bf6b1526f39 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 21:57:19 +0200 Subject: [PATCH 2/7] feat(layout): integreer AppNav en update metadata Importeer AppNav in root-layout, render boven
. Metadata bijgewerkt naar title "Ops Dashboard" en ops-beschrijving. Co-Authored-By: Claude Sonnet 4.6 --- app/layout.tsx | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/app/layout.tsx b/app/layout.tsx index 976eb90..d76603a 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; import { Geist, Geist_Mono } from "next/font/google"; import "./globals.css"; +import AppNav from "@/components/AppNav"; const geistSans = Geist({ variable: "--font-geist-sans", @@ -13,8 +14,8 @@ const geistMono = Geist_Mono({ }); export const metadata: Metadata = { - title: "Create Next App", - description: "Generated by create next app", + title: "Ops Dashboard", + description: "Live overzicht en bediening van Docker, systemd, Caddy en deploys.", }; export default function RootLayout({ @@ -27,7 +28,10 @@ export default function RootLayout({ lang="en" className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`} > - {children} + + +
{children}
+ ); } From faa1463cd78ba64bf934a9eb978fac8ef20efbe6 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:06:00 +0200 Subject: [PATCH 3/7] 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 --- app/_components/AuditWidget.tsx | 78 ++++++++++++++++ app/_components/CaddyWidget.tsx | 68 ++++++++++++++ app/_components/DockerWidget.tsx | 54 ++++++++++++ app/_components/GitWidget.tsx | 72 +++++++++++++++ app/_components/SystemdWidget.tsx | 70 +++++++++++++++ app/api/audit/latest/route.ts | 20 +++++ app/page.tsx | 142 +++++++++++++++++++++++++----- lib/agent-fetch.ts | 41 +++++++++ 8 files changed, 523 insertions(+), 22 deletions(-) create mode 100644 app/_components/AuditWidget.tsx create mode 100644 app/_components/CaddyWidget.tsx create mode 100644 app/_components/DockerWidget.tsx create mode 100644 app/_components/GitWidget.tsx create mode 100644 app/_components/SystemdWidget.tsx create mode 100644 app/api/audit/latest/route.ts create mode 100644 lib/agent-fetch.ts diff --git a/app/_components/AuditWidget.tsx b/app/_components/AuditWidget.tsx new file mode 100644 index 0000000..270e308 --- /dev/null +++ b/app/_components/AuditWidget.tsx @@ -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 = { + 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 { + 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(initial.data) + const [error, setError] = useState(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 ( + +

Audit

+ {error ? ( +

{error}

+ ) : data ? ( +
+
+ + {data.status} + + {relativeTime(data.started_at)} +
+

{data.flow_key}

+
+ ) : ( +

geen runs

+ )} + + ) +} diff --git a/app/_components/CaddyWidget.tsx b/app/_components/CaddyWidget.tsx new file mode 100644 index 0000000..5fc0b81 --- /dev/null +++ b/app/_components/CaddyWidget.tsx @@ -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 { + 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(initial.data) + const [error, setError] = useState(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 ( + +

Caddy / TLS

+ {error ? ( +

{error}

+ ) : data ? ( +
+ {data.soonestExpiryMs !== null ? ( +

+ {daysUntil(data.soonestExpiryMs)} + days to expiry +

+ ) : ( +

no certs

+ )} +

+ {data.count} cert{data.count !== 1 ? 's' : ''} +

+
+ ) : ( +

+ )} + + ) +} diff --git a/app/_components/DockerWidget.tsx b/app/_components/DockerWidget.tsx new file mode 100644 index 0000000..5c069c2 --- /dev/null +++ b/app/_components/DockerWidget.tsx @@ -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 { + 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(initial.data) + const [error, setError] = useState(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 ( + +

Docker

+ {error ? ( +

{error}

+ ) : data ? ( +

+ {data.running} + / {data.total} running +

+ ) : ( +

+ )} + + ) +} diff --git a/app/_components/GitWidget.tsx b/app/_components/GitWidget.tsx new file mode 100644 index 0000000..0513ff2 --- /dev/null +++ b/app/_components/GitWidget.tsx @@ -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 { + 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( + !notConfigured && 'data' in initial ? initial.data : null, + ) + const [error, setError] = useState( + !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 ( + +

Git

+ {notConfigured ? ( +

niet geconfigureerd

+ ) : error ? ( +

{error}

+ ) : data ? ( +

+ {data.dirty} + + {' '}repo{data.dirty !== 1 ? 's' : ''} dirty + +

+ ) : ( +

+ )} + + ) +} diff --git a/app/_components/SystemdWidget.tsx b/app/_components/SystemdWidget.tsx new file mode 100644 index 0000000..9c01760 --- /dev/null +++ b/app/_components/SystemdWidget.tsx @@ -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 { + 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( + !notConfigured && 'data' in initial ? initial.data : null, + ) + const [error, setError] = useState( + !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 ( + +

systemd

+ {notConfigured ? ( +

niet geconfigureerd

+ ) : error ? ( +

{error}

+ ) : data ? ( +

+ {data.healthy}/{data.total} + healthy +

+ ) : ( +

+ )} + + ) +} diff --git a/app/api/audit/latest/route.ts b/app/api/audit/latest/route.ts new file mode 100644 index 0000000..7dda6cb --- /dev/null +++ b/app/api/audit/latest/route.ts @@ -0,0 +1,20 @@ +import { NextResponse } from 'next/server' +import { getCurrentUser } from '@/lib/session' +import { prisma } from '@/lib/prisma' + +export const dynamic = 'force-dynamic' + +export async function GET() { + const user = await getCurrentUser() + if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 }) + + const run = await prisma.flowRun.findFirst({ + where: { user_id: user.id }, + orderBy: { started_at: 'desc' }, + select: { id: true, flow_key: true, status: true, started_at: true }, + }) + + return NextResponse.json({ + run: run ? { ...run, started_at: run.started_at.toISOString() } : null, + }) +} diff --git a/app/page.tsx b/app/page.tsx index 99118b6..a8dab7b 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,23 +1,126 @@ -import Link from 'next/link' import { redirect } from 'next/navigation' import { getCurrentUser } from '@/lib/session' +import { execAgent } from '@/lib/agent-client' +import { prisma } from '@/lib/prisma' +import { parseDockerPs } from '@/lib/parse-docker' +import { parseCertList } from '@/lib/parse-caddy' +import { parseSystemctlStatus } from '@/lib/parse-systemd' +import { parseGitStatus } from '@/lib/parse-git' +import DockerWidget, { type DockerInitial } from './_components/DockerWidget' +import CaddyWidget, { type CaddyInitial } from './_components/CaddyWidget' +import SystemdWidget, { type SystemdInitial } from './_components/SystemdWidget' +import GitWidget, { type GitInitial } from './_components/GitWidget' +import AuditWidget, { type AuditInitial } from './_components/AuditWidget' export const dynamic = 'force-dynamic' -const SECTIONS = [ - { href: '/docker', title: 'Docker', desc: 'Containers en status' }, - { href: '/git', title: 'Git', desc: 'Repo checkouts en diffs' }, - { href: '/systemd', title: 'systemd', desc: 'Services en journals' }, - { href: '/caddy', title: 'Caddy', desc: 'Config en certs' }, - { href: '/flows', title: 'Flows', desc: 'Multi-step deployments' }, - { href: '/audit', title: 'Audit', desc: 'Command-log en runs' }, - { href: '/settings', title: 'Settings', desc: 'Backups en config' }, -] - export default async function Home() { const user = await getCurrentUser() if (!user) redirect('/login') + const systemdUnits = (process.env.SYSTEMD_UNITS ?? '') + .split(',') + .map((u) => u.trim()) + .filter(Boolean) + + const repoPaths = (process.env.REPO_PATHS ?? '') + .split(',') + .map((p) => p.trim()) + .filter(Boolean) + + const [[dockerResult, caddyResult, auditResult], unitResults, repoResults] = await Promise.all([ + Promise.allSettled([ + execAgent('docker_ps'), + execAgent('caddy_list_certs'), + prisma.flowRun.findFirst({ + where: { user_id: user.id }, + orderBy: { started_at: 'desc' }, + select: { id: true, flow_key: true, status: true, started_at: true }, + }), + ]), + Promise.allSettled(systemdUnits.map((unit) => execAgent('systemctl_status', [unit]))), + Promise.allSettled(repoPaths.map((path) => execAgent('git_status', [path]))), + ]) + + // Docker widget initial state + const dockerInitial: DockerInitial = + dockerResult.status === 'rejected' + ? { data: null, error: dockerResult.reason instanceof Error ? dockerResult.reason.message : 'failed' } + : (() => { + const containers = parseDockerPs(dockerResult.value) + return { + data: { + running: containers.filter((c) => c.status.toLowerCase().startsWith('up')).length, + total: containers.length, + }, + error: null, + } + })() + + // Caddy widget initial state + const caddyInitial: CaddyInitial = + caddyResult.status === 'rejected' + ? { data: null, error: caddyResult.reason instanceof Error ? caddyResult.reason.message : 'failed' } + : (() => { + const certs = parseCertList(caddyResult.value) + const expiryTimes = certs + .filter((c) => c.notAfter) + .map((c) => new Date(c.notAfter).getTime()) + return { + data: { + soonestExpiryMs: expiryTimes.length > 0 ? Math.min(...expiryTimes) : null, + count: certs.length, + }, + error: null, + } + })() + + // Systemd widget initial state + let systemdInitial: SystemdInitial + if (systemdUnits.length === 0) { + systemdInitial = { configured: false } + } else if (unitResults.every((r) => r.status === 'rejected')) { + const first = unitResults[0] + systemdInitial = { + data: null, + error: first.status === 'rejected' && first.reason instanceof Error ? first.reason.message : 'all units failed', + } + } else { + const healthy = unitResults.reduce((count, r, i) => { + if (r.status !== 'fulfilled') return count + return parseSystemctlStatus(r.value, systemdUnits[i]).activeState === 'active' ? count + 1 : count + }, 0) + systemdInitial = { data: { healthy, total: systemdUnits.length }, error: null } + } + + // Git widget initial state + let gitInitial: GitInitial + if (repoPaths.length === 0) { + gitInitial = { configured: false } + } else if (repoResults.every((r) => r.status === 'rejected')) { + const first = repoResults[0] + gitInitial = { + data: null, + error: first.status === 'rejected' && first.reason instanceof Error ? first.reason.message : 'all repos failed', + } + } else { + const dirty = repoResults.filter( + (r) => r.status === 'fulfilled' && parseGitStatus(r.value).dirty, + ).length + gitInitial = { data: { dirty, total: repoPaths.length }, error: null } + } + + // Audit widget initial state + const auditInitial: AuditInitial = + auditResult.status === 'rejected' + ? { data: null, error: auditResult.reason instanceof Error ? auditResult.reason.message : 'failed' } + : { + data: auditResult.value + ? { ...auditResult.value, started_at: auditResult.value.started_at.toISOString() } + : null, + error: null, + } + return (
@@ -25,17 +128,12 @@ export default async function Home() {

Ops Dashboard

Welkom {user.email}

-
- {SECTIONS.map((s) => ( - -

{s.title}

-

{s.desc}

- - ))} +
+ + + + +
diff --git a/lib/agent-fetch.ts b/lib/agent-fetch.ts new file mode 100644 index 0000000..2561325 --- /dev/null +++ b/lib/agent-fetch.ts @@ -0,0 +1,41 @@ +import { apiFetch } from '@/lib/csrf' + +export async function fetchAgentOutput(commandKey: string, args: string[] = []): Promise { + const res = await apiFetch('/api/agent/exec', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command_key: commandKey, args }), + }) + + if (!res.ok) { + const text = await res.text() + throw new Error(`agent ${res.status}: ${text}`) + } + + const reader = res.body?.getReader() + if (!reader) throw new Error('no response body') + + const decoder = new TextDecoder() + let buffer = '' + let output = '' + + while (true) { + const { done, value } = await reader.read() + if (done) break + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n') + buffer = lines.pop() ?? '' + for (const line of lines) { + if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.slice(5).trim()) as { data?: string } + if (parsed.data !== undefined) output += parsed.data + } catch { + // ignore malformed SSE + } + } + } + } + + return output +} From 08d4b481902a2c6477e2ed1590a9d2ce51c80e57 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:10:51 +0200 Subject: [PATCH 4/7] feat(widgets): voeg relativeTime toe in lib/utils, expiringWarning-badge in CaddyWidget - relativeTime(date: Date) helper toegevoegd aan lib/utils.ts - AuditWidget gebruikt nu gedeelde relativeTime in plaats van inline functie - CaddyWidget toont rode badge als soonest cert-expiry <30 dagen - app/page.tsx berekent expiringWarning voor CaddyInitial Co-Authored-By: Claude Sonnet 4.6 --- app/_components/AuditWidget.tsx | 13 ++----------- app/_components/CaddyWidget.tsx | 20 ++++++++++++++------ app/page.tsx | 1 + lib/utils.ts | 10 ++++++++++ 4 files changed, 27 insertions(+), 17 deletions(-) diff --git a/app/_components/AuditWidget.tsx b/app/_components/AuditWidget.tsx index 270e308..0a9422e 100644 --- a/app/_components/AuditWidget.tsx +++ b/app/_components/AuditWidget.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react' import Link from 'next/link' import { apiFetch } from '@/lib/csrf' +import { relativeTime } from '@/lib/utils' type LatestRun = { id: string; flow_key: string; status: string; started_at: string } export type AuditInitial = @@ -17,16 +18,6 @@ const STATUS_STYLES: Record = { 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 { const res = await apiFetch('/api/audit/latest') if (!res.ok) throw new Error(`${res.status}`) @@ -66,7 +57,7 @@ export default function AuditWidget({ initial }: { initial: AuditInitial }) { > {data.status} - {relativeTime(data.started_at)} + {relativeTime(new Date(data.started_at))}

{data.flow_key}

diff --git a/app/_components/CaddyWidget.tsx b/app/_components/CaddyWidget.tsx index 5fc0b81..e7d6dc6 100644 --- a/app/_components/CaddyWidget.tsx +++ b/app/_components/CaddyWidget.tsx @@ -5,7 +5,7 @@ import Link from 'next/link' import { parseCertList } from '@/lib/parse-caddy' import { fetchAgentOutput } from '@/lib/agent-fetch' -type CaddyData = { soonestExpiryMs: number | null; count: number } +type CaddyData = { soonestExpiryMs: number | null; count: number; expiringWarning: boolean } export type CaddyInitial = { data: CaddyData; error: null } | { data: null; error: string } async function refreshCaddy(): Promise { @@ -15,7 +15,8 @@ async function refreshCaddy(): Promise { .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 } + const expiringWarning = certs.some((c) => c.expiringWarning) + return { soonestExpiryMs, count: certs.length, expiringWarning } } function daysUntil(ms: number): number { @@ -49,10 +50,17 @@ export default function CaddyWidget({ initial }: { initial: CaddyInitial }) { ) : data ? (
{data.soonestExpiryMs !== null ? ( -

- {daysUntil(data.soonestExpiryMs)} - days to expiry -

+
+

+ {daysUntil(data.soonestExpiryMs)} + dagen tot expiry +

+ {data.expiringWarning && ( + + <30d + + )} +
) : (

no certs

)} diff --git a/app/page.tsx b/app/page.tsx index a8dab7b..562d833 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -70,6 +70,7 @@ export default async function Home() { data: { soonestExpiryMs: expiryTimes.length > 0 ? Math.min(...expiryTimes) : null, count: certs.length, + expiringWarning: certs.some((c) => c.expiringWarning), }, error: null, } diff --git a/lib/utils.ts b/lib/utils.ts index bd0c391..49c77ca 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) } + +export function relativeTime(date: Date): string { + const diff = Date.now() - date.getTime() + const minutes = Math.floor(diff / 60_000) + if (minutes < 1) return 'net nu' + 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` +} From f6d0807a815ab2d37fbd1919432b6b3685ae0194 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:14:01 +0200 Subject: [PATCH 5/7] feat(widgets): voeg kleurbadges toe aan SystemdWidget en GitWidget - SystemdWidget: groen als N=M healthy, oranje als 00; toon K/M formaat Co-Authored-By: Claude Sonnet 4.6 --- app/_components/GitWidget.tsx | 11 ++++++++--- app/_components/SystemdWidget.tsx | 11 ++++++++++- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/app/_components/GitWidget.tsx b/app/_components/GitWidget.tsx index 0513ff2..333b84b 100644 --- a/app/_components/GitWidget.tsx +++ b/app/_components/GitWidget.tsx @@ -58,10 +58,15 @@ export default function GitWidget({ initial, repos }: { initial: GitInitial; rep ) : error ? (

{error}

) : data ? ( -

- {data.dirty} +

+ {data.dirty}/{data.total} - {' '}repo{data.dirty !== 1 ? 's' : ''} dirty + {' '}repos uncommitted

) : ( diff --git a/app/_components/SystemdWidget.tsx b/app/_components/SystemdWidget.tsx index 9c01760..147d141 100644 --- a/app/_components/SystemdWidget.tsx +++ b/app/_components/SystemdWidget.tsx @@ -58,7 +58,16 @@ export default function SystemdWidget({ initial, units }: { initial: SystemdInit ) : error ? (

{error}

) : data ? ( -

+

0 && data.healthy === data.total + ? 'text-green-600' + : data.healthy > 0 + ? 'text-orange-500' + : 'text-destructive', + ].join(' ')} + > {data.healthy}/{data.total} healthy

From e5423de319966e59ebba766b28a155521560ac77 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:15:09 +0200 Subject: [PATCH 6/7] fix(deploy): update build context naar /srv/ops/repos/ops-dashboard Co-Authored-By: Claude Sonnet 4.6 --- deploy/docker-compose.ops-dashboard.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/deploy/docker-compose.ops-dashboard.yml b/deploy/docker-compose.ops-dashboard.yml index 47580a9..ba48fd8 100644 --- a/deploy/docker-compose.ops-dashboard.yml +++ b/deploy/docker-compose.ops-dashboard.yml @@ -2,7 +2,7 @@ # Add the ops-dashboard service under the `services:` key. # # Build the image first: -# docker build -t ops-dashboard /srv/ops/ops-dashboard +# docker build -t ops-dashboard /srv/ops/repos/ops-dashboard # # Then bring the service up: # docker compose -f /srv/scrum4me/compose/docker-compose.yml up -d ops-dashboard @@ -10,7 +10,7 @@ services: ops-dashboard: build: - context: /srv/ops/ops-dashboard + context: /srv/ops/repos/ops-dashboard env_file: /srv/ops/ops-dashboard.env ports: - "127.0.0.1:3001:3000" From 2b11b999c0d4212cb57c6da4e09051f50b8c5b9e Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:16:04 +0200 Subject: [PATCH 7/7] fix(caddy): gebruik Docker servicenaam ipv host-IP in Caddyfile Vervang `reverse_proxy 172.18.0.1:3001` door `reverse_proxy ops-dashboard:3000` zodat de reverse-proxy stabiel werkt via Docker service-name resolution. Voeg comments toe als pre-conditie: Caddy moet op hetzelfde Docker-netwerk zitten. Co-Authored-By: Claude Sonnet 4.6 --- deploy/caddy/Caddyfile.ops-dashboard | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/deploy/caddy/Caddyfile.ops-dashboard b/deploy/caddy/Caddyfile.ops-dashboard index e8e0cab..7713fda 100644 --- a/deploy/caddy/Caddyfile.ops-dashboard +++ b/deploy/caddy/Caddyfile.ops-dashboard @@ -1,7 +1,10 @@ # Block to add to /srv/scrum4me/caddy/Caddyfile # After adding, restart Caddy (not reload — see deploy notes): # docker compose restart caddy +# Pre-condition: the Caddy container must share the Docker network of the ops-dashboard compose stack +# so that Docker service-name resolution works. ops.jp-visser.nl { - reverse_proxy 172.18.0.1:3001 + # Use Docker service-name; Caddy must share a network with ops-dashboard. + reverse_proxy ops-dashboard:3000 }