diff --git a/docs/features/host-console.mdx b/docs/features/host-console.mdx index 943d22b1..3ea41009 100644 --- a/docs/features/host-console.mdx +++ b/docs/features/host-console.mdx @@ -27,15 +27,24 @@ The Host Console gives you a real terminal session on the Sencho host, streamed Click **Console** in the top navigation bar. The session starts immediately in the `COMPOSE_DIR` root. If a stack is selected in the sidebar, the terminal opens directly inside that stack's directory instead. -The header bar shows: +## Cockpit layout -- **Host Console** label with a terminal icon -- The active **node name** (e.g. "Local" or a remote node) -- The **stack name** in parentheses, if one is selected -- A green **Connected** badge while the WebSocket session is active -- A **Close Console** button on the right to end the session +The Console page is a vertical stack of two surfaces: -Clicking **Close Console** terminates the shell process on the host and disconnects the WebSocket. + + Masthead with cyan rail, Connected state, and shell, viewport, and session uptime metadata + + +| Surface | What it shows | +|---------|---------------| +| **Masthead** | Connection state (`Connected`, `Reconnecting`, or `Disconnected`) with a live status dot that pulses cyan while the session is active. The kicker reads `HOST CONSOLE · {node}`. When the console was opened from a stack, a small `← {stack-name}` link sits beside the state word so you can return to the stack editor. | +| **Metadata** | Right side of the masthead: the active **shell** (e.g. `BASH`), the current **viewport** (`{cols}×{rows}` as reported by the fit addon), and **session uptime** since the shell opened. | +| **Terminal well** | The interactive xterm session, sized to fill the remaining height. | +| **Chip strip** | Floating bottom-right controls: **Copy** (copies the current selection), **Clear** (wipes the visible scrollback without killing the shell), **Download** (exports the full scrollback as a timestamped `.txt`), and **Reconnect** (closes and reopens the WebSocket without leaving the page). | + + + Floating chip strip with Copy, Clear, Download, and Reconnect buttons + ## Shell type diff --git a/docs/images/host-console/host-console-chip-strip.png b/docs/images/host-console/host-console-chip-strip.png new file mode 100644 index 00000000..2cd159bc Binary files /dev/null and b/docs/images/host-console/host-console-chip-strip.png differ diff --git a/docs/images/host-console/host-console-masthead.png b/docs/images/host-console/host-console-masthead.png new file mode 100644 index 00000000..d82af41b Binary files /dev/null and b/docs/images/host-console/host-console-masthead.png differ diff --git a/docs/images/host-console/host-console-overview.png b/docs/images/host-console/host-console-overview.png index 2c2742f0..9cef0ca9 100644 Binary files a/docs/images/host-console/host-console-overview.png and b/docs/images/host-console/host-console-overview.png differ diff --git a/frontend/src/components/HostConsole.tsx b/frontend/src/components/HostConsole.tsx index a94ff01a..6f8e93d7 100644 --- a/frontend/src/components/HostConsole.tsx +++ b/frontend/src/components/HostConsole.tsx @@ -1,8 +1,10 @@ import { useEffect, useRef, useState, useCallback } from 'react'; import { Terminal } from '@xterm/xterm'; import { FitAddon } from '@xterm/addon-fit'; -import { Terminal as TerminalIcon, X } from 'lucide-react'; +import { SerializeAddon } from '@xterm/addon-serialize'; +import { ArrowLeft, Copy, Trash2, Download, RefreshCw } from 'lucide-react'; import { Button } from './ui/button'; +import { PageMasthead, type MastheadTone } from './ui/PageMasthead'; import '@xterm/xterm/css/xterm.css'; import { useNodes } from '@/context/NodeContext'; @@ -11,6 +13,9 @@ interface HostConsoleProps { onClose: () => void; } +// Window considered "live" for the masthead pulsing dot. +const LIVE_WINDOW_MS = 5_000; + /** Build the xterm theme from CSS custom properties (resolved once per call). */ function getTerminalTheme() { const s = getComputedStyle(document.documentElement); @@ -23,25 +28,41 @@ function getTerminalTheme() { }; } +function formatUptime(ms: number): string { + const totalSeconds = Math.max(0, Math.floor(ms / 1000)); + const h = Math.floor(totalSeconds / 3600); + const m = Math.floor((totalSeconds % 3600) / 60); + const s = totalSeconds % 60; + if (h > 0) return `${h}H ${m.toString().padStart(2, '0')}M`; + return `${m}:${s.toString().padStart(2, '0')} UP`; +} + +type ConnState = 'reconnecting' | 'connected' | 'disconnected'; + export default function HostConsole({ stackName, onClose }: HostConsoleProps) { const { activeNode } = useNodes(); const terminalRef = useRef(null); const xtermRef = useRef(null); const fitAddonRef = useRef(null); + const serializeRef = useRef(null); const wsRef = useRef(null); - const [isConnected, setIsConnected] = useState(false); - const cleanup = useCallback(() => { - if (wsRef.current) { - wsRef.current.close(); - wsRef.current = null; - } - if (xtermRef.current) { - xtermRef.current.dispose(); - xtermRef.current = null; - } - fitAddonRef.current = null; - setIsConnected(false); + const [connState, setConnState] = useState('reconnecting'); + const [lastActivityAt, setLastActivityAt] = useState(null); + const [dims, setDims] = useState<{ cols: number; rows: number }>({ cols: 0, rows: 0 }); + const [mountedAt, setMountedAt] = useState(null); + const [tick, setTick] = useState(0); + const [reconnectNonce, setReconnectNonce] = useState(0); + + useEffect(() => { + const run = () => { + const now = Date.now(); + setTick(now); + setMountedAt(prev => prev ?? now); + }; + const init = setTimeout(run, 0); + const id = setInterval(run, 1000); + return () => { clearTimeout(init); clearInterval(id); }; }, []); useEffect(() => { @@ -58,15 +79,21 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { }); const fitAddon = new FitAddon(); + const serializeAddon = new SerializeAddon(); term.loadAddon(fitAddon); + term.loadAddon(serializeAddon); term.open(container); xtermRef.current = term; fitAddonRef.current = fitAddon; + serializeRef.current = serializeAddon; requestAnimationFrame(() => { try { - if (mounted) fitAddon.fit(); + if (mounted) { + fitAddon.fit(); + setDims({ cols: term.cols, rows: term.rows }); + } } catch { // Ignore fit errors during initial render } @@ -83,12 +110,16 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { ws.onopen = () => { if (!mounted) return; - setIsConnected(true); + setConnState('connected'); + setLastActivityAt(Date.now()); term.focus(); setTimeout(() => { try { - if (mounted) fitAddon.fit(); + if (mounted) { + fitAddon.fit(); + setDims({ cols: term.cols, rows: term.rows }); + } } catch { // Ignore } @@ -106,18 +137,19 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { if (!mounted) return; const text = typeof event.data === 'string' ? event.data : event.data.toString(); term.write(text); + setLastActivityAt(Date.now()); }; ws.onerror = () => { if (!mounted) return; term.write('\r\n\x1b[31mConnection error\x1b[0m\r\n'); - setIsConnected(false); + setConnState('disconnected'); }; ws.onclose = () => { if (!mounted) return; term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); - setIsConnected(false); + setConnState('disconnected'); }; term.onData((data) => { @@ -136,6 +168,7 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { if (!mounted || !fitAddonRef.current || !wsRef.current) return; try { fitAddonRef.current.fit(); + setDims({ cols: term.cols, rows: term.rows }); } catch { return; } @@ -155,42 +188,143 @@ export default function HostConsole({ stackName, onClose }: HostConsoleProps) { mounted = false; resizeObserver.disconnect(); clearTimeout(resizeTimeout); - cleanup(); + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + if (xtermRef.current) { + xtermRef.current.dispose(); + xtermRef.current = null; + } + fitAddonRef.current = null; + serializeRef.current = null; }; - }, [stackName, cleanup]); + }, [stackName, reconnectNonce]); + + const handleCopy = useCallback(() => { + const term = xtermRef.current; + if (!term) return; + const selection = term.getSelection(); + if (!selection) return; + navigator.clipboard?.writeText(selection).catch(() => { /* ignore */ }); + }, []); + + const handleClear = useCallback(() => { + xtermRef.current?.clear(); + }, []); + + const handleDownload = useCallback(() => { + const content = serializeRef.current?.serialize(); + if (!content) return; + const blob = new Blob([content], { type: 'text/plain;charset=utf-8' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = `sencho-console-${new Date().toISOString().replace(/[:.]/g, '-')}.txt`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + }, []); + + const handleReconnect = useCallback(() => { + setConnState('reconnecting'); + setReconnectNonce(n => n + 1); + }, []); + + const isLive = connState === 'connected' && lastActivityAt != null && (tick - lastActivityAt) < LIVE_WINDOW_MS; + const tone: MastheadTone = connState === 'disconnected' + ? 'error' + : connState === 'reconnecting' + ? 'warn' + : isLive ? 'live' : 'idle'; + const stateWord = connState === 'disconnected' + ? 'Disconnected' + : connState === 'reconnecting' ? 'Reconnecting' : 'Connected'; + const nodeLabel = activeNode ? (activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase()) : 'LOCAL'; + const kicker = `HOST CONSOLE · ${nodeLabel}`; + + const uptime = mountedAt != null ? formatUptime(tick - mountedAt) : '—'; + const viewport = dims.cols > 0 && dims.rows > 0 ? `${dims.cols}×${dims.rows}` : '—'; + const metadata = [ + { label: 'SHELL', value: 'BASH', tone: 'subtitle' as const }, + { label: 'VIEWPORT', value: viewport, tone: 'subtitle' as const }, + { label: 'SESSION', value: uptime, tone: 'subtitle' as const }, + ]; return ( -
-
-
- - Host Console - {activeNode && ( - - - {activeNode.name} - - )} - {stackName && ( - - ({stackName}) - - )} - {isConnected && ( - - Connected - - )} -
- -
+
+ + {stackName ? ( + + ) : null} + +
+ +
+
+ +
+ + +
+ +
+
);