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