Merge pull request #4 from madhura68/feat/sprint-r8sv4zh0

Sprint: 23
This commit is contained in:
Janpeter Visser 2026-05-13 20:22:31 +00:00 committed by GitHub
commit eea3c4b993
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 608 additions and 28 deletions

View file

@ -0,0 +1,69 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { apiFetch } from '@/lib/csrf'
import { relativeTime } from '@/lib/utils'
type LatestRun = { id: string; flow_key: string; status: string; started_at: string }
export type AuditInitial =
| { data: LatestRun | null; error: null }
| { data: null; error: string }
const STATUS_STYLES: Record<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',
}
async function fetchLatestRun(): Promise<LatestRun | null> {
const res = await apiFetch('/api/audit/latest')
if (!res.ok) throw new Error(`${res.status}`)
const json = (await res.json()) as { run: LatestRun | null }
return json.run
}
export default function AuditWidget({ initial }: { initial: AuditInitial }) {
const [data, setData] = useState<LatestRun | null>(initial.data)
const [error, setError] = useState<string | null>(initial.error)
const refresh = useCallback(async () => {
try {
const run = await fetchLatestRun()
setData(run)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh])
return (
<Link href="/audit" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Audit</h2>
{error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<div className="mt-2 space-y-1">
<div className="flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${STATUS_STYLES[data.status] ?? ''}`}
>
{data.status}
</span>
<span className="text-xs text-muted-foreground">{relativeTime(new Date(data.started_at))}</span>
</div>
<p className="font-mono text-xs text-muted-foreground truncate">{data.flow_key}</p>
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground">geen runs</p>
)}
</Link>
)
}

View file

@ -0,0 +1,76 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseCertList } from '@/lib/parse-caddy'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type CaddyData = { soonestExpiryMs: number | null; count: number; expiringWarning: boolean }
export type CaddyInitial = { data: CaddyData; error: null } | { data: null; error: string }
async function refreshCaddy(): Promise<CaddyData> {
const output = await fetchAgentOutput('caddy_list_certs')
const certs = parseCertList(output)
const expiryTimes = certs
.filter((c) => c.notAfter)
.map((c) => new Date(c.notAfter).getTime())
const soonestExpiryMs = expiryTimes.length > 0 ? Math.min(...expiryTimes) : null
const expiringWarning = certs.some((c) => c.expiringWarning)
return { soonestExpiryMs, count: certs.length, expiringWarning }
}
function daysUntil(ms: number): number {
return Math.floor((ms - Date.now()) / (1000 * 60 * 60 * 24))
}
export default function CaddyWidget({ initial }: { initial: CaddyInitial }) {
const [data, setData] = useState<CaddyData | null>(initial.data)
const [error, setError] = useState<string | null>(initial.error)
const refresh = useCallback(async () => {
try {
const d = await refreshCaddy()
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh])
return (
<Link href="/caddy" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Caddy / TLS</h2>
{error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<div className="mt-2">
{data.soonestExpiryMs !== null ? (
<div className="flex items-center gap-2">
<p className="text-2xl font-semibold">
{daysUntil(data.soonestExpiryMs)}
<span className="text-sm font-normal text-muted-foreground"> dagen tot expiry</span>
</p>
{data.expiringWarning && (
<span className="inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400">
&lt;30d
</span>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">no certs</p>
)}
<p className="mt-1 text-xs text-muted-foreground">
{data.count} cert{data.count !== 1 ? 's' : ''}
</p>
</div>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}

View file

@ -0,0 +1,54 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseDockerPs } from '@/lib/parse-docker'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type DockerData = { running: number; total: number }
export type DockerInitial = { data: DockerData; error: null } | { data: null; error: string }
async function refreshDocker(): Promise<DockerData> {
const output = await fetchAgentOutput('docker_ps')
const containers = parseDockerPs(output)
return {
running: containers.filter((c) => c.status.toLowerCase().startsWith('up')).length,
total: containers.length,
}
}
export default function DockerWidget({ initial }: { initial: DockerInitial }) {
const [data, setData] = useState<DockerData | null>(initial.data)
const [error, setError] = useState<string | null>(initial.error)
const refresh = useCallback(async () => {
try {
const d = await refreshDocker()
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [])
useEffect(() => {
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh])
return (
<Link href="/docker" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Docker</h2>
{error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<p className="mt-2 text-2xl font-semibold">
{data.running}
<span className="text-sm font-normal text-muted-foreground"> / {data.total} running</span>
</p>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}

View file

@ -0,0 +1,77 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseGitStatus } from '@/lib/parse-git'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type GitData = { dirty: number; total: number }
export type GitInitial =
| { configured: false }
| { data: GitData; error: null }
| { data: null; error: string }
async function refreshGit(repos: string[]): Promise<GitData> {
const results = await Promise.allSettled(
repos.map(async (path) => {
const output = await fetchAgentOutput('git_status', [path])
return parseGitStatus(output)
}),
)
const dirty = results.filter(
(r) => r.status === 'fulfilled' && r.value.dirty,
).length
return { dirty, total: repos.length }
}
export default function GitWidget({ initial, repos }: { initial: GitInitial; repos: string[] }) {
const notConfigured = 'configured' in initial && initial.configured === false
const [data, setData] = useState<GitData | null>(
!notConfigured && 'data' in initial ? initial.data : null,
)
const [error, setError] = useState<string | null>(
!notConfigured && 'error' in initial ? initial.error : null,
)
const refresh = useCallback(async () => {
if (notConfigured || repos.length === 0) return
try {
const d = await refreshGit(repos)
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [notConfigured, repos])
useEffect(() => {
if (notConfigured) return
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh, notConfigured])
return (
<Link href="/git" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">Git</h2>
{notConfigured ? (
<p className="mt-2 text-sm text-muted-foreground">niet geconfigureerd</p>
) : error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<p
className={[
'mt-2 text-2xl font-semibold',
data.dirty === 0 ? 'text-green-600' : 'text-orange-500',
].join(' ')}
>
{data.dirty}/{data.total}
<span className="text-sm font-normal text-muted-foreground">
{' '}repos uncommitted
</span>
</p>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}

View file

@ -0,0 +1,79 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseSystemctlStatus } from '@/lib/parse-systemd'
import { fetchAgentOutput } from '@/lib/agent-fetch'
type SystemdData = { healthy: number; total: number }
export type SystemdInitial =
| { configured: false }
| { data: SystemdData; error: null }
| { data: null; error: string }
async function refreshSystemd(units: string[]): Promise<SystemdData> {
const results = await Promise.allSettled(
units.map(async (unit) => {
const output = await fetchAgentOutput('systemctl_status', [unit])
return parseSystemctlStatus(output, unit)
}),
)
const healthy = results.filter(
(r) => r.status === 'fulfilled' && r.value.activeState === 'active',
).length
return { healthy, total: units.length }
}
export default function SystemdWidget({ initial, units }: { initial: SystemdInitial; units: string[] }) {
const notConfigured = 'configured' in initial && initial.configured === false
const [data, setData] = useState<SystemdData | null>(
!notConfigured && 'data' in initial ? initial.data : null,
)
const [error, setError] = useState<string | null>(
!notConfigured && 'error' in initial ? initial.error : null,
)
const refresh = useCallback(async () => {
if (notConfigured || units.length === 0) return
try {
const d = await refreshSystemd(units)
setData(d)
setError(null)
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
}
}, [notConfigured, units])
useEffect(() => {
if (notConfigured) return
const id = setInterval(refresh, 30_000)
return () => clearInterval(id)
}, [refresh, notConfigured])
return (
<Link href="/systemd" className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent">
<h2 className="text-sm font-medium text-muted-foreground">systemd</h2>
{notConfigured ? (
<p className="mt-2 text-sm text-muted-foreground">niet geconfigureerd</p>
) : error ? (
<p className="mt-2 text-sm text-destructive truncate">{error}</p>
) : data ? (
<p
className={[
'mt-2 text-2xl font-semibold',
data.total > 0 && data.healthy === data.total
? 'text-green-600'
: data.healthy > 0
? 'text-orange-500'
: 'text-destructive',
].join(' ')}
>
{data.healthy}/{data.total}
<span className="text-sm font-normal text-muted-foreground"> healthy</span>
</p>
) : (
<p className="mt-2 text-sm text-muted-foreground"></p>
)}
</Link>
)
}

View file

@ -0,0 +1,20 @@
import { NextResponse } from 'next/server'
import { getCurrentUser } from '@/lib/session'
import { prisma } from '@/lib/prisma'
export const dynamic = 'force-dynamic'
export async function GET() {
const user = await getCurrentUser()
if (!user) return NextResponse.json({ error: 'unauthorized' }, { status: 401 })
const run = await prisma.flowRun.findFirst({
where: { user_id: user.id },
orderBy: { started_at: 'desc' },
select: { id: true, flow_key: true, status: true, started_at: true },
})
return NextResponse.json({
run: run ? { ...run, started_at: run.started_at.toISOString() } : null,
})
}

View file

@ -1,6 +1,7 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import AppNav from "@/components/AppNav";
const geistSans = Geist({
variable: "--font-geist-sans",
@ -13,8 +14,8 @@ const geistMono = Geist_Mono({
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: "Ops Dashboard",
description: "Live overzicht en bediening van Docker, systemd, Caddy en deploys.",
};
export default function RootLayout({
@ -27,7 +28,10 @@ export default function RootLayout({
lang="en"
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">{children}</body>
<body className="min-h-full flex flex-col">
<AppNav />
<main className="flex-1">{children}</main>
</body>
</html>
);
}

View file

@ -1,23 +1,127 @@
import Link from 'next/link'
import { redirect } from 'next/navigation'
import { getCurrentUser } from '@/lib/session'
import { execAgent } from '@/lib/agent-client'
import { prisma } from '@/lib/prisma'
import { parseDockerPs } from '@/lib/parse-docker'
import { parseCertList } from '@/lib/parse-caddy'
import { parseSystemctlStatus } from '@/lib/parse-systemd'
import { parseGitStatus } from '@/lib/parse-git'
import DockerWidget, { type DockerInitial } from './_components/DockerWidget'
import CaddyWidget, { type CaddyInitial } from './_components/CaddyWidget'
import SystemdWidget, { type SystemdInitial } from './_components/SystemdWidget'
import GitWidget, { type GitInitial } from './_components/GitWidget'
import AuditWidget, { type AuditInitial } from './_components/AuditWidget'
export const dynamic = 'force-dynamic'
const SECTIONS = [
{ href: '/docker', title: 'Docker', desc: 'Containers en status' },
{ href: '/git', title: 'Git', desc: 'Repo checkouts en diffs' },
{ href: '/systemd', title: 'systemd', desc: 'Services en journals' },
{ href: '/caddy', title: 'Caddy', desc: 'Config en certs' },
{ href: '/flows', title: 'Flows', desc: 'Multi-step deployments' },
{ href: '/audit', title: 'Audit', desc: 'Command-log en runs' },
{ href: '/settings', title: 'Settings', desc: 'Backups en config' },
]
export default async function Home() {
const user = await getCurrentUser()
if (!user) redirect('/login')
const systemdUnits = (process.env.SYSTEMD_UNITS ?? '')
.split(',')
.map((u) => u.trim())
.filter(Boolean)
const repoPaths = (process.env.REPO_PATHS ?? '')
.split(',')
.map((p) => p.trim())
.filter(Boolean)
const [[dockerResult, caddyResult, auditResult], unitResults, repoResults] = await Promise.all([
Promise.allSettled([
execAgent('docker_ps'),
execAgent('caddy_list_certs'),
prisma.flowRun.findFirst({
where: { user_id: user.id },
orderBy: { started_at: 'desc' },
select: { id: true, flow_key: true, status: true, started_at: true },
}),
]),
Promise.allSettled(systemdUnits.map((unit) => execAgent('systemctl_status', [unit]))),
Promise.allSettled(repoPaths.map((path) => execAgent('git_status', [path]))),
])
// Docker widget initial state
const dockerInitial: DockerInitial =
dockerResult.status === 'rejected'
? { data: null, error: dockerResult.reason instanceof Error ? dockerResult.reason.message : 'failed' }
: (() => {
const containers = parseDockerPs(dockerResult.value)
return {
data: {
running: containers.filter((c) => c.status.toLowerCase().startsWith('up')).length,
total: containers.length,
},
error: null,
}
})()
// Caddy widget initial state
const caddyInitial: CaddyInitial =
caddyResult.status === 'rejected'
? { data: null, error: caddyResult.reason instanceof Error ? caddyResult.reason.message : 'failed' }
: (() => {
const certs = parseCertList(caddyResult.value)
const expiryTimes = certs
.filter((c) => c.notAfter)
.map((c) => new Date(c.notAfter).getTime())
return {
data: {
soonestExpiryMs: expiryTimes.length > 0 ? Math.min(...expiryTimes) : null,
count: certs.length,
expiringWarning: certs.some((c) => c.expiringWarning),
},
error: null,
}
})()
// Systemd widget initial state
let systemdInitial: SystemdInitial
if (systemdUnits.length === 0) {
systemdInitial = { configured: false }
} else if (unitResults.every((r) => r.status === 'rejected')) {
const first = unitResults[0]
systemdInitial = {
data: null,
error: first.status === 'rejected' && first.reason instanceof Error ? first.reason.message : 'all units failed',
}
} else {
const healthy = unitResults.reduce((count, r, i) => {
if (r.status !== 'fulfilled') return count
return parseSystemctlStatus(r.value, systemdUnits[i]).activeState === 'active' ? count + 1 : count
}, 0)
systemdInitial = { data: { healthy, total: systemdUnits.length }, error: null }
}
// Git widget initial state
let gitInitial: GitInitial
if (repoPaths.length === 0) {
gitInitial = { configured: false }
} else if (repoResults.every((r) => r.status === 'rejected')) {
const first = repoResults[0]
gitInitial = {
data: null,
error: first.status === 'rejected' && first.reason instanceof Error ? first.reason.message : 'all repos failed',
}
} else {
const dirty = repoResults.filter(
(r) => r.status === 'fulfilled' && parseGitStatus(r.value).dirty,
).length
gitInitial = { data: { dirty, total: repoPaths.length }, error: null }
}
// Audit widget initial state
const auditInitial: AuditInitial =
auditResult.status === 'rejected'
? { data: null, error: auditResult.reason instanceof Error ? auditResult.reason.message : 'failed' }
: {
data: auditResult.value
? { ...auditResult.value, started_at: auditResult.value.started_at.toISOString() }
: null,
error: null,
}
return (
<div className="min-h-screen bg-background p-6">
<div className="mx-auto max-w-6xl space-y-6">
@ -25,17 +129,12 @@ export default async function Home() {
<h1 className="text-2xl font-semibold tracking-tight">Ops Dashboard</h1>
<p className="text-sm text-muted-foreground">Welkom {user.email}</p>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 md:grid-cols-3">
{SECTIONS.map((s) => (
<Link
key={s.href}
href={s.href}
className="block rounded-lg border bg-card p-5 transition-colors hover:bg-accent"
>
<h2 className="text-lg font-medium">{s.title}</h2>
<p className="mt-1 text-sm text-muted-foreground">{s.desc}</p>
</Link>
))}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<DockerWidget initial={dockerInitial} />
<CaddyWidget initial={caddyInitial} />
<SystemdWidget initial={systemdInitial} units={systemdUnits} />
<GitWidget initial={gitInitial} repos={repoPaths} />
<AuditWidget initial={auditInitial} />
</div>
</div>
</div>

48
components/AppNav.tsx Normal file
View file

@ -0,0 +1,48 @@
'use client'
import Link from 'next/link'
import { usePathname } from 'next/navigation'
import { cn } from '@/lib/utils'
const NAV_ITEMS = [
{ href: '/', label: 'Dashboard' },
{ href: '/docker', label: 'Docker' },
{ href: '/git', label: 'Git' },
{ href: '/systemd', label: 'systemd' },
{ href: '/caddy', label: 'Caddy' },
{ href: '/flows', label: 'Flows' },
{ href: '/audit', label: 'Audit' },
{ href: '/settings', label: 'Settings' },
]
export default function AppNav() {
const pathname = usePathname()
return (
<nav className="sticky top-0 z-10 border-b border-border bg-background/95 backdrop-blur">
<div className="mx-auto max-w-6xl px-6 py-3 flex items-center gap-6">
<Link href="/" className="mr-2 text-sm font-semibold tracking-tight shrink-0">
Ops Dashboard
</Link>
{NAV_ITEMS.map((item) => {
const isActive =
item.href === '/' ? pathname === '/' : pathname.startsWith(item.href)
return (
<Link
key={item.href}
href={item.href}
className={cn(
'text-sm transition-colors',
isActive
? 'text-foreground font-medium'
: 'text-muted-foreground hover:text-foreground',
)}
>
{item.label}
</Link>
)
})}
</div>
</nav>
)
}

View file

@ -1,7 +1,10 @@
# Block to add to /srv/scrum4me/caddy/Caddyfile
# After adding, restart Caddy (not reload — see deploy notes):
# docker compose restart caddy
# Pre-condition: the Caddy container must share the Docker network of the ops-dashboard compose stack
# so that Docker service-name resolution works.
ops.jp-visser.nl {
reverse_proxy 172.18.0.1:3001
# Use Docker service-name; Caddy must share a network with ops-dashboard.
reverse_proxy ops-dashboard:3000
}

View file

@ -2,7 +2,7 @@
# Add the ops-dashboard service under the `services:` key.
#
# Build the image first:
# docker build -t ops-dashboard /srv/ops/ops-dashboard
# docker build -t ops-dashboard /srv/ops/repos/ops-dashboard
#
# Then bring the service up:
# docker compose -f /srv/scrum4me/compose/docker-compose.yml up -d ops-dashboard
@ -10,7 +10,7 @@
services:
ops-dashboard:
build:
context: /srv/ops/ops-dashboard
context: /srv/ops/repos/ops-dashboard
env_file: /srv/ops/ops-dashboard.env
ports:
- "127.0.0.1:3001:3000"

41
lib/agent-fetch.ts Normal file
View file

@ -0,0 +1,41 @@
import { apiFetch } from '@/lib/csrf'
export async function fetchAgentOutput(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 SSE
}
}
}
}
return output
}

View file

@ -4,3 +4,13 @@ import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function relativeTime(date: Date): string {
const diff = Date.now() - date.getTime()
const minutes = Math.floor(diff / 60_000)
if (minutes < 1) return 'net nu'
if (minutes < 60) return `${minutes}m geleden`
const hours = Math.floor(minutes / 60)
if (hours < 24) return `${hours}u geleden`
return `${Math.floor(hours / 24)}d geleden`
}