M5: Patch Deployment & Job Management
Backend: - migrations/003_jobs_scheduling.sql: retry_next_at/last_error columns, pg_notify trigger for immediate job dispatch, retry index - pm-agent-client: ApplyPatchesRequest/Response, AgentJobStatus, RollbackResponse types; apply_patches/job_status/rollback_job client methods + generic POST helper - pm-core/models: JobStatus, JobKind, PatchJob, PatchJobHost, CreateJobRequest, PatchJobSummary - pm-web/routes/jobs.rs: POST/GET /api/v1/jobs, GET /jobs/:id, POST /jobs/:id/cancel, POST /jobs/:id/rollback - pm-worker/job_executor.rs: NOTIFY listener, periodic scanner, execute_host_job, poll_running_jobs, handle_host_failure (3-retry exponential backoff 1m/5m/30m), sync_job_status, retry_pending_jobs - pm-worker/main.rs: spawn job_executor Frontend: - types/index.ts: PatchInfo, PatchJobHost, PatchJob, PatchJobSummary, CreateJobRequest interfaces - api/client.ts: jobsApi (list/get/create/cancel/rollback), patchesApi (getHostPatches) - pages/PatchDeploymentPage.tsx: 3-step MUI Stepper (host select → configure → result) - pages/JobsPage.tsx: job list table, expandable per-host detail, cancel/rollback actions with confirm dialog, load-more pagination - App.tsx: /jobs and /deployment routes wired to real pages cargo check: 0 errors | vite build: 0 errors
This commit is contained in:
435
frontend/src/pages/PatchDeploymentPage.tsx
Normal file
435
frontend/src/pages/PatchDeploymentPage.tsx
Normal file
@ -0,0 +1,435 @@
|
||||
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,
|
||||
} from '@mui/material'
|
||||
import { Search as SearchIcon } 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 [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
|
||||
return matchesSearch && matchesHealth
|
||||
})
|
||||
|
||||
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>
|
||||
</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>OS</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{filteredHosts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} 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.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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user