From 414cda2798d51865ab8f554baecb3daf250498e5 Mon Sep 17 00:00:00 2001 From: stroblme Date: Wed, 19 Aug 2026 17:09:53 +0200 Subject: [PATCH] Dashboard: run unchanged when a portal serves it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The same bundle is served by a portal under /i/{id}, so it reads its API base, credential and router basepath from an injected config instead of the build-time URL and localStorage. A normal installation finds no config and behaves exactly as before; the credential deliberately never touches localStorage, since two installations open in one browser share an origin and would overwrite each other's session. The websocket URL was resolving an absolute path against the base, which discards the base's own path — harmless until the base gained one, then it aimed the socket at the wrong host entirely. Connection state gets a store of its own, apart from the engine's: the proxy's 503 carries {offline, last_seen}, which raises a banner naming when the installation was last heard from and turns a failed mutation into 'not delivered' rather than a generic error. The screen keeps its last data underneath, since stale readings with a timestamp beat a blank page. A reconnecting socket invalidates every query, because whatever happened while it was down was missed. Verified in a browser against a real hub and installation: the full UI loads through the tunnel with no console errors, and killing the installation raises the banner within a poll. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01XtBzdDyLsmDaF1W7DLYtYM --- frontend/Dockerfile | 2 + .../components/Common/ConnectionBanner.tsx | 52 ++++++++++++++ frontend/src/components/Flow/useFlowSocket.ts | 18 ++++- .../src/components/Sidebar/AppSidebar.tsx | 28 ++++++-- frontend/src/hooks/useAuth.ts | 4 ++ frontend/src/lib/connectionStore.ts | 68 +++++++++++++++++++ frontend/src/lib/portal.ts | 44 ++++++++++++ frontend/src/main.tsx | 32 +++++++-- frontend/src/routes/_layout.tsx | 3 + frontend/src/utils.ts | 11 +++ frontend/vite.config.ts | 4 ++ 11 files changed, 254 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/Common/ConnectionBanner.tsx create mode 100644 frontend/src/lib/connectionStore.ts create mode 100644 frontend/src/lib/portal.ts diff --git a/frontend/Dockerfile b/frontend/Dockerfile index e9bae40..a65170e 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -13,6 +13,8 @@ RUN bun install COPY ./frontend /app/frontend ARG VITE_API_URL +# Set only for the copy a portal serves; see vite.config.ts. +ARG VITE_BASE RUN bun run build diff --git a/frontend/src/components/Common/ConnectionBanner.tsx b/frontend/src/components/Common/ConnectionBanner.tsx new file mode 100644 index 0000000..1856f03 --- /dev/null +++ b/frontend/src/components/Common/ConnectionBanner.tsx @@ -0,0 +1,52 @@ +import { WifiOff } from "lucide-react" +import { AnimatePresence, motion } from "motion/react" +import { useSyncExternalStore } from "react" + +import { ago } from "@/components/Health/queries" +import { connectionStore } from "@/lib/connectionStore" +import { fadeIn } from "@/lib/motion" +import { isPortal } from "@/lib/portal" + +/** + * Says when the installation cannot be reached, and when it was last heard + * from. + * + * Only ever shown under a portal: a local install cannot lose contact with + * itself. The screen underneath keeps its last data rather than blanking — + * stale readings with a timestamp are more use than an empty page, which is + * why the banner leads with when we last heard anything. + */ +export function ConnectionBanner() { + const connection = useSyncExternalStore( + connectionStore.subscribe, + connectionStore.snapshot, + connectionStore.snapshot, + ) + if (!isPortal()) return null + + return ( + + {connection.offline && ( + +
+ + Installation offline + + {connection.lastSeen + ? `last seen ${ago(connection.lastSeen / 1000)} — reconnecting…` + : "reconnecting…"} + +
+
+ )} +
+ ) +} diff --git a/frontend/src/components/Flow/useFlowSocket.ts b/frontend/src/components/Flow/useFlowSocket.ts index c823298..095d80e 100644 --- a/frontend/src/components/Flow/useFlowSocket.ts +++ b/frontend/src/components/Flow/useFlowSocket.ts @@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query" import { useEffect, useRef } from "react" import { OpenAPI } from "@/client" +import { connectionStore } from "@/lib/connectionStore" +import { apiToken } from "@/lib/portal" import { type LogLine, liveStore, type ValueSource } from "./liveStore" import { flowKeys } from "./queries" @@ -77,11 +79,14 @@ type FlowEvent = function socketUrl(): string { const base = String(OpenAPI.BASE || window.location.origin) - const url = new URL("/api/v1/flows/ws", base) + // Concatenated rather than resolved: an absolute path as the second argument + // to `new URL` discards the base's own path, which under a portal + // (`https://host/i/{id}`) would aim the socket at the wrong place entirely. + const url = new URL(`${base.replace(/\/$/, "")}/api/v1/flows/ws`) url.protocol = url.protocol === "https:" ? "wss:" : "ws:" // Browsers cannot set headers on a websocket handshake, so the token rides // in the query string. - url.searchParams.set("token", localStorage.getItem("access_token") ?? "") + url.searchParams.set("token", apiToken()) return url.toString() } @@ -117,6 +122,10 @@ export function useFlowSocket(onAuthFailure?: () => void): void { ws.onopen = () => { retry.current = RECONNECT_MIN liveStore.setConnected(true) + connectionStore.setOnline() + // Whatever happened while the socket was down was missed, so nothing + // held in cache can be trusted to still be current. + queryClient.invalidateQueries() } ws.onmessage = (event) => { @@ -215,6 +224,11 @@ export function useFlowSocket(onAuthFailure?: () => void): void { onAuthFailure?.() return } + // 1013 is the portal saying the installation is not attached — the one + // close code that means "offline" rather than "the socket dropped". + if (event.code === 1013) { + connectionStore.setOffline(null) + } timer.current = setTimeout(connect, retry.current) retry.current = Math.min(retry.current * 2, RECONNECT_MAX) } diff --git a/frontend/src/components/Sidebar/AppSidebar.tsx b/frontend/src/components/Sidebar/AppSidebar.tsx index 841d08a..b7b659f 100644 --- a/frontend/src/components/Sidebar/AppSidebar.tsx +++ b/frontend/src/components/Sidebar/AppSidebar.tsx @@ -1,4 +1,5 @@ import { + ArrowLeft, Bell, Home, KeyRound, @@ -19,6 +20,7 @@ import { SidebarTrigger, } from "@/components/ui/sidebar" import useAuth from "@/hooks/useAuth" +import { portalConfig } from "@/lib/portal" import { type Item, Main } from "./Main" const baseItems: Item[] = [ @@ -36,15 +38,31 @@ const baseItems: Item[] = [ export function AppSidebar() { const { user: currentUser, logout } = useAuth() + const portal = portalConfig() - const items = currentUser?.is_superuser + const withAdmin = currentUser?.is_superuser ? [...baseItems, { icon: Users, title: "Admin", path: "/admin" }] : baseItems - const footerItems: Item[] = [ - { icon: Settings, title: "Settings", path: "/settings" }, - { icon: LogOut, title: "Log Out", onClick: logout }, - ] + // Reached through a portal, the way out is back to the installations list — + // and the session belongs to the portal, so logging out is its business. + const items: Item[] = portal + ? [ + { + icon: ArrowLeft, + title: "All installations", + onClick: () => window.location.assign(portal.portalUrl), + }, + ...withAdmin, + ] + : withAdmin + + const footerItems: Item[] = portal + ? [{ icon: Settings, title: "Settings", path: "/settings" }] + : [ + { icon: Settings, title: "Settings", path: "/settings" }, + { icon: LogOut, title: "Log Out", onClick: logout }, + ] return ( // Floating frosted chrome over whatever surface the shell paints; see the diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts index e602744..67ba127 100644 --- a/frontend/src/hooks/useAuth.ts +++ b/frontend/src/hooks/useAuth.ts @@ -9,10 +9,14 @@ import { UsersService, } from "@/client" import { liveStore } from "@/components/Flow/liveStore" +import { isPortal } from "@/lib/portal" import { handleError } from "@/utils" import useCustomToast from "./useCustomToast" const isLoggedIn = () => { + // Under a portal the credential comes with the page rather than from + // storage, and the sign-in already happened there. + if (isPortal()) return true return localStorage.getItem("access_token") !== null } diff --git a/frontend/src/lib/connectionStore.ts b/frontend/src/lib/connectionStore.ts new file mode 100644 index 0000000..6c441ee --- /dev/null +++ b/frontend/src/lib/connectionStore.ts @@ -0,0 +1,68 @@ +/** + * Whether the installation this page talks to is reachable right now. + * + * Transport state, kept apart from `liveStore` on purpose: that one holds what + * the engine is doing, this one holds whether we can hear it at all. Only + * meaningful under a portal — a local install talks to itself. + * + * Same hand-rolled external store as liveStore, for the same reason: a + * `useSyncExternalStore` snapshot is the whole requirement. + */ + +type Connection = { + offline: boolean + /** When the portal last heard from the installation, epoch ms. */ + lastSeen: number | null +} + +let state: Connection = { offline: false, lastSeen: null } +const listeners = new Set<() => void>() + +function emit(next: Connection) { + // Same values, same object: React re-renders on identity, and a poll that + // keeps confirming "still offline" should not repaint the banner. + if (next.offline === state.offline && next.lastSeen === state.lastSeen) return + state = next + for (const listener of listeners) listener() +} + +export const connectionStore = { + setOffline(lastSeen: string | number | null) { + const at = + typeof lastSeen === "string" + ? Date.parse(lastSeen) + : typeof lastSeen === "number" + ? lastSeen + : null + emit({ offline: true, lastSeen: Number.isNaN(at) ? null : at }) + }, + setOnline() { + emit({ offline: false, lastSeen: null }) + }, + subscribe(listener: () => void) { + listeners.add(listener) + return () => listeners.delete(listener) + }, + snapshot(): Connection { + return state + }, +} + +/** + * The proxy's offline answer: a 503 carrying `{offline, last_seen}`. + * + * Told apart from every other 503 by that body, so an installation that is + * merely busy is not reported as unreachable. + */ +export function offlineDetail( + error: unknown, +): { lastSeen: string | null } | null { + const body = (error as { body?: unknown; status?: number })?.body + const status = (error as { status?: number })?.status + if ((status !== 503 && status !== 502) || typeof body !== "object" || !body) { + return null + } + const detail = body as { offline?: boolean; last_seen?: string | null } + if (detail.offline !== true) return null + return { lastSeen: detail.last_seen ?? null } +} diff --git a/frontend/src/lib/portal.ts b/frontend/src/lib/portal.ts new file mode 100644 index 0000000..8619686 --- /dev/null +++ b/frontend/src/lib/portal.ts @@ -0,0 +1,44 @@ +/** + * Runtime configuration injected when this app is served through a portal. + * + * A normal installation ships the same bundle and finds nothing here, so every + * portal-aware branch in the app collapses to its ordinary behaviour. When a + * portal serves the page it writes this object into the document first: the + * API lives under the installation's own path, the credential comes with the + * page rather than from storage, and there is somewhere to go "back" to. + * + * Deliberately not localStorage: two installations open in one browser share + * an origin, and a single `access_token` key would have them overwrite each + * other's session. + */ +export type PortalConfig = { + installationId: string + installationName: string + /** Router basepath, e.g. `/i/{id}`. */ + basePath: string + /** Origin-relative API base; the SDK appends `/api/v1/...`. */ + apiBase: string + /** Where "back to portal" goes. */ + portalUrl: string + /** Short-lived token scoped to this installation. */ + token: string +} + +declare global { + interface Window { + __FLUKSIO__?: PortalConfig + } +} + +export function portalConfig(): PortalConfig | undefined { + return typeof window === "undefined" ? undefined : window.__FLUKSIO__ +} + +export function isPortal(): boolean { + return portalConfig() !== undefined +} + +/** The bearer token for API calls, from whichever channel supplied one. */ +export function apiToken(): string { + return portalConfig()?.token ?? localStorage.getItem("access_token") ?? "" +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 5041c31..3e0618c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -12,19 +12,39 @@ import { ApiError, OpenAPI } from "./client" import { ThemeProvider } from "./components/theme-provider" import { Toaster } from "./components/ui/sonner" import "./index.css" +import { connectionStore, offlineDetail } from "./lib/connectionStore" +import { apiToken, portalConfig } from "./lib/portal" import { routeTree } from "./routeTree.gen" -OpenAPI.BASE = import.meta.env.VITE_API_URL -OpenAPI.TOKEN = async () => { - return localStorage.getItem("access_token") || "" -} +const portal = portalConfig() + +// Served through a portal, the API is a path on this same origin and the +// credential arrives with the page. Everywhere else this is the build-time URL +// and the token in storage, exactly as before. +OpenAPI.BASE = portal + ? window.location.origin + portal.apiBase + : import.meta.env.VITE_API_URL +OpenAPI.TOKEN = async () => apiToken() /** A session the server will not accept, whatever we do next. */ const isAuthFailure = (error: unknown) => error instanceof ApiError && [401, 403].includes(error.status) const handleApiError = (error: Error) => { + const offline = offlineDetail(error) + if (offline) { + // The installation is unreachable, not the session invalid: keep the user + // where they are and let the banner explain. + connectionStore.setOffline(offline.lastSeen) + return + } if (isAuthFailure(error)) { + if (portal) { + // The portal knows whether they are still signed in; it can mint a fresh + // handoff or send them to the login screen. + window.location.href = `${portal.portalUrl}?reauth=${portal.installationId}` + return + } localStorage.removeItem("access_token") window.location.href = "/login" } @@ -33,6 +53,8 @@ const handleApiError = (error: Error) => { const queryClient = new QueryClient({ queryCache: new QueryCache({ onError: handleApiError, + // Any answer at all means the tunnel is up again. + onSuccess: () => connectionStore.setOnline(), }), mutationCache: new MutationCache({ onError: handleApiError, @@ -48,7 +70,7 @@ const queryClient = new QueryClient({ }, }) -const router = createRouter({ routeTree }) +const router = createRouter({ routeTree, basepath: portal?.basePath }) declare module "@tanstack/react-router" { interface Register { router: typeof router diff --git a/frontend/src/routes/_layout.tsx b/frontend/src/routes/_layout.tsx index 1933107..432d9ba 100644 --- a/frontend/src/routes/_layout.tsx +++ b/frontend/src/routes/_layout.tsx @@ -1,5 +1,6 @@ import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" +import { ConnectionBanner } from "@/components/Common/ConnectionBanner" import { Footer } from "@/components/Common/Footer" import { useFlowSocket } from "@/components/Flow/useFlowSocket" import AppSidebar from "@/components/Sidebar/AppSidebar" @@ -28,6 +29,8 @@ function Layout() { return ( + {/* Renders nothing unless this page is served through a portal. */} + {/* The sidebar carries its own collapse control; a phone has no diff --git a/frontend/src/utils.ts b/frontend/src/utils.ts index 15d2492..af9f963 100644 --- a/frontend/src/utils.ts +++ b/frontend/src/utils.ts @@ -1,7 +1,18 @@ import { AxiosError } from "axios" import type { ApiError } from "./client" +import { ago } from "./components/Health/queries" +import { offlineDetail } from "./lib/connectionStore" function extractErrorMessage(err: ApiError): string { + // An unreachable installation is not a rejected action: say plainly that + // nothing was delivered, so nobody is left wondering whether it half-landed. + const offline = offlineDetail(err) + if (offline) { + return offline.lastSeen + ? `Not delivered — the installation is offline (last seen ${ago(offline.lastSeen)})` + : "Not delivered — the installation is offline" + } + if (err instanceof AxiosError) { return err.message } diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 874db90..7932128 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -6,6 +6,10 @@ import { defineConfig } from "vite" // https://vitejs.dev/config/ export default defineConfig({ + // A portal serves this bundle under a path of its own and caches one copy + // for every installation, so asset URLs have to be absolute under that + // prefix. Unset — every ordinary build — this stays "/". + base: process.env.VITE_BASE || "/", resolve: { alias: { "@": path.resolve(__dirname, "./src"),