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
@@ -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 { 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)
}
+23 -5
View File
@@ -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