'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 { 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 = { 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'}
        
) }