import { useEffect, useRef } from "react"; import { useQueryClient } from "@tanstack/react-query"; import { useAuthStore } from "@/store/auth"; /** * Refresh the UI when Docker changes, instead of asking it every few seconds. * * Almost everything the app renders — stack status, container state, images, * networks, volumes — only changes when the daemon does something, and the * daemon already announces that. So one WebSocket replaces the per-page polling * as the *primary* refresh mechanism; the intervals stay behind it as a slow * safety net for the cases an event cannot cover (a container quietly eating * memory, a registry gaining a newer image, a socket that dropped unnoticed). * * The result is both cheaper and faster: no fixed floor of requests per second * against the daemon, and a stack that finishes starting shows up immediately * rather than up to five seconds later. */ /** Query keys to drop, per Docker event resource type. */ const INVALIDATES: Record = { container: [["stacks"], ["stack"], ["stack-stats"], ["dashboard-fleet"], ["container"]], image: [["images"], ["stack-updates"], ["dashboard-fleet"]], network: [["networks"], ["network-containers"]], volume: [["volumes"], ["volume-sizes"]], }; /** * How long to gather events before refreshing. * * A single `compose up` emits a burst — create, start, network connect, health * check — per service. Without this, a ten-service stack would fire dozens of * refetches in a second, which is precisely the load this is meant to remove. */ const COALESCE_MS = 300; /** Backoff between reconnect attempts, capped so a long outage still recovers. */ const RECONNECT_MIN_MS = 1000; const RECONNECT_MAX_MS = 30000; export function useDockerEvents() { const token = useAuthStore((s) => s.accessToken); const qc = useQueryClient(); // Kept in a ref so the reconnect loop is not torn down by re-renders. const pending = useRef(new Set()); const timer = useRef(null); useEffect(() => { if (!token) return; let socket: WebSocket | null = null; let retry: number | null = null; let delay = RECONNECT_MIN_MS; let closed = false; const flush = () => { timer.current = null; const resources = [...pending.current]; pending.current.clear(); const keys = new Map(); for (const resource of resources) { for (const key of INVALIDATES[resource] ?? []) keys.set(key.join("/"), key); } for (const key of keys.values()) { // Prefix match: ["stack"] also drops ["stack", ""]. qc.invalidateQueries({ queryKey: key }); } }; const connect = () => { const proto = window.location.protocol === "https:" ? "wss" : "ws"; socket = new WebSocket( `${proto}://${window.location.host}/ws/events?token=${encodeURIComponent(token)}` ); socket.onopen = () => { delay = RECONNECT_MIN_MS; }; socket.onmessage = (ev) => { try { const msg = JSON.parse(ev.data); if (msg.type !== "event" || !msg.resource) return; pending.current.add(msg.resource); if (timer.current === null) { timer.current = window.setTimeout(flush, COALESCE_MS); } } catch { /* ignore malformed frames */ } }; // Any close reconnects with backoff — including 4401, since the access // token is short-lived and the API client refreshes it out from under us. socket.onclose = () => { if (closed) return; retry = window.setTimeout(connect, delay); delay = Math.min(delay * 2, RECONNECT_MAX_MS); }; }; connect(); return () => { closed = true; if (retry !== null) window.clearTimeout(retry); if (timer.current !== null) { window.clearTimeout(timer.current); timer.current = null; } socket?.close(); }; }, [token, qc]); }