Files
app/frontend/src/lib/connectionStore.ts
T
stroblmeandClaude Opus 5 cec31ba853 Home mosaic, multi-select delete, offline banner and loading states
- 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
2026-08-22 12:02:14 +02:00

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 }
}