feat: Web Terminal (xterm.js + PTY over WebSocket) + SSH-Copy-Karte im Tunnel-Tab
This commit is contained in:
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
||||
import api from '../api/client'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import StatusBadge from './StatusBadge'
|
||||
import TerminalModal from './TerminalModal'
|
||||
import {
|
||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
||||
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
|
||||
@@ -678,6 +679,8 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
||||
const [customHost, setCustomHost] = useState('127.0.0.1')
|
||||
const [customPort, setCustomPort] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||
const [copiedCmd, setCopiedCmd] = useState(false)
|
||||
|
||||
const backendHost = useSettingsStore.getState().getBackendHost()
|
||||
|
||||
@@ -736,9 +739,24 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
||||
setCustomPort('')
|
||||
}
|
||||
|
||||
const sshTunnel = tunnels.find(t => t.target_port === 22)
|
||||
const sshCmd = sshTunnel ? `ssh root@${backendHost} -p ${sshTunnel.proxy_port}` : null
|
||||
|
||||
const copySSHCmd = () => {
|
||||
if (sshCmd) {
|
||||
navigator.clipboard.writeText(sshCmd)
|
||||
setCopiedCmd(true)
|
||||
setTimeout(() => setCopiedCmd(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
{terminalOpen && (
|
||||
<TerminalModal agentId={agentId} agentName={agent?.name} onClose={() => setTerminalOpen(false)} />
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<span className="text-sm text-gray-400">Schnellzugriff:</span>
|
||||
<button
|
||||
onClick={() => openTunnel('127.0.0.1', webguiPort, 'webgui')}
|
||||
@@ -754,7 +772,14 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-gray-700 hover:bg-gray-600 disabled:opacity-50 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
{creating === 'ssh' ? 'Verbinde...' : 'SSH (22)'}
|
||||
{creating === 'ssh' ? 'Verbinde...' : 'SSH Tunnel'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTerminalOpen(true)}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-green-700 hover:bg-green-600 rounded text-sm text-white transition-colors"
|
||||
>
|
||||
<Terminal className="w-4 h-4" />
|
||||
Web Terminal
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setCustomOpen(!customOpen)}
|
||||
@@ -764,6 +789,24 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* SSH-Copy-Karte wenn SSH-Tunnel aktiv */}
|
||||
{sshTunnel && (
|
||||
<div className="flex items-center gap-3 bg-gray-900/60 border border-green-700/40 rounded-lg px-4 py-3">
|
||||
<Terminal className="w-4 h-4 text-green-400 shrink-0" />
|
||||
<code className="flex-1 text-sm font-mono text-green-300">{sshCmd}</code>
|
||||
<button
|
||||
onClick={copySSHCmd}
|
||||
className={`shrink-0 flex items-center gap-1.5 px-2.5 py-1 rounded text-xs font-medium transition-all ${
|
||||
copiedCmd
|
||||
? 'bg-green-700 text-white'
|
||||
: 'bg-gray-700 hover:bg-gray-600 text-gray-300'
|
||||
}`}
|
||||
>
|
||||
{copiedCmd ? '✓ Kopiert' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{customOpen && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<input type="text" value={customHost} onChange={(e) => setCustomHost(e.target.value)}
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { useSettingsStore } from '../stores/settings'
|
||||
import { API_KEY } from '../config'
|
||||
|
||||
export default function TerminalModal({ agentId, agentName, onClose }) {
|
||||
const termRef = useRef(null)
|
||||
const xtermRef = useRef(null)
|
||||
const fitRef = useRef(null)
|
||||
const wsRef = useRef(null)
|
||||
const [status, setStatus] = useState('connecting') // connecting | open | error | closed
|
||||
|
||||
const backendHost = useSettingsStore.getState().getBackendHost()
|
||||
const apiKey = API_KEY
|
||||
|
||||
useEffect(() => {
|
||||
if (!termRef.current) return
|
||||
|
||||
// xterm initialisieren
|
||||
const term = new Terminal({
|
||||
theme: {
|
||||
background: '#0d1117',
|
||||
foreground: '#e6edf3',
|
||||
cursor: '#58a6ff',
|
||||
selectionBackground: '#264f78',
|
||||
black: '#0d1117',
|
||||
red: '#ff7b72',
|
||||
green: '#3fb950',
|
||||
yellow: '#d29922',
|
||||
blue: '#58a6ff',
|
||||
magenta: '#bc8cff',
|
||||
cyan: '#39c5cf',
|
||||
white: '#e6edf3',
|
||||
},
|
||||
fontFamily: '"Cascadia Code", "JetBrains Mono", "Fira Code", monospace',
|
||||
fontSize: 13,
|
||||
lineHeight: 1.3,
|
||||
cursorBlink: true,
|
||||
cursorStyle: 'block',
|
||||
scrollback: 5000,
|
||||
allowProposedApi: true,
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(termRef.current)
|
||||
|
||||
setTimeout(() => {
|
||||
fitAddon.fit()
|
||||
}, 50)
|
||||
|
||||
xtermRef.current = term
|
||||
fitRef.current = fitAddon
|
||||
|
||||
// WebSocket verbinden
|
||||
const cols = term.cols || 220
|
||||
const rows = term.rows || 50
|
||||
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'wss:'
|
||||
const wsUrl = `wss://${backendHost}:8443/api/v1/agents/${agentId}/terminal?cols=${cols}&rows=${rows}&api_key=${apiKey}`
|
||||
|
||||
const ws = new WebSocket(wsUrl)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setStatus('open')
|
||||
term.write('\r\n\x1b[32m Verbunden mit ' + (agentName || agentId) + '\x1b[0m\r\n\r\n')
|
||||
}
|
||||
|
||||
ws.onmessage = (e) => {
|
||||
if (e.data instanceof ArrayBuffer) {
|
||||
term.write(new Uint8Array(e.data))
|
||||
} else if (typeof e.data === 'string') {
|
||||
term.write(e.data)
|
||||
}
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
setStatus('error')
|
||||
term.write('\r\n\x1b[31mVerbindungsfehler\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
ws.onclose = () => {
|
||||
setStatus('closed')
|
||||
term.write('\r\n\x1b[33mVerbindung getrennt\x1b[0m\r\n')
|
||||
}
|
||||
|
||||
// Tastatureingaben -> WebSocket
|
||||
term.onData((data) => {
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
const encoder = new TextEncoder()
|
||||
ws.send(encoder.encode(data))
|
||||
}
|
||||
})
|
||||
|
||||
// Resize
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
try {
|
||||
fitAddon.fit()
|
||||
if (ws.readyState === WebSocket.OPEN) {
|
||||
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
|
||||
}
|
||||
} catch (_) {}
|
||||
})
|
||||
resizeObserver.observe(termRef.current)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
ws.close()
|
||||
term.dispose()
|
||||
}
|
||||
}, [agentId])
|
||||
|
||||
const statusColor = {
|
||||
connecting: 'text-yellow-400',
|
||||
open: 'text-green-400',
|
||||
error: 'text-red-400',
|
||||
closed: 'text-gray-500',
|
||||
}[status]
|
||||
|
||||
const statusLabel = {
|
||||
connecting: 'Verbinde...',
|
||||
open: 'Verbunden',
|
||||
error: 'Fehler',
|
||||
closed: 'Getrennt',
|
||||
}[status]
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm">
|
||||
<div className="w-[90vw] max-w-6xl bg-[#0d1117] rounded-xl border border-gray-700/60 shadow-2xl flex flex-col"
|
||||
style={{ height: '80vh' }}>
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 border-b border-gray-700/60 bg-[#161b22] rounded-t-xl">
|
||||
<div className="flex gap-1.5">
|
||||
<button onClick={onClose}
|
||||
className="w-3 h-3 rounded-full bg-red-500 hover:bg-red-400 transition-colors flex items-center justify-center group">
|
||||
<span className="text-[8px] text-red-900 opacity-0 group-hover:opacity-100 font-bold">✕</span>
|
||||
</button>
|
||||
<div className="w-3 h-3 rounded-full bg-yellow-500/40" />
|
||||
<div className="w-3 h-3 rounded-full bg-green-500/40" />
|
||||
</div>
|
||||
|
||||
<div className="flex-1 text-center">
|
||||
<span className="text-sm font-mono text-gray-300">
|
||||
root@{agentName || agentId}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`text-xs ${statusColor}`}>● {statusLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Terminal */}
|
||||
<div ref={termRef} className="flex-1 p-2 min-h-0" style={{ overflow: 'hidden' }} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user