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.
This commit is contained in:
@@ -5,6 +5,7 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||||||
<title>Session Manager — Web Terminal</title>
|
<title>Session Manager — Web Terminal</title>
|
||||||
<meta name="theme-color" content="#0e0f13">
|
<meta name="theme-color" content="#0e0f13">
|
||||||
|
<link rel="stylesheet" href="./build/manage.css">
|
||||||
<link rel="stylesheet" href="./style.css">
|
<link rel="stylesheet" href="./style.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||
236
public/manage.ts
236
public/manage.ts
@@ -1,12 +1,19 @@
|
|||||||
/**
|
/**
|
||||||
* public/manage.ts — standalone Session Manager page (/manage.html).
|
* public/manage.ts — standalone Session Manager page (/manage.html).
|
||||||
*
|
*
|
||||||
* Lists the host's running server sessions (GET /live-sessions) and lets you
|
* A full-page grid of the host's running sessions, each with a LIVE preview
|
||||||
* open one (?join=<id>), kill one (DELETE /live-sessions/:id), or bulk-kill all
|
* thumbnail (a read-only xterm rendering the session's current screen, scaled
|
||||||
* / only detached ones (DELETE /live-sessions[?detached=1]). Auto-refreshes.
|
* down like a screenshot) so you can see what each one is doing at a glance.
|
||||||
* Independent of the main app, so it works even when many sessions are open.
|
* 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 {
|
interface LiveSession {
|
||||||
id: string
|
id: string
|
||||||
createdAt: number
|
createdAt: number
|
||||||
@@ -18,33 +25,30 @@ interface LiveSession {
|
|||||||
rows: number
|
rows: number
|
||||||
}
|
}
|
||||||
|
|
||||||
const REFRESH_MS = 3000
|
interface Preview {
|
||||||
|
id: string
|
||||||
function relTime(ms: number): string {
|
cols: number
|
||||||
const s = Math.max(0, (Date.now() - ms) / 1000)
|
rows: number
|
||||||
if (s < 60) return `${Math.floor(s)}s`
|
data: string
|
||||||
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 statusGlyph(s: LiveSession['status']): string {
|
const REFRESH_MS = 4000
|
||||||
return s === 'working' ? '⚙ working' : s === 'waiting' ? '⏳ waiting' : s === 'idle' ? '✓ idle' : '·'
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSessions(): Promise<LiveSession[]> {
|
const cards = new Map<string, Card>()
|
||||||
try {
|
|
||||||
const res = await fetch('/live-sessions')
|
|
||||||
const data: unknown = await res.json()
|
|
||||||
return Array.isArray(data) ? (data as LiveSession[]) : []
|
|
||||||
} catch {
|
|
||||||
return []
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const root = document.getElementById('manage-root')
|
|
||||||
if (!root) throw new Error('#manage-root not found')
|
|
||||||
|
|
||||||
let busy = false
|
let busy = false
|
||||||
|
|
||||||
function el<K extends keyof HTMLElementTagNameMap>(
|
function el<K extends keyof HTMLElementTagNameMap>(
|
||||||
@@ -58,43 +62,84 @@ function el<K extends keyof HTMLElementTagNameMap>(
|
|||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killOne(id: string): Promise<void> {
|
function relTime(ms: number): string {
|
||||||
if (busy) return
|
const s = Math.max(0, (Date.now() - ms) / 1000)
|
||||||
busy = true
|
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 {
|
try {
|
||||||
await fetch(`/live-sessions/${id}`, { method: 'DELETE' })
|
const res = await fetch(url)
|
||||||
|
if (!res.ok) return null
|
||||||
|
return (await res.json()) as T
|
||||||
} catch {
|
} catch {
|
||||||
/* best-effort */
|
return null
|
||||||
}
|
}
|
||||||
busy = false
|
}
|
||||||
|
|
||||||
|
async function killOne(id: string): Promise<void> {
|
||||||
|
await fetch(`/live-sessions/${id}`, { method: 'DELETE' }).catch(() => {})
|
||||||
void render()
|
void render()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function killBulk(detachedOnly: boolean): Promise<void> {
|
async function killBulk(detachedOnly: boolean): Promise<void> {
|
||||||
if (busy) return
|
|
||||||
const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions'
|
const label = detachedOnly ? 'all DETACHED sessions (no device watching)' : 'ALL sessions'
|
||||||
if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return
|
if (!confirm(`Kill ${label}? Running shells/Claude will be terminated.`)) return
|
||||||
busy = true
|
await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' }).catch(() => {})
|
||||||
try {
|
|
||||||
await fetch(`/live-sessions${detachedOnly ? '?detached=1' : ''}`, { method: 'DELETE' })
|
|
||||||
} catch {
|
|
||||||
/* best-effort */
|
|
||||||
}
|
|
||||||
busy = false
|
|
||||||
void render()
|
void render()
|
||||||
}
|
}
|
||||||
|
|
||||||
function row(s: LiveSession): HTMLElement {
|
/** Scale the rendered xterm down so the full screen fits the thumbnail width. */
|
||||||
const r = el('div', 'mg-row')
|
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`
|
||||||
|
}
|
||||||
|
|
||||||
const main = el('div', 'mg-main')
|
function makeCard(s: LiveSession): Card {
|
||||||
const top = el('div', 'mg-top')
|
const el0 = el('div', 'mg-card')
|
||||||
const name = el('span', 'mg-name', s.cwd ? (s.cwd.split('/').filter(Boolean).pop() ?? s.cwd) : s.id.slice(0, 8))
|
|
||||||
const watchers = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
|
const head = el('div', 'mg-card-head')
|
||||||
const status = el('span', `mg-status mg-${s.status}`, statusGlyph(s.status))
|
const title = el('span', 'mg-name', name(s))
|
||||||
top.append(name, watchers, status)
|
const status = el('span', `mg-status mg-${s.status}`, statusText(s.status))
|
||||||
const meta = el('div', 'mg-meta', `${s.cwd ?? 'unknown dir'} · ${s.cols}×${s.rows} · ${relTime(s.createdAt)} old · ${s.id.slice(0, 8)}`)
|
const watch = el('span', s.clientCount > 0 ? 'mg-watch live' : 'mg-watch', `👁 ${s.clientCount}`)
|
||||||
main.append(top, meta)
|
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 actions = el('div', 'mg-actions')
|
||||||
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
|
const open = el('a', 'mg-open', 'Open ↗') as HTMLAnchorElement
|
||||||
@@ -103,18 +148,40 @@ function row(s: LiveSession): HTMLElement {
|
|||||||
kill.addEventListener('click', () => void killOne(s.id))
|
kill.addEventListener('click', () => void killOne(s.id))
|
||||||
actions.append(open, kill)
|
actions.append(open, kill)
|
||||||
|
|
||||||
r.append(main, actions)
|
el0.append(head, thumb, meta, actions)
|
||||||
return r
|
return { el: el0, term, inner, thumb, meta, watch, status }
|
||||||
}
|
}
|
||||||
|
|
||||||
async function render(): Promise<void> {
|
async function refreshPreview(id: string, card: Card): Promise<void> {
|
||||||
const sessions = await fetchSessions()
|
const p = await getJSON<Preview>(`/live-sessions/${id}/preview`)
|
||||||
root!.replaceChildren()
|
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')
|
const header = el('div', 'mg-header')
|
||||||
header.append(el('div', 'mg-title', 'Session Manager'))
|
header.append(el('div', 'mg-title', 'Session Manager'))
|
||||||
const sub = el('div', 'mg-sub', `${sessions.length} session(s) running on the host`)
|
countEl = el('div', 'mg-sub', '')
|
||||||
header.append(sub)
|
header.append(countEl)
|
||||||
|
|
||||||
const bar = el('div', 'mg-bar')
|
const bar = el('div', 'mg-bar')
|
||||||
const back = el('a', 'mg-btn', '← Back to terminal') as HTMLAnchorElement
|
const back = el('a', 'mg-btn', '← Back to terminal') as HTMLAnchorElement
|
||||||
@@ -127,18 +194,51 @@ async function render(): Promise<void> {
|
|||||||
killAll.addEventListener('click', () => void killBulk(false))
|
killAll.addEventListener('click', () => void killBulk(false))
|
||||||
bar.append(back, refresh, killDetached, killAll)
|
bar.append(back, refresh, killDetached, killAll)
|
||||||
header.append(bar)
|
header.append(bar)
|
||||||
root!.append(header)
|
|
||||||
|
|
||||||
if (sessions.length === 0) {
|
grid = el('div', 'mg-grid')
|
||||||
root!.append(el('div', 'mg-empty', 'No sessions running. Open the terminal to start one.'))
|
root!.append(header, grid)
|
||||||
return
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
const list = el('div', 'mg-list')
|
// Drop cards for sessions that are gone.
|
||||||
for (const s of sessions) list.append(row(s))
|
for (const [id, card] of cards) {
|
||||||
root!.append(list)
|
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()
|
void render()
|
||||||
setInterval(() => {
|
setInterval(() => void render(), REFRESH_MS)
|
||||||
if (!busy) void render()
|
// Re-scale thumbnails if the window resizes.
|
||||||
}, REFRESH_MS)
|
window.addEventListener('resize', () => {
|
||||||
|
for (const card of cards.values()) fitThumb(card)
|
||||||
|
})
|
||||||
|
|||||||
@@ -662,9 +662,12 @@ body {
|
|||||||
|
|
||||||
/* ── Session Manager page (manage.html) ──────────────────────────── */
|
/* ── Session Manager page (manage.html) ──────────────────────────── */
|
||||||
#manage-root {
|
#manage-root {
|
||||||
max-width: 760px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px 16px 60px;
|
padding: 24px 16px 60px;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-sizing: border-box;
|
||||||
}
|
}
|
||||||
.mg-header {
|
.mg-header {
|
||||||
margin-bottom: 18px;
|
margin-bottom: 18px;
|
||||||
@@ -708,32 +711,54 @@ body {
|
|||||||
border-color: rgba(255, 107, 107, 0.5);
|
border-color: rgba(255, 107, 107, 0.5);
|
||||||
color: var(--red);
|
color: var(--red);
|
||||||
}
|
}
|
||||||
.mg-list {
|
.mg-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(330px, 1fr));
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
.mg-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: 10px;
|
||||||
}
|
padding: 12px;
|
||||||
.mg-row {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 12px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
background: var(--surface-2);
|
background: var(--surface-2);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: 10px;
|
border-radius: 12px;
|
||||||
}
|
}
|
||||||
.mg-main {
|
.mg-card-head {
|
||||||
flex: 1 1 auto;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
.mg-top {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.mg-name {
|
.mg-name {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
/* Live preview thumbnail (scaled read-only xterm) */
|
||||||
|
.mg-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 90px;
|
||||||
|
background: #0e0f13;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.mg-thumb:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
.mg-thumb-inner {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
transform-origin: top left;
|
||||||
|
padding: 4px;
|
||||||
}
|
}
|
||||||
.mg-watch {
|
.mg-watch {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
@@ -759,18 +784,20 @@ body {
|
|||||||
}
|
}
|
||||||
.mg-meta {
|
.mg-meta {
|
||||||
color: var(--text-faint);
|
color: var(--text-faint);
|
||||||
font-size: 12px;
|
font-size: 11px;
|
||||||
margin-top: 3px;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
font-family: Menlo, Consolas, monospace;
|
font-family: Menlo, Consolas, monospace;
|
||||||
}
|
}
|
||||||
.mg-actions {
|
.mg-actions {
|
||||||
flex: none;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
|
.mg-actions .mg-open {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
.mg-open {
|
.mg-open {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ import { WS_OPEN } from './types.js'
|
|||||||
const DEFAULT_COLS = 80
|
const DEFAULT_COLS = 80
|
||||||
const DEFAULT_ROWS = 24
|
const DEFAULT_ROWS = 24
|
||||||
|
|
||||||
|
// How many bytes of recent scrollback the manage-page preview renders (enough to
|
||||||
|
// contain a full-screen TUI repaint, so the thumbnail shows the current screen).
|
||||||
|
const PREVIEW_BYTES = 24 * 1024
|
||||||
|
|
||||||
// H3: how long the server holds a PermissionRequest before falling back to
|
// H3: how long the server holds a PermissionRequest before falling back to
|
||||||
// Claude's own interactive prompt (so it never hangs if nobody responds).
|
// Claude's own interactive prompt (so it never hangs if nobody responds).
|
||||||
const PERM_TIMEOUT_MS = 5 * 60_000
|
const PERM_TIMEOUT_MS = 5 * 60_000
|
||||||
@@ -117,6 +121,22 @@ export function startServer(cfg: Config): { close(): Promise<void> } {
|
|||||||
res.json(manager.list())
|
res.json(manager.list())
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Preview (v0.4 manage page) — the tail of a session's scrollback so the grid
|
||||||
|
// can render a live read-only thumbnail of its current screen. No client/attach.
|
||||||
|
app.get('/live-sessions/:id/preview', (req, res) => {
|
||||||
|
const session = manager.get(req.params.id)
|
||||||
|
if (session === undefined) {
|
||||||
|
res.status(404).end()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
res.json({
|
||||||
|
id: session.meta.id,
|
||||||
|
cols: session.pty.cols,
|
||||||
|
rows: session.pty.rows,
|
||||||
|
data: session.buffer.tail(PREVIEW_BYTES),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
|
// Kill ALL sessions, or only detached ones (?detached=1) — manage page bulk action.
|
||||||
app.delete('/live-sessions', (req, res) => {
|
app.delete('/live-sessions', (req, res) => {
|
||||||
const onlyDetached = req.query['detached'] === '1'
|
const onlyDetached = req.query['detached'] === '1'
|
||||||
|
|||||||
@@ -61,5 +61,20 @@ export function createRingBuffer(maxBytes: number): RingBuffer {
|
|||||||
return SOFT_RESET + chunks.map((c) => c.text).join('');
|
return SOFT_RESET + chunks.map((c) => c.text).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
return { append, snapshot };
|
function tail(maxBytes: number): string {
|
||||||
|
if (maxBytes <= 0) return '';
|
||||||
|
// Collect whole chunks from the newest end until we have ~maxBytes — never
|
||||||
|
// split a chunk (so no ANSI/UTF-8 sequence is cut, same rationale as M2).
|
||||||
|
const picked: string[] = [];
|
||||||
|
let acc = 0;
|
||||||
|
for (let i = chunks.length - 1; i >= 0; i -= 1) {
|
||||||
|
const c = chunks[i]!;
|
||||||
|
picked.push(c.text);
|
||||||
|
acc += c.bytes;
|
||||||
|
if (acc >= maxBytes) break;
|
||||||
|
}
|
||||||
|
return picked.reverse().join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return { append, snapshot, tail };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -113,6 +113,9 @@ export interface RingBuffer {
|
|||||||
append(chunk: string): void;
|
append(chunk: string): void;
|
||||||
/** full buffer for replay; prepend `\x1b[0m` soft-reset as a safety net (M2). */
|
/** full buffer for replay; prepend `\x1b[0m` soft-reset as a safety net (M2). */
|
||||||
snapshot(): string;
|
snapshot(): string;
|
||||||
|
/** last ~maxBytes of recent output, on whole-chunk boundaries (never splits an
|
||||||
|
* ANSI/UTF-8 sequence), for a read-only session preview. No soft-reset prefix. */
|
||||||
|
tail(maxBytes: number): string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// impl anchor [src/session/ring-buffer.ts]:
|
// impl anchor [src/session/ring-buffer.ts]:
|
||||||
|
|||||||
@@ -35,6 +35,31 @@ describe('createRingBuffer', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('tail()', () => {
|
||||||
|
it('returns empty for an empty buffer or non-positive maxBytes', () => {
|
||||||
|
const rb = createRingBuffer(1024);
|
||||||
|
expect(rb.tail(100)).toBe('');
|
||||||
|
rb.append('hello');
|
||||||
|
expect(rb.tail(0)).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the most recent chunks up to ~maxBytes, on chunk boundaries', () => {
|
||||||
|
const rb = createRingBuffer(1024);
|
||||||
|
rb.append('aaaa'); // 4 bytes
|
||||||
|
rb.append('bbbb'); // 4 bytes
|
||||||
|
rb.append('cccc'); // 4 bytes
|
||||||
|
// maxBytes=5 → newest 'cccc' (4) then 'bbbb' (8 ≥ 5) → stop; never splits.
|
||||||
|
expect(rb.tail(5)).toBe('bbbbcccc');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the whole buffer when maxBytes exceeds its size (no soft-reset)', () => {
|
||||||
|
const rb = createRingBuffer(1024);
|
||||||
|
rb.append('one');
|
||||||
|
rb.append('two');
|
||||||
|
expect(rb.tail(1000)).toBe('onetwo');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('within capacity', () => {
|
describe('within capacity', () => {
|
||||||
it('replays everything appended, in order', () => {
|
it('replays everything appended, in order', () => {
|
||||||
const rb = createRingBuffer(1024);
|
const rb = createRingBuffer(1024);
|
||||||
|
|||||||
Reference in New Issue
Block a user