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 = { queued: 'default', pending: 'info', running: 'warning', succeeded: 'success', failed: 'error', cancelled: 'default', } return map[status] } function StatusChip({ status }: { status: JobStatus }) { return } // ── Kind label ──────────────────────────────────────────────────────────────── function kindLabel(kind: JobKind): string { const map: Record = { 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 ( No host entries for this job. ) } return ( Host Status Agent Job ID Retries Error Started Completed {hosts.map((h) => ( {h.host_display_name} {h.agent_job_id ?? '—'} {h.retry_count} {h.error_message ? ( {h.error_message} ) : ( '—' )} {fmtDate(h.started_at)} {fmtDate(h.completed_at)} ))}
) } // ── 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 ( <> *': { borderBottom: expanded ? 'none' : undefined } }} onClick={() => onToggle(job.id)} > { e.stopPropagation(); onToggle(job.id) }}> {expanded ? : } {fmtDate(job.created_at)} {kindLabel(job.kind)} {job.host_count} {job.succeeded_count} 0 ? 'error.main' : 'text.primary'} fontWeight={600}> {job.failed_count} {job.notes || '—'} e.stopPropagation()}> {canCancel && ( onCancel(job.id)} > {cancelLoading ? ( ) : ( )} )} {canRollback && ( onRollback(job.id)} > {rollbackLoading ? ( ) : ( )} )} {/* ── Expandable detail row ── */} {detailLoading ? ( ) : detailError ? ( {detailError} ) : detail ? ( ) : null} ) } // ── JobsPage ────────────────────────────────────────────────────────────────── export default function JobsPage() { const [jobs, setJobs] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [offset, setOffset] = useState(0) const [hasMore, setHasMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false) // Expanded row detail state const [expandedId, setExpandedId] = useState(null) const [details, setDetails] = useState>({}) const [detailLoading, setDetailLoading] = useState>({}) const [detailError, setDetailError] = useState>({}) // Action state const [cancelLoadingId, setCancelLoadingId] = useState(null) const [rollbackLoadingId, setRollbackLoadingId] = useState(null) // Rollback confirm dialog const [rollbackTargetId, setRollbackTargetId] = useState(null) const [actionError, setActionError] = useState(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 ( Jobs loadJobs(0)} disabled={loading}> {loading ? : } {error && ( {error} )} {actionError && ( setActionError(null)}> {actionError} )} Created Kind Status Hosts Succeeded Failed Schedule Notes Actions {loading && jobs.length === 0 ? ( ) : jobs.length === 0 ? ( No jobs found ) : ( jobs.map((job) => ( 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} /> )) )}
{hasMore && ( )}
{/* ── Rollback confirm dialog ── */} setRollbackTargetId(null)} maxWidth="xs" fullWidth > Confirm Rollback Are you sure you want to rollback job{' '} {rollbackTargetId}? This will create a new rollback job that attempts to revert the applied patches.
) }