Private
Public Access
1
0

Merge fix/maintenance-windows-race-condition: resolve maintenance windows race condition and N+1 query
All checks were successful
CI Pipeline / Rust Format Check (push) Successful in 3s
CI Pipeline / Clippy Lints (push) Successful in 1m0s
CI Pipeline / Rust Unit Tests (push) Successful in 1m23s
CI Pipeline / Security Audit (push) Successful in 4s
CI Pipeline / Frontend Lint & Type Check (push) Successful in 16s
CI Pipeline / Build .deb & Release (push) Successful in 4m29s

This commit is contained in:
2026-05-22 03:28:59 +00:00
5 changed files with 104 additions and 23 deletions

0
.gitea/workflows/ci.yml Normal file → Executable file
View File

View File

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

36
crates/pm-web/src/routes/maintenance_windows.rs Executable file → Normal file
View File

@ -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<AppState> {
.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<AppState> {
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<AppState>,
_auth: AuthUser,
) -> Result<Json<Value>, (StatusCode, Json<Value>)> {
let windows: Vec<MaintenanceWindow> = 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]

View File

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

View File

@ -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<MaintenanceWindow | null>(null)
// ── Fetch all hosts + their windows ──────────────────────────────────────
const fetchData = useCallback(async () => {
// ── AbortController ref for cancelling stale fetches ──────────────────────
const abortRef = useRef<AbortController | null>(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<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] = []
}
})
)
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() {
</Typography>
<Button
startIcon={<RefreshIcon />}
onClick={fetchData}
onClick={refreshData}
disabled={loading}
>
Refresh