Merge pull request 'feat(modules): SSE settings.changed event broadcast' (#123) from feat-sse-settings-changed into main
Reviewed-on: #123
This commit was merged in pull request #123.
This commit is contained in:
@@ -3,12 +3,41 @@ import { Outlet } from "react-router-dom";
|
|||||||
import { useAuth } from "../hooks/useAuth";
|
import { useAuth } from "../hooks/useAuth";
|
||||||
import { useDensity } from "../hooks/useDensity";
|
import { useDensity } from "../hooks/useDensity";
|
||||||
import { useModules } from "../hooks/useModules";
|
import { useModules } from "../hooks/useModules";
|
||||||
|
import { useSSE } from "../hooks/useSSE";
|
||||||
import { Sidebar } from "./Sidebar";
|
import { Sidebar } from "./Sidebar";
|
||||||
|
|
||||||
export function AppShell() {
|
export function AppShell() {
|
||||||
const { user, loading, logout } = useAuth();
|
const { user, loading, logout } = useAuth();
|
||||||
const [density, toggleDensity] = useDensity();
|
const [density, toggleDensity] = useDensity();
|
||||||
const { modules } = useModules();
|
const { modules, refresh: refreshModules } = useModules();
|
||||||
|
const { on } = useSSE();
|
||||||
|
const [toast, setToast] = useState<string | null>(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(() => {
|
const [sidebarOpen, setSidebarOpen] = useState(() => {
|
||||||
return localStorage.getItem("silo-sidebar") !== "closed";
|
return localStorage.getItem("silo-sidebar") !== "closed";
|
||||||
@@ -63,6 +92,27 @@ export function AppShell() {
|
|||||||
<main style={{ flex: 1, overflow: "auto", padding: "1rem" }}>
|
<main style={{ flex: 1, overflow: "auto", padding: "1rem" }}>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
{toast && (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: "fixed",
|
||||||
|
bottom: "1rem",
|
||||||
|
right: "1rem",
|
||||||
|
padding: "0.5rem 1rem",
|
||||||
|
backgroundColor: "var(--ctp-surface1)",
|
||||||
|
color: "var(--ctp-text)",
|
||||||
|
borderRadius: "0.5rem",
|
||||||
|
fontSize: "var(--font-body)",
|
||||||
|
border: "1px solid var(--ctp-surface2)",
|
||||||
|
boxShadow: "0 2px 8px rgba(0,0,0,0.3)",
|
||||||
|
zIndex: 1000,
|
||||||
|
cursor: "pointer",
|
||||||
|
}}
|
||||||
|
onClick={() => setToast(null)}
|
||||||
|
>
|
||||||
|
{toast}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { get } from "../api/client";
|
import { get } from "../api/client";
|
||||||
import type { ModuleInfo, ModulesResponse } from "../api/types";
|
import type { ModuleInfo, ModulesResponse } from "../api/types";
|
||||||
|
|
||||||
@@ -6,6 +6,12 @@ export function useModules() {
|
|||||||
const [modules, setModules] = useState<Record<string, ModuleInfo>>({});
|
const [modules, setModules] = useState<Record<string, ModuleInfo>>({});
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
const refresh = useCallback(() => {
|
||||||
|
get<ModulesResponse>("/api/modules")
|
||||||
|
.then((res) => setModules(res.modules))
|
||||||
|
.catch(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
get<ModulesResponse>("/api/modules")
|
get<ModulesResponse>("/api/modules")
|
||||||
.then((res) => setModules(res.modules))
|
.then((res) => setModules(res.modules))
|
||||||
@@ -13,5 +19,5 @@ export function useModules() {
|
|||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return { modules, loading };
|
return { modules, loading, refresh };
|
||||||
}
|
}
|
||||||
|
|||||||
76
web/src/hooks/useSSE.ts
Normal file
76
web/src/hooks/useSSE.ts
Normal file
@@ -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<string, Set<SSEHandler>>());
|
||||||
|
|
||||||
|
// 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<typeof setTimeout>;
|
||||||
|
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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user