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:
@ -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>} />
|
||||
|
||||
@ -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`),
|
||||
}
|
||||
|
||||
222
frontend/src/pages/DashboardPage.tsx
Normal file
222
frontend/src/pages/DashboardPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
513
frontend/src/pages/JobsPage.tsx
Normal file
513
frontend/src/pages/JobsPage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user