feat: M6 maintenance windows + M7 WebSocket relay (real-time job status)
M6 - Maintenance Windows: - routes/maintenance_windows.rs: full CRUD API - migrations/004_maintenance_windows.sql - frontend/MaintenanceWindowsPage.tsx - HostDetailPage.tsx: maintenance window config panel M7 - WebSocket Relay: - pm-web: POST /api/v1/ws/ticket (JWT-auth, single-use, 60s TTL) - pm-web: WS /api/v1/ws/jobs?ticket=... (PgListener -> browser push) - pm-web: DashMap<String,WsTicket> in AppState, 30s cleanup task - pm-worker: ws_relay.rs subscribes to agent WS, updates patch_job_hosts, fires pg_notify(job_update) for real-time fan-out - frontend: useJobWebSocket hook with auto-reconnect + exponential backoff - frontend: JobsPage live updates with WS status indicator - types: JobWsEvent interface - api/client: wsApi.createTicket() All tasks marked complete in tasks/todo.md cargo build: zero errors, zero warnings
This commit is contained in:
@ -11,6 +11,7 @@ import UsersPage from './pages/UsersPage'
|
||||
import DashboardPage from './pages/DashboardPage'
|
||||
import PatchDeploymentPage from './pages/PatchDeploymentPage'
|
||||
import JobsPage from './pages/JobsPage'
|
||||
import MaintenanceWindowsPage from './pages/MaintenanceWindowsPage'
|
||||
|
||||
// Placeholder pages — implemented in later milestones
|
||||
const PlaceholderPage = ({ title }: { title: string }) => (
|
||||
@ -44,10 +45,14 @@ function App() {
|
||||
<Route path="/groups" element={<RequireAuth><GroupsPage /></RequireAuth>} />
|
||||
<Route path="/users" element={<RequireAuth><UsersPage /></RequireAuth>} />
|
||||
|
||||
{/* Protected — later milestones */}
|
||||
{/* Protected — M5 */}
|
||||
<Route path="/jobs" element={<RequireAuth><JobsPage /></RequireAuth>} />
|
||||
<Route path="/deployment" element={<RequireAuth><PatchDeploymentPage /></RequireAuth>} />
|
||||
<Route path="/maintenance" element={<RequireAuth><PlaceholderPage title="Maintenance Windows" /></RequireAuth>} />
|
||||
|
||||
{/* Protected — M6 */}
|
||||
<Route path="/maintenance" element={<RequireAuth><MaintenanceWindowsPage /></RequireAuth>} />
|
||||
|
||||
{/* Placeholder — later milestones */}
|
||||
<Route path="/reports" element={<RequireAuth><PlaceholderPage title="Reports" /></RequireAuth>} />
|
||||
<Route path="/certificates" element={<RequireAuth><PlaceholderPage title="Certificates" /></RequireAuth>} />
|
||||
<Route path="/settings" element={<RequireAuth><PlaceholderPage title="Settings" /></RequireAuth>} />
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import axios, { type AxiosError } from 'axios'
|
||||
import type { InternalAxiosRequestConfig } from 'axios'
|
||||
import { useAuthStore } from '../store/authStore'
|
||||
import type { FleetStatus, CreateJobRequest } from '../types'
|
||||
import type {
|
||||
FleetStatus,
|
||||
CreateJobRequest,
|
||||
CreateMaintenanceWindowRequest,
|
||||
UpdateMaintenanceWindowRequest,
|
||||
} from '../types'
|
||||
|
||||
const BASE_URL = '/api/v1'
|
||||
|
||||
@ -123,3 +128,22 @@ export const patchesApi = {
|
||||
// The backend reads from host_patch_data table (cached from agent poll)
|
||||
getHostPatches: (hostId: string) => apiClient.get(`/hosts/${hostId}/patches`),
|
||||
}
|
||||
|
||||
// ── Maintenance Windows API ───────────────────────────────────────────────────
|
||||
export const maintenanceWindowsApi = {
|
||||
list: (hostId: string) =>
|
||||
apiClient.get(`/hosts/${hostId}/maintenance-windows`),
|
||||
create: (hostId: string, body: CreateMaintenanceWindowRequest) =>
|
||||
apiClient.post(`/hosts/${hostId}/maintenance-windows`, body),
|
||||
update: (hostId: string, windowId: string, body: UpdateMaintenanceWindowRequest) =>
|
||||
apiClient.put(`/hosts/${hostId}/maintenance-windows/${windowId}`, body),
|
||||
remove: (hostId: string, windowId: string) =>
|
||||
apiClient.delete(`/hosts/${hostId}/maintenance-windows/${windowId}`),
|
||||
}
|
||||
|
||||
// ── WebSocket API (M7) ────────────────────────────────────────────────────────
|
||||
export const wsApi = {
|
||||
/** POST /api/v1/ws/ticket — obtain a single-use WS auth ticket (60 s expiry). */
|
||||
createTicket: (): Promise<{ ticket: string }> =>
|
||||
apiClient.post<{ ticket: string }>('/ws/ticket').then((r) => r.data),
|
||||
}
|
||||
|
||||
172
frontend/src/hooks/useJobWebSocket.ts
Normal file
172
frontend/src/hooks/useJobWebSocket.ts
Normal file
@ -0,0 +1,172 @@
|
||||
/**
|
||||
* useJobWebSocket — M7
|
||||
*
|
||||
* Manages a browser WebSocket connection to the job-update relay.
|
||||
* Authentication uses single-use tickets obtained via POST /api/v1/ws/ticket.
|
||||
*
|
||||
* Features:
|
||||
* - Fetches a fresh ticket before every (re)connect
|
||||
* - Exponential backoff reconnect: 1 s → 2 s → 4 s → … → 30 s max
|
||||
* - Calls `onEvent` callback for every parsed JobWsEvent
|
||||
* - Returns { connected, lastEvent } for UI indicator use
|
||||
*/
|
||||
|
||||
import { useEffect, useRef, useCallback, useState } from 'react'
|
||||
import { wsApi } from '../api/client'
|
||||
import type { JobWsEvent } from '../types'
|
||||
|
||||
// ── Constants ─────────────────────────────────────────────────────────────────
|
||||
|
||||
const BACKOFF_INITIAL_MS = 1_000
|
||||
const BACKOFF_MAX_MS = 30_000
|
||||
const BACKOFF_FACTOR = 2
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface JobWsOptions {
|
||||
/** Called on each inbound JobWsEvent. */
|
||||
onEvent?: (event: JobWsEvent) => void
|
||||
/** Set to false to disable the connection entirely (e.g. when logged out). */
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface JobWsState {
|
||||
connected: boolean
|
||||
lastEvent: JobWsEvent | null
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Derive the correct ws(s) URL from the current page origin. */
|
||||
function buildWsBase(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
return `${proto}://${window.location.host}`
|
||||
}
|
||||
|
||||
// ── Hook ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function useJobWebSocket(options: JobWsOptions = {}): JobWsState {
|
||||
const { onEvent, enabled = true } = options
|
||||
|
||||
const [connected, setConnected] = useState(false)
|
||||
const [lastEvent, setLastEvent] = useState<JobWsEvent | null>(null)
|
||||
|
||||
// Stable ref to the latest onEvent callback — avoids re-triggering the
|
||||
// effect every time the parent component re-renders.
|
||||
const onEventRef = useRef(onEvent)
|
||||
useEffect(() => { onEventRef.current = onEvent }, [onEvent])
|
||||
|
||||
// Internal bookkeeping refs (don't need to trigger re-renders).
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const backoffRef = useRef(BACKOFF_INITIAL_MS)
|
||||
const mountedRef = useRef(true)
|
||||
|
||||
const clearRetryTimer = useCallback(() => {
|
||||
if (retryTimerRef.current !== null) {
|
||||
clearTimeout(retryTimerRef.current)
|
||||
retryTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const closeSocket = useCallback(() => {
|
||||
if (wsRef.current) {
|
||||
// Prevent the onclose handler from scheduling another reconnect.
|
||||
wsRef.current.onclose = null
|
||||
wsRef.current.onerror = null
|
||||
wsRef.current.close()
|
||||
wsRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const connect = useCallback(async () => {
|
||||
if (!mountedRef.current || !enabled) return
|
||||
|
||||
// Close any existing socket before opening a new one.
|
||||
closeSocket()
|
||||
|
||||
let ticket: string
|
||||
try {
|
||||
const resp = await wsApi.createTicket()
|
||||
ticket = resp.ticket
|
||||
} catch (err) {
|
||||
console.warn('[JobWS] Failed to obtain WS ticket:', err)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
if (!mountedRef.current) return
|
||||
|
||||
const url = `${buildWsBase()}/api/v1/ws/jobs?ticket=${encodeURIComponent(ticket)}`
|
||||
let ws: WebSocket
|
||||
try {
|
||||
ws = new WebSocket(url)
|
||||
} catch (err) {
|
||||
console.error('[JobWS] WebSocket constructor threw:', err)
|
||||
scheduleReconnect()
|
||||
return
|
||||
}
|
||||
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
if (!mountedRef.current) { ws.close(); return }
|
||||
console.debug('[JobWS] Connected')
|
||||
backoffRef.current = BACKOFF_INITIAL_MS // reset backoff on successful connect
|
||||
setConnected(true)
|
||||
}
|
||||
|
||||
ws.onmessage = (ev: MessageEvent) => {
|
||||
if (!mountedRef.current) return
|
||||
try {
|
||||
const event: JobWsEvent = JSON.parse(ev.data as string)
|
||||
setLastEvent(event)
|
||||
onEventRef.current?.(event)
|
||||
} catch {
|
||||
console.warn('[JobWS] Unparseable message:', ev.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
console.warn('[JobWS] Socket error')
|
||||
// onclose will fire immediately after onerror — let it handle reconnect.
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
if (!mountedRef.current) return
|
||||
console.debug('[JobWS] Disconnected — scheduling reconnect')
|
||||
setConnected(false)
|
||||
wsRef.current = null
|
||||
scheduleReconnect()
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [enabled, closeSocket])
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (!mountedRef.current) return
|
||||
clearRetryTimer()
|
||||
const delay = backoffRef.current
|
||||
backoffRef.current = Math.min(delay * BACKOFF_FACTOR, BACKOFF_MAX_MS)
|
||||
console.debug(`[JobWS] Reconnecting in ${delay} ms`)
|
||||
retryTimerRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) connect()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
mountedRef.current = true
|
||||
|
||||
if (enabled) {
|
||||
connect()
|
||||
}
|
||||
|
||||
return () => {
|
||||
mountedRef.current = false
|
||||
clearRetryTimer()
|
||||
closeSocket()
|
||||
setConnected(false)
|
||||
}
|
||||
}, [enabled, connect, clearRetryTimer, closeSocket])
|
||||
|
||||
return { connected, lastEvent }
|
||||
}
|
||||
@ -1,8 +1,190 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { useParams, useNavigate } from 'react-router-dom'
|
||||
import { Alert, Box, Button, CircularProgress, Container, Divider, Grid, Paper, Typography } from '@mui/material'
|
||||
import { ArrowBack } from '@mui/icons-material'
|
||||
import { apiClient } from '../api/client'
|
||||
import {
|
||||
Alert,
|
||||
Box,
|
||||
Button,
|
||||
Chip,
|
||||
CircularProgress,
|
||||
Container,
|
||||
Dialog,
|
||||
DialogActions,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
Divider,
|
||||
FormControl,
|
||||
FormControlLabel,
|
||||
Grid,
|
||||
IconButton,
|
||||
InputLabel,
|
||||
MenuItem,
|
||||
Paper,
|
||||
Select,
|
||||
Snackbar,
|
||||
Switch,
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TextField,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from '@mui/material'
|
||||
import {
|
||||
Add as AddIcon,
|
||||
ArrowBack,
|
||||
Delete as DeleteIcon,
|
||||
Edit as EditIcon,
|
||||
Schedule as ScheduleIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { apiClient, maintenanceWindowsApi } from '../api/client'
|
||||
import type { MaintenanceWindow, WindowRecurrence } from '../types'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
const DAY_NAMES = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']
|
||||
|
||||
function recurrenceLabel(r: WindowRecurrence): string {
|
||||
const map: Record<WindowRecurrence, string> = {
|
||||
once: 'One-Time', daily: 'Daily', weekly: 'Weekly', monthly: 'Monthly',
|
||||
}
|
||||
return map[r]
|
||||
}
|
||||
|
||||
function scheduleDescription(w: MaintenanceWindow): string {
|
||||
const dur = `${w.duration_minutes} min`
|
||||
const time = new Date(w.start_at).toLocaleTimeString([], {
|
||||
hour: '2-digit', minute: '2-digit', timeZoneName: 'short',
|
||||
})
|
||||
switch (w.recurrence) {
|
||||
case 'once':
|
||||
return `Once at ${new Date(w.start_at).toLocaleString()} 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}` : '?'
|
||||
return `Every ${day} at ${time} for ${dur}`
|
||||
}
|
||||
case 'monthly': {
|
||||
const day = w.recurrence_day != null ? w.recurrence_day : '?'
|
||||
return `Monthly on day ${day} at ${time} for ${dur}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Form value type ───────────────────────────────────────────────────────────
|
||||
|
||||
interface FormValues {
|
||||
label: string
|
||||
recurrence: WindowRecurrence
|
||||
start_at: string
|
||||
duration_minutes: number
|
||||
recurrence_day: number | ''
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
function defaultForm(): FormValues {
|
||||
return {
|
||||
label: '',
|
||||
recurrence: 'once',
|
||||
start_at: new Date().toISOString().slice(0, 16),
|
||||
duration_minutes: 60,
|
||||
recurrence_day: '',
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Window form dialog ────────────────────────────────────────────────────────
|
||||
|
||||
interface WindowFormDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
initial: FormValues
|
||||
onClose: () => void
|
||||
onSubmit: (values: FormValues) => Promise<void>
|
||||
}
|
||||
|
||||
function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFormDialogProps) {
|
||||
const [form, setForm] = useState<FormValues>(initial)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(null)
|
||||
|
||||
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'
|
||||
setErr(msg)
|
||||
} finally { setSaving(false) }
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
{err && <Alert severity="error">{err}</Alert>}
|
||||
<TextField label="Label" value={form.label} onChange={e => set('label', e.target.value)} required fullWidth />
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Recurrence</InputLabel>
|
||||
<Select label="Recurrence" value={form.recurrence} onChange={e => set('recurrence', e.target.value as WindowRecurrence)}>
|
||||
<MenuItem value="once">One-Time</MenuItem>
|
||||
<MenuItem value="daily">Daily</MenuItem>
|
||||
<MenuItem value="weekly">Weekly</MenuItem>
|
||||
<MenuItem value="monthly">Monthly</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
<TextField
|
||||
label={form.recurrence === 'once' ? 'Start Date & Time (UTC)' : 'Reference Time (UTC)'}
|
||||
type="datetime-local" value={form.start_at}
|
||||
onChange={e => set('start_at', e.target.value)} fullWidth
|
||||
slotProps={{ inputLabel: { shrink: true } }}
|
||||
/>
|
||||
<TextField label="Duration (minutes)" type="number" value={form.duration_minutes}
|
||||
onChange={e => set('duration_minutes', parseInt(e.target.value, 10) || 60)} fullWidth
|
||||
slotProps={{ htmlInput: { min: 1, max: 1440 } }}
|
||||
/>
|
||||
{form.recurrence === 'weekly' && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Day of Week</InputLabel>
|
||||
<Select label="Day of Week" value={form.recurrence_day}
|
||||
onChange={e => set('recurrence_day', Number(e.target.value))}>
|
||||
{DAY_NAMES.map((name, i) => <MenuItem key={i} value={i}>{name}</MenuItem>)}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
{form.recurrence === 'monthly' && (
|
||||
<TextField label="Day of Month (1-31)" type="number" value={form.recurrence_day}
|
||||
onChange={e => set('recurrence_day', parseInt(e.target.value, 10) || 1)} fullWidth
|
||||
slotProps={{ htmlInput: { min: 1, max: 31 } }}
|
||||
/>
|
||||
)}
|
||||
<FormControlLabel
|
||||
control={<Switch checked={form.enabled} onChange={e => set('enabled', e.target.checked)} />}
|
||||
label="Enabled"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? <CircularProgress size={20} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function HostDetailPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
@ -11,6 +193,27 @@ export default function HostDetailPage() {
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
// Maintenance windows state
|
||||
const [windows, setWindows] = useState<MaintenanceWindow[]>([])
|
||||
const [winLoading, setWinLoading] = useState(false)
|
||||
const [snackbar, setSnackbar] = useState<{ open: boolean; message: string; severity: 'success' | 'error' }>({
|
||||
open: false, message: '', severity: 'success',
|
||||
})
|
||||
|
||||
// Create dialog
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState<FormValues>(defaultForm())
|
||||
|
||||
// Edit dialog
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editWindow, setEditWindow] = useState<MaintenanceWindow | null>(null)
|
||||
const [editForm, setEditForm] = useState<FormValues>(defaultForm())
|
||||
|
||||
// Delete dialog
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteTarget, setDeleteTarget] = useState<MaintenanceWindow | null>(null)
|
||||
|
||||
// ── Fetch host ────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
apiClient.get(`/hosts/${id}`)
|
||||
.then(r => setHost(r.data))
|
||||
@ -18,24 +221,227 @@ export default function HostDetailPage() {
|
||||
.finally(() => setLoading(false))
|
||||
}, [id])
|
||||
|
||||
// ── Fetch windows ─────────────────────────────────────────────────────────
|
||||
const fetchWindows = useCallback(async () => {
|
||||
if (!id) return
|
||||
setWinLoading(true)
|
||||
try {
|
||||
const res = await maintenanceWindowsApi.list(id)
|
||||
setWindows(res.data?.windows ?? [])
|
||||
} catch { /* ignore */ }
|
||||
finally { setWinLoading(false) }
|
||||
}, [id])
|
||||
|
||||
useEffect(() => { fetchWindows() }, [fetchWindows])
|
||||
|
||||
const showSnack = (message: string, severity: 'success' | 'error') =>
|
||||
setSnackbar({ open: true, message, severity })
|
||||
|
||||
// ── Create window ─────────────────────────────────────────────────────────
|
||||
const handleCreateSubmit = async (values: FormValues) => {
|
||||
if (!id) return
|
||||
await maintenanceWindowsApi.create(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,
|
||||
})
|
||||
setCreateOpen(false)
|
||||
showSnack('Window created', 'success')
|
||||
await fetchWindows()
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
})
|
||||
setEditOpen(true)
|
||||
}
|
||||
|
||||
const handleEditSubmit = async (values: FormValues) => {
|
||||
if (!id || !editWindow) return
|
||||
await maintenanceWindowsApi.update(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,
|
||||
})
|
||||
setEditOpen(false)
|
||||
showSnack('Window updated', 'success')
|
||||
await fetchWindows()
|
||||
}
|
||||
|
||||
// ── Delete window ─────────────────────────────────────────────────────────
|
||||
const handleDeleteConfirm = async () => {
|
||||
if (!id || !deleteTarget) return
|
||||
try {
|
||||
await maintenanceWindowsApi.remove(id, deleteTarget.id)
|
||||
setDeleteOpen(false)
|
||||
showSnack('Window deleted', 'success')
|
||||
await fetchWindows()
|
||||
} catch {
|
||||
showSnack('Failed to delete window', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
if (loading) return <Box display="flex" justifyContent="center" mt={8}><CircularProgress /></Box>
|
||||
if (error) return <Container sx={{ mt: 4 }}><Alert severity="error">{error}</Alert></Container>
|
||||
|
||||
return (
|
||||
<Container maxWidth="lg" sx={{ mt: 3 }}>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/hosts')} sx={{ mb: 2 }}>Back to Hosts</Button>
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} mb={2}>{String(host?.fqdn ?? '')}</Typography>
|
||||
<Container maxWidth="lg" sx={{ mt: 3, mb: 6 }}>
|
||||
<Button startIcon={<ArrowBack />} onClick={() => navigate('/hosts')} sx={{ mb: 2 }}>
|
||||
Back to Hosts
|
||||
</Button>
|
||||
|
||||
{/* ── Host details ─────────────────────────────────────────────────── */}
|
||||
<Paper sx={{ p: 3, mb: 3 }}>
|
||||
<Typography variant="h5" fontWeight={700} mb={2}>
|
||||
{String(host?.fqdn ?? '')}
|
||||
</Typography>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
<Grid container spacing={2}>
|
||||
{host && Object.entries(host).map(([k, v]) => v !== null && v !== '' ? (
|
||||
<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>
|
||||
) : null)}
|
||||
{host && Object.entries(host).map(([k, v]) =>
|
||||
v !== null && v !== '' ? (
|
||||
<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>
|
||||
) : null
|
||||
)}
|
||||
</Grid>
|
||||
</Paper>
|
||||
|
||||
{/* ── Maintenance Windows ──────────────────────────────────────────── */}
|
||||
<Paper sx={{ p: 3 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ScheduleIcon color="primary" />
|
||||
<Typography variant="h6" fontWeight={600}>Maintenance Windows</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
startIcon={<AddIcon />}
|
||||
variant="outlined"
|
||||
size="small"
|
||||
onClick={() => { setCreateForm(defaultForm()); setCreateOpen(true) }}
|
||||
>
|
||||
Add Window
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 2 }} />
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
|
||||
Queued patch jobs execute only when an enabled maintenance window is open.
|
||||
</Typography>
|
||||
|
||||
{winLoading ? (
|
||||
<Box display="flex" justifyContent="center" py={3}><CircularProgress size={28} /></Box>
|
||||
) : windows.length === 0 ? (
|
||||
<Alert severity="info">
|
||||
No maintenance windows. Queued jobs will not run until a window is configured.
|
||||
</Alert>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Label</TableCell>
|
||||
<TableCell>Schedule</TableCell>
|
||||
<TableCell>Recurrence</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{windows.map(w => (
|
||||
<TableRow key={w.id} hover>
|
||||
<TableCell>{w.label}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{scheduleDescription(w)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip label={recurrenceLabel(w.recurrence)} size="small" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={w.enabled ? 'Enabled' : 'Disabled'}
|
||||
color={w.enabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => handleEditClick(w)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton
|
||||
size="small" color="error"
|
||||
onClick={() => { setDeleteTarget(w); setDeleteOpen(true) }}
|
||||
>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{/* ── Dialogs ─────────────────────────────────────────────────────── */}
|
||||
<WindowFormDialog
|
||||
open={createOpen}
|
||||
title="Add Maintenance Window"
|
||||
initial={createForm}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreateSubmit}
|
||||
/>
|
||||
<WindowFormDialog
|
||||
open={editOpen}
|
||||
title="Edit Maintenance Window"
|
||||
initial={editForm}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
<Dialog open={deleteOpen} onClose={() => setDeleteOpen(false)} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete Window</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Delete <strong>{deleteTarget?.label}</strong>? This cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={() => setDeleteOpen(false)}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={handleDeleteConfirm}>Delete</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
|
||||
{/* Snackbar */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar(p => ({ ...p, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert severity={snackbar.severity} onClose={() => setSnackbar(p => ({ ...p, open: false }))}>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
|
||||
@ -29,9 +29,12 @@ import {
|
||||
ExpandMore,
|
||||
Refresh as RefreshIcon,
|
||||
Replay as ReplayIcon,
|
||||
Wifi as WifiIcon,
|
||||
WifiOff as WifiOffIcon,
|
||||
} from '@mui/icons-material'
|
||||
import { jobsApi } from '../api/client'
|
||||
import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost } from '../types'
|
||||
import { useJobWebSocket } from '../hooks/useJobWebSocket'
|
||||
import type { JobStatus, JobKind, PatchJobSummary, PatchJob, PatchJobHost, JobWsEvent } from '../types'
|
||||
|
||||
// ── Status chip ───────────────────────────────────────────────────────────────
|
||||
type ChipColor = 'default' | 'info' | 'warning' | 'success' | 'error'
|
||||
@ -340,6 +343,44 @@ export default function JobsPage() {
|
||||
loadJobs(0)
|
||||
}, [loadJobs])
|
||||
|
||||
// ── WS event handler — surgical state updates ─────────────────────────────
|
||||
const handleWsEvent = useCallback((event: JobWsEvent) => {
|
||||
// Update matching job summary row status.
|
||||
setJobs((prev) =>
|
||||
prev.map((job) => {
|
||||
if (job.id !== event.job_id) return job
|
||||
const updated = { ...job, status: event.status }
|
||||
// Increment counters when a host reaches a terminal state.
|
||||
if (event.status === 'succeeded') {
|
||||
updated.succeeded_count = job.succeeded_count + 1
|
||||
} else if (event.status === 'failed') {
|
||||
updated.failed_count = job.failed_count + 1
|
||||
}
|
||||
return updated
|
||||
})
|
||||
)
|
||||
|
||||
// Also update the host row in the expanded detail panel if loaded.
|
||||
setDetails((prev) => {
|
||||
const detail = prev[event.job_id]
|
||||
if (!detail) return prev
|
||||
const updatedHosts = detail.hosts.map((h) => {
|
||||
if (h.host_id !== event.host_id) return h
|
||||
return {
|
||||
...h,
|
||||
status: event.status,
|
||||
...(event.error_message ? { error_message: event.error_message } : {}),
|
||||
...(event.agent_job_id ? { agent_job_id: event.agent_job_id } : {}),
|
||||
}
|
||||
})
|
||||
return { ...prev, [event.job_id]: { ...detail, hosts: updatedHosts } }
|
||||
})
|
||||
}, [])
|
||||
|
||||
// ── WebSocket connection ──────────────────────────────────────────────────
|
||||
const { connected } = useJobWebSocket({ onEvent: handleWsEvent })
|
||||
|
||||
// ── Action handlers ───────────────────────────────────────────────────────
|
||||
const handleToggleExpand = useCallback(async (id: string) => {
|
||||
if (expandedId === id) {
|
||||
setExpandedId(null)
|
||||
@ -388,12 +429,31 @@ export default function JobsPage() {
|
||||
}
|
||||
}, [rollbackTargetId, loadJobs])
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3 }}>
|
||||
<Toolbar disableGutters sx={{ mb: 2 }}>
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||
Jobs
|
||||
</Typography>
|
||||
|
||||
{/* WS connection status indicator */}
|
||||
<Tooltip title={connected ? 'Live updates connected' : 'Live updates disconnected'}>
|
||||
<Box
|
||||
display="flex"
|
||||
alignItems="center"
|
||||
gap={0.5}
|
||||
sx={{ mr: 1, color: connected ? 'success.main' : 'text.disabled' }}
|
||||
>
|
||||
{connected
|
||||
? <WifiIcon fontSize="small" />
|
||||
: <WifiOffIcon fontSize="small" />}
|
||||
<Typography variant="caption">
|
||||
{connected ? 'Live' : 'Offline'}
|
||||
</Typography>
|
||||
</Box>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip title="Refresh">
|
||||
<span>
|
||||
<IconButton onClick={() => loadJobs(0)} disabled={loading}>
|
||||
|
||||
616
frontend/src/pages/MaintenanceWindowsPage.tsx
Normal file
616
frontend/src/pages/MaintenanceWindowsPage.tsx
Normal file
@ -0,0 +1,616 @@
|
||||
import { useEffect, useState, useCallback } 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 type { Host, MaintenanceWindow, WindowRecurrence } from '../types'
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function recurrenceLabel(r: WindowRecurrence): string {
|
||||
const map: Record<WindowRecurrence, string> = {
|
||||
once: 'One-Time',
|
||||
daily: 'Daily',
|
||||
weekly: 'Weekly',
|
||||
monthly: 'Monthly',
|
||||
}
|
||||
return map[r]
|
||||
}
|
||||
|
||||
function recurrenceColor(r: WindowRecurrence): 'default' | 'primary' | 'secondary' | 'info' {
|
||||
const map: Record<WindowRecurrence, 'default' | 'primary' | 'secondary' | 'info'> = {
|
||||
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}` : '?'
|
||||
return `Every ${day} at ${time} for ${dur}`
|
||||
}
|
||||
case 'monthly': {
|
||||
const day = w.recurrence_day != null ? w.recurrence_day : '?'
|
||||
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
|
||||
}
|
||||
|
||||
function defaultForm(): FormValues {
|
||||
return {
|
||||
label: '',
|
||||
recurrence: 'once',
|
||||
start_at: nowIso(),
|
||||
duration_minutes: 60,
|
||||
recurrence_day: '',
|
||||
enabled: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ── Window form dialog ────────────────────────────────────────────────────────
|
||||
|
||||
interface WindowFormDialogProps {
|
||||
open: boolean
|
||||
title: string
|
||||
initial: FormValues
|
||||
onClose: () => void
|
||||
onSubmit: (values: FormValues) => Promise<void>
|
||||
}
|
||||
|
||||
function WindowFormDialog({ open, title, initial, onClose, onSubmit }: WindowFormDialogProps) {
|
||||
const [form, setForm] = useState<FormValues>(initial)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [err, setErr] = useState<string | null>(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 (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="sm" fullWidth>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogContent sx={{ display: 'flex', flexDirection: 'column', gap: 2, pt: 2 }}>
|
||||
{err && <Alert severity="error">{err}</Alert>}
|
||||
|
||||
<TextField
|
||||
label="Label"
|
||||
value={form.label}
|
||||
onChange={e => set('label', e.target.value)}
|
||||
required
|
||||
fullWidth
|
||||
/>
|
||||
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Recurrence</InputLabel>
|
||||
<Select
|
||||
label="Recurrence"
|
||||
value={form.recurrence}
|
||||
onChange={e => set('recurrence', e.target.value as WindowRecurrence)}
|
||||
>
|
||||
<MenuItem value="once">One-Time</MenuItem>
|
||||
<MenuItem value="daily">Daily</MenuItem>
|
||||
<MenuItem value="weekly">Weekly</MenuItem>
|
||||
<MenuItem value="monthly">Monthly</MenuItem>
|
||||
</Select>
|
||||
</FormControl>
|
||||
|
||||
<TextField
|
||||
label={form.recurrence === 'once' ? 'Start Date & Time (UTC)' : 'Reference Time (UTC)'}
|
||||
type="datetime-local"
|
||||
value={form.start_at}
|
||||
onChange={e => 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)'
|
||||
}
|
||||
/>
|
||||
|
||||
<TextField
|
||||
label="Duration (minutes)"
|
||||
type="number"
|
||||
value={form.duration_minutes}
|
||||
onChange={e => set('duration_minutes', parseInt(e.target.value, 10) || 60)}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { min: 1, max: 1440 } }}
|
||||
/>
|
||||
|
||||
{form.recurrence === 'weekly' && (
|
||||
<FormControl fullWidth>
|
||||
<InputLabel>Day of Week</InputLabel>
|
||||
<Select
|
||||
label="Day of Week"
|
||||
value={form.recurrence_day}
|
||||
onChange={e => set('recurrence_day', Number(e.target.value))}
|
||||
>
|
||||
{DAY_NAMES.map((name, i) => (
|
||||
<MenuItem key={i} value={i}>{name}</MenuItem>
|
||||
))}
|
||||
</Select>
|
||||
</FormControl>
|
||||
)}
|
||||
|
||||
{form.recurrence === 'monthly' && (
|
||||
<TextField
|
||||
label="Day of Month (1-31)"
|
||||
type="number"
|
||||
value={form.recurrence_day}
|
||||
onChange={e => set('recurrence_day', parseInt(e.target.value, 10) || 1)}
|
||||
fullWidth
|
||||
slotProps={{ htmlInput: { min: 1, max: 31 } }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FormControlLabel
|
||||
control={
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onChange={e => set('enabled', e.target.checked)}
|
||||
/>
|
||||
}
|
||||
label="Enabled"
|
||||
/>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={saving}>Cancel</Button>
|
||||
<Button variant="contained" onClick={handleSubmit} disabled={saving}>
|
||||
{saving ? <CircularProgress size={20} /> : 'Save'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Confirm delete dialog ──────────────────────────────────────────────────────
|
||||
|
||||
interface ConfirmDeleteProps {
|
||||
open: boolean
|
||||
windowLabel: string
|
||||
onClose: () => void
|
||||
onConfirm: () => Promise<void>
|
||||
}
|
||||
|
||||
function ConfirmDeleteDialog({ open, windowLabel, onClose, onConfirm }: ConfirmDeleteProps) {
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
const handleConfirm = async () => {
|
||||
setLoading(true)
|
||||
await onConfirm()
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
|
||||
<DialogTitle>Delete Window</DialogTitle>
|
||||
<DialogContent>
|
||||
<Typography>
|
||||
Delete maintenance window <strong>{windowLabel}</strong>? This cannot be undone.
|
||||
</Typography>
|
||||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={onClose} disabled={loading}>Cancel</Button>
|
||||
<Button color="error" variant="contained" onClick={handleConfirm} disabled={loading}>
|
||||
{loading ? <CircularProgress size={20} /> : 'Delete'}
|
||||
</Button>
|
||||
</DialogActions>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Per-host windows table ────────────────────────────────────────────────────
|
||||
|
||||
interface HostWindowsTableProps {
|
||||
host: Host
|
||||
windows: MaintenanceWindow[]
|
||||
onEdit: (w: MaintenanceWindow) => void
|
||||
onDelete: (w: MaintenanceWindow) => void
|
||||
onAdd: (hostId: string) => void
|
||||
}
|
||||
|
||||
function HostWindowsTable({ host, windows, onEdit, onDelete, onAdd }: HostWindowsTableProps) {
|
||||
return (
|
||||
<Paper variant="outlined" sx={{ mb: 3 }}>
|
||||
<Box
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
px: 2,
|
||||
py: 1.5,
|
||||
backgroundColor: 'action.hover',
|
||||
borderRadius: '4px 4px 0 0',
|
||||
}}
|
||||
>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
|
||||
<ScheduleIcon fontSize="small" color="primary" />
|
||||
<Typography variant="subtitle1" fontWeight={600}>
|
||||
{host.display_name}
|
||||
</Typography>
|
||||
<Typography variant="caption" color="text.secondary">
|
||||
({host.fqdn})
|
||||
</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<AddIcon />}
|
||||
variant="outlined"
|
||||
onClick={() => onAdd(host.id)}
|
||||
>
|
||||
Add Window
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{windows.length === 0 ? (
|
||||
<Box px={2} py={2}>
|
||||
<Typography variant="body2" color="text.secondary">
|
||||
No maintenance windows configured. Queued jobs will not execute until a window is added.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<Table size="small">
|
||||
<TableHead>
|
||||
<TableRow>
|
||||
<TableCell>Label</TableCell>
|
||||
<TableCell>Schedule</TableCell>
|
||||
<TableCell>Recurrence</TableCell>
|
||||
<TableCell>Status</TableCell>
|
||||
<TableCell>Created</TableCell>
|
||||
<TableCell align="right">Actions</TableCell>
|
||||
</TableRow>
|
||||
</TableHead>
|
||||
<TableBody>
|
||||
{windows.map(w => (
|
||||
<TableRow key={w.id} hover>
|
||||
<TableCell>{w.label}</TableCell>
|
||||
<TableCell>
|
||||
<Typography variant="body2">{scheduleDescription(w)}</Typography>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={recurrenceLabel(w.recurrence)}
|
||||
color={recurrenceColor(w.recurrence)}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Chip
|
||||
label={w.enabled ? 'Enabled' : 'Disabled'}
|
||||
color={w.enabled ? 'success' : 'default'}
|
||||
size="small"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>{fmtDate(w.created_at)}</TableCell>
|
||||
<TableCell align="right">
|
||||
<Tooltip title="Edit">
|
||||
<IconButton size="small" onClick={() => onEdit(w)}>
|
||||
<EditIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
<Tooltip title="Delete">
|
||||
<IconButton size="small" color="error" onClick={() => onDelete(w)}>
|
||||
<DeleteIcon fontSize="small" />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</Paper>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Main page ──────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function MaintenanceWindowsPage() {
|
||||
const [hosts, setHosts] = useState<Host[]>([])
|
||||
const [windowsByHost, setWindowsByHost] = useState<Record<string, MaintenanceWindow[]>>({})
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(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<string | null>(null)
|
||||
const [createForm, setCreateForm] = useState<FormValues>(defaultForm())
|
||||
|
||||
// Edit dialog state
|
||||
const [editOpen, setEditOpen] = useState(false)
|
||||
const [editWindow, setEditWindow] = useState<MaintenanceWindow | null>(null)
|
||||
const [editForm, setEditForm] = useState<FormValues>(defaultForm())
|
||||
|
||||
// Delete dialog state
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [deleteWindow, setDeleteWindow] = useState<MaintenanceWindow | null>(null)
|
||||
|
||||
// ── Fetch all hosts + their windows ──────────────────────────────────────
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
try {
|
||||
const hostsRes = await hostsApi.list({ limit: 500 })
|
||||
const fetchedHosts: Host[] = hostsRes.data?.hosts ?? hostsRes.data ?? []
|
||||
setHosts(fetchedHosts)
|
||||
|
||||
const windowMap: Record<string, MaintenanceWindow[]> = {}
|
||||
await Promise.all(
|
||||
fetchedHosts.map(async (h) => {
|
||||
try {
|
||||
const res = await maintenanceWindowsApi.list(h.id)
|
||||
windowMap[h.id] = res.data?.windows ?? []
|
||||
} catch {
|
||||
windowMap[h.id] = []
|
||||
}
|
||||
})
|
||||
)
|
||||
setWindowsByHost(windowMap)
|
||||
} catch {
|
||||
setError('Failed to load hosts or maintenance windows.')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { fetchData() }, [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,
|
||||
})
|
||||
setCreateOpen(false)
|
||||
showSnackbar('Maintenance window created', 'success')
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
})
|
||||
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,
|
||||
})
|
||||
setEditOpen(false)
|
||||
showSnackbar('Maintenance window updated', 'success')
|
||||
await fetchData()
|
||||
}
|
||||
|
||||
// ── 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')
|
||||
await fetchData()
|
||||
} catch {
|
||||
showSnackbar('Failed to delete maintenance window', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<Container maxWidth="xl" sx={{ mt: 3, mb: 6 }}>
|
||||
{/* Page header */}
|
||||
<Toolbar disableGutters sx={{ mb: 2, gap: 1 }}>
|
||||
<ScheduleIcon color="primary" />
|
||||
<Typography variant="h5" fontWeight={700} sx={{ flexGrow: 1 }}>
|
||||
Maintenance Windows
|
||||
</Typography>
|
||||
<Button
|
||||
startIcon={<RefreshIcon />}
|
||||
onClick={fetchData}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</Button>
|
||||
</Toolbar>
|
||||
|
||||
<Typography variant="body2" color="text.secondary" sx={{ mb: 3 }}>
|
||||
Queued (non-immediate) patch jobs only execute during open maintenance windows.
|
||||
Configure one or more windows per host to control when patching occurs.
|
||||
</Typography>
|
||||
|
||||
{loading && (
|
||||
<Box display="flex" justifyContent="center" mt={8}>
|
||||
<CircularProgress />
|
||||
</Box>
|
||||
)}
|
||||
|
||||
{!loading && error && (
|
||||
<Alert severity="error" sx={{ mb: 2 }}>{error}</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && hosts.length === 0 && (
|
||||
<Alert severity="info">
|
||||
No hosts found. Register hosts first before configuring maintenance windows.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{!loading && !error && hosts.map(host => (
|
||||
<HostWindowsTable
|
||||
key={host.id}
|
||||
host={host}
|
||||
windows={windowsByHost[host.id] ?? []}
|
||||
onEdit={handleEditClick}
|
||||
onDelete={handleDeleteClick}
|
||||
onAdd={handleAddClick}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Create dialog */}
|
||||
<WindowFormDialog
|
||||
open={createOpen}
|
||||
title="Add Maintenance Window"
|
||||
initial={createForm}
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onSubmit={handleCreateSubmit}
|
||||
/>
|
||||
|
||||
{/* Edit dialog */}
|
||||
<WindowFormDialog
|
||||
open={editOpen}
|
||||
title="Edit Maintenance Window"
|
||||
initial={editForm}
|
||||
onClose={() => setEditOpen(false)}
|
||||
onSubmit={handleEditSubmit}
|
||||
/>
|
||||
|
||||
{/* Delete confirm dialog */}
|
||||
<ConfirmDeleteDialog
|
||||
open={deleteOpen}
|
||||
windowLabel={deleteWindow?.label ?? ''}
|
||||
onClose={() => setDeleteOpen(false)}
|
||||
onConfirm={handleDeleteConfirm}
|
||||
/>
|
||||
|
||||
{/* Success/error snackbar */}
|
||||
<Snackbar
|
||||
open={snackbar.open}
|
||||
autoHideDuration={4000}
|
||||
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
|
||||
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
|
||||
>
|
||||
<Alert
|
||||
severity={snackbar.severity}
|
||||
onClose={() => setSnackbar(prev => ({ ...prev, open: false }))}
|
||||
>
|
||||
{snackbar.message}
|
||||
</Alert>
|
||||
</Snackbar>
|
||||
</Container>
|
||||
)
|
||||
}
|
||||
@ -117,3 +117,52 @@ export interface CreateJobRequest {
|
||||
allow_reboot?: boolean
|
||||
notes?: string
|
||||
}
|
||||
|
||||
// ── Maintenance Windows ───────────────────────────────────────────────────────
|
||||
|
||||
export type WindowRecurrence = 'once' | 'daily' | 'weekly' | 'monthly'
|
||||
|
||||
export interface MaintenanceWindow {
|
||||
id: string
|
||||
host_id: string
|
||||
label: string
|
||||
recurrence: WindowRecurrence
|
||||
/** Absolute start (once) or time-of-day reference (recurring) — ISO 8601 UTC */
|
||||
start_at: string
|
||||
/** Duration in minutes */
|
||||
duration_minutes: number
|
||||
/** 0-6 for weekly (0=Sun), 1-31 for monthly, null for once/daily */
|
||||
recurrence_day?: number | null
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface CreateMaintenanceWindowRequest {
|
||||
label: string
|
||||
recurrence: WindowRecurrence
|
||||
start_at: string
|
||||
duration_minutes?: number
|
||||
recurrence_day?: number | null
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
export interface UpdateMaintenanceWindowRequest {
|
||||
label?: string
|
||||
recurrence?: WindowRecurrence
|
||||
start_at?: string
|
||||
duration_minutes?: number
|
||||
recurrence_day?: number | null
|
||||
enabled?: boolean
|
||||
}
|
||||
|
||||
// ── WebSocket event types (M7) ────────────────────────────────────────────────
|
||||
|
||||
export interface JobWsEvent {
|
||||
job_id: string
|
||||
host_id: string
|
||||
status: JobStatus
|
||||
output?: string
|
||||
error_message?: string
|
||||
agent_job_id?: string
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user