import { useEffect, useState, useCallback } from 'react' import { Alert, Box, Button, Checkbox, Chip, CircularProgress, Container, FormControlLabel, InputAdornment, Paper, Step, StepLabel, Stepper, Switch, Table, TableBody, TableCell, TableHead, TableRow, TextField, Toolbar, Typography, Tooltip, } from '@mui/material' import { Search as SearchIcon } from '@mui/icons-material' import { CheckCircle as CheckCircleIcon, Cancel as CancelIcon, Remove as RemoveIcon } from '@mui/icons-material' import { useNavigate } from 'react-router-dom' import { hostsApi, jobsApi } from '../api/client' import type { Host, HostHealthStatus } from '../types' const STEPS = ['Select Hosts', 'Review & Configure', 'Result'] // ── Health status chip ──────────────────────────────────────────────────────── function HealthChip({ status }: { status: HostHealthStatus }) { const map: Record = { healthy: 'success', degraded: 'warning', unreachable: 'error', pending: 'default', } return } // ── PatchDeploymentPage ─────────────────────────────────────────────────────── export default function PatchDeploymentPage() { const navigate = useNavigate() const [activeStep, setActiveStep] = useState(0) // Step 0 state const [hosts, setHosts] = useState([]) const [hostsLoading, setHostsLoading] = useState(true) const [hostsError, setHostsError] = useState(null) const [searchQuery, setSearchQuery] = useState('') const [healthFilter, setHealthFilter] = useState('') const [patchesFilter, setPatchesFilter] = useState<'all' | 'missing' | 'uptodate'>('all') const [selectedIds, setSelectedIds] = useState>(new Set()) // Step 1 state const [immediate, setImmediate] = useState(true) const [allowReboot, setAllowReboot] = useState(false) const [notes, setNotes] = useState('') const [packages, setPackages] = useState('') // Step 2 state const [submitting, setSubmitting] = useState(false) const [submitError, setSubmitError] = useState(null) const [createdJobId, setCreatedJobId] = useState(null) const loadHosts = useCallback(async () => { setHostsLoading(true) setHostsError(null) try { const res = await hostsApi.list() const data = res.data as { hosts?: Host[] } | Host[] setHosts(Array.isArray(data) ? data : (data.hosts ?? [])) } catch { setHostsError('Failed to load hosts') } finally { setHostsLoading(false) } }, []) useEffect(() => { loadHosts() }, [loadHosts]) const filteredHosts = hosts.filter((h) => { const matchesSearch = searchQuery === '' || h.display_name.toLowerCase().includes(searchQuery.toLowerCase()) || h.fqdn.toLowerCase().includes(searchQuery.toLowerCase()) const matchesHealth = healthFilter === '' || h.health_status === healthFilter const matchesPatches = patchesFilter === 'all' || (patchesFilter === 'missing' && h.patches_missing > 0) || (patchesFilter === 'uptodate' && h.patches_missing === 0) return matchesSearch && matchesHealth && matchesPatches }) const handleToggleHost = (id: string) => { setSelectedIds((prev) => { const next = new Set(prev) if (next.has(id)) next.delete(id) else next.add(id) return next }) } const handleToggleAll = () => { if (selectedIds.size === filteredHosts.length) { setSelectedIds(new Set()) } else { setSelectedIds(new Set(filteredHosts.map((h) => h.id))) } } const handleDeploy = async () => { setSubmitting(true) setSubmitError(null) try { const pkgList = packages .split(',') .map((p) => p.trim()) .filter((p) => p.length > 0) const res = await jobsApi.create({ host_ids: Array.from(selectedIds), packages: pkgList, immediate, allow_reboot: allowReboot, notes: notes.trim() || undefined, }) const job = res.data as { id: string } setCreatedJobId(job.id) setActiveStep(2) } catch (err: unknown) { const msg = err instanceof Error ? err.message : 'Deployment failed. Please try again.' setSubmitError(msg) setActiveStep(2) } finally { setSubmitting(false) } } const handleReset = () => { setActiveStep(0) setSelectedIds(new Set()) setImmediate(true) setAllowReboot(false) setNotes('') setPackages('') setSubmitError(null) setCreatedJobId(null) } const selectedHosts = hosts.filter((h) => selectedIds.has(h.id)) return ( Patch Deployment {STEPS.map((label) => ( {label} ))} {/* ── Step 0: Select Hosts ── */} {activeStep === 0 && ( Select Target Hosts {hostsError && ( {hostsError} )} setSearchQuery(e.target.value)} InputProps={{ startAdornment: ( ), }} sx={{ minWidth: 260 }} /> setHealthFilter(e.target.value as HostHealthStatus | '')} SelectProps={{ native: true }} sx={{ minWidth: 160 }} > setPatchesFilter(e.target.value as 'all' | 'missing' | 'uptodate')} SelectProps={{ native: true }} sx={{ minWidth: 160 }} > {hostsLoading ? ( ) : ( 0 && filteredHosts.every((h) => selectedIds.has(h.id)) } indeterminate={ filteredHosts.some((h) => selectedIds.has(h.id)) && !filteredHosts.every((h) => selectedIds.has(h.id)) } onChange={handleToggleAll} disabled={filteredHosts.length === 0} /> Display Name FQDN IP Address Health Checks Patches OS {filteredHosts.length === 0 ? ( No hosts found ) : ( filteredHosts.map((host) => ( handleToggleHost(host.id)} > handleToggleHost(host.id)} onClick={(e) => e.stopPropagation()} /> {host.display_name} {host.fqdn} {host.ip_address} {host.health_check_status === 'all_healthy' ? ( ) : host.health_check_status === 'some_unhealthy' ? ( ) : ( )} 0 ? 'error' : 'success'} size="small" /> {host.os_name ?? host.os_family ?? '—'} )) )}
)} {selectedIds.size} host{selectedIds.size !== 1 ? 's' : ''} selected
)} {/* ── Step 1: Review & Configure ── */} {activeStep === 1 && ( Review & Configure Selected Hosts ({selectedHosts.length}) {selectedHosts.map((h) => ( handleToggleHost(h.id)} size="small" /> ))} setImmediate(e.target.checked)} /> } label={ {immediate ? 'Apply Now' : 'Queue for Maintenance Window'} {immediate ? 'Job will run immediately on the selected hosts' : 'Job will run during the next scheduled maintenance window'} } /> setAllowReboot(e.target.checked)} /> } label="Allow reboot after patching" /> setPackages(e.target.value)} fullWidth helperText="e.g. openssl, curl, libssl1.1" /> setNotes(e.target.value)} fullWidth /> )} {/* ── Step 2: Result ── */} {activeStep === 2 && ( Deployment Result {createdJobId ? ( Deployment job created successfully! Job ID: {createdJobId} ) : ( Deployment failed {submitError && ( {submitError} )} )} {createdJobId && ( )} )}
) }