diff --git a/.env.example b/.env.example index ab4a19c..07a27ea 100644 --- a/.env.example +++ b/.env.example @@ -5,3 +5,5 @@ OPS_AGENT_SECRET="replace-with-contents-of-/etc/ops-agent/secret" OPS_AGENT_URL="http://127.0.0.1:3099" # Comma-separated list of absolute repo paths to show on the /git page REPO_PATHS="/srv/scrum4me/repos/scrum4me,/srv/ops/repos/ops-dashboard" +# Comma-separated list of systemd unit names to show on the /systemd page (must match commands.yml allowed list) +SYSTEMD_UNITS="scrum4me-web,ops-agent" diff --git a/app/systemd/[unit]/_components/unit-detail.tsx b/app/systemd/[unit]/_components/unit-detail.tsx new file mode 100644 index 0000000..4e54da2 --- /dev/null +++ b/app/systemd/[unit]/_components/unit-detail.tsx @@ -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 { + 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 = { + 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 = { + 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 ( + + + {label} + + ) +} + +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(initialError) + const [refreshing, setRefreshing] = useState(false) + const [lastUpdated, setLastUpdated] = useState(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 ( +
+
+
+ {parsedStatus && } + {parsedStatus?.uptime && ( + {parsedStatus.uptime} + )} + {refreshing && ( + refreshing… + )} +
+ + updated {lastUpdated.toLocaleTimeString()} · auto-refreshes every 10s + +
+ + {error && ( +
+ {error} +
+ )} + +
+

Unit Status

+
+          {statusOutput || '—'}
+        
+
+ +
+

Recent Journal (last hour)

+
+          {journalOutput || 'No journal entries'}
+        
+
+
+ ) +} diff --git a/app/systemd/[unit]/page.tsx b/app/systemd/[unit]/page.tsx new file mode 100644 index 0000000..9e79f6d --- /dev/null +++ b/app/systemd/[unit]/page.tsx @@ -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 ( +
+
+
+ + ← systemd Units + +
+
+ Unit "{unitName}" not found in SYSTEMD_UNITS. +
+
+
+ ) + } + + 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 ( +
+
+
+ + ← systemd Units + + / +

{unitName}

+
+ +
+
+ ) +} diff --git a/app/systemd/_components/systemd-units-list.tsx b/app/systemd/_components/systemd-units-list.tsx new file mode 100644 index 0000000..ec67901 --- /dev/null +++ b/app/systemd/_components/systemd-units-list.tsx @@ -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 { + 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 = { + 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 = { + 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 ( + + + {label} + + ) +} + +type Props = { + initialUnits: UnitEntry[] +} + +export default function SystemdUnitsList({ initialUnits }: Props) { + const [units, setUnits] = useState(initialUnits) + const [refreshing, setRefreshing] = useState(false) + const [lastUpdated, setLastUpdated] = useState(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 ( +
+
+
+ + {units.length} unit{units.length !== 1 ? 's' : ''} + + {refreshing && ( + refreshing… + )} +
+ + updated {lastUpdated.toLocaleTimeString()} + +
+ +
+ + + + + + + + + + + {units.map((entry) => ( + + + + + + + ))} + +
UnitDescriptionStatusUptime
+ + {entry.unit} + + + {entry.status?.description ?? '—'} + + {entry.error ? ( + {entry.error} + ) : entry.status ? ( + + ) : ( + + )} + + {entry.status?.uptime || '—'} +
+
+
+ ) +} diff --git a/app/systemd/page.tsx b/app/systemd/page.tsx new file mode 100644 index 0000000..e453262 --- /dev/null +++ b/app/systemd/page.tsx @@ -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 ( +
+
+
+

systemd Units

+

Auto-refreshes every 10 seconds

+
+ {units.length === 0 ? ( +
+ No units configured. Set SYSTEMD_UNITS in your + environment (comma-separated unit names). +
+ ) : ( + + )} +
+
+ ) +} diff --git a/lib/parse-systemd.ts b/lib/parse-systemd.ts new file mode 100644 index 0000000..1134220 --- /dev/null +++ b/lib/parse-systemd.ts @@ -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 } +} diff --git a/ops-agent/commands.yml.example b/ops-agent/commands.yml.example index 9928977..96e2559 100644 --- a/ops-agent/commands.yml.example +++ b/ops-agent/commands.yml.example @@ -37,9 +37,10 @@ commands: description: "Fetch all remotes silently (first arg = repo path)" systemctl_status: - cmd: ["systemctl", "status"] + cmd: ["systemctl", "status", "--no-pager", "-l"] args: allowed: + - scrum4me-web - ops-agent - caddy - docker @@ -47,6 +48,18 @@ commands: - 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"