Fix api routes and missing translations and improve reconnection for terminals

This commit is contained in:
LukeGus
2025-09-11 00:55:48 -05:00
parent eeea3479d1
commit 3e8e15508a
9 changed files with 228 additions and 173 deletions

View File

@@ -29,6 +29,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const pingIntervalRef = useRef<NodeJS.Timeout | null>(null);
const [visible, setVisible] = useState(false);
const [isConnected, setIsConnected] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [connectionError, setConnectionError] = useState<string | null>(null);
const isVisibleRef = useRef<boolean>(false);
const reconnectTimeoutRef = useRef<NodeJS.Timeout | null>(null);
@@ -36,7 +37,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
const maxReconnectAttempts = 3;
const isUnmountingRef = useRef(false);
const shouldNotReconnectRef = useRef(false);
const isReconnectingRef = useRef(false);
const connectionTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSentSizeRef = useRef<{ cols: number; rows: number } | null>(null);
const pendingSizeRef = useRef<{ cols: number; rows: number } | null>(null);
@@ -76,6 +78,7 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
disconnect: () => {
isUnmountingRef.current = true;
shouldNotReconnectRef.current = true;
isReconnectingRef.current = false;
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
@@ -84,8 +87,13 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
webSocketRef.current?.close();
setIsConnected(false);
setIsConnecting(false); // Clear connecting state
},
fit: () => {
fitAddonRef.current?.fit();
@@ -135,31 +143,54 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}
function attemptReconnection() {
// Don't attempt reconnection if component is unmounting or if we shouldn't reconnect
if (isUnmountingRef.current || shouldNotReconnectRef.current) {
// Don't attempt reconnection if component is unmounting, shouldn't reconnect, or already reconnecting
if (isUnmountingRef.current || shouldNotReconnectRef.current || isReconnectingRef.current) {
return;
}
// Check if we've already reached max attempts
if (reconnectAttempts.current >= maxReconnectAttempts) {
toast.error(t('terminal.maxReconnectAttemptsReached'));
return;
}
// Set reconnecting flag to prevent multiple simultaneous attempts
isReconnectingRef.current = true;
// Clear terminal immediately to prevent showing last line
if (terminal) {
terminal.clear();
}
// Increment attempt counter
reconnectAttempts.current++;
// Show toast with current attempt number
toast.info(t('terminal.reconnecting', { attempt: reconnectAttempts.current, max: maxReconnectAttempts }));
reconnectTimeoutRef.current = setTimeout(() => {
// Check again if component is still mounted and should reconnect
if (isUnmountingRef.current || shouldNotReconnectRef.current) {
isReconnectingRef.current = false;
return;
}
// Check if we haven't exceeded max attempts during the timeout
if (reconnectAttempts.current > maxReconnectAttempts) {
isReconnectingRef.current = false;
return;
}
if (terminal && hostConfig) {
// Clear terminal before reconnecting
// Ensure terminal is clear before reconnecting
terminal.clear();
const cols = terminal.cols;
const rows = terminal.rows;
connectToHost(cols, rows);
}
// Reset reconnecting flag after attempting connection
isReconnectingRef.current = false;
}, 2000 * reconnectAttempts.current); // Exponential backoff
}
@@ -187,6 +218,8 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
wasDisconnectedBySSH.current = false;
setConnectionError(null);
shouldNotReconnectRef.current = false; // Reset reconnection flag
isReconnectingRef.current = false; // Reset reconnecting flag
setIsConnecting(true); // Set connecting state
setupWebSocketListeners(ws, cols, rows);
}
@@ -195,12 +228,27 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
function setupWebSocketListeners(ws: WebSocket, cols: number, rows: number) {
ws.addEventListener('open', () => {
setIsConnected(true);
// Show reconnected toast if this was a reconnection attempt
if (reconnectAttempts.current > 0) {
toast.success(t('terminal.reconnected'));
}
reconnectAttempts.current = 0; // Reset on successful connection
// Don't set isConnected to true here - wait for actual SSH connection
// Don't show reconnected toast here - wait for actual connection confirmation
// Set a timeout for SSH connection establishment
connectionTimeoutRef.current = setTimeout(() => {
if (!isConnected) {
// SSH connection didn't establish within timeout
// Clear terminal immediately when connection times out
if (terminal) {
terminal.clear();
}
toast.error(t('terminal.connectionTimeout'));
if (webSocketRef.current) {
webSocketRef.current.close();
}
// Attempt reconnection if this was a reconnection attempt
if (reconnectAttempts.current > 0) {
attemptReconnection();
}
}
}, 10000); // 10 second timeout for SSH connection
ws.send(JSON.stringify({type: 'connectToHost', data: {cols, rows, hostConfig}}));
terminal.onData((data) => {
@@ -250,6 +298,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
errorMessage.toLowerCase().includes('network')) {
toast.error(t('terminal.connectionError', { message: errorMessage }));
setIsConnected(false);
// Clear terminal immediately when connection error occurs
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
attemptReconnection();
return;
}
@@ -258,9 +312,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
toast.error(t('terminal.error', { message: errorMessage }));
} else if (msg.type === 'connected') {
setIsConnected(true);
setIsConnecting(false); // Clear connecting state
// Clear connection timeout since SSH connection is established
if (connectionTimeoutRef.current) {
clearTimeout(connectionTimeoutRef.current);
connectionTimeoutRef.current = null;
}
// Show reconnected toast if this was a reconnection attempt
if (reconnectAttempts.current > 0) {
toast.success(t('terminal.reconnected'));
}
// Reset reconnection counter and flags on successful connection
reconnectAttempts.current = 0;
isReconnectingRef.current = false;
} else if (msg.type === 'disconnected') {
wasDisconnectedBySSH.current = true;
setIsConnected(false);
// Clear terminal immediately when disconnected
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
// Attempt reconnection for disconnections
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
attemptReconnection();
@@ -273,6 +346,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener('close', (event) => {
setIsConnected(false);
// Clear terminal immediately when connection closes
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
if (!wasDisconnectedBySSH.current && !isUnmountingRef.current && !shouldNotReconnectRef.current) {
// Attempt reconnection for unexpected disconnections
attemptReconnection();
@@ -282,6 +361,12 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
ws.addEventListener('error', (event) => {
setIsConnected(false);
setConnectionError(t('terminal.websocketError'));
// Clear terminal immediately when WebSocket error occurs
if (terminal) {
terminal.clear();
}
// Set connecting state immediately for reconnection
setIsConnecting(true);
// Attempt reconnection for WebSocket errors
if (!isUnmountingRef.current && !shouldNotReconnectRef.current) {
attemptReconnection();
@@ -429,11 +514,14 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
return () => {
isUnmountingRef.current = true;
shouldNotReconnectRef.current = true;
isReconnectingRef.current = false;
setIsConnecting(false); // Clear connecting state
resizeObserver.disconnect();
element?.removeEventListener('contextmenu', handleContextMenu);
if (notifyTimerRef.current) clearTimeout(notifyTimerRef.current);
if (resizeTimeout.current) clearTimeout(resizeTimeout.current);
if (reconnectTimeoutRef.current) clearTimeout(reconnectTimeoutRef.current);
if (connectionTimeoutRef.current) clearTimeout(connectionTimeoutRef.current);
if (pingIntervalRef.current) {
clearInterval(pingIntervalRef.current);
pingIntervalRef.current = null;
@@ -474,15 +562,28 @@ export const Terminal = forwardRef<any, SSHTerminalProps>(function SSHTerminal(
}, [splitScreen, isVisible, terminal]);
return (
<div
ref={xtermRef}
className={`h-full w-full m-1 transition-opacity duration-200 ${visible && isVisible ? 'opacity-100' : 'opacity-0'} overflow-hidden`}
onClick={() => {
if (terminal && !splitScreen) {
terminal.focus();
}
}}
/>
<div className="h-full w-full m-1 relative">
{/* Terminal */}
<div
ref={xtermRef}
className={`h-full w-full transition-opacity duration-200 ${visible && isVisible && !isConnecting ? 'opacity-100' : 'opacity-0'} overflow-hidden`}
onClick={() => {
if (terminal && !splitScreen) {
terminal.focus();
}
}}
/>
{/* Connecting State */}
{isConnecting && (
<div className="absolute inset-0 flex items-center justify-center bg-dark-bg">
<div className="flex items-center gap-3">
<div className="w-6 h-6 border-2 border-blue-400 border-t-transparent rounded-full animate-spin"></div>
<span className="text-gray-300">{t('terminal.connecting')}</span>
</div>
</div>
)}
</div>
);
});