diff --git a/frontend/src/components/fleet/SSHTerminal.tsx b/frontend/src/components/fleet/SSHTerminal.tsx new file mode 100644 index 0000000..60874cd --- /dev/null +++ b/frontend/src/components/fleet/SSHTerminal.tsx @@ -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('closed') + const [expanded, setExpanded] = useState(false) + const termRef = useRef(null) + const terminalRef = useRef(null) + const fitAddonRef = useRef(null) + const wsRef = useRef(null) + const resizeTimerRef = useRef | 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 ( + + ) + } + + return ( +
+
+ SSH: {deviceName} +
+ + {state === 'disconnected' ? ( + + ) : ( + + )} +
+
+
+ {state === 'connected' && ( +
+ SSH session active — idle timeout: 15 min +
+ )} +
+ ) +}