Private
Public Access
1
0

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:
2026-04-23 17:08:43 +00:00
parent a6eb762962
commit 6f9c6dc881
30 changed files with 8465 additions and 44 deletions

View File

@ -8,6 +8,9 @@ import HostsPage from './pages/HostsPage'
import HostDetailPage from './pages/HostDetailPage'
import GroupsPage from './pages/GroupsPage'
import UsersPage from './pages/UsersPage'
import DashboardPage from './pages/DashboardPage'
import PatchDeploymentPage from './pages/PatchDeploymentPage'
import JobsPage from './pages/JobsPage'
// Placeholder pages — implemented in later milestones
const PlaceholderPage = ({ title }: { title: string }) => (
@ -35,15 +38,15 @@ function App() {
<Route path="/mfa/setup" element={<RequireAuth><MfaSetupPage /></RequireAuth>} />
{/* Protected — M3 */}
<Route path="/dashboard" element={<RequireAuth><PlaceholderPage title="Dashboard" /></RequireAuth>} />
<Route path="/dashboard" element={<RequireAuth><DashboardPage /></RequireAuth>} />
<Route path="/hosts" element={<RequireAuth><HostsPage /></RequireAuth>} />
<Route path="/hosts/:id" element={<RequireAuth><HostDetailPage /></RequireAuth>} />
<Route path="/groups" element={<RequireAuth><GroupsPage /></RequireAuth>} />
<Route path="/users" element={<RequireAuth><UsersPage /></RequireAuth>} />
{/* Protected — later milestones */}
<Route path="/jobs" element={<RequireAuth><PlaceholderPage title="Jobs" /></RequireAuth>} />
<Route path="/deployment" element={<RequireAuth><PlaceholderPage title="Patch Deployment" /></RequireAuth>} />
<Route path="/jobs" element={<RequireAuth><JobsPage /></RequireAuth>} />
<Route path="/deployment" element={<RequireAuth><PatchDeploymentPage /></RequireAuth>} />
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />

View File

@ -1,6 +1,7 @@
import axios, { type AxiosError } from 'axios'
import type { InternalAxiosRequestConfig } from 'axios'
import { useAuthStore } from '../store/authStore'
import type { FleetStatus, CreateJobRequest } from '../types'
const BASE_URL = '/api/v1'
@ -93,3 +94,32 @@ export const authApi = {
verifyMfa: (secretBase32: string, code: string) =>
apiClient.post('/auth/mfa/verify', { secret_base32: secretBase32, code }),
}
// ── Fleet API functions ──────────────────────────────────────────────────────
export const fleetApi = {
getStatus: () => apiClient.get<FleetStatus>('/status/fleet'),
}
// ── Hosts API functions ──────────────────────────────────────────────────────
export const hostsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/hosts', { params }),
get: (id: string) => apiClient.get(`/hosts/${id}`),
delete: (id: string) => apiClient.delete(`/hosts/${id}`),
refresh: (id: string) => apiClient.post(`/hosts/${id}/refresh`),
}
// ── Jobs API ─────────────────────────────────────────────────────────────────
export const jobsApi = {
list: (params?: Record<string, unknown>) => apiClient.get('/jobs', { params }),
get: (id: string) => apiClient.get(`/jobs/${id}`),
create: (body: CreateJobRequest) => apiClient.post('/jobs', body),
cancel: (id: string) => apiClient.post(`/jobs/${id}/cancel`),
rollback: (id: string) => apiClient.post(`/jobs/${id}/rollback`),
}
// ── Patches API (per-host patch listing) ──────────────────────────────────────
export const patchesApi = {
// Returns patches available on a specific host via the manager's proxy
// The backend reads from host_patch_data table (cached from agent poll)
getHostPatches: (hostId: string) => apiClient.get(`/hosts/${hostId}/patches`),
}

View File

@ -0,0 +1,222 @@
import { useEffect, useState, useCallback } from 'react'
import {
Alert,
Box,
Card,
CardContent,
CircularProgress,
Container,
Grid,
IconButton,
LinearProgress,
Toolbar,
Tooltip,
Typography,
} from '@mui/material'
import {
CheckCircle,
Warning,
Error as ErrorIcon,
HourglassEmpty,
BugReport,
RestartAlt,
Refresh as RefreshIcon,
} from '@mui/icons-material'
import { fleetApi } from '../api/client'
import type { FleetStatus } from '../types'
// ── StatCard ─────────────────────────────────────────────────────────────────
function StatCard({
title,
value,
color,
icon,
}: {
title: string
value: number
color: string
icon: React.ReactNode
}) {
return (
<Card variant="outlined" sx={{ borderLeft: `4px solid ${color}`, height: '100%' }}>
<CardContent>
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
{icon}
<Typography variant="h4" fontWeight={700} lineHeight={1}>
{value}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
{title}
</Typography>
</CardContent>
</Card>
)
}
// ── DashboardPage ─────────────────────────────────────────────────────────────
export default function DashboardPage() {
const [status, setStatus] = useState<FleetStatus | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await fleetApi.getStatus()
setStatus(res.data)
} catch {
setError('Failed to load fleet status')
} finally {
setLoading(false)
}
}, [])
// Initial load
useEffect(() => {
load()
}, [load])
// Auto-refresh every 60 seconds
useEffect(() => {
const t = setInterval(load, 60_000)
return () => clearInterval(t)
}, [load])
return (
<Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 3 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
Dashboard
</Typography>
<Tooltip title="Refresh">
<span>
<IconButton onClick={load} disabled={loading}>
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
</IconButton>
</span>
</Tooltip>
</Toolbar>
{error && (
<Alert severity="error" sx={{ mb: 3 }}>
{error}
</Alert>
)}
{!loading && !status && !error && (
<Alert severity="info">No fleet data available.</Alert>
)}
{status && (
<Box>
{/* ── Row 1: Status stat cards ── */}
<Grid container spacing={2} sx={{ mb: 3 }}>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Healthy"
value={status.healthy}
color="#2e7d32"
icon={<CheckCircle sx={{ color: '#2e7d32' }} />}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Degraded"
value={status.degraded}
color="#ed6c02"
icon={<Warning sx={{ color: '#ed6c02' }} />}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Unreachable"
value={status.unreachable}
color="#d32f2f"
icon={<ErrorIcon sx={{ color: '#d32f2f' }} />}
/>
</Grid>
<Grid size={{ xs: 12, sm: 6, md: 3 }}>
<StatCard
title="Pending / Unknown"
value={status.pending}
color="#9e9e9e"
icon={<HourglassEmpty sx={{ color: '#9e9e9e' }} />}
/>
</Grid>
</Grid>
{/* ── Row 2: Compliance bar ── */}
<Card variant="outlined" sx={{ mb: 3 }}>
<CardContent>
<Box display="flex" justifyContent="space-between" alignItems="center" mb={1}>
<Typography variant="subtitle1" fontWeight={600}>
Compliance
</Typography>
<Typography variant="h6" fontWeight={700}>
{status.compliance_pct.toFixed(1)}%
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={Math.min(status.compliance_pct, 100)}
sx={{
height: 12,
borderRadius: 6,
backgroundColor: '#e0e0e0',
'& .MuiLinearProgress-bar': {
borderRadius: 6,
backgroundColor:
status.compliance_pct >= 90
? '#2e7d32'
: status.compliance_pct >= 70
? '#ed6c02'
: '#d32f2f',
},
}}
/>
<Typography variant="caption" color="text.secondary" mt={0.5} display="block">
{status.total_hosts} total host{status.total_hosts !== 1 ? 's' : ''} in fleet
</Typography>
</CardContent>
</Card>
{/* ── Row 3: Patches + Reboot ── */}
<Grid container spacing={2}>
<Grid size={{ xs: 12, sm: 6 }}>
<Card variant="outlined">
<CardContent>
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
<BugReport color="action" />
<Typography variant="h5" fontWeight={700}>
{status.total_pending_patches.toLocaleString()}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Pending Patches
</Typography>
</CardContent>
</Card>
</Grid>
<Grid size={{ xs: 12, sm: 6 }}>
<Card variant="outlined">
<CardContent>
<Box display="flex" alignItems="center" gap={1} mb={0.5}>
<RestartAlt color="action" />
<Typography variant="h5" fontWeight={700}>
{status.hosts_requiring_reboot.toLocaleString()}
</Typography>
</Box>
<Typography variant="body2" color="text.secondary">
Hosts Requiring Reboot
</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
)}
</Container>
)
}

View File

@ -50,7 +50,7 @@ export default function GroupsPage() {
<TableBody>
{groups.map(g => (
<TableRow key={g.id} hover>
<TableCell fontWeight={600}>{g.name}</TableCell>
<TableCell sx={{ fontWeight: 600 }}>{g.name}</TableCell>
<TableCell>{g.description || '—'}</TableCell>
<TableCell>{new Date(g.created_at).toLocaleDateString()}</TableCell>
<TableCell>

View File

@ -1,6 +1,6 @@
import { useEffect, useState } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Alert, Box, Button, Chip, CircularProgress, Container, Divider, Grid, Paper, Typography } from '@mui/material'
import { Alert, Box, Button, CircularProgress, Container, Divider, Grid, Paper, Typography } from '@mui/material'
import { ArrowBack } from '@mui/icons-material'
import { apiClient } from '../api/client'
@ -29,7 +29,7 @@ export default function HostDetailPage() {
<Divider sx={{ mb: 2 }} />
<Grid container spacing={2}>
{host && Object.entries(host).map(([k, v]) => v !== null && v !== '' ? (
<Grid item xs={12} sm={6} md={4} key={k}>
<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>

View File

@ -1,4 +1,4 @@
import { useEffect, useState } from 'react'
import { useEffect, useState, useCallback } from 'react'
import {
Box, Button, Chip, CircularProgress, Container, IconButton,
Paper, Table, TableBody, TableCell, TableContainer, TableHead,
@ -7,6 +7,7 @@ import {
import { Add as AddIcon, Refresh as RefreshIcon, Delete as DeleteIcon } from '@mui/icons-material'
import { useNavigate } from 'react-router-dom'
import { apiClient } from '../api/client'
import { hostsApi } from '../api/client'
import type { Host, HostHealthStatus } from '../types'
const statusColor = (s: HostHealthStatus) =>
@ -18,8 +19,9 @@ export default function HostsPage() {
const [total, setTotal] = useState(0)
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [refreshing, setRefreshing] = useState<string | null>(null)
const load = async () => {
const load = useCallback(async () => {
setLoading(true)
try {
const res = await apiClient.get('/hosts', { params: { limit: 100 } })
@ -27,6 +29,17 @@ export default function HostsPage() {
setTotal(res.data.total)
} catch { /* handled by interceptor */ }
finally { setLoading(false) }
}, [])
const handleRefresh = async (e: React.MouseEvent, hostId: string) => {
e.stopPropagation()
setRefreshing(hostId)
try {
await hostsApi.refresh(hostId)
setTimeout(() => { load(); setRefreshing(null) }, 2000)
} catch {
setRefreshing(null)
}
}
useEffect(() => { load() }, [])
@ -72,6 +85,15 @@ export default function HostsPage() {
</TableCell>
<TableCell>{h.agent_version ?? '—'}</TableCell>
<TableCell onClick={e => e.stopPropagation()}>
<Tooltip title="Request refresh">
<IconButton size="small" color="primary"
disabled={refreshing === h.id}
onClick={(e) => handleRefresh(e, h.id)}>
{refreshing === h.id
? <CircularProgress size={16} />
: <RefreshIcon fontSize="small" />}
</IconButton>
</Tooltip>
<Tooltip title="Delete"><IconButton size="small" color="error">
<DeleteIcon fontSize="small" />
</IconButton></Tooltip>

View File

@ -0,0 +1,513 @@
import { useEffect, useState, useCallback } from 'react'
import {
Alert,
Box,
Button,
Chip,
CircularProgress,
Collapse,
Container,
Dialog,
DialogActions,
DialogContent,
DialogTitle,
IconButton,
Paper,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Toolbar,
Tooltip,
Typography,
} from '@mui/material'
import {
Cancel as CancelIcon,
ExpandLess,
ExpandMore,
Refresh as RefreshIcon,
Replay as ReplayIcon,
} from '@mui/icons-material'
import { jobsApi } from '../api/client'
import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost } from '../types'
// ── Status chip ───────────────────────────────────────────────────────────────
type ChipColor = 'default' | 'info' | 'warning' | 'success' | 'error'
function statusColor(status: JobStatus): ChipColor {
const map: Record<JobStatus, ChipColor> = {
queued: 'default',
pending: 'info',
running: 'warning',
succeeded: 'success',
failed: 'error',
cancelled: 'default',
}
return map[status]
}
function StatusChip({ status }: { status: JobStatus }) {
return <Chip label={status} color={statusColor(status)} size="small" />
}
// ── Kind label ────────────────────────────────────────────────────────────────
function kindLabel(kind: JobKind): string {
const map: Record<JobKind, string> = {
patch_apply: 'Patch Apply',
patch_remove: 'Patch Remove',
reboot: 'Reboot',
rollback: 'Rollback',
}
return map[kind]
}
// ── Format date ───────────────────────────────────────────────────────────────
function fmtDate(iso?: string): string {
if (!iso) return '—'
return new Date(iso).toLocaleString()
}
// ── Per-host detail table ─────────────────────────────────────────────────────
function HostDetailTable({ hosts }: { hosts: PatchJobHost[] }) {
if (hosts.length === 0) {
return (
<Box py={2} px={3}>
<Typography variant="body2" color="text.secondary">
No host entries for this job.
</Typography>
</Box>
)
}
return (
<Box sx={{ backgroundColor: 'action.hover', px: 2, pb: 2 }}>
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Host</TableCell>
<TableCell>Status</TableCell>
<TableCell>Agent Job ID</TableCell>
<TableCell>Retries</TableCell>
<TableCell>Error</TableCell>
<TableCell>Started</TableCell>
<TableCell>Completed</TableCell>
</TableRow>
</TableHead>
<TableBody>
{hosts.map((h) => (
<TableRow key={h.id}>
<TableCell>{h.host_display_name}</TableCell>
<TableCell>
<StatusChip status={h.status} />
</TableCell>
<TableCell>
<Typography variant="caption" fontFamily="monospace">
{h.agent_job_id ?? '—'}
</Typography>
</TableCell>
<TableCell>{h.retry_count}</TableCell>
<TableCell>
{h.error_message ? (
<Tooltip title={h.error_message}>
<Typography
variant="caption"
color="error"
sx={{
maxWidth: 200,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{h.error_message}
</Typography>
</Tooltip>
) : (
'—'
)}
</TableCell>
<TableCell>{fmtDate(h.started_at)}</TableCell>
<TableCell>{fmtDate(h.completed_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</Box>
)
}
// ── Expandable job row ────────────────────────────────────────────────────────
interface JobRowProps {
job: PatchJobSummary
expanded: boolean
onToggle: (id: string) => void
onCancel: (id: string) => void
onRollback: (id: string) => void
cancelLoading: boolean
rollbackLoading: boolean
detail: PatchJob | null
detailLoading: boolean
detailError: string | null
}
function JobRow({
job,
expanded,
onToggle,
onCancel,
onRollback,
cancelLoading,
rollbackLoading,
detail,
detailLoading,
detailError,
}: JobRowProps) {
const canCancel = job.status === 'queued' || job.status === 'pending'
const canRollback = job.status === 'succeeded'
return (
<>
<TableRow
hover
sx={{ cursor: 'pointer', '& > *': { borderBottom: expanded ? 'none' : undefined } }}
onClick={() => onToggle(job.id)}
>
<TableCell padding="checkbox">
<IconButton size="small" onClick={(e) => { e.stopPropagation(); onToggle(job.id) }}>
{expanded ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
</IconButton>
</TableCell>
<TableCell>
<Typography variant="caption" fontFamily="monospace">
{fmtDate(job.created_at)}
</Typography>
</TableCell>
<TableCell>{kindLabel(job.kind)}</TableCell>
<TableCell>
<StatusChip status={job.status} />
</TableCell>
<TableCell align="right">{job.host_count}</TableCell>
<TableCell align="right">
<Typography color="success.main" fontWeight={600}>
{job.succeeded_count}
</Typography>
</TableCell>
<TableCell align="right">
<Typography color={job.failed_count > 0 ? 'error.main' : 'text.primary'} fontWeight={600}>
{job.failed_count}
</Typography>
</TableCell>
<TableCell>
<Chip
label={job.immediate ? 'Immediate' : 'Scheduled'}
size="small"
variant="outlined"
/>
</TableCell>
<TableCell>
<Typography
variant="caption"
sx={{
maxWidth: 180,
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}}
>
{job.notes || '—'}
</Typography>
</TableCell>
<TableCell onClick={(e) => e.stopPropagation()}>
<Box display="flex" gap={0.5}>
{canCancel && (
<Tooltip title="Cancel job">
<span>
<IconButton
size="small"
color="error"
disabled={cancelLoading}
onClick={() => onCancel(job.id)}
>
{cancelLoading ? (
<CircularProgress size={16} />
) : (
<CancelIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
)}
{canRollback && (
<Tooltip title="Rollback job">
<span>
<IconButton
size="small"
color="warning"
disabled={rollbackLoading}
onClick={() => onRollback(job.id)}
>
{rollbackLoading ? (
<CircularProgress size={16} />
) : (
<ReplayIcon fontSize="small" />
)}
</IconButton>
</span>
</Tooltip>
)}
</Box>
</TableCell>
</TableRow>
{/* ── Expandable detail row ── */}
<TableRow>
<TableCell colSpan={10} sx={{ py: 0, border: expanded ? undefined : 'none' }}>
<Collapse in={expanded} timeout="auto" unmountOnExit>
{detailLoading ? (
<Box display="flex" justifyContent="center" py={2}>
<CircularProgress size={24} />
</Box>
) : detailError ? (
<Alert severity="error" sx={{ m: 1 }}>
{detailError}
</Alert>
) : detail ? (
<HostDetailTable hosts={detail.hosts} />
) : null}
</Collapse>
</TableCell>
</TableRow>
</>
)
}
// ── JobsPage ──────────────────────────────────────────────────────────────────
export default function JobsPage() {
const [jobs, setJobs] = useState<PatchJobSummary[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false)
const [loadingMore, setLoadingMore] = useState(false)
// Expanded row detail state
const [expandedId, setExpandedId] = useState<string | null>(null)
const [details, setDetails] = useState<Record<string, PatchJob>>({})
const [detailLoading, setDetailLoading] = useState<Record<string, boolean>>({})
const [detailError, setDetailError] = useState<Record<string, string>>({})
// Action state
const [cancelLoadingId, setCancelLoadingId] = useState<string | null>(null)
const [rollbackLoadingId, setRollbackLoadingId] = useState<string | null>(null)
// Rollback confirm dialog
const [rollbackTargetId, setRollbackTargetId] = useState<string | null>(null)
const [actionError, setActionError] = useState<string | null>(null)
const LIMIT = 25
const loadJobs = useCallback(async (newOffset = 0) => {
if (newOffset === 0) {
setLoading(true)
setError(null)
} else {
setLoadingMore(true)
}
try {
const res = await jobsApi.list({ limit: LIMIT, offset: newOffset })
const data = res.data as { jobs?: PatchJobSummary[]; total?: number } | PatchJobSummary[]
const items: PatchJobSummary[] = Array.isArray(data) ? data : (data.jobs ?? [])
const total: number = Array.isArray(data) ? items.length : (data.total ?? items.length)
if (newOffset === 0) {
setJobs(items)
} else {
setJobs((prev) => [...prev, ...items])
}
setOffset(newOffset + items.length)
setHasMore(newOffset + items.length < total)
} catch {
if (newOffset === 0) setError('Failed to load jobs')
} finally {
setLoading(false)
setLoadingMore(false)
}
}, [])
useEffect(() => {
loadJobs(0)
}, [loadJobs])
const handleToggleExpand = useCallback(async (id: string) => {
if (expandedId === id) {
setExpandedId(null)
return
}
setExpandedId(id)
if (details[id]) return
setDetailLoading((prev) => ({ ...prev, [id]: true }))
setDetailError((prev) => { const n = { ...prev }; delete n[id]; return n })
try {
const res = await jobsApi.get(id)
setDetails((prev) => ({ ...prev, [id]: res.data as PatchJob }))
} catch {
setDetailError((prev) => ({ ...prev, [id]: 'Failed to load job detail' }))
} finally {
setDetailLoading((prev) => ({ ...prev, [id]: false }))
}
}, [expandedId, details])
const handleCancel = useCallback(async (id: string) => {
setCancelLoadingId(id)
setActionError(null)
try {
await jobsApi.cancel(id)
await loadJobs(0)
} catch {
setActionError(`Failed to cancel job ${id}`)
} finally {
setCancelLoadingId(null)
}
}, [loadJobs])
const handleRollbackConfirm = useCallback(async () => {
if (!rollbackTargetId) return
const id = rollbackTargetId
setRollbackTargetId(null)
setRollbackLoadingId(id)
setActionError(null)
try {
await jobsApi.rollback(id)
await loadJobs(0)
} catch {
setActionError(`Failed to rollback job ${id}`)
} finally {
setRollbackLoadingId(null)
}
}, [rollbackTargetId, loadJobs])
return (
<Container maxWidth="xl" sx={{ mt: 3 }}>
<Toolbar disableGutters sx={{ mb: 2 }}>
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
Jobs
</Typography>
<Tooltip title="Refresh">
<span>
<IconButton onClick={() => loadJobs(0)} disabled={loading}>
{loading ? <CircularProgress size={20} /> : <RefreshIcon />}
</IconButton>
</span>
</Tooltip>
</Toolbar>
{error && (
<Alert severity="error" sx={{ mb: 2 }}>
{error}
</Alert>
)}
{actionError && (
<Alert severity="error" sx={{ mb: 2 }} onClose={() => setActionError(null)}>
{actionError}
</Alert>
)}
<Paper variant="outlined">
<TableContainer>
<Table size="small">
<TableHead>
<TableRow>
<TableCell padding="checkbox" />
<TableCell>Created</TableCell>
<TableCell>Kind</TableCell>
<TableCell>Status</TableCell>
<TableCell align="right">Hosts</TableCell>
<TableCell align="right">Succeeded</TableCell>
<TableCell align="right">Failed</TableCell>
<TableCell>Schedule</TableCell>
<TableCell>Notes</TableCell>
<TableCell>Actions</TableCell>
</TableRow>
</TableHead>
<TableBody>
{loading && jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" sx={{ py: 4 }}>
<CircularProgress size={32} />
</TableCell>
</TableRow>
) : jobs.length === 0 ? (
<TableRow>
<TableCell colSpan={10} align="center" sx={{ py: 4 }}>
<Typography variant="body2" color="text.secondary">
No jobs found
</Typography>
</TableCell>
</TableRow>
) : (
jobs.map((job) => (
<JobRow
key={job.id}
job={job}
expanded={expandedId === job.id}
onToggle={handleToggleExpand}
onCancel={handleCancel}
onRollback={(id) => setRollbackTargetId(id)}
cancelLoading={cancelLoadingId === job.id}
rollbackLoading={rollbackLoadingId === job.id}
detail={details[job.id] ?? null}
detailLoading={detailLoading[job.id] ?? false}
detailError={detailError[job.id] ?? null}
/>
))
)}
</TableBody>
</Table>
</TableContainer>
{hasMore && (
<Box display="flex" justifyContent="center" py={2}>
<Button
variant="outlined"
onClick={() => loadJobs(offset)}
disabled={loadingMore}
startIcon={loadingMore ? <CircularProgress size={16} /> : undefined}
>
{loadingMore ? 'Loading…' : 'Load More'}
</Button>
</Box>
)}
</Paper>
{/* ── Rollback confirm dialog ── */}
<Dialog
open={rollbackTargetId !== null}
onClose={() => setRollbackTargetId(null)}
maxWidth="xs"
fullWidth
>
<DialogTitle>Confirm Rollback</DialogTitle>
<DialogContent>
<Typography variant="body2">
Are you sure you want to rollback job{' '}
<strong>{rollbackTargetId}</strong>? This will create a new rollback job
that attempts to revert the applied patches.
</Typography>
</DialogContent>
<DialogActions>
<Button onClick={() => setRollbackTargetId(null)}>Cancel</Button>
<Button
variant="contained"
color="warning"
onClick={handleRollbackConfirm}
>
Rollback
</Button>
</DialogActions>
</Dialog>
</Container>
)
}

View 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 &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>
)
}

View File

@ -45,3 +45,75 @@ export interface User {
is_active: boolean
last_login_at?: string
}
export interface FleetStatus {
total_hosts: number
healthy: number
degraded: number
unreachable: number
pending: number
total_pending_patches: number
hosts_requiring_reboot: number
compliance_pct: number
}
export interface PatchInfo {
name: string
current_version: string
available_version: string
severity: 'critical' | 'high' | 'medium' | 'low'
description: string
cve_ids: string[]
requires_reboot: boolean
}
export interface PatchJobHost {
id: string
job_id: string
host_id: string
host_display_name: string
status: JobStatus
agent_job_id?: string
retry_count: number
output: string
error_message?: string
retry_next_at?: string
started_at?: string
completed_at?: string
}
export interface PatchJob {
id: string
kind: JobKind
status: JobStatus
immediate: boolean
patch_selection: string[]
notes: string
created_at: string
started_at?: string
completed_at?: string
hosts: PatchJobHost[]
}
export interface PatchJobSummary {
id: string
kind: JobKind
status: JobStatus
immediate: boolean
host_count: number
succeeded_count: number
failed_count: number
notes: string
created_at: string
started_at?: string
completed_at?: string
}
export interface CreateJobRequest {
host_ids: string[]
packages: string[] // empty = all patches
immediate: boolean
maintenance_window_id?: string
allow_reboot?: boolean
notes?: string
}