diff --git a/app/_components/AuditWidget.tsx b/app/_components/AuditWidget.tsx new file mode 100644 index 0000000..0a9422e --- /dev/null +++ b/app/_components/AuditWidget.tsx @@ -0,0 +1,69 @@ +'use client' + +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 = + | { 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', +} + +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(new Date(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..e7d6dc6 --- /dev/null +++ b/app/_components/CaddyWidget.tsx @@ -0,0 +1,76 @@ +'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; expiringWarning: boolean } +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 + const expiringWarning = certs.some((c) => c.expiringWarning) + return { soonestExpiryMs, count: certs.length, expiringWarning } +} + +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)} + dagen tot expiry +

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

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..333b84b --- /dev/null +++ b/app/_components/GitWidget.tsx @@ -0,0 +1,77 @@ +'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}/{data.total} + + {' '}repos uncommitted + +

+ ) : ( +

+ )} + + ) +} diff --git a/app/_components/SystemdWidget.tsx b/app/_components/SystemdWidget.tsx new file mode 100644 index 0000000..147d141 --- /dev/null +++ b/app/_components/SystemdWidget.tsx @@ -0,0 +1,79 @@ +'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 ? ( +

0 && data.healthy === data.total + ? 'text-green-600' + : data.healthy > 0 + ? 'text-orange-500' + : 'text-destructive', + ].join(' ')} + > + {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/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}
+ ); } diff --git a/app/page.tsx b/app/page.tsx index 99118b6..562d833 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,23 +1,127 @@ -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, + expiringWarning: certs.some((c) => c.expiringWarning), + }, + 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 +129,12 @@ export default async function Home() {

Ops Dashboard

Welkom {user.email}

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

{s.title}

-

{s.desc}

- - ))} +
+ + + + +
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 ( + + ) +} 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 } 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" 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 +} 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` +}