diff --git a/.env.example b/.env.example index 07a27ea..542d6b8 100644 --- a/.env.example +++ b/.env.example @@ -7,3 +7,5 @@ OPS_AGENT_URL="http://127.0.0.1:3099" REPO_PATHS="/srv/scrum4me/repos/scrum4me,/srv/ops/repos/ops-dashboard" # Comma-separated list of systemd unit names to show on the /systemd page (must match commands.yml allowed list) SYSTEMD_UNITS="scrum4me-web,ops-agent" +# Worker run-logs directory inside the container (read-only bind mount; see docker-compose.yml) +WORKER_LOGS_DIR="/var/worker-logs/idea" 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/api/worker-logs/[name]/route.ts b/app/api/worker-logs/[name]/route.ts new file mode 100644 index 0000000..c0b8f28 --- /dev/null +++ b/app/api/worker-logs/[name]/route.ts @@ -0,0 +1,32 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' +import { readRunLog, WorkerLogError } from '@/lib/worker-logs' +import { parseRunLog } from '@/lib/parse-worker-log' + +export const dynamic = 'force-dynamic' + +// GET /api/worker-logs/.log — full parsed timeline for one run-log. +export async function GET( + _request: NextRequest, + { params }: { params: Promise<{ name: string }> }, +) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + const { name: rawName } = await params + const name = decodeURIComponent(rawName) + + try { + const raw = await readRunLog(name) + return Response.json(parseRunLog(raw, name)) + } catch (err) { + if (err instanceof WorkerLogError) { + const status = err.code === 'invalid' ? 400 : err.code === 'not-found' ? 404 : 500 + return Response.json({ error: err.message }, { status }) + } + const message = err instanceof Error ? err.message : 'failed to read worker log' + return Response.json({ error: message }, { status: 500 }) + } +} diff --git a/app/api/worker-logs/route.ts b/app/api/worker-logs/route.ts new file mode 100644 index 0000000..96c85d3 --- /dev/null +++ b/app/api/worker-logs/route.ts @@ -0,0 +1,25 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' +import { listRunLogs } from '@/lib/worker-logs' + +export const dynamic = 'force-dynamic' + +// GET /api/worker-logs?limit=10 — newest-first run-log summaries for the table. +export async function GET(request: NextRequest) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + const limitParam = request.nextUrl.searchParams.get('limit') + const limit = limitParam ? Number(limitParam) : 10 + + try { + const logs = await listRunLogs(limit) + return Response.json({ logs }) + } catch (err) { + // Surfaces a missing bind mount legibly (e.g. WORKER_LOGS_DIR not mounted). + const message = err instanceof Error ? err.message : 'failed to list worker logs' + return Response.json({ error: message }, { status: 500 }) + } +} 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) { )} -