Merge pull request #1 from madhura68/feat/sprint-sjg11oxq
Sprint: Ops dashboard
This commit is contained in:
commit
c147870456
102 changed files with 14758 additions and 1 deletions
8
.dockerignore
Normal file
8
.dockerignore
Normal file
|
|
@ -0,0 +1,8 @@
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
node_modules
|
||||||
|
.next
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
README.md
|
||||||
9
.env.example
Normal file
9
.env.example
Normal 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
43
.gitignore
vendored
Normal 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
5
AGENTS.md
Normal 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
1
CLAUDE.md
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
@AGENTS.md
|
||||||
29
Dockerfile
Normal file
29
Dockerfile
Normal 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
104
README.md
|
|
@ -2,4 +2,106 @@
|
||||||
|
|
||||||
Single-user ops dashboard voor jp-visser.nl.
|
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.
|
||||||
|
|
|
||||||
46
app/api/agent/[...path]/route.ts
Normal file
46
app/api/agent/[...path]/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
62
app/api/auth/login/route.ts
Normal file
62
app/api/auth/login/route.ts
Normal 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
|
||||||
|
}
|
||||||
16
app/api/auth/logout/route.ts
Normal file
16
app/api/auth/logout/route.ts
Normal 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
197
app/api/flows/run/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
185
app/api/flows/start/route.ts
Normal file
185
app/api/flows/start/route.ts
Normal 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',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
125
app/audit/[flow_run_id]/page.tsx
Normal file
125
app/audit/[flow_run_id]/page.tsx
Normal 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
100
app/audit/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
214
app/caddy/_components/caddy-editor.tsx
Normal file
214
app/caddy/_components/caddy-editor.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
166
app/caddy/_components/caddy-view.tsx
Normal file
166
app/caddy/_components/caddy-view.tsx
Normal 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
43
app/caddy/edit/page.tsx
Normal 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
70
app/caddy/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
34
app/docker/[name]/page.tsx
Normal file
34
app/docker/[name]/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
246
app/docker/_components/docker-table.tsx
Normal file
246
app/docker/_components/docker-table.tsx
Normal 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
34
app/docker/page.tsx
Normal 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
BIN
app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
262
app/flows/update-caddy-config/_components/flow-panel.tsx
Normal file
262
app/flows/update-caddy-config/_components/flow-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
app/flows/update-caddy-config/page.tsx
Normal file
36
app/flows/update-caddy-config/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
128
app/flows/update-scrum4me-web/_components/flow-panel.tsx
Normal file
128
app/flows/update-scrum4me-web/_components/flow-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
27
app/flows/update-scrum4me-web/page.tsx
Normal file
27
app/flows/update-scrum4me-web/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
142
app/git/[repo]/_components/diff-viewer.tsx
Normal file
142
app/git/[repo]/_components/diff-viewer.tsx
Normal 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
89
app/git/[repo]/page.tsx
Normal 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 "{repoName}" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
261
app/git/_components/git-repos-list.tsx
Normal file
261
app/git/_components/git-repos-list.tsx
Normal 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
54
app/git/page.tsx
Normal 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
130
app/globals.css
Normal 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
33
app/layout.tsx
Normal 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
98
app/login/page.tsx
Normal 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
65
app/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
172
app/settings/backups/_components/backups-panel.tsx
Normal file
172
app/settings/backups/_components/backups-panel.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
59
app/settings/backups/page.tsx
Normal file
59
app/settings/backups/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
163
app/systemd/[unit]/_components/unit-detail.tsx
Normal file
163
app/systemd/[unit]/_components/unit-detail.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
app/systemd/[unit]/page.tsx
Normal file
78
app/systemd/[unit]/page.tsx
Normal 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 "{unitName}" 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
244
app/systemd/_components/systemd-units-list.tsx
Normal file
244
app/systemd/_components/systemd-units-list.tsx
Normal 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
50
app/systemd/page.tsx
Normal 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
25
components.json
Normal 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": {}
|
||||||
|
}
|
||||||
62
components/ConfirmDialog.tsx
Normal file
62
components/ConfirmDialog.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
93
components/StreamingTerminal.tsx
Normal file
93
components/StreamingTerminal.tsx
Normal 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
58
components/ui/button.tsx
Normal 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 }
|
||||||
7
deploy/caddy/Caddyfile.ops-dashboard
Normal file
7
deploy/caddy/Caddyfile.ops-dashboard
Normal 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
|
||||||
|
}
|
||||||
19
deploy/docker-compose.ops-dashboard.yml
Normal file
19
deploy/docker-compose.ops-dashboard.yml
Normal 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
|
||||||
23
deploy/ops-agent/ops-agent.service
Normal file
23
deploy/ops-agent/ops-agent.service
Normal 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
|
||||||
20
deploy/ops-agent/ops-db-backup.service
Normal file
20
deploy/ops-agent/ops-db-backup.service
Normal 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
|
||||||
10
deploy/ops-agent/ops-db-backup.timer
Normal file
10
deploy/ops-agent/ops-db-backup.timer
Normal 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
58
deploy/ops-agent/setup.sh
Normal 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
9
deploy/ops-agent/sudoers
Normal 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
|
||||||
33
deploy/ops-dashboard-updater/install.sh
Normal file
33
deploy/ops-dashboard-updater/install.sh
Normal 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"
|
||||||
14
deploy/ops-dashboard-updater/ops-dashboard-updater.service
Normal file
14
deploy/ops-dashboard-updater/ops-dashboard-updater.service
Normal 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
|
||||||
11
deploy/ops-dashboard-updater/ops-dashboard-updater.timer
Normal file
11
deploy/ops-dashboard-updater/ops-dashboard-updater.timer
Normal 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
|
||||||
56
deploy/ops-dashboard-updater/update.sh
Normal file
56
deploy/ops-dashboard-updater/update.sh
Normal 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."
|
||||||
8
deploy/ops-dashboard.env.example
Normal file
8
deploy/ops-dashboard.env.example
Normal 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
173
docs/runbooks/recovery.md
Normal 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
168
hooks/useFlowRun.ts
Normal 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
58
lib/agent-client.ts
Normal 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
21
lib/csrf.ts
Normal 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
54
lib/parse-caddy.ts
Normal 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
40
lib/parse-docker.ts
Normal 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
46
lib/parse-git.ts
Normal 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
34
lib/parse-systemd.ts
Normal 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
17
lib/prisma.ts
Normal 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
52
lib/session.ts
Normal 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
6
lib/utils.ts
Normal 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
55
middleware.ts
Normal 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
7
next.config.ts
Normal 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
2
ops-agent/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
238
ops-agent/commands.yml.example
Normal file
238
ops-agent/commands.yml.example
Normal 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"
|
||||||
22
ops-agent/flows.example/backup_ops_db.yml
Normal file
22
ops-agent/flows.example/backup_ops_db.yml
Normal 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
|
||||||
43
ops-agent/flows.example/update_caddy_config.yml
Normal file
43
ops-agent/flows.example/update_caddy_config.yml
Normal 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
|
||||||
27
ops-agent/flows.example/update_caddy_config_force.yml
Normal file
27
ops-agent/flows.example/update_caddy_config_force.yml
Normal 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
|
||||||
36
ops-agent/flows.example/update_mcp_worker.yml
Normal file
36
ops-agent/flows.example/update_mcp_worker.yml
Normal 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
|
||||||
48
ops-agent/flows.example/update_scrum4me_web.yml
Normal file
48
ops-agent/flows.example/update_scrum4me_web.yml
Normal 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
745
ops-agent/package-lock.json
generated
Normal 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
20
ops-agent/package.json
Normal 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
32
ops-agent/src/auth.ts
Normal 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
35
ops-agent/src/index.ts
Normal 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);
|
||||||
|
});
|
||||||
168
ops-agent/src/lib/flow-runner.ts
Normal file
168
ops-agent/src/lib/flow-runner.ts
Normal 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));
|
||||||
|
});
|
||||||
|
}
|
||||||
127
ops-agent/src/routes/exec.ts
Normal file
127
ops-agent/src/routes/exec.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
37
ops-agent/src/routes/flow.ts
Normal file
37
ops-agent/src/routes/flow.ts
Normal 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();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
7
ops-agent/src/routes/health.ts
Normal file
7
ops-agent/src/routes/health.ts
Normal 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() });
|
||||||
|
});
|
||||||
|
}
|
||||||
80
ops-agent/src/whitelist.ts
Normal file
80
ops-agent/src/whitelist.ts
Normal 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
15
ops-agent/tsconfig.json
Normal 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
7722
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
43
package.json
Normal file
43
package.json
Normal 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
7
postcss.config.mjs
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
"@tailwindcss/postcss": {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
10
prisma.config.ts
Normal file
10
prisma.config.ts
Normal 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'),
|
||||||
|
},
|
||||||
|
})
|
||||||
67
prisma/migrations/20260513150226_init/migration.sql
Normal file
67
prisma/migrations/20260513150226_init/migration.sql
Normal 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;
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- CreateIndex
|
||||||
|
CREATE INDEX "FlowRun_user_id_started_at_idx" ON "FlowRun"("user_id", "started_at" DESC);
|
||||||
3
prisma/migrations/migration_lock.toml
Normal file
3
prisma/migrations/migration_lock.toml
Normal 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
61
prisma/schema.prisma
Normal 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
35
prisma/seed.ts
Normal 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
23
proxy.ts
Normal 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
1
public/file.svg
Normal 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
1
public/globe.svg
Normal 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
1
public/next.svg
Normal 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
1
public/vercel.svg
Normal 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
Loading…
Add table
Add a link
Reference in a new issue