import { useEffect, useRef, useState, useCallback } from 'react'; import { ArrowLeft, Copy, Trash2, Download, RefreshCw } from 'lucide-react'; import { Button } from './ui/button'; import { PageMasthead, type MastheadTone } from './ui/PageMasthead'; import { loadXtermModules, type Terminal, type FitAddon, type SerializeAddon } from '@/lib/xtermLoader'; import { buildXtermMinimalTheme } from '@/lib/terminalTheme'; import { useNodes } from '@/context/NodeContext'; import { copyToClipboard } from '@/lib/clipboard'; interface HostConsoleProps { /** Resolved active node id; WebSocket must target this id, not localStorage. */ nodeId: number; stackName?: string | null; onClose: () => void; } // Window considered "live" for the masthead pulsing dot. const LIVE_WINDOW_MS = 5_000; 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({ nodeId, 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 [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(() => { const container = terminalRef.current; if (!container) return; let mounted = true; let resizeObserver: ResizeObserver | null = null; let resizeTimeout: ReturnType | undefined; void loadXtermModules().then((mods) => { if (!mounted) return; const term = new mods.Terminal({ theme: buildXtermMinimalTheme(), fontFamily: "'Geist Mono', monospace", fontSize: 14, cursorBlink: true, }); const fitAddon = new mods.FitAddon(); const serializeAddon = new mods.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(); setDims({ cols: term.cols, rows: term.rows }); } } catch { // Ignore fit errors during initial render } }); const wsProtocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const qs = stackName ? `nodeId=${nodeId}&stack=${encodeURIComponent(stackName)}` : `nodeId=${nodeId}`; const ws = new WebSocket( `${wsProtocol}//${window.location.host}/api/system/host-console?${qs}`, ); wsRef.current = ws; ws.onopen = () => { if (!mounted) return; setConnState('connected'); setLastActivityAt(Date.now()); term.focus(); setTimeout(() => { try { if (mounted) { fitAddon.fit(); setDims({ cols: term.cols, rows: term.rows }); } } catch { // Ignore } if (ws.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, })); } }, 100); }; ws.onmessage = (event) => { 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'); setConnState('disconnected'); }; ws.onclose = () => { if (!mounted) return; term.write('\r\n\x1b[33mSession ended\x1b[0m\r\n'); setConnState('disconnected'); }; term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'input', payload: data, })); } }); resizeObserver = new ResizeObserver(() => { clearTimeout(resizeTimeout); resizeTimeout = setTimeout(() => { if (!mounted || !fitAddonRef.current || !wsRef.current) return; try { fitAddonRef.current.fit(); setDims({ cols: term.cols, rows: term.rows }); } catch { return; } if (wsRef.current.readyState === WebSocket.OPEN && term.rows > 0 && term.cols > 0) { wsRef.current.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, })); } }, 50); }); resizeObserver.observe(container); }).catch((err) => { console.error('HostConsole: failed to load xterm:', err); }); return () => { mounted = false; if (resizeObserver) resizeObserver.disconnect(); if (resizeTimeout) clearTimeout(resizeTimeout); if (wsRef.current) { wsRef.current.close(); wsRef.current = null; } if (xtermRef.current) { xtermRef.current.dispose(); xtermRef.current = null; } fitAddonRef.current = null; serializeRef.current = null; }; }, [nodeId, stackName, reconnectNonce]); const handleCopy = useCallback(() => { const term = xtermRef.current; if (!term) return; const selection = term.getSelection(); if (!selection) return; void copyToClipboard(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'; let nodeLabel = `NODE ${nodeId}`; if (activeNode?.id === nodeId) { nodeLabel = activeNode.type === 'local' ? 'LOCAL' : activeNode.name.toUpperCase(); } 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 (
{stackName ? ( ) : null}
); }