diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..fd1f5b0 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,8 @@ +.git +.gitignore +node_modules +.next +.env +.env.* +!.env.example +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..07a27ea --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +DATABASE_URL="postgresql://USER:PASSWORD@HOST:5432/ops_dashboard" +SEED_USER_EMAIL="admin@example.com" +SEED_USER_PASSWORD="changeme" +OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret" +OPS_AGENT_URL="http://127.0.0.1:3099" +# Comma-separated list of absolute repo paths to show on the /git page +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" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..77715bc --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/versions + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* +.pnpm-debug.log* + +# env files (can opt-in for committing if needed) +.env +.env.local +.env.*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..8bd0e39 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,5 @@ + +# This is NOT the Next.js you know + +This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` before writing any code. Heed deprecation notices. + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..43c994c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1 @@ +@AGENTS.md diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..383599b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,29 @@ +FROM node:22-alpine AS deps +WORKDIR /app +COPY package.json package-lock.json ./ +RUN npm ci + +FROM node:22-alpine AS builder +WORKDIR /app +COPY --from=deps /app/node_modules ./node_modules +COPY . . +RUN npx prisma generate +RUN npm run build + +FROM node:22-alpine AS runner +WORKDIR /app +ENV NODE_ENV=production + +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/README.md b/README.md index 3c8e2d0..62e9649 100644 --- a/README.md +++ b/README.md @@ -2,4 +2,106 @@ Single-user ops dashboard voor jp-visser.nl. -Managed by Scrum4Me. +See `docs/runbooks/` for setup, deployment, and operational procedures. + +## Installation + +### Prerequisites + +- Docker + Docker Compose (plugin) installed on the host +- A PostgreSQL service named `postgres` already running in the same Compose stack +- The repository cloned to `/srv/ops/repos/ops-dashboard` +- `/srv/scrum4me/compose/docker-compose.yml` as the shared Compose file + +### 1. Configure environment + +``` +cp deploy/ops-dashboard.env.example /srv/ops/ops-dashboard.env +# Edit /srv/ops/ops-dashboard.env — set DATABASE_URL, AUTH_SECRET, etc. +``` + +### 2. Install ops-agent + +``` +sudo deploy/ops-agent/setup.sh +``` + +This creates the `ops-agent` system user, installs `/opt/ops-agent`, generates +`/etc/ops-agent/secret`, and enables the systemd unit. + +Copy the generated secret into the web-app env file: + +``` +sudo cat /etc/ops-agent/secret +# Paste the value as OPS_AGENT_SECRET= in /srv/ops/ops-dashboard.env +``` + +### 3. Build and start the dashboard + +``` +sudo docker compose -f /srv/scrum4me/compose/docker-compose.yml build ops-dashboard +sudo docker compose -f /srv/scrum4me/compose/docker-compose.yml up -d ops-dashboard +``` + +The dashboard is now reachable on `127.0.0.1:3001` (proxied by Caddy). + +### 4. Install the self-update script + +``` +sudo deploy/ops-dashboard-updater/install.sh +``` + +To enable scheduled updates (daily at 03:00): + +``` +sudo systemctl enable --now ops-dashboard-updater.timer +``` + +To trigger a manual update via SSH: + +``` +sudo systemctl start ops-dashboard-updater.service +# or: +sudo /opt/ops-dashboard-updater/update.sh +``` + +> **Never** trigger updates through the dashboard UI — the script restarts the +> container that serves the UI. + +## Configuration + +| File | Purpose | +|---|---| +| `/srv/ops/ops-dashboard.env` | Web-app environment (DATABASE_URL, AUTH_SECRET, OPS_AGENT_SECRET, …) | +| `/etc/ops-agent/secret` | Shared HMAC secret between web-app and ops-agent | +| `/etc/ops-agent/commands.yml` | Whitelist of commands the ops-agent may run | +| `/etc/ops-agent/flows/` | Flow YAML files (backup, caddy reload, etc.) | +| `/srv/scrum4me/compose/docker-compose.yml` | Main Compose file (add ops-dashboard fragment from `deploy/`) | + +## Ops-agent auth + +The web-app communicates with the ops-agent via a shared secret stored in +`/etc/ops-agent/secret` (mode 0640, owner `root:ops-agent`). + +- The ops-agent reads the secret at startup via `OPS_AGENT_SECRET_PATH`. +- Every request from the web-app carries `Authorization: Bearer `. +- The agent validates using a constant-time comparison to prevent timing attacks. +- The web-app reads the secret value from the `OPS_AGENT_SECRET` environment variable. + +### Secret rotation procedure + +1. Generate a new secret on the server: + ``` + openssl rand -hex 32 | sudo tee /etc/ops-agent/secret + sudo chown root:ops-agent /etc/ops-agent/secret + sudo chmod 0640 /etc/ops-agent/secret + ``` +2. Update `OPS_AGENT_SECRET` in the web-app's environment file + (`/srv/ops/ops-dashboard.env`) with the new value. +3. Restart both services: + ``` + sudo systemctl restart ops-agent + sudo docker compose -f /srv/ops/docker-compose.ops-dashboard.yml restart ops-dashboard + ``` +4. Verify the dashboard is operational and that `systemctl status ops-agent` shows + the service running without errors. diff --git a/app/api/agent/[...path]/route.ts b/app/api/agent/[...path]/route.ts new file mode 100644 index 0000000..cf300a1 --- /dev/null +++ b/app/api/agent/[...path]/route.ts @@ -0,0 +1,46 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' + +export const dynamic = 'force-dynamic' + +const AGENT_URL = process.env.OPS_AGENT_URL ?? 'http://127.0.0.1:3099' +const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? '' + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ path: string[] }> }, +) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + const { path } = await params + const subpath = path.join('/') + const body = await request.text() + + let agentResponse: Response + try { + agentResponse = await fetch(`${AGENT_URL}/agent/v1/${subpath}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AGENT_SECRET}`, + }, + body, + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'agent unreachable' + return Response.json({ error: message }, { status: 502 }) + } + + const contentType = agentResponse.headers.get('Content-Type') ?? 'application/json' + return new Response(agentResponse.body, { + status: agentResponse.status, + headers: { + 'Content-Type': contentType, + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/app/api/auth/login/route.ts b/app/api/auth/login/route.ts new file mode 100644 index 0000000..10d6802 --- /dev/null +++ b/app/api/auth/login/route.ts @@ -0,0 +1,62 @@ +import { NextRequest, NextResponse } from 'next/server' +import bcrypt from 'bcryptjs' +import { prisma } from '@/lib/prisma' +import { generateSessionToken, createSession } from '@/lib/session' + +const loginAttempts = new Map() +const MAX_ATTEMPTS = 5 +const WINDOW_MS = 60_000 + +function isRateLimited(ip: string): boolean { + const now = Date.now() + const attempts = (loginAttempts.get(ip) ?? []).filter((t) => now - t < WINDOW_MS) + attempts.push(now) + loginAttempts.set(ip, attempts) + return attempts.length > MAX_ATTEMPTS +} + +export async function POST(request: NextRequest) { + const ip = + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown' + + if (isRateLimited(ip)) { + return NextResponse.json({ error: 'Too many requests' }, { status: 429 }) + } + + let email: string, password: string + try { + const body = await request.json() + email = (body.email ?? '').trim() + password = body.password ?? '' + } catch { + return NextResponse.json({ error: 'Invalid request body' }, { status: 400 }) + } + + if (!email || !password) { + return NextResponse.json( + { error: 'Email and password are required' }, + { status: 400 } + ) + } + + const user = await prisma.user.findUnique({ where: { email } }) + const validPassword = + user != null && (await bcrypt.compare(password, user.pwd_hash)) + + if (!user || !validPassword) { + return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) + } + + const token = generateSessionToken() + await createSession(user.id, token) + + const response = NextResponse.json({ success: true }) + response.cookies.set('ops_session', token, { + httpOnly: true, + secure: process.env.NODE_ENV === 'production', + sameSite: 'strict', + maxAge: 60 * 60 * 24, + path: '/', + }) + return response +} diff --git a/app/api/auth/logout/route.ts b/app/api/auth/logout/route.ts new file mode 100644 index 0000000..2b7edfa --- /dev/null +++ b/app/api/auth/logout/route.ts @@ -0,0 +1,16 @@ +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' +import { invalidateSession } from '@/lib/session' + +export async function POST(_request: NextRequest) { + const cookieStore = await cookies() + const token = cookieStore.get('ops_session')?.value + + if (token) { + await invalidateSession(token) + } + + const response = NextResponse.json({ success: true }) + response.cookies.delete('ops_session') + return response +} diff --git a/app/api/flows/run/route.ts b/app/api/flows/run/route.ts new file mode 100644 index 0000000..1c6c2f4 --- /dev/null +++ b/app/api/flows/run/route.ts @@ -0,0 +1,197 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { FlowStatus } from '@prisma/client' + +export const dynamic = 'force-dynamic' + +const AGENT_URL = process.env.OPS_AGENT_URL ?? 'http://127.0.0.1:3099' +const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? '' + +const TRUNCATE_BYTES = 64 * 1024 +const truncate = (s: string) => (s.length > TRUNCATE_BYTES ? s.slice(-TRUNCATE_BYTES) : s) + +export async function POST(request: NextRequest) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + let body: { flow_key?: string; dry_run?: boolean } + try { + body = await request.json() + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }) + } + + const { flow_key, dry_run = false } = body + if (!flow_key) { + return Response.json({ error: 'flow_key required' }, { status: 400 }) + } + + const flowRun = await prisma.flowRun.create({ + data: { + user_id: user.id, + flow_key, + status: FlowStatus.running, + dry_run, + }, + }) + + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (event: string, data: unknown) => { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ) + } + + enqueue('flow_run_id', { flow_run_id: flowRun.id }) + + let agentResponse: Response + try { + agentResponse = await fetch(`${AGENT_URL}/agent/v1/flow`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AGENT_SECRET}`, + }, + body: JSON.stringify({ flow_key, dry_run }), + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'agent unreachable' + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { status: FlowStatus.failed, ended_at: new Date() }, + }) + enqueue('error', { message }) + controller.close() + return + } + + if (!agentResponse.ok) { + const text = await agentResponse.text() + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { status: FlowStatus.failed, ended_at: new Date() }, + }) + enqueue('error', { message: `agent ${agentResponse.status}: ${text}` }) + controller.close() + return + } + + const reader = agentResponse.body!.getReader() + const decoder = new TextDecoder() + let buffer = '' + let currentEvent = '' + + // Per-step accumulators for DB writes + const stepRecordIds = new Map() + const stepStdout = new Map() + const stepStderr = new Map() + let currentStepIndex = -1 + + try { + 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('event:')) { + currentEvent = line.slice(6).trim() + } else if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.slice(5).trim()) as Record + + if (currentEvent === 'step_start') { + const stepIndex = parsed.step_index as number + currentStepIndex = stepIndex + const command_key = String(parsed.command_key ?? '') + const args = Array.isArray(parsed.args) ? (parsed.args as string[]) : [] + const flowStep = await prisma.flowStep.create({ + data: { + flow_run_id: flowRun.id, + step_index: stepIndex, + command_key, + args_json: JSON.stringify(args), + }, + }) + stepRecordIds.set(stepIndex, flowStep.id) + stepStdout.set(stepIndex, '') + stepStderr.set(stepIndex, '') + enqueue('step_start', parsed) + } else if (currentEvent === 'stdout') { + const chunk = String(parsed.data ?? '') + if (currentStepIndex >= 0) { + stepStdout.set(currentStepIndex, (stepStdout.get(currentStepIndex) ?? '') + chunk) + } + enqueue('stdout', { data: chunk }) + } else if (currentEvent === 'stderr') { + const chunk = String(parsed.data ?? '') + if (currentStepIndex >= 0) { + stepStderr.set(currentStepIndex, (stepStderr.get(currentStepIndex) ?? '') + chunk) + } + enqueue('stderr', { data: chunk }) + } else if (currentEvent === 'step_done') { + const stepIndex = parsed.step_index as number + const exitCode = typeof parsed.exit_code === 'number' ? parsed.exit_code : null + const stepId = stepRecordIds.get(stepIndex) + if (stepId) { + await prisma.flowStep.update({ + where: { id: stepId }, + data: { + exit_code: exitCode, + ended_at: new Date(), + stdout: truncate(stepStdout.get(stepIndex) ?? ''), + stderr: truncate(stepStderr.get(stepIndex) ?? ''), + }, + }) + } + enqueue('step_done', parsed) + } else if (currentEvent === 'done') { + const exitCode = typeof parsed.exit_code === 'number' ? parsed.exit_code : null + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { + status: exitCode === 0 ? FlowStatus.success : FlowStatus.failed, + exit_code: exitCode, + ended_at: new Date(), + }, + }) + enqueue('done', { flow_run_id: flowRun.id, exit_code: exitCode }) + } else if (currentEvent === 'error') { + const message = String(parsed.message ?? 'unknown error') + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { status: FlowStatus.failed, ended_at: new Date() }, + }) + enqueue('error', { message }) + } + } catch { + // ignore malformed SSE data + } + } + } + } + } catch { + // stream ended unexpectedly + } + + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/app/api/flows/start/route.ts b/app/api/flows/start/route.ts new file mode 100644 index 0000000..f757484 --- /dev/null +++ b/app/api/flows/start/route.ts @@ -0,0 +1,185 @@ +import { NextRequest } from 'next/server' +import { getCurrentUser } from '@/lib/session' +import { prisma } from '@/lib/prisma' +import { FlowStatus } from '@prisma/client' + +export const dynamic = 'force-dynamic' + +const AGENT_URL = process.env.OPS_AGENT_URL ?? 'http://127.0.0.1:3099' +const AGENT_SECRET = process.env.OPS_AGENT_SECRET ?? '' + +const flowStartAttempts = new Map() +const MAX_FLOW_ATTEMPTS = 10 +const FLOW_WINDOW_MS = 60_000 + +function isFlowRateLimited(userId: string): boolean { + const now = Date.now() + const attempts = (flowStartAttempts.get(userId) ?? []).filter((t) => now - t < FLOW_WINDOW_MS) + attempts.push(now) + flowStartAttempts.set(userId, attempts) + return attempts.length > MAX_FLOW_ATTEMPTS +} + +export async function POST(request: NextRequest) { + const user = await getCurrentUser() + if (!user) { + return Response.json({ error: 'unauthorized' }, { status: 401 }) + } + + if (isFlowRateLimited(user.id)) { + return Response.json({ error: 'Too many requests' }, { status: 429 }) + } + + let body: { command_key?: string; args?: string[]; stdin?: string } + try { + body = await request.json() + } catch { + return Response.json({ error: 'invalid JSON body' }, { status: 400 }) + } + + const { command_key, args = [], stdin } = body + if (!command_key) { + return Response.json({ error: 'command_key required' }, { status: 400 }) + } + + const flowRun = await prisma.flowRun.create({ + data: { + user_id: user.id, + flow_key: command_key, + status: FlowStatus.running, + }, + }) + + const flowStep = await prisma.flowStep.create({ + data: { + flow_run_id: flowRun.id, + step_index: 0, + command_key, + args_json: JSON.stringify(args), + }, + }) + + const encoder = new TextEncoder() + + const stream = new ReadableStream({ + async start(controller) { + const enqueue = (event: string, data: unknown) => { + controller.enqueue( + encoder.encode(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`), + ) + } + + enqueue('flow_run_id', { flow_run_id: flowRun.id }) + + const TRUNCATE_BYTES = 64 * 1024 + const truncate = (s: string) => + s.length > TRUNCATE_BYTES ? s.slice(-TRUNCATE_BYTES) : s + + let agentResponse: Response + try { + agentResponse = await fetch(`${AGENT_URL}/agent/v1/exec`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${AGENT_SECRET}`, + }, + body: JSON.stringify({ command_key, args, ...(stdin != null ? { stdin } : {}) }), + }) + } catch (err) { + const message = err instanceof Error ? err.message : 'agent unreachable' + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { status: FlowStatus.failed, ended_at: new Date() }, + }) + enqueue('error', { message }) + controller.close() + return + } + + if (!agentResponse.ok) { + const text = await agentResponse.text() + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { status: FlowStatus.failed, ended_at: new Date() }, + }) + enqueue('error', { message: `agent ${agentResponse.status}: ${text}` }) + controller.close() + return + } + + const reader = agentResponse.body!.getReader() + const decoder = new TextDecoder() + let buffer = '' + let currentEvent = '' + let stdout = '' + let stderr = '' + + try { + 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('event:')) { + currentEvent = line.slice(6).trim() + } else if (line.startsWith('data:')) { + try { + const parsed = JSON.parse(line.slice(5).trim()) as Record + if (currentEvent === 'stdout') { + const chunk = String(parsed.data ?? '') + stdout += chunk + enqueue('stdout', { data: chunk }) + } else if (currentEvent === 'stderr') { + const chunk = String(parsed.data ?? '') + stderr += chunk + enqueue('stderr', { data: chunk }) + } else if (currentEvent === 'exit') { + const exitCode = typeof parsed.code === 'number' ? parsed.code : null + const now = new Date() + await prisma.flowStep.update({ + where: { id: flowStep.id }, + data: { stdout: truncate(stdout), stderr: truncate(stderr), exit_code: exitCode, ended_at: now }, + }) + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { + status: exitCode === 0 ? FlowStatus.success : FlowStatus.failed, + exit_code: exitCode, + ended_at: now, + }, + }) + enqueue('done', { flow_run_id: flowRun.id, exit_code: exitCode }) + } else if (currentEvent === 'error') { + const message = String(parsed.message ?? 'unknown error') + await prisma.flowRun.update({ + where: { id: flowRun.id }, + data: { status: FlowStatus.failed, ended_at: new Date() }, + }) + enqueue('error', { message }) + } + } catch { + // ignore malformed SSE data + } + } + } + } + } catch { + // stream ended unexpectedly + } + + controller.close() + }, + }) + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }, + }) +} diff --git a/app/audit/[flow_run_id]/page.tsx b/app/audit/[flow_run_id]/page.tsx new file mode 100644 index 0000000..093493d --- /dev/null +++ b/app/audit/[flow_run_id]/page.tsx @@ -0,0 +1,125 @@ +import Link from 'next/link' +import { notFound, redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import { prisma } from '@/lib/prisma' + +export const dynamic = 'force-dynamic' + +type Props = { + params: Promise<{ flow_run_id: 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', +} + +export default async function AuditDetailPage({ params }: Props) { + const user = await getCurrentUser() + if (!user) redirect('/login') + + const { flow_run_id } = await params + + const run = await prisma.flowRun.findUnique({ + where: { id: flow_run_id }, + include: { steps: { orderBy: { step_index: 'asc' } } }, + }) + + if (!run || run.user_id !== user.id) notFound() + + const durationMs = + run.ended_at && run.started_at + ? run.ended_at.getTime() - run.started_at.getTime() + : null + + return ( +
+
+
+ + ← Audit Log + + / +

{run.flow_key}

+
+ +
+
+
+
Status
+ + {run.status} + +
+
+
Exit code
+ {run.exit_code != null ? run.exit_code : '—'} +
+
+
Started
+ {run.started_at.toLocaleString()} +
+
+
Duration
+ {durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '—'} +
+
+
+ + {run.steps.map((step) => { + const args = step.args_json ? (JSON.parse(step.args_json) as string[]) : [] + return ( +
+
+ Step {step.step_index + 1} + {step.command_key} + {args.length > 0 && ( + {args.join(' ')} + )} +
+ + {(step.stdout || step.stderr) && ( +
+
+ {step.stdout && ( +
+                        {step.stdout}
+                      
+ )} + {step.stderr && ( +
+                        {step.stderr}
+                      
+ )} +
+
+ exit {step.exit_code != null ? step.exit_code : '—'} + {step.ended_at && step.started_at && ( + + {((step.ended_at.getTime() - step.started_at.getTime()) / 1000).toFixed(1)}s + + )} +
+
+ )} + + {!step.stdout && !step.stderr && ( +
+ No output recorded. +
+ )} +
+ ) + })} +
+
+ ) +} diff --git a/app/audit/page.tsx b/app/audit/page.tsx new file mode 100644 index 0000000..3d565f2 --- /dev/null +++ b/app/audit/page.tsx @@ -0,0 +1,100 @@ +import Link from 'next/link' +import { redirect } from 'next/navigation' +import { getCurrentUser } from '@/lib/session' +import { prisma } from '@/lib/prisma' + +export const dynamic = 'force-dynamic' + +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', +} + +export default async function AuditPage() { + const user = await getCurrentUser() + if (!user) redirect('/login') + + const runs = await prisma.flowRun.findMany({ + where: { user_id: user.id }, + orderBy: { started_at: 'desc' }, + take: 100, + }) + + return ( +
+
+
+
+

Audit Log

+

Recent write actions executed on this server

+
+
+ + {runs.length === 0 ? ( +
+ No actions have been run yet. +
+ ) : ( +
+ + + + + + + + + + + + {runs.map((run) => { + const durationMs = + run.ended_at && run.started_at + ? run.ended_at.getTime() - run.started_at.getTime() + : null + return ( + + + + + + + + ) + })} + +
CommandStatusExitStartedDuration
+ + {run.flow_key} + + + + {run.status} + + + {run.exit_code != null ? run.exit_code : '—'} + + {run.started_at.toLocaleString()} + + {durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '—'} +
+
+ )} +
+
+ ) +} diff --git a/app/caddy/_components/caddy-editor.tsx b/app/caddy/_components/caddy-editor.tsx new file mode 100644 index 0000000..14a4427 --- /dev/null +++ b/app/caddy/_components/caddy-editor.tsx @@ -0,0 +1,214 @@ +'use client' + +import { useCallback, useEffect, useState } from 'react' +import Link from 'next/link' +import { useFlowRun } from '@/hooks/useFlowRun' +import ConfirmDialog from '@/components/ConfirmDialog' +import StreamingTerminal from '@/components/StreamingTerminal' + +type Phase = 'edit' | 'writing' | 'validating' | 'validated' | 'saving' | 'saved' + +type DialogPending = 'validate' | 'save' | null + +type Props = { + initialContent: string + initialError: string | null +} + +const VALIDATE_PREVIEW = + 'cat > /srv/scrum4me/caddy/Caddyfile.new \\\n && mv /srv/scrum4me/caddy/Caddyfile.new /srv/scrum4me/caddy/Caddyfile\ncaddy validate --config /srv/scrum4me/caddy/Caddyfile' + +const SAVE_PREVIEW = 'caddy reload --config /srv/scrum4me/caddy/Caddyfile' + +export default function CaddyEditor({ initialContent, initialError }: Props) { + const [content, setContent] = useState(initialContent) + const [phase, setPhase] = useState('edit') + const [dialogPending, setDialogPending] = useState(null) + + const writeFlow = useFlowRun() + const validateFlow = useFlowRun() + const reloadFlow = useFlowRun() + + // Chain: write done → start validate + useEffect(() => { + if (phase === 'writing' && writeFlow.status === 'done') { + setPhase('validating') + validateFlow.start('caddy_validate') + } else if (phase === 'writing' && (writeFlow.status === 'failed' || writeFlow.status === 'error')) { + setPhase('edit') + } + }, [writeFlow.status, phase, validateFlow.start]) + + // Validate done → validated phase + useEffect(() => { + if (phase === 'validating' && validateFlow.status === 'done') { + setPhase('validated') + } else if (phase === 'validating' && (validateFlow.status === 'failed' || validateFlow.status === 'error')) { + setPhase('edit') + } + }, [validateFlow.status, phase]) + + // Reload done → saved + useEffect(() => { + if (phase === 'saving' && reloadFlow.status === 'done') { + setPhase('saved') + } else if (phase === 'saving' && (reloadFlow.status === 'failed' || reloadFlow.status === 'error')) { + setPhase('validated') + } + }, [reloadFlow.status, phase]) + + const handleValidateConfirm = useCallback(() => { + setDialogPending(null) + setPhase('writing') + writeFlow.reset() + validateFlow.reset() + writeFlow.start('caddy_write_config', [], content) + }, [content, writeFlow.reset, writeFlow.start, validateFlow.reset]) + + const handleSaveConfirm = useCallback(() => { + setDialogPending(null) + setPhase('saving') + reloadFlow.reset() + reloadFlow.start('caddy_reload') + }, [reloadFlow.reset, reloadFlow.start]) + + const handleEditAgain = () => { + writeFlow.reset() + validateFlow.reset() + reloadFlow.reset() + setPhase('edit') + } + + const isActive = phase === 'writing' || phase === 'validating' || phase === 'saving' + + return ( +
+ {initialError && ( +
+ Failed to load current config: {initialError} +
+ )} + + {/* Textarea */} +
+
+

Caddyfile

+ {phase === 'saved' && ( + + + Reloaded successfully + + )} + {phase === 'validated' && ( + + + Config is valid + + )} +
+