Implements the fixes from docs/REVIEW_REPORT.md (4-agent parallel review). typecheck clean; 341 tests pass (16 files, +113); build:web ok; coverage thresholds (80%) enforced in vitest.config.ts. Critical: - multi-device approval race: release held approval only when the last client detaches (closing one mirror no longer cancels another's prompt) - unbounded session creation (DoS): Config.maxSessions cap (env MAX_SESSIONS), enforced in manager via the existing M4 exit(-1) path - signal-handler leak: named SIGINT/SIGTERM/uncaughtException refs removed in close() - terminal-session initialInput timer tracked + cleared on dispose - tabs.addEntry null-as-cast type hole removed (build session before entry) Should-fix: - security-headers middleware + Origin/CSRF guard on DELETE /live-sessions[/:id] - history.ts converted to fs/promises (async /sessions handler) - removed dead clientDims map + blur protocol message end-to-end - per-connection WS message rate limit (Config.maxMsgsPerSec) - /sessions behavior kept; documented as accepted LAN risk (TECH_DOC §7) Tests: - new tmux / preview-grid / terminal-session (jsdom) / tabs (jsdom) suites - extended history/config/manager/integration coverage incl. regressions Hygiene: - parsePositiveInt -> parseNonNegativeInt; ALLOWED_ORIGINS scheme validation - log-injection sanitize; isLoopback handles 127.0.0.0/8 + IPv4-mapped - operational constants moved into Config - extracted public/preview-grid.ts (DRY launcher/manage) - doc sweeps: ARCHITECTURE §8 runtime-handle exception, stale comments
136 lines
4.3 KiB
TypeScript
136 lines
4.3 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. The card + preview plumbing is shared with the launcher via
|
||
* public/preview-grid.ts (DRY).
|
||
*/
|
||
|
||
import '@xterm/xterm/css/xterm.css'
|
||
import type { LiveSessionInfo } from '../src/types.js'
|
||
import {
|
||
el,
|
||
relTime,
|
||
makePreviewCard,
|
||
updatePreviewCard,
|
||
loadPreviewInto,
|
||
fitThumb,
|
||
fetchLiveSessions,
|
||
type PreviewCard,
|
||
} from './preview-grid.js'
|
||
|
||
const REFRESH_MS = 4000
|
||
const THUMB_W = 360 // card thumbnail width in px
|
||
const THUMB_MAX_H = 220
|
||
|
||
const cards = new Map<string, PreviewCard>()
|
||
let busy = false
|
||
|
||
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()
|
||
}
|
||
|
||
function makeCard(s: LiveSessionInfo): PreviewCard {
|
||
const kill = el('button', 'mg-kill', 'Kill ✕')
|
||
kill.addEventListener('click', () => void killOne(s.id))
|
||
return makePreviewCard(s, {
|
||
onOpen: (id) => {
|
||
location.href = `/?join=${id}`
|
||
},
|
||
openHref: (id) => `/?join=${id}`,
|
||
extraActions: () => [kill],
|
||
})
|
||
}
|
||
|
||
function updateCard(card: PreviewCard, s: LiveSessionInfo): void {
|
||
updatePreviewCard(card, s)
|
||
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 fetchLiveSessions()
|
||
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 loadPreviewInto(s.id, card, THUMB_W, THUMB_MAX_H)
|
||
}
|
||
// 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, THUMB_W, THUMB_MAX_H)
|
||
})
|