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>
This commit is contained in:
422
docs/03-frontend/consolas-web.md
Normal file
422
docs/03-frontend/consolas-web.md
Normal file
@@ -0,0 +1,422 @@
|
||||
# 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)
|
||||
}
|
||||
```
|
||||
Reference in New Issue
Block a user