diff --git a/crates/pm-web/src/main.rs b/crates/pm-web/src/main.rs index e88cad0..99722db 100644 --- a/crates/pm-web/src/main.rs +++ b/crates/pm-web/src/main.rs @@ -288,6 +288,11 @@ pub fn build_router(state: AppState) -> Router { "/hosts/{host_id}/maintenance-windows", routes::maintenance_windows::router(), ) + // Maintenance windows — bulk list-all endpoint + .nest( + "/maintenance-windows", + routes::maintenance_windows::all_windows_router(), + ) // CA root certificate download .nest("/ca", routes::ca::ca_router()) // Certificate list / renew / revoke diff --git a/crates/pm-web/src/routes/maintenance_windows.rs b/crates/pm-web/src/routes/maintenance_windows.rs old mode 100755 new mode 100644 index 7f59efc..8122877 --- a/crates/pm-web/src/routes/maintenance_windows.rs +++ b/crates/pm-web/src/routes/maintenance_windows.rs @@ -1,6 +1,7 @@ //! Maintenance window management routes. //! //! GET /api/v1/hosts/{id}/maintenance-windows — list windows for host +//! GET /api/v1/maintenance-windows — list ALL windows (bulk) //! POST /api/v1/hosts/{id}/maintenance-windows — create window for host //! PUT /api/v1/hosts/{id}/maintenance-windows/{win_id} — update window //! DELETE /api/v1/hosts/{id}/maintenance-windows/{win_id} — delete window @@ -32,6 +33,41 @@ pub fn router() -> Router { .route("/{win_id}", put(update_window).delete(delete_window)) } +/// Top-level router for `/api/v1/maintenance-windows` — bulk list-all endpoint. +pub fn all_windows_router() -> Router { + Router::new().route("/", get(list_all_windows)) +} + +// ── GET /api/v1/maintenance-windows ────────────────────────────────────────── + +/// Bulk endpoint: return every maintenance window across all hosts. +/// Eliminates N+1 queries from the frontend (one request instead of one per host). +async fn list_all_windows( + State(state): State, + _auth: AuthUser, +) -> Result, (StatusCode, Json)> { + let windows: Vec = sqlx::query_as( + r#" + SELECT id, host_id, label, recurrence, start_at, duration_minutes, + recurrence_day, enabled, auto_apply, created_at, updated_at + FROM maintenance_windows + ORDER BY host_id, created_at ASC + "#, + ) + .fetch_all(&state.db) + .await + .map_err(|e| { + tracing::error!(error = %e, "list_all_windows: query failed"); + err( + StatusCode::INTERNAL_SERVER_ERROR, + "internal_error", + "Database error", + ) + })?; + + Ok(Json(json!({ "windows": windows }))) +} + // ── Error helper ────────────────────────────────────────────────────────────── #[inline] diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 2eb5a57..660827d 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -5,6 +5,7 @@ import type { CreateHostRequest, CreateJobRequest, CreateMaintenanceWindowRequest, + MaintenanceWindow, UpdateMaintenanceWindowRequest, Certificate, IssuedCert, @@ -176,6 +177,10 @@ export const patchesApi = { // ── Maintenance Windows API ─────────────────────────────────────────────────── export const maintenanceWindowsApi = { + /** Bulk: fetch ALL maintenance windows across every host in one request. */ + listAll: () => + apiClient.get<{ windows: MaintenanceWindow[] }>('/maintenance-windows'), + /** Per-host: fetch windows for a single host. */ list: (hostId: string) => apiClient.get(`/hosts/${hostId}/maintenance-windows`), create: (hostId: string, body: CreateMaintenanceWindowRequest) => diff --git a/frontend/src/pages/MaintenanceWindowsPage.tsx b/frontend/src/pages/MaintenanceWindowsPage.tsx index b502c32..cdd7d32 100644 --- a/frontend/src/pages/MaintenanceWindowsPage.tsx +++ b/frontend/src/pages/MaintenanceWindowsPage.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' import { Alert, Box, @@ -444,35 +444,70 @@ export default function MaintenanceWindowsPage() { const [deleteOpen, setDeleteOpen] = useState(false) const [deleteWindow, setDeleteWindow] = useState(null) - // ── Fetch all hosts + their windows ────────────────────────────────────── - const fetchData = useCallback(async () => { + // ── AbortController ref for cancelling stale fetches ────────────────────── + const abortRef = useRef(null) + + // ── Fetch hosts + all maintenance windows in 2 parallel requests ───────── + // Uses bulk /maintenance-windows endpoint instead of N+1 per-host calls. + // State updates are batched atomically so React never renders hosts without + // their windows (the root cause of the "randomly missing data" bug). + const fetchData = useCallback(async (signal?: AbortSignal) => { setLoading(true) setError(null) try { - const hostsRes = await hostsApi.list({ limit: 500 }) - const fetchedHosts: Host[] = hostsRes.data?.hosts ?? hostsRes.data ?? [] - setHosts(fetchedHosts) + // Fetch hosts and ALL windows in parallel — 2 requests, not N+1. + const [hostsRes, windowsRes] = await Promise.all([ + hostsApi.list({ limit: 500 }), + maintenanceWindowsApi.listAll(), + ]) + // If the request was aborted (e.g. component unmounted or new fetch + // started), discard the results silently. + if (signal?.aborted) return + + const fetchedHosts: Host[] = hostsRes.data?.hosts ?? hostsRes.data ?? [] + const allWindows: MaintenanceWindow[] = windowsRes.data?.windows ?? [] + + // Group windows by host_id for O(N) lookup. const windowMap: Record = {} - 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] = [] - } - }) - ) + for (const w of allWindows) { + if (!windowMap[w.host_id]) windowMap[w.host_id] = [] + windowMap[w.host_id].push(w) + } + + // Batch both state updates together — React 18+ auto-batches these + // into a single render, eliminating the race condition where hosts + // rendered with stale/empty windows. + setHosts(fetchedHosts) setWindowsByHost(windowMap) - } catch { + } catch (err: unknown) { + if (signal?.aborted) return // stale request — ignore silently + // Only log real errors, not cancellations. + if (err instanceof DOMException && err.name === 'AbortError') return setError('Failed to load hosts or maintenance windows.') } finally { - setLoading(false) + if (!signal?.aborted) { + setLoading(false) + } } }, []) - useEffect(() => { fetchData() }, [fetchData]) + useEffect(() => { + // Cancel any in-flight fetch from a previous render. + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + fetchData(controller.signal) + return () => { controller.abort() } + }, [fetchData]) + + // ── Refresh helper: cancels any in-flight fetch, starts a new one ──────── + const refreshData = useCallback(() => { + abortRef.current?.abort() + const controller = new AbortController() + abortRef.current = controller + fetchData(controller.signal) + }, [fetchData]) // ── Helpers ─────────────────────────────────────────────────────────────── const showSnackbar = (message: string, severity: 'success' | 'error') => @@ -498,7 +533,7 @@ export default function MaintenanceWindowsPage() { }) setCreateOpen(false) showSnackbar('Maintenance window created', 'success') - await fetchData() + refreshData() } // ── Edit window ─────────────────────────────────────────────────────────── @@ -529,7 +564,7 @@ export default function MaintenanceWindowsPage() { }) setEditOpen(false) showSnackbar('Maintenance window updated', 'success') - await fetchData() + refreshData() } // ── Delete window ───────────────────────────────────────────────────────── @@ -544,7 +579,7 @@ export default function MaintenanceWindowsPage() { await maintenanceWindowsApi.remove(deleteWindow.host_id, deleteWindow.id) setDeleteOpen(false) showSnackbar('Maintenance window deleted', 'success') - await fetchData() + refreshData() } catch { showSnackbar('Failed to delete maintenance window', 'error') } @@ -561,7 +596,7 @@ export default function MaintenanceWindowsPage() {