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 && (
+
+
+
+ )}
+
+ )
+}
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"),