feat: Web Terminal (xterm.js + PTY over WebSocket) + SSH-Copy-Karte im Tunnel-Tab
This commit is contained in:
parent
3c83cf11de
commit
67a337ed9f
@ -6,3 +6,5 @@ require (
|
|||||||
github.com/gorilla/websocket v1.5.0
|
github.com/gorilla/websocket v1.5.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
|
require github.com/creack/pty v1.1.24 // indirect
|
||||||
|
|||||||
@ -1,3 +1,5 @@
|
|||||||
|
github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s=
|
||||||
|
github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE=
|
||||||
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
|
||||||
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||||
|
|||||||
BIN
agent-linux/rmm-agent-linux
Executable file
BIN
agent-linux/rmm-agent-linux
Executable file
Binary file not shown.
@ -31,6 +31,7 @@ type Client struct {
|
|||||||
|
|
||||||
messageHandler *Handler
|
messageHandler *Handler
|
||||||
tunnelManager *TunnelManager
|
tunnelManager *TunnelManager
|
||||||
|
ptyManager *PTYManager
|
||||||
}
|
}
|
||||||
|
|
||||||
type Message struct {
|
type Message struct {
|
||||||
@ -60,6 +61,7 @@ func NewClient(backendURL, agentID, apiKey string, insecure bool) *Client {
|
|||||||
|
|
||||||
client.messageHandler = NewHandler(client)
|
client.messageHandler = NewHandler(client)
|
||||||
client.tunnelManager = NewTunnelManager(client)
|
client.tunnelManager = NewTunnelManager(client)
|
||||||
|
client.ptyManager = NewPTYManager(client)
|
||||||
|
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
@ -250,6 +252,21 @@ func (c *Client) readMessages(conn *websocket.Conn, msgChan chan<- Message, errC
|
|||||||
}
|
}
|
||||||
|
|
||||||
if messageType == websocket.BinaryMessage {
|
if messageType == websocket.BinaryMessage {
|
||||||
|
// PTY-Messages: session_id beginnt mit "pty-"
|
||||||
|
// Format: [len (1 byte)][session_id][data]
|
||||||
|
if len(data) > 1 {
|
||||||
|
idLen := int(data[0])
|
||||||
|
if 1+idLen <= len(data) {
|
||||||
|
sessionID := string(data[1 : 1+idLen])
|
||||||
|
payload := data[1+idLen:]
|
||||||
|
if len(sessionID) >= 4 && sessionID[:4] == "pty-" {
|
||||||
|
if err := c.ptyManager.WriteToPTY(sessionID, payload); err != nil {
|
||||||
|
log.Printf("PTY-Write fehlgeschlagen: %v", err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := c.tunnelManager.ProcessBinaryMessage(data); err != nil {
|
if err := c.tunnelManager.ProcessBinaryMessage(data); err != nil {
|
||||||
log.Printf("Binary-Message verarbeiten fehlgeschlagen: %v", err)
|
log.Printf("Binary-Message verarbeiten fehlgeschlagen: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,14 @@ func (h *Handler) HandleCommand(msg Message) {
|
|||||||
return
|
return
|
||||||
case "agent_update":
|
case "agent_update":
|
||||||
response = h.handleAgentUpdate(msg)
|
response = h.handleAgentUpdate(msg)
|
||||||
|
case "pty_start":
|
||||||
|
response = h.handlePTYStart(msg)
|
||||||
|
case "pty_stop":
|
||||||
|
h.handlePTYStop(msg)
|
||||||
|
return
|
||||||
|
case "pty_resize":
|
||||||
|
h.handlePTYResize(msg)
|
||||||
|
return
|
||||||
default:
|
default:
|
||||||
response.Status = "error"
|
response.Status = "error"
|
||||||
response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command)
|
response.Error = fmt.Sprintf("Unbekanntes Command: %s", msg.Command)
|
||||||
@ -200,6 +208,60 @@ func (h *Handler) handleAgentUpdate(msg Message) Message {
|
|||||||
return response
|
return response
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- PTY Handlers ---
|
||||||
|
|
||||||
|
func (h *Handler) handlePTYStart(msg Message) Message {
|
||||||
|
response := Message{Type: "response", ID: msg.ID}
|
||||||
|
|
||||||
|
sessionID, _ := msg.Params["session_id"].(string)
|
||||||
|
if sessionID == "" {
|
||||||
|
response.Status = "error"
|
||||||
|
response.Error = "session_id erforderlich"
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
cols := uint16(80)
|
||||||
|
rows := uint16(24)
|
||||||
|
if c, ok := msg.Params["cols"].(float64); ok && c > 0 {
|
||||||
|
cols = uint16(c)
|
||||||
|
}
|
||||||
|
if r, ok := msg.Params["rows"].(float64); ok && r > 0 {
|
||||||
|
rows = uint16(r)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.client.ptyManager.StartPTY(sessionID, cols, rows); err != nil {
|
||||||
|
response.Status = "error"
|
||||||
|
response.Error = err.Error()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
response.Status = "ok"
|
||||||
|
response.Data = map[string]interface{}{"session_id": sessionID}
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handlePTYStop(msg Message) {
|
||||||
|
sessionID, _ := msg.Params["session_id"].(string)
|
||||||
|
if sessionID != "" {
|
||||||
|
h.client.ptyManager.StopPTY(sessionID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) handlePTYResize(msg Message) {
|
||||||
|
sessionID, _ := msg.Params["session_id"].(string)
|
||||||
|
cols := uint16(80)
|
||||||
|
rows := uint16(24)
|
||||||
|
if c, ok := msg.Params["cols"].(float64); ok && c > 0 {
|
||||||
|
cols = uint16(c)
|
||||||
|
}
|
||||||
|
if r, ok := msg.Params["rows"].(float64); ok && r > 0 {
|
||||||
|
rows = uint16(r)
|
||||||
|
}
|
||||||
|
if sessionID != "" {
|
||||||
|
h.client.ptyManager.ResizePTY(sessionID, cols, rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// --- Tunnel Handlers ---
|
// --- Tunnel Handlers ---
|
||||||
|
|
||||||
func (h *Handler) handleTunnelConnect(msg Message) Message {
|
func (h *Handler) handleTunnelConnect(msg Message) Message {
|
||||||
|
|||||||
170
agent-linux/ws/pty_manager.go
Normal file
170
agent-linux/ws/pty_manager.go
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
package ws
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"sync"
|
||||||
|
"syscall"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/creack/pty"
|
||||||
|
)
|
||||||
|
|
||||||
|
type PTYSession struct {
|
||||||
|
ID string
|
||||||
|
ptmx *os.File
|
||||||
|
cmd *exec.Cmd
|
||||||
|
done chan struct{}
|
||||||
|
}
|
||||||
|
|
||||||
|
type PTYManager struct {
|
||||||
|
sessions map[string]*PTYSession
|
||||||
|
mu sync.Mutex
|
||||||
|
client *Client
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewPTYManager(c *Client) *PTYManager {
|
||||||
|
return &PTYManager{
|
||||||
|
sessions: make(map[string]*PTYSession),
|
||||||
|
client: c,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartPTY startet eine neue PTY-Session mit /bin/bash
|
||||||
|
func (pm *PTYManager) StartPTY(sessionID string, cols, rows uint16) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
defer pm.mu.Unlock()
|
||||||
|
|
||||||
|
if _, exists := pm.sessions[sessionID]; exists {
|
||||||
|
return fmt.Errorf("PTY-Session %s existiert bereits", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmd := exec.Command("/bin/bash", "-l")
|
||||||
|
cmd.Env = append(os.Environ(),
|
||||||
|
"TERM=xterm-256color",
|
||||||
|
fmt.Sprintf("COLUMNS=%d", cols),
|
||||||
|
fmt.Sprintf("LINES=%d", rows),
|
||||||
|
)
|
||||||
|
|
||||||
|
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{Rows: rows, Cols: cols})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("PTY starten fehlgeschlagen: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
session := &PTYSession{
|
||||||
|
ID: sessionID,
|
||||||
|
ptmx: ptmx,
|
||||||
|
cmd: cmd,
|
||||||
|
done: make(chan struct{}),
|
||||||
|
}
|
||||||
|
|
||||||
|
pm.sessions[sessionID] = session
|
||||||
|
|
||||||
|
// PTY-Ausgabe lesen und an Backend senden
|
||||||
|
go pm.readPTYOutput(session)
|
||||||
|
|
||||||
|
log.Printf("PTY-Session gestartet: %s (%dx%d)", sessionID, cols, rows)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopPTY beendet eine PTY-Session
|
||||||
|
func (pm *PTYManager) StopPTY(sessionID string) {
|
||||||
|
pm.mu.Lock()
|
||||||
|
session, exists := pm.sessions[sessionID]
|
||||||
|
if exists {
|
||||||
|
delete(pm.sessions, sessionID)
|
||||||
|
}
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
close(session.done)
|
||||||
|
session.cmd.Process.Kill()
|
||||||
|
session.ptmx.Close()
|
||||||
|
log.Printf("PTY-Session beendet: %s", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteToPTY schreibt Daten in die PTY-Stdin
|
||||||
|
func (pm *PTYManager) WriteToPTY(sessionID string, data []byte) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
session, exists := pm.sessions[sessionID]
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("PTY-Session %s nicht gefunden", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := session.ptmx.Write(data)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ResizePTY aendert die PTY-Groesse
|
||||||
|
func (pm *PTYManager) ResizePTY(sessionID string, cols, rows uint16) error {
|
||||||
|
pm.mu.Lock()
|
||||||
|
session, exists := pm.sessions[sessionID]
|
||||||
|
pm.mu.Unlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return fmt.Errorf("PTY-Session %s nicht gefunden", sessionID)
|
||||||
|
}
|
||||||
|
|
||||||
|
ws := &struct {
|
||||||
|
Rows, Cols, X, Y uint16
|
||||||
|
}{Rows: rows, Cols: cols}
|
||||||
|
|
||||||
|
_, _, errno := syscall.Syscall(syscall.SYS_IOCTL,
|
||||||
|
session.ptmx.Fd(),
|
||||||
|
syscall.TIOCSWINSZ,
|
||||||
|
uintptr(unsafe.Pointer(ws)),
|
||||||
|
)
|
||||||
|
if errno != 0 {
|
||||||
|
return errno
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (pm *PTYManager) readPTYOutput(session *PTYSession) {
|
||||||
|
buf := make([]byte, 4096)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-session.done:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
n, err := session.ptmx.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
// PTY geschlossen — Session beenden und Backend informieren
|
||||||
|
pm.StopPTY(session.ID)
|
||||||
|
// Exit-Nachricht senden
|
||||||
|
msg := map[string]interface{}{
|
||||||
|
"type": "pty_exit",
|
||||||
|
"session_id": session.ID,
|
||||||
|
}
|
||||||
|
if data, err := json.Marshal(msg); err == nil {
|
||||||
|
pm.client.SendMessage(Message{
|
||||||
|
Type: "event",
|
||||||
|
Event: "pty_exit",
|
||||||
|
Params: map[string]interface{}{
|
||||||
|
"session_id": session.ID,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
_ = data
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if n > 0 {
|
||||||
|
// Daten als Binary-Message senden (session_id Prefix)
|
||||||
|
if err := pm.client.messageHandler.SendSessionData(session.ID, buf[:n]); err != nil {
|
||||||
|
log.Printf("PTY-Daten senden fehlgeschlagen: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
146
backend/api/terminal.go
Normal file
146
backend/api/terminal.go
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/cynfo/rmm-backend/ws"
|
||||||
|
"github.com/gorilla/websocket"
|
||||||
|
)
|
||||||
|
|
||||||
|
var termUpgrader = websocket.Upgrader{
|
||||||
|
ReadBufferSize: 4096,
|
||||||
|
WriteBufferSize: 4096,
|
||||||
|
CheckOrigin: func(r *http.Request) bool { return true },
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminalHandler bridget Browser-WebSocket mit Agent-PTY
|
||||||
|
func (h *Handler) terminalHandler(hub *ws.Hub) http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
agentID := r.PathValue("id")
|
||||||
|
if agentID == "" {
|
||||||
|
http.Error(w, "agent_id erforderlich", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cols/Rows aus Query
|
||||||
|
cols := uint16(220)
|
||||||
|
rows := uint16(50)
|
||||||
|
if c := r.URL.Query().Get("cols"); c != "" {
|
||||||
|
var v uint16
|
||||||
|
fmt.Sscanf(c, "%d", &v)
|
||||||
|
if v > 0 {
|
||||||
|
cols = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if r := r.URL.Query().Get("rows"); r != "" {
|
||||||
|
var v uint16
|
||||||
|
fmt.Sscanf(r, "%d", &v)
|
||||||
|
if v > 0 {
|
||||||
|
rows = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebSocket-Upgrade
|
||||||
|
browserConn, err := termUpgrader.Upgrade(w, r, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("Terminal WS Upgrade fehlgeschlagen: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer browserConn.Close()
|
||||||
|
|
||||||
|
// PTY-Session-ID generieren
|
||||||
|
sessionID := fmt.Sprintf("pty-%s-%d", agentID[:8], time.Now().UnixNano())
|
||||||
|
|
||||||
|
// Output-Channel registrieren
|
||||||
|
outputCh := hub.RegisterPTYSession(sessionID)
|
||||||
|
defer hub.UnregisterPTYSession(sessionID)
|
||||||
|
|
||||||
|
// pty_start an Agent senden
|
||||||
|
err = h.sendCommandAndWaitPTY(hub, agentID, sessionID, cols, rows)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("PTY-Start fehlgeschlagen fuer %s: %v", agentID, err)
|
||||||
|
browserConn.WriteMessage(websocket.TextMessage, []byte("\r\nFehler: Agent nicht erreichbar oder PTY-Start fehlgeschlagen\r\n"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Printf("PTY-Session gestartet: %s fuer Agent %s", sessionID, agentID)
|
||||||
|
|
||||||
|
// Goroutine: Agent-Ausgabe -> Browser
|
||||||
|
done := make(chan struct{})
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case data, ok := <-outputCh:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
browserConn.SetWriteDeadline(time.Now().Add(10 * time.Second))
|
||||||
|
if err := browserConn.WriteMessage(websocket.BinaryMessage, data); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
// Keep-alive ping
|
||||||
|
if err := browserConn.WriteMessage(websocket.PingMessage, nil); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Browser-Input -> Agent
|
||||||
|
browserConn.SetReadDeadline(time.Time{}) // kein Timeout fuer interaktive Sessions
|
||||||
|
for {
|
||||||
|
messageType, data, err := browserConn.ReadMessage()
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
if messageType == websocket.BinaryMessage {
|
||||||
|
// Eingabe an Agent-PTY weiterleiten
|
||||||
|
idBytes := []byte(sessionID)
|
||||||
|
msg := make([]byte, 1+len(idBytes)+len(data))
|
||||||
|
msg[0] = byte(len(idBytes))
|
||||||
|
copy(msg[1:], idBytes)
|
||||||
|
copy(msg[1+len(idBytes):], data)
|
||||||
|
hub.SendBinaryToAgent(agentID, msg)
|
||||||
|
|
||||||
|
} else if messageType == websocket.TextMessage {
|
||||||
|
// JSON-Control-Messages (resize)
|
||||||
|
var ctrl struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Cols uint16 `json:"cols"`
|
||||||
|
Rows uint16 `json:"rows"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(data, &ctrl); err == nil && ctrl.Type == "resize" {
|
||||||
|
SendCommandToAgent(hub, agentID, "pty_resize", map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"cols": ctrl.Cols,
|
||||||
|
"rows": ctrl.Rows,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Browser disconnected -> PTY stoppen
|
||||||
|
SendCommandToAgent(hub, agentID, "pty_stop", map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
})
|
||||||
|
log.Printf("PTY-Session beendet: %s", sessionID)
|
||||||
|
|
||||||
|
<-done
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) sendCommandAndWaitPTY(hub *ws.Hub, agentID, sessionID string, cols, rows uint16) error {
|
||||||
|
_, err := h.sendCommandAndWaitResult(hub, agentID, "pty_start", map[string]interface{}{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"cols": cols,
|
||||||
|
"rows": rows,
|
||||||
|
}, 10)
|
||||||
|
return err
|
||||||
|
}
|
||||||
@ -43,6 +43,9 @@ func (h *Handler) setupTunnelRoutes(mux *http.ServeMux, hub *ws.Hub) {
|
|||||||
mux.HandleFunc("DELETE /api/v1/agents/{id}/backups/{backup_id}", h.deleteBackup())
|
mux.HandleFunc("DELETE /api/v1/agents/{id}/backups/{backup_id}", h.deleteBackup())
|
||||||
mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups())
|
mux.HandleFunc("GET /api/v1/agents/{id}/backups/diff", h.diffBackups())
|
||||||
|
|
||||||
|
// Terminal-Endpoint (Browser -> Agent PTY)
|
||||||
|
mux.HandleFunc("GET /api/v1/agents/{id}/terminal", h.terminalHandler(hub))
|
||||||
|
|
||||||
// WebSocket-Endpoint
|
// WebSocket-Endpoint
|
||||||
mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub))
|
mux.HandleFunc("GET /api/v1/agent/ws", ws.NewWebSocketHandler(hub))
|
||||||
|
|
||||||
@ -54,7 +57,6 @@ func (h *Handler) executeCommand(hub *ws.Hub) http.HandlerFunc {
|
|||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
agentID := r.PathValue("id")
|
agentID := r.PathValue("id")
|
||||||
|
|
||||||
// Request parsen
|
|
||||||
var req struct {
|
var req struct {
|
||||||
Command string `json:"command"`
|
Command string `json:"command"`
|
||||||
Timeout int `json:"timeout,omitempty"`
|
Timeout int `json:"timeout,omitempty"`
|
||||||
@ -74,32 +76,6 @@ func (h *Handler) executeCommand(hub *ws.Hub) http.HandlerFunc {
|
|||||||
req.Timeout = 30
|
req.Timeout = 30
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prüfen ob Agent verbunden ist
|
|
||||||
if !hub.IsAgentConnected(agentID) {
|
|
||||||
writeError(w, http.StatusServiceUnavailable, "Agent nicht verbunden")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Command-Message erstellen
|
|
||||||
cmd := map[string]interface{}{
|
|
||||||
"type": "command",
|
|
||||||
"id": generateID(),
|
|
||||||
"command": "exec",
|
|
||||||
"params": map[string]interface{}{
|
|
||||||
"command": req.Command,
|
|
||||||
"timeout": req.Timeout,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
cmdJSON, _ := json.Marshal(cmd)
|
|
||||||
|
|
||||||
// Command an Agent senden
|
|
||||||
if err := hub.SendToAgent(agentID, cmdJSON); err != nil {
|
|
||||||
log.Printf("Command senden fehlgeschlagen: %v", err)
|
|
||||||
writeError(w, http.StatusInternalServerError, "Command senden fehlgeschlagen")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit
|
// Audit
|
||||||
cmdTrunc := req.Command
|
cmdTrunc := req.Command
|
||||||
if len(cmdTrunc) > 100 {
|
if len(cmdTrunc) > 100 {
|
||||||
@ -107,11 +83,11 @@ func (h *Handler) executeCommand(hub *ws.Hub) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
h.auditLog(r, "exec", "agent", agentID, "", cmdTrunc)
|
h.auditLog(r, "exec", "agent", agentID, "", cmdTrunc)
|
||||||
|
|
||||||
// Response zurückgeben
|
// Command senden und auf Antwort warten
|
||||||
writeJSON(w, http.StatusOK, map[string]interface{}{
|
h.sendCommandAndWait(hub, agentID, "exec", map[string]interface{}{
|
||||||
"message": "Command gesendet",
|
"command": req.Command,
|
||||||
"command_id": cmd["id"],
|
"timeout": req.Timeout,
|
||||||
})
|
}, req.Timeout+5, w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -337,6 +313,57 @@ func (h *Handler) sendCommandAndWait(hub *ws.Hub, agentID, command string, param
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sendCommandAndWaitResult wie sendCommandAndWait, gibt Response direkt zurueck (kein http.ResponseWriter)
|
||||||
|
func (h *Handler) sendCommandAndWaitResult(hub *ws.Hub, agentID, command string, params map[string]interface{}, timeoutSec int) (map[string]interface{}, error) {
|
||||||
|
if !hub.IsAgentConnected(agentID) {
|
||||||
|
return nil, fmt.Errorf("Agent nicht verbunden")
|
||||||
|
}
|
||||||
|
|
||||||
|
cmdID := generateID()
|
||||||
|
cmd := map[string]interface{}{
|
||||||
|
"type": "command",
|
||||||
|
"id": cmdID,
|
||||||
|
"command": command,
|
||||||
|
}
|
||||||
|
if params != nil {
|
||||||
|
cmd["params"] = params
|
||||||
|
}
|
||||||
|
|
||||||
|
responseCh := hub.RegisterResponseWaiter(cmdID)
|
||||||
|
defer hub.UnregisterResponseWaiter(cmdID)
|
||||||
|
|
||||||
|
cmdJSON, _ := json.Marshal(cmd)
|
||||||
|
if err := hub.SendToAgent(agentID, cmdJSON); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case resp := <-responseCh:
|
||||||
|
if status, _ := resp["status"].(string); status == "error" {
|
||||||
|
if errMsg, _ := resp["error"].(string); errMsg != "" {
|
||||||
|
return nil, fmt.Errorf(errMsg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return resp, nil
|
||||||
|
case <-time.After(time.Duration(timeoutSec) * time.Second):
|
||||||
|
return nil, fmt.Errorf("timeout")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCommandToAgent sendet ein Command ohne auf Response zu warten (fire-and-forget)
|
||||||
|
func SendCommandToAgent(hub *ws.Hub, agentID, command string, params map[string]interface{}) error {
|
||||||
|
cmd := map[string]interface{}{
|
||||||
|
"type": "command",
|
||||||
|
"id": generateID(),
|
||||||
|
"command": command,
|
||||||
|
}
|
||||||
|
if params != nil {
|
||||||
|
cmd["params"] = params
|
||||||
|
}
|
||||||
|
cmdJSON, _ := json.Marshal(cmd)
|
||||||
|
return hub.SendToAgent(agentID, cmdJSON)
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) hubStatus(hub *ws.Hub) http.HandlerFunc {
|
func (h *Handler) hubStatus(hub *ws.Hub) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
stats := hub.GetAgentStats()
|
stats := hub.GetAgentStats()
|
||||||
|
|||||||
@ -29,6 +29,10 @@ type Hub struct {
|
|||||||
|
|
||||||
// Event-Callback fuer DB-Events bei Connect/Disconnect
|
// Event-Callback fuer DB-Events bei Connect/Disconnect
|
||||||
eventCallback func(agentID, eventType, message string)
|
eventCallback func(agentID, eventType, message string)
|
||||||
|
|
||||||
|
// PTY-Sessions: session_id -> channel fuer Ausgabe an Browser
|
||||||
|
ptySessions map[string]chan []byte
|
||||||
|
ptySessionsMux sync.RWMutex
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewHub() *Hub {
|
func NewHub() *Hub {
|
||||||
@ -41,6 +45,7 @@ func NewHub() *Hub {
|
|||||||
}
|
}
|
||||||
|
|
||||||
hub.tunnelManager = NewTunnelManager(hub)
|
hub.tunnelManager = NewTunnelManager(hub)
|
||||||
|
hub.ptySessions = make(map[string]chan []byte)
|
||||||
return hub
|
return hub
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -281,8 +286,28 @@ func (h *Hub) ProcessAgentMessage(agentID string, messageType int, data []byte)
|
|||||||
agent.LastSeen = time.Now()
|
agent.LastSeen = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Binary Messages sind normalerweise Tunnel-Daten
|
// Binary Messages: PTY oder Tunnel
|
||||||
if messageType == 2 { // websocket.BinaryMessage
|
if messageType == 2 { // websocket.BinaryMessage
|
||||||
|
// PTY-Session erkennen: session_id beginnt mit "pty-"
|
||||||
|
if len(data) > 1 {
|
||||||
|
idLen := int(data[0])
|
||||||
|
if 1+idLen <= len(data) {
|
||||||
|
sessionID := string(data[1 : 1+idLen])
|
||||||
|
payload := data[1+idLen:]
|
||||||
|
if len(sessionID) >= 4 && sessionID[:4] == "pty-" {
|
||||||
|
h.ptySessionsMux.RLock()
|
||||||
|
ch, ok := h.ptySessions[sessionID]
|
||||||
|
h.ptySessionsMux.RUnlock()
|
||||||
|
if ok {
|
||||||
|
select {
|
||||||
|
case ch <- payload:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return h.tunnelManager.ProcessBinaryMessage(agentID, data)
|
return h.tunnelManager.ProcessBinaryMessage(agentID, data)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -334,3 +359,18 @@ func (h *Hub) ProcessAgentMessage(agentID string, messageType int, data []byte)
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
// RegisterPTYSession registriert einen Output-Channel fuer eine PTY-Session
|
||||||
|
func (h *Hub) RegisterPTYSession(sessionID string) chan []byte {
|
||||||
|
ch := make(chan []byte, 256)
|
||||||
|
h.ptySessionsMux.Lock()
|
||||||
|
h.ptySessions[sessionID] = ch
|
||||||
|
h.ptySessionsMux.Unlock()
|
||||||
|
return ch
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnregisterPTYSession entfernt eine PTY-Session
|
||||||
|
func (h *Hub) UnregisterPTYSession(sessionID string) {
|
||||||
|
h.ptySessionsMux.Lock()
|
||||||
|
delete(h.ptySessions, sessionID)
|
||||||
|
h.ptySessionsMux.Unlock()
|
||||||
|
}
|
||||||
|
|||||||
17
frontend/package-lock.json
generated
17
frontend/package-lock.json
generated
@ -8,6 +8,8 @@
|
|||||||
"name": "frontend",
|
"name": "frontend",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
@ -1858,6 +1860,21 @@
|
|||||||
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
"vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@xterm/addon-fit": {
|
||||||
|
"version": "0.11.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.11.0.tgz",
|
||||||
|
"integrity": "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@xterm/xterm": {
|
||||||
|
"version": "6.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-6.0.0.tgz",
|
||||||
|
"integrity": "sha512-TQwDdQGtwwDt+2cgKDLn0IRaSxYu1tSUjgKarSDkUM0ZNiSRXFpjxEsvc/Zgc5kq5omJ+V0a8/kIM2WD3sMOYg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"workspaces": [
|
||||||
|
"addons/*"
|
||||||
|
]
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.16.0",
|
"version": "8.16.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
|
||||||
|
|||||||
@ -10,6 +10,8 @@
|
|||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@xterm/addon-fit": "^0.11.0",
|
||||||
|
"@xterm/xterm": "^6.0.0",
|
||||||
"lucide-react": "^0.575.0",
|
"lucide-react": "^0.575.0",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0",
|
"react-dom": "^19.2.0",
|
||||||
|
|||||||
@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
|
|||||||
import api from '../api/client'
|
import api from '../api/client'
|
||||||
import { useSettingsStore } from '../stores/settings'
|
import { useSettingsStore } from '../stores/settings'
|
||||||
import StatusBadge from './StatusBadge'
|
import StatusBadge from './StatusBadge'
|
||||||
|
import TerminalModal from './TerminalModal'
|
||||||
import {
|
import {
|
||||||
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
X, Cpu, MemoryStick, HardDrive, Clock, Network, Server, Monitor,
|
||||||
Container, Database, FolderTree, Settings, Download, Trash2, Copy, Check,
|
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 [customHost, setCustomHost] = useState('127.0.0.1')
|
||||||
const [customPort, setCustomPort] = useState('')
|
const [customPort, setCustomPort] = useState('')
|
||||||
const [error, setError] = useState('')
|
const [error, setError] = useState('')
|
||||||
|
const [terminalOpen, setTerminalOpen] = useState(false)
|
||||||
|
const [copiedCmd, setCopiedCmd] = useState(false)
|
||||||
|
|
||||||
const backendHost = useSettingsStore.getState().getBackendHost()
|
const backendHost = useSettingsStore.getState().getBackendHost()
|
||||||
|
|
||||||
@ -736,9 +739,24 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
|||||||
setCustomPort('')
|
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 (
|
return (
|
||||||
<div className="space-y-4">
|
<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>
|
<span className="text-sm text-gray-400">Schnellzugriff:</span>
|
||||||
<button
|
<button
|
||||||
onClick={() => openTunnel('127.0.0.1', webguiPort, 'webgui')}
|
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"
|
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" />
|
<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>
|
||||||
<button
|
<button
|
||||||
onClick={() => setCustomOpen(!customOpen)}
|
onClick={() => setCustomOpen(!customOpen)}
|
||||||
@ -764,6 +789,24 @@ function TunnelTab({ agentId, agent, webguiPort = 8006 }) {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 && (
|
{customOpen && (
|
||||||
<div className="flex items-center gap-2 text-sm">
|
<div className="flex items-center gap-2 text-sm">
|
||||||
<input type="text" value={customHost} onChange={(e) => setCustomHost(e.target.value)}
|
<input type="text" value={customHost} onChange={(e) => setCustomHost(e.target.value)}
|
||||||
|
|||||||
162
frontend/src/components/TerminalModal.jsx
Normal file
162
frontend/src/components/TerminalModal.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user