From 0bc497cf2914d9a4a0ffc62af0e424d5f035232d Mon Sep 17 00:00:00 2001 From: Pedro Pilla Date: Sun, 8 Feb 2026 12:34:15 +0000 Subject: [PATCH 1/3] feat(terminal-mgmt): integration --- src/components/Terminal.js | 547 +--------------------------- src/components/TerminalContainer.js | 35 +- src/hooks/useTerminal.js | 297 +++++++-------- src/lib/api.js | 42 --- src/package-lock.json | 37 -- src/package.json | 4 - 6 files changed, 149 insertions(+), 813 deletions(-) diff --git a/src/components/Terminal.js b/src/components/Terminal.js index 4420fed..d569bd1 100644 --- a/src/components/Terminal.js +++ b/src/components/Terminal.js @@ -1,544 +1,13 @@ -// frontend/components/Terminal.js - Enhanced version -import React, { useEffect, useRef, useState, useCallback } from 'react'; -import dynamic from 'next/dynamic'; -import '@xterm/xterm/css/xterm.css'; -import { Button, StatusIndicator } from './common'; +const Terminal = ({ terminalUrl }) => { + if (!terminalUrl) return null; -// Dynamically import xterm with no SSR -const TerminalComponent = dynamic( - () => { - return Promise.all([ - import('@xterm/xterm'), - import('@xterm/addon-fit'), - import('@xterm/addon-web-links'), - import('@xterm/addon-search') - ]).then(([xtermModule, fitAddonModule, webLinksAddonModule, searchAddonModule]) => { - const Terminal = xtermModule.Terminal; - const FitAddon = fitAddonModule.FitAddon; - const WebLinksAddon = webLinksAddonModule.WebLinksAddon; - const SearchAddon = searchAddonModule.SearchAddon; - - // Return the actual component that uses these modules - return ({ terminalId, onConnectionChange }) => { - const terminalRef = useRef(null); - const terminal = useRef(null); - const fitAddon = useRef(null); - const socket = useRef(null); - const searchAddon = useRef(null); - const reconnectTimeout = useRef(null); - const reconnectAttempts = useRef(0); - const eventListeners = useRef([]); // Track all event listeners - const isComponentMounted = useRef(true); // Track component mount state - - - const [connected, setConnected] = useState(false); - const [searchVisible, setSearchVisible] = useState(false); - const [searchTerm, setSearchTerm] = useState(''); - const [currentSearchIndex, setCurrentSearchIndex] = useState(0); - const [totalSearchResults, setTotalSearchResults] = useState(0); - - // Update the connectWebSocket function (around line 50) - const connectWebSocket = useCallback(() => { - if (!isComponentMounted.current) return; - if (!terminalId || !terminal.current) return; - if (socket.current?.readyState === WebSocket.CONNECTING) return; - - // Disconnect existing connection first - disconnectWebSocket(); - - // Show connecting message - terminal.current.writeln('\r\nConnecting to terminal...'); - - try { - const apiBaseUrl = window.__API_BASE_URL__ || 'http://localhost:8080/api/v1'; - const apiUrl = new URL(apiBaseUrl); - const protocol = apiUrl.protocol === 'https:' ? 'wss:' : 'ws:'; - const host = apiUrl.host; - const wsPath = `/api/v1/terminals/${terminalId}/attach`; - const wsUrl = `${protocol}//${host}${wsPath}`; - - console.log(`Creating WebSocket connection to: ${wsUrl}`); - socket.current = new WebSocket(wsUrl); - - // WebSocket event handlers - socket.current.onopen = () => { - console.log(`WebSocket connected for terminal ${terminalId}`); - setConnected(true); - reconnectAttempts.current = 0; - if (onConnectionChange) onConnectionChange(true); - terminal.current.clear(); - terminal.current.writeln('Connected to terminal!'); - terminal.current.writeln(''); - - // Send initial terminal size - sendTerminalSize(); - }; - - socket.current.onclose = (event) => { - if (!isComponentMounted.current) return; - - console.log(`WebSocket closed for terminal ${terminalId}`, event); - setConnected(false); - if (onConnectionChange) onConnectionChange(false); - - // Retry connection if not intentionally closed - if (isComponentMounted.current && event.code !== 1000) { - terminal.current.writeln('\r\nConnection closed. Attempting to reconnect...'); - scheduleReconnect(); - } - }; - - socket.current.onerror = (error) => { - console.error(`WebSocket error for terminal ${terminalId}:`, error); - terminal.current.writeln('\r\nConnection error. Will attempt to reconnect...'); - }; - - socket.current.onmessage = (event) => { - // Handle binary data - if (event.data instanceof Blob) { - const reader = new FileReader(); - reader.onload = () => { - terminal.current.write(new Uint8Array(reader.result)); - }; - reader.readAsArrayBuffer(event.data); - } else { - terminal.current.write(event.data); - } - }; - - // Set up terminal input - terminal.current.onData(data => { - if (socket.current && socket.current.readyState === WebSocket.OPEN) { - socket.current.send(data); - } - }); - } catch (error) { - console.error('Error creating WebSocket connection:', error); - terminal.current.writeln(`\r\nFailed to connect: ${error.message}`); - scheduleReconnect(); - } - }, [terminalId, onConnectionChange]); - - // Schedule reconnection with proper cleanup - const scheduleReconnect = useCallback(() => { - if (!isComponentMounted.current) return; - if (reconnectTimeout.current) return; - - reconnectAttempts.current++; - const delay = calculateReconnectDelay(); - - reconnectTimeout.current = setTimeout(() => { - reconnectTimeout.current = null; - if (isComponentMounted.current) { - connectWebSocket(); - } - }, delay); - }, [connectWebSocket]); - - // Disconnect WebSocket - const disconnectWebSocket = useCallback(() => { - if (socket.current) { - socket.current.onclose = null; // Prevent auto-reconnect on intentional close - socket.current.close(); - socket.current = null; - } - }, []); - - // Calculate reconnect delay with exponential backoff - const calculateReconnectDelay = useCallback(() => { - const baseDelay = 1000; // 1 second - const maxDelay = 120000; // 30 seconds - const delay = Math.min(baseDelay * Math.pow(1.5, reconnectAttempts.current), maxDelay); - - // Add jitter to prevent thundering herd problem - return delay + (Math.random() * 1000); - }, []); - - // Send terminal size to server - const sendTerminalSize = useCallback(() => { - if (!fitAddon.current || !socket.current || socket.current.readyState !== WebSocket.OPEN) { - return; - } - - try { - const dims = fitAddon.current.proposeDimensions(); - if (!dims || !dims.cols || !dims.rows) { - return; - } - - // Create binary message for resize - const sizeMessage = new Uint8Array(5); - sizeMessage[0] = 1; // Resize message type - sizeMessage[1] = dims.cols >> 8; - sizeMessage[2] = dims.cols & 0xff; - sizeMessage[3] = dims.rows >> 8; - sizeMessage[4] = dims.rows & 0xff; - - socket.current.send(sizeMessage); - console.log(`Sent terminal resize: ${dims.cols}x${dims.rows}`); - } catch (error) { - console.error('Error sending terminal size:', error); - } - }, []); - - // Handle search functionality - const performSearch = useCallback((searchForward = true) => { - if (!terminal.current || !searchAddon.current || !searchTerm) return; - - try { - if (searchForward) { - searchAddon.current.findNext(searchTerm, { - incremental: false, - decorations: { - matchBackground: '#444', - matchOverviewRuler: '#888', - activeMatchBackground: '#f90', - activeMatchColorOverviewRuler: '#f90' - } - }); - } else { - searchAddon.current.findPrevious(searchTerm, { - incremental: false, - decorations: { - matchBackground: '#444', - matchOverviewRuler: '#888', - activeMatchBackground: '#f90', - activeMatchColorOverviewRuler: '#f90' - } - }); - } - } catch (error) { - console.error('Search error:', error); - } - }, [searchTerm]); - - // Handle search input change - const handleSearchInputChange = useCallback((value) => { - setSearchTerm(value); - if (value) { - performSearch(); - } - }, [performSearch]); - - // Clear terminal - const clearTerminal = useCallback(() => { - if (terminal.current) { - terminal.current.clear(); - terminal.current.scrollToTop(); - } - }, []); - - // Cleanup function to remove all resources - const cleanup = useCallback(() => { - console.log('Starting terminal cleanup'); - - // Clear reconnect timeout - if (reconnectTimeout.current) { - clearTimeout(reconnectTimeout.current); - reconnectTimeout.current = null; - } - - // Close WebSocket - if (socket.current) { - socket.current.onclose = null; // Prevent reconnection - socket.current.onerror = null; - socket.current.onmessage = null; - socket.current.onopen = null; - - if (socket.current.readyState === WebSocket.OPEN) { - socket.current.close(); - } - socket.current = null; - } - - // Remove all event listeners - eventListeners.current.forEach(({ target, event, handler }) => { - target.removeEventListener(event, handler); - }); - eventListeners.current = []; - - // Dispose terminal and addons - if (searchAddon.current) { - searchAddon.current.dispose(); - searchAddon.current = null; - } - - if (fitAddon.current) { - fitAddon.current.dispose(); - fitAddon.current = null; - } - - if (terminal.current) { - terminal.current.dispose(); - terminal.current = null; - } - - console.log('Terminal cleanup complete'); - }, []); - - // Add event listener with tracking - const addEventListenerTracked = useCallback((target, event, handler) => { - target.addEventListener(event, handler); - eventListeners.current.push({ target, event, handler }); - }, []); - - // Close search - const closeSearch = useCallback(() => { - setSearchVisible(false); - setSearchTerm(''); - if (searchAddon.current) { - searchAddon.current.clearDecorations(); - } - if (terminal.current) { - terminal.current.focus(); - } - }, []); - - // Initialize terminal - useEffect(() => { - isComponentMounted.current = true; - - if (!terminalRef.current || !terminalId) return; - // Create terminal instance - terminal.current = new Terminal({ - fontFamily: 'Menlo, Monaco, "Courier New", monospace', - fontSize: 14, - rows: 24, - cursorBlink: true, - theme: { - background: '#1e1e1e', - foreground: '#d4d4d4' - } - }); - - // Create addons - fitAddon.current = new FitAddon(); - searchAddon.current = new SearchAddon(); - const webLinksAddon = new WebLinksAddon(); - - // Load addons - terminal.current.loadAddon(fitAddon.current); - terminal.current.loadAddon(searchAddon.current); - terminal.current.loadAddon(webLinksAddon); - - // Handle search results - searchAddon.current.onDidChangeResults(results => { - setCurrentSearchIndex(results ? results.resultIndex : 0); - setTotalSearchResults(results ? results.resultCount : 0); - }); - - // Open terminal - terminal.current.open(terminalRef.current); - - // Initial fit after terminal is open - setTimeout(() => { - if (fitAddon.current) { - try { - fitAddon.current.fit(); - console.log('Terminal fitted successfully'); - } catch (error) { - console.error('Terminal fit error:', error); - } - } - connectWebSocket(); - }, 100); - - // Cleanup - return () => { - isComponentMounted.current = false; - cleanup(); - }; - }, [terminalId, cleanup]); - - // Handle resize with tracked event listener - useEffect(() => { - const handleResize = () => { - if (!isComponentMounted.current) return; - if (fitAddon.current && terminal.current) { - try { - fitAddon.current.fit(); - sendTerminalSize(); - } catch (error) { - console.error('Resize error:', error); - } - } - }; - - addEventListenerTracked(window, 'resize', handleResize); - - return () => { - // Cleanup happens in main useEffect - }; - }, [sendTerminalSize, addEventListenerTracked]); - - // Handle keyboard shortcuts - useEffect(() => { - const handleKeyDown = (e) => { - if (!isComponentMounted.current) return; - // Ctrl+F or Cmd+F for search - if ((e.ctrlKey || e.metaKey) && e.key === 'f') { - e.preventDefault(); - setSearchVisible(true); - - // Focus search input after state update - setTimeout(() => { - const searchInput = document.getElementById('terminal-search-input'); - if (searchInput) searchInput.focus(); - }, 0); - } - - // Escape to close search - if (e.key === 'Escape' && searchVisible) { - closeSearch(); - } - }; - addEventListenerTracked(window, 'keydown', handleKeyDown); - - return () => { - // Cleanup happens in main useEffect - }; - }, [searchVisible, closeSearch, addEventListenerTracked]); - - return ( -
- {/* Connection indicator */} -
- -
- - {/* Search bar */} - {searchVisible && ( -
- handleSearchInputChange(e.target.value)} - placeholder="Search..." - className="flex-1 px-3 py-1 text-sm text-white bg-gray-700 border border-gray-600 rounded-l" - onKeyPress={(e) => { - if (e.key === 'Enter') { - performSearch(); - } - }} - /> -
- {totalSearchResults > 0 ? ( - {currentSearchIndex + 1}/{totalSearchResults} - ) : ( - 0/0 - )} -
- - - -
- )} - - {/* Terminal container */} -
-
-
- - {/* Terminal toolbar */} -
-
- - -
- -
- -
-
-
- ); - }; - }); - }, - { - ssr: false, - loading: () => ( -
-
-
- Loading terminal... -
-
- ) - } -); - -const Terminal = ({ terminalId, onConnectionChange }) => { return ( -
- -
+