feat: Web Terminal (xterm.js + PTY over WebSocket) + SSH-Copy-Karte im Tunnel-Tab

This commit is contained in:
cynfo3000
2026-03-07 00:20:03 +01:00
parent 3c83cf11de
commit 67a337ed9f
13 changed files with 731 additions and 41 deletions
+45 -2
View File
@@ -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)}
+162
View File
@@ -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>
)
}