diff --git a/docs/RESTART.md b/docs/RESTART.md new file mode 100644 index 0000000..4441951 --- /dev/null +++ b/docs/RESTART.md @@ -0,0 +1,78 @@ +# Restart and upgrade contract + +Maestro is upgraded in place: the operator reinstalls the binary and systemd +restarts the daemon under every browser tab and TUI that is attached to it. +tmux keeps the sessions alive across that (see `docs/INSTALL.md`), but clients +lose their sockets. This document is the contract for what a client does about +it — what the daemon publishes, how a client tells a restart from a blip, and +what the user sees while it happens. + +## What the daemon publishes + +`GET /api/info` carries two fields beyond identity and presets: + +| field | meaning | +| ---------- | -------------------------------------------------------------- | +| `build` | `{ commit, commit_date, dirty }` — the commit the binary was compiled from | +| `instance` | uuid regenerated on every daemon start | + +`build` is stamped at compile time by `crates/maestro-daemon/build.rs`, which +runs `git` in the checkout it is compiled in. The Nix build has no `.git` in the +crane-filtered source tree, so `flake.nix` passes the flake's own revision as +`MAESTRO_BUILD_COMMIT` / `MAESTRO_BUILD_DATE` / `MAESTRO_BUILD_DIRTY`. A build +with neither source reports empty strings — clients render that as +"unknown release" and fall back to `instance` alone. + +`instance` is the one that never goes missing. It says *this process*, so two +replies that differ on it are two runs of the daemon, even when the commit did +not change (a plain `systemctl restart`, or a crash). + +## How a client reads them + +Three verdicts, from comparing the reply before an outage with the one after +(`compareIdentity` in `web/src/daemonLink.ts`, mirrored by +`crates/maestro-tui/src/daemon_link.rs`): + +- **upgraded** — both commits are known and differ. New server code. +- **restarted** — commits match (or are unknown) and `instance` differs. +- **same** — neither changed; the socket blipped, the daemon never moved. + +Only *upgraded* is special. A browser tab is holding a bundle that the upgraded +daemon no longer serves, so it reloads the page rather than driving new server +code from an old bundle. The TUI is its own binary — a reload would not replace +it — so it stays up, reconnects, and says which release it is now talking to; +the user restarts `maestro tui` when they want the new client. + +## What the user sees + +The **status indicator** lives in the sidebar footer, left of the spawn button, +in both clients. Its colour is the link state, and it names the running release +— commit hash and commit date — on hover (web) or in the status bar (TUI). + +| state | colour | meaning | +| -------------- | ------ | -------------------------------------------------- | +| `connecting` | amber | first probe hasn't landed | +| `online` | green | daemon answered; same process, or a resolved new one | +| `reconnecting` | amber | socket dropped, still inside the grace window | +| `down` | red | unreachable past the grace window — the UI blocks | +| `updating` | red | answered on a new commit; the page is reloading | + +The **restart overlay** is up for exactly `down` and `updating`. It is not +dismissable and it swallows the app's keyboard shortcuts: everything behind it +would act on a daemon that is not there. It clears itself the moment a probe +succeeds — there is no "retry" button, because the client is already retrying. + +## Timing + +- **Grace window** — 1200 ms between the socket dropping and the overlay + appearing. A wifi hiccup or a laptop waking up should not throw a modal. +- **Poll cadence** — `GET /api/info` every 700 ms while unreachable, flat, not + backed off: a restart takes a second or two and the point is to unlock as soon + as it is over. The events socket keeps its own exponential backoff for the + session stream; the two are independent, and the probe is what decides state. + +The events socket dropping is the trigger, not a timer: the daemon cannot warn +anyone it is about to die, so its socket closing is the earliest signal there +is. A reconnect of that socket does **not** by itself clear the state — only a +probe does, because the socket carries no identity and reconnecting to an +upgraded daemon must reload rather than resume. diff --git a/web/src/catalog.ts b/web/src/catalog.ts index 651bab5..62fb106 100644 --- a/web/src/catalog.ts +++ b/web/src/catalog.ts @@ -25,6 +25,8 @@ import { import type { TreeNode } from "./tree.js"; import { buildTree } from "./tree.js"; import { createThemeToggle } from "./theme.js"; +import type { DaemonLink } from "./daemonLink.js"; +import { createStatusIndicator } from "./statusIndicator.js"; /** Ask the shell to open the spawn modal. Handled in main.ts, where the * multi-view controller lives; dispatched from every Spawn affordance. */ @@ -336,6 +338,9 @@ export interface SidebarOptions { /** Pointerdown on a session row — the multi-view controller decides drag vs * tap (issue #32). Tap falls through to the row's normal click (open). */ onLeafDrag?: (sessionId: string, e: PointerEvent) => void; + /** Daemon link behind the footer's status dot. Omitted in tests and in the + * design kit, which render the sidebar without a daemon. */ + link?: DaemonLink; } export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): SidebarHandle { @@ -390,6 +395,10 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S kit.title = "Open the design kit (DEV)"; footer.appendChild(kit); } + // Daemon status sits beside the spawn button: colour for the link state, + // hover for the release the daemon is running. + const status = opts.link ? createStatusIndicator(opts.link) : null; + if (status) footer.appendChild(status.el); const spawnBtn = document.createElement("button"); spawnBtn.type = "button"; spawnBtn.className = "btn btn-primary spawn-btn"; @@ -617,6 +626,7 @@ export function renderSidebar(store: CatalogStore, opts: SidebarOptions = {}): S }, dispose() { clearInterval(clock); + status?.dispose(); // An unfinished rename would otherwise leave every later sidebar // refusing to refresh. renamingId = null; diff --git a/web/src/daemonLink.test.ts b/web/src/daemonLink.test.ts new file mode 100644 index 0000000..6387c08 --- /dev/null +++ b/web/src/daemonLink.test.ts @@ -0,0 +1,193 @@ +import { describe, expect, it, vi } from "vitest"; + +import { DaemonLink, compareIdentity, formatRelease } from "./daemonLink.js"; +import type { Scheduler } from "./events.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. */ +function manual(): Scheduler & { run(): void; pending(): number } { + let next = 1; + const timers = new Map void>(); + return { + setTimer(fn) { + const h = next++; + timers.set(h, fn); + 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("DaemonLink", () => { + it("starts online once the first probe lands", async () => { + const link = new DaemonLink({ + fetchInfo: async () => info("aaa", "i1"), + scheduler: manual(), + reload: () => {}, + }); + await link.start(); + expect(link.status().state).toBe("online"); + expect(link.status().build?.commit).toBe("aaa"); + }); + + it("holds the modal back during the grace period, then blocks", async () => { + const sched = manual(); + const link = new DaemonLink({ + fetchInfo: async () => { + throw new Error("down"); + }, + scheduler: sched, + reload: () => {}, + graceMs: 1000, + }); + link.noteDisconnected(); + // Still inside the grace window: the dot goes amber, nothing blocks. + expect(link.status().state).toBe("reconnecting"); + sched.run(); + await Promise.resolve(); + expect(link.status().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 link = new DaemonLink({ + fetchInfo: async () => { + if (!reply) throw new Error("down"); + return reply; + }, + scheduler: sched, + reload, + }); + await link.start(); + + reply = null; + link.noteDisconnected(); + sched.run(); // grace expires → blocked, poll scheduled + await Promise.resolve(); + expect(link.status().state).toBe("down"); + + // Same commit, new process: a restart, not an upgrade — the page stays. + reply = info("aaa", "i2"); + await link.probe(); + expect(reload).not.toHaveBeenCalled(); + expect(link.status().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 link = new DaemonLink({ + fetchInfo: async () => reply, + scheduler: sched, + reload, + }); + await link.start(); + + link.noteDisconnected(); + reply = info("bbb", "i2"); + await link.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(link.status().state).toBe("updating"); + }); + + it("keeps polling on a scheduled cadence while the daemon is unreachable", async () => { + const sched = manual(); + let attempts = 0; + const link = new DaemonLink({ + fetchInfo: async () => { + attempts += 1; + throw new Error("down"); + }, + scheduler: sched, + reload: () => {}, + }); + link.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); + link.stop(); + }); + + it("notifies subscribers on every state change", async () => { + const sched = manual(); + const seen: string[] = []; + const link = new DaemonLink({ + fetchInfo: async () => info("aaa", "i1"), + scheduler: sched, + reload: () => {}, + }); + link.subscribe((s) => seen.push(s.state)); + await link.start(); + link.noteDisconnected(); + sched.run(); + await Promise.resolve(); + expect(seen).toContain("reconnecting"); + expect(seen[0]).toBe("connecting"); + }); +}); diff --git a/web/src/daemonLink.ts b/web/src/daemonLink.ts new file mode 100644 index 0000000..7950b81 --- /dev/null +++ b/web/src/daemonLink.ts @@ -0,0 +1,218 @@ +// The link to the daemon behind this page: is it up, and which release is it? +// The sidebar's status indicator and the restart overlay both read this one +// controller. See docs/RESTART.md for the restart contract it implements. + +import { getDaemonInfo } from "./api.js"; +import type { Scheduler } from "./events.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"; + +export interface LinkStatus { + 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; +} + +/** 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; + +const browserScheduler: Scheduler = { + setTimer: (fn, ms) => window.setTimeout(fn, ms), + clearTimer: (h) => window.clearTimeout(h), +}; + +/** + * 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(" "); +} + +export interface DaemonLinkOptions { + /** Injectable for tests; defaults to `GET /api/info`. */ + fetchInfo?: () => Promise; + scheduler?: Scheduler; + /** What an upgrade does. Injectable so tests don't navigate. */ + reload?: () => void; + graceMs?: number; + pollMs?: number; +} + +export type LinkListener = (status: LinkStatus) => void; + +export class DaemonLink { + private state: LinkState = "connecting"; + private identity: DaemonInfo | null = null; + private attempts = 0; + private graceTimer: number | null = null; + private pollTimer: number | null = null; + private readonly listeners = new Set(); + private readonly fetchInfo: () => Promise; + private readonly scheduler: Scheduler; + private readonly reload: () => void; + private readonly graceMs: number; + private readonly pollMs: number; + + constructor(opts: DaemonLinkOptions = {}) { + this.fetchInfo = opts.fetchInfo ?? getDaemonInfo; + this.scheduler = opts.scheduler ?? browserScheduler; + this.reload = opts.reload ?? (() => window.location.reload()); + this.graceMs = opts.graceMs ?? GRACE_MS; + this.pollMs = opts.pollMs ?? POLL_MS; + } + + /** Record the release we started against. Everything later is a diff on it. */ + async start(): Promise { + await this.probe(); + } + + status(): LinkStatus { + return { + state: this.state, + build: this.identity?.build, + hostname: this.identity?.hostname, + attempts: this.attempts, + }; + } + + subscribe(listener: LinkListener): () => void { + this.listeners.add(listener); + listener(this.status()); + return () => this.listeners.delete(listener); + } + + /** The events socket dropped. Starts the grace timer and the poll. */ + noteDisconnected(): void { + if (this.state === "down" || this.state === "updating") return; + this.attempts = 0; + this.setState("reconnecting"); + if (this.graceTimer === null) { + this.graceTimer = this.scheduler.setTimer(() => { + this.graceTimer = null; + if (this.state === "reconnecting") this.setState("down"); + void this.poll(); + }, this.graceMs); + } + } + + /** 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 { + if (this.state === "online" || this.state === "updating") return; + void this.probe(); + } + + /** + * One `/api/info` round-trip. Resolves `true` when the daemon answered. + * + * A first answer becomes the baseline. A later one that differs on commit + * reloads the page; anything else (plain restart, or the same process) puts + * the UI back online. + */ + async probe(): Promise { + let info: DaemonInfo; + try { + info = await this.fetchInfo(); + } catch { + this.attempts += 1; + this.emit(); + return false; + } + const before = this.identity; + this.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. + this.setState("updating"); + this.reload(); + return true; + } + this.attempts = 0; + this.clearTimers(); + this.setState("online"); + return true; + } + + /** Stop polling. The page's own teardown; not used in normal operation. */ + stop(): void { + this.clearTimers(); + } + + private async poll(): Promise { + if (this.state === "online" || this.state === "updating") return; + const ok = await this.probe(); + if (ok || this.pollTimer !== null) return; + this.pollTimer = this.scheduler.setTimer(() => { + this.pollTimer = null; + void this.poll(); + }, this.pollMs); + } + + private clearTimers(): void { + if (this.graceTimer !== null) { + this.scheduler.clearTimer(this.graceTimer); + this.graceTimer = null; + } + if (this.pollTimer !== null) { + this.scheduler.clearTimer(this.pollTimer); + this.pollTimer = null; + } + } + + private setState(state: LinkState): void { + if (this.state === state) return; + this.state = state; + this.emit(); + } + + private emit(): void { + const s = this.status(); + for (const fn of this.listeners) fn(s); + } +} diff --git a/web/src/events.test.ts b/web/src/events.test.ts index ab864ca..724c8f4 100644 --- a/web/src/events.test.ts +++ b/web/src/events.test.ts @@ -160,3 +160,21 @@ describe("CatalogStore reconnect", () => { expect(store.backoff()).toBe(INITIAL_BACKOFF_MS); }); }); + +describe("connection listeners", () => { + it("report the socket opening and dropping", () => { + const sock = new FakeSocket(); + const store = new CatalogStore({ + socket: () => sock as unknown as WebSocket, + scheduler: fakeScheduler(), + }); + const seen: boolean[] = []; + store.subscribeConnection((up) => seen.push(up)); + store.start(); + // Nothing on subscribe — the daemon link's own probe sets the first state. + expect(seen).toEqual([]); + sock.open(); + sock.drop(); + expect(seen).toEqual([true, false]); + }); +}); diff --git a/web/src/events.ts b/web/src/events.ts index b982bd7..c6b48c8 100644 --- a/web/src/events.ts +++ b/web/src/events.ts @@ -33,9 +33,15 @@ export interface CatalogStoreOptions { refetch?: () => Promise; } +/** Called with `true` when the events socket opens, `false` when it drops. + * Drives the daemon link (daemonLink.ts) — the socket dying is the first sign + * the daemon went down or was upgraded under the page. */ +export type ConnectionListener = (up: boolean) => void; + export class CatalogStore { private state: CatalogState = { sessions: new Map() }; private listeners = new Set(); + private connectionListeners = new Set(); private ws: WebSocket | null = null; private reconnectTimer: number | null = null; private backoffMs = INITIAL_BACKOFF_MS; @@ -70,6 +76,13 @@ export class CatalogStore { } } + /** Watch the socket's up/down transitions. Not called on subscribe: the + * caller's own probe decides the starting state. */ + subscribeConnection(listener: ConnectionListener): () => void { + this.connectionListeners.add(listener); + return () => this.connectionListeners.delete(listener); + } + subscribe(listener: CatalogListener): () => void { this.listeners.add(listener); listener(this.snapshot()); @@ -99,10 +112,12 @@ export class CatalogStore { ws.onopen = () => { this.backoffMs = INITIAL_BACKOFF_MS; + this.emitConnection(true); }; ws.onclose = () => { this.ws = null; + this.emitConnection(false); this.scheduleReconnect(); }; @@ -161,6 +176,10 @@ export class CatalogStore { } } + private emitConnection(up: boolean): void { + for (const fn of this.connectionListeners) fn(up); + } + private emit(): void { const snap = this.snapshot(); for (const fn of this.listeners) fn(snap); diff --git a/web/src/main.ts b/web/src/main.ts index d9a0e60..a7f74da 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -7,7 +7,9 @@ import { ConnectionPool } from "./connections.js"; import { installDrawer, installDrawerSwipe, requestToggleDrawer } from "./drawer.js"; import { installHudKeybind } from "./debug.js"; import { installHelpKeybind, isHelpOpen, isTypingTarget } from "./help.js"; +import { DaemonLink } from "./daemonLink.js"; import { CatalogStore } from "./events.js"; +import { installRestartOverlay } from "./restartOverlay.js"; import { shouldRetryClose } from "./reconnect.js"; import { createSession, @@ -69,6 +71,15 @@ if (import.meta.env.DEV && location.search.includes("kit")) { async function bootApp(): Promise { const store = new CatalogStore(); + +// The daemon's identity and liveness: names the release in the sidebar's status +// dot, and blocks the UI behind the restart overlay while the daemon is away +// (docs/RESTART.md). The events socket is the trigger — it dies first. +const link = new DaemonLink(); +installRestartOverlay(link); +store.subscribeConnection((up) => (up ? link.noteConnected() : link.noteDisconnected())); +void link.start(); + store.start(); // Dev-only: `?mock` seeds a representative catalog so the UI can be reviewed @@ -89,6 +100,7 @@ shellEl.dataset.route = "catalog"; let mv: MultiView; const sidebar = renderSidebar(store, { onLeafDrag: (id, e) => mv?.beginSessionDrag(id, e), + link, }); const content = document.createElement("section"); content.className = "content"; diff --git a/web/src/restartOverlay.ts b/web/src/restartOverlay.ts new file mode 100644 index 0000000..de8141f --- /dev/null +++ b/web/src/restartOverlay.ts @@ -0,0 +1,93 @@ +// Blocking overlay for a daemon that went away — see docs/RESTART.md. +// +// Mounted while the link is `down` or `updating`, torn down the moment it is +// back. It owns no polling of its own: DaemonLink polls, and this only renders +// what the link reports. + +import { formatRelease, type DaemonLink, type LinkStatus } from "./daemonLink.js"; + +export interface RestartOverlayHandle { + dispose(): void; +} + +/** Mount the overlay controller. It stays subscribed for the page's lifetime. */ +export function installRestartOverlay( + link: DaemonLink, + host: HTMLElement = document.body, +): RestartOverlayHandle { + let el: HTMLElement | null = null; + let title: HTMLElement | null = null; + let detail: HTMLElement | null = null; + let release: HTMLElement | null = null; + + const unsubscribe = link.subscribe((status) => render(status)); + + function render(status: LinkStatus): void { + const blocking = status.state === "down" || status.state === "updating"; + if (!blocking) { + unmount(); + return; + } + if (!el) mount(); + const updating = status.state === "updating"; + title!.textContent = updating ? "Maestro was updated" : "Maestro is restarting"; + detail!.textContent = updating + ? "Reloading this page onto the new release…" + : attemptText(status.attempts); + release!.textContent = formatRelease(status.build); + } + + /** 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 mount(): void { + el = document.createElement("div"); + el.className = "modal-backdrop restart-backdrop"; + const dialog = document.createElement("div"); + dialog.className = "modal restart-modal"; + dialog.setAttribute("role", "alertdialog"); + dialog.setAttribute("aria-modal", "true"); + dialog.setAttribute("aria-labelledby", "restart-title"); + + title = document.createElement("h2"); + title.id = "restart-title"; + detail = document.createElement("p"); + detail.className = "restart-detail"; + release = document.createElement("p"); + release.className = "restart-release"; + const spinner = document.createElement("div"); + spinner.className = "restart-spinner"; + spinner.setAttribute("aria-hidden", "true"); + + dialog.append(spinner, title, detail, release); + el.appendChild(dialog); + host.appendChild(el); + // 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. + window.addEventListener("keydown", swallow, true); + } + + function swallow(e: KeyboardEvent): void { + e.stopImmediatePropagation(); + } + + function unmount(): void { + if (!el) return; + window.removeEventListener("keydown", swallow, true); + el.remove(); + el = null; + } + + return { + dispose(): void { + unsubscribe(); + unmount(); + }, + }; +} diff --git a/web/src/statusIndicator.test.ts b/web/src/statusIndicator.test.ts new file mode 100644 index 0000000..629363f --- /dev/null +++ b/web/src/statusIndicator.test.ts @@ -0,0 +1,157 @@ +// @vitest-environment happy-dom +import { describe, expect, it } from "vitest"; + +import { DaemonLink } from "./daemonLink.js"; +import { installRestartOverlay } from "./restartOverlay.js"; +import { createStatusIndicator } from "./statusIndicator.js"; +import type { DaemonInfo } from "./types.js"; +import type { Scheduler } from "./events.js"; + +function info(commit: string, instance: string): DaemonInfo { + return { + hostname: "konishi", + version: "0.1.0", + presets: [], + build: { commit, commit_date: "2026-09-02T14:03:11Z", dirty: false }, + instance, + }; +} + +function manual(): Scheduler & { run(): void } { + let next = 1; + const timers = new Map void>(); + return { + setTimer(fn) { + const h = next++; + timers.set(h, fn); + return h; + }, + clearTimer(h) { + timers.delete(h); + }, + run() { + const due = [...timers.values()]; + timers.clear(); + for (const fn of due) fn(); + }, + }; +} + +describe("status indicator", () => { + it("shows the release on the tooltip once the daemon answers", async () => { + const link = new DaemonLink({ + fetchInfo: async () => info("f89be4a716c7", "i1"), + scheduler: manual(), + reload: () => {}, + }); + const ind = createStatusIndicator(link); + await link.start(); + + expect(ind.el.dataset.state).toBe("online"); + const tip = ind.el.querySelector(".status-tip")!.textContent ?? ""; + expect(tip).toContain("f89be4a716c7"); + expect(tip).toContain("2026-09-02"); + expect(tip).toContain("konishi"); + expect(ind.el.getAttribute("aria-label")).toContain("Connected"); + ind.dispose(); + }); + + it("tracks the link through a drop", async () => { + const sched = manual(); + const link = new DaemonLink({ + fetchInfo: async () => { + throw new Error("down"); + }, + scheduler: sched, + reload: () => {}, + }); + const ind = createStatusIndicator(link); + expect(ind.el.dataset.state).toBe("connecting"); + + link.noteDisconnected(); + expect(ind.el.dataset.state).toBe("reconnecting"); + sched.run(); + await Promise.resolve(); + expect(ind.el.dataset.state).toBe("down"); + ind.dispose(); + link.stop(); + }); +}); + +describe("restart overlay", () => { + it("blocks once the daemon is unreachable and unlocks when it answers", async () => { + const sched = manual(); + let reply: DaemonInfo | null = info("aaa", "i1"); + const link = new DaemonLink({ + fetchInfo: async () => { + if (!reply) throw new Error("down"); + return reply; + }, + scheduler: sched, + reload: () => {}, + }); + const overlay = installRestartOverlay(link); + await link.start(); + expect(document.querySelector(".restart-backdrop")).toBeNull(); + + reply = null; + link.noteDisconnected(); + expect(document.querySelector(".restart-backdrop")).toBeNull(); // grace + sched.run(); + await Promise.resolve(); + const dialog = document.querySelector(".restart-modal"); + expect(dialog?.textContent).toContain("Maestro is restarting"); + + // Restarted on the same build: no reload, and the overlay goes away. + reply = info("aaa", "i2"); + await link.probe(); + expect(document.querySelector(".restart-backdrop")).toBeNull(); + overlay.dispose(); + }); + + it("swallows the app's shortcuts while it is up", async () => { + const sched = manual(); + const link = new DaemonLink({ + fetchInfo: async () => { + throw new Error("down"); + }, + scheduler: sched, + reload: () => {}, + }); + const overlay = installRestartOverlay(link); + let sawKey = false; + window.addEventListener("keydown", () => { + sawKey = true; + }); + + link.noteDisconnected(); + sched.run(); + await Promise.resolve(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true })); + expect(sawKey).toBe(false); + + overlay.dispose(); + link.stop(); + window.dispatchEvent(new KeyboardEvent("keydown", { key: "n", bubbles: true })); + expect(sawKey).toBe(true); + }); + + it("says the page is reloading after an upgrade", async () => { + const sched = manual(); + let reply = info("aaa", "i1"); + const link = new DaemonLink({ + fetchInfo: async () => reply, + scheduler: sched, + reload: () => {}, + }); + const overlay = installRestartOverlay(link); + await link.start(); + link.noteDisconnected(); + reply = info("bbb", "i2"); + await link.probe(); + const dialog = document.querySelector(".restart-modal"); + expect(dialog?.textContent).toContain("Maestro was updated"); + expect(dialog?.textContent).toContain("bbb"); + overlay.dispose(); + }); +}); diff --git a/web/src/statusIndicator.ts b/web/src/statusIndicator.ts new file mode 100644 index 0000000..f0a71ba --- /dev/null +++ b/web/src/statusIndicator.ts @@ -0,0 +1,67 @@ +// The sidebar footer's daemon-status dot, sat next to the spawn button. +// +// Colour is the link state; hovering (or focusing — it is a button, so the +// keyboard reaches it) reveals the release the daemon is running: commit hash +// and commit date. + +import { formatRelease, type DaemonLink, type LinkState, type LinkStatus } from "./daemonLink.js"; + +/** One human-readable phrase per link state, used for both the tooltip's first + * line and the button's accessible name. */ +const LABELS: Record = { + connecting: "Connecting…", + online: "Connected", + reconnecting: "Reconnecting…", + down: "Daemon unreachable", + updating: "Updated — reloading", +}; + +export interface StatusIndicatorHandle { + el: HTMLElement; + dispose(): void; +} + +export function createStatusIndicator(link: DaemonLink): StatusIndicatorHandle { + const el = document.createElement("button"); + el.type = "button"; + el.className = "btn btn-ghost btn-icon status-chip"; + el.setAttribute("aria-describedby", "status-tip"); + + const dot = document.createElement("span"); + dot.className = "status-dot"; + dot.setAttribute("aria-hidden", "true"); + el.appendChild(dot); + + const tip = document.createElement("span"); + tip.className = "status-tip"; + tip.id = "status-tip"; + tip.setAttribute("role", "tooltip"); + el.appendChild(tip); + + const unsubscribe = link.subscribe((status) => render(status)); + + function render(status: LinkStatus): void { + const label = LABELS[status.state]; + const release = formatRelease(status.build); + el.dataset.state = status.state; + el.setAttribute("aria-label", `${label} — ${release}`); + tip.textContent = ""; + tip.append(line("status-tip-state", status.hostname ? `${label} · ${status.hostname}` : label)); + tip.append(line("status-tip-release", release)); + } + + function line(cls: string, text: string): HTMLElement { + const span = document.createElement("span"); + span.className = cls; + span.textContent = text; + return span; + } + + return { + el, + dispose(): void { + unsubscribe(); + el.remove(); + }, + }; +} diff --git a/web/src/styles.css b/web/src/styles.css index 205ea26..97d6a22 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -435,6 +435,134 @@ textarea { flex: 1 1 auto; } +/* ---------- daemon status dot ---------- */ + +/* Colour is the link state; the tooltip names the release the daemon is + running. The chip is a button so the keyboard reaches the tooltip too. */ +.status-chip { + position: relative; + flex: 0 0 auto; + overflow: visible; +} + +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--muted); + box-shadow: 0 0 0 3px transparent; + transition: + background var(--dur) var(--ease-out), + box-shadow var(--dur) var(--ease-out); +} + +.status-chip[data-state="online"] .status-dot { + background: var(--live); +} + +.status-chip[data-state="connecting"] .status-dot, +.status-chip[data-state="reconnecting"] .status-dot { + background: var(--warn); + animation: pulse 1.1s var(--ease-out) infinite; +} + +.status-chip[data-state="down"] .status-dot, +.status-chip[data-state="updating"] .status-dot { + background: var(--danger); + animation: pulse 1.1s var(--ease-out) infinite; +} + +.status-tip { + position: absolute; + bottom: calc(100% + var(--sp-2)); + left: 0; + z-index: var(--z-sticky); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 180px; + padding: var(--sp-2); + border: 1px solid var(--border); + border-radius: var(--r-2); + background: var(--surface-2); + box-shadow: var(--shadow-2); + font-size: var(--fs-11); + text-align: left; + white-space: nowrap; + opacity: 0; + visibility: hidden; + transition: + opacity var(--dur-fast) var(--ease-out), + visibility var(--dur-fast) var(--ease-out); +} + +.status-chip:hover .status-tip, +.status-chip:focus-visible .status-tip { + opacity: 1; + visibility: visible; +} + +.status-tip-state { + color: var(--fg); +} + +.status-tip-release { + color: var(--muted); + font-variant-numeric: tabular-nums; +} + +/* ---------- restart overlay ---------- */ + +/* Up while the daemon is unreachable or being replaced. Nothing to dismiss: + it clears itself when the link is back (docs/RESTART.md). */ +.restart-modal { + max-width: 380px; + align-items: center; + gap: var(--sp-2); + padding: var(--sp-5) var(--sp-4); + text-align: center; +} + +.restart-modal h2 { + font-size: var(--fs-15); + margin: 0; +} + +.restart-detail { + margin: 0; + color: var(--muted); + font-size: var(--fs-13); +} + +.restart-release { + margin: 0; + color: var(--faint); + font-size: var(--fs-11); + font-variant-numeric: tabular-nums; +} + +.restart-spinner { + width: 18px; + height: 18px; + border-radius: 50%; + border: 2px solid var(--border-strong); + border-top-color: var(--accent); + animation: spin 900ms linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} + +@media (prefers-reduced-motion: reduce) { + .restart-spinner, + .status-dot { + animation: none; + } +} + .sidebar-search .search { width: 100%; max-width: none;