- 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>
423 lines
11 KiB
Markdown
423 lines
11 KiB
Markdown
# 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<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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 (
|
|
<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
|
|
|
|
```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
|
|
<button
|
|
onClick={() => xtermRef.current?.clear()}
|
|
className="btn-secondary"
|
|
>
|
|
Clear
|
|
</button>
|
|
```
|
|
|
|
### 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)
|
|
}
|
|
```
|