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 +}