import { useEffect, useState, useCallback } from 'react' import { useParams, useNavigate } from 'react-router-dom' import { Alert, Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions, DialogContent, DialogTitle, Divider, FormControl, FormControlLabel, Grid, IconButton, InputLabel, MenuItem, Paper, Select, Snackbar, Switch, Table, TableBody, TableCell, TableHead, TableRow, TextField, Tooltip, Typography, } from '@mui/material' import { Add as AddIcon, ArrowBack, Cancel as CancelIcon, CheckCircle as CheckCircleIcon, Delete as DeleteIcon, Edit as EditIcon, MonitorHeart as MonitorHeartIcon, PlayArrow as PlayArrowIcon, Remove as RemoveIcon, Schedule as ScheduleIcon, VpnKey as VpnKeyIcon, ContentCopy as CopyIcon, } from '@mui/icons-material' import { apiClient, hostsApi, maintenanceWindowsApi, healthChecksApi, certsApi } from '../api/client' import type { CreateHostRequest, IssuedCert, MaintenanceWindow, WindowRecurrence, HealthCheckType, HealthCheckWithResult, CreateHealthCheckRequest, UpdateHealthCheckRequest, } from '../types' // ── Helpers ─────────────────────────────────────────────────────────────────── const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] function recurrenceLabel(r: WindowRecurrence): string { const map: Record = { once: 'One-Time', daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', } return map[r] } function scheduleDescription(w: MaintenanceWindow): string { const dur = `${w.duration_minutes} min` const time = new Date(w.start_at).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short', }) switch (w.recurrence) { case 'once': return `Once at ${new Date(w.start_at).toLocaleString()} for ${dur}` case 'daily': return `Every day at ${time} for ${dur}` case 'weekly': { const day = w.recurrence_day != null ? DAY_NAMES[w.recurrence_day] ?? `Day ${w.recurrence_day}` : '?' // eslint-disable-line eqeqeq return `Every ${day} at ${time} for ${dur}` } case 'monthly': { const day = w.recurrence_day != null ? w.recurrence_day : '?' // eslint-disable-line eqeqeq return `Monthly on day ${day} at ${time} for ${dur}` } } } // ── Window form value type ──────────────────────────────────────────────────── interface FormValues { label: string recurrence: WindowRecurrence start_at: string duration_minutes: number recurrence_day: number | '' enabled: boolean } function defaultForm(): FormValues { return { label: '', recurrence: 'once', start_at: new Date().toISOString().slice(0, 16), duration_minutes: 60, recurrence_day: '', enabled: true, } } // ── Window form dialog ──────────────────────────────────────────────────────── interface WindowFormDialogProps { open: boolean title: string initial: FormValues onClose: () => void onSubmit: (values: FormValues) => Promise } function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFormDialogProps) { const [form, setForm] = useState(initial) const [saving, setSaving] = useState(false) const [err, setErr] = useState(null) useEffect(() => { setForm(initial); setErr(null) }, [open, initial]) const set = (field: keyof FormValues, value: FormValues[keyof FormValues]) => setForm(prev => ({ ...prev, [field]: value })) const needsDay = form.recurrence === 'weekly' || form.recurrence === 'monthly' const handleSubmit = async () => { if (!form.label.trim()) { setErr('Label is required'); return } if (needsDay && form.recurrence_day === '') { setErr('Recurrence day is required'); return } setSaving(true); setErr(null) try { await onSubmit(form) } catch (e: unknown) { const msg = (e as { response?: { data?: { error?: { message?: string } } } }) ?.response?.data?.error?.message ?? 'Failed to save' setErr(msg) } finally { setSaving(false) } } return ( {title} {err && {err}} set('label', e.target.value)} required fullWidth /> Recurrence set('start_at', e.target.value)} fullWidth slotProps={{ inputLabel: { shrink: true } }} /> set('duration_minutes', parseInt(e.target.value, 10) || 60)} fullWidth slotProps={{ htmlInput: { min: 1, max: 1440 } }} /> {form.recurrence === 'weekly' && ( Day of Week )} {form.recurrence === 'monthly' && ( set('recurrence_day', parseInt(e.target.value, 10) || 1)} fullWidth slotProps={{ htmlInput: { min: 1, max: 31 } }} /> )} set('enabled', e.target.checked)} />} label="Enabled" /> ) } // ── Health Check form value type ───────────────────────────────────────────── interface HealthCheckFormValues { name: string check_type: HealthCheckType service_name: string url: string expected_body: string ignore_cert_errors: boolean basic_auth_user: string basic_auth_pass: string enabled: boolean } function defaultHealthCheckForm(): HealthCheckFormValues { return { name: '', check_type: 'service', service_name: '', url: '', expected_body: '', ignore_cert_errors: false, basic_auth_user: '', basic_auth_pass: '', enabled: true, } } // ── Health Check form dialog ────────────────────────────────────────────────── interface HealthCheckFormDialogProps { open: boolean title: string initial: HealthCheckFormValues onClose: () => void onSubmit: (values: HealthCheckFormValues) => Promise } function HealthCheckFormDialog({ open, title, initial, onClose, onSubmit }: HealthCheckFormDialogProps) { const [form, setForm] = useState(initial) const [saving, setSaving] = useState(false) const [err, setErr] = useState(null) useEffect(() => { setForm(initial); setErr(null) }, [open, initial]) const set = (field: keyof HealthCheckFormValues, value: HealthCheckFormValues[keyof HealthCheckFormValues]) => setForm(prev => ({ ...prev, [field]: value })) const handleSubmit = async () => { if (!form.name.trim()) { setErr('Name is required'); return } if (form.check_type === 'service' && !form.service_name.trim()) { setErr('Service name is required'); return } if (form.check_type === 'http' && !form.url.trim()) { setErr('URL is required'); return } setSaving(true); setErr(null) try { await onSubmit(form) } catch (e: unknown) { const msg = (e as { response?: { data?: { error?: { message?: string } } } }) ?.response?.data?.error?.message ?? 'Failed to save' setErr(msg) } finally { setSaving(false) } } return ( {title} {err && {err}} set('name', e.target.value)} required fullWidth /> Check Type {form.check_type === 'service' && ( set('service_name', e.target.value)} required fullWidth helperText="Systemd service unit name to check" /> )} {form.check_type === 'http' && ( <> set('url', e.target.value)} required fullWidth helperText="Full URL to check (e.g. https://example.com/health)" /> set('expected_body', e.target.value)} fullWidth helperText="Substring expected in response body" /> set('ignore_cert_errors', e.target.checked)} />} label="Ignore Certificate Errors" /> set('basic_auth_user', e.target.value)} fullWidth /> set('basic_auth_pass', e.target.value)} fullWidth helperText="Leave blank to keep existing password" /> )} set('enabled', e.target.checked)} />} label="Enabled" /> ) } // ── Create Host Form ────────────────────────────────────────────────────────── // ── One-Time Key Display Dialog ─────────────────────────────────────────────── interface KeyDisplayDialogProps { open: boolean cert: IssuedCert | null onClose: () => void } function KeyDisplayDialog({ open, cert, onClose }: KeyDisplayDialogProps) { const [copiedField, setCopiedField] = useState<'cert' | 'key' | null>(null) const handleCopy = async (text: string, field: 'cert' | 'key') => { await navigator.clipboard.writeText(text) setCopiedField(field) setTimeout(() => setCopiedField(null), 2000) } return ( Certificate Issued — Save Your Private Key This private key will NOT be shown again. Copy and store it securely before closing this dialog. {cert && ( <> Serial: {cert.serial_number}  |  Expires: {new Date(cert.expires_at).toLocaleDateString()} Certificate (cert.pem) {cert.cert_pem} Private Key (key.pem) {cert.key_pem} )} ) } // ── Create Host Form ────────────────────────────────────────────────────────── function CreateHostForm() { const navigate = useNavigate() const [form, setForm] = useState({ fqdn: '', display_name: '', agent_port: 12443, notes: '', }) const [saving, setSaving] = useState(false) const [err, setErr] = useState(null) const set = (field: keyof CreateHostRequest, value: CreateHostRequest[keyof CreateHostRequest]) => setForm(prev => ({ ...prev, [field]: value })) const handleSubmit = async () => { if (!form.fqdn.trim()) { setErr('FQDN is required'); return } setSaving(true); setErr(null) try { const body: CreateHostRequest = { fqdn: form.fqdn.trim(), } if (form.display_name?.trim()) body.display_name = form.display_name.trim() if (form.agent_port) body.agent_port = form.agent_port if (form.notes?.trim()) body.notes = form.notes.trim() const res = await hostsApi.register(body) const newId = res.data?.id ?? res.data?.host?.id if (newId) { navigate(`/hosts/${newId}`) } else { navigate('/hosts') } } catch (e: unknown) { const msg = (e as { response?: { data?: { error?: { message?: string } } } }) ?.response?.data?.error?.message ?? 'Failed to register host' setErr(msg) } finally { setSaving(false) } } return ( Register New Host {err && {err}} set('fqdn', e.target.value)} required fullWidth helperText="Fully qualified domain name (IP address resolved automatically)" /> set('display_name', e.target.value)} fullWidth helperText="Optional friendly name for this host" /> set('agent_port', parseInt(e.target.value, 10) || 12443)} fullWidth slotProps={{ htmlInput: { min: 1, max: 65535 } }} helperText="Port the patch agent listens on (default 12443)" /> set('notes', e.target.value)} fullWidth multiline rows={3} helperText="Optional notes about this host" /> ) } // ── Main page ────────────────────────────────────────────────────────────────── export default function HostDetailPage() { const { id } = useParams<{ id: string }>() const navigate = useNavigate() const [host, setHost] = useState | null>(null) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) // Maintenance windows state const [windows, setWindows] = useState([]) const [winLoading, setWinLoading] = useState(false) const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success', }) // Create window dialog const [createOpen, setCreateOpen] = useState(false) const [createForm, setCreateForm] = useState(defaultForm()) // Edit window dialog const [editOpen, setEditOpen] = useState(false) const [editWindow, setEditWindow] = useState(null) const [editForm, setEditForm] = useState(defaultForm()) // Delete window dialog const [deleteOpen, setDeleteOpen] = useState(false) const [deleteTarget, setDeleteTarget] = useState(null) // Health checks state const [healthChecks, setHealthChecks] = useState([]) const [hcLoading, setHcLoading] = useState(false) const [testingId, setTestingId] = useState(null) // Create health check dialog const [hcCreateOpen, setHcCreateOpen] = useState(false) const [hcCreateForm, setHcCreateForm] = useState(defaultHealthCheckForm()) // Edit health check dialog const [hcEditOpen, setHcEditOpen] = useState(false) const [hcEditTarget, setHcEditTarget] = useState(null) const [hcEditForm, setHcEditForm] = useState(defaultHealthCheckForm()) // Delete health check dialog const [hcDeleteOpen, setHcDeleteOpen] = useState(false) const [hcDeleteTarget, setHcDeleteTarget] = useState(null) // Certificate state const [certExists, setCertExists] = useState(false) const [issueCertOpen, setIssueCertOpen] = useState(false) const [issuedCert, setIssuedCert] = useState(null) const [issueCertLoading, setIssueCertLoading] = useState(false) const [keyDialogOpen, setKeyDialogOpen] = useState(false) const [issueCertHostname, setIssueCertHostname] = useState('') const [issueCertError, setIssueCertError] = useState(null) // ── Fetch host ──────────────────────────────────────────────────────────── useEffect(() => { if (id === 'new') { setLoading(false); return } apiClient.get(`/hosts/${id}`) .then(r => setHost(r.data)) .catch(() => setError('Host not found or access denied.')) .finally(() => setLoading(false)) }, [id]) // ── Check cert existence ─────────────────────────────────────────────────── useEffect(() => { if (!id || id === 'new') return certsApi.list({ host_id: id }) .then(res => { const certs = res.data const hasActive = Array.isArray(certs) && certs.some((c: { status: string }) => c.status === 'active') setCertExists(hasActive) }) .catch(() => setCertExists(false)) }, [id]) // ── Fetch windows ───────────────────────────────────────────────────────── const fetchWindows = useCallback(async () => { if (!id) return setWinLoading(true) try { const res = await maintenanceWindowsApi.list(id) setWindows(res.data?.windows ?? []) } catch { /* ignore */ } finally { setWinLoading(false) } }, [id]) useEffect(() => { fetchWindows() }, [fetchWindows]) // ── Fetch health checks ─────────────────────────────────────────────────── const fetchHealthChecks = useCallback(async () => { if (!id) return setHcLoading(true) try { const res = await healthChecksApi.list(id) setHealthChecks(Array.isArray(res.data) ? res.data : []) } catch { /* ignore */ } finally { setHcLoading(false) } }, [id]) useEffect(() => { fetchHealthChecks() }, [fetchHealthChecks]) const showSnack = (message: string, severity: 'success' | 'error') => setSnackbar({ open: true, message, severity }) // ── Create window ───────────────────────────────────────────────────────── const handleCreateSubmit = async (values: FormValues) => { if (!id) return await maintenanceWindowsApi.create(id, { label: values.label, recurrence: values.recurrence, start_at: new Date(values.start_at).toISOString(), duration_minutes: values.duration_minutes, recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day, enabled: values.enabled, }) setCreateOpen(false) showSnack('Window created', 'success') await fetchWindows() } // ── Edit window ─────────────────────────────────────────────────────────── const handleEditClick = (w: MaintenanceWindow) => { setEditWindow(w) setEditForm({ label: w.label, recurrence: w.recurrence, start_at: new Date(w.start_at).toISOString().slice(0, 16), duration_minutes: w.duration_minutes, recurrence_day: w.recurrence_day ?? '', enabled: w.enabled, }) setEditOpen(true) } const handleEditSubmit = async (values: FormValues) => { if (!id || !editWindow) return await maintenanceWindowsApi.update(id, editWindow.id, { label: values.label, recurrence: values.recurrence, start_at: new Date(values.start_at).toISOString(), duration_minutes: values.duration_minutes, recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day, enabled: values.enabled, }) setEditOpen(false) showSnack('Window updated', 'success') await fetchWindows() } // ── Delete window ───────────────────────────────────────────────────────── const handleDeleteConfirm = async () => { if (!id || !deleteTarget) return try { await maintenanceWindowsApi.remove(id, deleteTarget.id) setDeleteOpen(false) showSnack('Window deleted', 'success') await fetchWindows() } catch { showSnack('Failed to delete window', 'error') } } // ── Download client cert ───────────────────────────────────────────────── const handleDownloadClientCert = async () => { if (!id) return try { const res = await certsApi.downloadClientCert(id) const url = URL.createObjectURL(res.data as Blob) const a = document.createElement('a') a.href = url a.download = 'client.crt' a.click() URL.revokeObjectURL(url) } catch { showSnack('No client certificate found for this host', 'error') } } // ── Issue client certificate ────────────────────────────────────────────── const handleOpenIssueCert = () => { setIssueCertHostname(String(host?.fqdn ?? '')) setIssueCertError(null) setIssueCertOpen(true) } const handleIssueCertSubmit = async () => { if (!id || !issueCertHostname.trim()) { setIssueCertError('Hostname is required'); return } setIssueCertLoading(true) setIssueCertError(null) try { const res = await certsApi.issue(id, issueCertHostname.trim()) setIssuedCert(res.data) setIssueCertOpen(false) setKeyDialogOpen(true) setCertExists(true) } catch (e: unknown) { const msg = (e as { response?: { data?: { error?: { message?: string } } } }) ?.response?.data?.error?.message ?? 'Failed to issue certificate' setIssueCertError(msg) } finally { setIssueCertLoading(false) } } // ── Create health check ────────────────────────────────────────────────── const handleHcCreateSubmit = async (values: HealthCheckFormValues) => { if (!id) return const body: CreateHealthCheckRequest = { name: values.name, check_type: values.check_type, } if (values.check_type === 'service') { body.service_name = values.service_name || undefined } else { body.url = values.url || undefined body.expected_body = values.expected_body || undefined body.ignore_cert_errors = values.ignore_cert_errors || undefined body.basic_auth_user = values.basic_auth_user || undefined body.basic_auth_pass = values.basic_auth_pass || undefined } await healthChecksApi.create(id, body) setHcCreateOpen(false) showSnack('Health check created', 'success') await fetchHealthChecks() } // ── Edit health check ──────────────────────────────────────────────────── const handleHcEditClick = (check: HealthCheckWithResult) => { setHcEditTarget(check) setHcEditForm({ name: check.name, check_type: check.check_type, service_name: check.service_name ?? '', url: check.url ?? '', expected_body: check.expected_body ?? '', ignore_cert_errors: check.ignore_cert_errors, basic_auth_user: check.basic_auth_user ?? '', basic_auth_pass: '', enabled: check.enabled, }) setHcEditOpen(true) } const handleHcEditSubmit = async (values: HealthCheckFormValues) => { if (!id || !hcEditTarget) return const body: UpdateHealthCheckRequest = { name: values.name, enabled: values.enabled, } if (values.check_type === 'service') { body.service_name = values.service_name || undefined } else { body.url = values.url || undefined body.expected_body = values.expected_body || undefined body.ignore_cert_errors = values.ignore_cert_errors body.basic_auth_user = values.basic_auth_user || undefined body.basic_auth_pass = values.basic_auth_pass || undefined } await healthChecksApi.update(id, hcEditTarget.id, body) setHcEditOpen(false) showSnack('Health check updated', 'success') await fetchHealthChecks() } // ── Delete health check ────────────────────────────────────────────────── const handleHcDeleteConfirm = async () => { if (!id || !hcDeleteTarget) return try { await healthChecksApi.delete(id, hcDeleteTarget.id) setHcDeleteOpen(false) showSnack('Health check deleted', 'success') await fetchHealthChecks() } catch { showSnack('Failed to delete health check', 'error') } } // ── Toggle health check enabled ────────────────────────────────────────── const handleToggleEnabled = async (check: HealthCheckWithResult) => { if (!id) return try { await healthChecksApi.update(id, check.id, { enabled: !check.enabled }) await fetchHealthChecks() } catch { showSnack('Failed to toggle health check', 'error') } } // ── Test health check ──────────────────────────────────────────────────── const handleTestCheck = async (check: HealthCheckWithResult) => { if (!id) return setTestingId(check.id) try { await healthChecksApi.test(id, check.id) await fetchHealthChecks() showSnack('Health check test completed', 'success') } catch { showSnack('Health check test failed', 'error') } finally { setTestingId(null) } } // ── Render ──────────────────────────────────────────────────────────────── if (loading) return if (error) return {error} // ── New host creation form ───────────────────────────────────────────────── if (id === 'new') { return } return ( {/* ── Host details ─────────────────────────────────────────────────── */} {String(host?.fqdn ?? '')} {!certExists && ( )} {host && Object.entries(host).map(([k, v]) => v !== null && v !== '' ? ( {k.replace(/_/g, ' ').toUpperCase()} {String(v)} ) : null )} {/* ── Maintenance Windows ──────────────────────────────────────────── */} Maintenance Windows Queued patch jobs execute only when an enabled maintenance window is open. {winLoading ? ( ) : windows.length === 0 ? ( No maintenance windows. Queued jobs will not run until a window is configured. ) : ( Label Schedule Recurrence Status Actions {windows.map(w => ( {w.label} {scheduleDescription(w)} handleEditClick(w)}> { setDeleteTarget(w); setDeleteOpen(true) }} > ))}
)}
{/* ── Health Checks ────────────────────────────────────────────────── */} Health Checks Monitor host health with service and HTTP checks. Maximum 5 checks per host. {hcLoading ? ( ) : healthChecks.length === 0 ? ( No health checks configured. Add a check to monitor this host's health. ) : ( Name Type Status Enabled Detail Latency Last Checked Actions {healthChecks.map(check => ( {check.name} {check.last_result ? ( check.last_result.healthy ? ( ) : ( ) ) : ( )} handleToggleEnabled(check)} /> {check.last_result?.detail ?? '—'} {check.last_result?.latency_ms != null ? `${check.last_result.latency_ms} ms` : '—'} {check.last_result?.checked_at ? new Date(check.last_result.checked_at).toLocaleString() : '—'} handleTestCheck(check)} > {testingId === check.id ? : } handleHcEditClick(check)}> { setHcDeleteTarget(check); setHcDeleteOpen(true) }} > ))}
)}
{/* ── Dialogs ─────────────────────────────────────────────────────── */} setCreateOpen(false)} onSubmit={handleCreateSubmit} /> setEditOpen(false)} onSubmit={handleEditSubmit} /> setDeleteOpen(false)} maxWidth="xs" fullWidth> Delete Window Delete {deleteTarget?.label}? This cannot be undone. {/* Health Check Dialogs */} setHcCreateOpen(false)} onSubmit={handleHcCreateSubmit} /> setHcEditOpen(false)} onSubmit={handleHcEditSubmit} /> setHcDeleteOpen(false)} maxWidth="xs" fullWidth> Delete Health Check Delete {hcDeleteTarget?.name}? This cannot be undone. {/* Issue Certificate Dialog */} setIssueCertOpen(false)} maxWidth="sm" fullWidth> Issue Client Certificate {issueCertError && {issueCertError}} setIssueCertHostname(e.target.value)} required fullWidth helperText="Common name for the certificate (usually the host FQDN)" /> {/* One-time key display dialog */} setKeyDialogOpen(false)} /> {/* Snackbar */} setSnackbar(p => ({ ...p, open: false }))} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackbar(p => ({ ...p, open: false }))}> {snackbar.message}
) }