Dashboard: run unchanged when a portal serves it

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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XtBzdDyLsmDaF1W7DLYtYM
This commit is contained in:
2026-08-19 17:09:53 +02:00
co-authored by Claude Opus 5
parent f4b81507d1
commit 32d8b42682
11 changed files with 254 additions and 12 deletions
+2
View File
@@ -13,6 +13,8 @@ RUN bun install
COPY ./frontend /app/frontend COPY ./frontend /app/frontend
ARG VITE_API_URL ARG VITE_API_URL
# Set only for the copy a portal serves; see vite.config.ts.
ARG VITE_BASE
RUN bun run build RUN bun run build
@@ -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 (
<AnimatePresence>
{connection.offline && (
<motion.div
variants={fadeIn}
initial="hidden"
animate="visible"
exit="hidden"
role="status"
aria-live="polite"
className="pointer-events-none fixed inset-x-0 top-4 z-50 flex justify-center px-4"
>
<div className="flex items-center gap-2 rounded-full border border-border bg-card/80 px-4 py-2 text-sm shadow-e2 backdrop-blur-md">
<WifiOff className="size-4 shrink-0 text-muted-foreground" />
<span className="font-medium">Installation offline</span>
<span className="text-muted-foreground">
{connection.lastSeen
? `last seen ${ago(connection.lastSeen / 1000)} — reconnecting…`
: "reconnecting…"}
</span>
</div>
</motion.div>
)}
</AnimatePresence>
)
}
+16 -2
View File
@@ -2,6 +2,8 @@ import { useQueryClient } from "@tanstack/react-query"
import { useEffect, useRef } from "react" import { useEffect, useRef } from "react"
import { OpenAPI } from "@/client" import { OpenAPI } from "@/client"
import { connectionStore } from "@/lib/connectionStore"
import { apiToken } from "@/lib/portal"
import { type LogLine, liveStore, type ValueSource } from "./liveStore" import { type LogLine, liveStore, type ValueSource } from "./liveStore"
import { flowKeys } from "./queries" import { flowKeys } from "./queries"
@@ -77,11 +79,14 @@ type FlowEvent =
function socketUrl(): string { function socketUrl(): string {
const base = String(OpenAPI.BASE || window.location.origin) 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:" url.protocol = url.protocol === "https:" ? "wss:" : "ws:"
// Browsers cannot set headers on a websocket handshake, so the token rides // Browsers cannot set headers on a websocket handshake, so the token rides
// in the query string. // in the query string.
url.searchParams.set("token", localStorage.getItem("access_token") ?? "") url.searchParams.set("token", apiToken())
return url.toString() return url.toString()
} }
@@ -117,6 +122,10 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
ws.onopen = () => { ws.onopen = () => {
retry.current = RECONNECT_MIN retry.current = RECONNECT_MIN
liveStore.setConnected(true) 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) => { ws.onmessage = (event) => {
@@ -215,6 +224,11 @@ export function useFlowSocket(onAuthFailure?: () => void): void {
onAuthFailure?.() onAuthFailure?.()
return 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) timer.current = setTimeout(connect, retry.current)
retry.current = Math.min(retry.current * 2, RECONNECT_MAX) retry.current = Math.min(retry.current * 2, RECONNECT_MAX)
} }
+20 -2
View File
@@ -1,4 +1,5 @@
import { import {
ArrowLeft,
Bell, Bell,
Home, Home,
KeyRound, KeyRound,
@@ -19,6 +20,7 @@ import {
SidebarTrigger, SidebarTrigger,
} from "@/components/ui/sidebar" } from "@/components/ui/sidebar"
import useAuth from "@/hooks/useAuth" import useAuth from "@/hooks/useAuth"
import { portalConfig } from "@/lib/portal"
import { type Item, Main } from "./Main" import { type Item, Main } from "./Main"
const baseItems: Item[] = [ const baseItems: Item[] = [
@@ -36,12 +38,28 @@ const baseItems: Item[] = [
export function AppSidebar() { export function AppSidebar() {
const { user: currentUser, logout } = useAuth() 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, { icon: Users, title: "Admin", path: "/admin" }]
: baseItems : baseItems
const footerItems: Item[] = [ // 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: Settings, title: "Settings", path: "/settings" },
{ icon: LogOut, title: "Log Out", onClick: logout }, { icon: LogOut, title: "Log Out", onClick: logout },
] ]
+4
View File
@@ -9,10 +9,14 @@ import {
UsersService, UsersService,
} from "@/client" } from "@/client"
import { liveStore } from "@/components/Flow/liveStore" import { liveStore } from "@/components/Flow/liveStore"
import { isPortal } from "@/lib/portal"
import { handleError } from "@/utils" import { handleError } from "@/utils"
import useCustomToast from "./useCustomToast" import useCustomToast from "./useCustomToast"
const isLoggedIn = () => { 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 return localStorage.getItem("access_token") !== null
} }
+68
View File
@@ -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 }
}
+44
View File
@@ -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") ?? ""
}
+27 -5
View File
@@ -12,19 +12,39 @@ import { ApiError, OpenAPI } from "./client"
import { ThemeProvider } from "./components/theme-provider" import { ThemeProvider } from "./components/theme-provider"
import { Toaster } from "./components/ui/sonner" import { Toaster } from "./components/ui/sonner"
import "./index.css" import "./index.css"
import { connectionStore, offlineDetail } from "./lib/connectionStore"
import { apiToken, portalConfig } from "./lib/portal"
import { routeTree } from "./routeTree.gen" import { routeTree } from "./routeTree.gen"
OpenAPI.BASE = import.meta.env.VITE_API_URL const portal = portalConfig()
OpenAPI.TOKEN = async () => {
return localStorage.getItem("access_token") || "" // 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. */ /** A session the server will not accept, whatever we do next. */
const isAuthFailure = (error: unknown) => const isAuthFailure = (error: unknown) =>
error instanceof ApiError && [401, 403].includes(error.status) error instanceof ApiError && [401, 403].includes(error.status)
const handleApiError = (error: Error) => { 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 (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") localStorage.removeItem("access_token")
window.location.href = "/login" window.location.href = "/login"
} }
@@ -33,6 +53,8 @@ const handleApiError = (error: Error) => {
const queryClient = new QueryClient({ const queryClient = new QueryClient({
queryCache: new QueryCache({ queryCache: new QueryCache({
onError: handleApiError, onError: handleApiError,
// Any answer at all means the tunnel is up again.
onSuccess: () => connectionStore.setOnline(),
}), }),
mutationCache: new MutationCache({ mutationCache: new MutationCache({
onError: handleApiError, 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" { declare module "@tanstack/react-router" {
interface Register { interface Register {
router: typeof router router: typeof router
+3
View File
@@ -1,5 +1,6 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router" import { createFileRoute, Outlet, redirect } from "@tanstack/react-router"
import { ConnectionBanner } from "@/components/Common/ConnectionBanner"
import { Footer } from "@/components/Common/Footer" import { Footer } from "@/components/Common/Footer"
import { useFlowSocket } from "@/components/Flow/useFlowSocket" import { useFlowSocket } from "@/components/Flow/useFlowSocket"
import AppSidebar from "@/components/Sidebar/AppSidebar" import AppSidebar from "@/components/Sidebar/AppSidebar"
@@ -28,6 +29,8 @@ function Layout() {
return ( return (
<SidebarProvider className="bg-card"> <SidebarProvider className="bg-card">
{/* Renders nothing unless this page is served through a portal. */}
<ConnectionBanner />
<AppSidebar /> <AppSidebar />
<SidebarInset className="bg-card"> <SidebarInset className="bg-card">
{/* The sidebar carries its own collapse control; a phone has no {/* The sidebar carries its own collapse control; a phone has no
+11
View File
@@ -1,7 +1,18 @@
import { AxiosError } from "axios" import { AxiosError } from "axios"
import type { ApiError } from "./client" import type { ApiError } from "./client"
import { ago } from "./components/Health/queries"
import { offlineDetail } from "./lib/connectionStore"
function extractErrorMessage(err: ApiError): string { 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) { if (err instanceof AxiosError) {
return err.message return err.message
} }
+4
View File
@@ -6,6 +6,10 @@ import { defineConfig } from "vite"
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ 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: { resolve: {
alias: { alias: {
"@": path.resolve(__dirname, "./src"), "@": path.resolve(__dirname, "./src"),