feat(web): port daemonLink and restartOverlay to React (#242)
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
This commit is contained in:
@@ -0,0 +1,215 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { GRACE_MS, POLL_MS, compareIdentity, createDaemonLinkStore, formatRelease, type Scheduler } from "./DaemonLink.js";
|
||||||
|
import type { DaemonInfo } from "../../types.js";
|
||||||
|
|
||||||
|
function info(commit: string, instance: string, date = "2026-09-02T14:03:11+01:00"): DaemonInfo {
|
||||||
|
return {
|
||||||
|
hostname: "konishi",
|
||||||
|
version: "0.1.0",
|
||||||
|
presets: [],
|
||||||
|
build: { commit, commit_date: date, dirty: false },
|
||||||
|
instance,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scheduler whose timers only fire when the test says so, recording the
|
||||||
|
* delay each was scheduled with. */
|
||||||
|
function manual(): Scheduler & { run(): void; pending(): number; delays: number[] } {
|
||||||
|
let next = 1;
|
||||||
|
const timers = new Map<number, () => void>();
|
||||||
|
const delays: number[] = [];
|
||||||
|
return {
|
||||||
|
delays,
|
||||||
|
setTimer(fn, ms) {
|
||||||
|
const h = next++;
|
||||||
|
timers.set(h, fn);
|
||||||
|
delays.push(ms);
|
||||||
|
return h;
|
||||||
|
},
|
||||||
|
clearTimer(h) {
|
||||||
|
timers.delete(h);
|
||||||
|
},
|
||||||
|
run() {
|
||||||
|
const due = [...timers.entries()];
|
||||||
|
timers.clear();
|
||||||
|
for (const [, fn] of due) fn();
|
||||||
|
},
|
||||||
|
pending: () => timers.size,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("compareIdentity", () => {
|
||||||
|
it("reads a changed commit as an upgrade", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("bbb", "i2"))).toBe("upgraded");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads a changed instance on the same commit as a plain restart", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i2"))).toBe("restarted");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reads an unchanged process as no event at all", () => {
|
||||||
|
expect(compareIdentity(info("aaa", "i1"), info("aaa", "i1"))).toBe("same");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back to the instance when the build is unknown", () => {
|
||||||
|
// A daemon built outside a checkout reports empty commits; the instance is
|
||||||
|
// still enough to see the restart, and never claims a phantom upgrade.
|
||||||
|
expect(compareIdentity(info("", "i1"), info("", "i2"))).toBe("restarted");
|
||||||
|
expect(compareIdentity(info("", "i1"), info("", "i1"))).toBe("same");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("formatRelease", () => {
|
||||||
|
it("pairs the short hash with a readable date", () => {
|
||||||
|
expect(formatRelease({ commit: "f89be4a716c7", commit_date: "2026-09-02T14:03:11Z", dirty: false })).toBe(
|
||||||
|
"f89be4a716c7 · 2026-09-02",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("marks a dirty build", () => {
|
||||||
|
expect(formatRelease({ commit: "f89be4a", commit_date: "", dirty: true })).toBe("f89be4a (dirty)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says so when the release is unknown", () => {
|
||||||
|
expect(formatRelease(undefined)).toBe("unknown release");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("daemon link store", () => {
|
||||||
|
it("starts in the connecting state until the first probe lands", async () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => info("aaa", "i1"),
|
||||||
|
scheduler: manual(),
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
expect(store.getState().state).toBe("connecting");
|
||||||
|
await store.getState().probe();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
expect(store.getState().build?.commit).toBe("aaa");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("holds the modal back during the grace period, then blocks", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
graceMs: 1000,
|
||||||
|
});
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
// Still inside the grace window: the dot goes amber, nothing blocks.
|
||||||
|
expect(store.getState().state).toBe("reconnecting");
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("down");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("unlocks without reloading when the same build comes back", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const reload = vi.fn();
|
||||||
|
let reply: DaemonInfo | null = info("aaa", "i1");
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
if (!reply) throw new Error("down");
|
||||||
|
return reply;
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload,
|
||||||
|
});
|
||||||
|
await store.getState().probe();
|
||||||
|
|
||||||
|
reply = null;
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
sched.run(); // grace expires → blocked, poll scheduled
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("down");
|
||||||
|
|
||||||
|
// Same commit, new process: a restart, not an upgrade — the page stays.
|
||||||
|
reply = info("aaa", "i2");
|
||||||
|
await store.getState().probe();
|
||||||
|
expect(reload).not.toHaveBeenCalled();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reloads the page when the daemon comes back on a new commit", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const reload = vi.fn();
|
||||||
|
let reply = info("aaa", "i1");
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => reply,
|
||||||
|
scheduler: sched,
|
||||||
|
reload,
|
||||||
|
});
|
||||||
|
await store.getState().probe();
|
||||||
|
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
reply = info("bbb", "i2");
|
||||||
|
await store.getState().probe();
|
||||||
|
expect(reload).toHaveBeenCalledTimes(1);
|
||||||
|
// Left blocked deliberately: the reload is what ends this page's life, and
|
||||||
|
// flashing an unlocked UI in between would let the user act on stale code.
|
||||||
|
expect(store.getState().state).toBe("updating");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
let attempts = 0;
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
attempts += 1;
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
sched.run(); // grace → first poll
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
const first = attempts;
|
||||||
|
expect(first).toBeGreaterThan(0);
|
||||||
|
sched.run(); // next poll tick
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(attempts).toBeGreaterThan(first);
|
||||||
|
store.getState().stop();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("notifies subscribers on every state change", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const seen: string[] = [];
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => info("aaa", "i1"),
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
store.subscribe((s) => seen.push(s.state));
|
||||||
|
await store.getState().probe();
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
sched.run();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(seen).toContain("reconnecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("schedules the grace timer and poll at the documented defaults", async () => {
|
||||||
|
const sched = manual();
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => {
|
||||||
|
throw new Error("down");
|
||||||
|
},
|
||||||
|
scheduler: sched,
|
||||||
|
reload: () => {},
|
||||||
|
});
|
||||||
|
expect(GRACE_MS).toBe(1200);
|
||||||
|
expect(POLL_MS).toBe(700);
|
||||||
|
store.getState().noteDisconnected();
|
||||||
|
expect(sched.delays).toEqual([GRACE_MS]);
|
||||||
|
sched.run(); // grace fires → schedules the first poll
|
||||||
|
await Promise.resolve();
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(sched.delays).toEqual([GRACE_MS, POLL_MS]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, render } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createDaemonLinkStore } from "./DaemonLink.js";
|
||||||
|
import { DaemonLink } from "./DaemonLink.js";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
describe("DaemonLink", () => {
|
||||||
|
it("probes once on mount, regardless of the initial connected value", async () => {
|
||||||
|
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
|
||||||
|
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||||
|
render(<DaemonLink store={store} connected={false} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(fetchInfo).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does not treat the initial connected value as a transition", async () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||||
|
scheduler: { setTimer: () => 0, clearTimer: () => {} },
|
||||||
|
});
|
||||||
|
render(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
// Only the mount probe ran — no extra noteConnected() probe on top of it.
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards a later connected drop to noteDisconnected", async () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||||
|
scheduler: { setTimer: () => 0, clearTimer: () => {} },
|
||||||
|
});
|
||||||
|
const { rerender } = render(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
|
||||||
|
rerender(<DaemonLink store={store} connected={false} />);
|
||||||
|
expect(store.getState().state).toBe("reconnecting");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("forwards a later connected recovery to noteConnected", async () => {
|
||||||
|
const fetchInfo = vi.fn().mockResolvedValue({ hostname: "h", version: "1", presets: [] });
|
||||||
|
const store = createDaemonLinkStore({ fetchInfo, scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||||
|
const { rerender } = render(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
|
||||||
|
// The socket dropped: noteDisconnected() moves off "online" so the coming
|
||||||
|
// recovery isn't a no-op.
|
||||||
|
rerender(<DaemonLink store={store} connected={false} />);
|
||||||
|
expect(store.getState().state).toBe("reconnecting");
|
||||||
|
fetchInfo.mockClear();
|
||||||
|
|
||||||
|
rerender(<DaemonLink store={store} connected={true} />);
|
||||||
|
await Promise.resolve();
|
||||||
|
expect(fetchInfo).toHaveBeenCalledTimes(1);
|
||||||
|
expect(store.getState().state).toBe("online");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("stops the store's polling on unmount", () => {
|
||||||
|
const store = createDaemonLinkStore({
|
||||||
|
fetchInfo: async () => ({ hostname: "h", version: "1", presets: [] }),
|
||||||
|
});
|
||||||
|
const stop = vi.spyOn(store.getState(), "stop");
|
||||||
|
const { unmount } = render(<DaemonLink store={store} connected={false} />);
|
||||||
|
unmount();
|
||||||
|
expect(stop).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,234 @@
|
|||||||
|
// 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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// @vitest-environment happy-dom
|
||||||
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
import { createDaemonLinkStore, type DaemonLinkStoreApi } from "./DaemonLink.js";
|
||||||
|
import { RestartOverlay } from "./RestartOverlay.js";
|
||||||
|
|
||||||
|
afterEach(() => cleanup());
|
||||||
|
|
||||||
|
function storeAt(state: "connecting" | "online" | "reconnecting" | "down" | "updating"): DaemonLinkStoreApi {
|
||||||
|
const store = createDaemonLinkStore({ scheduler: { setTimer: () => 0, clearTimer: () => {} } });
|
||||||
|
store.setState({ state });
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("RestartOverlay", () => {
|
||||||
|
it("renders nothing while connecting, online, or reconnecting", () => {
|
||||||
|
for (const state of ["connecting", "online", "reconnecting"] as const) {
|
||||||
|
const { unmount } = render(<RestartOverlay store={storeAt(state)} />);
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
unmount();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("blocks with a restarting message when down", () => {
|
||||||
|
render(<RestartOverlay store={storeAt("down")} />);
|
||||||
|
const dialog = screen.getByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain("Maestro is restarting");
|
||||||
|
expect(dialog.textContent).toContain("Waiting for the daemon to come back…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("counts attempts once more than one probe has failed", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
store.setState({ attempts: 3 });
|
||||||
|
render(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.getByRole("alertdialog").textContent).toContain("(3 attempts)");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("says it is reloading when updating", () => {
|
||||||
|
render(<RestartOverlay store={storeAt("updating")} />);
|
||||||
|
const dialog = screen.getByRole("alertdialog");
|
||||||
|
expect(dialog.textContent).toContain("Maestro was updated");
|
||||||
|
expect(dialog.textContent).toContain("Reloading this page onto the new release…");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("names the release from the store's build", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
store.setState({ build: { commit: "f89be4a", commit_date: "2026-09-02T00:00:00Z", dirty: false } });
|
||||||
|
render(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.getByRole("alertdialog").textContent).toContain("f89be4a · 2026-09-02");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("clears the moment the store reports the link is back", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
const { rerender } = render(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.queryByRole("alertdialog")).not.toBeNull();
|
||||||
|
store.setState({ state: "online" });
|
||||||
|
rerender(<RestartOverlay store={store} />);
|
||||||
|
expect(screen.queryByRole("alertdialog")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("swallows keyboard shortcuts while blocking, but not once cleared", () => {
|
||||||
|
const store = storeAt("down");
|
||||||
|
const handler = vi.fn();
|
||||||
|
window.addEventListener("keydown", handler);
|
||||||
|
const { rerender } = render(<RestartOverlay store={store} />);
|
||||||
|
|
||||||
|
fireEvent.keyDown(window, { key: "n" });
|
||||||
|
expect(handler).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
store.setState({ state: "online" });
|
||||||
|
rerender(<RestartOverlay store={store} />);
|
||||||
|
fireEvent.keyDown(window, { key: "n" });
|
||||||
|
expect(handler).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
window.removeEventListener("keydown", handler);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// React port of ../../restartOverlay.ts. Blocking overlay for a daemon that
|
||||||
|
// went away — see docs/RESTART.md.
|
||||||
|
//
|
||||||
|
// Rendered while the link is `down` or `updating`, gone the moment it is
|
||||||
|
// back. It owns no polling of its own: `DaemonLink` polls, this only renders
|
||||||
|
// what the store reports.
|
||||||
|
|
||||||
|
import { useEffect } from "react";
|
||||||
|
import { useStore } from "zustand";
|
||||||
|
|
||||||
|
import { formatRelease, type DaemonLinkStoreApi } from "./DaemonLink.js";
|
||||||
|
|
||||||
|
export interface RestartOverlayProps {
|
||||||
|
/** The same store a sibling `DaemonLink` drives. */
|
||||||
|
store: DaemonLinkStoreApi;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The poll runs while this is up; saying how many tries have gone by is what
|
||||||
|
* separates "still restarting" from "wedged" for someone staring at it. */
|
||||||
|
function attemptText(attempts: number): string {
|
||||||
|
if (attempts <= 1) return "Waiting for the daemon to come back…";
|
||||||
|
return `Waiting for the daemon to come back… (${attempts} attempts)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function swallow(e: KeyboardEvent): void {
|
||||||
|
e.stopImmediatePropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RestartOverlay({ store }: RestartOverlayProps) {
|
||||||
|
const state = useStore(store, (s) => s.state);
|
||||||
|
const build = useStore(store, (s) => s.build);
|
||||||
|
const attempts = useStore(store, (s) => s.attempts);
|
||||||
|
|
||||||
|
const blocking = state === "down" || state === "updating";
|
||||||
|
|
||||||
|
// The app's shortcuts live on `window`, so hiding the UI behind a backdrop
|
||||||
|
// does not stop them: `n` would still open a spawn form against a daemon
|
||||||
|
// that is not there. Swallow keys in the capture phase until the link is
|
||||||
|
// back, without `preventDefault` so the browser's own chords still work.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!blocking) return;
|
||||||
|
window.addEventListener("keydown", swallow, true);
|
||||||
|
return () => window.removeEventListener("keydown", swallow, true);
|
||||||
|
}, [blocking]);
|
||||||
|
|
||||||
|
if (!blocking) return null;
|
||||||
|
|
||||||
|
const updating = state === "updating";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal-backdrop restart-backdrop">
|
||||||
|
<div className="modal restart-modal" role="alertdialog" aria-modal="true" aria-labelledby="restart-title">
|
||||||
|
<div className="restart-spinner" aria-hidden="true" />
|
||||||
|
<h2 id="restart-title">{updating ? "Maestro was updated" : "Maestro is restarting"}</h2>
|
||||||
|
<p className="restart-detail">
|
||||||
|
{updating ? "Reloading this page onto the new release…" : attemptText(attempts)}
|
||||||
|
</p>
|
||||||
|
<p className="restart-release">{formatRelease(build)}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user