From e68c9b6d73914278b3f75942eb93b6afefb415d9 Mon Sep 17 00:00:00 2001 From: naps62-yolo Date: Mon, 25 May 2026 09:48:39 +0000 Subject: [PATCH] feat(maestro): web client (Vite + TS + xterm.js) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NAP-218. Browser UI for the prototype: catalog tree derived from `metadata.label`, live updates over `WS /api/events`, search across session name+label, spawn modal (with optional git worktree), and an attach view that opens the daemon's per-session WS for xterm.js. Framework choice: vanilla TypeScript. The interactivity surface is small enough that pulling in a framework wouldn't pay back; xterm.js does the heavy lifting. Easy to swap later without protocol impact. Hash-based routing (`#/`, `#/attach/`) keeps the registry's static surface dumb — no SPA fallback needed; concurrent attaches work by opening the same hash URL in another tab. Co-Authored-By: Claude Opus 4.7 (1M context) --- web/.gitignore | 5 + web/README.md | 54 ++++ web/dist/.gitkeep | 0 web/index.html | 17 ++ web/package.json | 20 ++ web/pnpm-lock.yaml | 615 +++++++++++++++++++++++++++++++++++++++++++++ web/src/api.ts | 64 +++++ web/src/attach.ts | 171 +++++++++++++ web/src/catalog.ts | 228 +++++++++++++++++ web/src/events.ts | 136 ++++++++++ web/src/main.ts | 67 +++++ web/src/spawn.ts | 155 ++++++++++++ web/src/styles.css | 535 +++++++++++++++++++++++++++++++++++++++ web/src/types.ts | 81 ++++++ web/tsconfig.json | 20 ++ web/vite.config.ts | 20 ++ 16 files changed, 2188 insertions(+) create mode 100644 web/.gitignore create mode 100644 web/README.md create mode 100644 web/dist/.gitkeep create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/pnpm-lock.yaml create mode 100644 web/src/api.ts create mode 100644 web/src/attach.ts create mode 100644 web/src/catalog.ts create mode 100644 web/src/events.ts create mode 100644 web/src/main.ts create mode 100644 web/src/spawn.ts create mode 100644 web/src/styles.css create mode 100644 web/src/types.ts create mode 100644 web/tsconfig.json create mode 100644 web/vite.config.ts diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 0000000..e9541ca --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,5 @@ +node_modules/ +dist/* +!dist/.gitkeep +*.log +*.tsbuildinfo diff --git a/web/README.md b/web/README.md new file mode 100644 index 0000000..5747cdc --- /dev/null +++ b/web/README.md @@ -0,0 +1,54 @@ +# maestro-web + +Browser UI for Maestro. Connects to the registry HTTP/WS API for the catalog +and to individual daemons (via direct WebSocket) for terminal I/O. + +## Framework choice: vanilla TypeScript + +No frontend framework. The UI is small enough that the cost of pulling in +Preact / Solid / Leptos doesn't pay back. xterm.js does all the heavy +rendering; everything else is a session tree and a spawn form, both trivial +in plain DOM. + +This decision is non-architectural — swap in a framework later without +touching the registry or daemon if needed. + +## Layout + +``` +src/ + main.ts # entry point, hash-route dispatch + api.ts # registry HTTP API client + events.ts # `/api/events` WebSocket subscriber + catalog.ts # session tree view (groups by metadata.label) + attach.ts # xterm.js + daemon WS attach view + spawn.ts # spawn-session modal + types.ts # mirror of `maestro-protocol` JSON shapes + styles.css # mobile-first base styles +``` + +Routing is hash-based: +- `#/` → catalog +- `#/attach/` → terminal + +This keeps the registry's static handler dumb (no SPA fallback needed). +Concurrent attaches: open the same `#/attach/` URL in another tab. + +## Dev + +```sh +pnpm install +pnpm dev # http://localhost:5173, proxies /api → http://127.0.0.1:8080 +pnpm build # → dist/, embedded into the registry binary at cargo build time +``` + +The Vite dev server proxies `/api` and the events WebSocket to a locally +running `maestro-registry` (default `127.0.0.1:8080`). Direct daemon WS +connections (for attach) bypass the proxy and use the URL returned by +`GET /api/sessions/:id/connect-info`. + +## Production + +`pnpm build` writes the bundle to `web/dist/`. The Rust registry crate +embeds that directory via `rust-embed` at compile time and serves it from +`/`. See `crates/maestro-registry/src/web_assets.rs`. diff --git a/web/dist/.gitkeep b/web/dist/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..e129326 --- /dev/null +++ b/web/index.html @@ -0,0 +1,17 @@ + + + + + + + maestro + + + +
+ + + diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..bef39fb --- /dev/null +++ b/web/package.json @@ -0,0 +1,20 @@ +{ + "name": "maestro-web", + "private": true, + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "preview": "vite preview", + "typecheck": "tsc -b --noEmit" + }, + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0" + }, + "devDependencies": { + "typescript": "^5.6.3", + "vite": "^5.4.10" + } +} diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml new file mode 100644 index 0000000..4628288 --- /dev/null +++ b/web/pnpm-lock.yaml @@ -0,0 +1,615 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@xterm/addon-fit': + specifier: ^0.10.0 + version: 0.10.0(@xterm/xterm@5.5.0) + '@xterm/xterm': + specifier: ^5.5.0 + version: 5.5.0 + devDependencies: + typescript: + specifier: ^5.6.3 + version: 5.9.3 + vite: + specifier: ^5.4.10 + version: 5.4.21 + +packages: + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@rollup/rollup-android-arm-eabi@4.60.4': + resolution: {integrity: sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.60.4': + resolution: {integrity: sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.60.4': + resolution: {integrity: sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.60.4': + resolution: {integrity: sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.60.4': + resolution: {integrity: sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.60.4': + resolution: {integrity: sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + resolution: {integrity: sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + resolution: {integrity: sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + resolution: {integrity: sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.60.4': + resolution: {integrity: sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + resolution: {integrity: sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.60.4': + resolution: {integrity: sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + resolution: {integrity: sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + resolution: {integrity: sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + resolution: {integrity: sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + resolution: {integrity: sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + resolution: {integrity: sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.60.4': + resolution: {integrity: sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.60.4': + resolution: {integrity: sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.60.4': + resolution: {integrity: sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.60.4': + resolution: {integrity: sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + resolution: {integrity: sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + resolution: {integrity: sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.60.4': + resolution: {integrity: sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.60.4': + resolution: {integrity: sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.8': + resolution: {integrity: sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==} + + '@xterm/addon-fit@0.10.0': + resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + + '@xterm/xterm@5.5.0': + resolution: {integrity: sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + nanoid@3.3.12: + resolution: {integrity: sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.15: + resolution: {integrity: sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==} + engines: {node: ^10 || ^12 || >=14} + + rollup@4.60.4: + resolution: {integrity: sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + typescript@5.9.3: + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + engines: {node: '>=14.17'} + hasBin: true + + vite@5.4.21: + resolution: {integrity: sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + +snapshots: + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@rollup/rollup-android-arm-eabi@4.60.4': + optional: true + + '@rollup/rollup-android-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-arm64@4.60.4': + optional: true + + '@rollup/rollup-darwin-x64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-arm64@4.60.4': + optional: true + + '@rollup/rollup-freebsd-x64@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.60.4': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-linux-x64-musl@4.60.4': + optional: true + + '@rollup/rollup-openbsd-x64@4.60.4': + optional: true + + '@rollup/rollup-openharmony-arm64@4.60.4': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.60.4': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.60.4': + optional: true + + '@types/estree@1.0.8': {} + + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + + '@xterm/xterm@5.5.0': {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + fsevents@2.3.3: + optional: true + + nanoid@3.3.12: {} + + picocolors@1.1.1: {} + + postcss@8.5.15: + dependencies: + nanoid: 3.3.12 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + rollup@4.60.4: + dependencies: + '@types/estree': 1.0.8 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.60.4 + '@rollup/rollup-android-arm64': 4.60.4 + '@rollup/rollup-darwin-arm64': 4.60.4 + '@rollup/rollup-darwin-x64': 4.60.4 + '@rollup/rollup-freebsd-arm64': 4.60.4 + '@rollup/rollup-freebsd-x64': 4.60.4 + '@rollup/rollup-linux-arm-gnueabihf': 4.60.4 + '@rollup/rollup-linux-arm-musleabihf': 4.60.4 + '@rollup/rollup-linux-arm64-gnu': 4.60.4 + '@rollup/rollup-linux-arm64-musl': 4.60.4 + '@rollup/rollup-linux-loong64-gnu': 4.60.4 + '@rollup/rollup-linux-loong64-musl': 4.60.4 + '@rollup/rollup-linux-ppc64-gnu': 4.60.4 + '@rollup/rollup-linux-ppc64-musl': 4.60.4 + '@rollup/rollup-linux-riscv64-gnu': 4.60.4 + '@rollup/rollup-linux-riscv64-musl': 4.60.4 + '@rollup/rollup-linux-s390x-gnu': 4.60.4 + '@rollup/rollup-linux-x64-gnu': 4.60.4 + '@rollup/rollup-linux-x64-musl': 4.60.4 + '@rollup/rollup-openbsd-x64': 4.60.4 + '@rollup/rollup-openharmony-arm64': 4.60.4 + '@rollup/rollup-win32-arm64-msvc': 4.60.4 + '@rollup/rollup-win32-ia32-msvc': 4.60.4 + '@rollup/rollup-win32-x64-gnu': 4.60.4 + '@rollup/rollup-win32-x64-msvc': 4.60.4 + fsevents: 2.3.3 + + source-map-js@1.2.1: {} + + typescript@5.9.3: {} + + vite@5.4.21: + dependencies: + esbuild: 0.21.5 + postcss: 8.5.15 + rollup: 4.60.4 + optionalDependencies: + fsevents: 2.3.3 diff --git a/web/src/api.ts b/web/src/api.ts new file mode 100644 index 0000000..d788261 --- /dev/null +++ b/web/src/api.ts @@ -0,0 +1,64 @@ +import type { + ApiError, + ConnectInfo, + CreateSessionRequest, + DaemonInfo, + Session, + Uuid, +} from "./types.js"; + +async function jsonOrError(res: Response): Promise { + if (res.ok) { + if (res.status === 204) return undefined as unknown as T; + return (await res.json()) as T; + } + let detail = ""; + try { + const body = (await res.json()) as ApiError; + detail = `${body.error.code}: ${body.error.message}`; + } catch { + detail = await res.text(); + } + throw new Error(`${res.status} ${res.statusText}${detail ? ` — ${detail}` : ""}`); +} + +export async function listDaemons(): Promise { + return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" })); +} + +export async function listSessions(): Promise { + return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" })); +} + +export async function createSession(req: CreateSessionRequest): Promise { + return jsonOrError( + await fetch("/api/sessions", { + method: "POST", + credentials: "same-origin", + headers: { "content-type": "application/json" }, + body: JSON.stringify(req), + }), + ); +} + +export async function killSession(id: Uuid): Promise { + await jsonOrError( + await fetch(`/api/sessions/${encodeURIComponent(id)}`, { + method: "DELETE", + credentials: "same-origin", + }), + ); +} + +export async function getConnectInfo(id: Uuid): Promise { + return jsonOrError( + await fetch(`/api/sessions/${encodeURIComponent(id)}/connect-info`, { + credentials: "same-origin", + }), + ); +} + +export function eventsWsUrl(): string { + const proto = location.protocol === "https:" ? "wss:" : "ws:"; + return `${proto}//${location.host}/api/events`; +} diff --git a/web/src/attach.ts b/web/src/attach.ts new file mode 100644 index 0000000..bd12b66 --- /dev/null +++ b/web/src/attach.ts @@ -0,0 +1,171 @@ +import { FitAddon } from "@xterm/addon-fit"; +import { Terminal } from "@xterm/xterm"; +import "@xterm/xterm/css/xterm.css"; + +import { getConnectInfo } from "./api.js"; +import type { ClientControl, Uuid } from "./types.js"; + +interface AttachHandle { + dispose: () => void; +} + +function statusMessage(host: HTMLElement, kind: "info" | "error", text: string): void { + host.innerHTML = `
${text}
`; +} + +function streamUrl(daemonUrl: string, sessionId: Uuid, jwt: string): string { + let base = daemonUrl; + // Allow operators to give an http(s) URL; convert to ws(s). + if (base.startsWith("http://")) base = "ws://" + base.slice("http://".length); + else if (base.startsWith("https://")) base = "wss://" + base.slice("https://".length); + // Trim trailing slash. + base = base.replace(/\/+$/, ""); + const encId = encodeURIComponent(sessionId); + const encJwt = encodeURIComponent(jwt); + return `${base}/sessions/${encId}/stream?token=${encJwt}`; +} + +export async function renderAttach( + root: HTMLElement, + sessionId: Uuid, +): Promise { + root.innerHTML = ""; + + const shell = document.createElement("div"); + shell.className = "attach"; + shell.innerHTML = ` +
+ ← + ${sessionId.slice(0, 8)} + connecting… +
+
+ `; + root.appendChild(shell); + + const body = shell.querySelector(".attach-body") as HTMLDivElement; + const stateEl = shell.querySelector(".attach-state") as HTMLSpanElement; + + const setState = (s: string, kind: "connecting" | "open" | "closed" | "error") => { + stateEl.textContent = s; + stateEl.dataset.state = kind; + }; + + statusMessage(body, "info", "Fetching connect info…"); + + let connect; + try { + connect = await getConnectInfo(sessionId); + } catch (e) { + setState("error", "error"); + statusMessage(body, "error", `Failed to get connect info: ${(e as Error).message}`); + return { dispose: () => {} }; + } + + body.innerHTML = ""; + + const term = new Terminal({ + cursorBlink: true, + fontFamily: + 'ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace', + fontSize: 13, + convertEol: false, + theme: { + background: "#101115", + foreground: "#e6e6e6", + cursor: "#e6e6e6", + }, + scrollback: 5000, + allowProposedApi: true, + }); + const fit = new FitAddon(); + term.loadAddon(fit); + term.open(body); + + const tryFit = () => { + try { + fit.fit(); + } catch { + // ignore — fit fails if container is detached + } + }; + tryFit(); + + const wsUrl = streamUrl(connect.daemon_url, sessionId, connect.jwt); + const ws = new WebSocket(wsUrl); + ws.binaryType = "arraybuffer"; + + const sendControl = (msg: ClientControl) => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(JSON.stringify(msg)); + }; + + const onResize = () => { + tryFit(); + sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); + }; + + ws.onopen = () => { + setState("connected", "open"); + sendControl({ type: "Resize", cols: term.cols, rows: term.rows }); + term.focus(); + }; + + ws.onmessage = (ev) => { + if (typeof ev.data === "string") { + // Text frames: ignore unknown control messages; we just need a parse. + try { + JSON.parse(ev.data) as ClientControl; + } catch { + // ignore + } + return; + } + if (ev.data instanceof ArrayBuffer) { + term.write(new Uint8Array(ev.data)); + } else if (ev.data instanceof Blob) { + // Some browsers may default to Blob despite binaryType="arraybuffer". + ev.data.arrayBuffer().then((b) => term.write(new Uint8Array(b))); + } + }; + + ws.onerror = () => { + setState("error", "error"); + }; + + ws.onclose = (ev) => { + setState(`closed (${ev.code})`, "closed"); + }; + + // PTY input: every keystroke goes out as a binary frame. + const dataDisp = term.onData((data) => { + if (ws.readyState !== WebSocket.OPEN) return; + ws.send(new TextEncoder().encode(data)); + }); + const resizeDisp = term.onResize(({ cols, rows }) => { + sendControl({ type: "Resize", cols, rows }); + }); + + window.addEventListener("resize", onResize); + // Keep focus + sizing correct when the visual viewport shifts (mobile keyboards). + const visual = window.visualViewport ?? null; + visual?.addEventListener("resize", onResize); + + let disposed = false; + const dispose = () => { + if (disposed) return; + disposed = true; + window.removeEventListener("resize", onResize); + visual?.removeEventListener("resize", onResize); + dataDisp.dispose(); + resizeDisp.dispose(); + try { + ws.close(); + } catch { + // ignore + } + term.dispose(); + }; + + return { dispose }; +} diff --git a/web/src/catalog.ts b/web/src/catalog.ts new file mode 100644 index 0000000..efc28b0 --- /dev/null +++ b/web/src/catalog.ts @@ -0,0 +1,228 @@ +import { killSession } from "./api.js"; +import { CatalogStore } from "./events.js"; +import type { DaemonInfo, Session } from "./types.js"; +import { sessionLabel, sessionName } from "./types.js"; +import { openSpawnModal } from "./spawn.js"; + +interface TreeFolder { + kind: "folder"; + name: string; + path: string; + children: TreeNode[]; +} + +interface TreeLeaf { + kind: "leaf"; + session: Session; +} + +type TreeNode = TreeFolder | TreeLeaf; + +const collapsed = new Set(); + +function compareNodes(a: TreeNode, b: TreeNode): number { + if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1; + const an = a.kind === "folder" ? a.name : sessionName(a.session); + const bn = b.kind === "folder" ? b.name : sessionName(b.session); + return an.localeCompare(bn); +} + +function buildTree(sessions: Session[], filter: string): TreeNode { + const root: TreeFolder = { kind: "folder", name: "", path: "", children: [] }; + const lc = filter.trim().toLowerCase(); + + for (const s of sessions) { + if (lc) { + const haystack = `${sessionName(s)} ${sessionLabel(s) ?? ""}`.toLowerCase(); + if (!haystack.includes(lc)) continue; + } + const label = sessionLabel(s); + const parts = label ? label.split("/").filter(Boolean) : []; + let cursor = root; + for (const part of parts) { + const path = cursor.path ? `${cursor.path}/${part}` : part; + let next = cursor.children.find( + (n): n is TreeFolder => n.kind === "folder" && n.name === part, + ); + if (!next) { + next = { kind: "folder", name: part, path, children: [] }; + cursor.children.push(next); + } + cursor = next; + } + cursor.children.push({ kind: "leaf", session: s }); + } + + const sortRec = (n: TreeFolder) => { + n.children.sort(compareNodes); + for (const c of n.children) if (c.kind === "folder") sortRec(c); + }; + sortRec(root); + return root; +} + +function renderNode(node: TreeNode, depth: number): HTMLElement { + if (node.kind === "folder") { + const wrap = document.createElement("div"); + wrap.className = "tree-folder"; + + if (node.name) { + const header = document.createElement("button"); + header.type = "button"; + header.className = "tree-folder-header"; + header.style.paddingLeft = `${depth * 16}px`; + const isCollapsed = collapsed.has(node.path); + header.setAttribute("aria-expanded", String(!isCollapsed)); + header.innerHTML = `${isCollapsed ? "▸" : "▾"}${escapeHtml(node.name)}${countLeaves(node)}`; + header.addEventListener("click", () => { + if (collapsed.has(node.path)) collapsed.delete(node.path); + else collapsed.add(node.path); + // re-render handled by caller observing the store; trigger a refresh: + document.dispatchEvent(new CustomEvent("catalog-rerender")); + }); + wrap.appendChild(header); + if (isCollapsed) return wrap; + } + + for (const child of node.children) { + wrap.appendChild(renderNode(child, node.name ? depth + 1 : depth)); + } + return wrap; + } + + const s = node.session; + const row = document.createElement("a"); + row.className = "tree-leaf"; + row.href = `#/attach/${encodeURIComponent(s.id)}`; + row.style.paddingLeft = `${depth * 16 + 8}px`; + row.dataset.sessionId = s.id; + const status = s.status === "Running" ? "running" : "dead"; + row.innerHTML = ` + + + ${escapeHtml(sessionName(s))} + ${escapeHtml(s.daemon_id)} · ${escapeHtml(s.command)} + + + `; + const killBtn = row.querySelector(".leaf-kill") as HTMLButtonElement; + killBtn.addEventListener("click", async (ev) => { + ev.preventDefault(); + ev.stopPropagation(); + if (!confirm(`Kill session "${sessionName(s)}"?`)) return; + try { + await killSession(s.id); + } catch (e) { + alert(`Failed to kill: ${(e as Error).message}`); + } + }); + return row; +} + +function countLeaves(node: TreeNode): number { + if (node.kind === "leaf") return 1; + return node.children.reduce((n, c) => n + countLeaves(c), 0); +} + +function escapeHtml(s: string): string { + return s + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """); +} + +function renderDaemons(daemons: DaemonInfo[]): HTMLElement { + const wrap = document.createElement("div"); + wrap.className = "daemon-bar"; + if (daemons.length === 0) { + wrap.innerHTML = `No daemons connected`; + return wrap; + } + for (const d of daemons) { + const chip = document.createElement("span"); + chip.className = "daemon-chip"; + chip.title = `${d.hostname} · ${d.daemon_url}`; + chip.textContent = d.daemon_id; + wrap.appendChild(chip); + } + return wrap; +} + +export function renderCatalog(root: HTMLElement, store: CatalogStore): () => void { + root.innerHTML = ""; + + const shell = document.createElement("div"); + shell.className = "catalog"; + + const header = document.createElement("header"); + header.className = "catalog-header"; + header.innerHTML = ` +

maestro

+
+ + +
+ `; + shell.appendChild(header); + + const daemonsHost = document.createElement("div"); + shell.appendChild(daemonsHost); + + const treeHost = document.createElement("div"); + treeHost.className = "tree"; + shell.appendChild(treeHost); + + root.appendChild(shell); + + const search = header.querySelector(".search") as HTMLInputElement; + let filter = ""; + search.addEventListener("input", () => { + filter = search.value; + refresh(); + }); + + const spawnBtn = header.querySelector(".spawn-btn") as HTMLButtonElement; + spawnBtn.addEventListener("click", () => { + openSpawnModal(store.snapshot().daemons); + }); + + const refresh = () => { + const state = store.snapshot(); + daemonsHost.replaceChildren(renderDaemons([...state.daemons.values()])); + const sessions = [...state.sessions.values()]; + const tree = buildTree(sessions, filter); + treeHost.replaceChildren(); + if (sessions.length === 0) { + const empty = document.createElement("p"); + empty.className = "empty"; + empty.textContent = + store.snapshot().daemons.size === 0 + ? "Waiting for a daemon to connect…" + : "No sessions yet. Press + Spawn to create one."; + treeHost.appendChild(empty); + return; + } + if (tree.kind === "folder") { + for (const child of tree.children) treeHost.appendChild(renderNode(child, 0)); + } + }; + + const rerenderHandler = () => refresh(); + document.addEventListener("catalog-rerender", rerenderHandler); + + const unsubscribe = store.subscribe(refresh); + + return () => { + document.removeEventListener("catalog-rerender", rerenderHandler); + unsubscribe(); + }; +} diff --git a/web/src/events.ts b/web/src/events.ts new file mode 100644 index 0000000..5aed7e5 --- /dev/null +++ b/web/src/events.ts @@ -0,0 +1,136 @@ +import { eventsWsUrl, listDaemons, listSessions } from "./api.js"; +import type { DaemonInfo, RegistryEvent, Session, Uuid } from "./types.js"; + +export interface CatalogState { + daemons: Map; + sessions: Map; +} + +export type CatalogListener = (state: CatalogState) => void; + +export class CatalogStore { + private state: CatalogState = { daemons: new Map(), sessions: new Map() }; + private listeners = new Set(); + private ws: WebSocket | null = null; + private reconnectTimer: number | null = null; + private backoffMs = 500; + + start(): void { + this.connect(); + } + + stop(): void { + if (this.reconnectTimer !== null) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = null; + } + if (this.ws) { + this.ws.onclose = null; + this.ws.close(); + this.ws = null; + } + } + + subscribe(listener: CatalogListener): () => void { + this.listeners.add(listener); + listener(this.snapshot()); + return () => this.listeners.delete(listener); + } + + snapshot(): CatalogState { + return this.state; + } + + private connect(): void { + const ws = new WebSocket(eventsWsUrl()); + this.ws = ws; + + ws.onmessage = (ev) => { + let event: RegistryEvent; + try { + event = JSON.parse(ev.data) as RegistryEvent; + } catch { + return; + } + this.apply(event); + }; + + ws.onopen = () => { + this.backoffMs = 500; + }; + + ws.onclose = () => { + this.ws = null; + this.scheduleReconnect(); + }; + + ws.onerror = () => { + try { + ws.close(); + } catch { + // ignore + } + }; + } + + private scheduleReconnect(): void { + if (this.reconnectTimer !== null) return; + const delay = Math.min(this.backoffMs, 10_000); + this.reconnectTimer = window.setTimeout(() => { + this.reconnectTimer = null; + this.backoffMs = Math.min(this.backoffMs * 2, 10_000); + this.connect(); + }, delay); + } + + private apply(event: RegistryEvent): void { + switch (event.type) { + case "Snapshot": + this.state = { + daemons: new Map(event.daemons.map((d) => [d.daemon_id, d])), + sessions: new Map(event.sessions.map((s) => [s.id, s])), + }; + break; + case "DaemonAdded": + this.state.daemons.set(event.daemon.daemon_id, event.daemon); + break; + case "DaemonRemoved": + this.state.daemons.delete(event.daemon_id); + for (const [id, sess] of this.state.sessions) { + if (sess.daemon_id === event.daemon_id) this.state.sessions.delete(id); + } + break; + case "SessionAdded": + this.state.sessions.set(event.session.id, event.session); + break; + case "SessionRemoved": + this.state.sessions.delete(event.id); + break; + case "SessionUpdated": + this.state.sessions.set(event.session.id, event.session); + break; + case "Lagged": + // Re-derive from REST to avoid showing stale state. + void this.resync(); + return; + } + this.emit(); + } + + private async resync(): Promise { + try { + const [daemons, sessions] = await Promise.all([listDaemons(), listSessions()]); + this.state = { + daemons: new Map(daemons.map((d) => [d.daemon_id, d])), + sessions: new Map(sessions.map((s) => [s.id, s])), + }; + this.emit(); + } catch { + // surface in UI on next reconnect cycle; nothing else useful to do + } + } + + private emit(): void { + for (const fn of this.listeners) fn(this.state); + } +} diff --git a/web/src/main.ts b/web/src/main.ts new file mode 100644 index 0000000..94e0610 --- /dev/null +++ b/web/src/main.ts @@ -0,0 +1,67 @@ +import { renderAttach } from "./attach.js"; +import { renderCatalog } from "./catalog.js"; +import { CatalogStore } from "./events.js"; +import "./styles.css"; + +const root = document.getElementById("app"); +if (!root) { + throw new Error("missing #app"); +} + +const store = new CatalogStore(); +store.start(); + +type Route = + | { kind: "catalog" } + | { kind: "attach"; sessionId: string } + | { kind: "not-found"; raw: string }; + +function parseRoute(): Route { + const hash = location.hash.replace(/^#/, ""); + if (hash === "" || hash === "/") return { kind: "catalog" }; + const m = hash.match(/^\/attach\/([^/?#]+)$/); + if (m) return { kind: "attach", sessionId: decodeURIComponent(m[1]) }; + return { kind: "not-found", raw: hash }; +} + +let currentTeardown: (() => void) | (() => Promise) | null = null; + +async function dispatch() { + if (currentTeardown) { + try { + await Promise.resolve(currentTeardown()); + } catch { + // ignore + } + currentTeardown = null; + } + const route = parseRoute(); + switch (route.kind) { + case "catalog": { + const teardown = renderCatalog(root!, store); + currentTeardown = teardown; + return; + } + case "attach": { + const handle = await renderAttach(root!, route.sessionId); + currentTeardown = () => handle.dispose(); + return; + } + case "not-found": { + root!.innerHTML = ` +
+

Unknown route

+

#${route.raw} doesn't match anything.

+ Back to catalog +
+ `; + return; + } + } +} + +window.addEventListener("hashchange", () => { + void dispatch(); +}); + +void dispatch(); diff --git a/web/src/spawn.ts b/web/src/spawn.ts new file mode 100644 index 0000000..1494db4 --- /dev/null +++ b/web/src/spawn.ts @@ -0,0 +1,155 @@ +import { createSession } from "./api.js"; +import type { CreateSessionRequest, DaemonInfo } from "./types.js"; + +export function openSpawnModal(daemons: Map): void { + const existing = document.getElementById("modal-host"); + if (existing) existing.remove(); + + const host = document.createElement("div"); + host.id = "modal-host"; + host.className = "modal-backdrop"; + host.innerHTML = ` + + `; + document.body.appendChild(host); + + const select = host.querySelector("select[name=daemon_id]") as HTMLSelectElement; + if (daemons.size === 0) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "(no daemons connected)"; + opt.disabled = true; + opt.selected = true; + select.appendChild(opt); + } else { + for (const d of daemons.values()) { + const opt = document.createElement("option"); + opt.value = d.daemon_id; + opt.textContent = `${d.daemon_id} (${d.hostname})`; + select.appendChild(opt); + } + } + + const close = () => host.remove(); + host.querySelector(".close")?.addEventListener("click", close); + host.querySelector(".cancel")?.addEventListener("click", close); + host.addEventListener("click", (e) => { + if (e.target === host) close(); + }); + window.addEventListener( + "keydown", + (e) => { + if (e.key === "Escape") close(); + }, + { once: true }, + ); + + const form = host.querySelector("#spawn-form") as HTMLFormElement; + const errEl = form.querySelector(".form-error") as HTMLElement; + + form.addEventListener("submit", async (e) => { + e.preventDefault(); + errEl.textContent = ""; + const data = new FormData(form); + const daemonId = String(data.get("daemon_id") ?? ""); + const cwd = String(data.get("cwd") ?? "").trim(); + const command = String(data.get("command") ?? "").trim(); + if (!daemonId || !cwd || !command) { + errEl.textContent = "Daemon, cwd, and command are required."; + return; + } + const name = String(data.get("name") ?? "").trim(); + const label = String(data.get("label") ?? "").trim(); + const metadata: Record = {}; + if (name) metadata.name = name; + if (label) metadata.label = label; + + const req: CreateSessionRequest = { + daemon_id: daemonId, + cwd, + command, + metadata, + }; + if (data.get("worktree_enabled")) { + const baseRepo = String(data.get("worktree_base_repo") ?? "").trim(); + const branch = String(data.get("worktree_branch") ?? "").trim(); + if (!baseRepo || !branch) { + errEl.textContent = "Worktree requires base repo and branch."; + return; + } + req.worktree = { + base_repo: baseRepo, + branch, + cleanup_on_kill: Boolean(data.get("worktree_cleanup_on_kill")), + }; + } + + const submitBtn = form.querySelector( + "button[type=submit]", + ) as HTMLButtonElement; + submitBtn.disabled = true; + try { + const session = await createSession(req); + close(); + location.hash = `#/attach/${encodeURIComponent(session.id)}`; + } catch (err) { + errEl.textContent = (err as Error).message; + submitBtn.disabled = false; + } + }); + + const firstInput = form.querySelector("input, select") as HTMLElement | null; + firstInput?.focus(); +} diff --git a/web/src/styles.css b/web/src/styles.css new file mode 100644 index 0000000..40cfe1a --- /dev/null +++ b/web/src/styles.css @@ -0,0 +1,535 @@ +:root { + --bg: #0c0d10; + --surface: #14161b; + --surface-2: #1c1f26; + --border: #2a2e38; + --fg: #e6e6e6; + --muted: #98a2b3; + --accent: #6c8eef; + --accent-fg: #0c0d10; + --danger: #e5484d; + --ok: #30a46c; + --row-h: 44px; + --font-sans: + -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", + Arial, "Noto Sans", sans-serif; + --font-mono: + ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", + monospace; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; + margin: 0; + background: var(--bg); + color: var(--fg); + font-family: var(--font-sans); + font-size: 15px; + line-height: 1.4; + -webkit-text-size-adjust: 100%; +} + +#app { + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} + +a { + color: inherit; + text-decoration: none; +} + +code { + font-family: var(--font-mono); + font-size: 0.92em; + padding: 1px 4px; + background: var(--surface-2); + border-radius: 3px; +} + +button, +input, +select, +textarea { + font-family: inherit; + font-size: inherit; + color: inherit; +} + +.btn { + border: 1px solid var(--border); + background: var(--surface); + color: var(--fg); + padding: 0 14px; + height: 36px; + border-radius: 8px; + cursor: pointer; + font-weight: 500; + min-height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn:hover { + background: var(--surface-2); +} + +.btn-primary { + background: var(--accent); + border-color: var(--accent); + color: var(--accent-fg); +} + +.btn-primary:hover { + filter: brightness(1.08); +} + +.btn-ghost { + background: transparent; +} + +.btn-icon { + width: 36px; + padding: 0; +} + +/* ---------- catalog ---------- */ + +.catalog { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} + +.catalog-header { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 12px 16px; + border-bottom: 1px solid var(--border); + background: var(--surface); + position: sticky; + top: 0; + z-index: 1; +} + +.catalog-header h1 { + margin: 0; + font-size: 18px; + letter-spacing: 0.02em; +} + +.catalog-actions { + display: flex; + flex: 1 1 280px; + gap: 8px; + align-items: center; + justify-content: flex-end; +} + +.search { + flex: 1 1 220px; + max-width: 360px; + height: 36px; + padding: 0 12px; + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + outline: none; +} + +.search:focus { + border-color: var(--accent); +} + +.daemon-bar { + padding: 8px 16px; + display: flex; + flex-wrap: wrap; + gap: 6px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.daemon-chip { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 999px; + padding: 2px 10px; + font-size: 12px; + color: var(--muted); +} + +.daemon-empty { + font-size: 12px; + color: var(--muted); +} + +.tree { + flex: 1 1 auto; + overflow: auto; + padding: 8px 0 24px; +} + +.empty { + padding: 24px 16px; + color: var(--muted); + text-align: center; +} + +.tree-folder { + display: flex; + flex-direction: column; +} + +.tree-folder-header { + display: flex; + align-items: center; + gap: 8px; + background: transparent; + border: 0; + width: 100%; + text-align: left; + height: var(--row-h); + padding-right: 16px; + color: var(--muted); + cursor: pointer; + font-size: 13px; + letter-spacing: 0.02em; + text-transform: uppercase; +} + +.tree-folder-header:hover { + background: var(--surface); +} + +.tree-folder-header .chevron { + width: 14px; + text-align: center; + color: var(--muted); +} + +.folder-name { + flex: 1 1 auto; + color: var(--fg); + text-transform: none; + font-size: 14px; + letter-spacing: 0; +} + +.folder-count { + font-size: 12px; + color: var(--muted); + padding: 1px 8px; + border-radius: 999px; + background: var(--surface-2); +} + +.tree-leaf { + display: flex; + align-items: center; + gap: 10px; + min-height: var(--row-h); + padding: 8px 16px 8px 8px; + border-bottom: 1px solid transparent; + color: var(--fg); +} + +.tree-leaf:hover { + background: var(--surface); +} + +.tree-leaf:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; +} + +.status { + width: 8px; + height: 8px; + border-radius: 50%; + flex: 0 0 8px; + background: var(--muted); +} + +.status-running { + background: var(--ok); +} + +.status-dead { + background: var(--danger); + opacity: 0.7; +} + +.leaf-main { + display: flex; + flex-direction: column; + flex: 1 1 auto; + min-width: 0; +} + +.leaf-name { + font-weight: 500; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaf-meta { + font-size: 12px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.leaf-kill { + background: transparent; + color: var(--muted); + border: 1px solid transparent; + border-radius: 8px; + width: 36px; + height: 36px; + cursor: pointer; + flex: 0 0 36px; +} + +.leaf-kill:hover { + color: var(--danger); + background: var(--surface-2); + border-color: var(--border); +} + +/* ---------- attach view ---------- */ + +.attach { + display: flex; + flex-direction: column; + height: 100%; + background: var(--bg); +} + +.attach-header { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-bottom: 1px solid var(--border); + background: var(--surface); +} + +.attach-header .back { + font-size: 18px; + width: 40px; + height: 36px; +} + +.attach-title { + font-family: var(--font-mono); + font-size: 13px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; +} + +.attach-state { + font-size: 12px; + padding: 2px 8px; + border-radius: 999px; + background: var(--surface-2); + color: var(--muted); + border: 1px solid var(--border); +} + +.attach-state[data-state="open"] { + color: var(--ok); +} + +.attach-state[data-state="closed"], +.attach-state[data-state="error"] { + color: var(--danger); +} + +.attach-body { + flex: 1 1 auto; + min-height: 0; + padding: 6px; + display: flex; + background: #101115; +} + +.attach-body .xterm { + width: 100%; + height: 100%; +} + +.attach-status { + margin: 16px; + padding: 12px 14px; + border-radius: 8px; + background: var(--surface); + border: 1px solid var(--border); + font-size: 14px; +} + +.attach-status.status-error { + border-color: var(--danger); + color: var(--danger); +} + +/* ---------- modal ---------- */ + +.modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.55); + display: flex; + align-items: flex-end; + justify-content: center; + padding: 0; + z-index: 50; +} + +@media (min-width: 600px) { + .modal-backdrop { + align-items: center; + padding: 24px; + } +} + +.modal { + background: var(--surface); + border-radius: 16px 16px 0 0; + border: 1px solid var(--border); + width: 100%; + max-width: 560px; + max-height: 90vh; + display: flex; + flex-direction: column; +} + +@media (min-width: 600px) { + .modal { + border-radius: 12px; + } +} + +.modal-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 14px 16px; + border-bottom: 1px solid var(--border); +} + +.modal-header h2 { + margin: 0; + font-size: 16px; +} + +.modal-body { + padding: 16px; + overflow: auto; + display: flex; + flex-direction: column; + gap: 12px; +} + +.modal-body label { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 13px; + color: var(--muted); +} + +.modal-body label.row-inline { + flex-direction: row; + align-items: center; + gap: 8px; + color: var(--fg); + font-size: 14px; +} + +.modal-body input[type="text"], +.modal-body select { + background: var(--surface-2); + border: 1px solid var(--border); + border-radius: 8px; + padding: 10px 12px; + color: var(--fg); + font-size: 15px; + width: 100%; +} + +.modal-body input[type="text"]:focus, +.modal-body select:focus { + outline: none; + border-color: var(--accent); +} + +.worktree { + border: 1px solid var(--border); + border-radius: 8px; + padding: 12px; + display: flex; + flex-direction: column; + gap: 10px; +} + +.worktree legend { + padding: 0 6px; + color: var(--fg); +} + +.modal-actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 8px; +} + +.form-error { + color: var(--danger); + margin: 0; + font-size: 13px; + min-height: 1.2em; +} + +/* ---------- not-found ---------- */ + +.not-found { + padding: 24px; + display: flex; + flex-direction: column; + gap: 12px; + align-items: flex-start; +} + +/* ---------- responsive tweaks ---------- */ + +@media (max-width: 480px) { + .catalog-header h1 { + font-size: 16px; + } + .catalog-actions { + flex: 1 1 100%; + justify-content: stretch; + } + .search { + max-width: none; + } +} diff --git a/web/src/types.ts b/web/src/types.ts new file mode 100644 index 0000000..ae4e124 --- /dev/null +++ b/web/src/types.ts @@ -0,0 +1,81 @@ +// Mirror of `maestro-protocol` JSON shapes. Keep in sync with +// crates/maestro-protocol/src/lib.rs. + +export type Uuid = string; +export type IsoTimestamp = string; + +export type SessionStatus = "Running" | "Dead"; + +export interface Session { + id: Uuid; + daemon_id: string; + cwd: string; + command: string; + env: Record; + metadata: Record; + status: SessionStatus; + created_at: IsoTimestamp; +} + +export interface DaemonInfo { + daemon_id: string; + hostname: string; + version: string; + daemon_url: string; +} + +export interface ConnectInfo { + daemon_url: string; + jwt: string; +} + +export interface WorktreeSpec { + base_repo: string; + branch: string; + cleanup_on_kill?: boolean; +} + +export interface CreateSessionRequest { + daemon_id: string; + cwd: string; + command: string; + env?: Record; + metadata?: Record; + worktree?: WorktreeSpec; +} + +export type RegistryEvent = + | { + type: "Snapshot"; + daemons: DaemonInfo[]; + sessions: Session[]; + } + | { type: "DaemonAdded"; daemon: DaemonInfo } + | { type: "DaemonRemoved"; daemon_id: string } + | { type: "SessionAdded"; session: Session } + | { type: "SessionRemoved"; daemon_id: string; id: Uuid } + | { type: "SessionUpdated"; session: Session } + | { type: "Lagged"; skipped: number }; + +export type ClientControl = + | { type: "Resize"; cols: number; rows: number } + | { type: "Ping" } + | { type: "Pong" } + | { type: "Error"; code: string; message: string }; + +export interface ApiError { + error: { code: string; message: string }; +} + +export function sessionName(s: Session): string { + const meta = s.metadata as Record; + const name = meta?.name; + if (typeof name === "string" && name.length > 0) return name; + return s.command; +} + +export function sessionLabel(s: Session): string | undefined { + const meta = s.metadata as Record; + const label = meta?.label; + return typeof label === "string" ? label : undefined; +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 0000000..5391e2e --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "Bundler", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "noImplicitOverride": true, + "isolatedModules": true, + "esModuleInterop": true, + "resolveJsonModule": true, + "skipLibCheck": true, + "useDefineForClassFields": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/web/vite.config.ts b/web/vite.config.ts new file mode 100644 index 0000000..3b458f4 --- /dev/null +++ b/web/vite.config.ts @@ -0,0 +1,20 @@ +import { defineConfig } from "vite"; + +export default defineConfig({ + build: { + target: "es2022", + outDir: "dist", + emptyOutDir: true, + sourcemap: false, + }, + server: { + port: 5173, + proxy: { + "/api": { + target: "http://127.0.0.1:8080", + changeOrigin: false, + ws: true, + }, + }, + }, +});