Files
web-terminal/public/manage.ts
Yaojia Wang 787f806e02 feat(v0.4): session manager grid with live preview thumbnails
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.
2026-06-19 11:37:32 +02:00

245 lines
7.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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)
})