Private
Public Access
1
0

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:
2026-04-23 17:42:51 +00:00
parent 6f9c6dc881
commit a5d52ffab0
21 changed files with 2833 additions and 36 deletions

View File

@ -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>} />

View File

@ -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),
}

View 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 }
}

View File

@ -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>
)
}

View File

@ -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}>

View 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>
)
}

View File

@ -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
}