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 01/18] 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 02/18] 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 03/18] 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 04/18] 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 05/18] 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 06/18] 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 07/18] 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 } From 6581a9ef33d8273654cde57dcf2553aba1936d87 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:23:30 +0200 Subject: [PATCH 08/18] fix(agent): await child completion in /agent/v1/exec route MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fastify-handler returnde direct na het attachen van event handlers. Fastify finaliseerde dan de reply waardoor `req.raw.on('close')` direct firede en `child.kill()` aanriep voordat het kind iets kon produceren. Symptoom: SSE bevatte alleen `event:exit code:null` zonder stdout/stderr, audit-log toonde `exit_code:null duration_ms:0`, dashboard-modules toonden "No containers running" / "No data" terwijl handmatige command prima werkte. Wrap de event-handlers in een Promise zodat de async route-handler wacht op child close/error voordat ie returnt. Verplaats client-disconnect detectie van `req.raw.on('close')` naar `reply.raw.on('close')` — die fired bij echte connectie-sluiting, niet bij request body parse. Bevestigd: `docker_ps` retourneert nu volledige container-lijst, dashboard /docker pagina rendert alle 6 containers. Co-Authored-By: Claude Opus 4.7 (1M context) --- ops-agent/src/routes/exec.ts | 43 +++++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 15 deletions(-) diff --git a/ops-agent/src/routes/exec.ts b/ops-agent/src/routes/exec.ts index 9a393ec..d017246 100644 --- a/ops-agent/src/routes/exec.ts +++ b/ops-agent/src/routes/exec.ts @@ -108,20 +108,33 @@ export async function execRoutes(app: FastifyInstance): Promise { sendEvent('stderr', chunk.toString()); }); - child.on('close', (code) => { - auditLog(command_key, args, code, Date.now() - startedAt); - reply.raw.write(`event: exit\ndata: ${JSON.stringify({ code })}\n\n`); - reply.raw.end(); - }); - - child.on('error', (err) => { - auditLog(command_key, args, null, Date.now() - startedAt); - reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`); - reply.raw.end(); - }); - - req.raw.on('close', () => { - child.kill(); - }); + // Houd de route-handler open totdat het kind klaar is. Zonder dit return-t + // de async functie meteen, finaliseert Fastify de reply, en triggert dat + // `req.raw.on('close')` → `child.kill()` voordat het kind iets kon doen. + await new Promise((resolve) => { + let settled = false + const finish = () => { + if (settled) return + settled = true + resolve() + } + child.on('close', (code) => { + auditLog(command_key, args, code, Date.now() - startedAt) + reply.raw.write(`event: exit\ndata: ${JSON.stringify({ code })}\n\n`) + reply.raw.end() + finish() + }) + child.on('error', (err) => { + auditLog(command_key, args, null, Date.now() - startedAt) + reply.raw.write(`event: error\ndata: ${JSON.stringify({ message: err.message })}\n\n`) + reply.raw.end() + finish() + }) + // Detect client disconnect via response stream (niet request stream — + // die fired al direct na request body parse). + reply.raw.on('close', () => { + if (!settled) child.kill() + }) + }) }); } From 84d7bb0add56aa274b6a05ac8601a707cd4c42a1 Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:29:34 +0200 Subject: [PATCH 09/18] fix(caddy): val terug op nginx-grammar voor Caddyfile syntax-highlighting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shiki 1.29 bundelt geen 'caddyfile' grammar — runtime error "Language 'caddyfile' is not included in this bundle". Nginx-grammar is syntactisch het dichtst bij (directives + nested braces + reverse_proxy lijkt op location-blocks), dus levert acceptabele kleuring zonder dependency toe te voegen. Echte Caddyfile-grammar zou via een externe TextMate JSON moeten worden geladen — later. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/caddy/page.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/app/caddy/page.tsx b/app/caddy/page.tsx index eb8d580..d50604c 100644 --- a/app/caddy/page.tsx +++ b/app/caddy/page.tsx @@ -16,8 +16,10 @@ export default async function CaddyPage() { let configError: string | null = null try { const raw = await execAgent('caddy_show_config') + // shiki 1.29 bundelt geen caddyfile-grammar; nginx is syntactisch het + // dichtst bij (directives + braces + reverse_proxy lijkt op locations) configHtml = await codeToHtml(raw || '# (empty)', { - lang: 'caddyfile', + lang: 'nginx', theme: 'github-dark', }) } catch (err) { From fda7be3214d0efe266214917e5b07ad881f430bb Mon Sep 17 00:00:00 2001 From: Janpeter Visser <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 22:35:49 +0200 Subject: [PATCH 10/18] docs: handleiding + functionele + technische specificatie MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drie nieuwe markdown-bestanden onder /docs: - handleiding.md — voor de dagelijkse gebruiker: eerste login, modules, veelvoorkomende taken (Caddy editen, sprint mergen via flow), wat expliciet niet vanuit de UI kan, log-locaties bij incidenten, veiligheidsadvies. - specs/functional.md — wat de app doet: scope per module met acceptatiecriteria, flow state-machine (pending/running/success/ failed/cancelled/timeout), hard limits (1 actieve flow, 64KB log knippen, 24u session), expliciete buiten-scope-lijst. - specs/technical.md — hoe het werkt: 3-process architectuur (dashboard container + agent op host + Postgres), stack-tabel met versies en redenen, data-model (User/Session/FlowRun/FlowStep), auth-flow met CSRF, agent-protocol over SSE, security-eigenschappen per laag. Lengtes pragmatisch gekozen — geen completeness-fetisj, wel genoeg om iemand die nieuw is in de codebase binnen 30 min te oriënteren. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/handleiding.md | 128 +++++++++++++++++++ docs/specs/functional.md | 147 +++++++++++++++++++++ docs/specs/technical.md | 270 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 545 insertions(+) create mode 100644 docs/handleiding.md create mode 100644 docs/specs/functional.md create mode 100644 docs/specs/technical.md diff --git a/docs/handleiding.md b/docs/handleiding.md new file mode 100644 index 0000000..d5ceb1a --- /dev/null +++ b/docs/handleiding.md @@ -0,0 +1,128 @@ +# Handleiding — Ops Dashboard + +Voor de dagelijkse beheerder van een single-host server-stack (Docker, systemd, Git, Caddy, Postgres). Deze handleiding beschrijft *hoe* je de app gebruikt, niet *hoe* hij werkt — voor dat laatste zie [`specs/technical.md`](./specs/technical.md). + +## Eerste inlog + +1. Open `https:///` (bv. `https://ops.jp-visser.nl`). +2. Je wordt doorgestuurd naar `/login`. Vul de admin-credentials in die je tijdens deploy in `SEED_USER_EMAIL` / `SEED_USER_PASSWORD` hebt gezet. +3. Na succesvolle login zit je 24 uur ingelogd via een HttpOnly-cookie. Daarna opnieuw inloggen. + +Wachtwoord vergeten? Geen reset-flow in de app — los op met een SQL-update: + +```bash +# Genereer een nieuwe bcrypt-hash voor je nieuwe wachtwoord +docker run --rm -e PW='' node:22-alpine sh -c ' + cd /tmp && npm init -y >/dev/null 2>&1 && npm install --silent bcryptjs >/dev/null 2>&1 + node -e "console.log(require(\"bcryptjs\").hashSync(process.env.PW, 12))" +' +# Plak in psql: +docker exec -it scrum4me-postgres psql -U scrum4me -d ops_dashboard \ + -c "UPDATE \"User\" SET pwd_hash = '' WHERE email = '';" +``` + +## Dashboard (home) + +Vijf live status-widgets, auto-refresh ~5 sec: + +| Widget | Toont | +|---|---| +| **Docker** | Aantal draaiende containers + lijst | +| **Git** | Branch en uncommitted-status per geconfigureerd repo-pad | +| **systemd** | Service-status (active/inactive/failed) per geconfigureerde unit | +| **Caddy** | TLS-certs met dichtstbijzijnde expiratiedatum (geel = <30 dagen) | +| **Audit** | Laatste flow-run met timestamp en exit-status | + +Klik een widget aan om naar de detail-pagina te gaan. + +## Modules + +### `/docker` — Containers + +Tabel van `docker ps` met auto-refresh. Klik op een container-naam voor detail (logs, image, ports, status). + +Read-only — geen start/stop/restart vanuit de UI. Voor wijzigingen: een **Flow** (zie hieronder). + +### `/git` — Repositories + +Per geconfigureerd pad in `REPO_PATHS` (env var): branch, uncommitted-files (M/A/D/??), laatste 3 commits. Klik door voor diff-viewer. + +Read-only — pulls/commits gaan via een Flow. + +### `/systemd` — Services + +Lijst van services uit `SYSTEMD_UNITS` (env var). Toont status, laatste log-regels. Klik door voor full journal-tail van die unit. + +Restart-knop: alleen voor units die in `commands.yml` zijn whitelisted én in `sudoers.d/ops-agent` met `NOPASSWD` staan. + +### `/caddy` — Reverse-proxy & TLS + +Toont de actieve Caddyfile (syntax-highlighted) plus alle TLS-certs (subject + expiry). "Edit"-knop opent een editor — opslaan **valideert** de Caddyfile via `caddy validate` voordat het wordt geschreven. + +### `/flows` — Multi-step deployments + +Twee voor-gedefinieerde flows: + +- **Update Scrum4Me website** — pull main, build container, restart, smoke-test +- **Update Caddy config** — schrijf nieuwe Caddyfile, valideer, restart Caddy, verifieer dat alle hostnames nog reageren + +Een flow draait stap-voor-stap met **dry-run** als standaard. Na dry-run zie je per stap wat het gaat doen. Klik "Run" om echt uit te voeren. Tijdens executie zie je live stdout/stderr per stap. + +### `/audit` — Flow-runs + +Chronologische lijst van alle gestarte flows: starttijd, duur, exit-status, wie 'm startte. Klik door voor de volledige output (stdout/stderr per stap). + +### `/settings/backups` — Backups + +Postgres backup-management: + +- Lijst van bestaande dump-bestanden in `/srv/scrum4me/backups` +- "Backup now"-knop maakt een dump met timestamp-naam +- Restore-runbook (handmatige stappen — geen automatische restore vanuit UI om de blast-radius klein te houden) + +## Veelvoorkomende taken + +### Container hangt — wat nu? + +1. `/docker` → klik container-naam → bekijk logs +2. Diagnose? Open een SSH-sessie en gebruik `docker logs`, `docker exec` etc. (Niet vanuit de UI — dat is buiten scope.) +3. Restart nodig? Voeg de container toe aan `commands.yml` whitelist (op de host) + run via `/flows` + +### Caddy-config wijzigen + +1. `/caddy` → "Edit" +2. Pas Caddyfile aan in de editor +3. Save → app draait `caddy validate` → bij succes wordt het geschreven en Caddy herstart +4. Verifieer in `/caddy` dat het cert-overzicht klopt + +> Voor breaking changes (verkeerde syntax of niet-bestaande site): de validate-stap blokkeert. Bij twijfel: maak eerst een backup van `/srv/scrum4me/caddy/Caddyfile`. + +### Sprint mergen via flow + +`/flows/update-scrum4me-web` — kies branch (default `main`), klik dry-run, lees wat het doet, klik "Run". Stap-output stream live. Na success: smoke-test verifieert dat de homepage 200 geeft. + +## Wat kan **niet** vanuit de UI + +- SSH-toegang of arbitrary shell-commando's (alleen whitelisted commands.yml-keys) +- User-management (één admin via seed; multi-user is buiten scope) +- Container starten met andere image of args (alleen restart van bestaande) +- Wachtwoord reset (SQL-update vereist) +- Cert handmatig forceren (Caddy doet auto-ACME) + +## Logs voor incident-response + +| Component | Log-locatie | +|---|---| +| Dashboard app | `docker logs scrum4me-ops-dashboard` | +| ops-agent | `journalctl -u ops-agent -f` | +| Caddy | `docker logs scrum4me-caddy` | +| Postgres | `docker logs scrum4me-postgres` | + +Audit-trail van wat-doet-wie-wanneer: tabel `FlowRun` + `FlowStep` in de `ops_dashboard` database, of via `/audit` in de UI. + +## Veiligheidsadvies + +- Houd port 3099 (ops-agent) **niet** open naar de buitenwereld. UFW-regel scoped op `172.18.0.0/16`. Zie [`runbooks/post-install.md`](./runbooks/post-install.md). +- Roteer `OPS_AGENT_SECRET` jaarlijks: nieuw secret in `.env` én `/etc/ops-agent/secret`, dan beide herstarten. +- Voeg geen wildcards toe in `sudoers.d/ops-agent` — elke `systemctl`-actie moet een expliciete service-naam zijn. +- `commands.yml` is single source of truth voor wat de agent mag — alles wat niet in de whitelist staat, kan een aanvaller niet uitvoeren ook al heeft hij het secret. diff --git a/docs/specs/functional.md b/docs/specs/functional.md new file mode 100644 index 0000000..ca2512c --- /dev/null +++ b/docs/specs/functional.md @@ -0,0 +1,147 @@ +# Functionele specificatie — Ops Dashboard + +## Doel + +Eén web-UI waarmee de eigenaar van een single-host server-stack (Docker + systemd + Git-checkouts + Caddy + Postgres) dezelfde operaties kan uitvoeren die anders in een SSH-terminal gebeuren — met audit-log, herhaalbare flows en minder typefouten. + +**Schaal:** één host, één admin-gebruiker. Multi-host/team is buiten scope. + +## Gebruikers en rollen + +| Rol | Beschrijving | Hoeveelheid | +|---|---|---| +| **admin** | Volle toegang tot alle modules en flows. Single account, geseed via env. | 1 | + +Geen RBAC, geen tenant-isolatie, geen "view-only"-modus. Wie inlogt kan alles. + +## Functionele scope per module + +### Dashboard (`/`) + +5 live status-widgets met auto-refresh ~5s: + +| Widget | Data-bron | Indicator | +|---|---|---| +| Docker | `docker ps --format json` | Count van running containers, lijst (naam + status) | +| Git | `git status --short --branch` per pad in `REPO_PATHS` | Branch + dirty-vlag | +| systemd | `systemctl is-active ` per item in `SYSTEMD_UNITS` | Active / Inactive / Failed | +| Caddy | `caddy admin-cmd certificates` (of equiv. shell-output parse) | Aantal certs + dichtstbijzijnde expiry | +| Audit | DB-query op `FlowRun` desc | Laatste run + status | + +**Acceptatie:** +- Widget laadt < 1 s na page-load +- Auto-refresh werkt in achtergrond zonder volledig herrenderen +- Bij fout (agent down, command faalt): widget toont rood errorblok, niet de hele page + +### Auth (`/login`) + +- Email + wachtwoord (single user) +- 5 failed attempts in 1 minuut → 429 rate-limit per IP +- Succesvolle login → session-cookie 24u, HttpOnly, SameSite=strict, Secure (production) +- `/api/auth/logout` invalideert sessie en wist cookie + +### Docker (`/docker`) + +- Lijst running containers (CONTAINER ID, IMAGE, COMMAND, CREATED, STATUS, PORTS, NAMES) +- Auto-refresh elke 5s +- `/docker/[name]` → detail-page met logs (laatste 200 regels), image-info, environment +- **Geen** start/stop/restart vanuit UI — alleen via flows + +### Git (`/git`) + +- Per pad in `REPO_PATHS`: huidige branch, ahead/behind count, modified-files-count, laatste 3 commits +- `/git/[repo]` → diff-viewer voor uncommitted changes + commit-historie laatste 20 + +### systemd (`/systemd`) + +- Per unit in `SYSTEMD_UNITS`: active/inactive/failed, last-changed-timestamp +- `/systemd/[unit]` → laatste 100 journal-regels van die unit, met level-filter +- **Restart-actie**: alleen voor units die expliciet in `sudoers.d/ops-agent` met NOPASSWD staan + +### Caddy (`/caddy`) + +- Toon huidige `/srv/scrum4me/caddy/Caddyfile` met syntax-highlighting +- Toon alle uitgegeven certs (subject, issuer, expiry, dichtstbijzijnde eerst) +- Geel-warning bij expiry < 30 dagen, rood bij < 7 dagen +- `/caddy/edit` → editor met save-knop; save valideert via `caddy validate` voor commit en restart van caddy-container + +### Flows (`/flows`) + +Twee voor-gedefinieerde flows in YAML in `ops-agent/flows.example/`: + +| Flow | Stappen | +|---|---| +| `update_scrum4me_web` | git pull → npm run build → docker compose up -d --build → smoke-test op homepage | +| `update_caddy_config` | write nieuw Caddyfile → caddy validate → docker compose restart caddy → check cert renewal | + +Per flow: +- Dry-run default (toont alleen wat het zou doen) +- "Run"-knop voert echt uit; toont live SSE-stream van stdout/stderr per stap +- Bij stap-fail: stop, markeer FlowRun als `failed`, latere stappen niet uitgevoerd +- Bij success: FlowRun = `success`, totaalduur opgeslagen + +### Audit (`/audit`) + +- Lijst van alle `FlowRun` records, default 50 laatste, sort desc op `started_at` +- Filter op status, datumrange, flow-name +- `/audit/[flow_run_id]` → volledige output per stap, scrollable + +### Settings/Backups (`/settings/backups`) + +- Lijst van `.sql.gz` bestanden in `/srv/scrum4me/backups`, met size + mtime +- "Backup now"-knop → maakt nieuwe dump met `pg_dumpall` voor alle databases +- Restore: **handmatig vanuit terminal** — UI toont alleen de stappen als runbook + +## State-machine flows + +``` + ┌────────┐ + │ pending │ + └────┬────┘ + │ (start request) + ┌────▼────┐ + │ running │ + └────┬────┘ + ┌────┼────┬────────┐ + ▼ ▼ ▼ ▼ + success failed cancelled timeout (>30min) +``` + +`pending` → `running`: bij ontvangst start-request +`running` → `success`: alle stappen exit-code 0 +`running` → `failed`: een stap exit-code ≠ 0 +`running` → `cancelled`: user klikt cancel +`running` → `timeout`: na 30 min nog steeds running (cleanup-job) + +## Hard limits + +- Max 1 actieve flow tegelijk (lock-file in `/var/run/agent/`); 2e start-request → 409 Conflict +- Stdout/stderr per stap geknipt op 64 KB om audit-log niet te laten exploderen +- Session-TTL hard 24 uur, geen "remember me" +- Auto-refresh max 1 keer per 5 seconden om agent niet te overbelasten + +## Niet-functionele eisen + +| Eis | Doel | +|---|---| +| First load < 1s | Single-user, lokale Postgres, geen onnodige round-trips | +| Module-page TTI < 2s | Server-side render met direct agent-call, geen client-fetch waterval | +| Audit-trail volledig | Elke flow-start logt user + tijdstempel + args; elke command-execution logt exit + duration | +| Geen geheime data in URLs | Tokens in headers, secrets nooit in query-params | +| CSP strict | `script-src 'self' 'unsafe-inline'`; geen externe CDNs | +| HTTPS-only in productie | Caddy auto-ACME; cookies Secure-flag in prod-mode | + +## Buiten scope + +- Meerdere admins / RBAC +- Meerdere hosts / cluster-management +- Custom container starten (alleen restart bestaande) +- Real-time alerts (geen pager, geen email) +- Externe monitoring-integratie (Grafana/Prometheus/Sentry) +- Wachtwoord-reset-flow / SSO + +## Verwante documenten + +- [Technische specificatie](./technical.md) — hoe het werkt +- [Handleiding](../handleiding.md) — hoe je het gebruikt +- [Post-install runbook](../runbooks/post-install.md) — eerste deploy diff --git a/docs/specs/technical.md b/docs/specs/technical.md new file mode 100644 index 0000000..33e67cb --- /dev/null +++ b/docs/specs/technical.md @@ -0,0 +1,270 @@ +# Technische specificatie — Ops Dashboard + +## Architectuur in één plaatje + +``` +┌────────────────┐ HTTPS ┌──────┐ HTTP ┌─────────────────┐ +│ Browser (jou) ├─────────►│Caddy ├────────►│ ops-dashboard │ +│ │ │ :443 │ │ Next.js 16 :3000│ +└────────────────┘ └──────┘ └────┬────────┬───┘ + │ │ + HMAC HTTP │ │ TCP/SQL + :3099 │ │ + ┌───────────────▼┐ │ + │ ops-agent │ │ + │ Fastify on host│ │ + │ spawn/exec │ │ + └───┬────────────┘ │ + │ │ + ┌───────┴───────┐ ┌───────▼────────┐ + │ Whitelisted │ │ Postgres 17 │ + │ host commands │ │ db=ops_dashb.. │ + │ docker/git/etc │ └────────────────┘ + └───────────────┘ +``` + +Drie processen, één host: + +1. **ops-dashboard** — Next.js app in Docker, op compose-bridge, exposed via Caddy +2. **ops-agent** — Node/Fastify service direct op host (geen container), heeft sudoers + docker.sock access +3. **postgres** — Docker container, dezelfde als die Scrum4Me al gebruikt; ops-dashboard heeft eigen DB `ops_dashboard` + +## Stack + +| Laag | Technologie | Versie | Reden | +|---|---|---|---| +| App framework | Next.js | 16.2 (App Router) | RSC server-side fetching matched onze "render with agent data" patroon | +| UI library | React | 19 | Bundled bij Next 16 | +| Styling | Tailwind CSS | 4 | Utility-first; geen custom design system | +| UI primitives | `@base-ui/react` | 1.4 | Headless components, geen Radix-lock-in | +| Code highlighting | shiki | 1.29 | Server-side highlighting in Caddyfile view | +| Database ORM | Prisma | 7.8 (via `@prisma/adapter-pg`) | Same as Scrum4Me; één skill om beide te onderhouden | +| Auth (password) | bcryptjs | 3 | Geen native bindings nodig | +| Session | Custom in `lib/session.ts` | — | Eenvoudig: token in DB, hash in cookie | +| Agent | Fastify | 5 | Lichtgewicht, native SSE-streaming | +| Agent whitelist | js-yaml | 4 | Read-only configfile | + +## Deploy-topologie + +| Component | Locatie | Beheer | +|---|---|---| +| ops-dashboard | Docker container `scrum4me-ops-dashboard`, image `ops-dashboard:latest` | `docker compose` in `/srv/scrum4me/compose/docker-compose.yml` | +| ops-agent | systemd unit `ops-agent.service`, host-binary `/opt/ops-agent/dist/index.js` | systemd, geïnstalleerd via `deploy/ops-agent/setup.sh` | +| Caddyfile-route | Block in `/srv/scrum4me/caddy/Caddyfile` | Handmatig, na add restart Caddy-container | +| Database | Postgres-container `scrum4me-postgres`, db `ops_dashboard` | Hergebruik bestaande container | +| Backups | `/srv/scrum4me/backups/*.sql.gz` | Cron of handmatig via UI | + +Caddy routeert `ops.jp-visser.nl` → service-naam `ops-dashboard:3000` op compose-bridge. + +## Data-model + +``` +User +├── id cuid (string PK) +├── email unique +├── pwd_hash bcrypt $2b$12$... +└── created_at + +Session +├── id cuid (PK) +├── user_id → User +├── token_hash sha256 hex (cookie waarde wordt gehashed opgeslagen) +└── expires_at 24h na create + +FlowRun +├── id cuid (PK) +├── user_id → User +├── flow_name string (bv. "update_scrum4me_web") +├── status enum: pending|running|success|failed|cancelled +├── started_at +├── finished_at nullable +└── (1:N) FlowStep + +FlowStep +├── id cuid (PK) +├── flow_run_id → FlowRun (cascade delete) +├── step_index int +├── name string (zoals in YAML flow-definitie) +├── exit_code int nullable +├── stdout text (max 64KB, geknipt) +├── stderr text (max 64KB, geknipt) +├── started_at +└── finished_at nullable +``` + +Migrations in `prisma/migrations/`. Seed in `prisma/seed.ts` (creëert eerste admin uit `SEED_USER_*`). + +## Auth-flow + +``` +1. Browser GET /login + ← Set-Cookie: csrf_token=; SameSite=strict; httpOnly=false + ← HTML form + +2. Browser POST /api/auth/login + Headers: + Cookie: csrf_token=; ops_session=... + x-csrf-token: ← double-submit CSRF check + Body: { email, password } + +3. Server: + a. proxy.ts CSRF check (cookie==header) + b. /api/auth/login route: + - rate-limit per IP (5/min) + - prisma.user.findUnique({ email }) + - bcrypt.compare(password, user.pwd_hash) + c. Bij succes: + - generateSessionToken (32 bytes hex) + - prisma.session.create({ token_hash: sha256(token), expires_at: now+24h }) + - Set-Cookie ops_session=; HttpOnly; SameSite=strict; Secure (in prod) + +4. Browser GET / + Server: proxy.ts → als geen ops_session cookie → redirect /login + Anders: getCurrentUser() leest cookie, hashed, prisma.session.findUnique({ token_hash }) +``` + +CSRF: double-submit cookie pattern. CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy via proxy.ts response-headers. + +## Agent-protocol + +Dashboard → agent communicatie via `lib/agent-client.ts`: + +``` +POST http://172.18.0.1:3099/agent/v1/exec +Headers: + Authorization: Bearer + Content-Type: application/json +Body: + { command_key: "docker_ps", args?: string[], stdin?: string } +``` + +Response: SSE stream + +``` +event: stdout +data: {"data": ""} + +event: stderr +data: {"data": ""} + +event: exit +data: {"code": 0} +``` + +Agent server-side flow per call: +1. `req.body.command_key` → lookup in `/etc/ops-agent/commands.yml` +2. Bij hit: spawn `def.cmd[0]` met `def.cmd.slice(1) ++ args` (geen shell, geen interpolatie) +3. Stream stdout/stderr chunks naar SSE +4. Bij `child.close`: write `event: exit`, end response +5. Bij `child.error`: write `event: error`, end response +6. Bij `reply.raw.close` (client-disconnect): `child.kill()` +7. Audit-log naar journalctl: `{audit:true, command_key, args, exit_code, duration_ms}` + +`commands.yml` voorbeeld: + +```yaml +docker_ps: + cmd: ["docker", "ps", "--format", "json"] + description: "List running containers" + +git_status: + cmd: ["git", "status", "--short", "--branch"] + cwd_pattern: true # args[0] = cwd, rest = command args + description: "Git status in a repo" + +systemctl_restart_caddy: + cmd: ["sudo", "/usr/bin/systemctl", "restart", "caddy"] + description: "Restart caddy service" +``` + +Geen `command_key` in whitelist → 403 Forbidden. + +## Flows engine + +YAML-definitie in `ops-agent/flows.example/*.yml`: + +```yaml +name: update_scrum4me_web +description: Pull main, build, restart container, verify +steps: + - name: Pull latest main + command_key: git_pull + args: ["/srv/scrum4me/repos/Scrum4Me", "main"] + precondition: git_status_clean + - name: Build container + command_key: docker_compose_build + args: ["scrum4me-web"] + - name: Restart + command_key: docker_compose_up + args: ["-d", "scrum4me-web"] + - name: Smoke test + command_key: curl_status + args: ["https://scrum4me.jp-visser.nl"] + expect_exit_code: 0 +``` + +Runner (`ops-agent/src/lib/flow-runner.ts`): +- Sequential, fail-fast +- Per stap: check preconditions, spawn, capture stdout/stderr, store in FlowStep +- Bij dry-run: vervang `spawn` door log van `def.cmd ++ args` +- Bij echte run: stream via SSE naar dashboard `/api/flows/run` route + +## Realtime in de UI + +Niet via WebSocket of Server-Sent Events op de dashboard-side. Auto-refresh wordt server-rendered (`export const dynamic = 'force-dynamic'`) met client-side `useEffect(setInterval, 5000)` om `router.refresh()` te triggeren. + +Flow-execution: client opent `EventSource` op `/api/flows/run/[id]` die de SSE van de agent doorstuurt. + +## Configuratie + +Verplicht in `.env`: + +```bash +DATABASE_URL=postgresql://USER:PASS@postgres:5432/ops_dashboard +OPS_AGENT_URL=http://172.18.0.1:3099 +OPS_AGENT_SECRET= +SEED_USER_EMAIL=admin@example.com +SEED_USER_PASSWORD= +``` + +Optioneel: + +```bash +SYSTEMD_UNITS=scrum4me-web,ops-agent # comma-separated +REPO_PATHS=/srv/scrum4me/repos/Scrum4Me,… # comma-separated absolute paths +``` + +Bij start: app valideert dat verplichte env vars gezet zijn; faalt fast met duidelijke error. + +## Security-eigenschappen + +| Eigenschap | Implementatie | +|---|---| +| Wachtwoord-hashing | bcrypt 12 rounds | +| Session-cookie | HttpOnly, SameSite=strict, Secure in prod, 24u TTL | +| CSRF | Double-submit cookie pattern, validated in `proxy.ts` voor POSTs | +| CSP | Strict in response headers — geen inline scripts behalve Next.js internals met nonce | +| Agent-auth | HMAC via Bearer-token (`OPS_AGENT_SECRET`) — symmetrisch | +| Command-injection | `spawn(bin, args, {shell: false})` — geen shell-interpolatie ooit | +| Whitelist | `commands.yml` is single source of truth voor wat draaibaar is | +| Sudo | `sudoers.d/ops-agent` met absolute paden + service-namen, geen wildcards | +| Audit | Elke `/agent/v1/exec` call logt naar journalctl met `{audit:true, …}` markeer | +| Rate-limit | Login 5/min/IP; agent per-secret zonder rate-limit (single-user trust) | +| Bind | Agent bindt op `0.0.0.0:3099`; UFW staat alleen `172.18.0.0/16` toe | + +## Niet-functionele eigenschappen + +| Eigenschap | Specificatie | +|---|---| +| Geen multi-tenancy | Eén user-row in DB, app verifieert alleen "is er een geldig session-record"; geen `WHERE user_id = ?` filter (single-tenant) | +| Geen retry/queue | Failed flows blijven failed; user moet handmatig opnieuw klikken | +| Geen migrations-automation | `prisma migrate deploy` is **niet** in de boot-flow; doe je expliciet bij elke deploy | +| Geen graceful shutdown | Container SIGTERM → in-flight requests verloren; geen drain | +| Logging | Stdout/stderr van containers via `docker logs`; agent via `journalctl -u ops-agent`; geen aggregator | + +## Open punten + +- **Echte caddyfile-grammar** (IDEA-061) — nu nginx-fallback +- **Multi-user / RBAC** — buiten scope, mogelijk later +- **Rate-limit op agent** — voor multi-user toekomst nodig +- **Real-time alerts** — momenteel pull-based, push naar Slack/Tailscale-only nog niet From 93b50254e562d5c4a6ecd2f3988dd13329ea0b73 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 23:28:47 +0200 Subject: [PATCH 11/18] feat(caddy): add Caddyfile TextMate grammar and enable Shiki syntax highlighting Adds lib/grammars/caddyfile.json with scopes for directives, named-matchers (@prefix), placeholders, strings, and comments. Updates /caddy page to use createHighlighter with the local grammar instead of the nginx fallback. Co-Authored-By: Claude Sonnet 4.6 --- app/caddy/page.tsx | 14 ++++--- lib/grammars/caddyfile.json | 83 +++++++++++++++++++++++++++++++++++++ 2 files changed, 92 insertions(+), 5 deletions(-) create mode 100644 lib/grammars/caddyfile.json diff --git a/app/caddy/page.tsx b/app/caddy/page.tsx index d50604c..3e3e411 100644 --- a/app/caddy/page.tsx +++ b/app/caddy/page.tsx @@ -1,6 +1,7 @@ import { redirect } from 'next/navigation' import Link from 'next/link' -import { codeToHtml } from 'shiki' +import { createHighlighter } from 'shiki' +import caddyfileGrammar from '@/lib/grammars/caddyfile.json' import { getCurrentUser } from '@/lib/session' import { execAgent } from '@/lib/agent-client' import { parseCertList, type CertInfo } from '@/lib/parse-caddy' @@ -16,10 +17,13 @@ export default async function CaddyPage() { let configError: string | null = null try { const raw = await execAgent('caddy_show_config') - // shiki 1.29 bundelt geen caddyfile-grammar; nginx is syntactisch het - // dichtst bij (directives + braces + reverse_proxy lijkt op locations) - configHtml = await codeToHtml(raw || '# (empty)', { - lang: 'nginx', + const highlighter = await createHighlighter({ + themes: ['github-dark'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + langs: [caddyfileGrammar as any], + }) + configHtml = highlighter.codeToHtml(raw || '# (empty)', { + lang: 'caddyfile', theme: 'github-dark', }) } catch (err) { diff --git a/lib/grammars/caddyfile.json b/lib/grammars/caddyfile.json new file mode 100644 index 0000000..5d545a1 --- /dev/null +++ b/lib/grammars/caddyfile.json @@ -0,0 +1,83 @@ +{ + "name": "caddyfile", + "scopeName": "source.Caddyfile", + "fileTypes": ["Caddyfile"], + "patterns": [ + { "include": "#comment" }, + { "include": "#site-address" }, + { "include": "#named-matcher-def" }, + { "include": "#named-matcher-ref" }, + { "include": "#directive" }, + { "include": "#placeholder" }, + { "include": "#string-double" }, + { "include": "#string-backtick" }, + { "include": "#number" }, + { "include": "#braces" } + ], + "repository": { + "comment": { + "name": "comment.line.number-sign.caddyfile", + "match": "#.*$" + }, + "site-address": { + "name": "entity.name.section.caddyfile", + "match": "^(?:https?://)?[a-zA-Z0-9][a-zA-Z0-9.*-]*(?::[0-9]+)?(?=\\s*(?:\\{|,|$))" + }, + "named-matcher-def": { + "name": "entity.other.attribute-name.caddyfile", + "match": "@[a-zA-Z_][a-zA-Z0-9_-]*(?=\\s)" + }, + "named-matcher-ref": { + "name": "entity.other.attribute-name.caddyfile", + "match": "(?<=\\s)@[a-zA-Z_][a-zA-Z0-9_-]*" + }, + "directive": { + "patterns": [ + { + "name": "keyword.control.caddyfile", + "match": "\\b(reverse_proxy|encode|file_server|handle_errors|handle_path|handle|root|header|request_header|response_header|redir|respond|rewrite|uri|try_files|php_fastcgi|push|templates|basicauth|forward_auth|map|vars|log|tls|bind|import|snippet|abort|error|static_response|acme_server|invoke)\\b" + }, + { + "name": "support.function.caddyfile", + "match": "\\b(on_demand|off|auto|internal|force|strip_prefix|replace|path_regexp|method|host|header_regexp|remote_ip|client_ip|not|query|cookie|expression|path|protocol|vars_regexp|file|jwt|geo_ip)\\b" + }, + { + "name": "keyword.other.option.caddyfile", + "match": "\\b(auto_https|admin|debug|grace_period|shutdown_delay|servers|storage|order|email|acme_ca|acme_ca_root|acme_eab|ocsp_stapling|key_type|cert_issuer|local_certs|skip_install_trust|renew_interval|check_interval|persistent_key|insecure_secrets_log|prefer_wildcard|resolvers|max_size|retention|format|output|level|sampling|include|exclude|dial|upstream|transport|lb_policy|health_uri|health_interval|health_timeout|health_status|health_body|flush_interval|buffer_requests|buffer_responses|max_buffer_size|trusted_proxies|to|from|prefix|replacements|gzip|zstd|br)\\b" + } + ] + }, + "placeholder": { + "name": "variable.other.caddyfile", + "match": "\\{(?:http\\.(?:request|response|vars|regexp|handlers)|tls|env|vars|system|time|rand|counter|uuid|path|query|header|cookie|form|file|dir|args|blocks|labels|err|http)[^}]*\\}" + }, + "string-double": { + "name": "string.quoted.double.caddyfile", + "begin": "\"", + "end": "\"", + "patterns": [ + { + "name": "constant.character.escape.caddyfile", + "match": "\\\\." + }, + { + "name": "variable.other.caddyfile", + "match": "\\{[^}]+\\}" + } + ] + }, + "string-backtick": { + "name": "string.quoted.other.caddyfile", + "begin": "`", + "end": "`" + }, + "number": { + "name": "constant.numeric.caddyfile", + "match": "\\b[0-9]+(?:\\.[0-9]+)?(?:s|ms|m|h|d|kb|mb|gb)?\\b" + }, + "braces": { + "name": "punctuation.section.block.caddyfile", + "match": "[{}]" + } + } +} From 87f554083dfdb56ea0c234054347e4e1c4d71934 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 23:31:46 +0200 Subject: [PATCH 12/18] refactor(caddy): extract module-level highlighter singleton Replace inline createHighlighter() call with a module-level singleton so the Caddyfile grammar is parsed only once across requests. Add type Highlighter import for proper TypeScript typing. Co-Authored-By: Claude Sonnet 4.6 --- app/caddy/page.tsx | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/app/caddy/page.tsx b/app/caddy/page.tsx index 3e3e411..ff20c4b 100644 --- a/app/caddy/page.tsx +++ b/app/caddy/page.tsx @@ -1,6 +1,6 @@ import { redirect } from 'next/navigation' import Link from 'next/link' -import { createHighlighter } from 'shiki' +import { createHighlighter, type Highlighter } from 'shiki' import caddyfileGrammar from '@/lib/grammars/caddyfile.json' import { getCurrentUser } from '@/lib/session' import { execAgent } from '@/lib/agent-client' @@ -9,6 +9,18 @@ import CaddyView from './_components/caddy-view' export const dynamic = 'force-dynamic' +let highlighterPromise: Promise | null = null +function getHighlighter() { + if (!highlighterPromise) { + highlighterPromise = createHighlighter({ + themes: ['github-dark'], + // eslint-disable-next-line @typescript-eslint/no-explicit-any + langs: [caddyfileGrammar as any], + }) + } + return highlighterPromise +} + export default async function CaddyPage() { const user = await getCurrentUser() if (!user) redirect('/login') @@ -17,11 +29,7 @@ export default async function CaddyPage() { let configError: string | null = null try { const raw = await execAgent('caddy_show_config') - const highlighter = await createHighlighter({ - themes: ['github-dark'], - // eslint-disable-next-line @typescript-eslint/no-explicit-any - langs: [caddyfileGrammar as any], - }) + const highlighter = await getHighlighter() configHtml = highlighter.codeToHtml(raw || '# (empty)', { lang: 'caddyfile', theme: 'github-dark', From 7d5a7576bf706ad2c84d317a2bc931627f99f3c7 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 23:36:30 +0200 Subject: [PATCH 13/18] feat(deps): install CodeMirror 6 dependencies for live Caddyfile editor Adds codemirror@6.0.2, @codemirror/view@6.42.1, @codemirror/state@6.6.0, @codemirror/language@6.12.3, @codemirror/legacy-modes@6.5.2 and @uiw/react-codemirror@4.25.9. No peer-dep conflicts; @uiw/react-codemirror v4.x is compatible with React 19.2.4 in this project. Co-Authored-By: Claude Sonnet 4.6 --- package-lock.json | 224 ++++++++++++++++++++++++++++++++++++++++++++++ package.json | 6 ++ 2 files changed, 230 insertions(+) diff --git a/package-lock.json b/package-lock.json index 51b6a01..0eb4437 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,13 +9,19 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "^1.4.1", + "@codemirror/language": "^6.12.3", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.42.1", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@types/bcryptjs": "^2.4.6", "@types/pg": "^8.20.0", + "@uiw/react-codemirror": "^4.25.9", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "lucide-react": "^1.14.0", "next": "16.2.6", "pg": "^8.20.0", @@ -551,6 +557,108 @@ } } }, + "node_modules/@codemirror/autocomplete": { + "version": "6.20.2", + "resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.2.tgz", + "integrity": "sha512-G5FPkgIiLjOgZMjqVjvuKQ1rGPtHogLldJr33eFJdVLtmwY+giGrlv/ewljLz6b9BSQLkjxuwBc6g6omDM+YxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@codemirror/commands": { + "version": "6.10.3", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.10.3.tgz", + "integrity": "sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.27.0", + "@lezer/common": "^1.1.0" + } + }, + "node_modules/@codemirror/language": { + "version": "6.12.3", + "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.12.3.tgz", + "integrity": "sha512-QwCZW6Tt1siP37Jet9Tb02Zs81TQt6qQrZR2H+eGMcFsL1zMrk2/b9CLC7/9ieP1fjIUMgviLWMmgiHoJrj+ZA==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.23.0", + "@lezer/common": "^1.5.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0", + "style-mod": "^4.0.0" + } + }, + "node_modules/@codemirror/legacy-modes": { + "version": "6.5.2", + "resolved": "https://registry.npmjs.org/@codemirror/legacy-modes/-/legacy-modes-6.5.2.tgz", + "integrity": "sha512-/jJbwSTazlQEDOQw2FJ8LEEKVS72pU0lx6oM54kGpL8t/NJ2Jda3CZ4pcltiKTdqYSRk3ug1B3pil1gsjA6+8Q==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0" + } + }, + "node_modules/@codemirror/lint": { + "version": "6.9.6", + "resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.6.tgz", + "integrity": "sha512-6Kp7r6XfCi/D/5sdXieMfg9pJU1bUEx96WITuLU6ESaKizCz0QHFMjY/TaFSbigDdEAIgi93itLBIUETP4oK+A==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.42.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/search": { + "version": "6.7.0", + "resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.0.tgz", + "integrity": "sha512-ZvGm99wc/s2cITtMT15LFdn8aH/aS+V+DqyGq/N5ZlV5vWtH+nILvC2nw0zX7ByNoHHDZ2IxxdW38O0tc5nVHg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.37.0", + "crelt": "^1.0.5" + } + }, + "node_modules/@codemirror/state": { + "version": "6.6.0", + "resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.6.0.tgz", + "integrity": "sha512-4nbvra5R5EtiCzr9BTHiTLc+MLXK2QGiAVYMyi8PkQd3SR+6ixar/Q/01Fa21TBIDOZXgeWV4WppsQolSreAPQ==", + "license": "MIT", + "dependencies": { + "@marijn/find-cluster-break": "^1.0.0" + } + }, + "node_modules/@codemirror/theme-one-dark": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/@codemirror/theme-one-dark/-/theme-one-dark-6.1.3.tgz", + "integrity": "sha512-NzBdIvEJmx6fjeremiGp3t/okrLPYT0d9orIc7AFun8oZcRk58aejkqhv6spnz4MLAevrKNPMQYXEWMg4s+sKA==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0", + "@lezer/highlight": "^1.0.0" + } + }, + "node_modules/@codemirror/view": { + "version": "6.42.1", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.42.1.tgz", + "integrity": "sha512-ToN3oFc0nsxNUYVF5P0ztLgbC4UPPjPtA9aKYhkOKQaZASpOUo6ISXyQLP66ctVwlDc+j6Jv0uK5IFALkiXztg==", + "license": "MIT", + "dependencies": { + "@codemirror/state": "^6.6.0", + "crelt": "^1.0.6", + "style-mod": "^4.1.0", + "w3c-keyname": "^2.2.4" + } + }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", "resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz", @@ -1406,6 +1514,36 @@ "integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==", "license": "MIT" }, + "node_modules/@lezer/common": { + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.2.tgz", + "integrity": "sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ==", + "license": "MIT" + }, + "node_modules/@lezer/highlight": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.3.tgz", + "integrity": "sha512-qXdH7UqTvGfdVBINrgKhDsVTJTxactNNxLk7+UMwZhU13lMHaOBlJe9Vqp907ya56Y3+ed2tlqzys7jDkTmW0g==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.3.0" + } + }, + "node_modules/@lezer/lr": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.10.tgz", + "integrity": "sha512-rnCpTIBafOx4mRp43xOxDJbFipJm/c0cia/V5TiGlhmMa+wsSdoGmUN3w5Bqrks/09Q/D4tNAmWaT8p6NRi77A==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.0.0" + } + }, + "node_modules/@marijn/find-cluster-break": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz", + "integrity": "sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g==", + "license": "MIT" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -2562,6 +2700,59 @@ "integrity": "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==", "license": "MIT" }, + "node_modules/@uiw/codemirror-extensions-basic-setup": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/codemirror-extensions-basic-setup/-/codemirror-extensions-basic-setup-4.25.9.tgz", + "integrity": "sha512-QFAqr+pu6lDmNpAlecODcF49TlsrZ0bj15zPzfhiqSDl+Um3EsDLFLppixC7kFLn+rdDM2LTvVjn5CPvefpRgw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@codemirror/autocomplete": ">=6.0.0", + "@codemirror/commands": ">=6.0.0", + "@codemirror/language": ">=6.0.0", + "@codemirror/lint": ">=6.0.0", + "@codemirror/search": ">=6.0.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/view": ">=6.0.0" + } + }, + "node_modules/@uiw/react-codemirror": { + "version": "4.25.9", + "resolved": "https://registry.npmjs.org/@uiw/react-codemirror/-/react-codemirror-4.25.9.tgz", + "integrity": "sha512-HftqCBUYShAOH0pGi1CHP8vfm5L8fQ3+0j0VI6lQD6QpK+UBu3J7nxfEN5O/BXMilMNf9ZyFJRvRcuMMOLHMng==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.6", + "@codemirror/commands": "^6.1.0", + "@codemirror/state": "^6.1.1", + "@codemirror/theme-one-dark": "^6.0.0", + "@uiw/codemirror-extensions-basic-setup": "4.25.9", + "codemirror": "^6.0.0" + }, + "funding": { + "url": "https://jaywcjlove.github.io/#/sponsor" + }, + "peerDependencies": { + "@babel/runtime": ">=7.11.0", + "@codemirror/state": ">=6.0.0", + "@codemirror/theme-one-dark": ">=6.0.0", + "@codemirror/view": ">=6.0.0", + "codemirror": ">=6.0.0", + "react": ">=17.0.0", + "react-dom": ">=17.0.0" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -3130,6 +3321,21 @@ "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", "license": "MIT" }, + "node_modules/codemirror": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-6.0.2.tgz", + "integrity": "sha512-VhydHotNW5w1UGK0Qj96BwSk/Zqbp9WbnyK2W/eVMv4QyF41INRGpjUhFJY7/uDNuudSc33a/PKr4iDqRduvHw==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/commands": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/search": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.0.0" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -3269,6 +3475,12 @@ "dev": true, "license": "MIT" }, + "node_modules/crelt": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", + "integrity": "sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g==", + "license": "MIT" + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -6953,6 +7165,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/style-mod": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.3.tgz", + "integrity": "sha512-i/n8VsZydrugj3Iuzll8+x/00GH2vnYsk1eomD8QiRrSAeW6ItbCQDtfXCeJHd0iwiNagqjQkvpvREEPtW3IoQ==", + "license": "MIT" + }, "node_modules/styled-jsx": { "version": "5.1.6", "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", @@ -7447,6 +7665,12 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/w3c-keyname": { + "version": "2.2.8", + "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz", + "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==", + "license": "MIT" + }, "node_modules/web-streams-polyfill": { "version": "3.3.3", "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", diff --git a/package.json b/package.json index a9c9481..2770f59 100644 --- a/package.json +++ b/package.json @@ -13,13 +13,19 @@ }, "dependencies": { "@base-ui/react": "^1.4.1", + "@codemirror/language": "^6.12.3", + "@codemirror/legacy-modes": "^6.5.2", + "@codemirror/state": "^6.6.0", + "@codemirror/view": "^6.42.1", "@prisma/adapter-pg": "^7.8.0", "@prisma/client": "^7.8.0", "@types/bcryptjs": "^2.4.6", "@types/pg": "^8.20.0", + "@uiw/react-codemirror": "^4.25.9", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "codemirror": "^6.0.2", "lucide-react": "^1.14.0", "next": "16.2.6", "pg": "^8.20.0", From 97420b93cf35adeab349bdde41f864e37f33d814 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 23:37:30 +0200 Subject: [PATCH 14/18] feat(codemirror): add Caddyfile StreamLanguage mode Defines a minimal StreamLanguage tokenizer for CodeMirror 6 that recognises Caddy directives, named-matchers (@-prefix), comments, strings and braces via cm6 highlight-tags. Co-Authored-By: Claude Sonnet 4.6 --- lib/codemirror/caddyfile-mode.ts | 31 +++++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 lib/codemirror/caddyfile-mode.ts diff --git a/lib/codemirror/caddyfile-mode.ts b/lib/codemirror/caddyfile-mode.ts new file mode 100644 index 0000000..87de6b7 --- /dev/null +++ b/lib/codemirror/caddyfile-mode.ts @@ -0,0 +1,31 @@ +import { StreamLanguage, type StreamParser } from '@codemirror/language' + +const CADDY_DIRECTIVES = new Set([ + 'reverse_proxy', 'encode', 'file_server', 'handle', 'handle_errors', + 'root', 'header', 'redir', 'rewrite', 'respond', 'route', 'tls', + 'log', 'basicauth', 'request_body', 'try_files', 'php_fastcgi', + 'templates', 'import', 'bind', 'metrics', 'admin', 'auto_https', +]) +const CADDY_GLOBAL = new Set(['email', 'storage', 'order', 'servers', 'log']) + +const parser: StreamParser = { + token(stream) { + if (stream.eatSpace()) return null + if (stream.match(/^#.*/)) return 'comment' + if (stream.match(/^"(?:[^"\\]|\\.)*"/)) return 'string' + if (stream.match(/^@[A-Za-z_][\w-]*/)) return 'variableName' + if (stream.match(/^[{}]/)) return 'brace' + const word = stream.match(/^[A-Za-z_][\w.-]*/) as RegExpMatchArray | null + if (word) { + const w = word[0] + if (CADDY_DIRECTIVES.has(w)) return 'keyword' + if (CADDY_GLOBAL.has(w)) return 'typeName' + return 'variableName' + } + stream.next() + return null + }, + languageData: { commentTokens: { line: '#' } }, +} + +export const caddyfileLanguage = StreamLanguage.define(parser) From 8b72a00127fc2770ac17a32420be9a49996a5317 Mon Sep 17 00:00:00 2001 From: Scrum4Me Agent <30029041+madhura68@users.noreply.github.com> Date: Wed, 13 May 2026 23:39:29 +0200 Subject: [PATCH 15/18] feat(caddy): replace textarea with CodeMirror 6 editor in caddy-editor Replaces the plain textarea on /caddy/edit with a CodeMirror 6 component that provides live Caddyfile syntax highlighting (keywords, named matchers, comments). The editor is dynamically imported (ssr: false) to prevent hydration errors. The write/validate/save/reload state machine and content flow remain unchanged. Bundle impact: ~300 kB additional for the /caddy/edit route (CodeMirror 6 core + @uiw/react-codemirror). Co-Authored-By: Claude Sonnet 4.6 --- app/caddy/_components/caddy-codemirror.tsx | 25 ++++++++++++++++++++++ app/caddy/_components/caddy-editor.tsx | 20 +++++++++++------ 2 files changed, 38 insertions(+), 7 deletions(-) create mode 100644 app/caddy/_components/caddy-codemirror.tsx diff --git a/app/caddy/_components/caddy-codemirror.tsx b/app/caddy/_components/caddy-codemirror.tsx new file mode 100644 index 0000000..15b010c --- /dev/null +++ b/app/caddy/_components/caddy-codemirror.tsx @@ -0,0 +1,25 @@ +'use client' +import CodeMirror from '@uiw/react-codemirror' +import { caddyfileLanguage } from '@/lib/codemirror/caddyfile-mode' +import { EditorView } from '@codemirror/view' + +type Props = { + value: string + onChange: (next: string) => void + readOnly?: boolean +} + +export default function CaddyCodeMirror({ value, onChange, readOnly }: Props) { + return ( + + ) +} diff --git a/app/caddy/_components/caddy-editor.tsx b/app/caddy/_components/caddy-editor.tsx index 14a4427..6d5f0d1 100644 --- a/app/caddy/_components/caddy-editor.tsx +++ b/app/caddy/_components/caddy-editor.tsx @@ -1,11 +1,21 @@ 'use client' import { useCallback, useEffect, useState } from 'react' +import dynamic from 'next/dynamic' import Link from 'next/link' import { useFlowRun } from '@/hooks/useFlowRun' import ConfirmDialog from '@/components/ConfirmDialog' import StreamingTerminal from '@/components/StreamingTerminal' +const CaddyCodeMirror = dynamic(() => import('./caddy-codemirror'), { + ssr: false, + loading: () => ( +
+ Loading editor… +
+ ), +}) + type Phase = 'edit' | 'writing' | 'validating' | 'validated' | 'saving' | 'saved' type DialogPending = 'validate' | 'save' | null @@ -106,17 +116,13 @@ export default function CaddyEditor({ initialContent, initialError }: Props) { )}
-