- Home puts the dashboards beside the flows: two equal-height columns, capped and scrollable, most recently worked on first. Each tile is a schematic footprint built from the stored widget placements. - Flows and dashboards can be picked by long press or ctrl-click; the create button becomes a trash and one dialog covers the batch. - The offline banner is drawn on the body so it centres on the viewport, and the live socket now releases the offline latch a stray 503 set. - A boot spinner before React's first commit, a router pending screen for code-split pages, and skeletons where an empty list used to flash. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018tULRZJUkZsw7rMJ3h4xvu
94 lines
3.2 KiB
TypeScript
94 lines
3.2 KiB
TypeScript
/**
|
|
* 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 }
|
|
/**
|
|
* Whether the live socket is up right now.
|
|
*
|
|
* The socket is the continuous signal; a failed request is only a lagging one.
|
|
* Latching `offline` on a single refused request is what used to leave the
|
|
* banner up for good: the socket had never dropped, so no reconnect was coming
|
|
* to clear it, and nothing else on a quiet page refetches. Reloading was the
|
|
* only way out.
|
|
*/
|
|
let socketOpen = false
|
|
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) {
|
|
// The socket is up, so the installation is reachable: this was one bad
|
|
// answer rather than a lost tunnel, and the banner would have nothing to
|
|
// take it back down again.
|
|
if (socketOpen) return
|
|
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 })
|
|
},
|
|
/**
|
|
* The live socket opened or closed.
|
|
*
|
|
* An open socket is proof the installation can be heard, so it also clears
|
|
* the banner — and since the socket reconnects on its own, that is the one
|
|
* release the offline state can always count on.
|
|
*/
|
|
setSocketOpen(open: boolean) {
|
|
socketOpen = open
|
|
if (open) 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 }
|
|
}
|