feat(systemd): unit overview + journal viewer pages

- Add journalctl_recent command and scrum4me-web to whitelist in commands.yml.example
- Add SYSTEMD_UNITS env var to .env.example
- lib/parse-systemd.ts: parse activeState, subState, uptime, description
- /app/systemd: server page reading SYSTEMD_UNITS, client list with 10s polling and status badges
- /app/systemd/[unit]: server detail page, client component showing systemctl status + last 100 journal lines (polling 10s)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Scrum4Me Agent 2026-05-13 17:41:54 +02:00
parent 9e08a7c31f
commit c12e36e0a4
7 changed files with 523 additions and 1 deletions

View file

@ -5,3 +5,5 @@ OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret"
OPS_AGENT_URL="http://127.0.0.1:3099" OPS_AGENT_URL="http://127.0.0.1:3099"
# Comma-separated list of absolute repo paths to show on the /git page # Comma-separated list of absolute repo paths to show on the /git page
REPO_PATHS="/srv/scrum4me/repos/scrum4me,/srv/ops/repos/ops-dashboard" 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"

View file

@ -0,0 +1,162 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/parse-systemd'
async function fetchOutput(commandKey: string, args: string[]): Promise<string> {
const res = await fetch('/api/agent/exec', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command_key: commandKey, args }),
})
if (!res.ok) {
const text = await res.text()
throw new Error(`agent ${res.status}: ${text}`)
}
const reader = res.body?.getReader()
if (!reader) throw new Error('no response body')
const decoder = new TextDecoder()
let buffer = ''
let output = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const lines = buffer.split('\n')
buffer = lines.pop() ?? ''
for (const line of lines) {
if (line.startsWith('data:')) {
try {
const parsed = JSON.parse(line.slice(5).trim()) as { data?: string }
if (parsed.data !== undefined) output += parsed.data
} catch {
// ignore malformed
}
}
}
}
return output
}
const badgeClass: Record<ActiveState, string> = {
active: 'bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400',
inactive: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
failed: 'bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400',
activating: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
deactivating: 'bg-amber-100 text-amber-800 dark:bg-amber-900/30 dark:text-amber-400',
unknown: 'bg-zinc-100 text-zinc-600 dark:bg-zinc-800 dark:text-zinc-400',
}
const dotClass: Record<ActiveState, string> = {
active: 'bg-green-500 dark:bg-green-400',
inactive: 'bg-zinc-400 dark:bg-zinc-500',
failed: 'bg-red-500 dark:bg-red-400',
activating: 'bg-amber-500 dark:bg-amber-400',
deactivating: 'bg-amber-500 dark:bg-amber-400',
unknown: 'bg-zinc-400 dark:bg-zinc-500',
}
function StatusBadge({ status }: { status: UnitStatus }) {
const label = status.subState
? `${status.activeState} (${status.subState})`
: status.activeState
return (
<span
className={`inline-flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium ${badgeClass[status.activeState]}`}
>
<span className={`size-1.5 rounded-full ${dotClass[status.activeState]}`} />
{label}
</span>
)
}
type Props = {
unitName: string
initialStatusOutput: string
initialJournalOutput: string
initialError: string | null
}
export default function UnitDetail({
unitName,
initialStatusOutput,
initialJournalOutput,
initialError,
}: Props) {
const [statusOutput, setStatusOutput] = useState(initialStatusOutput)
const [journalOutput, setJournalOutput] = useState(initialJournalOutput)
const [error, setError] = useState<string | null>(initialError)
const [refreshing, setRefreshing] = useState(false)
const [lastUpdated, setLastUpdated] = useState<Date>(new Date())
const parsedStatus = statusOutput ? parseSystemctlStatus(statusOutput, unitName) : null
const refresh = useCallback(async () => {
setRefreshing(true)
try {
const [statusOut, journalOut] = await Promise.all([
fetchOutput('systemctl_status', [unitName]),
fetchOutput('journalctl_recent', [unitName]),
])
setStatusOutput(statusOut)
setJournalOutput(journalOut)
setError(null)
setLastUpdated(new Date())
} catch (err) {
setError(err instanceof Error ? err.message : 'refresh failed')
} finally {
setRefreshing(false)
}
}, [unitName])
useEffect(() => {
const id = setInterval(refresh, 10000)
return () => clearInterval(id)
}, [refresh])
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
{parsedStatus && <StatusBadge status={parsedStatus} />}
{parsedStatus?.uptime && (
<span className="text-sm text-muted-foreground">{parsedStatus.uptime}</span>
)}
{refreshing && (
<span className="text-xs text-muted-foreground animate-pulse">refreshing</span>
)}
</div>
<span className="text-xs text-muted-foreground">
updated {lastUpdated.toLocaleTimeString()} · auto-refreshes every 10s
</span>
</div>
{error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
<div>
<h2 className="text-base font-semibold mb-3">Unit Status</h2>
<pre className="overflow-x-auto rounded-lg border border-border bg-muted/30 p-4 text-xs font-mono leading-relaxed whitespace-pre">
{statusOutput || '—'}
</pre>
</div>
<div>
<h2 className="text-base font-semibold mb-3">Recent Journal (last hour)</h2>
<pre className="overflow-x-auto overflow-y-auto max-h-[600px] rounded-lg border border-border bg-muted/30 p-4 text-xs font-mono leading-relaxed whitespace-pre">
{journalOutput || 'No journal entries'}
</pre>
</div>
</div>
)
}

View file

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

View file

@ -0,0 +1,183 @@
'use client'
import { useCallback, useEffect, useState } from 'react'
import Link from 'next/link'
import { parseSystemctlStatus, type UnitStatus, type ActiveState } from '@/lib/parse-systemd'
interface UnitEntry {
unit: string
status: UnitStatus | null
error: string | null
}
async function fetchUnitStatus(unit: string): Promise<UnitStatus> {
const res = await fetch('/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 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 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])
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>
</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>
</tr>
))}
</tbody>
</table>
</div>
</div>
)
}

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

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

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

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

View file

@ -37,9 +37,10 @@ commands:
description: "Fetch all remotes silently (first arg = repo path)" description: "Fetch all remotes silently (first arg = repo path)"
systemctl_status: systemctl_status:
cmd: ["systemctl", "status"] cmd: ["systemctl", "status", "--no-pager", "-l"]
args: args:
allowed: allowed:
- scrum4me-web
- ops-agent - ops-agent
- caddy - caddy
- docker - docker
@ -47,6 +48,18 @@ commands:
- postgresql - postgresql
description: "Show systemctl status for an allowed service" 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: caddy_show_config:
cmd: ["caddy", "fmt", "/etc/caddy/Caddyfile"] cmd: ["caddy", "fmt", "/etc/caddy/Caddyfile"]
description: "Print the formatted Caddy config" description: "Print the formatted Caddy config"