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

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