feat(maestro): web client (Vite + TS + xterm.js)
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/<id>`) 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) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
node_modules/
|
||||
dist/*
|
||||
!dist/.gitkeep
|
||||
*.log
|
||||
*.tsbuildinfo
|
||||
@@ -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/<session-id>` → terminal
|
||||
|
||||
This keeps the registry's static handler dumb (no SPA fallback needed).
|
||||
Concurrent attaches: open the same `#/attach/<id>` 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`.
|
||||
Vendored
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1.0, viewport-fit=cover"
|
||||
/>
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>maestro</title>
|
||||
<link rel="icon" href="data:," />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
Generated
+615
@@ -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
|
||||
@@ -0,0 +1,64 @@
|
||||
import type {
|
||||
ApiError,
|
||||
ConnectInfo,
|
||||
CreateSessionRequest,
|
||||
DaemonInfo,
|
||||
Session,
|
||||
Uuid,
|
||||
} from "./types.js";
|
||||
|
||||
async function jsonOrError<T>(res: Response): Promise<T> {
|
||||
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<DaemonInfo[]> {
|
||||
return jsonOrError(await fetch("/api/daemons", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function listSessions(): Promise<Session[]> {
|
||||
return jsonOrError(await fetch("/api/sessions", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
export async function createSession(req: CreateSessionRequest): Promise<Session> {
|
||||
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<void> {
|
||||
await jsonOrError(
|
||||
await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
|
||||
method: "DELETE",
|
||||
credentials: "same-origin",
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function getConnectInfo(id: Uuid): Promise<ConnectInfo> {
|
||||
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`;
|
||||
}
|
||||
@@ -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 = `<div class="attach-status status-${kind}">${text}</div>`;
|
||||
}
|
||||
|
||||
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<AttachHandle> {
|
||||
root.innerHTML = "";
|
||||
|
||||
const shell = document.createElement("div");
|
||||
shell.className = "attach";
|
||||
shell.innerHTML = `
|
||||
<header class="attach-header">
|
||||
<a class="btn btn-ghost back" href="#/" aria-label="Back to catalog">←</a>
|
||||
<span class="attach-title" title="${sessionId}">${sessionId.slice(0, 8)}</span>
|
||||
<span class="attach-state" data-state="connecting">connecting…</span>
|
||||
</header>
|
||||
<div class="attach-body"></div>
|
||||
`;
|
||||
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 };
|
||||
}
|
||||
@@ -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<string>();
|
||||
|
||||
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 = `<span class="chevron">${isCollapsed ? "▸" : "▾"}</span><span class="folder-name">${escapeHtml(node.name)}</span><span class="folder-count">${countLeaves(node)}</span>`;
|
||||
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 = `
|
||||
<span class="status status-${status}" aria-hidden="true"></span>
|
||||
<span class="leaf-main">
|
||||
<span class="leaf-name">${escapeHtml(sessionName(s))}</span>
|
||||
<span class="leaf-meta">${escapeHtml(s.daemon_id)} · ${escapeHtml(s.command)}</span>
|
||||
</span>
|
||||
<button class="leaf-kill" type="button" title="Kill session" aria-label="Kill session">✕</button>
|
||||
`;
|
||||
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, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
function renderDaemons(daemons: DaemonInfo[]): HTMLElement {
|
||||
const wrap = document.createElement("div");
|
||||
wrap.className = "daemon-bar";
|
||||
if (daemons.length === 0) {
|
||||
wrap.innerHTML = `<span class="daemon-empty">No daemons connected</span>`;
|
||||
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 = `
|
||||
<h1>maestro</h1>
|
||||
<div class="catalog-actions">
|
||||
<input
|
||||
type="search"
|
||||
class="search"
|
||||
placeholder="Search sessions…"
|
||||
autocomplete="off"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false"
|
||||
/>
|
||||
<button type="button" class="btn btn-primary spawn-btn">+ Spawn</button>
|
||||
</div>
|
||||
`;
|
||||
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();
|
||||
};
|
||||
}
|
||||
@@ -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<string, DaemonInfo>;
|
||||
sessions: Map<Uuid, Session>;
|
||||
}
|
||||
|
||||
export type CatalogListener = (state: CatalogState) => void;
|
||||
|
||||
export class CatalogStore {
|
||||
private state: CatalogState = { daemons: new Map(), sessions: new Map() };
|
||||
private listeners = new Set<CatalogListener>();
|
||||
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<void> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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<void>) | 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 = `
|
||||
<div class="not-found">
|
||||
<h1>Unknown route</h1>
|
||||
<p><code>#${route.raw}</code> doesn't match anything.</p>
|
||||
<a class="btn btn-primary" href="#/">Back to catalog</a>
|
||||
</div>
|
||||
`;
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("hashchange", () => {
|
||||
void dispatch();
|
||||
});
|
||||
|
||||
void dispatch();
|
||||
@@ -0,0 +1,155 @@
|
||||
import { createSession } from "./api.js";
|
||||
import type { CreateSessionRequest, DaemonInfo } from "./types.js";
|
||||
|
||||
export function openSpawnModal(daemons: Map<string, DaemonInfo>): 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 = `
|
||||
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="spawn-title">
|
||||
<header class="modal-header">
|
||||
<h2 id="spawn-title">Spawn session</h2>
|
||||
<button type="button" class="btn btn-icon close" aria-label="Close">✕</button>
|
||||
</header>
|
||||
<form class="modal-body" id="spawn-form">
|
||||
<label>
|
||||
Daemon
|
||||
<select name="daemon_id" required></select>
|
||||
</label>
|
||||
<label>
|
||||
Working directory
|
||||
<input name="cwd" type="text" required placeholder="/home/user/project" inputmode="text" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Command
|
||||
<input name="command" type="text" required placeholder="bash" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Name (optional)
|
||||
<input name="name" type="text" placeholder="e.g. impl" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Label (optional, e.g. <code>personal/maestro</code>)
|
||||
<input name="label" type="text" placeholder="group/name" autocomplete="off" />
|
||||
</label>
|
||||
<fieldset class="worktree">
|
||||
<legend>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_enabled" />
|
||||
Create git worktree
|
||||
</label>
|
||||
</legend>
|
||||
<label>
|
||||
Base repo
|
||||
<input name="worktree_base_repo" type="text" placeholder="/path/to/repo" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Branch
|
||||
<input name="worktree_branch" type="text" placeholder="feat/foo" autocomplete="off" />
|
||||
</label>
|
||||
<label class="row-inline">
|
||||
<input type="checkbox" name="worktree_cleanup_on_kill" />
|
||||
Remove worktree when session is killed
|
||||
</label>
|
||||
</fieldset>
|
||||
<p class="form-error" role="alert" aria-live="polite"></p>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-ghost cancel">Cancel</button>
|
||||
<button type="submit" class="btn btn-primary">Spawn</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
`;
|
||||
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<string, unknown> = {};
|
||||
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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<string, string>;
|
||||
metadata: Record<string, unknown>;
|
||||
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<string, string>;
|
||||
metadata?: Record<string, unknown>;
|
||||
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<string, unknown>;
|
||||
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<string, unknown>;
|
||||
const label = meta?.label;
|
||||
return typeof label === "string" ? label : undefined;
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user