Private
Public Access
1
0
Files
linux_patch_manager/frontend/src/pages/PatchDeploymentPage.tsx
Echo 6f9c6dc881 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
2026-04-23 17:08:43 +00:00

436 lines
14 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,
} 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 &amp; 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>
)
}