feat(frontend): add SSH terminal component with xterm.js
This commit is contained in:
200
frontend/src/components/fleet/SSHTerminal.tsx
Normal file
200
frontend/src/components/fleet/SSHTerminal.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useMutation } from '@tanstack/react-query'
|
||||
import { Terminal as TerminalIcon, Maximize2, Minimize2, X } from 'lucide-react'
|
||||
import { Terminal } from '@xterm/xterm'
|
||||
import { FitAddon } from '@xterm/addon-fit'
|
||||
import '@xterm/xterm/css/xterm.css'
|
||||
import { remoteAccessApi } from '@/lib/api'
|
||||
|
||||
interface SSHTerminalProps {
|
||||
tenantId: string
|
||||
deviceId: string
|
||||
deviceName: string
|
||||
}
|
||||
|
||||
type State = 'closed' | 'connecting' | 'connected' | 'disconnected'
|
||||
|
||||
export function SSHTerminal({ tenantId, deviceId, deviceName }: SSHTerminalProps) {
|
||||
const [state, setState] = useState<State>('closed')
|
||||
const [expanded, setExpanded] = useState(false)
|
||||
const termRef = useRef<HTMLDivElement>(null)
|
||||
const terminalRef = useRef<Terminal | null>(null)
|
||||
const fitAddonRef = useRef<FitAddon | null>(null)
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const resizeTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
const openMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const cols = terminalRef.current?.cols || 80
|
||||
const rows = terminalRef.current?.rows || 24
|
||||
return remoteAccessApi.openSSH(tenantId, deviceId, cols, rows)
|
||||
},
|
||||
onSuccess: (data) => {
|
||||
const { websocket_url } = data
|
||||
const scheme = location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const url = `${scheme}://${location.host}${websocket_url}`
|
||||
connectWebSocket(url)
|
||||
},
|
||||
onError: () => {
|
||||
terminalRef.current?.write('\r\n\x1b[31mFailed to create SSH session.\x1b[0m\r\n')
|
||||
setState('disconnected')
|
||||
},
|
||||
})
|
||||
|
||||
const connectWebSocket = useCallback((url: string) => {
|
||||
const ws = new WebSocket(url)
|
||||
ws.binaryType = 'arraybuffer'
|
||||
wsRef.current = ws
|
||||
|
||||
ws.onopen = () => {
|
||||
setState('connected')
|
||||
terminalRef.current?.write('Connecting to router...\r\n')
|
||||
}
|
||||
|
||||
ws.onmessage = (event) => {
|
||||
if (event.data instanceof ArrayBuffer) {
|
||||
terminalRef.current?.write(new Uint8Array(event.data))
|
||||
}
|
||||
}
|
||||
|
||||
ws.onclose = (event) => {
|
||||
setState('disconnected')
|
||||
const reason = event.code === 1006 ? 'Connection dropped'
|
||||
: event.code === 1008 ? 'Authentication failed'
|
||||
: event.code === 1011 ? 'Server error'
|
||||
: 'Session closed'
|
||||
terminalRef.current?.write(`\r\n\x1b[31m${reason}.\x1b[0m\r\n`)
|
||||
}
|
||||
|
||||
ws.onerror = () => {
|
||||
terminalRef.current?.write('\r\n\x1b[31mConnection error.\x1b[0m\r\n')
|
||||
}
|
||||
}, [])
|
||||
|
||||
const initTerminal = useCallback(() => {
|
||||
if (!termRef.current || terminalRef.current) return
|
||||
|
||||
const isDark = document.documentElement.classList.contains('dark')
|
||||
const term = new Terminal({
|
||||
cursorBlink: true,
|
||||
fontFamily: 'Geist Mono, monospace',
|
||||
fontSize: 14,
|
||||
scrollback: 2000,
|
||||
convertEol: true,
|
||||
theme: isDark
|
||||
? { background: '#09090b', foreground: '#fafafa' }
|
||||
: { background: '#ffffff', foreground: '#09090b' },
|
||||
})
|
||||
|
||||
const fitAddon = new FitAddon()
|
||||
term.loadAddon(fitAddon)
|
||||
term.open(termRef.current)
|
||||
fitAddon.fit()
|
||||
|
||||
terminalRef.current = term
|
||||
fitAddonRef.current = fitAddon
|
||||
|
||||
// User input → WebSocket
|
||||
term.onData((data) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
const encoder = new TextEncoder()
|
||||
wsRef.current.send(encoder.encode(data))
|
||||
}
|
||||
})
|
||||
|
||||
// Resize → throttled WebSocket message
|
||||
term.onResize(({ cols, rows }) => {
|
||||
if (resizeTimerRef.current) clearTimeout(resizeTimerRef.current)
|
||||
resizeTimerRef.current = setTimeout(() => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify({ type: 'resize', cols, rows }))
|
||||
}
|
||||
}, 75)
|
||||
})
|
||||
|
||||
// Refit on container resize
|
||||
const observer = new ResizeObserver(() => fitAddon.fit())
|
||||
observer.observe(termRef.current)
|
||||
|
||||
return () => {
|
||||
observer.disconnect()
|
||||
term.dispose()
|
||||
terminalRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Cleanup on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
wsRef.current?.close()
|
||||
terminalRef.current?.dispose()
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleOpen = () => {
|
||||
setState('connecting')
|
||||
requestAnimationFrame(() => {
|
||||
initTerminal()
|
||||
openMutation.mutate()
|
||||
})
|
||||
}
|
||||
|
||||
const handleReconnect = () => {
|
||||
terminalRef.current?.dispose()
|
||||
terminalRef.current = null
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
setState('connecting')
|
||||
requestAnimationFrame(() => {
|
||||
initTerminal()
|
||||
openMutation.mutate()
|
||||
})
|
||||
}
|
||||
|
||||
const handleDisconnect = () => {
|
||||
wsRef.current?.close()
|
||||
terminalRef.current?.dispose()
|
||||
terminalRef.current = null
|
||||
setState('closed')
|
||||
}
|
||||
|
||||
if (state === 'closed') {
|
||||
return (
|
||||
<button
|
||||
onClick={handleOpen}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 rounded-md bg-primary text-primary-foreground hover:bg-primary/90"
|
||||
>
|
||||
<TerminalIcon className="h-4 w-4" />
|
||||
SSH Terminal
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`rounded-md border overflow-hidden ${expanded ? 'fixed inset-4 z-50 bg-background' : ''}`}>
|
||||
<div className="flex items-center justify-between px-3 py-2 bg-muted/50 border-b">
|
||||
<span className="text-sm font-medium">SSH: {deviceName}</span>
|
||||
<div className="flex gap-1">
|
||||
<button onClick={() => setExpanded(!expanded)} className="p-1 hover:bg-accent rounded">
|
||||
{expanded ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</button>
|
||||
{state === 'disconnected' ? (
|
||||
<button onClick={handleReconnect} className="px-2 py-1 text-xs rounded bg-primary text-primary-foreground">
|
||||
Reconnect
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={handleDisconnect} className="p-1 hover:bg-accent rounded">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div ref={termRef} className="h-80" tabIndex={0} style={expanded ? { height: 'calc(100% - 40px)' } : {}} />
|
||||
{state === 'connected' && (
|
||||
<div className="px-3 py-1 text-xs text-muted-foreground border-t">
|
||||
SSH session active — idle timeout: 15 min
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user