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:
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 }
|
||||
}
|
||||
Reference in New Issue
Block a user