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:
@@ -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 }
|
||||
}
|
||||
@@ -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") ?? ""
|
||||
}
|
||||
Reference in New Issue
Block a user