Files
aiworker/docs/03-frontend/consolas-web.md
Hector Ros db71705842 Complete documentation for future sessions
- CLAUDE.md for AI agents to understand the codebase
- GITEA-GUIDE.md centralizes all Gitea operations (API, Registry, Auth)
- DEVELOPMENT-WORKFLOW.md explains complete dev process
- ROADMAP.md, NEXT-SESSION.md for planning
- QUICK-REFERENCE.md, TROUBLESHOOTING.md for daily use
- 40+ detailed docs in /docs folder
- Backend as submodule from Gitea

Everything documented for autonomous operation.

Co-Authored-By: Claude Sonnet 4.5 (1M context) <noreply@anthropic.com>
2026-01-20 00:37:19 +01:00

11 KiB

Consolas Web con xterm.js

Implementación del Terminal Web

WebTerminal Component

// 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<HTMLDivElement>(null)
  const xtermRef = useRef<Terminal>()
  const fitAddonRef = useRef<FitAddon>()
  const wsRef = useRef<WebSocket>()
  const [isConnected, setIsConnected] = useState(false)
  const [error, setError] = useState<string | null>(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 (
    <div className="flex flex-col h-full">
      {/* Header */}
      <div className="bg-gray-800 text-white px-4 py-2 flex items-center justify-between">
        <div className="flex items-center gap-3">
          <div className={`w-2 h-2 rounded-full ${isConnected ? 'bg-green-400' : 'bg-red-400'}`} />
          <span className="font-mono text-sm">{podName}</span>
          <span className="text-gray-400 text-xs">({namespace})</span>
        </div>

        {error && (
          <span className="text-red-400 text-xs">{error}</span>
        )}
      </div>

      {/* Terminal */}
      <div className="flex-1 bg-[#1e1e1e] overflow-hidden">
        <div ref={terminalRef} className="h-full w-full p-2" />
      </div>
    </div>
  )
}

Terminal Tabs Manager

// 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 (
      <div className="h-full flex items-center justify-center text-gray-500">
        <p>No hay terminales abiertas</p>
      </div>
    )
  }

  return (
    <div className="flex flex-col h-full">
      {/* Tabs */}
      <div className="flex items-center bg-gray-800 border-b border-gray-700 overflow-x-auto">
        {tabs.map((tab) => (
          <div
            key={tab.id}
            className={`
              flex items-center gap-2 px-4 py-2 cursor-pointer
              ${tab.isActive ? 'bg-gray-700 text-white' : 'text-gray-400 hover:text-white'}
              border-r border-gray-700
            `}
            onClick={() => setActiveTab(tab.id)}
          >
            <span className="font-mono text-sm truncate max-w-[150px]">
              {tab.podName}
            </span>
            <button
              onClick={(e) => {
                e.stopPropagation()
                closeTerminal(tab.id)
              }}
              className="hover:text-red-400"
            >
              <X className="w-4 h-4" />
            </button>
          </div>
        ))}
      </div>

      {/* Active terminal */}
      <div className="flex-1">
        {tabs.map((tab) => (
          <div
            key={tab.id}
            className={`h-full ${tab.isActive ? 'block' : 'hidden'}`}
          >
            <WebTerminal
              agentId={tab.agentId}
              podName={tab.podName}
            />
          </div>
        ))}
      </div>
    </div>
  )
}

Terminal Page/View

// 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 (
    <div className="flex h-screen">
      {/* Sidebar with agents */}
      <div className="w-64 bg-white border-r border-gray-200 overflow-y-auto">
        <div className="p-4">
          <h2 className="font-semibold text-gray-900 mb-4">Agentes Disponibles</h2>

          <div className="space-y-2">
            {agents.map((agent) => (
              <button
                key={agent.id}
                onClick={() => openTerminal(agent.id, agent.podName)}
                className="w-full text-left p-3 rounded-lg hover:bg-gray-100 transition-colors"
              >
                <div className="flex items-center justify-between">
                  <span className="font-mono text-sm truncate">{agent.podName}</span>
                  <div className={`w-2 h-2 rounded-full ${
                    agent.status === 'idle' ? 'bg-green-400' :
                    agent.status === 'busy' ? 'bg-blue-400' :
                    'bg-gray-400'
                  }`} />
                </div>
                <p className="text-xs text-gray-500 mt-1">{agent.status}</p>
              </button>
            ))}
          </div>
        </div>
      </div>

      {/* Terminals */}
      <div className="flex-1">
        <TerminalTabs />
      </div>
    </div>
  )
}

Backend WebSocket Handler

// 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

// 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

<button
  onClick={() => xtermRef.current?.clear()}
  className="btn-secondary"
>
  Clear
</button>

Download Log

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)
}