import { useEffect, useState, useCallback, useRef } from 'react' import { Alert, Box, Button, Chip, CircularProgress, Container, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, FormControlLabel, IconButton, InputLabel, MenuItem, Paper, Select, Snackbar, Switch, Table, TableBody, TableCell, TableHead, TableRow, TextField, Toolbar, Tooltip, Typography, } from '@mui/material' import { Add as AddIcon, Delete as DeleteIcon, Edit as EditIcon, Refresh as RefreshIcon, Schedule as ScheduleIcon, } from '@mui/icons-material' import { maintenanceWindowsApi, hostsApi } from '../api/client' import { useAuthStore } from '../store/authStore' import type { Host, MaintenanceWindow, WindowRecurrence } from '../types' // ── Helpers ─────────────────────────────────────────────────────────────────── function recurrenceLabel(r: WindowRecurrence): string { const map: Record = { once: 'One-Time', daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly', } return map[r] } function recurrenceColor(r: WindowRecurrence): 'default' | 'primary' | 'secondary' | 'info' { const map: Record = { once: 'default', daily: 'primary', weekly: 'secondary', monthly: 'info', } return map[r] } function fmtDate(iso: string): string { return new Date(iso).toLocaleString() } function fmtTimeOnly(iso: string): string { const d = new Date(iso) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', timeZoneName: 'short' }) } const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] function scheduleDescription(w: MaintenanceWindow): string { const dur = `${w.duration_minutes} min` const time = fmtTimeOnly(w.start_at) switch (w.recurrence) { case 'once': return `Once at ${fmtDate(w.start_at)} for ${dur}` case 'daily': return `Every day at ${time} for ${dur}` case 'weekly': { const day = w.recurrence_day != null ? DAY_NAMES[w.recurrence_day] ?? `Day ${w.recurrence_day}` : '?' // eslint-disable-line eqeqeq return `Every ${day} at ${time} for ${dur}` } case 'monthly': { const day = w.recurrence_day != null ? w.recurrence_day : '?' // eslint-disable-line eqeqeq return `Monthly on day ${day} at ${time} for ${dur}` } } } // ── Default form values ─────────────────────────────────────────────────────── function nowIso(): string { return new Date().toISOString().slice(0, 16) // "YYYY-MM-DDTHH:MM" } interface FormValues { label: string recurrence: WindowRecurrence start_at: string duration_minutes: number recurrence_day: number | '' enabled: boolean auto_apply: boolean } function defaultForm(): FormValues { return { label: '', recurrence: 'once', start_at: nowIso(), duration_minutes: 60, recurrence_day: '', enabled: true, auto_apply: true, } } // ── Window form dialog ──────────────────────────────────────────────────────── interface WindowFormDialogProps { open: boolean title: string initial: FormValues onClose: () => void onSubmit: (values: FormValues) => Promise } function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFormDialogProps) { const [form, setForm] = useState(initial) const [saving, setSaving] = useState(false) const [err, setErr] = useState(null) // Reset form when dialog opens with new initial values useEffect(() => { setForm(initial); setErr(null) }, [open, initial]) const set = (field: keyof FormValues, value: FormValues[keyof FormValues]) => setForm(prev => ({ ...prev, [field]: value })) const needsDay = form.recurrence === 'weekly' || form.recurrence === 'monthly' const handleSubmit = async () => { if (!form.label.trim()) { setErr('Label is required'); return } if (needsDay && form.recurrence_day === '') { setErr('Recurrence day is required'); return } setSaving(true) setErr(null) try { await onSubmit(form) } catch (e: unknown) { const msg = (e as { response?: { data?: { error?: { message?: string } } } }) ?.response?.data?.error?.message ?? 'Failed to save window' setErr(msg) } finally { setSaving(false) } } return ( {title} {err && {err}} set('label', e.target.value)} required fullWidth /> Recurrence set('start_at', e.target.value)} fullWidth slotProps={{ inputLabel: { shrink: true } }} helperText={ form.recurrence === 'once' ? 'When the window begins' : 'Time of day for the recurring window (date part ignored)' } /> set('duration_minutes', parseInt(e.target.value, 10) || 60)} fullWidth slotProps={{ htmlInput: { min: 1, max: 1440 } }} /> {form.recurrence === 'weekly' && ( Day of Week )} {form.recurrence === 'monthly' && ( set('recurrence_day', parseInt(e.target.value, 10) || 1)} fullWidth slotProps={{ htmlInput: { min: 1, max: 31 } }} /> )} set('enabled', e.target.checked)} /> } label="Enabled" /> set('auto_apply', e.target.checked)} /> } label="Auto-Apply Patches" /> When enabled, pending patches are automatically applied during this window. ) } // ── Confirm delete dialog ────────────────────────────────────────────────────── interface ConfirmDeleteProps { open: boolean windowLabel: string onClose: () => void onConfirm: () => Promise } function ConfirmDeleteDialog({ open, windowLabel, onClose, onConfirm }: ConfirmDeleteProps) { const [loading, setLoading] = useState(false) const handleConfirm = async () => { setLoading(true) await onConfirm() setLoading(false) } return ( Delete Window Delete maintenance window {windowLabel}? This cannot be undone. ) } // ── Per-host windows table ──────────────────────────────────────────────────── interface HostWindowsTableProps { host: Host windows: MaintenanceWindow[] onEdit: (w: MaintenanceWindow) => void onDelete: (w: MaintenanceWindow) => void onAdd: (hostId: string) => void canWrite: boolean } function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd, canWrite }: HostWindowsTableProps) { return ( {host.display_name} ({host.fqdn}) {canWrite && } {windows.length === 0 ? ( No maintenance windows configured. Queued jobs will not execute until a window is added. ) : ( Label Schedule Recurrence Status Auto-Apply Created {canWrite && Actions} {windows.map(w => ( {w.label} {scheduleDescription(w)} {fmtDate(w.created_at)} {canWrite && onEdit(w)}> onDelete(w)}> } ))}
)}
) } // ── Main page ────────────────────────────────────────────────────────────────── export default function MaintenanceWindowsPage() { const user = useAuthStore(state => state.user) const canWrite = user?.role === 'admin' || user?.role === 'operator' const [hosts, setHosts] = useState([]) const [windowsByHost, setWindowsByHost] = useState>({}) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({ open: false, message: '', severity: 'success', }) // Create dialog state const [createOpen, setCreateOpen] = useState(false) const [createHostId, setCreateHostId] = useState(null) const [createForm, setCreateForm] = useState(defaultForm()) // Edit dialog state const [editOpen, setEditOpen] = useState(false) const [editWindow, setEditWindow] = useState(null) const [editForm, setEditForm] = useState(defaultForm()) // Delete dialog state const [deleteOpen, setDeleteOpen] = useState(false) const [deleteWindow, setDeleteWindow] = useState(null) // ── AbortController ref for cancelling stale fetches ────────────────────── const abortRef = useRef(null) // ── Fetch hosts + all maintenance windows in 2 parallel requests ───────── // Uses bulk /maintenance-windows endpoint instead of N+1 per-host calls. // State updates are batched atomically so React never renders hosts without // their windows (the root cause of the "randomly missing data" bug). const fetchData = useCallback(async (signal?: AbortSignal) => { setLoading(true) setError(null) try { // Fetch hosts and ALL windows in parallel — 2 requests, not N+1. const [hostsRes, windowsRes] = await Promise.all([ hostsApi.list({ limit: 500 }), maintenanceWindowsApi.listAll(), ]) // If the request was aborted (e.g. component unmounted or new fetch // started), discard the results silently. if (signal?.aborted) return const fetchedHosts: Host[] = hostsRes.data?.hosts ?? hostsRes.data ?? [] const allWindows: MaintenanceWindow[] = windowsRes.data?.windows ?? [] // Group windows by host_id for O(N) lookup. const windowMap: Record = {} for (const w of allWindows) { if (!windowMap[w.host_id]) windowMap[w.host_id] = [] windowMap[w.host_id].push(w) } // Batch both state updates together — React 18+ auto-batches these // into a single render, eliminating the race condition where hosts // rendered with stale/empty windows. setHosts(fetchedHosts) setWindowsByHost(windowMap) } catch (err: unknown) { if (signal?.aborted) return // stale request — ignore silently // Only log real errors, not cancellations. if (err instanceof DOMException && err.name === 'AbortError') return setError('Failed to load hosts or maintenance windows.') } finally { if (!signal?.aborted) { setLoading(false) } } }, []) useEffect(() => { // Cancel any in-flight fetch from a previous render. abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller fetchData(controller.signal) return () => { controller.abort() } }, [fetchData]) // ── Refresh helper: cancels any in-flight fetch, starts a new one ──────── const refreshData = useCallback(() => { abortRef.current?.abort() const controller = new AbortController() abortRef.current = controller fetchData(controller.signal) }, [fetchData]) // ── Helpers ─────────────────────────────────────────────────────────────── const showSnackbar = (message: string, severity: 'success' | 'error') => setSnackbar({ open: true, message, severity }) // ── Create window ───────────────────────────────────────────────────────── const handleAddClick = (hostId: string) => { setCreateHostId(hostId) setCreateForm(defaultForm()) setCreateOpen(true) } const handleCreateSubmit = async (values: FormValues) => { if (!createHostId) return await maintenanceWindowsApi.create(createHostId, { label: values.label, recurrence: values.recurrence, start_at: new Date(values.start_at).toISOString(), duration_minutes: values.duration_minutes, recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day, enabled: values.enabled, auto_apply: values.auto_apply, }) setCreateOpen(false) showSnackbar('Maintenance window created', 'success') refreshData() } // ── Edit window ─────────────────────────────────────────────────────────── const handleEditClick = (w: MaintenanceWindow) => { setEditWindow(w) setEditForm({ label: w.label, recurrence: w.recurrence, start_at: new Date(w.start_at).toISOString().slice(0, 16), duration_minutes: w.duration_minutes, recurrence_day: w.recurrence_day ?? '', enabled: w.enabled, auto_apply: w.auto_apply, }) setEditOpen(true) } const handleEditSubmit = async (values: FormValues) => { if (!editWindow) return await maintenanceWindowsApi.update(editWindow.host_id, editWindow.id, { label: values.label, recurrence: values.recurrence, start_at: new Date(values.start_at).toISOString(), duration_minutes: values.duration_minutes, recurrence_day: values.recurrence_day === '' ? undefined : values.recurrence_day, enabled: values.enabled, auto_apply: values.auto_apply, }) setEditOpen(false) showSnackbar('Maintenance window updated', 'success') refreshData() } // ── Delete window ───────────────────────────────────────────────────────── const handleDeleteClick = (w: MaintenanceWindow) => { setDeleteWindow(w) setDeleteOpen(true) } const handleDeleteConfirm = async () => { if (!deleteWindow) return try { await maintenanceWindowsApi.remove(deleteWindow.host_id, deleteWindow.id) setDeleteOpen(false) showSnackbar('Maintenance window deleted', 'success') refreshData() } catch { showSnackbar('Failed to delete maintenance window', 'error') } } // ── Render ──────────────────────────────────────────────────────────────── return ( {/* Page header */} Maintenance Windows Queued (non-immediate) patch jobs only execute during open maintenance windows. Configure one or more windows per host to control when patching occurs. {loading && ( )} {!loading && error && ( {error} )} {!loading && !error && hosts.length === 0 && ( No hosts found. Register hosts first before configuring maintenance windows. )} {!loading && !error && hosts.map(host => ( ))} {/* Create dialog */} setCreateOpen(false)} onSubmit={handleCreateSubmit} /> {/* Edit dialog */} setEditOpen(false)} onSubmit={handleEditSubmit} /> {/* Delete confirm dialog */} setDeleteOpen(false)} onConfirm={handleDeleteConfirm} /> {/* Success/error snackbar */} setSnackbar(prev => ({ ...prev, open: false }))} anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }} > setSnackbar(prev => ({ ...prev, open: false }))} > {snackbar.message} ) }