The flat session list didn't tell you what each session was doing. Rebuild
/manage.html as a full-page grid where each card renders a LIVE preview
thumbnail — a read-only xterm showing the session's actual current screen,
scaled down like a screenshot — so you can recognize each at a glance.
- RingBuffer.tail(maxBytes): newest scrollback on chunk boundaries (no ANSI/UTF-8
split); GET /live-sessions/:id/preview returns {cols,rows,data} (no attach, so
it doesn't inflate watcher counts or keep sessions alive)
- manage.ts: card grid; per card a scaled read-only Terminal renders CLEAR+tail,
auto-refresh every 4s, click thumbnail/Open to join, Kill / bulk kill
- bundles xterm into manage.js (+manage.css linked in manage.html)
- ring-buffer tail tests
Verified: 3 sessions rendered as recognizable thumbnails (top / git log / ls),
colored, with status/age; click opens. 228 tests green, tsc clean.
245 lines
7.5 KiB
TypeScript
245 lines
7.5 KiB
TypeScript
/**
|
||
* public/manage.ts — standalone Session Manager page (/manage.html).
|
||
*
|
||
* A full-page grid of the host's running sessions, each with a LIVE preview
|
||
* thumbnail (a read-only xterm rendering the session's current screen, scaled
|
||
* down like a screenshot) so you can see what each one is doing at a glance.
|
||
* Open one (?join=<id>), kill one, or bulk-kill. Auto-refreshes.
|
||
*
|
||
* Previews use GET /live-sessions/:id/preview (the scrollback tail) — they do
|
||
* NOT open a WS / attach, so they don't inflate watcher counts or keep sessions
|
||
* alive.
|
||
*/
|
||
|
||
import { Terminal } from '@xterm/xterm'
|
||
import '@xterm/xterm/css/xterm.css'
|
||
|
||
interface LiveSession {
|
||
id: string
|
||
createdAt: number
|
||
clientCount: number
|
||
status: 'working' | 'waiting' | 'idle' | 'unknown'
|
||
exited: boolean
|
||
cwd: string | null
|
||
cols: number
|
||
rows: number
|
||
}
|
||
|
||
interface Preview {
|
||
id: string
|
||
cols: number
|
||
rows: number
|
||
data: string
|
||
}
|
||
|
||
const REFRESH_MS = 4000
|
||
const THUMB_W = 360 // card thumbnail width in px
|
||
const THUMB_MAX_H = 220
|
||
const CLEAR = '\x1b[2J\x1b[3J\x1b[H\x1b[0m' // reset screen+scrollback+cursor before writing the tail
|
||
const PREVIEW_THEME = { background: '#0e0f13', foreground: '#e7e8ec', cursor: '#0e0f13' }
|
||
|
||
interface Card {
|
||
el: HTMLElement
|
||
term: Terminal
|
||
inner: HTMLElement
|
||
thumb: HTMLElement
|
||
meta: HTMLElement
|
||
watch: HTMLElement
|
||
status: HTMLElement
|
||
}
|
||
|
||
const cards = new Map<string, Card>()
|
||
let busy = false
|
||
|
||
function el<K extends keyof HTMLElementTagNameMap>(
|
||
tag: K,
|
||
cls?: string,
|
||
text?: string,
|
||
): HTMLElementTagNameMap[K] {
|
||
const node = document.createElement(tag)
|
||
if (cls) node.className = cls
|
||
if (text !== undefined) node.textContent = text
|
||
return node
|
||
}
|
||
|
||
function relTime(ms: number): string {
|
||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||
if (s < 60) return `${Math.floor(s)}s`
|
||
if (s < 3600) return `${Math.floor(s / 60)}m`
|
||
if (s < 86400) return `${Math.floor(s / 3600)}h`
|
||
return `${Math.floor(s / 86400)}d`
|
||
}
|
||
|
||
function statusText(s: LiveSession['status']): string {
|
||
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
|
||
}
|
||
|
||
function name(s: LiveSession): string {
|
||
return s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8)
|
||
}
|
||
|
||
async function getJSON<T>(url: string): Promise<T | null> {
|
||
try {
|
||
const res = await fetch(url)
|
||
if (!res.ok) return null
|
||
return (await res.json()) as T
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
|
||
async function killOne(id: string): Promise<void> {
|
||
await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {})
|
||
void render()
|
||
}
|
||
|
||
async function killBulk(detachedOnly: boolean): Promise<void> {
|
||
const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions'
|
||
if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return
|
||
await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' }).catch(() => {})
|
||
void render()
|
||
}
|
||
|
||
/** Scale the rendered xterm down so the full screen fits the thumbnail width. */
|
||
function fitThumb(card: Card): void {
|
||
const w = card.inner.offsetWidth
|
||
const h = card.inner.offsetHeight
|
||
if (w === 0 || h === 0) return
|
||
const scale = Math.min(1, THUMB_W / w)
|
||
card.inner.style.transform = `scale(${scale})`
|
||
card.thumb.style.height = `${Math.min(h * scale, THUMB_MAX_H)}px`
|
||
}
|
||
|
||
function makeCard(s: LiveSession): Card {
|
||
const el0 = el('div', 'mg-card')
|
||
|
||
const head = el('div', 'mg-card-head')
|
||
const title = el('span', 'mg-name', name(s))
|
||
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
|
||
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
|
||
head.append(title, status, watch)
|
||
|
||
const thumb = el('div', 'mg-thumb')
|
||
const inner = el('div', 'mg-thumb-inner')
|
||
thumb.append(inner)
|
||
// Click the preview to open the session full.
|
||
thumb.addEventListener('click', () => {
|
||
location.href = `/?join=${s.id}`
|
||
})
|
||
|
||
const term = new Terminal({
|
||
cols: Math.max(2, s.cols),
|
||
rows: Math.max(2, s.rows),
|
||
disableStdin: true,
|
||
cursorBlink: false,
|
||
fontFamily: 'Menlo, Consolas, monospace',
|
||
fontSize: 12,
|
||
scrollback: 0,
|
||
theme: PREVIEW_THEME,
|
||
})
|
||
term.open(inner)
|
||
|
||
const meta = el('div', 'mg-meta')
|
||
|
||
const actions = el('div', 'mg-actions')
|
||
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
|
||
open.href = `/?join=${s.id}`
|
||
const kill = el('button', 'mg-kill', 'Kill ✕')
|
||
kill.addEventListener('click', () => void killOne(s.id))
|
||
actions.append(open, kill)
|
||
|
||
el0.append(head, thumb, meta, actions)
|
||
return { el: el0, term, inner, thumb, meta, watch, status }
|
||
}
|
||
|
||
async function refreshPreview(id: string, card: Card): Promise<void> {
|
||
const p = await getJSON<Preview>(`/live-sessions/${id}/preview`)
|
||
if (!p) return
|
||
if (card.term.cols !== Math.max(2, p.cols) || card.term.rows !== Math.max(2, p.rows)) {
|
||
card.term.resize(Math.max(2, p.cols), Math.max(2, p.rows))
|
||
}
|
||
card.term.reset()
|
||
card.term.write(CLEAR + p.data, () => requestAnimationFrame(() => fitThumb(card)))
|
||
}
|
||
|
||
function updateCard(card: Card, s: LiveSession): void {
|
||
card.status.className = `mg-status mg-${s.status}`
|
||
card.status.textContent = statusText(s.status)
|
||
card.watch.className = s.clientCount > 0 ? 'mg-watch live' : 'mg-watch'
|
||
card.watch.textContent = `👁 ${s.clientCount}`
|
||
card.meta.textContent = `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`
|
||
}
|
||
|
||
const root = document.getElementById('manage-root')
|
||
if (!root) throw new Error('#manage-root not found')
|
||
|
||
let grid: HTMLElement | null = null
|
||
let countEl: HTMLElement | null = null
|
||
|
||
function ensureChrome(): void {
|
||
if (grid) return
|
||
const header = el('div', 'mg-header')
|
||
header.append(el('div', 'mg-title', 'Session Manager'))
|
||
countEl = el('div', 'mg-sub', '')
|
||
header.append(countEl)
|
||
|
||
const bar = el('div', 'mg-bar')
|
||
const back = el('a', 'mg-btn', '← Back to terminal') as HTMLAnchorElement
|
||
back.href = '/'
|
||
const refresh = el('button', 'mg-btn', '↻ Refresh')
|
||
refresh.addEventListener('click', () => void render())
|
||
const killDetached = el('button', 'mg-btn warn', 'Kill detached')
|
||
killDetached.addEventListener('click', () => void killBulk(true))
|
||
const killAll = el('button', 'mg-btn danger', 'Kill all')
|
||
killAll.addEventListener('click', () => void killBulk(false))
|
||
bar.append(back, refresh, killDetached, killAll)
|
||
header.append(bar)
|
||
|
||
grid = el('div', 'mg-grid')
|
||
root!.append(header, grid)
|
||
}
|
||
|
||
async function render(): Promise<void> {
|
||
if (busy) return
|
||
busy = true
|
||
ensureChrome()
|
||
|
||
const sessions = (await getJSON<LiveSession[]>('/live-sessions')) ?? []
|
||
if (countEl) countEl.textContent = `${sessions.length} session(s) running on the host`
|
||
|
||
const seen = new Set<string>()
|
||
for (const s of sessions) {
|
||
seen.add(s.id)
|
||
let card = cards.get(s.id)
|
||
if (!card) {
|
||
card = makeCard(s)
|
||
cards.set(s.id, card)
|
||
grid!.append(card.el)
|
||
}
|
||
updateCard(card, s)
|
||
void refreshPreview(s.id, card)
|
||
}
|
||
// Drop cards for sessions that are gone.
|
||
for (const [id, card] of cards) {
|
||
if (!seen.has(id)) {
|
||
card.term.dispose()
|
||
card.el.remove()
|
||
cards.delete(id)
|
||
}
|
||
}
|
||
|
||
if (sessions.length === 0 && grid && grid.querySelector('.mg-empty') === null) {
|
||
grid.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.'))
|
||
} else {
|
||
grid?.querySelector('.mg-empty')?.remove()
|
||
}
|
||
busy = false
|
||
}
|
||
|
||
void render()
|
||
setInterval(() => void render(), REFRESH_MS)
|
||
// Re-scale thumbnails if the window resizes.
|
||
window.addEventListener('resize', () => {
|
||
for (const card of cards.values()) fitThumb(card)
|
||
})
|