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