# Consolas Web con xterm.js ## Implementación del Terminal Web ### WebTerminal Component ```typescript // components/terminal/WebTerminal.tsx import { useEffect, useRef, useState } from 'react' import { Terminal } from 'xterm' import { FitAddon } from 'xterm-addon-fit' import { WebLinksAddon } from 'xterm-addon-web-links' import { SearchAddon } from 'xterm-addon-search' import 'xterm/css/xterm.css' interface WebTerminalProps { agentId: string podName: string namespace?: string } export function WebTerminal({ agentId, podName, namespace = 'agents' }: WebTerminalProps) { const terminalRef = useRef(null) const xtermRef = useRef() const fitAddonRef = useRef() const wsRef = useRef() const [isConnected, setIsConnected] = useState(false) const [error, setError] = useState(null) useEffect(() => { if (!terminalRef.current) return // Create terminal instance const term = new Terminal({ cursorBlink: true, fontSize: 14, fontFamily: 'Menlo, Monaco, "Courier New", monospace', lineHeight: 1.2, theme: { background: '#1e1e1e', foreground: '#d4d4d4', cursor: '#ffffff', selection: '#264f78', black: '#000000', red: '#cd3131', green: '#0dbc79', yellow: '#e5e510', blue: '#2472c8', magenta: '#bc3fbc', cyan: '#11a8cd', white: '#e5e5e5', brightBlack: '#666666', brightRed: '#f14c4c', brightGreen: '#23d18b', brightYellow: '#f5f543', brightBlue: '#3b8eea', brightMagenta: '#d670d6', brightCyan: '#29b8db', brightWhite: '#ffffff', }, scrollback: 10000, tabStopWidth: 4, }) // Addons const fitAddon = new FitAddon() const webLinksAddon = new WebLinksAddon() const searchAddon = new SearchAddon() term.loadAddon(fitAddon) term.loadAddon(webLinksAddon) term.loadAddon(searchAddon) // Open terminal term.open(terminalRef.current) fitAddon.fit() // Store refs xtermRef.current = term fitAddonRef.current = fitAddon // Connect to backend WebSocket const wsUrl = `${import.meta.env.VITE_WS_URL || 'ws://localhost:3000'}/terminal/${agentId}` const ws = new WebSocket(wsUrl) wsRef.current = ws ws.onopen = () => { setIsConnected(true) setError(null) term.writeln(`\x1b[32m✓\x1b[0m Connected to ${podName}`) term.writeln('') } ws.onerror = (err) => { setError('Connection error') term.writeln(`\x1b[31m✗\x1b[0m Connection error`) } ws.onclose = () => { setIsConnected(false) term.writeln('') term.writeln(`\x1b[33m⚠\x1b[0m Disconnected from ${podName}`) } ws.onmessage = (event) => { term.write(event.data) } // Send input to backend term.onData((data) => { if (ws.readyState === WebSocket.OPEN) { ws.send(data) } }) // Handle terminal resize const handleResize = () => { fitAddon.fit() // Send resize info to backend if (ws.readyState === WebSocket.OPEN) { ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows, })) } } window.addEventListener('resize', handleResize) // Cleanup return () => { term.dispose() ws.close() window.removeEventListener('resize', handleResize) } }, [agentId, podName, namespace]) return (
{/* Header */}
{podName} ({namespace})
{error && ( {error} )}
{/* Terminal */}
) } ``` ### Terminal Tabs Manager ```typescript // components/terminal/TerminalTabs.tsx import { X } from 'lucide-react' import { useTerminalStore } from '@/store/terminalStore' import { WebTerminal } from './WebTerminal' export function TerminalTabs() { const { tabs, activeTabId, setActiveTab, closeTerminal } = useTerminalStore() if (tabs.length === 0) { return (

No hay terminales abiertas

) } return (
{/* Tabs */}
{tabs.map((tab) => (
setActiveTab(tab.id)} > {tab.podName}
))}
{/* Active terminal */}
{tabs.map((tab) => (
))}
) } ``` ### Terminal Page/View ```typescript // pages/TerminalsView.tsx import { TerminalTabs } from '@/components/terminal/TerminalTabs' import { useAgents } from '@/hooks/useAgents' import { useTerminalStore } from '@/store/terminalStore' import { Plus } from 'lucide-react' export default function TerminalsView() { const { data: agents = [] } = useAgents() const { openTerminal } = useTerminalStore() return (
{/* Sidebar with agents */}

Agentes Disponibles

{agents.map((agent) => ( ))}
{/* Terminals */}
) } ``` ## Backend WebSocket Handler ```typescript // backend: api/websocket/terminal.ts import { Server as SocketIOServer, Socket } from 'socket.io' import { K8sClient } from '../../services/kubernetes/client' import { logger } from '../../utils/logger' const k8sClient = new K8sClient() export function setupTerminalWebSocket(io: SocketIOServer) { io.of('/terminal').on('connection', async (socket: Socket) => { const agentId = socket.handshake.query.agentId as string if (!agentId) { socket.disconnect() return } logger.info(`Terminal connection: agent ${agentId}`) try { // Get agent pod info const agent = await db.query.agents.findFirst({ where: eq(agents.id, agentId), }) if (!agent) { socket.emit('error', { message: 'Agent not found' }) socket.disconnect() return } // Connect to K8s pod exec const stream = await k8sClient.execInPod({ namespace: agent.k8sNamespace, podName: agent.podName, command: ['/bin/bash'], }) // Forward data from K8s to client stream.stdout.on('data', (data: Buffer) => { socket.emit('data', data.toString()) }) stream.stderr.on('data', (data: Buffer) => { socket.emit('data', data.toString()) }) // Forward data from client to K8s socket.on('data', (data: string) => { stream.stdin.write(data) }) // Handle resize socket.on('resize', ({ cols, rows }: { cols: number; rows: number }) => { stream.resize({ cols, rows }) }) // Cleanup on disconnect socket.on('disconnect', () => { logger.info(`Terminal disconnected: agent ${agentId}`) stream.stdin.end() stream.destroy() }) } catch (error) { logger.error('Terminal connection error:', error) socket.emit('error', { message: 'Failed to connect to pod' }) socket.disconnect() } }) } ``` ## Features Adicionales ### Copy/Paste ```typescript // En WebTerminal component term.attachCustomKeyEventHandler((e) => { // Ctrl+C / Cmd+C if ((e.ctrlKey || e.metaKey) && e.key === 'c') { const selection = term.getSelection() if (selection) { navigator.clipboard.writeText(selection) return false } } // Ctrl+V / Cmd+V if ((e.ctrlKey || e.metaKey) && e.key === 'v') { navigator.clipboard.readText().then((text) => { if (ws.readyState === WebSocket.OPEN) { ws.send(text) } }) return false } return true }) ``` ### Clear Terminal ```typescript ``` ### Download Log ```typescript const downloadLog = () => { if (!xtermRef.current) return const buffer = xtermRef.current.buffer.active let content = '' for (let i = 0; i < buffer.length; i++) { const line = buffer.getLine(i) if (line) { content += line.translateToString(true) + '\n' } } const blob = new Blob([content], { type: 'text/plain' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') a.href = url a.download = `${podName}-${Date.now()}.log` a.click() URL.revokeObjectURL(url) } ```