Some checks failed
CI Pipeline / Rust Format Check (push) Failing after 4s
CI Pipeline / Clippy Lints (push) Successful in 46s
CI Pipeline / Rust Unit Tests (push) Successful in 1m1s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Failing after 10s
CI Pipeline / Build .deb & Release (push) Has been skipped
- Added health_check_poller.rs: periodic service/HTTP health checks - Added pre-patch health gate in job_executor.rs - Added waiting_health_check job status (migration 008) - Added health_check_status to HostSummary and hosts API - Added health check types and API functions to frontend - Added health check UI section to HostDetailPage - Added health check status indicators to HostsPage and PatchDeploymentPage - Added serde default for health_check_poll_interval_secs - Fixed missing AgentClient import in health_check_poller.rs - Fixed missing ws_relay import in main.rs - Fixed missing closing paren in retry_pending_jobs SQL - Added ReadWritePaths for /etc/patch-manager/keys in systemd services
474 lines
16 KiB
TypeScript
474 lines
16 KiB
TypeScript
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<HostHealthStatus, 'success' | 'warning' | 'error' | 'default'> = {
|
|
healthy: 'success',
|
|
degraded: 'warning',
|
|
unreachable: 'error',
|
|
pending: 'default',
|
|
}
|
|
return <Chip label={status} color={map[status]} size="small" />
|
|
}
|
|
|
|
// ── PatchDeploymentPage ───────────────────────────────────────────────────────
|
|
export default function PatchDeploymentPage() {
|
|
const navigate = useNavigate()
|
|
const [activeStep, setActiveStep] = useState(0)
|
|
|
|
// Step 0 state
|
|
const [hosts, setHosts] = useState<Host[]>([])
|
|
const [hostsLoading, setHostsLoading] = useState(true)
|
|
const [hostsError, setHostsError] = useState<string | null>(null)
|
|
const [searchQuery, setSearchQuery] = useState('')
|
|
const [healthFilter, setHealthFilter] = useState<HostHealthStatus | ''>('')
|
|
const [patchesFilter, setPatchesFilter] = useState<'all' | 'missing' | 'uptodate'>('all')
|
|
const [selectedIds, setSelectedIds] = useState<Set<string>>(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<string | null>(null)
|
|
const [createdJobId, setCreatedJobId] = useState<string | null>(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 (
|
|
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
|
<Toolbar disableGutters sx={{ mb: 3 }}>
|
|
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
|
Patch Deployment
|
|
</Typography>
|
|
</Toolbar>
|
|
|
|
<Stepper activeStep={activeStep} sx={{ mb: 4 }}>
|
|
{STEPS.map((label) => (
|
|
<Step key={label}>
|
|
<StepLabel>{label}</StepLabel>
|
|
</Step>
|
|
))}
|
|
</Stepper>
|
|
|
|
{/* ── Step 0: Select Hosts ── */}
|
|
{activeStep === 0 && (
|
|
<Paper variant="outlined" sx={{ p: 3 }}>
|
|
<Typography variant="h6" fontWeight={600} mb={2}>
|
|
Select Target Hosts
|
|
</Typography>
|
|
|
|
{hostsError && (
|
|
<Alert severity="error" sx={{ mb: 2 }}>
|
|
{hostsError}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box display="flex" gap={2} mb={2} flexWrap="wrap">
|
|
<TextField
|
|
size="small"
|
|
placeholder="Search by name or FQDN…"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
InputProps={{
|
|
startAdornment: (
|
|
<InputAdornment position="start">
|
|
<SearchIcon fontSize="small" />
|
|
</InputAdornment>
|
|
),
|
|
}}
|
|
sx={{ minWidth: 260 }}
|
|
/>
|
|
<TextField
|
|
select
|
|
size="small"
|
|
label="Health Filter"
|
|
value={healthFilter}
|
|
onChange={(e) => setHealthFilter(e.target.value as HostHealthStatus | '')}
|
|
SelectProps={{ native: true }}
|
|
sx={{ minWidth: 160 }}
|
|
>
|
|
<option value="">All</option>
|
|
<option value="healthy">Healthy</option>
|
|
<option value="degraded">Degraded</option>
|
|
<option value="unreachable">Unreachable</option>
|
|
<option value="pending">Pending</option>
|
|
</TextField>
|
|
<TextField
|
|
select
|
|
size="small"
|
|
label="Patches Missing"
|
|
value={patchesFilter}
|
|
onChange={(e) => setPatchesFilter(e.target.value as 'all' | 'missing' | 'uptodate')}
|
|
SelectProps={{ native: true }}
|
|
sx={{ minWidth: 160 }}
|
|
>
|
|
<option value="all">All</option>
|
|
<option value="missing">Missing (>0)</option>
|
|
<option value="uptodate">Up to date (0)</option>
|
|
</TextField>
|
|
</Box>
|
|
|
|
{hostsLoading ? (
|
|
<Box display="flex" justifyContent="center" py={4}>
|
|
<CircularProgress />
|
|
</Box>
|
|
) : (
|
|
<Box sx={{ overflowX: 'auto' }}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox
|
|
checked={
|
|
filteredHosts.length > 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}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>Display Name</TableCell>
|
|
<TableCell>FQDN</TableCell>
|
|
<TableCell>IP Address</TableCell>
|
|
<TableCell>Health</TableCell>
|
|
<TableCell>Checks</TableCell>
|
|
<TableCell>Patches</TableCell>
|
|
<TableCell>OS</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{filteredHosts.length === 0 ? (
|
|
<TableRow>
|
|
<TableCell colSpan={8} align="center">
|
|
<Typography variant="body2" color="text.secondary" py={2}>
|
|
No hosts found
|
|
</Typography>
|
|
</TableCell>
|
|
</TableRow>
|
|
) : (
|
|
filteredHosts.map((host) => (
|
|
<TableRow
|
|
key={host.id}
|
|
hover
|
|
selected={selectedIds.has(host.id)}
|
|
sx={{ cursor: 'pointer' }}
|
|
onClick={() => handleToggleHost(host.id)}
|
|
>
|
|
<TableCell padding="checkbox">
|
|
<Checkbox
|
|
checked={selectedIds.has(host.id)}
|
|
onChange={() => handleToggleHost(host.id)}
|
|
onClick={(e) => e.stopPropagation()}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>{host.display_name}</TableCell>
|
|
<TableCell>{host.fqdn}</TableCell>
|
|
<TableCell>{host.ip_address}</TableCell>
|
|
<TableCell>
|
|
<HealthChip status={host.health_status} />
|
|
</TableCell>
|
|
<TableCell>
|
|
{host.health_check_status === 'all_healthy' ? (
|
|
<Tooltip title="All checks healthy"><CheckCircleIcon color="success" fontSize="small" /></Tooltip>
|
|
) : host.health_check_status === 'some_unhealthy' ? (
|
|
<Tooltip title="Some checks unhealthy"><CancelIcon color="error" fontSize="small" /></Tooltip>
|
|
) : (
|
|
<Tooltip title="No checks configured"><RemoveIcon color="disabled" fontSize="small" /></Tooltip>
|
|
)}
|
|
</TableCell>
|
|
<TableCell>
|
|
<Chip
|
|
label={host.patches_missing}
|
|
color={host.patches_missing > 0 ? 'error' : 'success'}
|
|
size="small"
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
{host.os_name ?? host.os_family ?? '—'}
|
|
</TableCell>
|
|
</TableRow>
|
|
))
|
|
)}
|
|
</TableBody>
|
|
</Table>
|
|
</Box>
|
|
)}
|
|
|
|
<Box display="flex" justifyContent="space-between" alignItems="center" mt={3}>
|
|
<Typography variant="body2" color="text.secondary">
|
|
{selectedIds.size} host{selectedIds.size !== 1 ? 's' : ''} selected
|
|
</Typography>
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => setActiveStep(1)}
|
|
disabled={selectedIds.size === 0}
|
|
>
|
|
Next
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* ── Step 1: Review & Configure ── */}
|
|
{activeStep === 1 && (
|
|
<Paper variant="outlined" sx={{ p: 3 }}>
|
|
<Typography variant="h6" fontWeight={600} mb={2}>
|
|
Review & Configure
|
|
</Typography>
|
|
|
|
<Typography variant="subtitle2" color="text.secondary" mb={1}>
|
|
Selected Hosts ({selectedHosts.length})
|
|
</Typography>
|
|
<Box display="flex" flexWrap="wrap" gap={1} mb={3}>
|
|
{selectedHosts.map((h) => (
|
|
<Chip
|
|
key={h.id}
|
|
label={h.display_name}
|
|
onDelete={() => handleToggleHost(h.id)}
|
|
size="small"
|
|
/>
|
|
))}
|
|
</Box>
|
|
|
|
<Box display="flex" flexDirection="column" gap={2.5} maxWidth={560}>
|
|
<FormControlLabel
|
|
control={
|
|
<Switch
|
|
checked={immediate}
|
|
onChange={(e) => setImmediate(e.target.checked)}
|
|
/>
|
|
}
|
|
label={
|
|
<Box>
|
|
<Typography variant="body2" fontWeight={600}>
|
|
{immediate ? 'Apply Now' : 'Queue for Maintenance Window'}
|
|
</Typography>
|
|
<Typography variant="caption" color="text.secondary">
|
|
{immediate
|
|
? 'Job will run immediately on the selected hosts'
|
|
: 'Job will run during the next scheduled maintenance window'}
|
|
</Typography>
|
|
</Box>
|
|
}
|
|
/>
|
|
|
|
<FormControlLabel
|
|
control={
|
|
<Checkbox
|
|
checked={allowReboot}
|
|
onChange={(e) => setAllowReboot(e.target.checked)}
|
|
/>
|
|
}
|
|
label="Allow reboot after patching"
|
|
/>
|
|
|
|
<TextField
|
|
label="Packages (optional)"
|
|
placeholder="Leave empty to apply all available patches, or enter comma-separated package names"
|
|
multiline
|
|
minRows={2}
|
|
value={packages}
|
|
onChange={(e) => setPackages(e.target.value)}
|
|
fullWidth
|
|
helperText="e.g. openssl, curl, libssl1.1"
|
|
/>
|
|
|
|
<TextField
|
|
label="Notes (optional)"
|
|
placeholder="Describe the purpose of this deployment…"
|
|
multiline
|
|
minRows={3}
|
|
value={notes}
|
|
onChange={(e) => setNotes(e.target.value)}
|
|
fullWidth
|
|
/>
|
|
</Box>
|
|
|
|
<Box display="flex" gap={2} mt={4}>
|
|
<Button variant="outlined" onClick={() => setActiveStep(0)}>
|
|
Back
|
|
</Button>
|
|
<Button
|
|
variant="contained"
|
|
color="primary"
|
|
onClick={handleDeploy}
|
|
disabled={submitting}
|
|
startIcon={submitting ? <CircularProgress size={16} color="inherit" /> : undefined}
|
|
>
|
|
{submitting ? 'Deploying…' : 'Deploy'}
|
|
</Button>
|
|
</Box>
|
|
</Paper>
|
|
)}
|
|
|
|
{/* ── Step 2: Result ── */}
|
|
{activeStep === 2 && (
|
|
<Paper variant="outlined" sx={{ p: 3 }}>
|
|
<Typography variant="h6" fontWeight={600} mb={3}>
|
|
Deployment Result
|
|
</Typography>
|
|
|
|
{createdJobId ? (
|
|
<Alert severity="success" sx={{ mb: 3 }}>
|
|
<Typography fontWeight={600}>Deployment job created successfully!</Typography>
|
|
<Typography variant="body2" mt={0.5}>
|
|
Job ID: <strong>{createdJobId}</strong>
|
|
</Typography>
|
|
</Alert>
|
|
) : (
|
|
<Alert severity="error" sx={{ mb: 3 }}>
|
|
<Typography fontWeight={600}>Deployment failed</Typography>
|
|
{submitError && (
|
|
<Typography variant="body2" mt={0.5}>
|
|
{submitError}
|
|
</Typography>
|
|
)}
|
|
</Alert>
|
|
)}
|
|
|
|
<Box display="flex" gap={2}>
|
|
<Button variant="outlined" onClick={handleReset}>
|
|
Deploy Another
|
|
</Button>
|
|
{createdJobId && (
|
|
<Button
|
|
variant="contained"
|
|
onClick={() => navigate('/jobs')}
|
|
>
|
|
View Jobs
|
|
</Button>
|
|
)}
|
|
</Box>
|
|
</Paper>
|
|
)}
|
|
</Container>
|
|
)
|
|
}
|