Merge pull request #1 from madhura68/feat/sprint-sjg11oxq

Sprint: Ops dashboard
This commit is contained in:
Janpeter Visser 2026-05-13 18:23:55 +00:00 committed by GitHub
commit c147870456
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
102 changed files with 14758 additions and 1 deletions

8
.dockerignore Normal file
View file

@ -0,0 +1,8 @@
.git
.gitignore
node_modules
.next
.env
.env.*
!.env.example
README.md

9
.env.example Normal file
View file

@ -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"

43
.gitignore vendored Normal file
View file

@ -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

5
AGENTS.md Normal file
View file

@ -0,0 +1,5 @@
<!-- BEGIN:nextjs-agent-rules -->
# 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.
<!-- END:nextjs-agent-rules -->

1
CLAUDE.md Normal file
View file

@ -0,0 +1 @@
@AGENTS.md

29
Dockerfile Normal file
View file

@ -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"]

104
README.md
View file

@ -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 <secret>`.
- 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.

View file

@ -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',
},
})
}

View file

@ -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<string, number[]>()
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
}

View file

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

197
app/api/flows/run/route.ts Normal file
View file

@ -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<number, string>()
const stepStdout = new Map<number, string>()
const stepStderr = new Map<number, string>()
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<string, unknown>
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',
},
})
}

View file

@ -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<string, number[]>()
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<string, unknown>
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',
},
})
}

View file

@ -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<string, string> = {
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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/audit" className="text-sm text-muted-foreground hover:text-foreground">
Audit Log
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-xl font-semibold tracking-tight font-mono">{run.flow_key}</h1>
</div>
<div className="rounded-lg border border-border p-4 space-y-3 text-sm">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<div>
<div className="text-xs text-muted-foreground mb-1">Status</div>
<span
className={
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ' +
(STATUS_STYLES[run.status] ?? '')
}
>
{run.status}
</span>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Exit code</div>
<span className="font-mono text-xs">{run.exit_code != null ? run.exit_code : '—'}</span>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Started</div>
<span className="text-xs">{run.started_at.toLocaleString()}</span>
</div>
<div>
<div className="text-xs text-muted-foreground mb-1">Duration</div>
<span className="text-xs">{durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '—'}</span>
</div>
</div>
</div>
{run.steps.map((step) => {
const args = step.args_json ? (JSON.parse(step.args_json) as string[]) : []
return (
<div key={step.id} className="space-y-3">
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Step {step.step_index + 1}</span>
<span className="font-mono text-sm font-medium">{step.command_key}</span>
{args.length > 0 && (
<span className="font-mono text-xs text-muted-foreground">{args.join(' ')}</span>
)}
</div>
{(step.stdout || step.stderr) && (
<div className="rounded-lg border border-border bg-zinc-950 font-mono text-xs overflow-hidden">
<div className="max-h-96 overflow-y-auto p-3 space-y-0">
{step.stdout && (
<pre className="whitespace-pre-wrap break-all leading-5 text-zinc-100">
{step.stdout}
</pre>
)}
{step.stderr && (
<pre className="whitespace-pre-wrap break-all leading-5 text-red-400">
{step.stderr}
</pre>
)}
</div>
<div className="border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
exit {step.exit_code != null ? step.exit_code : '—'}
{step.ended_at && step.started_at && (
<span className="ml-3">
{((step.ended_at.getTime() - step.started_at.getTime()) / 1000).toFixed(1)}s
</span>
)}
</div>
</div>
)}
{!step.stdout && !step.stderr && (
<div className="rounded-lg border border-border p-4 text-xs text-muted-foreground">
No output recorded.
</div>
)}
</div>
)
})}
</div>
</div>
)
}

100
app/audit/page.tsx Normal file
View file

@ -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<string, string> = {
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 (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-2xl font-semibold tracking-tight">Audit Log</h1>
<p className="text-sm text-muted-foreground">Recent write actions executed on this server</p>
</div>
</div>
{runs.length === 0 ? (
<div className="rounded-lg border border-border p-8 text-center text-sm text-muted-foreground">
No actions have been run yet.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Command</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Exit</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Started</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Duration</th>
</tr>
</thead>
<tbody>
{runs.map((run) => {
const durationMs =
run.ended_at && run.started_at
? run.ended_at.getTime() - run.started_at.getTime()
: null
return (
<tr
key={run.id}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-mono text-xs">
<Link
href={`/audit/${run.id}`}
className="font-medium text-foreground hover:underline"
>
{run.flow_key}
</Link>
</td>
<td className="px-4 py-3">
<span
className={
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ' +
(STATUS_STYLES[run.status] ?? '')
}
>
{run.status}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{run.exit_code != null ? run.exit_code : '—'}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{run.started_at.toLocaleString()}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{durationMs != null ? `${(durationMs / 1000).toFixed(1)}s` : '—'}
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</div>
)
}

View file

@ -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<Phase>('edit')
const [dialogPending, setDialogPending] = useState<DialogPending>(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 (
<div className="space-y-6">
{initialError && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Failed to load current config: {initialError}
</div>
)}
{/* Textarea */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<h2 className="text-base font-medium tracking-tight">Caddyfile</h2>
{phase === 'saved' && (
<span className="inline-flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<span className="size-2 rounded-full bg-green-500" />
Reloaded successfully
</span>
)}
{phase === 'validated' && (
<span className="inline-flex items-center gap-1.5 text-sm text-green-600 dark:text-green-400">
<span className="size-2 rounded-full bg-green-500" />
Config is valid
</span>
)}
</div>
<textarea
value={content}
onChange={(e) => {
setContent(e.target.value)
// Reset validated state if user edits after validation
if (phase === 'validated' || phase === 'saved') setPhase('edit')
}}
readOnly={isActive}
rows={24}
spellCheck={false}
className="w-full rounded-lg border border-border bg-zinc-950 p-4 font-mono text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-ring resize-y disabled:opacity-50"
/>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
{(phase === 'edit' || phase === 'validated' || phase === 'saved') && (
<button
onClick={() => setDialogPending('validate')}
disabled={isActive || !content.trim()}
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Validate
</button>
)}
{(phase === 'validated') && (
<button
onClick={() => setDialogPending('save')}
disabled={isActive}
className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:opacity-50 transition-colors"
>
Save + Reload
</button>
)}
{(phase === 'validated' || phase === 'saved') && (
<button
onClick={handleEditAgain}
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 transition-colors"
>
Edit again
</button>
)}
{phase === 'saved' && (
<Link
href="/caddy"
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 transition-colors"
>
Back to Caddy
</Link>
)}
</div>
{/* Write flow terminal (step 1 of validate) */}
{(phase === 'writing' || phase === 'validating' || (writeFlow.status !== 'idle' && writeFlow.lines.length > 0)) && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Writing config</p>
<StreamingTerminal
lines={writeFlow.lines}
status={writeFlow.status === 'done' ? 'done' : writeFlow.status}
error={writeFlow.error}
/>
</div>
)}
{/* Validate flow terminal (step 2 of validate) */}
{(phase === 'validating' || phase === 'validated' || (validateFlow.status !== 'idle')) && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Validating config</p>
<StreamingTerminal
lines={validateFlow.lines}
status={validateFlow.status}
error={validateFlow.error}
/>
</div>
)}
{/* Reload flow terminal */}
{(phase === 'saving' || phase === 'saved' || (reloadFlow.status !== 'idle')) && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Reloading Caddy</p>
<StreamingTerminal
lines={reloadFlow.lines}
status={reloadFlow.status}
error={reloadFlow.error}
/>
</div>
)}
{/* Confirm dialogs */}
<ConfirmDialog
open={dialogPending === 'validate'}
title="Validate Caddyfile"
commandPreview={VALIDATE_PREVIEW}
onConfirm={handleValidateConfirm}
onCancel={() => setDialogPending(null)}
/>
<ConfirmDialog
open={dialogPending === 'save'}
title="Save + Reload Caddy"
commandPreview={SAVE_PREVIEW}
onConfirm={handleSaveConfirm}
onCancel={() => setDialogPending(null)}
/>
</div>
)
}

View file

@ -0,0 +1,166 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { parseCertList, type CertInfo } from '@/lib/parse-caddy'
import { apiFetch } from '@/lib/csrf'
async function fetchCerts(): Promise<CertInfo[]> {
const res = await apiFetch('/api/agent/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command_key: 'caddy_list_certs', 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
}
}
}
}
return parseCertList(output)
}
type Props = {
initialCerts: CertInfo[]
certsError: string | null
}
export default function CaddyView({ initialCerts, certsError }: Props) {
const [certs, setCerts] = useState<CertInfo[]>(initialCerts)
const [error, setError] = useState<string | null>(certsError)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const updated = await fetchCerts()
setCerts(updated)
setError(null)
setLastUpdated(new Date())
} catch (err) {
setError(err instanceof Error ? err.message : 'failed')
} finally {
setRefreshing(false)
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 60000)
return () => clearInterval(id)
}, [refresh])
return (
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium tracking-tight">TLS Certificates</h2>
<div className="flex items-center gap-3">
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()}
</span>
<button
onClick={refresh}
disabled={refreshing}
className="rounded-md border border-border px-3 py-1 text-xs hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Refresh
</button>
</div>
</div>
{error ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
{error}
</div>
) : certs.length === 0 ? (
<div className="rounded-lg border border-border p-6 text-sm text-muted-foreground">
No certificates found in <code className="font-mono">/data/caddy/certificates/</code>.
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Domain</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Issuer</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Valid from</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Expires</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
</tr>
</thead>
<tbody>
{certs.map((cert) => (
<tr
key={cert.domain}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-mono text-xs font-medium">{cert.domain}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{cert.issuerCN}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{cert.notBefore || '—'}</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{cert.notAfter || '—'}</td>
<td className="px-4 py-3">
<CertStatusBadge cert={cert} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)
}
function CertStatusBadge({ cert }: { cert: CertInfo }) {
if (cert.expired) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-red-100 px-2 py-0.5 text-xs font-medium text-red-800 dark:bg-red-900/30 dark:text-red-400">
<span className="size-1.5 rounded-full bg-red-500" />
Expired
</span>
)
}
if (cert.expiringWarning) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-800 dark:bg-amber-900/30 dark:text-amber-400">
<span className="size-1.5 rounded-full bg-amber-500" />
Expiring soon
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-800 dark:bg-green-900/30 dark:text-green-400">
<span className="size-1.5 rounded-full bg-green-500" />
Valid
</span>
)
}

43
app/caddy/edit/page.tsx Normal file
View file

@ -0,0 +1,43 @@
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import CaddyEditor from '../_components/caddy-editor'
export const dynamic = 'force-dynamic'
export default async function CaddyEditPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
let initialContent = ''
let initialError: string | null = null
try {
initialContent = await execAgent('caddy_show_config')
} catch (err) {
initialError = err instanceof Error ? err.message : 'failed to load config'
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-4">
<Link
href="/caddy"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Caddy
</Link>
<div>
<h1 className="text-2xl font-semibold tracking-tight">Edit Caddyfile</h1>
<p className="text-sm text-muted-foreground">
Edit, validate, then save and reload Caddy
</p>
</div>
</div>
<CaddyEditor initialContent={initialContent} initialError={initialError} />
</div>
</div>
)
}

70
app/caddy/page.tsx Normal file
View file

@ -0,0 +1,70 @@
import { redirect } from 'next/navigation'
import Link from 'next/link'
import { codeToHtml } from 'shiki'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import { parseCertList, type CertInfo } from '@/lib/parse-caddy'
import CaddyView from './_components/caddy-view'
export const dynamic = 'force-dynamic'
export default async function CaddyPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
let configHtml = ''
let configError: string | null = null
try {
const raw = await execAgent('caddy_show_config')
configHtml = await codeToHtml(raw || '# (empty)', {
lang: 'caddyfile',
theme: 'github-dark',
})
} catch (err) {
configError = err instanceof Error ? err.message : 'failed'
}
let initialCerts: CertInfo[] = []
let certsError: string | null = null
try {
const raw = await execAgent('caddy_list_certs')
initialCerts = parseCertList(raw)
} catch (err) {
certsError = err instanceof Error ? err.message : 'failed'
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-8">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Caddy</h1>
<p className="text-sm text-muted-foreground">Config view and TLS certificate status</p>
</div>
<section className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-lg font-medium tracking-tight">Caddyfile</h2>
<Link
href="/caddy/edit"
className="rounded-md border border-border px-3 py-1 text-xs hover:bg-muted/50 transition-colors"
>
Edit
</Link>
</div>
{configError ? (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
{configError}
</div>
) : (
<div
className="overflow-x-auto rounded-lg border border-border text-sm [&>pre]:p-4"
dangerouslySetInnerHTML={{ __html: configHtml }}
/>
)}
</section>
<CaddyView initialCerts={initialCerts} certsError={certsError} />
</div>
</div>
)
}

View file

@ -0,0 +1,34 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
export const dynamic = 'force-dynamic'
type Props = {
params: Promise<{ name: string }>
}
export default async function DockerDetailPage({ params }: Props) {
const user = await getCurrentUser()
if (!user) redirect('/login')
const { name } = await params
const containerName = decodeURIComponent(name)
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/docker" className="text-sm text-muted-foreground hover:text-foreground">
Containers
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight font-mono">{containerName}</h1>
</div>
<div className="rounded-lg border border-border p-6 text-sm text-muted-foreground">
Log viewer coming in Story 3.
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,246 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { type Container, parseDockerPs } from '@/lib/parse-docker'
import { useFlowRun } from '@/hooks/useFlowRun'
import ConfirmDialog from '@/components/ConfirmDialog'
import StreamingTerminal from '@/components/StreamingTerminal'
import { apiFetch } from '@/lib/csrf'
async function fetchContainers(): Promise<Container[]> {
const res = await apiFetch('/api/agent/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command_key: 'docker_ps' }),
})
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
}
}
}
}
return parseDockerPs(output)
}
function statusBadge(status: string) {
const up = status.toLowerCase().startsWith('up')
return (
<span
className={
'inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ' +
(up
? 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400'
: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400')
}
>
<span
className={
'size-1.5 rounded-full ' +
(up ? 'bg-green-500 dark:bg-green-400' : 'bg-zinc-400 dark:bg-zinc-500')
}
/>
{status}
</span>
)
}
type ActionDef = {
commandKey: string
args: string[]
preview: string
title: string
}
type Props = {
initialContainers: Container[]
initialError: string | null
}
export default function DockerTable({ initialContainers, initialError }: Props) {
const [containers, setContainers] = useState<Container[]>(initialContainers)
const [error, setError] = useState<string | null>(initialError)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
const [pendingAction, setPendingAction] = useState<ActionDef | null>(null)
const flowRun = useFlowRun()
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const data = await fetchContainers()
setContainers(data)
setError(null)
setLastUpdated(new Date())
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
} finally {
setRefreshing(false)
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 5000)
return () => clearInterval(id)
}, [refresh])
const handleConfirm = useCallback(() => {
if (!pendingAction) return
flowRun.start(pendingAction.commandKey, pendingAction.args)
setPendingAction(null)
}, [pendingAction, flowRun.start])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{containers.length} container{containers.length !== 1 ? 's' : ''}
</span>
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
</div>
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()}
</span>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Name</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Image</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Ports</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Uptime</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{containers.length === 0 && !error ? (
<tr>
<td colSpan={6} className="px-4 py-8 text-center text-muted-foreground">
No containers running
</td>
</tr>
) : (
containers.map((c) => (
<tr
key={c.id}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-mono text-xs">
<Link
href={`/docker/${encodeURIComponent(c.name)}`}
className="font-medium text-foreground hover:underline"
>
{c.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{c.image}</td>
<td className="px-4 py-3">{statusBadge(c.status)}</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{c.ports || '—'}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">{c.created}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() =>
setPendingAction({
commandKey: 'docker_compose_restart',
args: [c.name],
preview: `docker compose restart ${c.name}`,
title: `Restart ${c.name}`,
})
}
disabled={flowRun.status === 'running'}
className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Restart
</button>
<button
onClick={() =>
setPendingAction({
commandKey: 'docker_compose_stop',
args: [c.name],
preview: `docker compose stop ${c.name}`,
title: `Stop ${c.name}`,
})
}
disabled={flowRun.status === 'running'}
className="rounded-md border border-destructive/30 px-2 py-1 text-xs text-destructive hover:bg-destructive/10 disabled:opacity-50 transition-colors"
>
Stop
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{flowRun.status !== 'idle' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Output</span>
{flowRun.status !== 'running' && (
<button
onClick={flowRun.reset}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Close
</button>
)}
</div>
<StreamingTerminal lines={flowRun.lines} status={flowRun.status} error={flowRun.error} />
</div>
)}
<ConfirmDialog
open={pendingAction !== null}
title={pendingAction?.title ?? ''}
commandPreview={pendingAction?.preview ?? ''}
onConfirm={handleConfirm}
onCancel={() => setPendingAction(null)}
/>
</div>
)
}

34
app/docker/page.tsx Normal file
View file

@ -0,0 +1,34 @@
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import { parseDockerPs, type Container } from '@/lib/parse-docker'
import DockerTable from './_components/docker-table'
export const dynamic = 'force-dynamic'
export default async function DockerPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
let initialContainers: Container[] = []
let initialError: string | null = null
try {
const output = await execAgent('docker_ps')
initialContainers = parseDockerPs(output)
} catch (err) {
initialError = err instanceof Error ? err.message : 'Failed to fetch containers'
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Docker Containers</h1>
<p className="text-sm text-muted-foreground">Auto-refreshes every 5 seconds</p>
</div>
<DockerTable initialContainers={initialContainers} initialError={initialError} />
</div>
</div>
)
}

BIN
app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View file

@ -0,0 +1,262 @@
'use client'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { useFlowRun } from '@/hooks/useFlowRun'
import StreamingTerminal from '@/components/StreamingTerminal'
import ConfirmDialog from '@/components/ConfirmDialog'
type RestartMode = 'reload' | 'force'
const FLOW_KEYS: Record<RestartMode, string> = {
reload: 'update_caddy_config',
force: 'update_caddy_config_force',
}
const STEP_LABELS: Record<RestartMode, string[]> = {
reload: [
'caddy validate (syntax check)',
'caddy reload (zero-downtime via admin API)',
'smoke test: curl -I each hostname (expect 200/301/308/401)',
],
force: [
'caddy validate (syntax check)',
'docker compose up --force-recreate caddy (hard restart)',
'smoke test: curl -I each hostname (expect 200/301/308/401)',
],
}
function parseCaddyfileHostnames(content: string): string[] {
const hostnames: string[] = []
// Match top-level server block labels: hostname { or hostname:port {
for (const match of content.matchAll(/^([a-zA-Z0-9][a-zA-Z0-9\-.:]+)\s*\{/gm)) {
const candidate = match[1].trim()
// Skip obvious non-hostnames like "tls", "log", "handle", etc.
if (!candidate.includes('.') && !candidate.includes(':')) continue
// Strip port suffix for display
const host = candidate.split(':')[0]
if (!hostnames.includes(host)) hostnames.push(host)
}
return hostnames
}
function CaddyfileDiff({ content, error }: { content: string; error: string | null }) {
if (error) {
return (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Could not load Caddyfile: {error}
</div>
)
}
if (!content.trim()) {
return (
<div className="rounded-lg border border-border px-4 py-6 text-sm text-muted-foreground text-center">
Caddyfile is empty or not found
</div>
)
}
return (
<pre className="overflow-x-auto rounded-lg border border-border bg-zinc-950 p-4 text-xs font-mono text-zinc-100 leading-relaxed max-h-80">
{content}
</pre>
)
}
type Props = {
caddyfile: string
caddyfileError: string | null
}
export default function FlowPanel({ caddyfile, caddyfileError }: Props) {
const [mode, setMode] = useState<RestartMode>('reload')
const [pendingDryRun, setPendingDryRun] = useState<boolean | null>(null)
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(null)
const [showConfig, setShowConfig] = useState(false)
const handleComplete = useCallback((flowRunId: string) => {
setCompletedFlowRunId(flowRunId)
}, [])
const flowRun = useFlowRun(handleComplete)
const handleConfirm = useCallback(() => {
if (pendingDryRun === null) return
const dryRun = pendingDryRun
setPendingDryRun(null)
setCompletedFlowRunId(null)
flowRun.startFlow(FLOW_KEYS[mode], dryRun)
}, [pendingDryRun, flowRun, mode])
const handleReset = useCallback(() => {
flowRun.reset()
setCompletedFlowRunId(null)
}, [flowRun])
const hostnames = parseCaddyfileHostnames(caddyfile)
const steps = STEP_LABELS[mode]
const flowKey = FLOW_KEYS[mode]
return (
<div className="space-y-6">
{/* Description */}
<div className="rounded-lg border border-border p-5 space-y-4">
<div>
<p className="text-sm text-muted-foreground">
Validates the Caddyfile on disk, then reloads or restarts Caddy, and
smoke-tests every public hostname. Edit the config first via the{' '}
<Link href="/caddy/edit" className="underline hover:text-foreground transition-colors">
Caddy editor
</Link>
.
</p>
<p className="mt-1 text-xs text-muted-foreground font-mono">
config: /srv/scrum4me/caddy/Caddyfile
</p>
</div>
{/* Restart mode toggle */}
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">Restart mode:</span>
<button
onClick={() => setMode('reload')}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === 'reload'
? 'bg-foreground text-background'
: 'border border-border hover:bg-muted/50'
}`}
>
Reload (zero-downtime)
</button>
<button
onClick={() => setMode('force')}
className={`rounded px-3 py-1 text-xs font-medium transition-colors ${
mode === 'force'
? 'bg-foreground text-background'
: 'border border-border hover:bg-muted/50'
}`}
>
Force Restart (docker compose)
</button>
</div>
{/* Step list */}
<ol className="space-y-1">
{steps.map((step, i) => (
<li key={i} className="flex gap-2 text-xs font-mono text-muted-foreground">
<span className="text-border min-w-[1.5rem]">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
{/* Detected hostnames */}
{hostnames.length > 0 && (
<div className="space-y-1">
<p className="text-xs text-muted-foreground">
Hostnames detected in Caddyfile (will be smoke-tested):
</p>
<ul className="space-y-0.5">
{hostnames.map((h) => (
<li key={h} className="text-xs font-mono text-muted-foreground pl-4">
{h}
</li>
))}
</ul>
</div>
)}
</div>
{/* Config preview (dry-run diff) */}
<div className="space-y-2">
<button
onClick={() => setShowConfig((v) => !v)}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
{showConfig ? '▾' : '▸'} {showConfig ? 'Hide' : 'Preview'} pending Caddyfile
</button>
{showConfig && (
<div className="space-y-2">
<p className="text-xs text-muted-foreground">
This is the config that will be validated and applied:
</p>
<CaddyfileDiff content={caddyfile} error={caddyfileError} />
{!caddyfileError && (
<p className="text-xs text-muted-foreground">
To change the config before applying,{' '}
<Link href="/caddy/edit" className="underline hover:text-foreground">
edit in the Caddy editor
</Link>{' '}
first.
</p>
)}
</div>
)}
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<button
onClick={() => setPendingDryRun(false)}
disabled={flowRun.status === 'running'}
className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Run
</button>
<button
onClick={() => setPendingDryRun(true)}
disabled={flowRun.status === 'running'}
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Dry Run
</button>
{flowRun.status !== 'idle' && flowRun.status !== 'running' && (
<button
onClick={handleReset}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{/* Terminal output */}
{flowRun.status !== 'idle' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Output</span>
{completedFlowRunId && (
<Link
href={`/audit/${completedFlowRunId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View in audit log
</Link>
)}
</div>
<StreamingTerminal
lines={flowRun.lines}
status={flowRun.status}
error={flowRun.error}
/>
</div>
)}
{/* Confirm dialog */}
<ConfirmDialog
open={pendingDryRun !== null}
title={
pendingDryRun
? `Dry Run: Update Caddy Config (${mode === 'reload' ? 'Reload' : 'Force Restart'})`
: `Run: Update Caddy Config (${mode === 'reload' ? 'Reload' : 'Force Restart'})`
}
commandPreview={
pendingDryRun
? `[DRY RUN] flow: ${flowKey}\n\nAll steps will be shown without executing.\n\nPending config: /srv/scrum4me/caddy/Caddyfile${hostnames.length > 0 ? `\nHostnames: ${hostnames.join(', ')}` : ''}`
: `flow: ${flowKey}\n\nSteps:\n${steps.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}${hostnames.length > 0 ? `\n\nHostnames to smoke-test: ${hostnames.join(', ')}` : ''}`
}
onConfirm={handleConfirm}
onCancel={() => setPendingDryRun(null)}
/>
</div>
)
}

View file

@ -0,0 +1,36 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import FlowPanel from './_components/flow-panel'
export const dynamic = 'force-dynamic'
export default async function UpdateCaddyConfigPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
let caddyfile = ''
let caddyfileError: string | null = null
try {
caddyfile = await execAgent('caddy_show_config')
} catch (err) {
caddyfileError = err instanceof Error ? err.message : 'failed to load Caddyfile'
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Home
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight">Update Caddy Config</h1>
</div>
<FlowPanel caddyfile={caddyfile} caddyfileError={caddyfileError} />
</div>
</div>
)
}

View file

@ -0,0 +1,128 @@
'use client'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { useFlowRun } from '@/hooks/useFlowRun'
import StreamingTerminal from '@/components/StreamingTerminal'
import ConfirmDialog from '@/components/ConfirmDialog'
const FLOW_KEY = 'update_scrum4me_web'
const STEPS = [
'git status (show current state)',
'git fetch (fetch remote refs)',
'git log (commits ahead of upstream)',
'git pull --ff-only (aborts if dirty)',
'npm ci (install dependencies)',
'prisma migrate deploy (apply migrations)',
'npm run build (build application)',
'systemctl restart scrum4me-web',
'smoke test: curl /api/products (expect 200 or 401)',
]
export default function FlowPanel() {
const [pendingDryRun, setPendingDryRun] = useState<boolean | null>(null)
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(null)
const handleComplete = useCallback((flowRunId: string) => {
setCompletedFlowRunId(flowRunId)
}, [])
const flowRun = useFlowRun(handleComplete)
const handleConfirm = useCallback(() => {
if (pendingDryRun === null) return
const dryRun = pendingDryRun
setPendingDryRun(null)
setCompletedFlowRunId(null)
flowRun.startFlow(FLOW_KEY, dryRun)
}, [pendingDryRun, flowRun])
const handleReset = useCallback(() => {
flowRun.reset()
setCompletedFlowRunId(null)
}, [flowRun])
return (
<div className="space-y-6">
<div className="rounded-lg border border-border p-5 space-y-4">
<div>
<p className="text-sm text-muted-foreground">
Reproduces the Scrum4Me website update: pulls latest code, installs
dependencies, applies migrations, builds, restarts the service, and
verifies the endpoint is responding.
</p>
<p className="mt-1 text-xs text-muted-foreground font-mono">
repo: /srv/scrum4me/repos/Scrum4Me
</p>
</div>
<ol className="space-y-1">
{STEPS.map((step, i) => (
<li key={i} className="flex gap-2 text-xs font-mono text-muted-foreground">
<span className="text-border min-w-[1.5rem]">{i + 1}.</span>
<span>{step}</span>
</li>
))}
</ol>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setPendingDryRun(false)}
disabled={flowRun.status === 'running'}
className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Run
</button>
<button
onClick={() => setPendingDryRun(true)}
disabled={flowRun.status === 'running'}
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Dry Run
</button>
{flowRun.status !== 'idle' && flowRun.status !== 'running' && (
<button
onClick={handleReset}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{flowRun.status !== 'idle' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Output</span>
{completedFlowRunId && (
<Link
href={`/audit/${completedFlowRunId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View in audit log
</Link>
)}
</div>
<StreamingTerminal
lines={flowRun.lines}
status={flowRun.status}
error={flowRun.error}
/>
</div>
)}
<ConfirmDialog
open={pendingDryRun !== null}
title={pendingDryRun ? 'Dry Run: Update Scrum4Me Web' : 'Run: Update Scrum4Me Web'}
commandPreview={
pendingDryRun
? `[DRY RUN] flow: ${FLOW_KEY}\n\nAll steps will be shown without executing.`
: `flow: ${FLOW_KEY}\n\nSteps:\n${STEPS.map((s, i) => ` ${i + 1}. ${s}`).join('\n')}`
}
onConfirm={handleConfirm}
onCancel={() => setPendingDryRun(null)}
/>
</div>
)
}

View file

@ -0,0 +1,27 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import FlowPanel from './_components/flow-panel'
export const dynamic = 'force-dynamic'
export default async function UpdateScrum4MeWebPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Home
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight">Update Scrum4Me Web</h1>
</div>
<FlowPanel />
</div>
</div>
)
}

View file

@ -0,0 +1,142 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { apiFetch } from '@/lib/csrf'
async function fetchDiff(repoPath: string): Promise<string> {
const res = await apiFetch('/api/agent/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command_key: 'git_diff', args: [repoPath] }),
})
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
}
}
}
}
return output
}
function DiffLine({ line }: { line: string }) {
if (line.startsWith('+++') || line.startsWith('---')) {
return <span className="text-muted-foreground">{line}{'\n'}</span>
}
if (line.startsWith('+')) {
return (
<span className="bg-green-500/10 text-green-700 dark:text-green-400 block">
{line}{'\n'}
</span>
)
}
if (line.startsWith('-')) {
return (
<span className="bg-red-500/10 text-red-700 dark:text-red-400 block">
{line}{'\n'}
</span>
)
}
if (line.startsWith('@@')) {
return <span className="text-blue-600 dark:text-blue-400">{line}{'\n'}</span>
}
if (line.startsWith('diff ') || line.startsWith('index ')) {
return <span className="text-muted-foreground font-semibold">{line}{'\n'}</span>
}
return <span>{line}{'\n'}</span>
}
type Props = {
repoPath: string
initialDiff: string
initialError: string | null
}
export default function DiffViewer({ repoPath, initialDiff, initialError }: Props) {
const [diff, setDiff] = useState(initialDiff)
const [error, setError] = useState(initialError)
const [refreshing, setRefreshing] = useState(false)
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const data = await fetchDiff(repoPath)
setDiff(data)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'failed')
} finally {
setRefreshing(false)
}
}, [repoPath])
useEffect(() => {
const id = setInterval(refresh, 30000)
return () => clearInterval(id)
}, [refresh])
if (error) {
return (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">git diff HEAD</span>
<div className="flex items-center gap-2">
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
<button
onClick={refresh}
disabled={refreshing}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Refresh
</button>
</div>
</div>
{diff.trim() === '' ? (
<div className="rounded-lg border border-border px-4 py-8 text-center text-sm text-muted-foreground">
No uncommitted changes
</div>
) : (
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/30 p-4 text-xs font-mono leading-relaxed">
{diff.split('\n').map((line, i) => (
<DiffLine key={i} line={line} />
))}
</pre>
)}
</div>
)
}

89
app/git/[repo]/page.tsx Normal file
View file

@ -0,0 +1,89 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import { parseGitStatus } from '@/lib/parse-git'
import DiffViewer from './_components/diff-viewer'
export const dynamic = 'force-dynamic'
type Props = {
params: Promise<{ repo: string }>
}
function findRepoPath(repoName: string): string | null {
const paths = (process.env.REPO_PATHS ?? '')
.split(',')
.map((p) => p.trim())
.filter(Boolean)
return paths.find((p) => p.split('/').filter(Boolean).pop() === repoName) ?? null
}
export default async function GitRepoPage({ params }: Props) {
const user = await getCurrentUser()
if (!user) redirect('/login')
const { repo } = await params
const repoName = decodeURIComponent(repo)
const repoPath = findRepoPath(repoName)
if (!repoPath) {
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/git" className="text-sm text-muted-foreground hover:text-foreground">
Repositories
</Link>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Repo &quot;{repoName}&quot; not found in REPO_PATHS.
</div>
</div>
</div>
)
}
let statusSummary = ''
let initialDiff = ''
let initialError: string | null = null
try {
const [statusOut, diffOut] = await Promise.all([
execAgent('git_status', [repoPath]),
execAgent('git_diff', [repoPath]),
])
const status = parseGitStatus(statusOut)
statusSummary = `${status.branch}${status.dirty ? ' · dirty' : ' · clean'}${status.ahead ? ` · ↑${status.ahead} ahead` : ''}${status.behind ? ` · ↓${status.behind} behind` : ''}`
initialDiff = diffOut
} catch (err) {
initialError = err instanceof Error ? err.message : 'Failed to fetch repo data'
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/git" className="text-sm text-muted-foreground hover:text-foreground">
Repositories
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight font-mono">{repoName}</h1>
</div>
{statusSummary && (
<div className="flex items-center gap-2 text-sm text-muted-foreground font-mono">
<span>{repoPath}</span>
<span className="text-border">·</span>
<span>{statusSummary}</span>
</div>
)}
<div>
<h2 className="text-base font-semibold mb-3">Uncommitted changes</h2>
<DiffViewer repoPath={repoPath} initialDiff={initialDiff} initialError={initialError} />
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,261 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { type RepoStatus, parseGitStatus } from '@/lib/parse-git'
import { useFlowRun } from '@/hooks/useFlowRun'
import ConfirmDialog from '@/components/ConfirmDialog'
import StreamingTerminal from '@/components/StreamingTerminal'
import { apiFetch } from '@/lib/csrf'
interface RepoEntry {
path: string
name: string
status: RepoStatus | null
error: string | null
}
async function fetchRepoStatus(repoPath: string): Promise<RepoStatus> {
const res = await apiFetch('/api/agent/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command_key: 'git_status', args: [repoPath] }),
})
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
}
}
}
}
return parseGitStatus(output)
}
function statusBadge(status: RepoStatus) {
if (status.dirty) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">
<span className="size-1.5 rounded-full bg-orange-500 dark:bg-orange-400" />
dirty
</span>
)
}
if (status.behind && status.behind > 0) {
return (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
<span className="size-1.5 rounded-full bg-blue-500 dark:bg-blue-400" />
{status.behind} behind
</span>
)
}
return (
<span className="inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<span className="size-1.5 rounded-full bg-green-500 dark:bg-green-400" />
clean
</span>
)
}
type ActionDef = {
commandKey: string
args: string[]
preview: string
title: string
}
type Props = {
initialRepos: RepoEntry[]
}
export default function GitReposList({ initialRepos }: Props) {
const [repos, setRepos] = useState<RepoEntry[]>(initialRepos)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
const [pendingAction, setPendingAction] = useState<ActionDef | null>(null)
const flowRun = useFlowRun()
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const updated = await Promise.all(
initialRepos.map(async (r) => {
try {
const status = await fetchRepoStatus(r.path)
return { ...r, status, error: null }
} catch (err) {
return { ...r, status: null, error: err instanceof Error ? err.message : 'failed' }
}
}),
)
setRepos(updated)
setLastUpdated(new Date())
} finally {
setRefreshing(false)
}
}, [initialRepos])
useEffect(() => {
const id = setInterval(refresh, 30000)
return () => clearInterval(id)
}, [refresh])
const handleConfirm = useCallback(() => {
if (!pendingAction) return
flowRun.start(pendingAction.commandKey, pendingAction.args)
setPendingAction(null)
}, [pendingAction, flowRun.start])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{repos.length} repo{repos.length !== 1 ? 's' : ''}
</span>
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
</div>
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()}
</span>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Repo</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Branch</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Ahead</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Path</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{repos.map((r) => (
<tr
key={r.path}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-medium">
<Link
href={`/git/${encodeURIComponent(r.name)}`}
className="hover:underline text-foreground"
>
{r.name}
</Link>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">
{r.status?.branch ?? '—'}
</td>
<td className="px-4 py-3">
{r.error ? (
<span className="text-xs text-destructive">{r.error}</span>
) : r.status ? (
statusBadge(r.status)
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{r.status?.ahead !== undefined && r.status.ahead > 0 ? (
<span className="text-amber-600 dark:text-amber-400">{r.status.ahead}</span>
) : (
'—'
)}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground">{r.path}</td>
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<button
onClick={() =>
setPendingAction({
commandKey: 'git_fetch',
args: [r.path],
preview: `git fetch --quiet\n(in ${r.path})`,
title: `Fetch ${r.name}`,
})
}
disabled={flowRun.status === 'running'}
className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Fetch
</button>
<button
onClick={() =>
setPendingAction({
commandKey: 'git_pull',
args: [r.path],
preview: `git pull --ff-only\n(in ${r.path})`,
title: `Pull ${r.name}`,
})
}
disabled={flowRun.status === 'running' || r.status?.dirty === true}
title={r.status?.dirty ? 'Working tree is dirty — commit or stash changes first' : undefined}
className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted/50 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
Pull
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
{flowRun.status !== 'idle' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Output</span>
{flowRun.status !== 'running' && (
<button
onClick={flowRun.reset}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Close
</button>
)}
</div>
<StreamingTerminal lines={flowRun.lines} status={flowRun.status} error={flowRun.error} />
</div>
)}
<ConfirmDialog
open={pendingAction !== null}
title={pendingAction?.title ?? ''}
commandPreview={pendingAction?.preview ?? ''}
onConfirm={handleConfirm}
onCancel={() => setPendingAction(null)}
/>
</div>
)
}

54
app/git/page.tsx Normal file
View file

@ -0,0 +1,54 @@
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import { parseGitStatus, type RepoStatus } from '@/lib/parse-git'
import GitReposList from './_components/git-repos-list'
export const dynamic = 'force-dynamic'
function repoName(repoPath: string): string {
return repoPath.split('/').filter(Boolean).pop() ?? repoPath
}
export default async function GitPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
const repoPaths = (process.env.REPO_PATHS ?? '')
.split(',')
.map((p) => p.trim())
.filter(Boolean)
const initialRepos = await Promise.all(
repoPaths.map(async (path) => {
let status: RepoStatus | null = null
let error: string | null = null
try {
const output = await execAgent('git_status', [path])
status = parseGitStatus(output)
} catch (err) {
error = err instanceof Error ? err.message : 'failed'
}
return { path, name: repoName(path), status, error }
}),
)
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">Git Repositories</h1>
<p className="text-sm text-muted-foreground">Auto-refreshes every 30 seconds</p>
</div>
{repoPaths.length === 0 ? (
<div className="rounded-lg border border-border p-6 text-sm text-muted-foreground">
No repos configured. Set <code className="font-mono">REPO_PATHS</code> in your
environment (comma-separated absolute paths).
</div>
) : (
<GitReposList initialRepos={initialRepos} />
)}
</div>
</div>
)
}

130
app/globals.css Normal file
View file

@ -0,0 +1,130 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-sans);
--font-mono: var(--font-geist-mono);
--font-heading: var(--font-sans);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) * 0.6);
--radius-md: calc(var(--radius) * 0.8);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) * 1.4);
--radius-2xl: calc(var(--radius) * 1.8);
--radius-3xl: calc(var(--radius) * 2.2);
--radius-4xl: calc(var(--radius) * 2.6);
}
:root {
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--radius: 0.625rem;
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.87 0 0);
--chart-2: oklch(0.556 0 0);
--chart-3: oklch(0.439 0 0);
--chart-4: oklch(0.371 0 0);
--chart-5: oklch(0.269 0 0);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
html {
@apply font-sans;
}
}

33
app/layout.tsx Normal file
View file

@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
</html>
);
}

98
app/login/page.tsx Normal file
View file

@ -0,0 +1,98 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { apiFetch } from '@/lib/csrf'
export default function LoginPage() {
const router = useRouter()
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError('')
setLoading(true)
try {
const res = await apiFetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email, password }),
})
if (res.ok) {
router.push('/')
router.refresh()
} else {
const data = await res.json()
setError(data.error ?? 'Login failed')
}
} catch {
setError('Network error, please try again')
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-full items-center justify-center bg-zinc-50 dark:bg-zinc-950">
<div className="w-full max-w-sm rounded-xl border border-zinc-200 bg-white p-8 shadow-sm dark:border-zinc-800 dark:bg-zinc-900">
<h1 className="mb-6 text-xl font-semibold text-zinc-900 dark:text-zinc-50">
Ops Dashboard
</h1>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<label
htmlFor="email"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Email
</label>
<input
id="email"
type="email"
autoComplete="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500 focus:ring-2 focus:ring-zinc-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder:text-zinc-500 dark:focus:border-zinc-400"
placeholder="admin@example.com"
/>
</div>
<div className="flex flex-col gap-1.5">
<label
htmlFor="password"
className="text-sm font-medium text-zinc-700 dark:text-zinc-300"
>
Password
</label>
<input
id="password"
type="password"
autoComplete="current-password"
required
value={password}
onChange={(e) => setPassword(e.target.value)}
className="rounded-lg border border-zinc-300 bg-white px-3 py-2 text-sm text-zinc-900 outline-none placeholder:text-zinc-400 focus:border-zinc-500 focus:ring-2 focus:ring-zinc-500/20 dark:border-zinc-700 dark:bg-zinc-800 dark:text-zinc-50 dark:placeholder:text-zinc-500 dark:focus:border-zinc-400"
placeholder="••••••••"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<Button type="submit" disabled={loading} className="mt-2 w-full">
{loading ? 'Signing in…' : 'Sign in'}
</Button>
</form>
</div>
</div>
)
}

65
app/page.tsx Normal file
View file

@ -0,0 +1,65 @@
import Image from "next/image";
export default function Home() {
return (
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
<Image
className="dark:invert"
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
To get started, edit the page.tsx file.
</h1>
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
className="font-medium text-zinc-950 dark:text-zinc-50"
>
Learning
</a>{" "}
center.
</p>
</div>
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
<a
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className="dark:invert"
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
);
}

View file

@ -0,0 +1,172 @@
'use client'
import { useState, useCallback } from 'react'
import Link from 'next/link'
import { useFlowRun } from '@/hooks/useFlowRun'
import StreamingTerminal from '@/components/StreamingTerminal'
import ConfirmDialog from '@/components/ConfirmDialog'
import type { BackupFile } from '../page'
function formatSize(bytes: number): string {
if (bytes === 0) return '—'
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
}
type Props = {
backups: BackupFile[]
listError: string | null
}
export default function BackupsPanel({ backups, listError }: Props) {
const [pending, setPending] = useState(false)
const [completedFlowRunId, setCompletedFlowRunId] = useState<string | null>(null)
const handleComplete = useCallback((flowRunId: string) => {
setCompletedFlowRunId(flowRunId)
}, [])
const flowRun = useFlowRun(handleComplete)
const handleConfirm = useCallback(() => {
setPending(false)
setCompletedFlowRunId(null)
flowRun.startFlow('backup_ops_db', false)
}, [flowRun])
const handleReset = useCallback(() => {
flowRun.reset()
setCompletedFlowRunId(null)
}, [flowRun])
return (
<div className="space-y-6">
{/* Description */}
<div className="rounded-lg border border-border p-5 space-y-3">
<p className="text-sm text-muted-foreground">
Backs up the <code className="font-mono text-xs">ops_dashboard</code> database using{' '}
<code className="font-mono text-xs">pg_dump</code>. Dumps are stored in{' '}
<code className="font-mono text-xs">/srv/ops/backups/</code> and retained for 30 days.
For automated daily backups, enable the systemd timer:{' '}
<code className="font-mono text-xs">deploy/ops-agent/ops-db-backup.timer</code>.
</p>
<ol className="space-y-0.5">
<li className="flex gap-2 text-xs font-mono text-muted-foreground">
<span className="text-border min-w-[1.5rem]">1.</span>
<span>pg_dump ops_dashboard /srv/ops/backups/ops_db_YYYYMMDD_HHMM.dump</span>
</li>
<li className="flex gap-2 text-xs font-mono text-muted-foreground">
<span className="text-border min-w-[1.5rem]">2.</span>
<span>cleanup: delete backup files older than 30 days</span>
</li>
</ol>
</div>
{/* Action buttons */}
<div className="flex items-center gap-3">
<button
onClick={() => setPending(true)}
disabled={flowRun.status === 'running'}
className="rounded-lg bg-foreground text-background px-4 py-2 text-sm font-medium hover:opacity-90 disabled:opacity-50 transition-opacity"
>
Backup now
</button>
{flowRun.status !== 'idle' && flowRun.status !== 'running' && (
<button
onClick={handleReset}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset
</button>
)}
</div>
{/* Terminal output */}
{flowRun.status !== 'idle' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">Output</span>
{completedFlowRunId && (
<Link
href={`/audit/${completedFlowRunId}`}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
View in audit log
</Link>
)}
</div>
<StreamingTerminal
lines={flowRun.lines}
status={flowRun.status}
error={flowRun.error}
/>
{flowRun.status === 'done' && (
<p className="text-xs text-muted-foreground">
Reload this page to see the updated backup list.
</p>
)}
</div>
)}
{/* Backup list */}
<div className="space-y-3">
<h2 className="text-sm font-semibold">Existing backups</h2>
{listError && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Could not list backups: {listError}
</div>
)}
{!listError && backups.length === 0 && (
<div className="rounded-lg border border-border px-4 py-6 text-sm text-muted-foreground text-center">
No backups found in /srv/ops/backups/
</div>
)}
{!listError && backups.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs font-mono">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-2 font-medium text-muted-foreground">
Timestamp
</th>
<th className="text-left px-4 py-2 font-medium text-muted-foreground">File</th>
<th className="text-right px-4 py-2 font-medium text-muted-foreground">Size</th>
</tr>
</thead>
<tbody>
{backups.map((b, i) => (
<tr key={b.name} className={i % 2 === 0 ? '' : 'bg-muted/10'}>
<td className="px-4 py-2 text-muted-foreground">{b.label}</td>
<td className="px-4 py-2">{b.name}</td>
<td className="px-4 py-2 text-right text-muted-foreground">
{formatSize(b.sizeBytes)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="text-xs text-muted-foreground">
Backups older than 30 days are removed automatically by the cleanup step.
</p>
</div>
{/* Confirm dialog */}
<ConfirmDialog
open={pending}
title="Backup ops_dashboard database"
commandPreview={
'flow: backup_ops_db\n\nSteps:\n 1. pg_dump ops_dashboard → /srv/ops/backups/ops_db_YYYYMMDD_HHMM.dump\n 2. cleanup: delete backups older than 30 days'
}
onConfirm={handleConfirm}
onCancel={() => setPending(false)}
/>
</div>
)
}

View file

@ -0,0 +1,59 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import BackupsPanel from './_components/backups-panel'
export const dynamic = 'force-dynamic'
export interface BackupFile {
name: string
sizeBytes: number
label: string
}
function parseBackupList(output: string): BackupFile[] {
return output
.split('\n')
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
const [name, sizeStr] = line.split('\t')
const sizeBytes = parseInt(sizeStr ?? '0', 10) || 0
const m = name?.match(/ops_db_(\d{4})(\d{2})(\d{2})_(\d{2})(\d{2})\.dump/)
const label = m ? `${m[1]}-${m[2]}-${m[3]} ${m[4]}:${m[5]}` : (name ?? '')
return { name: name ?? '', sizeBytes, label }
})
.filter((b) => b.name)
}
export default async function BackupsPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
let backups: BackupFile[] = []
let listError: string | null = null
try {
const output = await execAgent('list_ops_backups')
backups = parseBackupList(output)
} catch (err) {
listError = err instanceof Error ? err.message : 'failed to list backups'
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-4xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/" className="text-sm text-muted-foreground hover:text-foreground">
Home
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight">Backups</h1>
</div>
<BackupsPanel backups={backups} listError={listError} />
</div>
</div>
)
}

View file

@ -0,0 +1,163 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/parse-systemd'
import { apiFetch } from '@/lib/csrf'
async function fetchOutput(commandKey: string, args: string[]): Promise<string> {
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
}
}
}
}
return output
}
const badgeClass: Record<ActiveState, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
inactive: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
activating: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
deactivating: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
unknown: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
}
const dotClass: Record<ActiveState, string> = {
active: 'bg-green-500 dark:bg-green-400',
inactive: 'bg-zinc-400 dark:bg-zinc-500',
failed: 'bg-red-500 dark:bg-red-400',
activating: 'bg-amber-500 dark:bg-amber-400',
deactivating: 'bg-amber-500 dark:bg-amber-400',
unknown: 'bg-zinc-400 dark:bg-zinc-500',
}
function StatusBadge({ status }: { status: UnitStatus }) {
const label = status.subState
? `${status.activeState} (${status.subState})`
: status.activeState
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass[status.activeState]}`}
>
<span className={`size-1.5 rounded-full ${dotClass[status.activeState]}`} />
{label}
</span>
)
}
type Props = {
unitName: string
initialStatusOutput: string
initialJournalOutput: string
initialError: string | null
}
export default function UnitDetail({
unitName,
initialStatusOutput,
initialJournalOutput,
initialError,
}: Props) {
const [statusOutput, setStatusOutput] = useState(initialStatusOutput)
const [journalOutput, setJournalOutput] = useState(initialJournalOutput)
const [error, setError] = useState<string | null>(initialError)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
const parsedStatus = statusOutput ? parseSystemctlStatus(statusOutput, unitName) : null
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const [statusOut, journalOut] = await Promise.all([
fetchOutput('systemctl_status', [unitName]),
fetchOutput('journalctl_recent', [unitName]),
])
setStatusOutput(statusOut)
setJournalOutput(journalOut)
setError(null)
setLastUpdated(new Date())
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
} finally {
setRefreshing(false)
}
}, [unitName])
useEffect(() => {
const id = setInterval(refresh, 10000)
return () => clearInterval(id)
}, [refresh])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{parsedStatus && <StatusBadge status={parsedStatus} />}
{parsedStatus?.uptime && (
<span className="text-sm text-muted-foreground">{parsedStatus.uptime}</span>
)}
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
</div>
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()} · auto-refreshes every 10s
</span>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div>
<h2 className="text-base font-semibold mb-3">Unit Status</h2>
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/30 p-4 text-xs font-mono leading-relaxed whitespace-pre">
{statusOutput || '—'}
</pre>
</div>
<div>
<h2 className="text-base font-semibold mb-3">Recent Journal (last hour)</h2>
<pre className="overflow-x-auto overflow-y-auto max-h-[600px] rounded-lg border border-border bg-muted/30 p-4 text-xs font-mono leading-relaxed whitespace-pre">
{journalOutput || 'No journal entries'}
</pre>
</div>
</div>
)
}

View file

@ -0,0 +1,78 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import UnitDetail from './_components/unit-detail'
export const dynamic = 'force-dynamic'
type Props = {
params: Promise<{ unit: string }>
}
function getAllowedUnits(): string[] {
return (process.env.SYSTEMD_UNITS ?? '')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
}
export default async function SystemdUnitPage({ params }: Props) {
const user = await getCurrentUser()
if (!user) redirect('/login')
const { unit } = await params
const unitName = decodeURIComponent(unit)
if (!getAllowedUnits().includes(unitName)) {
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/systemd" className="text-sm text-muted-foreground hover:text-foreground">
systemd Units
</Link>
</div>
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
Unit &quot;{unitName}&quot; not found in SYSTEMD_UNITS.
</div>
</div>
</div>
)
}
let initialStatusOutput = ''
let initialJournalOutput = ''
let initialError: string | null = null
try {
const [statusOut, journalOut] = await Promise.all([
execAgent('systemctl_status', [unitName]),
execAgent('journalctl_recent', [unitName]),
])
initialStatusOutput = statusOut
initialJournalOutput = journalOut
} catch (err) {
initialError = err instanceof Error ? err.message : 'Failed to fetch unit data'
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div className="flex items-center gap-3">
<Link href="/systemd" className="text-sm text-muted-foreground hover:text-foreground">
systemd Units
</Link>
<span className="text-muted-foreground">/</span>
<h1 className="text-2xl font-semibold tracking-tight font-mono">{unitName}</h1>
</div>
<UnitDetail
unitName={unitName}
initialStatusOutput={initialStatusOutput}
initialJournalOutput={initialJournalOutput}
initialError={initialError}
/>
</div>
</div>
)
}

View file

@ -0,0 +1,244 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/parse-systemd'
import { useFlowRun } from '@/hooks/useFlowRun'
import ConfirmDialog from '@/components/ConfirmDialog'
import StreamingTerminal from '@/components/StreamingTerminal'
import { apiFetch } from '@/lib/csrf'
interface UnitEntry {
unit: string
status: UnitStatus | null
error: string | null
}
async function fetchUnitStatus(unit: string): Promise<UnitStatus> {
const res = await apiFetch('/api/agent/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command_key: 'systemctl_status', args: [unit] }),
})
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
}
}
}
}
return parseSystemctlStatus(output, unit)
}
const badgeClass: Record<ActiveState, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
inactive: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
activating: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
deactivating: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
unknown: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
}
const dotClass: Record<ActiveState, string> = {
active: 'bg-green-500 dark:bg-green-400',
inactive: 'bg-zinc-400 dark:bg-zinc-500',
failed: 'bg-red-500 dark:bg-red-400',
activating: 'bg-amber-500 dark:bg-amber-400',
deactivating: 'bg-amber-500 dark:bg-amber-400',
unknown: 'bg-zinc-400 dark:bg-zinc-500',
}
function StatusBadge({ status }: { status: UnitStatus }) {
const label = status.subState
? `${status.activeState} (${status.subState})`
: status.activeState
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass[status.activeState]}`}
>
<span className={`size-1.5 rounded-full ${dotClass[status.activeState]}`} />
{label}
</span>
)
}
type ActionDef = {
commandKey: string
args: string[]
preview: string
title: string
}
type Props = {
initialUnits: UnitEntry[]
}
export default function SystemdUnitsList({ initialUnits }: Props) {
const [units, setUnits] = useState<UnitEntry[]>(initialUnits)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
const [pendingAction, setPendingAction] = useState<ActionDef | null>(null)
const flowRun = useFlowRun()
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const updated = await Promise.all(
initialUnits.map(async (entry) => {
try {
const status = await fetchUnitStatus(entry.unit)
return { ...entry, status, error: null }
} catch (err) {
return { ...entry, status: null, error: err instanceof Error ? err.message : 'failed' }
}
}),
)
setUnits(updated)
setLastUpdated(new Date())
} finally {
setRefreshing(false)
}
}, [initialUnits])
useEffect(() => {
const id = setInterval(refresh, 10000)
return () => clearInterval(id)
}, [refresh])
const handleConfirm = useCallback(() => {
if (!pendingAction) return
flowRun.start(pendingAction.commandKey, pendingAction.args)
setPendingAction(null)
}, [pendingAction, flowRun.start])
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<span className="text-sm text-muted-foreground">
{units.length} unit{units.length !== 1 ? 's' : ''}
</span>
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
</div>
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()}
</span>
</div>
<div className="overflow-x-auto rounded-lg border border-border">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border bg-muted/50">
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Unit</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Description</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Status</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Uptime</th>
<th className="px-4 py-3 text-left font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{units.map((entry) => (
<tr
key={entry.unit}
className="border-b border-border last:border-0 hover:bg-muted/30 transition-colors"
>
<td className="px-4 py-3 font-mono text-xs">
<Link
href={`/systemd/${encodeURIComponent(entry.unit)}`}
className="font-medium text-foreground hover:underline"
>
{entry.unit}
</Link>
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{entry.status?.description ?? '—'}
</td>
<td className="px-4 py-3">
{entry.error ? (
<span className="text-xs text-destructive">{entry.error}</span>
) : entry.status ? (
<StatusBadge status={entry.status} />
) : (
<span className="text-xs text-muted-foreground"></span>
)}
</td>
<td className="px-4 py-3 text-xs text-muted-foreground">
{entry.status?.uptime || '—'}
</td>
<td className="px-4 py-3">
<button
onClick={() =>
setPendingAction({
commandKey: 'systemctl_restart',
args: [entry.unit],
preview: `sudo /usr/bin/systemctl restart ${entry.unit}`,
title: `Restart ${entry.unit}`,
})
}
disabled={flowRun.status === 'running'}
className="rounded-md border border-border px-2 py-1 text-xs hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Restart
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{flowRun.status !== 'idle' && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-foreground">Output</span>
{flowRun.status !== 'running' && (
<button
onClick={flowRun.reset}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Close
</button>
)}
</div>
<StreamingTerminal lines={flowRun.lines} status={flowRun.status} error={flowRun.error} />
</div>
)}
<ConfirmDialog
open={pendingAction !== null}
title={pendingAction?.title ?? ''}
commandPreview={pendingAction?.preview ?? ''}
onConfirm={handleConfirm}
onCancel={() => setPendingAction(null)}
/>
</div>
)
}

50
app/systemd/page.tsx Normal file
View file

@ -0,0 +1,50 @@
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import { parseSystemctlStatus, type UnitStatus } from '@/lib/parse-systemd'
import SystemdUnitsList from './_components/systemd-units-list'
export const dynamic = 'force-dynamic'
export default async function SystemdPage() {
const user = await getCurrentUser()
if (!user) redirect('/login')
const units = (process.env.SYSTEMD_UNITS ?? '')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
const initialUnits = await Promise.all(
units.map(async (unit) => {
let status: UnitStatus | null = null
let error: string | null = null
try {
const output = await execAgent('systemctl_status', [unit])
status = parseSystemctlStatus(output, unit)
} catch (err) {
error = err instanceof Error ? err.message : 'failed'
}
return { unit, status, error }
}),
)
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
<div>
<h1 className="text-2xl font-semibold tracking-tight">systemd Units</h1>
<p className="text-sm text-muted-foreground">Auto-refreshes every 10 seconds</p>
</div>
{units.length === 0 ? (
<div className="rounded-lg border border-border p-6 text-sm text-muted-foreground">
No units configured. Set <code className="font-mono">SYSTEMD_UNITS</code> in your
environment (comma-separated unit names).
</div>
) : (
<SystemdUnitsList initialUnits={initialUnits} />
)}
</div>
</div>
)
}

25
components.json Normal file
View file

@ -0,0 +1,25 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "base-nova",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"menuColor": "default",
"menuAccent": "subtle",
"registries": {}
}

View file

@ -0,0 +1,62 @@
'use client'
type Props = {
open: boolean
title?: string
commandPreview: string
onConfirm: () => void
onCancel: () => void
loading?: boolean
}
export default function ConfirmDialog({
open,
title = 'Confirm action',
commandPreview,
onConfirm,
onCancel,
loading = false,
}: Props) {
if (!open) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
<div
className="absolute inset-0 bg-black/50"
onClick={onCancel}
aria-hidden="true"
/>
<div className="relative z-10 w-full max-w-lg rounded-xl border border-border bg-background p-6 shadow-lg">
<h2 className="text-base font-semibold tracking-tight">{title}</h2>
<p className="mt-1 text-sm text-muted-foreground">
The following command will be executed on the server:
</p>
<pre className="mt-3 overflow-x-auto rounded-lg border border-border bg-muted/50 px-4 py-3 font-mono text-xs text-foreground select-all">
{commandPreview}
</pre>
<p className="mt-3 text-xs text-muted-foreground">
This action cannot be undone. Review the command above before confirming.
</p>
<div className="mt-5 flex justify-end gap-3">
<button
onClick={onCancel}
disabled={loading}
className="rounded-lg border border-border px-4 py-2 text-sm hover:bg-muted/50 disabled:opacity-50 transition-colors"
>
Cancel
</button>
<button
onClick={onConfirm}
disabled={loading}
className="rounded-lg bg-destructive/10 border border-destructive/30 px-4 py-2 text-sm font-medium text-destructive hover:bg-destructive/20 disabled:opacity-50 transition-colors"
>
{loading ? 'Running…' : 'Confirm'}
</button>
</div>
</div>
</div>
)
}

View file

@ -0,0 +1,93 @@
'use client'
import { useEffect, useRef } from 'react'
export type TerminalLine = {
type: 'stdout' | 'stderr' | 'info'
text: string
}
export type TerminalStatus = 'idle' | 'running' | 'done' | 'failed' | 'error'
type Props = {
lines: TerminalLine[]
status: TerminalStatus
error?: string | null
className?: string
}
export default function StreamingTerminal({ lines, status, error, className = '' }: Props) {
const bottomRef = useRef<HTMLDivElement>(null)
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
}, [lines])
const statusBar = () => {
if (status === 'running') {
return (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-muted-foreground border-t border-border">
<span className="inline-block size-2 rounded-full bg-amber-400 animate-pulse" />
Running
</div>
)
}
if (status === 'done') {
return (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-green-400 border-t border-border">
<span className="inline-block size-2 rounded-full bg-green-400" />
Completed successfully
</div>
)
}
if (status === 'failed') {
return (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 border-t border-border">
<span className="inline-block size-2 rounded-full bg-red-500" />
Exited with error
</div>
)
}
if (status === 'error') {
return (
<div className="flex items-center gap-2 px-3 py-1.5 text-xs text-red-400 border-t border-border">
<span className="inline-block size-2 rounded-full bg-red-500" />
{error ?? 'Unknown error'}
</div>
)
}
return null
}
return (
<div
className={
'flex flex-col rounded-lg border border-border bg-zinc-950 font-mono text-xs overflow-hidden ' +
className
}
>
<div className="flex-1 overflow-y-auto max-h-96 p-3 space-y-0">
{lines.length === 0 && status === 'running' && (
<span className="text-zinc-500 animate-pulse">Waiting for output</span>
)}
{lines.map((line, i) => (
<pre
key={i}
className={
'whitespace-pre-wrap break-all leading-5 ' +
(line.type === 'stderr'
? 'text-red-400'
: line.type === 'info'
? 'text-sky-400'
: 'text-zinc-100')
}
>
{line.text}
</pre>
))}
<div ref={bottomRef} />
</div>
{statusBar()}
</div>
)
}

58
components/ui/button.tsx Normal file
View file

@ -0,0 +1,58 @@
import { Button as ButtonPrimitive } from "@base-ui/react/button"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"group/button inline-flex shrink-0 items-center justify-center rounded-lg border border-transparent bg-clip-padding text-sm font-medium whitespace-nowrap transition-all outline-none select-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 active:not-aria-[haspopup]:translate-y-px disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 dark:aria-invalid:border-destructive/50 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a]:hover:bg-primary/80",
outline:
"border-border bg-background hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80 aria-expanded:bg-secondary aria-expanded:text-secondary-foreground",
ghost:
"hover:bg-muted hover:text-foreground aria-expanded:bg-muted aria-expanded:text-foreground dark:hover:bg-muted/50",
destructive:
"bg-destructive/10 text-destructive hover:bg-destructive/20 focus-visible:border-destructive/40 focus-visible:ring-destructive/20 dark:bg-destructive/20 dark:hover:bg-destructive/30 dark:focus-visible:ring-destructive/40",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default:
"h-8 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
xs: "h-6 gap-1 rounded-[min(var(--radius-md),10px)] px-2 text-xs in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-7 gap-1 rounded-[min(var(--radius-md),12px)] px-2.5 text-[0.8rem] in-data-[slot=button-group]:rounded-lg has-data-[icon=inline-end]:pr-1.5 has-data-[icon=inline-start]:pl-1.5 [&_svg:not([class*='size-'])]:size-3.5",
lg: "h-9 gap-1.5 px-2.5 has-data-[icon=inline-end]:pr-2 has-data-[icon=inline-start]:pl-2",
icon: "size-8",
"icon-xs":
"size-6 rounded-[min(var(--radius-md),10px)] in-data-[slot=button-group]:rounded-lg [&_svg:not([class*='size-'])]:size-3",
"icon-sm":
"size-7 rounded-[min(var(--radius-md),12px)] in-data-[slot=button-group]:rounded-lg",
"icon-lg": "size-9",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
...props
}: ButtonPrimitive.Props & VariantProps<typeof buttonVariants>) {
return (
<ButtonPrimitive
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,7 @@
# Block to add to /srv/scrum4me/caddy/Caddyfile
# After adding, restart Caddy (not reload — see deploy notes):
# docker compose restart caddy
ops.jp-visser.nl {
reverse_proxy 172.18.0.1:3001
}

View file

@ -0,0 +1,19 @@
# Fragment to merge into /srv/scrum4me/compose/docker-compose.yml
# Add the ops-dashboard service under the `services:` key.
#
# Build the image first:
# docker build -t ops-dashboard /srv/ops/ops-dashboard
#
# Then bring the service up:
# docker compose -f /srv/scrum4me/compose/docker-compose.yml up -d ops-dashboard
services:
ops-dashboard:
build:
context: /srv/ops/ops-dashboard
env_file: /srv/ops/ops-dashboard.env
ports:
- "127.0.0.1:3001:3000"
restart: unless-stopped
depends_on:
- postgres

View file

@ -0,0 +1,23 @@
[Unit]
Description=Ops Agent privileged command bridge for ops-dashboard
After=network.target
[Service]
Type=simple
User=ops-agent
Group=ops-agent
WorkingDirectory=/opt/ops-agent
ExecStart=/usr/bin/node /opt/ops-agent/dist/index.js
Restart=on-failure
RestartSec=5s
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ops-agent
Environment=OPS_AGENT_PORT=3099
Environment=OPS_AGENT_HOST=127.0.0.1
Environment=OPS_AGENT_WHITELIST_PATH=/etc/ops-agent/commands.yml
Environment=OPS_AGENT_SECRET_PATH=/etc/ops-agent/secret
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,20 @@
[Unit]
Description=Daily backup of ops_dashboard database
After=network.target ops-agent.service
[Service]
Type=oneshot
User=ops-agent
Group=ops-agent
# Reads the shared secret and POSTs to ops-agent to trigger the backup flow.
# ops-agent must be running and backup_ops_db.yml must be installed in /etc/ops-agent/flows/.
ExecStart=/usr/bin/bash -c '\
SECRET=$(cat /etc/ops-agent/secret); \
curl -sf -X POST http://127.0.0.1:3099/agent/v1/flow \
-H "Authorization: Bearer $SECRET" \
-H "Content-Type: application/json" \
-d "{\"flow_key\":\"backup_ops_db\"}" \
'
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ops-db-backup

View file

@ -0,0 +1,10 @@
[Unit]
Description=Daily backup of ops_dashboard database (timer)
[Timer]
# Run every day at 02:00 local time.
OnCalendar=*-*-* 02:00:00
Persistent=true
[Install]
WantedBy=timers.target

58
deploy/ops-agent/setup.sh Normal file
View file

@ -0,0 +1,58 @@
#!/usr/bin/env bash
# Deploy ops-agent to the host.
# Run as root.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
INSTALL_DIR=/opt/ops-agent
CONFIG_DIR=/etc/ops-agent
SERVICE_FILE=/etc/systemd/system/ops-agent.service
echo "==> Creating ops-agent system user"
if ! id ops-agent &>/dev/null; then
useradd --system --no-create-home --shell /usr/sbin/nologin ops-agent
fi
echo "==> Installing service files to ${INSTALL_DIR}"
mkdir -p "${INSTALL_DIR}"
rsync -a --delete \
--exclude=node_modules \
--exclude=.git \
"${REPO_DIR}/ops-agent/" "${INSTALL_DIR}/"
echo "==> Installing Node dependencies"
cd "${INSTALL_DIR}"
npm ci --omit=dev 2>/dev/null || npm install --omit=dev
echo "==> Building TypeScript"
npx tsc
chown -R ops-agent:ops-agent "${INSTALL_DIR}"
echo "==> Installing config dir"
mkdir -p "${CONFIG_DIR}"
if [ ! -f "${CONFIG_DIR}/commands.yml" ]; then
cp "${REPO_DIR}/ops-agent/commands.yml.example" "${CONFIG_DIR}/commands.yml"
echo " Installed default commands.yml — review before use"
fi
echo "==> Generating shared secret (if not present)"
if [ ! -f "${CONFIG_DIR}/secret" ]; then
openssl rand -hex 32 > "${CONFIG_DIR}/secret"
fi
chown root:ops-agent "${CONFIG_DIR}/secret"
chmod 0640 "${CONFIG_DIR}/secret"
echo "==> Installing systemd unit"
cp "${REPO_DIR}/deploy/ops-agent/ops-agent.service" "${SERVICE_FILE}"
echo "==> Installing sudoers config"
install -m 0440 -o root -g root "${REPO_DIR}/deploy/ops-agent/sudoers" /etc/sudoers.d/ops-agent
visudo -c -f /etc/sudoers.d/ops-agent
echo "==> Enabling and starting ops-agent"
systemctl daemon-reload
systemctl enable --now ops-agent
echo "==> Done. Status:"
systemctl status ops-agent --no-pager

9
deploy/ops-agent/sudoers Normal file
View file

@ -0,0 +1,9 @@
# /etc/sudoers.d/ops-agent
# NOPASSWD for explicit systemctl restart invocations by the ops-agent service account.
# Only the service names whitelisted in commands.yml are listed here.
# Installed by deploy/ops-agent/setup.sh.
ops-agent ALL=(root) NOPASSWD: \
/usr/bin/systemctl restart scrum4me-web, \
/usr/bin/systemctl restart ops-agent, \
/usr/bin/systemctl restart caddy

View file

@ -0,0 +1,33 @@
#!/usr/bin/env bash
# Install the ops-dashboard self-update script and systemd units.
# Run as root from within the repo.
set -euo pipefail
REPO_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
INSTALL_DIR=/opt/ops-dashboard-updater
SERVICE_DIR=/etc/systemd/system
echo "==> Installing update script to ${INSTALL_DIR}"
mkdir -p "${INSTALL_DIR}"
install -m 0750 -o root -g root \
"${REPO_DIR}/deploy/ops-dashboard-updater/update.sh" \
"${INSTALL_DIR}/update.sh"
echo "==> Installing systemd units"
install -m 0644 -o root -g root \
"${REPO_DIR}/deploy/ops-dashboard-updater/ops-dashboard-updater.service" \
"${SERVICE_DIR}/ops-dashboard-updater.service"
install -m 0644 -o root -g root \
"${REPO_DIR}/deploy/ops-dashboard-updater/ops-dashboard-updater.timer" \
"${SERVICE_DIR}/ops-dashboard-updater.timer"
systemctl daemon-reload
echo ""
echo "==> Done. To enable automatic scheduled updates:"
echo " systemctl enable --now ops-dashboard-updater.timer"
echo ""
echo " To run a manual update now:"
echo " systemctl start ops-dashboard-updater.service"
echo " # or directly:"
echo " /opt/ops-dashboard-updater/update.sh"

View file

@ -0,0 +1,14 @@
[Unit]
Description=Self-update ops-dashboard (oneshot, triggered by timer or SSH)
After=network.target docker.service
[Service]
Type=oneshot
User=root
ExecStart=/opt/ops-dashboard-updater/update.sh
StandardOutput=journal
StandardError=journal
SyslogIdentifier=ops-dashboard-update
[Install]
WantedBy=multi-user.target

View file

@ -0,0 +1,11 @@
[Unit]
Description=Scheduled self-update for ops-dashboard (optional)
[Timer]
# Check for updates every day at 03:00 local time.
# Disable this timer if you prefer manual-only updates via SSH.
OnCalendar=*-*-* 03:00:00
Persistent=true
[Install]
WantedBy=timers.target

View file

@ -0,0 +1,56 @@
#!/usr/bin/env bash
# Self-update script for ops-dashboard.
# Run as root via SSH or the systemd oneshot service below.
# Do NOT invoke this through the UI — it restarts the container serving the UI.
set -euo pipefail
REPO_DIR=/srv/ops/repos/ops-dashboard
COMPOSE_FILE=/srv/scrum4me/compose/docker-compose.yml
SERVICE=ops-dashboard
LOG_TAG=ops-dashboard-update
log() { echo "[$(date -u +%FT%TZ)] $*" | tee /dev/fd/1 | systemd-cat -t "$LOG_TAG" -p info 2>/dev/null || true; }
die() { echo "[$(date -u +%FT%TZ)] ERROR: $*" >&2; exit 1; }
# ── 1. Pull latest code ────────────────────────────────────────────────────────
log "Pulling latest code from origin..."
git -C "$REPO_DIR" fetch --prune origin
CURRENT=$(git -C "$REPO_DIR" rev-parse HEAD)
git -C "$REPO_DIR" reset --hard origin/main
NEW=$(git -C "$REPO_DIR" rev-parse HEAD)
if [[ "$CURRENT" == "$NEW" ]]; then
log "Already up-to-date at $NEW — nothing to rebuild."
exit 0
fi
log "Updated $CURRENT$NEW"
# ── 2. Build new image ─────────────────────────────────────────────────────────
log "Building Docker image..."
docker compose -f "$COMPOSE_FILE" build "$SERVICE"
# ── 3. Restart container ───────────────────────────────────────────────────────
log "Restarting $SERVICE with new image..."
docker compose -f "$COMPOSE_FILE" up -d --force-recreate "$SERVICE"
# ── 4. Smoke test ──────────────────────────────────────────────────────────────
log "Waiting for container to become healthy..."
for i in $(seq 1 12); do
STATUS=$(docker inspect --format='{{.State.Health.Status}}' "$SERVICE" 2>/dev/null || true)
if [[ "$STATUS" == "healthy" ]]; then
log "Container is healthy after ${i}×5 s."
break
fi
# Fallback: accept running if no HEALTHCHECK is defined
RUNNING=$(docker inspect --format='{{.State.Running}}' "$SERVICE" 2>/dev/null || echo false)
if [[ "$RUNNING" == "true" && -z "$STATUS" ]]; then
log "Container running (no HEALTHCHECK defined)."
break
fi
if [[ $i -eq 12 ]]; then
die "Container did not become healthy within 60 s. Check: docker logs $SERVICE"
fi
sleep 5
done
log "Update complete — $SERVICE is running commit $NEW."

View file

@ -0,0 +1,8 @@
# Copy to /srv/ops/ops-dashboard.env on the server and fill in real values.
DATABASE_URL="postgresql://USER:PASSWORD@postgres:5432/ops_dashboard"
SEED_USER_EMAIL="admin@example.com"
SEED_USER_PASSWORD="changeme"
SESSION_SECRET="replace-with-a-long-random-string"
# Shared secret for ops-agent auth — must match /etc/ops-agent/secret on the host
OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret"
OPS_AGENT_URL="http://127.0.0.1:3099"

173
docs/runbooks/recovery.md Normal file
View file

@ -0,0 +1,173 @@
# Recovery Runbook — Ops Dashboard
This runbook covers the four most common failure scenarios. All commands must
be run as root (or with `sudo`) on the host over SSH.
---
## 1. Agent crashed / ops-agent not responding
**Symptoms:** The dashboard shows "Agent unreachable" or HTTP 502/504 on flow
endpoints; `curl http://127.0.0.1:3099/healthz` times out.
```bash
# Check current status and recent logs
systemctl status ops-agent
journalctl -u ops-agent -n 50 --no-pager
# Restart the agent
systemctl restart ops-agent
# Verify it came back
systemctl status ops-agent
curl -sf http://127.0.0.1:3099/healthz && echo OK
```
If the agent exits immediately on restart, the most common causes are:
| Cause | Fix |
|---|---|
| Missing `/etc/ops-agent/secret` | `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` |
| Missing or invalid `commands.yml` | `cp /opt/ops-agent/commands.yml.example /etc/ops-agent/commands.yml` |
| Port 3099 already in use | `ss -tlnp \| grep 3099` then kill the conflicting process |
| Node.js missing | `apt install nodejs` or reinstall via `deploy/ops-agent/setup.sh` |
If the binary itself is corrupt, reinstall from the repo:
```bash
cd /srv/ops/repos/ops-dashboard
sudo deploy/ops-agent/setup.sh
```
---
## 2. Database corruption (ops_dashboard DB)
**Symptoms:** Dashboard shows database errors; Prisma throws `P1001` / `P1002`;
`psql` commands fail against the `ops_dashboard` database.
### 2a. Restore from latest backup
Backups are stored in `/var/backups/ops-dashboard/` (default path from the
backup flow). Each file is a plain-SQL `pg_dump` dump named
`ops_dashboard_YYYY-MM-DDTHH-MM-SSZ.sql`.
```bash
# List available backups (newest first)
ls -lt /var/backups/ops-dashboard/*.sql | head -10
# Drop the damaged DB and restore
BACKUP=/var/backups/ops-dashboard/<chosen-file>.sql
sudo -u postgres psql -c "SELECT pg_terminate_backend(pid) FROM pg_stat_activity WHERE datname = 'ops_dashboard';"
sudo -u postgres dropdb ops_dashboard
sudo -u postgres createdb ops_dashboard
sudo -u postgres psql ops_dashboard < "$BACKUP"
```
Then restart the dashboard to re-open connections:
```bash
docker compose -f /srv/scrum4me/compose/docker-compose.yml restart ops-dashboard
```
### 2b. No usable backup available
Run Prisma migrations to re-create the schema (data loss):
```bash
cd /srv/ops/repos/ops-dashboard
sudo -u postgres createdb ops_dashboard # if it was dropped
docker compose -f /srv/scrum4me/compose/docker-compose.yml run --rm ops-dashboard \
npx prisma migrate deploy
```
---
## 3. Container refuses to start
**Symptoms:** `docker compose up ops-dashboard` exits immediately; the container
appears in `docker ps -a` with `Exited`.
```bash
# Show exit logs
docker logs ops-dashboard --tail 50
# Inspect exit code
docker inspect ops-dashboard --format='ExitCode: {{.State.ExitCode}}'
```
### Common causes and fixes
| Exit code / symptom | Likely cause | Fix |
|---|---|---|
| `EACCES` / permission denied | Env file unreadable | `chmod 640 /srv/ops/ops-dashboard.env` |
| `DATABASE_URL` connect error | DB not ready or wrong credentials | Check `docker ps` for `postgres`; verify `DATABASE_URL` in env file |
| Port 3000 already bound | Another process on 3000 | `ss -tlnp \| grep 3000`; adjust port mapping in Compose file |
| Invalid `AUTH_SECRET` | Secret too short for Next.js Auth | Regenerate: `openssl rand -base64 32` |
| Image not found | `build` step skipped | `docker compose -f ... build ops-dashboard` then `up -d` |
Force a clean rebuild:
```bash
docker compose -f /srv/scrum4me/compose/docker-compose.yml build --no-cache ops-dashboard
docker compose -f /srv/scrum4me/compose/docker-compose.yml up -d --force-recreate ops-dashboard
```
---
## 4. TLS certificate expired
**Symptoms:** Browser shows `ERR_CERT_DATE_INVALID`; Caddy logs show
`certificate expired`.
Caddy manages TLS automatically via Let's Encrypt. A certificate should never
expire under normal operation because Caddy renews ~30 days before expiry.
### Check certificate status
```bash
# View Caddy logs for ACME activity
journalctl -u caddy -n 100 --no-pager | grep -i acme
# Check the expiry of the live certificate
echo | openssl s_client -connect jp-visser.nl:443 -servername jp-visser.nl 2>/dev/null \
| openssl x509 -noout -dates
```
### Force renewal
```bash
# Stop Caddy, clear the ACME cache, restart
systemctl stop caddy
rm -rf /var/lib/caddy/.local/share/caddy/certificates
systemctl start caddy
journalctl -u caddy -f # watch for successful issuance
```
### If ACME fails (rate-limited or DNS not resolving)
1. Confirm DNS for `jp-visser.nl` points to this server's public IP.
2. Confirm port 80 is open inbound (required for HTTP-01 challenge).
3. If rate-limited (>5 certificates/week for the domain), wait until the limit
resets or use a staging cert temporarily:
```
# In Caddyfile, add: acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
# Remove after the rate-limit window passes.
```
### Emergency: self-signed certificate
If renewal is blocked and you need access now:
```bash
# Generate a temporary self-signed cert
openssl req -x509 -newkey rsa:4096 -keyout /etc/caddy/selfsigned.key \
-out /etc/caddy/selfsigned.crt -days 30 -nodes \
-subj "/CN=jp-visser.nl"
# Point Caddyfile at the self-signed cert (tls section):
# tls /etc/caddy/selfsigned.crt /etc/caddy/selfsigned.key
systemctl reload caddy
```
Remember to revert to automatic TLS once the ACME issue is resolved.

168
hooks/useFlowRun.ts Normal file
View file

@ -0,0 +1,168 @@
'use client'
import { useState, useCallback, useRef } from 'react'
import type { TerminalLine, TerminalStatus } from '@/components/StreamingTerminal'
import { apiFetch } from '@/lib/csrf'
export interface FlowRunState {
status: TerminalStatus
flowRunId: string | null
lines: TerminalLine[]
exitCode: number | null
error: string | null
}
export function useFlowRun(onComplete?: (flowRunId: string, exitCode: number | null) => void) {
const [state, setState] = useState<FlowRunState>({
status: 'idle',
flowRunId: null,
lines: [],
exitCode: null,
error: null,
})
const abortRef = useRef<AbortController | null>(null)
const streamSSE = useCallback(
async (url: string, body: Record<string, unknown>, signal: AbortSignal) => {
let response: Response
try {
response = await apiFetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal,
})
} catch (err) {
if ((err as Error).name === 'AbortError') return
setState((s) => ({
...s,
status: 'error',
error: err instanceof Error ? err.message : 'request failed',
}))
return
}
if (!response.ok) {
const text = await response.text()
setState((s) => ({
...s,
status: 'error',
error: `${response.status}: ${text}`,
}))
return
}
const reader = response.body!.getReader()
const decoder = new TextDecoder()
let buffer = ''
let currentEvent = ''
try {
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const rawLines = buffer.split('\n')
buffer = rawLines.pop() ?? ''
for (const line of rawLines) {
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<string, unknown>
if (currentEvent === 'flow_run_id') {
setState((s) => ({ ...s, flowRunId: String(parsed.flow_run_id ?? '') }))
} else if (currentEvent === 'step_start') {
const stepIndex = (parsed.step_index as number) + 1
const totalSteps = parsed.total_steps as number
const commandKey = String(parsed.command_key ?? '')
setState((s) => ({
...s,
lines: [
...s.lines,
{
type: 'info' as const,
text: `\n── Step ${stepIndex}/${totalSteps}: ${commandKey} ──\n`,
},
],
}))
} else if (currentEvent === 'stdout') {
const text = String(parsed.data ?? '')
setState((s) => ({
...s,
lines: [...s.lines, { type: 'stdout' as const, text }],
}))
} else if (currentEvent === 'stderr') {
const text = String(parsed.data ?? '')
setState((s) => ({
...s,
lines: [...s.lines, { type: 'stderr' as const, text }],
}))
} else if (currentEvent === 'done') {
const exitCode = typeof parsed.exit_code === 'number' ? parsed.exit_code : null
const flowRunId = String(parsed.flow_run_id ?? '')
setState((s) => ({
...s,
status: exitCode === 0 ? 'done' : 'failed',
exitCode,
flowRunId,
}))
onComplete?.(flowRunId, exitCode)
} else if (currentEvent === 'error') {
const message = String(parsed.message ?? 'unknown error')
setState((s) => ({ ...s, status: 'error', error: message }))
}
} catch {
// ignore malformed SSE data
}
}
}
}
} catch (err) {
if ((err as Error).name === 'AbortError') return
setState((s) => ({
...s,
status: 'error',
error: err instanceof Error ? err.message : 'stream error',
}))
}
},
[onComplete],
)
const start = useCallback(
async (commandKey: string, args: string[] = [], stdin?: string) => {
abortRef.current?.abort()
const abort = new AbortController()
abortRef.current = abort
setState({ status: 'running', flowRunId: null, lines: [], exitCode: null, error: null })
await streamSSE(
'/api/flows/start',
{ command_key: commandKey, args, ...(stdin != null ? { stdin } : {}) },
abort.signal,
)
},
[streamSSE],
)
const startFlow = useCallback(
async (flowKey: string, dryRun = false) => {
abortRef.current?.abort()
const abort = new AbortController()
abortRef.current = abort
setState({ status: 'running', flowRunId: null, lines: [], exitCode: null, error: null })
await streamSSE('/api/flows/run', { flow_key: flowKey, dry_run: dryRun }, abort.signal)
},
[streamSSE],
)
const reset = useCallback(() => {
abortRef.current?.abort()
setState({ status: 'idle', flowRunId: null, lines: [], exitCode: null, error: null })
}, [])
return { ...state, start, startFlow, reset }
}

58
lib/agent-client.ts Normal file
View file

@ -0,0 +1,58 @@
import 'server-only'
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 execAgent(
commandKey: string,
args: string[] = [],
onChunk?: (chunk: string) => void,
): Promise<string> {
const response = await fetch(`${AGENT_URL}/agent/v1/exec`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${AGENT_SECRET}`,
},
body: JSON.stringify({ command_key: commandKey, args }),
cache: 'no-store',
})
if (!response.ok) {
const text = await response.text()
throw new Error(`agent error ${response.status}: ${text}`)
}
const reader = response.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:')) {
const jsonStr = line.slice(5).trim()
try {
const parsed = JSON.parse(jsonStr) as { data?: string }
if (parsed.data !== undefined) {
output += parsed.data
onChunk?.(parsed.data)
}
} catch {
// skip malformed SSE data
}
}
}
}
return output
}

21
lib/csrf.ts Normal file
View file

@ -0,0 +1,21 @@
'use client'
function getCsrfToken(): string {
if (typeof document === 'undefined') return ''
return (
document.cookie
.split('; ')
.find((c) => c.startsWith('csrf_token='))
?.split('=')[1] ?? ''
)
}
/** Drop-in replacement for fetch() that automatically injects the CSRF token on POST requests. */
export function apiFetch(url: string, init: RequestInit = {}): Promise<Response> {
if ((init.method ?? 'GET').toUpperCase() !== 'POST') {
return fetch(url, init)
}
const headers = new Headers(init.headers)
headers.set('x-csrf-token', getCsrfToken())
return fetch(url, { ...init, headers })
}

54
lib/parse-caddy.ts Normal file
View file

@ -0,0 +1,54 @@
export interface CertInfo {
domain: string
subject: string
issuer: string
issuerCN: string
notBefore: string
notAfter: string
expiringWarning: boolean
expired: boolean
}
export function parseCertList(output: string): CertInfo[] {
const certs: CertInfo[] = []
const blocks = output.split('CERTEND')
for (const block of blocks) {
const fileMatch = block.match(/CERTFILE:(.+)/)
if (!fileMatch) continue
const filePath = fileMatch[1].trim()
const domain = filePath.split('/').filter(Boolean).pop()?.replace(/\.crt$/, '') ?? ''
let subject = ''
let issuer = ''
let notBefore = ''
let notAfter = ''
for (const line of block.split('\n')) {
const subjectMatch = line.match(/^subject=(.+)/)
if (subjectMatch) subject = subjectMatch[1].trim()
const issuerMatch = line.match(/^issuer=(.+)/)
if (issuerMatch) issuer = issuerMatch[1].trim()
const notBeforeMatch = line.match(/^notBefore=(.+)/)
if (notBeforeMatch) notBefore = notBeforeMatch[1].trim()
const notAfterMatch = line.match(/^notAfter=(.+)/)
if (notAfterMatch) notAfter = notAfterMatch[1].trim()
}
const issuerCN = issuer.match(/CN\s*=\s*([^,]+)/)?.[1]?.trim() ?? issuer
const now = Date.now()
const notAfterDate = notAfter ? new Date(notAfter) : null
const notAfterMs = notAfterDate?.getTime() ?? Infinity
const expiringWarning = notAfterMs - now < 30 * 24 * 60 * 60 * 1000
const expired = notAfterMs < now
certs.push({ domain, subject, issuer, issuerCN, notBefore, notAfter, expiringWarning, expired })
}
return certs
}

40
lib/parse-docker.ts Normal file
View file

@ -0,0 +1,40 @@
export type Container = {
id: string
image: string
command: string
created: string
status: string
ports: string
name: string
}
// Parse the fixed-width table output of `docker ps --format table`
export function parseDockerPs(output: string): Container[] {
const lines = output.trim().split('\n').filter(Boolean)
if (lines.length < 2) return []
const header = lines[0]
const COLS = ['CONTAINER ID', 'IMAGE', 'COMMAND', 'CREATED', 'STATUS', 'PORTS', 'NAMES'] as const
const positions = COLS.map((col) => header.indexOf(col))
// Must find at least NAMES (last) to produce useful output
if (positions[6] === -1) return []
const extract = (line: string, i: number): string => {
const start = positions[i]
if (start === -1) return ''
const nextIdx = positions.slice(i + 1).find((p) => p !== -1)
return line.slice(start, nextIdx).trim()
}
return lines.slice(1).map((line) => ({
id: extract(line, 0),
image: extract(line, 1),
command: extract(line, 2),
created: extract(line, 3),
status: extract(line, 4),
ports: extract(line, 5),
name: extract(line, 6),
}))
}

46
lib/parse-git.ts Normal file
View file

@ -0,0 +1,46 @@
export interface RepoStatus {
branch: string
/** Number of commits ahead of upstream (undefined if no upstream) */
ahead?: number
/** Number of commits behind upstream (undefined if no upstream) */
behind?: number
/** True when there are uncommitted changes */
dirty: boolean
}
/**
* Parses `git status --short --branch` output into a RepoStatus.
*
* First line format: ## main...origin/main [ahead N, behind M]
* Remaining lines: XY path (presence means dirty)
*/
export function parseGitStatus(output: string): RepoStatus {
const lines = output.trim().split('\n').filter(Boolean)
let branch = 'unknown'
let ahead: number | undefined
let behind: number | undefined
let dirty = false
for (const line of lines) {
if (line.startsWith('## ')) {
const rest = line.slice(3)
// "No commits yet on main" or "HEAD (no branch)"
const trackMatch = rest.match(/^([^.]+)\.\.\.(\S+)/)
if (trackMatch) {
branch = trackMatch[1]
} else {
branch = rest.split(' ')[0]
}
const aheadMatch = rest.match(/ahead (\d+)/)
const behindMatch = rest.match(/behind (\d+)/)
if (aheadMatch) ahead = parseInt(aheadMatch[1], 10)
if (behindMatch) behind = parseInt(behindMatch[1], 10)
} else {
// Any non-header line means there are changes
dirty = true
}
}
return { branch, ahead, behind, dirty }
}

34
lib/parse-systemd.ts Normal file
View file

@ -0,0 +1,34 @@
export type ActiveState = 'active' | 'inactive' | 'failed' | 'activating' | 'deactivating' | 'unknown'
export interface UnitStatus {
activeState: ActiveState
subState: string
uptime: string
description: string
}
const KNOWN_STATES = new Set(['active', 'inactive', 'failed', 'activating', 'deactivating'])
export function parseSystemctlStatus(output: string, unitName: string): UnitStatus {
let activeState: ActiveState = 'unknown'
let subState = ''
let uptime = ''
let description = unitName
for (const line of output.split('\n')) {
// Header line: "● scrum4me-web.service - Description text"
const headerMatch = line.match(/^\s*[●○×◉]\s+\S+\s+-\s+(.+)/)
if (headerMatch) description = headerMatch[1].trim()
// Active line: " Active: active (running) since Tue 2025-01-13...; 2h 30min ago"
const activeMatch = line.match(/\bActive:\s+(\w+)(?:\s+\(([^)]+)\))?(?:.*?;\s+(.+?ago))?/)
if (activeMatch) {
const state = activeMatch[1].toLowerCase()
activeState = KNOWN_STATES.has(state) ? (state as ActiveState) : 'unknown'
subState = activeMatch[2] ?? ''
uptime = activeMatch[3] ?? ''
}
}
return { activeState, subState, uptime, description }
}

17
lib/prisma.ts Normal file
View file

@ -0,0 +1,17 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
function createPrismaClient() {
const connectionString = process.env.DATABASE_URL
if (!connectionString) {
throw new Error('DATABASE_URL environment variable is required')
}
const adapter = new PrismaPg({ connectionString })
return new PrismaClient({ adapter })
}
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }
export const prisma = globalForPrisma.prisma ?? createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma

52
lib/session.ts Normal file
View file

@ -0,0 +1,52 @@
import 'server-only'
import { cookies } from 'next/headers'
import { createHash, randomBytes } from 'crypto'
import { prisma } from './prisma'
const COOKIE_NAME = 'ops_session'
const SESSION_TTL_MS = 24 * 60 * 60 * 1000
export function generateSessionToken(): string {
return randomBytes(32).toString('hex')
}
export function hashToken(token: string): string {
return createHash('sha256').update(token).digest('hex')
}
export async function createSession(userId: string, token: string): Promise<void> {
const expiresAt = new Date(Date.now() + SESSION_TTL_MS)
await prisma.session.create({
data: {
user_id: userId,
token_hash: hashToken(token),
expires_at: expiresAt,
},
})
}
export async function getCurrentUser() {
const cookieStore = await cookies()
const token = cookieStore.get(COOKIE_NAME)?.value
if (!token) return null
const session = await prisma.session.findUnique({
where: { token_hash: hashToken(token) },
include: { user: true },
})
if (!session) return null
if (session.expires_at < new Date()) {
await prisma.session.delete({ where: { id: session.id } })
return null
}
return session.user
}
export async function invalidateSession(token: string): Promise<void> {
await prisma.session.deleteMany({
where: { token_hash: hashToken(token) },
})
}

6
lib/utils.ts Normal file
View file

@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

55
middleware.ts Normal file
View file

@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server'
const CSP = [
"default-src 'self'",
"script-src 'self' 'unsafe-inline'",
"style-src 'self' 'unsafe-inline'",
"font-src 'self'",
"img-src 'self' data:",
"connect-src 'self'",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join('; ')
const CSRF_COOKIE = 'csrf_token'
const CSRF_HEADER = 'x-csrf-token'
export function middleware(request: NextRequest) {
const { method, nextUrl } = request
// Validate CSRF token on all POST requests to API routes
if (method === 'POST' && nextUrl.pathname.startsWith('/api/')) {
const cookieToken = request.cookies.get(CSRF_COOKIE)?.value
const headerToken = request.headers.get(CSRF_HEADER)
if (!cookieToken || cookieToken !== headerToken) {
return new NextResponse(
JSON.stringify({ error: 'CSRF validation failed' }),
{ status: 403, headers: { 'Content-Type': 'application/json' } },
)
}
}
const response = NextResponse.next()
response.headers.set('Content-Security-Policy', CSP)
response.headers.set('X-Frame-Options', 'DENY')
response.headers.set('X-Content-Type-Options', 'nosniff')
response.headers.set('Referrer-Policy', 'strict-origin-when-cross-origin')
// Issue a CSRF token cookie on GET requests when not yet present
if (method === 'GET' && !request.cookies.get(CSRF_COOKIE)) {
response.cookies.set(CSRF_COOKIE, crypto.randomUUID(), {
httpOnly: false, // must be readable by client JS for the double-submit pattern
sameSite: 'strict',
secure: process.env.NODE_ENV === 'production',
path: '/',
})
}
return response
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
}

7
next.config.ts Normal file
View file

@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
output: "standalone",
};
export default nextConfig;

2
ops-agent/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
node_modules/
dist/

View file

@ -0,0 +1,238 @@
# Whitelist of allowed commands for ops-agent.
# Copy to /etc/ops-agent/commands.yml on the host.
# Restart ops-agent after changes.
#
# Schema per command:
# cmd: required — command + static args as array (no shell, no interpolation)
# cwd: optional — working directory for the subprocess
# cwd_pattern: optional — working directory as a glob/pattern (resolved at runtime)
# args:
# allowed: optional — whitelist of argument values accepted from the caller
# If absent or empty, the command takes no extra arguments.
# description: optional — human-readable description
commands:
docker_ps:
cmd: ["docker", "ps", "--format", "table"]
description: "List running Docker containers"
git_status:
cmd: ["git", "status", "--short", "--branch"]
cwd_pattern: "/srv/"
description: "Git status with branch info (first arg = repo path, must start with /srv/)"
git_log_ahead:
cmd: ["git", "log", "@{upstream}..HEAD", "--oneline"]
cwd_pattern: "/srv/"
description: "Local commits not yet pushed (first arg = repo path)"
git_diff:
cmd: ["git", "diff", "HEAD"]
cwd_pattern: "/srv/"
description: "Uncommitted diff against HEAD (first arg = repo path)"
git_fetch:
cmd: ["git", "fetch", "--quiet"]
cwd_pattern: "/srv/"
description: "Fetch all remotes silently (first arg = repo path)"
systemctl_status:
cmd: ["systemctl", "status", "--no-pager", "-l"]
args:
allowed:
- scrum4me-web
- ops-agent
- caddy
- docker
- nginx
- postgresql
description: "Show systemctl status for an allowed service"
journalctl_recent:
cmd: ["journalctl", "--since", "1 hour ago", "-n", "100", "--no-pager", "-u"]
args:
allowed:
- scrum4me-web
- ops-agent
- caddy
- docker
- nginx
- postgresql
description: "Last 100 journal lines from the past hour for an allowed service"
caddy_show_config:
cmd: ["caddy", "fmt", "/etc/caddy/Caddyfile"]
description: "Print the formatted Caddy config"
caddy_list_certs:
cmd:
- sh
- -c
- "for f in /data/caddy/certificates/*/*.crt; do [ -f \"$f\" ] || continue; echo \"CERTFILE:$f\"; openssl x509 -noout -subject -issuer -dates -in \"$f\" 2>&1; echo \"CERTEND\"; done"
description: "List TLS cert info (subject, issuer, validity dates) from Caddy certificate store"
# ── Destructive / write commands ──────────────────────────────────────────
docker_compose_restart:
cmd: ["docker", "compose", "restart"]
cwd: "/srv/scrum4me/compose"
args:
allowed:
- scrum4me-web
- worker-idea
- ops-dashboard
- caddy
- postgres
description: "Restart a docker compose service (ops-agent user must be in the docker group)"
docker_compose_stop:
cmd: ["docker", "compose", "stop"]
cwd: "/srv/scrum4me/compose"
args:
allowed:
- scrum4me-web
- worker-idea
- ops-dashboard
- caddy
- postgres
description: "Stop a docker compose service"
docker_compose_build:
cmd: ["docker", "compose", "build"]
cwd: "/srv/scrum4me/compose"
args:
allowed:
- scrum4me-web
- worker-idea
- ops-dashboard
description: "Build a docker compose service image"
docker_compose_up:
cmd: ["docker", "compose", "up", "-d"]
cwd: "/srv/scrum4me/compose"
args:
allowed:
- scrum4me-web
- worker-idea
- ops-dashboard
description: "Start or recreate a docker compose service in detached mode"
docker_compose_up_recreate:
cmd: ["docker", "compose", "up", "-d", "--force-recreate"]
cwd: "/srv/scrum4me/compose"
args:
allowed:
- scrum4me-web
- worker-idea
- ops-dashboard
description: "Force-recreate a docker compose service (picks up a rebuilt image)"
git_pull:
cmd: ["git", "pull", "--ff-only"]
cwd_pattern: "/srv/"
preconditions:
- git_status_clean
description: "Fast-forward pull — refused when working tree is dirty"
systemctl_restart:
# Requires /etc/sudoers.d/ops-agent (see deploy/ops-agent/sudoers).
cmd: ["sudo", "/usr/bin/systemctl", "restart"]
args:
allowed:
- scrum4me-web
- ops-agent
- caddy
description: "Restart an allowed systemd service via sudo"
caddy_validate:
cmd: ["caddy", "validate", "--config", "/srv/scrum4me/caddy/Caddyfile"]
description: "Validate /srv/scrum4me/caddy/Caddyfile without reloading"
caddy_reload:
cmd: ["caddy", "reload", "--config", "/srv/scrum4me/caddy/Caddyfile"]
description: "Reload Caddy with /srv/scrum4me/caddy/Caddyfile"
caddy_write_config:
# Writes stdin to Caddyfile.new first; mv is atomic on the same filesystem.
# ops-agent user must own /srv/scrum4me/caddy/.
cmd:
- sh
- -c
- "cat > /srv/scrum4me/caddy/Caddyfile.new && mv /srv/scrum4me/caddy/Caddyfile.new /srv/scrum4me/caddy/Caddyfile"
stdin_from_body: true
description: "Atomically replace /srv/scrum4me/caddy/Caddyfile (write stdin to .new, then mv)"
# ── Smoke tests / health checks ───────────────────────────────────────────
curl_smoke_scrum4me_web:
cmd: ["curl", "-sf", "--max-time", "10", "https://scrum4me.com"]
description: "HTTP smoke test — fails (non-zero) if the site is unreachable or returns a non-2xx status"
docker_compose_ps_worker:
cmd: ["docker", "compose", "ps", "--filter", "status=running", "worker-idea"]
cwd: "/srv/scrum4me/compose"
description: "Verify worker-idea container is in the running state"
wait_for_health_worker:
cmd:
- sh
- -c
- "timeout 60 sh -c 'until grep -q \"pre-flight passed\" /var/log/agent/current 2>/dev/null; do sleep 3; done && echo \"pre-flight passed\"'"
description: "Wait up to 60s for MCP worker pre-flight check (/var/log/agent/current)"
# ── Scrum4Me web deployment steps ────────────────────────────────────────
npm_ci:
cmd: ["npm", "ci"]
cwd: "/srv/scrum4me/repos/Scrum4Me"
description: "Install production dependencies for Scrum4Me web (npm ci)"
prisma_migrate_deploy:
cmd: ["npx", "prisma", "migrate", "deploy"]
cwd: "/srv/scrum4me/repos/Scrum4Me"
description: "Apply pending Prisma migrations for Scrum4Me web"
npm_run_build:
cmd: ["npm", "run", "build"]
cwd: "/srv/scrum4me/repos/Scrum4Me"
description: "Build the Scrum4Me web application (next build)"
curl_smoke_scrum4me_thuis:
cmd:
- sh
- -c
- "code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 https://thuis.jp-visser.nl/api/products); echo \"HTTP $code\"; [ \"$code\" = \"200\" ] || [ \"$code\" = \"401\" ]"
description: "Smoke test: /api/products must return 200 or 401"
# ── Ops-dashboard database backup ────────────────────────────────────────
pg_dump_ops_db:
cmd:
- sh
- -c
- |
mkdir -p /srv/ops/backups
FNAME="/srv/ops/backups/ops_db_$(date +%Y%m%d_%H%M).dump"
docker exec postgres pg_dump -Fc ops_dashboard > "$FNAME"
echo "Backup written: $FNAME"
ls -lh "$FNAME"
description: "Dump ops_dashboard DB via docker exec postgres to /srv/ops/backups/"
list_ops_backups:
cmd:
- sh
- -c
- "find /srv/ops/backups -maxdepth 1 -name '*.dump' -printf '%f\\t%s\\n' 2>/dev/null | sort -r || true"
description: "List ops_dashboard backup files (filename TAB size_bytes, newest-first)"
cleanup_ops_backups:
cmd:
- find
- /srv/ops/backups
- -name
- "*.dump"
- -mtime
- "+30"
- -delete
- -print
description: "Delete ops_dashboard backup files older than 30 days"

View file

@ -0,0 +1,22 @@
# Backup the ops_dashboard database.
# Copy to /etc/ops-agent/flows/backup_ops_db.yml on the host.
#
# Prerequisites:
# - ops-agent user must be in the docker group (to run docker exec)
# - /srv/ops/backups/ directory or its parent must be writable by ops-agent
#
# Steps:
# 1. Dump ops_dashboard via pg_dump inside the postgres container
# 2. Remove backup files older than 30 days (retention policy)
#
# Run on a schedule via ops-db-backup.timer (see deploy/ops-agent/).
# Or trigger manually via the Ops Dashboard → Settings → Backups.
name: Backup Ops DB
description: Dump ops_dashboard database and apply 30-day retention policy
steps:
- command_key: pg_dump_ops_db
on_failure: abort
- command_key: cleanup_ops_backups
on_failure: continue

View file

@ -0,0 +1,43 @@
# Validate and reload the Caddy configuration (zero-downtime).
# Copy to /etc/ops-agent/flows/update_caddy_config.yml on the host.
#
# Prerequisites:
# - The new Caddyfile must already be written to /srv/scrum4me/caddy/Caddyfile
# (e.g. via the Caddy editor in the Ops Dashboard, or edited by hand).
#
# Steps:
# 1. Validate the Caddyfile syntax (caddy validate)
# 2. Reload Caddy via its admin API — zero-downtime config swap
# 3. Smoke-test public hostnames: curl -I, expect 200/301/308/401
#
# For a hard container restart instead of a graceful reload, use
# update_caddy_config_force.yml (needed after port/TLS listener changes).
#
# Smoke-test commands must be registered in commands.yml.
# Add one curl_smoke_<name> entry per public hostname. Example:
#
# curl_smoke_scrum4me_web:
# cmd: ["curl", "-sI", "--max-time", "10", "https://scrum4me.example.com/api/health"]
# description: "Smoke test scrum4me-web HTTPS endpoint"
#
# Then add one step per hostname below:
#
# - command_key: curl_smoke_scrum4me_web
# on_failure: continue
# - command_key: curl_smoke_other_site
# on_failure: continue
name: Update Caddy Config
description: Validate the Caddyfile and reload Caddy (zero-downtime via admin API)
steps:
- command_key: caddy_validate
on_failure: abort
- command_key: caddy_reload
on_failure: abort
# Add one smoke-test step per public hostname served by Caddy.
# Accepted exit codes: 0 (200/301/308) or 22 (4xx, use --fail to control).
# on_failure: continue keeps the flow going even if a hostname is temporarily slow.
- command_key: curl_smoke_scrum4me_web
on_failure: continue

View file

@ -0,0 +1,27 @@
# Validate the Caddyfile and recreate the Caddy container (hard restart).
# Copy to /etc/ops-agent/flows/update_caddy_config_force.yml on the host.
#
# Use this flow instead of update_caddy_config.yml when a graceful reload
# is insufficient — e.g. after adding a new TLS listener, changing ports,
# or updating the Docker image itself.
#
# Steps:
# 1. Validate the Caddyfile syntax (caddy validate)
# 2. Recreate the Caddy container via docker compose (hard restart)
# 3. Smoke-test public hostnames: curl -I, expect 200/301/308/401
#
# See update_caddy_config.yml for instructions on registering smoke-test
# commands in commands.yml.
name: Update Caddy Config (Force Restart)
description: Validate the Caddyfile and recreate the Caddy container via docker compose
steps:
- command_key: caddy_validate
on_failure: abort
- command_key: caddy_compose_restart
on_failure: abort
# Add one smoke-test step per public hostname served by Caddy.
- command_key: curl_smoke_scrum4me_web
on_failure: continue

View file

@ -0,0 +1,36 @@
# Deploy the latest MCP worker image.
# Copy to /etc/ops-agent/flows/update_mcp_worker.yml on the host.
#
# Steps:
# 1. Show current git status (informational)
# 2. Fetch remote refs
# 3. Fast-forward pull (aborts if working tree is dirty)
# 4. Rebuild the Docker image
# 5. Recreate the container in detached mode (force-recreate picks up new image)
# 6. Wait for worker pre-flight to pass (checks /var/log/agent/current)
name: Update MCP Worker
description: Pull latest code, rebuild Docker image, and restart the MCP worker service
steps:
- command_key: git_status
args: ["/srv/scrum4me/repos/scrum4me-docker"]
on_failure: continue
- command_key: git_fetch
args: ["/srv/scrum4me/repos/scrum4me-docker"]
on_failure: abort
- command_key: git_pull
args: ["/srv/scrum4me/repos/scrum4me-docker"]
on_failure: abort
- command_key: docker_compose_build
args: ["worker-idea"]
on_failure: abort
- command_key: docker_compose_up_recreate
args: ["worker-idea"]
on_failure: abort
- command_key: wait_for_health_worker
on_failure: continue

View file

@ -0,0 +1,48 @@
# Deploy the latest Scrum4Me web application from source.
# Copy to /etc/ops-agent/flows/update_scrum4me_web.yml on the host.
#
# Steps:
# 1. Show current git status (dirty tree aborts later at git_pull)
# 2. Fetch remote refs
# 3. Show commits ahead of upstream
# 4. Fast-forward pull (aborts if working tree is dirty)
# 5. Install dependencies
# 6. Apply database migrations
# 7. Build the application
# 8. Restart the systemd service
# 9. Smoke-test the public endpoint (200 or 401 = pass)
name: Update Scrum4Me Web
description: Pull latest code, install deps, run migrations, build, and restart scrum4me-web.service
steps:
- command_key: git_status
args: ["/srv/scrum4me/repos/Scrum4Me"]
on_failure: continue
- command_key: git_fetch
args: ["/srv/scrum4me/repos/Scrum4Me"]
on_failure: abort
- command_key: git_log_ahead
args: ["/srv/scrum4me/repos/Scrum4Me"]
on_failure: continue
- command_key: git_pull
args: ["/srv/scrum4me/repos/Scrum4Me"]
on_failure: abort
- command_key: npm_ci
on_failure: abort
- command_key: prisma_migrate_deploy
on_failure: abort
- command_key: npm_run_build
on_failure: abort
- command_key: systemctl_restart
args: ["scrum4me-web"]
on_failure: abort
- command_key: curl_smoke_scrum4me_thuis
on_failure: continue

745
ops-agent/package-lock.json generated Normal file
View file

@ -0,0 +1,745 @@
{
"name": "ops-agent",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ops-agent",
"version": "0.1.0",
"dependencies": {
"@fastify/cors": "^10.0.1",
"fastify": "^5.3.2",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.15.3",
"typescript": "^5.8.3"
}
},
"node_modules/@fastify/ajv-compiler": {
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/@fastify/ajv-compiler/-/ajv-compiler-4.0.5.tgz",
"integrity": "sha512-KoWKW+MhvfTRWL4qrhUwAAZoaChluo0m0vbiJlGMt2GXvL4LVPQEjt8kSpHI3IBq5Rez8fg+XeH3cneztq+C7A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-10.1.0.tgz",
"integrity": "sha512-MZyBCBJtII60CU9Xme/iE4aEy8G7QpzGR8zkdXZkDFt7ElEMachbE61tfhAG/bvSaULlqlf0huMT12T7iqEmdQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fastify-plugin": "^5.0.0",
"mnemonist": "0.40.0"
}
},
"node_modules/@fastify/error": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/@fastify/error/-/error-4.2.0.tgz",
"integrity": "sha512-RSo3sVDXfHskiBZKBPRgnQTtIqpi/7zhJOEmAxCiBcM7d0uwdGdxLlsCaLzGs8v8NnxIRlfG0N51p5yFaOentQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/fast-json-stringify-compiler": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@fastify/fast-json-stringify-compiler/-/fast-json-stringify-compiler-5.0.3.tgz",
"integrity": "sha512-uik7yYHkLr6fxd8hJSZ8c+xF4WafPK+XzneQDPU+D10r5X19GW8lJcom2YijX2+qtFF1ENJlHXKFM9ouXNJYgQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"fast-json-stringify": "^6.0.0"
}
},
"node_modules/@fastify/forwarded": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/@fastify/forwarded/-/forwarded-3.0.1.tgz",
"integrity": "sha512-JqDochHFqXs3C3Ml3gOY58zM7OqO9ENqPo0UqAjAjH8L01fRZqwX9iLeX34//kiJubF7r2ZQHtBRU36vONbLlw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/@fastify/merge-json-schemas": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/@fastify/merge-json-schemas/-/merge-json-schemas-0.2.1.tgz",
"integrity": "sha512-OA3KGBCy6KtIvLf8DINC5880o5iBlDX4SxzLQS8HorJAbqluzLRn80UXU0bxZn7UOFhFgpRJDasfwn9nG4FG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/@fastify/proxy-addr": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/@fastify/proxy-addr/-/proxy-addr-5.1.0.tgz",
"integrity": "sha512-INS+6gh91cLUjB+PVHfu1UqcB76Sqtpyp7bnL+FYojhjygvOPA9ctiD/JDKsyD9Xgu4hUhCSJBPig/w7duNajw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/forwarded": "^3.0.0",
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@pinojs/redact": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz",
"integrity": "sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg==",
"license": "MIT"
},
"node_modules/@types/js-yaml": {
"version": "4.0.9",
"resolved": "https://registry.npmjs.org/@types/js-yaml/-/js-yaml-4.0.9.tgz",
"integrity": "sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/abstract-logging": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/abstract-logging/-/abstract-logging-2.0.1.tgz",
"integrity": "sha512-2BjRTZxTPvheOvGbBslFSYOUkr+SjPtOnrLP33f+VIWLzezQpZcqVg7ja3L4dBXmzzgwT+a029jRx5PCi3JuiA==",
"license": "MIT"
},
"node_modules/ajv": {
"version": "8.20.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
"integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-uri": "^3.0.1",
"json-schema-traverse": "^1.0.0",
"require-from-string": "^2.0.2"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/ajv-formats": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
},
"peerDependencies": {
"ajv": "^8.0.0"
},
"peerDependenciesMeta": {
"ajv": {
"optional": true
}
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/atomic-sleep": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz",
"integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==",
"license": "MIT",
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/avvio": {
"version": "9.2.0",
"resolved": "https://registry.npmjs.org/avvio/-/avvio-9.2.0.tgz",
"integrity": "sha512-2t/sy01ArdHHE0vRH5Hsay+RtCZt3dLPji7W7/MMOCEgze5b7SNDC4j5H6FnVgPkI1MTNFGzHdHrVXDDl7QSSQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/error": "^4.0.0",
"fastq": "^1.17.1"
}
},
"node_modules/cookie": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz",
"integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==",
"license": "MIT",
"engines": {
"node": ">=18"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/express"
}
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/fast-decode-uri-component": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/fast-decode-uri-component/-/fast-decode-uri-component-1.0.1.tgz",
"integrity": "sha512-WKgKWg5eUxvRZGwW8FvfbaH7AXSh2cL+3j5fMGzUMCxWBJ3dV3a7Wz8y2f/uQ0e3B6WmodD3oS54jTQ9HVTIIg==",
"license": "MIT"
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==",
"license": "MIT"
},
"node_modules/fast-json-stringify": {
"version": "6.4.0",
"resolved": "https://registry.npmjs.org/fast-json-stringify/-/fast-json-stringify-6.4.0.tgz",
"integrity": "sha512-ibRCQ0GZKJIQ+P3Et1h0LhPgp3PMTYk0MH8O+kW3lNYsvmaQww5Nn3f1jf73Q0jR1Yz3a1CDP4/NZD3vOajWJQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/merge-json-schemas": "^0.2.0",
"ajv": "^8.12.0",
"ajv-formats": "^3.0.1",
"fast-uri": "^3.0.0",
"json-schema-ref-resolver": "^3.0.0",
"rfdc": "^1.2.0"
}
},
"node_modules/fast-querystring": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz",
"integrity": "sha512-g6KuKWmFXc0fID8WWH0jit4g0AGBoJhCkJMb1RmbsSEUNvQ+ZC8D6CUZ+GtF8nMzSPXnhiePyyqqipzNNEnHjg==",
"license": "MIT",
"dependencies": {
"fast-decode-uri-component": "^1.0.1"
}
},
"node_modules/fast-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.2.tgz",
"integrity": "sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/fastify": {
"version": "5.8.5",
"resolved": "https://registry.npmjs.org/fastify/-/fastify-5.8.5.tgz",
"integrity": "sha512-Yqptv59pQzPgQUSIm87hMqHJmdkb1+GPxdE6vW6FRyVE9G86mt7rOghitiU4JHRaTyDUk9pfeKmDeu70lAwM4Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@fastify/ajv-compiler": "^4.0.5",
"@fastify/error": "^4.0.0",
"@fastify/fast-json-stringify-compiler": "^5.0.0",
"@fastify/proxy-addr": "^5.0.0",
"abstract-logging": "^2.0.1",
"avvio": "^9.0.0",
"fast-json-stringify": "^6.0.0",
"find-my-way": "^9.0.0",
"light-my-request": "^6.0.0",
"pino": "^9.14.0 || ^10.1.0",
"process-warning": "^5.0.0",
"rfdc": "^1.3.1",
"secure-json-parse": "^4.0.0",
"semver": "^7.6.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/fastify-plugin": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/fastify-plugin/-/fastify-plugin-5.1.0.tgz",
"integrity": "sha512-FAIDA8eovSt5qcDgcBvDuX/v0Cjz0ohGhENZ/wpc3y+oZCY2afZ9Baqql3g/lC+OHRnciQol4ww7tuthOb9idw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/fastq": {
"version": "1.20.1",
"resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz",
"integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==",
"license": "ISC",
"dependencies": {
"reusify": "^1.0.4"
}
},
"node_modules/find-my-way": {
"version": "9.6.0",
"resolved": "https://registry.npmjs.org/find-my-way/-/find-my-way-9.6.0.tgz",
"integrity": "sha512-Zf4Xve4RymLl7NgaavNebZ01joJ8MfVerOG43wy7SHLO+r+K0C6d/SE0BiR7AV5V1VOCFlOP7ecdo+I4qmiHrQ==",
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"fast-querystring": "^1.0.0",
"safe-regex2": "^5.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/ipaddr.js": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.4.0.tgz",
"integrity": "sha512-9VGk3HGanVE6JoZXHiCpnGy5X0jYDnN4EA4lntFPj+1vIWlFhIylq2CrrCOJH9EAhc5CYhq18F2Av2tgoAPsYQ==",
"license": "MIT",
"engines": {
"node": ">= 10"
}
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/json-schema-ref-resolver": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/json-schema-ref-resolver/-/json-schema-ref-resolver-3.0.0.tgz",
"integrity": "sha512-hOrZIVL5jyYFjzk7+y7n5JDzGlU8rfWDuYyHwGa2WA8/pcmMHezp2xsVwxrebD/Q9t8Nc5DboieySDpCp4WG4A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"dequal": "^2.0.3"
}
},
"node_modules/json-schema-traverse": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz",
"integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==",
"license": "MIT"
},
"node_modules/light-my-request": {
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/light-my-request/-/light-my-request-6.6.0.tgz",
"integrity": "sha512-CHYbu8RtboSIoVsHZ6Ye4cj4Aw/yg2oAFimlF7mNvfDV192LR7nDiKtSIfCuLT7KokPSTn/9kfVLm5OGN0A28A==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause",
"dependencies": {
"cookie": "^1.0.1",
"process-warning": "^4.0.0",
"set-cookie-parser": "^2.6.0"
}
},
"node_modules/light-my-request/node_modules/process-warning": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz",
"integrity": "sha512-3c2LzQ3rY9d0hc1emcsHhfT9Jwz0cChib/QN89oME2R451w5fy3f0afAhERFZAwrbDU43wk12d0ORBpDVME50Q==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/mnemonist": {
"version": "0.40.0",
"resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.40.0.tgz",
"integrity": "sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==",
"license": "MIT",
"dependencies": {
"obliterator": "^2.0.4"
}
},
"node_modules/obliterator": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz",
"integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==",
"license": "MIT"
},
"node_modules/on-exit-leak-free": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz",
"integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==",
"license": "MIT",
"engines": {
"node": ">=14.0.0"
}
},
"node_modules/pino": {
"version": "10.3.1",
"resolved": "https://registry.npmjs.org/pino/-/pino-10.3.1.tgz",
"integrity": "sha512-r34yH/GlQpKZbU1BvFFqOjhISRo1MNx1tWYsYvmj6KIRHSPMT2+yHOEb1SG6NMvRoHRF0a07kCOox/9yakl1vg==",
"license": "MIT",
"dependencies": {
"@pinojs/redact": "^0.4.0",
"atomic-sleep": "^1.0.0",
"on-exit-leak-free": "^2.1.0",
"pino-abstract-transport": "^3.0.0",
"pino-std-serializers": "^7.0.0",
"process-warning": "^5.0.0",
"quick-format-unescaped": "^4.0.3",
"real-require": "^0.2.0",
"safe-stable-stringify": "^2.3.1",
"sonic-boom": "^4.0.1",
"thread-stream": "^4.0.0"
},
"bin": {
"pino": "bin.js"
}
},
"node_modules/pino-abstract-transport": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz",
"integrity": "sha512-wlfUczU+n7Hy/Ha5j9a/gZNy7We5+cXp8YL+X+PG8S0KXxw7n/JXA3c46Y0zQznIJ83URJiwy7Lh56WLokNuxg==",
"license": "MIT",
"dependencies": {
"split2": "^4.0.0"
}
},
"node_modules/pino-std-serializers": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.1.0.tgz",
"integrity": "sha512-BndPH67/JxGExRgiX1dX0w1FvZck5Wa4aal9198SrRhZjH3GxKQUKIBnYJTdj2HDN3UQAS06HlfcSbQj2OHmaw==",
"license": "MIT"
},
"node_modules/process-warning": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz",
"integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT"
},
"node_modules/quick-format-unescaped": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz",
"integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==",
"license": "MIT"
},
"node_modules/real-require": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz",
"integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==",
"license": "MIT",
"engines": {
"node": ">= 12.13.0"
}
},
"node_modules/require-from-string": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz",
"integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ret": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz",
"integrity": "sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/reusify": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz",
"integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==",
"license": "MIT",
"engines": {
"iojs": ">=1.0.0",
"node": ">=0.10.0"
}
},
"node_modules/rfdc": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/rfdc/-/rfdc-1.4.1.tgz",
"integrity": "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==",
"license": "MIT"
},
"node_modules/safe-regex2": {
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/safe-regex2/-/safe-regex2-5.1.1.tgz",
"integrity": "sha512-mOSBvHGDZMuIEZMdOz/aCEYDCv0E7nfcNsIhUF+/P+xC7Hyf3FkvymqgPbg9D1EdSGu+uKbJgy09K/RKKc7kJA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"ret": "~0.5.0"
},
"bin": {
"safe-regex2": "bin/safe-regex2.js"
}
},
"node_modules/safe-stable-stringify": {
"version": "2.5.0",
"resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz",
"integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==",
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/secure-json-parse": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-4.1.0.tgz",
"integrity": "sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "BSD-3-Clause"
},
"node_modules/semver": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz",
"integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==",
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/set-cookie-parser": {
"version": "2.7.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz",
"integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==",
"license": "MIT"
},
"node_modules/sonic-boom": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.1.tgz",
"integrity": "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q==",
"license": "MIT",
"dependencies": {
"atomic-sleep": "^1.0.0"
}
},
"node_modules/split2": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz",
"integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==",
"license": "ISC",
"engines": {
"node": ">= 10.x"
}
},
"node_modules/thread-stream": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-4.1.0.tgz",
"integrity": "sha512-Bw6h2iBDt16v6iHLChBIoVYU8CBo9GPsW8TG7h1hRVhqKhIkH6N8qkxNSmiOZTKsCLPbtWG4ViWLkU6KeKXpig==",
"license": "MIT",
"dependencies": {
"real-require": "^1.0.0"
},
"engines": {
"node": ">=20"
}
},
"node_modules/thread-stream/node_modules/real-require": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/real-require/-/real-require-1.0.0.tgz",
"integrity": "sha512-P4nbQYQfePJxRSmY+v/KINxVucm4NF3p3s7pJveMTtom52FR4YGltUQLB8idDXwDDWW+eYrWDFbuzUnjoWHF7g==",
"license": "MIT"
},
"node_modules/toad-cache": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/toad-cache/-/toad-cache-3.7.0.tgz",
"integrity": "sha512-/m8M+2BJUpoJdgAHoG+baCwBT+tf2VraSfkBgl0Y00qIWt41DJ8R5B8nsEw0I58YwF5IZH6z24/2TobDKnqSWw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
}
}
}

20
ops-agent/package.json Normal file
View file

@ -0,0 +1,20 @@
{
"name": "ops-agent",
"version": "0.1.0",
"private": true,
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "ts-node --esm src/index.ts"
},
"dependencies": {
"@fastify/cors": "^10.0.1",
"fastify": "^5.3.2",
"js-yaml": "^4.1.0"
},
"devDependencies": {
"@types/js-yaml": "^4.0.9",
"@types/node": "^22.15.3",
"typescript": "^5.8.3"
}
}

32
ops-agent/src/auth.ts Normal file
View file

@ -0,0 +1,32 @@
import fs from 'fs';
import { timingSafeEqual } from 'crypto';
import { FastifyRequest, FastifyReply } from 'fastify';
const SECRET_PATH = process.env.OPS_AGENT_SECRET_PATH ?? '/etc/ops-agent/secret';
let secretBuf: Buffer | null = null;
export function loadSecret(): void {
if (!fs.existsSync(SECRET_PATH)) {
console.warn(`[auth] Secret file not found at ${SECRET_PATH} — auth disabled`);
return;
}
const stat = fs.statSync(SECRET_PATH);
if ((stat.mode & 0o177) !== 0) {
console.warn(`[auth] Warning: ${SECRET_PATH} has loose permissions — expected 0640`);
}
secretBuf = Buffer.from(fs.readFileSync(SECRET_PATH, 'utf8').trim());
}
export async function authHook(req: FastifyRequest, reply: FastifyReply): Promise<void> {
if (secretBuf === null) return; // auth disabled
const header = req.headers['authorization'] ?? '';
const token = header.startsWith('Bearer ') ? header.slice(7) : '';
const tokenBuf = Buffer.from(token);
const valid =
tokenBuf.length === secretBuf.length && timingSafeEqual(tokenBuf, secretBuf);
if (!valid) {
await reply.status(401).send({ error: 'unauthorized' });
}
}

35
ops-agent/src/index.ts Normal file
View file

@ -0,0 +1,35 @@
import Fastify from 'fastify';
import cors from '@fastify/cors';
import { loadWhitelist } from './whitelist.js';
import { loadSecret, authHook } from './auth.js';
import { healthRoutes } from './routes/health.js';
import { execRoutes } from './routes/exec.js';
import { makeFlowRoutes } from './routes/flow.js';
const WHITELIST_PATH = process.env.OPS_AGENT_WHITELIST_PATH ?? '/etc/ops-agent/commands.yml';
const FLOWS_PATH = process.env.OPS_AGENT_FLOWS_PATH ?? '/etc/ops-agent/flows';
const PORT = parseInt(process.env.OPS_AGENT_PORT ?? '3099', 10);
const HOST = process.env.OPS_AGENT_HOST ?? '127.0.0.1';
async function main() {
loadWhitelist(WHITELIST_PATH);
loadSecret();
const app = Fastify({ logger: true });
await app.register(cors, { origin: false });
app.addHook('onRequest', authHook);
await app.register(healthRoutes);
await app.register(execRoutes);
await app.register(makeFlowRoutes(FLOWS_PATH));
await app.listen({ port: PORT, host: HOST });
console.log(`ops-agent listening on ${HOST}:${PORT}`);
}
main().catch((err) => {
console.error(err);
process.exit(1);
});

View file

@ -0,0 +1,168 @@
import fs from 'fs';
import path from 'path';
import { spawn } from 'child_process';
import yaml from 'js-yaml';
import { getCommand, validateArgs, validateCwd } from '../whitelist.js';
export interface FlowStepDef {
command_key: string;
args?: string[];
on_failure?: 'abort' | 'continue';
}
export interface FlowDef {
name: string;
description?: string;
steps: FlowStepDef[];
}
export type SendEvent = (event: string, data: unknown) => void;
export function loadFlow(flowsDir: string, flowKey: string): FlowDef {
const filePath = path.join(flowsDir, `${flowKey}.yml`);
if (!fs.existsSync(filePath)) {
throw new Error(`flow '${flowKey}' not found`);
}
const raw = fs.readFileSync(filePath, 'utf8');
const parsed = yaml.load(raw) as FlowDef;
if (!parsed?.steps || !Array.isArray(parsed.steps) || parsed.steps.length === 0) {
throw new Error(`flow '${flowKey}' has no steps`);
}
return parsed;
}
export function listFlowKeys(flowsDir: string): string[] {
if (!fs.existsSync(flowsDir)) return [];
return fs
.readdirSync(flowsDir)
.filter((f) => f.endsWith('.yml'))
.map((f) => f.slice(0, -4));
}
/**
* Runs a named flow from flowsDir, emitting SSE-style events via sendEvent.
* Returns the final exit code (0 = success).
*/
export async function runFlow(
flowsDir: string,
flowKey: string,
dryRun: boolean,
sendEvent: SendEvent,
): Promise<number> {
let flow: FlowDef;
try {
flow = loadFlow(flowsDir, flowKey);
} catch (err) {
sendEvent('error', { message: (err as Error).message });
return 1;
}
const totalSteps = flow.steps.length;
for (let i = 0; i < totalSteps; i++) {
const step = flow.steps[i];
const { command_key, args = [], on_failure = 'abort' } = step;
const def = getCommand(command_key);
if (!def) {
sendEvent('error', {
message: `step ${i}: command_key '${command_key}' is not in the whitelist`,
});
return 1;
}
const cwdError = validateCwd(def, args);
if (cwdError) {
sendEvent('error', { message: `step ${i}: ${cwdError}` });
return 1;
}
const argError = validateArgs(def, args);
if (argError) {
sendEvent('error', { message: `step ${i}: ${argError}` });
return 1;
}
sendEvent('step_start', {
step_index: i,
total_steps: totalSteps,
command_key,
args,
});
const cwd = def.cwd_pattern ? args[0] : def.cwd;
const [bin, ...staticArgs] = def.cmd;
const effectiveArgs = def.cwd_pattern ? args.slice(1) : args;
if (dryRun) {
const fullCmd = [...def.cmd, ...effectiveArgs].join(' ');
const cwdNote = cwd ? ` (cwd: ${cwd})` : '';
sendEvent('stdout', { data: `WOULD RUN: ${fullCmd}${cwdNote}\n` });
sendEvent('step_done', { step_index: i, exit_code: 0 });
continue;
}
// Check preconditions before executing
if (def.preconditions) {
for (const pre of def.preconditions) {
if (pre === 'git_status_clean') {
const clean = await checkGitStatusClean(cwd);
if (!clean) {
sendEvent('stderr', {
data: `precondition 'git_status_clean' failed: working tree is not clean\n`,
});
sendEvent('step_done', { step_index: i, exit_code: 1 });
sendEvent('done', { exit_code: 1 });
return 1;
}
}
}
}
const exitCode = await spawnStep(bin, [...staticArgs, ...effectiveArgs], cwd, sendEvent);
sendEvent('step_done', { step_index: i, exit_code: exitCode });
if (exitCode !== 0 && on_failure === 'abort') {
sendEvent('done', { exit_code: exitCode });
return exitCode;
}
}
sendEvent('done', { exit_code: 0 });
return 0;
}
function spawnStep(
bin: string,
args: string[],
cwd: string | undefined,
sendEvent: SendEvent,
): Promise<number> {
return new Promise((resolve) => {
const child = spawn(bin, args, { shell: false, cwd });
child.stdout.on('data', (chunk: Buffer) => {
sendEvent('stdout', { data: chunk.toString() });
});
child.stderr.on('data', (chunk: Buffer) => {
sendEvent('stderr', { data: chunk.toString() });
});
child.on('close', (code) => resolve(code ?? 1));
child.on('error', (err) => {
sendEvent('stderr', { data: `spawn error: ${err.message}\n` });
resolve(1);
});
});
}
function checkGitStatusClean(cwd: string | undefined): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('git', ['status', '--porcelain'], { shell: false, cwd });
let output = '';
child.stdout.on('data', (chunk: Buffer) => {
output += chunk.toString();
});
child.on('close', () => resolve(output.trim() === ''));
child.on('error', () => resolve(false));
});
}

View file

@ -0,0 +1,127 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { spawn } from 'child_process';
import { getCommand, validateArgs, validateCwd } from '../whitelist.js';
interface ExecBody {
command_key: string;
args?: string[];
/** Config content piped to stdin for commands with stdin_from_body: true */
stdin?: string;
}
function checkGitStatusClean(cwd: string | undefined): Promise<boolean> {
return new Promise((resolve) => {
const child = spawn('git', ['status', '--porcelain'], { shell: false, cwd });
let output = '';
child.stdout.on('data', (chunk: Buffer) => {
output += chunk.toString();
});
child.on('close', () => resolve(output.trim() === ''));
child.on('error', () => resolve(false));
});
}
function auditLog(
command_key: string,
args: string[],
exit_code: number | null,
duration_ms: number,
): void {
// systemd captures stdout/stderr into the journal
console.log(
JSON.stringify({
audit: true,
command_key,
args,
exit_code,
duration_ms,
}),
);
}
export async function execRoutes(app: FastifyInstance): Promise<void> {
app.post('/agent/v1/exec', async (req: FastifyRequest<{ Body: ExecBody }>, reply) => {
const { command_key, args = [], stdin } = req.body;
const def = getCommand(command_key);
if (!def) {
return reply
.status(403)
.send({ error: `command_key '${command_key}' is not in the whitelist` });
}
const cwdError = validateCwd(def, args);
if (cwdError) {
return reply.status(400).send({ error: cwdError });
}
const argError = validateArgs(def, args);
if (argError) {
return reply.status(400).send({ error: argError });
}
const cwd = def.cwd_pattern ? args[0] : def.cwd;
if (def.preconditions) {
for (const precondition of def.preconditions) {
if (precondition === 'git_status_clean') {
const clean = await checkGitStatusClean(cwd);
if (!clean) {
return reply.status(409).send({
error: 'working tree is not clean; commit or stash changes before pulling',
});
}
}
}
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const sendEvent = (event: string, data: string) => {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify({ data })}\n\n`);
};
const [bin, ...staticArgs] = def.cmd;
const effectiveArgs = def.cwd_pattern ? args.slice(1) : args;
// shell: false ensures no shell interpolation — args are passed as-is (execFile semantics)
const child = spawn(bin, [...staticArgs, ...effectiveArgs], {
shell: false,
cwd,
});
if (def.stdin_from_body && stdin != null) {
child.stdin!.write(stdin, 'utf8');
}
child.stdin!.end();
const startedAt = Date.now();
child.stdout.on('data', (chunk: Buffer) => {
sendEvent('stdout', chunk.toString());
});
child.stderr.on('data', (chunk: Buffer) => {
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();
});
});
}

View file

@ -0,0 +1,37 @@
import { FastifyInstance, FastifyRequest } from 'fastify';
import { runFlow } from '../lib/flow-runner.js';
interface FlowBody {
flow_key: string;
dry_run?: boolean;
}
export function makeFlowRoutes(flowsDir: string) {
return async function flowRoutes(app: FastifyInstance): Promise<void> {
app.post('/agent/v1/flow', async (req: FastifyRequest<{ Body: FlowBody }>, reply) => {
const { flow_key, dry_run = false } = req.body ?? {};
if (!flow_key) {
return reply.status(400).send({ error: 'flow_key required' });
}
reply.raw.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const sendEvent = (event: string, data: unknown) => {
reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`);
};
req.raw.on('close', () => {
// client disconnected — runFlow will still complete the current step
// but we won't write any more events after the socket closes
});
await runFlow(flowsDir, flow_key, dry_run, sendEvent);
reply.raw.end();
});
};
}

View file

@ -0,0 +1,7 @@
import { FastifyInstance } from 'fastify';
export async function healthRoutes(app: FastifyInstance): Promise<void> {
app.get('/agent/v1/health', async (_req, reply) => {
return reply.send({ status: 'ok', uptime: process.uptime() });
});
}

View file

@ -0,0 +1,80 @@
import fs from 'fs';
import yaml from 'js-yaml';
export interface ArgsConfig {
allowed?: string[];
}
export interface CommandDef {
cmd: string[];
cwd?: string;
/** Required path prefix for dynamic cwd: caller passes repo path as first arg */
cwd_pattern?: string;
args?: ArgsConfig;
description?: string;
/** Named preconditions that must pass before the command runs (e.g. 'git_status_clean') */
preconditions?: string[];
/** When true, the caller's 'stdin' body field is piped to the child process */
stdin_from_body?: boolean;
}
export type Whitelist = Record<string, CommandDef>;
let whitelist: Whitelist = {};
export function loadWhitelist(path: string): void {
const raw = fs.readFileSync(path, 'utf8');
const parsed = yaml.load(raw) as { commands: Whitelist };
whitelist = parsed.commands ?? {};
for (const [key, def] of Object.entries(whitelist)) {
if (!Array.isArray(def.cmd) || def.cmd.length === 0) {
throw new Error(`commands.yml: '${key}' must have a non-empty 'cmd' array`);
}
}
}
export function getCommand(key: string): CommandDef | undefined {
return whitelist[key];
}
export function listCommands(): string[] {
return Object.keys(whitelist);
}
/**
* Validates the dynamic cwd arg when cwd_pattern is set.
* Returns an error string if validation fails, null on success.
*/
export function validateCwd(def: CommandDef, requestArgs: string[]): string | null {
if (!def.cwd_pattern) return null;
if (requestArgs.length === 0) return `command requires a repo path as first argument`;
if (!requestArgs[0].startsWith(def.cwd_pattern)) {
return `repo path must start with '${def.cwd_pattern}'`;
}
return null;
}
/**
* Validates request args against a command's allowed list.
* When cwd_pattern is set, skips the first arg (used as cwd).
* Returns an error string if validation fails, null on success.
*/
export function validateArgs(def: CommandDef, requestArgs: string[]): string | null {
const args = def.cwd_pattern ? requestArgs.slice(1) : requestArgs;
const allowed = def.args?.allowed;
if (args.length === 0) return null;
if (!allowed || allowed.length === 0) {
return `command does not accept arguments`;
}
for (const arg of args) {
if (!allowed.includes(arg)) {
return `argument '${arg}' is not in the allowed list`;
}
}
return null;
}

15
ops-agent/tsconfig.json Normal file
View file

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"resolveJsonModule": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}

7722
package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

43
package.json Normal file
View file

@ -0,0 +1,43 @@
{
"name": "cmp45dacr000y347rsdhhny6u",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"db:seed": "ts-node --esm prisma/seed.ts"
},
"prisma": {
"seed": "ts-node --esm prisma/seed.ts"
},
"dependencies": {
"@base-ui/react": "^1.4.1",
"@prisma/adapter-pg": "^7.8.0",
"@prisma/client": "^7.8.0",
"@types/bcryptjs": "^2.4.6",
"@types/pg": "^8.20.0",
"bcryptjs": "^3.0.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.14.0",
"next": "16.2.6",
"pg": "^8.20.0",
"prisma": "^7.8.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"shadcn": "^4.7.0",
"shiki": "^1.29.2",
"tailwind-merge": "^3.6.0",
"tw-animate-css": "^1.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"tailwindcss": "^4",
"ts-node": "^10.9.2",
"typescript": "^5"
}
}

7
postcss.config.mjs Normal file
View file

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

10
prisma.config.ts Normal file
View file

@ -0,0 +1,10 @@
import { defineConfig, env } from 'prisma/config'
export default defineConfig({
migrations: {
seed: 'ts-node --esm prisma/seed.ts',
},
datasource: {
url: env('DATABASE_URL'),
},
})

View file

@ -0,0 +1,67 @@
-- CreateEnum
CREATE TYPE "FlowStatus" AS ENUM ('pending', 'running', 'success', 'failed', 'cancelled');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"pwd_hash" TEXT NOT NULL,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"token_hash" TEXT NOT NULL,
"expires_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FlowRun" (
"id" TEXT NOT NULL,
"user_id" TEXT NOT NULL,
"flow_key" TEXT NOT NULL,
"status" "FlowStatus" NOT NULL DEFAULT 'pending',
"dry_run" BOOLEAN NOT NULL DEFAULT false,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ended_at" TIMESTAMP(3),
"exit_code" INTEGER,
CONSTRAINT "FlowRun_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "FlowStep" (
"id" TEXT NOT NULL,
"flow_run_id" TEXT NOT NULL,
"step_index" INTEGER NOT NULL,
"command_key" TEXT NOT NULL,
"args_json" TEXT,
"stdout" TEXT,
"stderr" TEXT,
"exit_code" INTEGER,
"started_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"ended_at" TIMESTAMP(3),
CONSTRAINT "FlowStep_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Session_token_hash_key" ON "Session"("token_hash");
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FlowRun" ADD CONSTRAINT "FlowRun_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "FlowStep" ADD CONSTRAINT "FlowStep_flow_run_id_fkey" FOREIGN KEY ("flow_run_id") REFERENCES "FlowRun"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -0,0 +1,2 @@
-- CreateIndex
CREATE INDEX "FlowRun_user_id_started_at_idx" ON "FlowRun"("user_id", "started_at" DESC);

View file

@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

61
prisma/schema.prisma Normal file
View file

@ -0,0 +1,61 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum FlowStatus {
pending
running
success
failed
cancelled
}
model User {
id String @id @default(cuid())
email String @unique
pwd_hash String
created_at DateTime @default(now())
sessions Session[]
flow_runs FlowRun[]
}
model Session {
id String @id @default(cuid())
user_id String
token_hash String @unique
expires_at DateTime
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
}
model FlowRun {
id String @id @default(cuid())
user_id String
flow_key String
status FlowStatus @default(pending)
dry_run Boolean @default(false)
started_at DateTime @default(now())
ended_at DateTime?
exit_code Int?
user User @relation(fields: [user_id], references: [id], onDelete: Cascade)
steps FlowStep[]
@@index([user_id, started_at(sort: Desc)])
}
model FlowStep {
id String @id @default(cuid())
flow_run_id String
step_index Int
command_key String
args_json String?
stdout String?
stderr String?
exit_code Int?
started_at DateTime @default(now())
ended_at DateTime?
flow_run FlowRun @relation(fields: [flow_run_id], references: [id], onDelete: Cascade)
}

35
prisma/seed.ts Normal file
View file

@ -0,0 +1,35 @@
import { PrismaClient } from '@prisma/client'
import { PrismaPg } from '@prisma/adapter-pg'
import bcrypt from 'bcryptjs'
const connectionString = process.env.DATABASE_URL
if (!connectionString) throw new Error('DATABASE_URL is required')
const adapter = new PrismaPg({ connectionString })
const prisma = new PrismaClient({ adapter })
async function main() {
const email = process.env.SEED_USER_EMAIL
const password = process.env.SEED_USER_PASSWORD
if (!email || !password) {
throw new Error('SEED_USER_EMAIL and SEED_USER_PASSWORD env vars are required')
}
const pwd_hash = await bcrypt.hash(password, 12)
const user = await prisma.user.upsert({
where: { email },
update: { pwd_hash },
create: { email, pwd_hash },
})
console.log(`Seeded user: ${user.email} (id: ${user.id})`)
}
main()
.catch((e) => {
console.error(e)
process.exit(1)
})
.finally(() => prisma.$disconnect())

23
proxy.ts Normal file
View file

@ -0,0 +1,23 @@
import { NextRequest, NextResponse } from 'next/server'
const PUBLIC_PATHS = ['/login']
export default function proxy(request: NextRequest) {
const { pathname } = request.nextUrl
const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p))
const hasSession = request.cookies.has('ops_session')
if (!isPublic && !hasSession) {
return NextResponse.redirect(new URL('/login', request.url))
}
if (isPublic && hasSession) {
return NextResponse.redirect(new URL('/', request.url))
}
return NextResponse.next()
}
export const config = {
matcher: ['/((?!api|_next/static|_next/image|.*\\.(?:png|ico|svg)$).*)'],
}

1
public/file.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

1
public/globe.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1 KiB

1
public/next.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

1
public/vercel.svg Normal file
View file

@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

Some files were not shown because too many files have changed in this diff Show more