Files
CLICD/frontend/src/components/WebSSHViewer.tsx
T
2026-06-06 10:08:31 +08:00

220 lines
6.6 KiB
TypeScript

import { useEffect, useRef, useState } from 'react'
import { Terminal } from '@xterm/xterm'
import { FitAddon } from '@xterm/addon-fit'
import '@xterm/xterm/css/xterm.css'
import { RefreshCw, TerminalSquare, X } from 'lucide-react'
import { createWebSSHTicket } from '../services/api'
interface WebSSHViewerProps {
containerName: string
onClose: () => void
}
export default function WebSSHViewer({ containerName, onClose }: WebSSHViewerProps) {
const terminalRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
const termRef = useRef<Terminal | null>(null)
const fitRef = useRef<FitAddon | null>(null)
const resizeObserverRef = useRef<ResizeObserver | null>(null)
const [status, setStatus] = useState<'connecting' | 'preparing' | 'connected' | 'disconnected' | 'error'>('connecting')
const [errorMsg, setErrorMsg] = useState('')
const buildWebSSHUrl = () => {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const params = new URLSearchParams({
container: containerName,
})
return `${protocol}//${window.location.host}/api/ssh?${params.toString()}`
}
const sendResize = () => {
const ws = wsRef.current
const term = termRef.current
if (!ws || !term || ws.readyState !== WebSocket.OPEN) return
ws.send(JSON.stringify({ type: 'resize', cols: term.cols, rows: term.rows }))
}
const cleanup = () => {
resizeObserverRef.current?.disconnect()
resizeObserverRef.current = null
if (wsRef.current) {
wsRef.current.close()
wsRef.current = null
}
if (termRef.current) {
termRef.current.dispose()
termRef.current = null
fitRef.current = null
}
}
const connect = async () => {
if (!terminalRef.current) return
cleanup()
setStatus('connecting')
setErrorMsg('')
const term = new Terminal({
cursorBlink: true,
convertEol: true,
fontFamily: 'Consolas, Menlo, Monaco, monospace',
fontSize: 13,
theme: {
background: '#050505',
foreground: '#f3f4f6',
cursor: '#ffffff',
selectionBackground: '#374151',
},
})
const fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(terminalRef.current)
termRef.current = term
fitRef.current = fitAddon
const fitTerminal = () => {
try {
fitAddon.fit()
sendResize()
} catch {
// The modal may report zero size during the first paint. Retry below.
}
}
requestAnimationFrame(() => {
fitTerminal()
window.setTimeout(fitTerminal, 80)
window.setTimeout(fitTerminal, 250)
})
let ticket = ''
try {
const response = await createWebSSHTicket(containerName)
ticket = response.data.data?.ticket || ''
} catch {
setStatus('error')
setErrorMsg('WebSSH ticket 创建失败,请重新登录后再试')
return
}
if (!ticket) {
setStatus('error')
setErrorMsg('WebSSH ticket 为空,请重新登录后再试')
return
}
const ws = new WebSocket(buildWebSSHUrl(), [`clicd-ticket.${ticket}`])
ws.binaryType = 'arraybuffer'
wsRef.current = ws
term.writeln(`Connecting to ${containerName} as root...`)
ws.onopen = () => {
setStatus('preparing')
term.writeln('\r\nWebSocket connected. Preparing SSH shell...')
sendResize()
term.focus()
}
ws.onmessage = async (event) => {
setStatus('connected')
if (event.data instanceof ArrayBuffer) {
term.write(new Uint8Array(event.data))
return
}
if (event.data instanceof Blob) {
const buffer = await event.data.arrayBuffer()
term.write(new Uint8Array(buffer))
return
}
term.write(String(event.data))
}
ws.onerror = () => {
setStatus('error')
setErrorMsg('WebSSH 连接失败,请确认容器已运行且 SSH 服务可用')
}
ws.onclose = () => {
if (status !== 'error') {
setStatus((current) => current === 'connected' ? 'disconnected' : current)
}
}
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(new TextEncoder().encode(data))
}
})
const observer = new ResizeObserver(() => {
fitTerminal()
})
observer.observe(terminalRef.current)
resizeObserverRef.current = observer
}
useEffect(() => {
const timer = window.setTimeout(connect, 100)
return () => {
window.clearTimeout(timer)
cleanup()
}
}, [containerName])
return (
<div className="bg-white border border-gray-200 rounded-lg overflow-hidden h-full flex flex-col">
<div className="flex items-center justify-between px-4 py-2.5 border-b border-gray-200 bg-gray-50 shrink-0">
<div className="flex items-center gap-2">
<TerminalSquare className="w-4 h-4 text-gray-600" />
<span className="text-sm font-medium text-black">WebSSH - {containerName}</span>
{status === 'connected' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-green-100 text-green-700">已连接</span>
)}
{status === 'connecting' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">连接中...</span>
)}
{status === 'preparing' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-100 text-yellow-700">SSH preparing...</span>
)}
{status === 'disconnected' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-gray-100 text-gray-600">已断开</span>
)}
{status === 'error' && (
<span className="text-xs px-1.5 py-0.5 rounded bg-red-100 text-red-700">连接失败</span>
)}
</div>
<div className="flex items-center gap-1">
<button
onClick={connect}
className="p-1.5 hover:bg-gray-200 rounded text-gray-500 text-xs"
title="重新连接"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
<button
onClick={onClose}
className="p-1.5 hover:bg-gray-200 rounded text-gray-500"
title="关闭"
>
<X className="w-4 h-4" />
</button>
</div>
</div>
<div className="relative flex-1 bg-black min-h-[500px]">
<div ref={terminalRef} className="absolute inset-0 p-2" />
{status === 'error' && (
<div className="absolute inset-x-0 bottom-0 border-t border-red-900 bg-red-950 px-4 py-2 text-sm text-red-100">
{errorMsg}
</div>
)}
</div>
</div>
)
}