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"),