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:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user