feat: WebShell真实SSH终端 + 移除日志能力菜单
This commit is contained in:
1 parent
925348e583
commit
3be53b89fa
6 files changed
+338
-50
No files matched your search
@@ -4,10 +4,13 @@ import (
|
|||||||
"encoding/json"
|
"encoding/json"
|
||||||
"log"
|
"log"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"strconv"
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/gorilla/websocket"
|
"github.com/gorilla/websocket"
|
||||||
|
"golang.org/x/crypto/ssh"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
|
|
||||||
"ezops/internal/models"
|
"ezops/internal/models"
|
||||||
@@ -15,13 +18,13 @@ import (
|
|||||||
|
|
||||||
var upgrader = websocket.Upgrader{
|
var upgrader = websocket.Upgrader{
|
||||||
CheckOrigin: func(r *http.Request) bool {
|
CheckOrigin: func(r *http.Request) bool {
|
||||||
return true // allow all origins in dev
|
return true
|
||||||
},
|
},
|
||||||
ReadBufferSize: 1024,
|
ReadBufferSize: 1024,
|
||||||
WriteBufferSize: 1024,
|
WriteBufferSize: 1024,
|
||||||
}
|
}
|
||||||
|
|
||||||
// WSMessage WebSocket消息结构
|
// WSMessage WebSocket消息结构 // Logan.Li
|
||||||
type WSMessage struct {
|
type WSMessage struct {
|
||||||
Status int `json:"status"` // 0=data, 1=resize
|
Status int `json:"status"` // 0=data, 1=resize
|
||||||
Data string `json:"data"`
|
Data string `json:"data"`
|
||||||
@@ -32,7 +35,7 @@ type WSMessage struct {
|
|||||||
// WebShellHandler WebShell处理器
|
// WebShellHandler WebShell处理器
|
||||||
type WebShellHandler struct {
|
type WebShellHandler struct {
|
||||||
db *gorm.DB
|
db *gorm.DB
|
||||||
clients sync.Map // serverID -> []*websocket.Conn
|
clients sync.Map
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWebShellHandler 创建WebShell处理器
|
// NewWebShellHandler 创建WebShell处理器
|
||||||
@@ -40,14 +43,7 @@ func NewWebShellHandler(db *gorm.DB) *WebShellHandler {
|
|||||||
return &WebShellHandler{db: db}
|
return &WebShellHandler{db: db}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Terminal WebSocket终端连接
|
// Terminal WebSocket终端连接 — 真实SSH // Logan.Li
|
||||||
// @Summary WebShell终端
|
|
||||||
// @Description 通过WebSocket连接到服务器的SSH终端
|
|
||||||
// @Tags WebShell
|
|
||||||
// @Param id query int true "服务器ID"
|
|
||||||
// @Param cols query int false "终端列数"
|
|
||||||
// @Param rows query int false "终端行数"
|
|
||||||
// @Router /ws/terminal [get]
|
|
||||||
func (h *WebShellHandler) Terminal(c *gin.Context) {
|
func (h *WebShellHandler) Terminal(c *gin.Context) {
|
||||||
serverID := c.Query("id")
|
serverID := c.Query("id")
|
||||||
if serverID == "" {
|
if serverID == "" {
|
||||||
@@ -55,36 +51,127 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify server exists
|
|
||||||
var server models.Server
|
var server models.Server
|
||||||
if err := h.db.First(&server, serverID).Error; err != nil {
|
if err := h.db.First(&server, serverID).Error; err != nil {
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
c.JSON(http.StatusNotFound, gin.H{"error": "server not found"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Upgrade to WebSocket
|
// 先升级 WebSocket — 这样前端能看到连接过程
|
||||||
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
conn, err := upgrader.Upgrade(c.Writer, c.Request, nil)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WebShell] upgrade error: %v", err)
|
log.Printf("[WebShell] WebSocket升级失败: %v", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer conn.Close()
|
defer conn.Close()
|
||||||
|
|
||||||
log.Printf("[WebShell] connected to server %s (%s:%d)", server.Hostname, server.IP, server.Port)
|
conn.WriteJSON(WSMessage{
|
||||||
|
|
||||||
// Send welcome message
|
|
||||||
welcome := WSMessage{
|
|
||||||
Status: 0,
|
Status: 0,
|
||||||
Data: "\r\n\x1b[32mEZOps WebShell\x1b[0m - Connected to " + server.Hostname + " (" + server.IP + ")\r\n",
|
Data: "\r\n\x1b[36m正在连接 " + server.Hostname + " (" + server.IP + ")...\x1b[0m\r\n",
|
||||||
}
|
})
|
||||||
conn.WriteJSON(welcome)
|
|
||||||
|
|
||||||
// Demo mode: echo input back with a prompt
|
// 构建 SSH 配置
|
||||||
// In production this would connect to actual SSH via crypto/ssh or a PTY bridge
|
sshConfig := &ssh.ClientConfig{
|
||||||
|
User: server.Username,
|
||||||
|
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
|
||||||
|
Timeout: 10 * time.Second,
|
||||||
|
}
|
||||||
|
|
||||||
|
switch server.AuthType {
|
||||||
|
case "key":
|
||||||
|
signer, err := ssh.ParsePrivateKey([]byte(server.SSHKey))
|
||||||
|
if err != nil {
|
||||||
|
sshConfig.Auth = []ssh.AuthMethod{ssh.Password(server.Password)}
|
||||||
|
} else {
|
||||||
|
sshConfig.Auth = []ssh.AuthMethod{ssh.PublicKeys(signer)}
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
sshConfig.Auth = []ssh.AuthMethod{
|
||||||
|
ssh.Password(server.Password),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 连接 SSH(带10秒超时)
|
||||||
|
addr := server.IP + ":" + strconv.Itoa(server.Port)
|
||||||
|
sshClient, err := ssh.Dial("tcp", addr, sshConfig)
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WebShell] SSH连接失败 %s: %v", addr, err)
|
||||||
|
conn.WriteJSON(WSMessage{
|
||||||
|
Status: 0,
|
||||||
|
Data: "\r\n\x1b[31mSSH连接失败: " + err.Error() + "\x1b[0m\r\n\x1b[33m提示: 请检查服务器IP/端口/密码是否正确,目标主机是否在线\x1b[0m\r\n",
|
||||||
|
})
|
||||||
|
conn.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer sshClient.Close()
|
||||||
|
|
||||||
|
log.Printf("[WebShell] SSH已连接 %s (%s)", server.Hostname, addr)
|
||||||
|
|
||||||
|
// 创建 SSH session
|
||||||
|
session, err := sshClient.NewSession()
|
||||||
|
if err != nil {
|
||||||
|
log.Printf("[WebShell] 创建session失败: %v", err)
|
||||||
|
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m创建SSH会话失败\x1b[0m\r\n"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer session.Close()
|
||||||
|
|
||||||
|
// 请求 PTY
|
||||||
|
modes := ssh.TerminalModes{
|
||||||
|
ssh.ECHO: 1,
|
||||||
|
ssh.TTY_OP_ISPEED: 14400,
|
||||||
|
ssh.TTY_OP_OSPEED: 14400,
|
||||||
|
}
|
||||||
|
if err := session.RequestPty("xterm-256color", 24, 80, modes); err != nil {
|
||||||
|
log.Printf("[WebShell] PTY请求失败: %v", err)
|
||||||
|
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31mPTY请求失败\x1b[0m\r\n"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取管道
|
||||||
|
stdinPipe, err := session.StdinPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
stdoutPipe, err := session.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 启动 shell
|
||||||
|
if err := session.Shell(); err != nil {
|
||||||
|
log.Printf("[WebShell] Shell启动失败: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发送欢迎消息
|
||||||
|
conn.WriteJSON(WSMessage{
|
||||||
|
Status: 0,
|
||||||
|
Data: "\r\n\x1b[32mEZOps WebShell\x1b[0m — 已连接 " + server.Hostname + " (" + server.IP + ")\r\n\r\n",
|
||||||
|
})
|
||||||
|
|
||||||
|
done := make(chan struct{})
|
||||||
|
|
||||||
|
// goroutine: SSH stdout -> WebSocket
|
||||||
|
go func() {
|
||||||
|
defer close(done)
|
||||||
|
buf := make([]byte, 8192)
|
||||||
|
for {
|
||||||
|
n, err := stdoutPipe.Read(buf)
|
||||||
|
if err != nil {
|
||||||
|
conn.WriteJSON(WSMessage{Status: 0, Data: "\r\n\x1b[31m[远程连接已断开]\x1b[0m\r\n"})
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if n > 0 {
|
||||||
|
conn.WriteJSON(WSMessage{Status: 0, Data: string(buf[:n])})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 主循环: WebSocket -> SSH stdin
|
||||||
for {
|
for {
|
||||||
msgType, data, err := conn.ReadMessage()
|
msgType, data, err := conn.ReadMessage()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[WebShell] read error: %v", err)
|
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,29 +182,18 @@ func (h *WebShellHandler) Terminal(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch msg.Status {
|
switch msg.Status {
|
||||||
case 0: // data
|
case 0: // 数据输入
|
||||||
// Process command input
|
stdinPipe.Write([]byte(msg.Data))
|
||||||
resp := WSMessage{Status: 0}
|
|
||||||
switch msg.Data {
|
|
||||||
case "\r", "\n":
|
|
||||||
resp.Data = "\r\n[" + server.Hostname + " ~]$ "
|
|
||||||
case "\u0003": // Ctrl+C
|
|
||||||
resp.Data = "^C\r\n[" + server.Hostname + " ~]$ "
|
|
||||||
case "\u0004": // Ctrl+D
|
|
||||||
resp.Data = "\r\n\x1b[33mConnection closed.\x1b[0m\r\n"
|
|
||||||
conn.WriteJSON(resp)
|
|
||||||
goto done
|
|
||||||
default:
|
|
||||||
resp.Data = msg.Data // echo back
|
|
||||||
}
|
|
||||||
conn.WriteJSON(resp)
|
|
||||||
|
|
||||||
case 1: // resize
|
case 1: // resize
|
||||||
log.Printf("[WebShell] resize to cols=%d rows=%d", msg.Cols, msg.Rows)
|
if msg.Cols > 0 && msg.Rows > 0 {
|
||||||
|
session.WindowChange(msg.Rows, msg.Cols)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
done:
|
// 等待 stdout goroutine 结束
|
||||||
log.Printf("[WebShell] disconnected from server %s", server.Hostname)
|
<-done
|
||||||
|
stdinPipe.Close()
|
||||||
|
log.Printf("[WebShell] 断开 %s (%s)", server.Hostname, addr)
|
||||||
}
|
}
|
||||||
@@ -24,13 +24,13 @@ import { PipelineManager } from '@/components/deploy/PipelineManager';
|
|||||||
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
import { ReleaseManager } from '@/components/deploy/ReleaseManager';
|
||||||
import { CronJobManager } from '@/components/automation/CronJobManager';
|
import { CronJobManager } from '@/components/automation/CronJobManager';
|
||||||
import AnsibleManager from '@/components/automation/AnsibleManager';
|
import AnsibleManager from '@/components/automation/AnsibleManager';
|
||||||
|
import SQLAuditManager from '@/components/audit/SQLAuditManager';
|
||||||
import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant';
|
import { AIOpsAssistant } from '@/components/aiops/AIOpsAssistant';
|
||||||
import { AIOpsHub } from '@/components/aiops/AIOpsHub';
|
import { AIOpsHub } from '@/components/aiops/AIOpsHub';
|
||||||
import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
|
import { AIAlertAnalyzer } from '@/components/aiops/AIAlertAnalyzer';
|
||||||
import { AIScriptGenerator } from '@/components/aiops/AIScriptGenerator';
|
import { AIScriptGenerator } from '@/components/aiops/AIScriptGenerator';
|
||||||
import { AIFaultDiagnosis } from '@/components/aiops/AIFaultDiagnosis';
|
import { AIFaultDiagnosis } from '@/components/aiops/AIFaultDiagnosis';
|
||||||
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
import { AIPredictiveAnalytics } from '@/components/aiops/AIPredictiveAnalytics';
|
||||||
import LogManager from '@/components/monitor/logs/LogManager';
|
|
||||||
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
|
import AlertCenter from '@/components/monitor/alert-center/AlertCenter';
|
||||||
import TraceManager from '@/components/monitor/trace/TraceManager';
|
import TraceManager from '@/components/monitor/trace/TraceManager';
|
||||||
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
|
import GrafanaDashboard from '@/components/monitor/dashboard/GrafanaDashboard';
|
||||||
@@ -74,7 +74,6 @@ function App() {
|
|||||||
{/* 监控告警 */}
|
{/* 监控告警 */}
|
||||||
<Route path="metrics" element={<AlertManager />} />
|
<Route path="metrics" element={<AlertManager />} />
|
||||||
<Route path="alert-center" element={<AlertCenter />} />
|
<Route path="alert-center" element={<AlertCenter />} />
|
||||||
<Route path="logs-monitor" element={<LogManager />} />
|
|
||||||
<Route path="trace" element={<TraceManager />} />
|
<Route path="trace" element={<TraceManager />} />
|
||||||
<Route path="grafana" element={<GrafanaDashboard />} />
|
<Route path="grafana" element={<GrafanaDashboard />} />
|
||||||
<Route path="agent-alerts" element={<AgentAlerts />} />
|
<Route path="agent-alerts" element={<AgentAlerts />} />
|
||||||
@@ -86,6 +85,7 @@ function App() {
|
|||||||
<Route path="cronjobs" element={<CronJobManager />} />
|
<Route path="cronjobs" element={<CronJobManager />} />
|
||||||
<Route path="scripts" element={<ScriptManager />} />
|
<Route path="scripts" element={<ScriptManager />} />
|
||||||
<Route path="ansible" element={<AnsibleManager />} />
|
<Route path="ansible" element={<AnsibleManager />} />
|
||||||
|
<Route path="sql-audit" element={<SQLAuditManager />} />
|
||||||
{/* AIOps智能 */}
|
{/* AIOps智能 */}
|
||||||
<Route path="ai-hub" element={<AIOpsHub />} />
|
<Route path="ai-hub" element={<AIOpsHub />} />
|
||||||
<Route path="ai-assistant" element={<AIOpsAssistant />} />
|
<Route path="ai-assistant" element={<AIOpsAssistant />} />
|
||||||
|
|||||||
@@ -90,7 +90,6 @@ const menuItems: MenuItem[] = [
|
|||||||
children: [
|
children: [
|
||||||
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
|
{ id: 'metrics', name: '指标监控', icon: Cpu, path: '/metrics' },
|
||||||
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' },
|
{ id: 'alert-center', name: '告警中心', icon: Bell, path: '/alert-center' },
|
||||||
{ id: 'logs-monitor', name: '日志能力', icon: FileCode, path: '/logs-monitor' },
|
|
||||||
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
|
{ id: 'trace', name: '链路追踪', icon: GitBranch, path: '/trace' },
|
||||||
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' },
|
{ id: 'grafana', name: '监控大屏', icon: Monitor, path: '/grafana' },
|
||||||
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
|
{ id: 'agent-alerts', name: 'Agent告警', icon: AlertTriangle, path: '/agent-alerts' },
|
||||||
@@ -106,6 +105,7 @@ const menuItems: MenuItem[] = [
|
|||||||
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
|
{ id: 'cronjobs', name: '定时任务', icon: PlayCircle, path: '/cronjobs' },
|
||||||
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
|
{ id: 'scripts', name: '脚本库', icon: FileCode, path: '/scripts' },
|
||||||
{ id: 'ansible', name: 'Ansible', icon: FileTerminal, path: '/ansible' },
|
{ id: 'ansible', name: 'Ansible', icon: FileTerminal, path: '/ansible' },
|
||||||
|
{ id: 'sql-audit', name: 'SQL审计', icon: Shield, path: '/sql-audit' },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ import {
|
|||||||
import { serverApi } from '@/services/api';
|
import { serverApi } from '@/services/api';
|
||||||
import type { Server as ServerType } from '@/types';
|
import type { Server as ServerType } from '@/types';
|
||||||
import { CloudServerImport } from './CloudServerImport';
|
import { CloudServerImport } from './CloudServerImport';
|
||||||
|
import { TerminalDialog } from './TerminalDialog';
|
||||||
import { ExcelServerImport } from './ExcelServerImport';
|
import { ExcelServerImport } from './ExcelServerImport';
|
||||||
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
import { ServerForm, EMPTY_FORM, type ServerFormData } from './ServerForm';
|
||||||
|
|
||||||
@@ -67,6 +68,15 @@ export function ServerManager() {
|
|||||||
const [formSaving, setFormSaving] = useState(false);
|
const [formSaving, setFormSaving] = useState(false);
|
||||||
const [detailsOpen, setDetailsOpen] = useState(false);
|
const [detailsOpen, setDetailsOpen] = useState(false);
|
||||||
|
|
||||||
|
// 终端 Dialog 状态
|
||||||
|
const [terminalOpen, setTerminalOpen] = useState(false);
|
||||||
|
const [terminalServer, setTerminalServer] = useState<{ id: number; name: string; ip: string } | null>(null);
|
||||||
|
|
||||||
|
const openTerminal = (server: any) => {
|
||||||
|
setTerminalServer({ id: server.id, name: server.hostname, ip: server.ip });
|
||||||
|
setTerminalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
const loadData = async () => {
|
const loadData = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -558,7 +568,7 @@ export function ServerManager() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell className="text-right">
|
<TableCell className="text-right">
|
||||||
<div className="flex items-center justify-end gap-1">
|
<div className="flex items-center justify-end gap-1">
|
||||||
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]">
|
<Button variant="ghost" size="icon" className="h-8 w-8 text-[var(--dg-text-secondary)] hover:text-[var(--dg-blue)]" onClick={() => openTerminal(server)}>
|
||||||
<Terminal className="h-4 w-4" />
|
<Terminal className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -577,9 +587,9 @@ export function ServerManager() {
|
|||||||
<Edit className="h-4 w-4 mr-2" />
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
编辑
|
编辑
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
|
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer" onClick={() => openTerminal(server)}>
|
||||||
<Terminal className="h-4 w-4 mr-2" />
|
<Terminal className="h-4 w-4 mr-2" />
|
||||||
WebShell
|
终端
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
<DropdownMenuSeparator className="bg-[var(--dg-border)]" />
|
||||||
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
|
<DropdownMenuItem className="text-[var(--dg-text-secondary)] focus:bg-[var(--dg-bg-hover)] focus:text-[var(--dg-text-primary)] cursor-pointer">
|
||||||
@@ -635,6 +645,15 @@ export function ServerManager() {
|
|||||||
loadData();
|
loadData();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 终端 Dialog */}
|
||||||
|
<TerminalDialog
|
||||||
|
open={terminalOpen}
|
||||||
|
onOpenChange={setTerminalOpen}
|
||||||
|
serverId={terminalServer?.id ?? null}
|
||||||
|
serverName={terminalServer?.name ?? ''}
|
||||||
|
serverIp={terminalServer?.ip ?? ''}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Terminal as XTerm } from 'xterm';
|
||||||
|
import { FitAddon } from 'xterm-addon-fit';
|
||||||
|
import 'xterm/css/xterm.css';
|
||||||
|
import { Maximize2, Minimize2, X } from 'lucide-react';
|
||||||
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
|
interface TerminalDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
serverId: number | null;
|
||||||
|
serverName: string;
|
||||||
|
serverIp: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 终端 Dialog — WebSocket SSH 终端 // Logan.Li
|
||||||
|
* 打开时自动连接,关闭时自动断开
|
||||||
|
*/
|
||||||
|
export function TerminalDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
serverId,
|
||||||
|
serverName,
|
||||||
|
serverIp,
|
||||||
|
}: TerminalDialogProps) {
|
||||||
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const termRef = useRef<XTerm | null>(null);
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const fitRef = useRef<FitAddon | null>(null);
|
||||||
|
const [connected, setConnected] = useState(false);
|
||||||
|
const [fullscreen, setFullscreen] = useState(false);
|
||||||
|
|
||||||
|
// 连接终端
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !serverId) return;
|
||||||
|
|
||||||
|
// 创建终端
|
||||||
|
const term = new XTerm({
|
||||||
|
cursorBlink: true,
|
||||||
|
fontSize: 13,
|
||||||
|
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
|
||||||
|
allowProposedApi: true,
|
||||||
|
theme: {
|
||||||
|
background: '#0a0e14',
|
||||||
|
foreground: '#d6d6d6',
|
||||||
|
cursor: '#d6d6d6',
|
||||||
|
selectionBackground: 'rgba(255,255,255,0.2)',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const fit = new FitAddon();
|
||||||
|
term.loadAddon(fit);
|
||||||
|
termRef.current = term;
|
||||||
|
fitRef.current = fit;
|
||||||
|
|
||||||
|
// WebSocket 连接
|
||||||
|
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
|
||||||
|
const wsUrl = `${proto}://${location.host}/ws/terminal?id=${serverId}&cols=80&rows=24`;
|
||||||
|
const ws = new WebSocket(wsUrl);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
term.onData((data) => {
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ status: 0, data }));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const msg = JSON.parse(event.data);
|
||||||
|
if (msg.status === 0 && typeof msg.data === 'string') {
|
||||||
|
term.write(msg.data);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
term.write(typeof event.data === 'string' ? event.data : '');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
setConnected(true);
|
||||||
|
// 挂载终端到容器
|
||||||
|
if (containerRef.current) {
|
||||||
|
term.open(containerRef.current);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
term.focus();
|
||||||
|
} catch { /* noop */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
setConnected(false);
|
||||||
|
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = () => {
|
||||||
|
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
|
||||||
|
};
|
||||||
|
|
||||||
|
// resize 监听
|
||||||
|
const handleResize = () => {
|
||||||
|
if (fitRef.current && termRef.current) {
|
||||||
|
try {
|
||||||
|
fit.fit();
|
||||||
|
const { cols, rows } = termRef.current;
|
||||||
|
if (ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify({ status: 1, cols, rows }));
|
||||||
|
}
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
try {
|
||||||
|
ws.close();
|
||||||
|
term.dispose();
|
||||||
|
} catch { /* noop */ }
|
||||||
|
termRef.current = null;
|
||||||
|
wsRef.current = null;
|
||||||
|
setConnected(false);
|
||||||
|
};
|
||||||
|
}, [open, serverId]);
|
||||||
|
|
||||||
|
// fullscreen 切换时重新 fit
|
||||||
|
useEffect(() => {
|
||||||
|
if (open && fitRef.current) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
try {
|
||||||
|
fitRef.current?.fit();
|
||||||
|
termRef.current?.focus();
|
||||||
|
} catch { /* noop */ }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [fullscreen, open]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className={cn(
|
||||||
|
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
||||||
|
fullscreen && '!max-w-none !w-screen !h-screen !rounded-none !translate-x-0 !translate-y-0 !left-0 !top-0'
|
||||||
|
)}
|
||||||
|
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
|
||||||
|
>
|
||||||
|
<DialogTitle className="sr-only">终端 - {serverName}</DialogTitle>
|
||||||
|
<DialogDescription className="sr-only">SSH 终端连接</DialogDescription>
|
||||||
|
{/* 终端头部 */}
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<div className={cn('w-2 h-2 rounded-full', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||||
|
<span className="text-[var(--dg-text-primary)] font-medium">{serverName}</span>
|
||||||
|
<span className="text-[var(--dg-text-muted)]">({serverIp})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setFullscreen(!fullscreen)}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||||
|
>
|
||||||
|
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 终端容器 */}
|
||||||
|
<div
|
||||||
|
ref={containerRef}
|
||||||
|
className="bg-[#0a0e14]"
|
||||||
|
style={{ height: fullscreen ? 'calc(100vh - 49px)' : '480px' }}
|
||||||
|
/>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ export default defineConfig({
|
|||||||
target: "http://localhost:8080",
|
target: "http://localhost:8080",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
},
|
},
|
||||||
|
"/ws": {
|
||||||
|
target: "http://localhost:8080",
|
||||||
|
ws: true,
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
"/health": {
|
"/health": {
|
||||||
target: "http://localhost:8080",
|
target: "http://localhost:8080",
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
Reference in new issue
Block a user