All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 53s
CI Pipeline / Rust Unit Tests (push) Successful in 1m12s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 14s
CI Pipeline / Build .deb & Release (push) Successful in 3m51s
1368 lines
55 KiB
TypeScript
1368 lines
55 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import JSZip from 'jszip'
|
|
import { useParams, useNavigate } from 'react-router-dom'
|
|
import {
|
|
Alert,
|
|
Box,
|
|
Button,
|
|
Chip,
|
|
CircularProgress,
|
|
Container,
|
|
Dialog,
|
|
DialogActions,
|
|
DialogContent,
|
|
DialogTitle,
|
|
Divider,
|
|
FormControl,
|
|
FormControlLabel,
|
|
Grid,
|
|
IconButton,
|
|
InputLabel,
|
|
FormHelperText,
|
|
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 { useAuthStore } from '../store/authStore'
|
|
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<WindowRecurrence, string> = {
|
|
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<void>
|
|
}
|
|
|
|
function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFormDialogProps) {
|
|
const [form, setForm] = useState<FormValues>(initial)
|
|
const [saving, setSaving] = useState(false)
|
|
const [err, setErr] = useState<string | null>(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 (
|
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
|
{err && <Alert severity="error">{err}</Alert>}
|
|
<TextField label="Label" value={form.label} onChange={e => set('label', e.target.value)} required fullWidth />
|
|
<FormControl fullWidth>
|
|
<InputLabel>Recurrence</InputLabel>
|
|
<Select label="Recurrence" value={form.recurrence} onChange={e => set('recurrence', e.target.value as WindowRecurrence)}>
|
|
<MenuItem value="once">One-Time</MenuItem>
|
|
<MenuItem value="daily">Daily</MenuItem>
|
|
<MenuItem value="weekly">Weekly</MenuItem>
|
|
<MenuItem value="monthly">Monthly</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
<TextField
|
|
label={form.recurrence === 'once' ? 'Start Date & Time (UTC)' : 'Reference Time (UTC)'}
|
|
type="datetime-local" value={form.start_at}
|
|
onChange={e => set('start_at', e.target.value)} fullWidth
|
|
slotProps={{ inputLabel: { shrink: true } }}
|
|
/>
|
|
<TextField label="Duration (minutes)" type="number" value={form.duration_minutes}
|
|
onChange={e => set('duration_minutes', parseInt(e.target.value, 10) || 60)} fullWidth
|
|
slotProps={{ htmlInput: { min: 1, max: 1440 } }}
|
|
/>
|
|
{form.recurrence === 'weekly' && (
|
|
<FormControl fullWidth>
|
|
<InputLabel>Day of Week</InputLabel>
|
|
<Select label="Day of Week" value={form.recurrence_day}
|
|
onChange={e => set('recurrence_day', Number(e.target.value))}>
|
|
{DAY_NAMES.map((name, i) => <MenuItem key={i} value={i}>{name}</MenuItem>)}
|
|
</Select>
|
|
</FormControl>
|
|
)}
|
|
{form.recurrence === 'monthly' && (
|
|
<TextField label="Day of Month (1-31)" type="number" value={form.recurrence_day}
|
|
onChange={e => set('recurrence_day', parseInt(e.target.value, 10) || 1)} fullWidth
|
|
slotProps={{ htmlInput: { min: 1, max: 31 } }}
|
|
/>
|
|
)}
|
|
<FormControlLabel
|
|
control={<Switch checked={form.enabled} onChange={e => set('enabled', e.target.checked)} />}
|
|
label="Enabled"
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
|
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
|
{saving ? <CircularProgress size={20} /> : 'Save'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ── 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
|
|
target_host_id: string
|
|
}
|
|
|
|
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,
|
|
target_host_id: '',
|
|
}
|
|
}
|
|
|
|
// ── Health Check form dialog ──────────────────────────────────────────────────
|
|
|
|
interface HealthCheckFormDialogProps {
|
|
open: boolean
|
|
title: string
|
|
initial: HealthCheckFormValues
|
|
hosts: { id: string; display_name: string; fqdn: string }[]
|
|
currentHostId: string
|
|
onClose: () => void
|
|
onSubmit: (values: HealthCheckFormValues) => Promise<void>
|
|
}
|
|
|
|
function HealthCheckFormDialog({ open, title, initial, hosts, currentHostId, onClose, onSubmit }: HealthCheckFormDialogProps) {
|
|
const [form, setForm] = useState<HealthCheckFormValues>(initial)
|
|
const [saving, setSaving] = useState(false)
|
|
const [err, setErr] = useState<string | null>(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 (
|
|
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
|
<DialogTitle>{title}</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
|
{err && <Alert severity="error">{err}</Alert>}
|
|
<TextField label="Name" value={form.name} onChange={e => set('name', e.target.value)} required fullWidth />
|
|
<FormControl fullWidth>
|
|
<InputLabel>Check Type</InputLabel>
|
|
<Select label="Check Type" value={form.check_type} onChange={e => set('check_type', e.target.value as HealthCheckType)}>
|
|
<MenuItem value="service">Service</MenuItem>
|
|
<MenuItem value="http">HTTP</MenuItem>
|
|
</Select>
|
|
</FormControl>
|
|
{form.check_type === 'service' && (
|
|
<>
|
|
<TextField label="Service Name" value={form.service_name} onChange={e => set('service_name', e.target.value)} required fullWidth
|
|
helperText="Systemd service unit name to check" />
|
|
<FormControl fullWidth>
|
|
<InputLabel>Target Host (optional)</InputLabel>
|
|
<Select label="Target Host (optional)" value={form.target_host_id} onChange={e => set('target_host_id', e.target.value)}>
|
|
<MenuItem value="">Own Host (default)</MenuItem>
|
|
{hosts.filter(h => h.id !== currentHostId).map(h => (
|
|
<MenuItem key={h.id} value={h.id}>{h.display_name || h.fqdn}</MenuItem>
|
|
))}
|
|
</Select>
|
|
<FormHelperText>Query a service on a different host's agent (for redundant services)</FormHelperText>
|
|
</FormControl>
|
|
</>
|
|
)}
|
|
{form.check_type === 'http' && (
|
|
<>
|
|
<TextField label="URL" value={form.url} onChange={e => set('url', e.target.value)} required fullWidth
|
|
helperText="Full URL to check (e.g. https://example.com/health)" />
|
|
<TextField label="Expected Body (optional)" value={form.expected_body} onChange={e => set('expected_body', e.target.value)} fullWidth
|
|
helperText="Substring expected in response body" />
|
|
<FormControlLabel
|
|
control={<Switch checked={form.ignore_cert_errors} onChange={e => set('ignore_cert_errors', e.target.checked)} />}
|
|
label="Ignore Certificate Errors"
|
|
/>
|
|
<TextField label="Basic Auth User (optional)" value={form.basic_auth_user} onChange={e => set('basic_auth_user', e.target.value)} fullWidth />
|
|
<TextField label="Basic Auth Password (optional)" type="password" value={form.basic_auth_pass} onChange={e => set('basic_auth_pass', e.target.value)} fullWidth
|
|
helperText="Leave blank to keep existing password" />
|
|
</>
|
|
)}
|
|
<FormControlLabel
|
|
control={<Switch checked={form.enabled} onChange={e => set('enabled', e.target.checked)} />}
|
|
label="Enabled"
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
|
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
|
{saving ? <CircularProgress size={20} /> : 'Save'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ── Create Host Form ──────────────────────────────────────────────────────────
|
|
// ── One-Time Key Display Dialog ───────────────────────────────────────────────
|
|
|
|
interface KeyDisplayDialogProps {
|
|
open: boolean
|
|
cert: IssuedCert | null
|
|
hostname?: string
|
|
onClose: () => void
|
|
}
|
|
|
|
function KeyDisplayDialog({ open, cert, hostname, onClose }: KeyDisplayDialogProps) {
|
|
const [copiedField, setCopiedField] = useState<'ca' | 'server-cert' | 'server-key' | null>(null)
|
|
const [downloading, setDownloading] = useState(false)
|
|
|
|
const handleCopy = async (text: string, field: 'ca' | 'server-cert' | 'server-key') => {
|
|
await navigator.clipboard.writeText(text)
|
|
setCopiedField(field)
|
|
setTimeout(() => setCopiedField(null), 2000)
|
|
}
|
|
|
|
const handleDownloadBundle = async () => {
|
|
if (!cert) return
|
|
setDownloading(true)
|
|
try {
|
|
const zip = new JSZip()
|
|
zip.file('ca.crt', cert.ca_root_pem)
|
|
zip.file('server.crt', cert.server_cert_pem)
|
|
zip.file('server.key', cert.server_key_pem)
|
|
const blob = await zip.generateAsync({ type: 'blob' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `${hostname || 'host'}-agent-certs.zip`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
} finally {
|
|
setDownloading(false)
|
|
}
|
|
}
|
|
|
|
const preStyle = {
|
|
p: 2,
|
|
bgcolor: 'grey.100',
|
|
borderRadius: 1,
|
|
fontSize: 12,
|
|
overflow: 'auto',
|
|
maxHeight: 150,
|
|
fontFamily: 'monospace' as const,
|
|
whiteSpace: 'pre-wrap' as const,
|
|
wordBreak: 'break-all' as const,
|
|
}
|
|
|
|
return (
|
|
<Dialog open={open} onClose={onClose} maxWidth="md" fullWidth>
|
|
<DialogTitle>Agent Certificates Issued — Save Your Private Key</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
|
<Alert severity="warning">
|
|
<strong>The server private key will NOT be shown again.</strong> Download the bundle or copy it now.
|
|
</Alert>
|
|
{cert && (
|
|
<>
|
|
<Typography variant="caption" color="text.secondary">
|
|
Server Serial: {cert.server_serial_number} | Expires: {new Date(cert.expires_at).toLocaleDateString()}
|
|
</Typography>
|
|
|
|
{/* CA Root Certificate */}
|
|
<Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
|
<Typography variant="subtitle2">CA Root Certificate (ca.crt)</Typography>
|
|
<Tooltip title={copiedField === 'ca' ? 'Copied!' : 'Copy CA root cert to clipboard'}>
|
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.ca_root_pem, 'ca')} variant="outlined">
|
|
{copiedField === 'ca' ? 'Copied!' : 'Copy CA Root'}
|
|
</Button>
|
|
</Tooltip>
|
|
</Box>
|
|
<Box component="pre" sx={preStyle}>{cert.ca_root_pem}</Box>
|
|
</Box>
|
|
|
|
|
|
|
|
{/* Server Certificate (Agent TLS) */}
|
|
<Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
|
<Typography variant="subtitle2">Server Certificate — Agent TLS (server.crt)</Typography>
|
|
<Tooltip title={copiedField === 'server-cert' ? 'Copied!' : 'Copy server cert to clipboard'}>
|
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_cert_pem, 'server-cert')} variant="outlined">
|
|
{copiedField === 'server-cert' ? 'Copied!' : 'Copy Server Cert'}
|
|
</Button>
|
|
</Tooltip>
|
|
</Box>
|
|
<Box component="pre" sx={preStyle}>{cert.server_cert_pem}</Box>
|
|
</Box>
|
|
|
|
{/* Server Private Key */}
|
|
<Box>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 0.5 }}>
|
|
<Typography variant="subtitle2" color="error">Server Private Key (server.key)</Typography>
|
|
<Tooltip title={copiedField === 'server-key' ? 'Copied!' : 'Copy server key to clipboard'}>
|
|
<Button size="small" startIcon={<CopyIcon />} onClick={() => handleCopy(cert.server_key_pem, 'server-key')} variant="outlined" color="error">
|
|
{copiedField === 'server-key' ? 'Copied!' : 'Copy Server Key'}
|
|
</Button>
|
|
</Tooltip>
|
|
</Box>
|
|
<Box component="pre" sx={preStyle}>{cert.server_key_pem}</Box>
|
|
</Box>
|
|
</>
|
|
)}
|
|
</DialogContent>
|
|
<DialogActions sx={{ justifyContent: 'space-between' }}>
|
|
<Button
|
|
variant="outlined"
|
|
onClick={handleDownloadBundle}
|
|
disabled={downloading || !cert}
|
|
>
|
|
{downloading ? <CircularProgress size={20} /> : 'Download Agent Bundle (.zip)'}
|
|
</Button>
|
|
<Button variant="contained" onClick={onClose}>I Have Saved the Key</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
)
|
|
}
|
|
|
|
// ── Create Host Form ──────────────────────────────────────────────────────────
|
|
|
|
function CreateHostForm() {
|
|
const navigate = useNavigate()
|
|
const [form, setForm] = useState<CreateHostRequest>({
|
|
fqdn: '',
|
|
display_name: '',
|
|
agent_port: 12443,
|
|
notes: '',
|
|
})
|
|
const [saving, setSaving] = useState(false)
|
|
const [err, setErr] = useState<string | null>(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 (
|
|
<Container maxWidth="sm" sx={{ mt: 3, mb: 6 }}>
|
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/hosts')} sx={{ mb: 2 }}>
|
|
Back to Hosts
|
|
</Button>
|
|
<Paper sx={{ p: 3 }}>
|
|
<Typography variant="h5" fontWeight={700} gutterBottom>
|
|
Register New Host
|
|
</Typography>
|
|
<Divider sx={{ mb: 3 }} />
|
|
{err && <Alert severity="error" sx={{ mb: 2 }}>{err}</Alert>}
|
|
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
|
|
<TextField
|
|
label="FQDN"
|
|
value={form.fqdn}
|
|
onChange={e => set('fqdn', e.target.value)}
|
|
required
|
|
fullWidth
|
|
helperText="Fully qualified domain name (IP address resolved automatically)"
|
|
/>
|
|
<TextField
|
|
label="Display Name"
|
|
value={form.display_name ?? ''}
|
|
onChange={e => set('display_name', e.target.value)}
|
|
fullWidth
|
|
helperText="Optional friendly name for this host"
|
|
/>
|
|
<TextField
|
|
label="Agent Port"
|
|
type="number"
|
|
value={form.agent_port ?? 12443}
|
|
onChange={e => 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)"
|
|
/>
|
|
<TextField
|
|
label="Notes"
|
|
value={form.notes ?? ''}
|
|
onChange={e => set('notes', e.target.value)}
|
|
fullWidth
|
|
multiline
|
|
rows={3}
|
|
helperText="Optional notes about this host"
|
|
/>
|
|
</Box>
|
|
<Box sx={{ display: 'flex', justifyContent: 'flex-end', gap: 1, mt: 3 }}>
|
|
<Button onClick={() => navigate('/hosts')} disabled={saving}>
|
|
Cancel
|
|
</Button>
|
|
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
|
{saving ? <CircularProgress size={20} /> : 'Register Host'}
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
</Container>
|
|
)
|
|
}
|
|
|
|
// ── Main page ──────────────────────────────────────────────────────────────────
|
|
|
|
export default function HostDetailPage() {
|
|
const { id } = useParams<{ id: string }>()
|
|
const navigate = useNavigate()
|
|
const user = useAuthStore(state => state.user)
|
|
const canWrite = user?.role === 'admin' || user?.role === 'operator'
|
|
const [host, setHost] = useState<Record<string, unknown> | null>(null)
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState<string | null>(null)
|
|
|
|
// Maintenance windows state
|
|
const [windows, setWindows] = useState<MaintenanceWindow[]>([])
|
|
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<FormValues>(defaultForm())
|
|
|
|
// Edit window dialog
|
|
const [editOpen, setEditOpen] = useState(false)
|
|
const [editWindow, setEditWindow] = useState<MaintenanceWindow | null>(null)
|
|
const [editForm, setEditForm] = useState<FormValues>(defaultForm())
|
|
|
|
// Delete window dialog
|
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
|
const [deleteTarget, setDeleteTarget] = useState<MaintenanceWindow | null>(null)
|
|
|
|
// Health checks state
|
|
const [healthChecks, setHealthChecks] = useState<HealthCheckWithResult[]>([])
|
|
const [hcLoading, setHcLoading] = useState(false)
|
|
const [testingId, setTestingId] = useState<string | null>(null)
|
|
|
|
// Create health check dialog
|
|
const [hcCreateOpen, setHcCreateOpen] = useState(false)
|
|
const [hcCreateForm, setHcCreateForm] = useState<HealthCheckFormValues>(defaultHealthCheckForm())
|
|
|
|
// Edit health check dialog
|
|
const [hcEditOpen, setHcEditOpen] = useState(false)
|
|
const [hcEditTarget, setHcEditTarget] = useState<HealthCheckWithResult | null>(null)
|
|
const [hcEditForm, setHcEditForm] = useState<HealthCheckFormValues>(defaultHealthCheckForm())
|
|
|
|
// Delete health check dialog
|
|
const [hcDeleteOpen, setHcDeleteOpen] = useState(false)
|
|
const [hcDeleteTarget, setHcDeleteTarget] = useState<HealthCheckWithResult | null>(null)
|
|
|
|
// Certificate state
|
|
const [certExists, setCertExists] = useState(false)
|
|
const [issueCertOpen, setIssueCertOpen] = useState(false)
|
|
const [issuedCert, setIssuedCert] = useState<IssuedCert | null>(null)
|
|
const [issueCertLoading, setIssueCertLoading] = useState(false)
|
|
const [keyDialogOpen, setKeyDialogOpen] = useState(false)
|
|
const [issueCertHostname, setIssueCertHostname] = useState('')
|
|
const [issueCertError, setIssueCertError] = useState<string | null>(null)
|
|
|
|
// Re-issue certificate state
|
|
const [reissueConfirmOpen, setReissueConfirmOpen] = useState(false)
|
|
const [reissueLoading, setReissueLoading] = useState(false)
|
|
const [reissueError, setReissueError] = useState<string | null>(null)
|
|
|
|
// Hosts list for target_host_id dropdown
|
|
const [hosts, setHosts] = useState<{ id: string; display_name: string; fqdn: string }[]>([])
|
|
|
|
// ── Host editing state ────────────────────────────────────────────────────
|
|
const [editing, setEditing] = useState(false)
|
|
const [editFqdn, setEditFqdn] = useState('')
|
|
const [editIp, setEditIp] = useState('')
|
|
const [editDisplayName, setEditDisplayName] = useState('')
|
|
const [savingHost, setSavingHost] = useState(false)
|
|
|
|
const enterEdit = () => {
|
|
setEditFqdn(String(host?.fqdn ?? ''))
|
|
setEditIp(String(host?.ip_address ?? ''))
|
|
setEditDisplayName(String(host?.display_name ?? ''))
|
|
setEditing(true)
|
|
}
|
|
|
|
const cancelEdit = () => {
|
|
setEditing(false)
|
|
setSavingHost(false)
|
|
}
|
|
|
|
const handleSaveHost = async () => {
|
|
if (!id) return
|
|
setSavingHost(true)
|
|
try {
|
|
const res = await hostsApi.update(id, {
|
|
fqdn: editFqdn !== String(host?.fqdn ?? '') ? editFqdn : undefined,
|
|
ip_address: editIp !== String(host?.ip_address ?? '') ? editIp : undefined,
|
|
display_name: editDisplayName !== String(host?.display_name ?? '') ? editDisplayName : undefined,
|
|
})
|
|
setHost(res.data)
|
|
setEditing(false)
|
|
showSnack('Host updated', 'success')
|
|
} catch (e: unknown) {
|
|
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
|
|
?.response?.data?.error?.message ?? 'Failed to update host'
|
|
showSnack(msg, 'error')
|
|
} finally {
|
|
setSavingHost(false)
|
|
}
|
|
}
|
|
|
|
// ── 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])
|
|
|
|
// ── Fetch hosts list (for target_host_id dropdown) ──────────────────────
|
|
useEffect(() => {
|
|
hostsApi.list()
|
|
.then(res => setHosts(res.data?.hosts ?? []))
|
|
.catch(() => { /* ignore */ })
|
|
}, [])
|
|
|
|
// ── 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(res.data?.checks ?? [])
|
|
} 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')
|
|
}
|
|
}
|
|
|
|
// ── 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)
|
|
}
|
|
}
|
|
|
|
// ── Re-issue certificate ────────────────────────────────────────────────
|
|
const handleReissue = async () => {
|
|
if (!id) return
|
|
setReissueLoading(true)
|
|
setReissueError(null)
|
|
try {
|
|
const res = await certsApi.reissue(id)
|
|
setIssuedCert(res.data)
|
|
setReissueConfirmOpen(false)
|
|
setKeyDialogOpen(true)
|
|
setCertExists(true)
|
|
} catch (e: unknown) {
|
|
const msg = (e as { response?: { data?: { error?: { message?: string } } } })
|
|
?.response?.data?.error?.message ?? 'Failed to re-issue certificate'
|
|
setReissueError(msg)
|
|
} finally {
|
|
setReissueLoading(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
|
|
body.target_host_id = values.target_host_id || 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,
|
|
target_host_id: check.target_host_id ?? '',
|
|
})
|
|
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
|
|
body.target_host_id = values.target_host_id || 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 <Box display="flex" justifyContent="center" mt={8}><CircularProgress /></Box>
|
|
if (error) return <Container sx={{ mt: 4 }}><Alert severity="error">{error}</Alert></Container>
|
|
|
|
// ── New host creation form ─────────────────────────────────────────────────
|
|
if (id === 'new') {
|
|
return <CreateHostForm />
|
|
}
|
|
|
|
return (
|
|
<Container maxWidth="lg" sx={{ mt: 3, mb: 6 }}>
|
|
<Button startIcon={<ArrowBack />} onClick={() => navigate('/hosts')} sx={{ mb: 2 }}>
|
|
Back to Hosts
|
|
</Button>
|
|
|
|
{/* ── Host details ─────────────────────────────────────────────────── */}
|
|
<Paper sx={{ p: 3, mb: 3 }}>
|
|
<Box display="flex" alignItems="center" justifyContent="space-between" mb={2}>
|
|
<Typography variant="h5" fontWeight={700}>
|
|
{String(host?.fqdn ?? '')}
|
|
</Typography>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
{canWrite && !editing && (
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
startIcon={<EditIcon />}
|
|
onClick={enterEdit}
|
|
>
|
|
Edit
|
|
</Button>
|
|
)}
|
|
{canWrite && editing && (
|
|
<>
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
startIcon={<CheckCircleIcon />}
|
|
onClick={handleSaveHost}
|
|
disabled={savingHost}
|
|
>
|
|
{savingHost ? <CircularProgress size={16} /> : 'Save'}
|
|
</Button>
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
startIcon={<CancelIcon />}
|
|
onClick={cancelEdit}
|
|
disabled={savingHost}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</>
|
|
)}
|
|
{!editing && canWrite && !certExists && (
|
|
<Button
|
|
variant="contained"
|
|
size="small"
|
|
startIcon={<VpnKeyIcon />}
|
|
onClick={handleOpenIssueCert}
|
|
>
|
|
Issue Certificate
|
|
</Button>
|
|
)}
|
|
{!editing && canWrite && certExists && (
|
|
<Button
|
|
variant="outlined"
|
|
size="small"
|
|
color="warning"
|
|
startIcon={<VpnKeyIcon />}
|
|
onClick={() => { setReissueError(null); setReissueConfirmOpen(true) }}
|
|
>
|
|
Re-issue Certificate
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Box>
|
|
<Divider sx={{ mb: 2 }} />
|
|
<Grid container spacing={2}>
|
|
{host && (<>
|
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
|
<Typography variant="caption" color="text.secondary" display="block">FQDN</Typography>
|
|
{editing ? (
|
|
<TextField size="small" fullWidth value={editFqdn} onChange={e => setEditFqdn(e.target.value)} />
|
|
) : (
|
|
<Typography variant="body2">{String(host.fqdn)}</Typography>
|
|
)}
|
|
</Grid>
|
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
|
<Typography variant="caption" color="text.secondary" display="block">IP ADDRESS</Typography>
|
|
{editing ? (
|
|
<TextField size="small" fullWidth value={editIp} onChange={e => setEditIp(e.target.value)} />
|
|
) : (
|
|
<Typography variant="body2">{String(host.ip_address)}</Typography>
|
|
)}
|
|
</Grid>
|
|
<Grid size={{ xs: 12, sm: 6, md: 4 }}>
|
|
<Typography variant="caption" color="text.secondary" display="block">DISPLAY NAME</Typography>
|
|
{editing ? (
|
|
<TextField size="small" fullWidth value={editDisplayName} onChange={e => setEditDisplayName(e.target.value)} />
|
|
) : (
|
|
<Typography variant="body2">{String(host.display_name)}</Typography>
|
|
)}
|
|
</Grid>
|
|
{Object.entries(host).filter(([k]) => !['fqdn','ip_address','display_name'].includes(k)).map(([k, v]) =>
|
|
v !== null && v !== '' ? (
|
|
<Grid size={{ xs: 12, sm: 6, md: 4 }} key={k}>
|
|
<Typography variant="caption" color="text.secondary" display="block">
|
|
{k.replace(/_/g, ' ').toUpperCase()}
|
|
</Typography>
|
|
<Typography variant="body2">{String(v)}</Typography>
|
|
</Grid>
|
|
) : null
|
|
)}
|
|
</>)}
|
|
</Grid>
|
|
</Paper>
|
|
|
|
{/* ── Maintenance Windows ──────────────────────────────────────────── */}
|
|
<Paper sx={{ p: 3, mb: 3 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<ScheduleIcon color="primary" />
|
|
<Typography variant="h6" fontWeight={600}>Maintenance Windows</Typography>
|
|
</Box>
|
|
{canWrite && <Button
|
|
startIcon={<AddIcon />}
|
|
variant="outlined"
|
|
size="small"
|
|
onClick={() => { setCreateForm(defaultForm()); setCreateOpen(true) }}
|
|
>
|
|
Add Window
|
|
</Button>}
|
|
</Box>
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Queued patch jobs execute only when an enabled maintenance window is open.
|
|
</Typography>
|
|
|
|
{winLoading ? (
|
|
<Box display="flex" justifyContent="center" py={3}><CircularProgress size={28} /></Box>
|
|
) : windows.length === 0 ? (
|
|
<Alert severity="info">
|
|
No maintenance windows. Queued jobs will not run until a window is configured.
|
|
</Alert>
|
|
) : (
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Label</TableCell>
|
|
<TableCell>Schedule</TableCell>
|
|
<TableCell>Recurrence</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
{canWrite && <TableCell align="right">Actions</TableCell>}
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{windows.map(w => (
|
|
<TableRow key={w.id} hover>
|
|
<TableCell>{w.label}</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2">{scheduleDescription(w)}</Typography>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip label={recurrenceLabel(w.recurrence)} size="small" />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip
|
|
label={w.enabled ? 'Enabled' : 'Disabled'}
|
|
color={w.enabled ? 'success' : 'default'}
|
|
size="small"
|
|
/>
|
|
</TableCell>
|
|
{canWrite && <TableCell align="right">
|
|
<Tooltip title="Edit">
|
|
<IconButton size="small" onClick={() => handleEditClick(w)}>
|
|
<EditIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Delete">
|
|
<IconButton
|
|
size="small" color="error"
|
|
onClick={() => { setDeleteTarget(w); setDeleteOpen(true) }}
|
|
>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</TableCell>}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* ── Health Checks ────────────────────────────────────────────────── */}
|
|
<Paper sx={{ p: 3, mb: 3 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
|
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
|
<MonitorHeartIcon color="primary" />
|
|
<Typography variant="h6" fontWeight={600}>Health Checks</Typography>
|
|
</Box>
|
|
{canWrite && <Button
|
|
startIcon={<AddIcon />}
|
|
variant="outlined"
|
|
size="small"
|
|
disabled={healthChecks.length >= 5}
|
|
onClick={() => { setHcCreateForm(defaultHealthCheckForm()); setHcCreateOpen(true) }}
|
|
>
|
|
Add Health Check
|
|
</Button>}
|
|
</Box>
|
|
<Divider sx={{ mb: 2 }} />
|
|
|
|
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
|
Monitor host health with service and HTTP checks. Maximum 5 checks per host.
|
|
</Typography>
|
|
|
|
{hcLoading ? (
|
|
<Box display="flex" justifyContent="center" py={3}><CircularProgress size={28} /></Box>
|
|
) : healthChecks.length === 0 ? (
|
|
<Alert severity="info">
|
|
No health checks configured. Add a check to monitor this host's health.
|
|
</Alert>
|
|
) : (
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>Name</TableCell>
|
|
<TableCell>Type</TableCell>
|
|
<TableCell>Status</TableCell>
|
|
<TableCell>Target</TableCell>
|
|
<TableCell>Enabled</TableCell>
|
|
<TableCell>Detail</TableCell>
|
|
<TableCell>Latency</TableCell>
|
|
<TableCell>Last Checked</TableCell>
|
|
{canWrite && <TableCell align="right">Actions</TableCell>}
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{healthChecks.map(check => (
|
|
<TableRow key={check.id} hover>
|
|
<TableCell>{check.name}</TableCell>
|
|
<TableCell>
|
|
<Chip label={check.check_type} size="small" variant="outlined" />
|
|
</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2" sx={{ maxWidth: 250, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{check.check_type === 'service'
|
|
? (check.service_name ?? '—')
|
|
: (check.url ?? '—')}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell>
|
|
{check.last_result ? (
|
|
check.last_result.healthy ? (
|
|
<Tooltip title="Healthy">
|
|
<CheckCircleIcon color="success" fontSize="small" />
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip title="Unhealthy">
|
|
<CancelIcon color="error" fontSize="small" />
|
|
</Tooltip>
|
|
)
|
|
) : (
|
|
<Tooltip title="No result yet">
|
|
<RemoveIcon color="disabled" fontSize="small" />
|
|
</Tooltip>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Switch
|
|
size="small"
|
|
checked={check.enabled}
|
|
onChange={canWrite ? () => handleToggleEnabled(check) : undefined}
|
|
disabled={!canWrite}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<Typography variant="body2" sx={{ maxWidth: 200, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{check.last_result?.detail ?? '—'}
|
|
</Typography>
|
|
</TableCell>
|
|
<TableCell>
|
|
{check.last_result?.latency_ms !== null && check.last_result?.latency_ms !== undefined ? `${check.last_result.latency_ms} ms` : '—'}
|
|
</TableCell>
|
|
<TableCell>
|
|
{check.last_result?.checked_at
|
|
? new Date(check.last_result.checked_at).toLocaleString()
|
|
: '—'}
|
|
</TableCell>
|
|
{canWrite && <TableCell align="right">
|
|
<Tooltip title="Test now">
|
|
<IconButton
|
|
size="small"
|
|
color="primary"
|
|
disabled={testingId === check.id}
|
|
onClick={() => handleTestCheck(check)}
|
|
>
|
|
{testingId === check.id
|
|
? <CircularProgress size={16} />
|
|
: <PlayArrowIcon fontSize="small" />}
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Edit">
|
|
<IconButton size="small" onClick={() => handleHcEditClick(check)}>
|
|
<EditIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
<Tooltip title="Delete">
|
|
<IconButton
|
|
size="small" color="error"
|
|
onClick={() => { setHcDeleteTarget(check); setHcDeleteOpen(true) }}
|
|
>
|
|
<DeleteIcon fontSize="small" />
|
|
</IconButton>
|
|
</Tooltip>
|
|
</TableCell>}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
)}
|
|
</Paper>
|
|
|
|
{/* ── Dialogs ─────────────────────────────────────────────────────── */}
|
|
<WindowFormDialog
|
|
open={createOpen}
|
|
title="Add Maintenance Window"
|
|
initial={createForm}
|
|
onClose={() => setCreateOpen(false)}
|
|
onSubmit={handleCreateSubmit}
|
|
/>
|
|
<WindowFormDialog
|
|
open={editOpen}
|
|
title="Edit Maintenance Window"
|
|
initial={editForm}
|
|
onClose={() => setEditOpen(false)}
|
|
onSubmit={handleEditSubmit}
|
|
/>
|
|
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth>
|
|
<DialogTitle>Delete Window</DialogTitle>
|
|
<DialogContent>
|
|
<Typography>
|
|
Delete <strong>{deleteTarget?.label}</strong>? This cannot be undone.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setDeleteOpen(false)}>Cancel</Button>
|
|
<Button color="error" variant="contained" onClick={handleDeleteConfirm}>Delete</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Health Check Dialogs */}
|
|
<HealthCheckFormDialog
|
|
open={hcCreateOpen}
|
|
title="Add Health Check"
|
|
initial={hcCreateForm}
|
|
hosts={hosts}
|
|
currentHostId={id ?? ''}
|
|
onClose={() => setHcCreateOpen(false)}
|
|
onSubmit={handleHcCreateSubmit}
|
|
/>
|
|
<HealthCheckFormDialog
|
|
open={hcEditOpen}
|
|
title="Edit Health Check"
|
|
initial={hcEditForm}
|
|
hosts={hosts}
|
|
currentHostId={id ?? ''}
|
|
onClose={() => setHcEditOpen(false)}
|
|
onSubmit={handleHcEditSubmit}
|
|
/>
|
|
<Dialog open={hcDeleteOpen} onClose={() => setHcDeleteOpen(false)} maxWidth="xs" fullWidth>
|
|
<DialogTitle>Delete Health Check</DialogTitle>
|
|
<DialogContent>
|
|
<Typography>
|
|
Delete <strong>{hcDeleteTarget?.name}</strong>? This cannot be undone.
|
|
</Typography>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setHcDeleteOpen(false)}>Cancel</Button>
|
|
<Button color="error" variant="contained" onClick={handleHcDeleteConfirm}>Delete</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Issue Certificate Dialog */}
|
|
<Dialog open={issueCertOpen} onClose={() => setIssueCertOpen(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Issue Client Certificate</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
|
{issueCertError && <Alert severity="error">{issueCertError}</Alert>}
|
|
<TextField
|
|
label="Hostname"
|
|
value={issueCertHostname}
|
|
onChange={(e) => setIssueCertHostname(e.target.value)}
|
|
required
|
|
fullWidth
|
|
helperText="Common name for the certificate (usually the host FQDN)"
|
|
/>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setIssueCertOpen(false)} disabled={issueCertLoading}>Cancel</Button>
|
|
<Button variant="contained" onClick={handleIssueCertSubmit} disabled={issueCertLoading}>
|
|
{issueCertLoading ? <CircularProgress size={20} /> : 'Issue Certificate'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* Re-issue Certificate Confirmation Dialog */}
|
|
<Dialog open={reissueConfirmOpen} onClose={() => setReissueConfirmOpen(false)} maxWidth="sm" fullWidth>
|
|
<DialogTitle>Re-issue Certificate</DialogTitle>
|
|
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
|
{reissueError && <Alert severity="error">{reissueError}</Alert>}
|
|
<Alert severity="warning">
|
|
<strong>This will revoke all existing certificates for this host and issue a new set.</strong>
|
|
{' '}The new private key will only be shown once. Continue?
|
|
</Alert>
|
|
</DialogContent>
|
|
<DialogActions>
|
|
<Button onClick={() => setReissueConfirmOpen(false)} disabled={reissueLoading}>Cancel</Button>
|
|
<Button color="warning" variant="contained" onClick={handleReissue} disabled={reissueLoading}>
|
|
{reissueLoading ? <CircularProgress size={20} /> : 'Re-issue Certificate'}
|
|
</Button>
|
|
</DialogActions>
|
|
</Dialog>
|
|
|
|
{/* One-time key display dialog */}
|
|
<KeyDisplayDialog
|
|
open={keyDialogOpen}
|
|
cert={issuedCert}
|
|
hostname={String(host?.fqdn ?? '')}
|
|
onClose={() => setKeyDialogOpen(false)}
|
|
/>
|
|
|
|
{/* Snackbar */}
|
|
<Snackbar
|
|
open={snackbar.open}
|
|
autoHideDuration={4000}
|
|
onClose={() => setSnackbar(p => ({ ...p, open: false }))}
|
|
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
|
>
|
|
<Alert severity={snackbar.severity} onClose={() => setSnackbar(p => ({ ...p, open: false }))}>
|
|
{snackbar.message}
|
|
</Alert>
|
|
</Snackbar>
|
|
</Container>
|
|
)
|
|
}
|