Files
xingrin/frontend/components/settings/workers/deploy-terminal-dialog.tsx
2025-12-19 20:07:55 +08:00

409 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client"
import { useState, useEffect, useRef, useCallback } from "react"
import {
Dialog,
DialogContent,
} from "@/components/ui/dialog"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { Button } from "@/components/ui/button"
import { IconRocket, IconEye, IconTrash, IconRefresh } from "@tabler/icons-react"
import type { WorkerNode } from "@/types/worker.types"
interface DeployTerminalDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
worker: WorkerNode | null
onDeployComplete?: () => void
}
// 自动根据当前页面 URL 生成 WebSocket URL
const getWsBaseUrl = () => {
if (typeof window === 'undefined') return 'ws://localhost:8888'
// 优先使用环境变量
if (process.env.NEXT_PUBLIC_WS_URL) {
return process.env.NEXT_PUBLIC_WS_URL
}
// 根据当前页面协议和域名自动生成
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const host = window.location.host
return `${protocol}//${host}`
}
export function DeployTerminalDialog({
open,
onOpenChange,
worker,
onDeployComplete,
}: DeployTerminalDialogProps) {
const [isConnected, setIsConnected] = useState(false)
const [error, setError] = useState<string | null>(null)
// 本地 worker 状态,用于实时更新按钮显示
const [localStatus, setLocalStatus] = useState<string | null>(null)
const [uninstallDialogOpen, setUninstallDialogOpen] = useState(false)
// 使用本地状态或传入的 worker 状态
const currentStatus = localStatus || worker?.status
const terminalRef = useRef<HTMLDivElement>(null)
const terminalInstanceRef = useRef<any>(null)
const fitAddonRef = useRef<any>(null)
const wsRef = useRef<WebSocket | null>(null)
// 初始化 xterm
const initTerminal = useCallback(async () => {
if (!terminalRef.current || terminalInstanceRef.current) return
const { Terminal } = await import('@xterm/xterm')
const { FitAddon } = await import('@xterm/addon-fit')
const { WebLinksAddon } = await import('@xterm/addon-web-links')
const terminal = new Terminal({
cursorBlink: true,
fontSize: 12, // 减小字体
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: {
background: '#1a1b26',
foreground: '#a9b1d6',
cursor: '#c0caf5',
black: '#32344a',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#ad8ee6',
cyan: '#449dab',
white: '#787c99',
},
})
const fitAddon = new FitAddon()
terminal.loadAddon(fitAddon)
terminal.loadAddon(new WebLinksAddon())
terminal.open(terminalRef.current)
fitAddon.fit()
terminalInstanceRef.current = terminal
fitAddonRef.current = fitAddon
// 显示连接提示
terminal.writeln('\x1b[90m正在建立 SSH 连接...\x1b[0m')
// 监听窗口大小变化
const handleResize = () => fitAddon.fit()
window.addEventListener('resize', handleResize)
// 自动连接 WebSocket
connectWs()
return () => {
window.removeEventListener('resize', handleResize)
}
}, [worker])
// 连接 WebSocket
const connectWs = useCallback(() => {
if (!worker || !terminalInstanceRef.current) return
const terminal = terminalInstanceRef.current
// 如果已有连接先关闭
if (wsRef.current) {
wsRef.current.close()
}
const ws = new WebSocket(`${getWsBaseUrl()}/ws/workers/${worker.id}/deploy/`)
ws.binaryType = 'arraybuffer'
wsRef.current = ws
ws.onopen = () => {
terminal.writeln('\x1b[32m✓ WebSocket 已连接\x1b[0m')
// 后端会自动开始 SSH 连接
}
ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) {
// 二进制数据 - 终端输出
const decoder = new TextDecoder()
terminal.write(decoder.decode(event.data))
} else {
// JSON 消息
try {
const data = JSON.parse(event.data)
if (data.type === 'connected') {
setIsConnected(true)
setError(null)
// 绑定终端输入
terminal.onData((data: string) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
// 发送终端大小
ws.send(JSON.stringify({
type: 'resize',
cols: terminal.cols,
rows: terminal.rows,
}))
} else if (data.type === 'error') {
terminal.writeln(`\x1b[31m✗ ${data.message}\x1b[0m`)
setError(data.message)
} else if (data.type === 'status') {
// 更新本地状态以实时显示正确的按钮
setLocalStatus(data.status)
// 任何状态变化都刷新父组件列表
onDeployComplete?.()
}
} catch {
// 忽略解析错误
}
}
}
ws.onclose = () => {
terminal.writeln('')
terminal.writeln('\x1b[33m连接已关闭\x1b[0m')
setIsConnected(false)
}
ws.onerror = () => {
terminal.writeln('\x1b[31m✗ WebSocket 连接失败\x1b[0m')
setError('连接失败')
}
}, [worker, onDeployComplete])
// 发送终端大小变化
useEffect(() => {
if (!isConnected || !wsRef.current || !terminalInstanceRef.current) return
const terminal = terminalInstanceRef.current
const handleResize = () => {
if (wsRef.current?.readyState === WebSocket.OPEN) {
wsRef.current.send(JSON.stringify({
type: 'resize',
cols: terminal.cols,
rows: terminal.rows,
}))
}
}
terminal.onResize?.(handleResize)
}, [isConnected])
// 打开时初始化
useEffect(() => {
if (open && worker) {
// 延迟初始化,确保 DOM 已渲染
const timer = setTimeout(initTerminal, 100)
return () => clearTimeout(timer)
}
}, [open, worker, initTerminal])
// 关闭时清理
const handleClose = () => {
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (terminalInstanceRef.current) {
terminalInstanceRef.current.dispose()
terminalInstanceRef.current = null
}
fitAddonRef.current = null
setIsConnected(false)
setError(null)
setLocalStatus(null) // 重置本地状态
// 关闭时刷新父组件列表,确保状态同步
onDeployComplete?.()
onOpenChange(false)
}
// 执行部署脚本(后台运行)
const handleDeploy = () => {
if (!wsRef.current || !isConnected) return
setLocalStatus('deploying') // 立即更新为部署中状态
onDeployComplete?.() // 刷新父组件列表
wsRef.current.send(JSON.stringify({ type: 'deploy' }))
}
// 查看部署进度attach 到 tmux 会话)
const handleAttach = () => {
if (!wsRef.current || !isConnected) return
wsRef.current.send(JSON.stringify({ type: 'attach' }))
}
// 卸载 Agent打开确认弹窗
const handleUninstallClick = () => {
if (!wsRef.current || !isConnected) return
setUninstallDialogOpen(true)
}
// 确认卸载
const handleUninstallConfirm = () => {
if (!wsRef.current || !isConnected) return
setUninstallDialogOpen(false)
wsRef.current.send(JSON.stringify({ type: 'uninstall' }))
}
return (
<Dialog open={open} onOpenChange={handleClose}>
<DialogContent className="w-[50vw] max-w-[50vw] h-[80vh] flex flex-col p-0 gap-0 overflow-hidden [&>button]:hidden">
{/* 终端标题栏 - macOS 风格 */}
<div className="flex items-center justify-between px-4 py-3 bg-[#1a1b26] border-b border-[#32344a]">
<div className="flex items-center gap-3">
{/* 红黄绿按钮 */}
<div className="flex items-center gap-1.5">
<button
onClick={handleClose}
className="w-3 h-3 rounded-full bg-[#ff5f56] hover:bg-[#ff5f56]/80 transition-colors"
title="关闭"
/>
<div className="w-3 h-3 rounded-full bg-[#ffbd2e]" />
<div className="w-3 h-3 rounded-full bg-[#27c93f]" />
</div>
{/* 标题 */}
<span className="text-sm text-[#a9b1d6] font-medium">
{worker?.username}@{worker?.ipAddress}
</span>
</div>
<div className="flex items-center gap-1.5">
<span className={`w-2 h-2 rounded-full ${isConnected ? 'bg-[#9ece6a]' : 'bg-[#f7768e]'}`} />
<span className="text-xs text-[#a9b1d6]">{isConnected ? '已连接' : '未连接'}</span>
</div>
</div>
{/* xterm 终端容器 */}
<div
ref={terminalRef}
className="flex-1 overflow-hidden bg-[#1a1b26]"
/>
{/* 底部操作栏 - 根据状态显示不同按钮 */}
<div className="flex items-center justify-between px-4 py-3 bg-[#1a1b26] border-t border-[#32344a]">
{/* 左侧:状态提示 */}
<div className="text-xs text-[#565f89]">
{!isConnected && '等待连接...'}
{isConnected && currentStatus === 'pending' && '节点未部署,点击右侧按钮开始部署扫描环境'}
{isConnected && currentStatus === 'deploying' && '正在部署中,点击查看进度'}
{isConnected && currentStatus === 'online' && '节点运行正常'}
{isConnected && currentStatus === 'offline' && '节点离线,可尝试重新部署'}
{isConnected && currentStatus === 'updating' && '正在自动更新 Agent...'}
{isConnected && currentStatus === 'outdated' && '版本过低,需要更新'}
</div>
{/* 右侧:操作按钮 */}
<div className="flex items-center gap-2">
{!isConnected && (
<button
onClick={connectWs}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#32344a] text-[#a9b1d6] hover:bg-[#414868] transition-colors"
>
<IconRefresh className="mr-1.5 h-4 w-4" />
</button>
)}
{isConnected && worker && (
<>
{/* 未部署 -> 显示"开始部署" */}
{currentStatus === 'pending' && (
<button
onClick={handleDeploy}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#7aa2f7] text-[#1a1b26] hover:bg-[#7aa2f7]/80 transition-colors"
>
<IconRocket className="mr-1.5 h-4 w-4" />
</button>
)}
{/* 部署中 -> 显示"查看进度" */}
{currentStatus === 'deploying' && (
<button
onClick={handleAttach}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#7aa2f7] text-[#1a1b26] hover:bg-[#7aa2f7]/80 transition-colors"
>
<IconEye className="mr-1.5 h-4 w-4" />
</button>
)}
{/* 更新中 -> 显示"查看进度" */}
{currentStatus === 'updating' && (
<button
onClick={handleAttach}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#e0af68] text-[#1a1b26] hover:bg-[#e0af68]/80 transition-colors"
>
<IconEye className="mr-1.5 h-4 w-4" />
</button>
)}
{/* 版本过低 -> 显示"重新部署" */}
{currentStatus === 'outdated' && (
<button
onClick={handleDeploy}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#f7768e] text-[#1a1b26] hover:bg-[#f7768e]/80 transition-colors"
>
<IconRocket className="mr-1.5 h-4 w-4" />
</button>
)}
{/* 已部署(online/offline) -> 显示"重新部署"和"卸载" */}
{(currentStatus === 'online' || currentStatus === 'offline') && (
<>
<button
onClick={handleDeploy}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#32344a] text-[#a9b1d6] hover:bg-[#414868] transition-colors"
>
<IconRocket className="mr-1.5 h-4 w-4" />
</button>
<button
onClick={handleUninstallClick}
className="inline-flex items-center px-3 py-1.5 text-sm rounded-md bg-[#32344a] text-[#f7768e] hover:bg-[#414868] transition-colors"
>
<IconTrash className="mr-1.5 h-4 w-4" />
</button>
</>
)}
</>
)}
</div>
</div>
</DialogContent>
{/* 卸载确认弹窗 */}
<AlertDialog open={uninstallDialogOpen} onOpenChange={setUninstallDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
Agent Docker
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleUninstallConfirm}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}