DaemonLink.tsx holds the link state machine as a Zustand store (same shape as catalogStore/connectionStore) plus a thin controller component wiring the catalog store's `connected` field to it. RestartOverlay.tsx renders the blocking modal for down/updating, reusing the existing CSS. Closes #242
235 lines
8.2 KiB
TypeScript
235 lines
8.2 KiB
TypeScript
// React port of ../../daemonLink.ts. The link to the daemon behind this page:
|
|
// is it up, and which release is it? `RestartOverlay` (./RestartOverlay.tsx)
|
|
// renders what this reports. See docs/RESTART.md for the restart contract.
|
|
|
|
import { useEffect, useRef } from "react";
|
|
import { create } from "zustand";
|
|
|
|
import { getDaemonInfo } from "../../api.js";
|
|
import type { BuildInfo, DaemonInfo } from "../../types.js";
|
|
|
|
/**
|
|
* - `connecting` — first probe hasn't landed yet.
|
|
* - `online` — the daemon answered and is the same process, or a resolved new one.
|
|
* - `reconnecting` — the socket dropped, but within the grace window; a blip
|
|
* shouldn't throw a modal in the user's face.
|
|
* - `down` — unreachable past the grace window: the overlay blocks the UI and
|
|
* the poll runs until it answers.
|
|
* - `updating` — it answered on a different commit; the page is reloading and
|
|
* stays blocked until it does.
|
|
*/
|
|
export type LinkState = "connecting" | "online" | "reconnecting" | "down" | "updating";
|
|
|
|
/** What happened between two `/api/info` replies. */
|
|
export type RestartKind = "same" | "restarted" | "upgraded";
|
|
|
|
/** How long a dropped socket may stay dropped before the UI blocks. Long enough
|
|
* to ride out a wifi hiccup, short enough that a real restart is visible. */
|
|
export const GRACE_MS = 1200;
|
|
/** Cadence of the `/api/info` poll while the daemon is unreachable. */
|
|
export const POLL_MS = 700;
|
|
|
|
/**
|
|
* Compare two daemon replies.
|
|
*
|
|
* The commit decides first: a different one means new code, which for a browser
|
|
* means new assets. The per-process `instance` catches the rest — a restart of
|
|
* the same build, which needs no reload. A daemon built outside a git checkout
|
|
* reports an empty commit, so an empty-vs-empty pair never counts as an
|
|
* upgrade; the instance still reports the restart.
|
|
*/
|
|
export function compareIdentity(before: DaemonInfo, after: DaemonInfo): RestartKind {
|
|
const a = before.build?.commit ?? "";
|
|
const b = after.build?.commit ?? "";
|
|
if (a && b && a !== b) return "upgraded";
|
|
if (before.instance && after.instance && before.instance !== after.instance) return "restarted";
|
|
return "same";
|
|
}
|
|
|
|
/** One line naming a release: `f89be4a716c7 · 2026-09-02`, with `(dirty)` when
|
|
* the build carried uncommitted changes. Empty builds say so rather than
|
|
* rendering a blank tooltip. */
|
|
export function formatRelease(build: BuildInfo | undefined): string {
|
|
if (!build?.commit) return "unknown release";
|
|
const day = build.commit_date ? build.commit_date.slice(0, 10) : "";
|
|
const parts = [build.commit];
|
|
if (day) parts.push(`· ${day}`);
|
|
if (build.dirty) parts.push("(dirty)");
|
|
return parts.join(" ");
|
|
}
|
|
|
|
/** Schedules a probe/grace timer; returns a handle `clearTimer` understands. */
|
|
export interface Scheduler {
|
|
setTimer(fn: () => void, ms: number): number;
|
|
clearTimer(handle: number): void;
|
|
}
|
|
|
|
const browserScheduler: Scheduler = {
|
|
setTimer: (fn, ms) => window.setTimeout(fn, ms),
|
|
clearTimer: (h) => window.clearTimeout(h),
|
|
};
|
|
|
|
export interface DaemonLinkState {
|
|
state: LinkState;
|
|
/** The release the daemon last reported. Undefined until the first probe,
|
|
* and on a daemon too old to carry one. */
|
|
build?: BuildInfo;
|
|
/** Hostname the daemon reports, for the indicator's tooltip. */
|
|
hostname?: string;
|
|
/** How many probes have failed in the current outage — the overlay counts
|
|
* attempts so a long restart doesn't look frozen. */
|
|
attempts: number;
|
|
}
|
|
|
|
export interface DaemonLinkActions {
|
|
/** One `/api/info` round-trip. Resolves `true` when the daemon answered. */
|
|
probe(): Promise<boolean>;
|
|
/** The events socket dropped. Starts the grace timer and the poll. */
|
|
noteDisconnected(): void;
|
|
/** The events socket came back. It carries no identity of its own, so the
|
|
* state only clears once a probe confirms *which* daemon answered — a
|
|
* reconnect to an upgraded daemon must reload, not resume. */
|
|
noteConnected(): void;
|
|
/** Stop polling. */
|
|
stop(): void;
|
|
}
|
|
|
|
export type DaemonLinkStore = DaemonLinkState & DaemonLinkActions;
|
|
|
|
export interface DaemonLinkOptions {
|
|
/** Injectable for tests; defaults to `GET /api/info`. */
|
|
fetchInfo?: () => Promise<DaemonInfo>;
|
|
scheduler?: Scheduler;
|
|
/** What an upgrade does. Injectable so tests don't navigate. */
|
|
reload?: () => void;
|
|
graceMs?: number;
|
|
pollMs?: number;
|
|
}
|
|
|
|
/** Builds an isolated daemon-link store — same shape as
|
|
* {@link ../../stores/catalogStore.js!createCatalogStore}: both a React hook
|
|
* and a plain object (`getState`/`subscribe`), so it drives the overlay
|
|
* whether or not React is the one calling `noteConnected`/`noteDisconnected`. */
|
|
export function createDaemonLinkStore(opts: DaemonLinkOptions = {}) {
|
|
const fetchInfo = opts.fetchInfo ?? getDaemonInfo;
|
|
const scheduler = opts.scheduler ?? browserScheduler;
|
|
const reload = opts.reload ?? (() => window.location.reload());
|
|
const graceMs = opts.graceMs ?? GRACE_MS;
|
|
const pollMs = opts.pollMs ?? POLL_MS;
|
|
|
|
let identity: DaemonInfo | null = null;
|
|
let graceTimer: number | null = null;
|
|
let pollTimer: number | null = null;
|
|
|
|
return create<DaemonLinkStore>((set, get) => {
|
|
function clearTimers(): void {
|
|
if (graceTimer !== null) {
|
|
scheduler.clearTimer(graceTimer);
|
|
graceTimer = null;
|
|
}
|
|
if (pollTimer !== null) {
|
|
scheduler.clearTimer(pollTimer);
|
|
pollTimer = null;
|
|
}
|
|
}
|
|
|
|
async function probe(): Promise<boolean> {
|
|
let info: DaemonInfo;
|
|
try {
|
|
info = await fetchInfo();
|
|
} catch {
|
|
set((s) => ({ attempts: s.attempts + 1 }));
|
|
return false;
|
|
}
|
|
const before = identity;
|
|
identity = info;
|
|
if (before && compareIdentity(before, info) === "upgraded") {
|
|
// Stay blocked through the reload: an unlocked frame here would let the
|
|
// user drive new server code from the bundle it just replaced.
|
|
set({ state: "updating", build: info.build, hostname: info.hostname });
|
|
reload();
|
|
return true;
|
|
}
|
|
clearTimers();
|
|
set({ state: "online", build: info.build, hostname: info.hostname, attempts: 0 });
|
|
return true;
|
|
}
|
|
|
|
async function poll(): Promise<void> {
|
|
const state = get().state;
|
|
if (state === "online" || state === "updating") return;
|
|
const ok = await probe();
|
|
if (ok || pollTimer !== null) return;
|
|
pollTimer = scheduler.setTimer(() => {
|
|
pollTimer = null;
|
|
void poll();
|
|
}, pollMs);
|
|
}
|
|
|
|
return {
|
|
state: "connecting",
|
|
build: undefined,
|
|
hostname: undefined,
|
|
attempts: 0,
|
|
|
|
probe,
|
|
|
|
noteDisconnected() {
|
|
const state = get().state;
|
|
if (state === "down" || state === "updating") return;
|
|
set({ state: "reconnecting", attempts: 0 });
|
|
if (graceTimer === null) {
|
|
graceTimer = scheduler.setTimer(() => {
|
|
graceTimer = null;
|
|
if (get().state === "reconnecting") set({ state: "down" });
|
|
void poll();
|
|
}, graceMs);
|
|
}
|
|
},
|
|
|
|
noteConnected() {
|
|
const state = get().state;
|
|
if (state === "online" || state === "updating") return;
|
|
void probe();
|
|
},
|
|
|
|
stop: clearTimers,
|
|
};
|
|
});
|
|
}
|
|
|
|
export type DaemonLinkStoreApi = ReturnType<typeof createDaemonLinkStore>;
|
|
|
|
export interface DaemonLinkProps {
|
|
/** The link store this instance drives — `RestartOverlay` (and, later, the
|
|
* sidebar's status indicator) read the same one. */
|
|
store: DaemonLinkStoreApi;
|
|
/** The catalog store's `connected` field (docs/RESTART.md): the events
|
|
* socket dying is the earliest signal a restart happened, ahead of any
|
|
* poll. Pass `useStore(catalogStore, (s) => s.connected)`. */
|
|
connected: boolean;
|
|
}
|
|
|
|
/**
|
|
* Renders nothing — the controller that wires a `DaemonLinkStore` to the
|
|
* catalog's socket state. Mount once near the app root; `RestartOverlay`
|
|
* reads the store it drives.
|
|
*/
|
|
export function DaemonLink({ store, connected }: DaemonLinkProps): null {
|
|
const mounted = useRef(false);
|
|
|
|
useEffect(() => {
|
|
if (!mounted.current) {
|
|
mounted.current = true;
|
|
void store.getState().probe();
|
|
return;
|
|
}
|
|
if (connected) store.getState().noteConnected();
|
|
else store.getState().noteDisconnected();
|
|
}, [store, connected]);
|
|
|
|
useEffect(() => () => store.getState().stop(), [store]);
|
|
|
|
return null;
|
|
}
|