From 7fec21915293d6ffa92ec2b1da9e6b7f11f0c72b Mon Sep 17 00:00:00 2001 From: Forbes Date: Sun, 15 Feb 2026 13:11:04 -0600 Subject: [PATCH] feat(modules): SSE settings.changed event broadcast and UI reactions Add useSSE hook that connects to /api/events with automatic reconnect and exponential backoff. On settings.changed events: - Refresh module state so sidebar nav items show/hide immediately - Show dismissable toast when another admin updates settings The backend already publishes settings.changed in HandleUpdateModuleSettings. Closes #101 --- web/src/components/AppShell.tsx | 52 +++++++++++++++++++++- web/src/hooks/useModules.ts | 10 ++++- web/src/hooks/useSSE.ts | 76 +++++++++++++++++++++++++++++++++ 3 files changed, 135 insertions(+), 3 deletions(-) create mode 100644 web/src/hooks/useSSE.ts diff --git a/web/src/components/AppShell.tsx b/web/src/components/AppShell.tsx index 6158fc2..18cc8df 100644 --- a/web/src/components/AppShell.tsx +++ b/web/src/components/AppShell.tsx @@ -3,12 +3,41 @@ import { Outlet } from "react-router-dom"; import { useAuth } from "../hooks/useAuth"; import { useDensity } from "../hooks/useDensity"; import { useModules } from "../hooks/useModules"; +import { useSSE } from "../hooks/useSSE"; import { Sidebar } from "./Sidebar"; export function AppShell() { const { user, loading, logout } = useAuth(); const [density, toggleDensity] = useDensity(); - const { modules } = useModules(); + const { modules, refresh: refreshModules } = useModules(); + const { on } = useSSE(); + const [toast, setToast] = useState(null); + + // Listen for settings.changed SSE events + useEffect(() => { + return on("settings.changed", (raw) => { + try { + const data = JSON.parse(raw) as { + module: string; + changed_keys: string[]; + updated_by: string; + }; + refreshModules(); + if (data.updated_by !== user?.username) { + setToast(`Settings updated by ${data.updated_by}`); + } + } catch { + // ignore malformed events + } + }); + }, [on, refreshModules, user?.username]); + + // Auto-dismiss toast + useEffect(() => { + if (!toast) return; + const timer = setTimeout(() => setToast(null), 5000); + return () => clearTimeout(timer); + }, [toast]); const [sidebarOpen, setSidebarOpen] = useState(() => { return localStorage.getItem("silo-sidebar") !== "closed"; @@ -63,6 +92,27 @@ export function AppShell() {
+ {toast && ( +
setToast(null)} + > + {toast} +
+ )} ); } diff --git a/web/src/hooks/useModules.ts b/web/src/hooks/useModules.ts index 87a4e87..7f14368 100644 --- a/web/src/hooks/useModules.ts +++ b/web/src/hooks/useModules.ts @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { get } from "../api/client"; import type { ModuleInfo, ModulesResponse } from "../api/types"; @@ -6,6 +6,12 @@ export function useModules() { const [modules, setModules] = useState>({}); const [loading, setLoading] = useState(true); + const refresh = useCallback(() => { + get("/api/modules") + .then((res) => setModules(res.modules)) + .catch(() => {}); + }, []); + useEffect(() => { get("/api/modules") .then((res) => setModules(res.modules)) @@ -13,5 +19,5 @@ export function useModules() { .finally(() => setLoading(false)); }, []); - return { modules, loading }; + return { modules, loading, refresh }; } diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts new file mode 100644 index 0000000..a3b1f0a --- /dev/null +++ b/web/src/hooks/useSSE.ts @@ -0,0 +1,76 @@ +import { useEffect, useRef, useCallback } from "react"; + +type SSEHandler = (data: string) => void; + +/** + * Subscribe to the server-sent event stream at /api/events. + * Returns a stable `on` function to register typed event handlers. + * Reconnects automatically with exponential backoff on connection loss. + */ +export function useSSE() { + const handlersRef = useRef(new Map>()); + + // Register a handler for a given event type. Returns an unsubscribe function. + const on = useCallback((eventType: string, handler: SSEHandler) => { + if (!handlersRef.current.has(eventType)) { + handlersRef.current.set(eventType, new Set()); + } + handlersRef.current.get(eventType)!.add(handler); + return () => { + handlersRef.current.get(eventType)?.delete(handler); + }; + }, []); + + useEffect(() => { + let retryDelay = 1000; + let timer: ReturnType; + let cancelled = false; + let es: EventSource | null = null; + + function dispatch(type: string, data: string) { + const handlers = handlersRef.current.get(type); + if (handlers) { + for (const h of handlers) h(data); + } + } + + function connect() { + if (cancelled) return; + + es = new EventSource("/api/events", { withCredentials: true }); + + es.onopen = () => { + retryDelay = 1000; + }; + + // The backend sends named events (event: settings.changed\ndata: ...), + // so we register listeners for all event types we care about. + // We use a generic message handler plus named event listeners. + const knownEvents = ["settings.changed", "server.state", "heartbeat"]; + for (const eventType of knownEvents) { + es.addEventListener(eventType, ((e: MessageEvent) => { + dispatch(eventType, e.data); + }) as EventListener); + } + + es.onerror = () => { + es?.close(); + es = null; + if (!cancelled) { + timer = setTimeout(connect, retryDelay); + retryDelay = Math.min(retryDelay * 2, 30000); + } + }; + } + + connect(); + + return () => { + cancelled = true; + clearTimeout(timer); + es?.close(); + }; + }, []); + + return { on }; +} -- 2.49.1