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
113 lines
4.6 KiB
TypeScript
113 lines
4.6 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import {
|
|
Box, Button, Chip, CircularProgress, Container, IconButton,
|
|
Paper, Table, TableBody, TableCell, TableContainer, TableHead,
|
|
TableRow, TextField, Toolbar, Tooltip, Typography,
|
|
} from '@mui/material'
|
|
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) =>
|
|
s === 'healthy' ? 'success' : s === 'degraded' ? 'warning' : s === 'unreachable' ? 'error' : 'default'
|
|
|
|
export default function HostsPage() {
|
|
const navigate = useNavigate()
|
|
const [hosts, setHosts] = useState<Host[]>([])
|
|
const [total, setTotal] = useState(0)
|
|
const [loading, setLoading] = useState(true)
|
|
const [search, setSearch] = useState('')
|
|
const [refreshing, setRefreshing] = useState<string | null>(null)
|
|
|
|
const load = useCallback(async () => {
|
|
setLoading(true)
|
|
try {
|
|
const res = await apiClient.get('/hosts', { params: { limit: 100 } })
|
|
setHosts(res.data.hosts)
|
|
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() }, [])
|
|
|
|
const filtered = hosts.filter(h =>
|
|
h.fqdn.toLowerCase().includes(search.toLowerCase()) ||
|
|
h.display_name.toLowerCase().includes(search.toLowerCase())
|
|
)
|
|
|
|
return (
|
|
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
|
<Toolbar disableGutters sx={{ mb: 2 }}>
|
|
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>Hosts</Typography>
|
|
<TextField size="small" placeholder="Search..." value={search}
|
|
onChange={e => setSearch(e.target.value)} sx={{ mr: 2 }} />
|
|
<Tooltip title="Refresh"><IconButton onClick={load}><RefreshIcon /></IconButton></Tooltip>
|
|
<Button variant="contained" startIcon={<AddIcon />} onClick={() => navigate('/hosts/new')} sx={{ ml: 1 }}>Add Host</Button>
|
|
</Toolbar>
|
|
{loading ? <Box display="flex" justifyContent="center" mt={4}><CircularProgress /></Box> : (
|
|
<TableContainer component={Paper}>
|
|
<Table size="small">
|
|
<TableHead>
|
|
<TableRow>
|
|
<TableCell>FQDN</TableCell>
|
|
<TableCell>Display Name</TableCell>
|
|
<TableCell>IP Address</TableCell>
|
|
<TableCell>OS</TableCell>
|
|
<TableCell>Health</TableCell>
|
|
<TableCell>Agent</TableCell>
|
|
<TableCell>Actions</TableCell>
|
|
</TableRow>
|
|
</TableHead>
|
|
<TableBody>
|
|
{filtered.map(h => (
|
|
<TableRow key={h.id} hover sx={{ cursor: 'pointer' }}
|
|
onClick={() => navigate(`/hosts/${h.id}`)}>
|
|
<TableCell>{h.fqdn}</TableCell>
|
|
<TableCell>{h.display_name}</TableCell>
|
|
<TableCell>{h.ip_address}</TableCell>
|
|
<TableCell>{h.os_name ?? h.os_family ?? '—'}</TableCell>
|
|
<TableCell>
|
|
<Chip size="small" label={h.health_status} color={statusColor(h.health_status)} />
|
|
</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>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</TableContainer>
|
|
)}
|
|
<Typography variant="caption" color="text.secondary" mt={1} display="block">
|
|
Showing {filtered.length} of {total} hosts
|
|
</Typography>
|
|
</Container>
|
|
)
|
|
}
|