diff --git a/apps/admin-ui/src/app/api/[...path]/route.ts b/apps/admin-ui/src/app/api/[...path]/route.ts index 773fe62..cde1670 100644 --- a/apps/admin-ui/src/app/api/[...path]/route.ts +++ b/apps/admin-ui/src/app/api/[...path]/route.ts @@ -63,13 +63,15 @@ async function proxy(request: NextRequest, pathSegments: string[]) { const search = url.search; const isLogin = request.method === "POST" && targetPath === "v1/auth/login"; const isLogout = request.method === "POST" && targetPath === "v1/auth/logout"; + const isSse = + request.method === "GET" && targetPath === "v1/admin/viewers/events"; const headers = new Headers(); const contentType = request.headers.get("content-type"); if (contentType) headers.set("content-type", contentType); const cookie = request.headers.get("cookie"); if (cookie) headers.set("cookie", cookie); - headers.set("accept", "application/json"); + headers.set("accept", isSse ? "text/event-stream" : "application/json"); const body = request.method !== "GET" && request.method !== "HEAD" @@ -86,6 +88,10 @@ async function proxy(request: NextRequest, pathSegments: string[]) { headers, body, redirect: "manual", + // SSE must not be aborted when the proxy function "returns" the stream + ...(isSse + ? { cache: "no-store" as RequestCache, signal: request.signal } + : {}), }); // Special-case auth so the session cookie is owned by admin.vonas.nl @@ -130,6 +136,34 @@ async function proxy(request: NextRequest, pathSegments: string[]) { return response; } + if (isSse) { + if (!upstream.ok) { + const errBody = await upstream.arrayBuffer(); + return new NextResponse(errBody, { + status: upstream.status, + headers: { + "content-type": + upstream.headers.get("content-type") ?? "application/json", + }, + }); + } + if (!upstream.body) { + return NextResponse.json( + { error: { code: "UPSTREAM_ERROR", message: "SSE stream missing body" } }, + { status: 502 } + ); + } + return new NextResponse(upstream.body, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache, no-transform", + Connection: "keep-alive", + "X-Accel-Buffering": "no", + }, + }); + } + const responseBody = await upstream.arrayBuffer(); const response = new NextResponse(responseBody, { status: upstream.status }); response.headers.set( diff --git a/apps/admin-ui/src/app/viewers/page.tsx b/apps/admin-ui/src/app/viewers/page.tsx index cf51d02..e4bb2c4 100644 --- a/apps/admin-ui/src/app/viewers/page.tsx +++ b/apps/admin-ui/src/app/viewers/page.tsx @@ -194,6 +194,48 @@ export default function ViewersPage() { .catch(() => undefined); }, [load]); + // Live updates via SSE (device link, login lock, CRUD). Geen interval-poll. + // Overslaan tijdens typen; formuliervelden zitten in aparte state en blijven staan. + useEffect(() => { + let debounce: ReturnType | null = null; + const isTyping = () => { + const el = document.activeElement; + if (!el || !(el instanceof HTMLElement)) return false; + const tag = el.tagName; + return tag === "INPUT" || tag === "TEXTAREA" || tag === "SELECT" || el.isContentEditable; + }; + const scheduleRefresh = () => { + if (isTyping()) return; + if (debounce) clearTimeout(debounce); + debounce = setTimeout(() => { + if (!isTyping()) load(); + }, 200); + }; + + const es = new EventSource("/api/v1/admin/viewers/events"); + es.addEventListener("viewers", (ev) => { + try { + const data = JSON.parse((ev as MessageEvent).data) as { type?: string }; + if (data.type === "hello") return; + } catch { + // still refresh on malformed payloads + } + scheduleRefresh(); + }); + + // Fallback als je terugkomt op het tabblad (bijv. na korte netwerk-dip) + const onVisibility = () => { + if (document.visibilityState === "visible") scheduleRefresh(); + }; + document.addEventListener("visibilitychange", onVisibility); + + return () => { + if (debounce) clearTimeout(debounce); + es.close(); + document.removeEventListener("visibilitychange", onVisibility); + }; + }, [load]); + const stats = useMemo(() => { const active = viewers.filter((v) => v.enabled).length; const appOn = viewers.filter((v) => v.appAccess).length; @@ -809,6 +851,9 @@ export default function ViewersPage() { />
+ {playConfig?.configured ? ( <>