'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 { 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 = { 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 ActionDef = { commandKey: string args: string[] preview: string title: string } 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 [pendingAction, setPendingAction] = useState(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 (
{units.length} unit{units.length !== 1 ? 's' : ''} {refreshing && ( refreshing… )}
updated {lastUpdated.toLocaleTimeString()}
{units.map((entry) => ( ))}
Unit Description Status Uptime Actions
{entry.unit} {entry.status?.description ?? '—'} {entry.error ? ( {entry.error} ) : entry.status ? ( ) : ( )} {entry.status?.uptime || '—'}
{flowRun.status !== 'idle' && (
Output {flowRun.status !== 'running' && ( )}
)} setPendingAction(null)} />
) }