This repository has been archived on 2026-10-08. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
maestro/web/src/restartOverlay.ts
T
Miguel PalhasandClaude Opus 5 4d7e368d31 feat(maestro): daemon status indicator and restart overlay on web
The sidebar footer gains a status dot beside the spawn button: green while the
daemon answers, amber while reconnecting, red while it is gone. Hovering it
shows the release the daemon is running — commit hash and commit date.

When the events socket drops, `DaemonLink` starts polling `GET /api/info`. Past
a 1200 ms grace window an overlay blocks the UI (and swallows the app's
shortcuts) until the daemon answers again. If it comes back on a different
commit the page reloads, so a tab never drives new server code from the bundle
that was just replaced; a plain restart just unlocks.

docs/RESTART.md is the contract.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 15:45:50 +01:00

94 lines
3.2 KiB
TypeScript

// 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();
},
};
}