import { useState, useEffect } from 'react'; import Sidebar from './components/Sidebar' import MachineDetails from './components/MachineDetails' import ActionPanel from './components/ActionPanel' import LoginScreen from './components/LoginScreen' import RestoreConnectionsModal from './components/RestoreConnectionsModal' import { AddMachineModal } from './components/AddMachineModal' import { SaveConfirmationModal } from './components/SaveConfirmationModal' import { GuacamoleViewerWithSuspense, MachineCredentialsModalWithSuspense } from './components/LazyComponents' import { ErrorBoundary } from './components/ErrorBoundary' import { ToastProvider } from './components/Toast/ToastProvider' import { BulkActionsToolbar } from './components/BulkActionsToolbar' import { BulkHealthCheckProgressModal } from './components/BulkHealthCheckProgressModal' import { BulkResultsModal } from './components/BulkResultsModal' import { BulkSSHCommandModal } from './components/BulkSSHCommandModal' import { BulkSSHResultsModal } from './components/BulkSSHResultsModal' import { useMachineStore } from './store/machineStore' import { authService } from './services/auth-service' import { guacamoleService, type ActiveConnection } from './services/guacamole-api' import { machineAvailabilityService } from './services/machine-availability' import { bulkOperationsApi } from './services/bulk-operations-api' import { websocketNotificationService, type WebSocketEvent } from './services/websocket-notifications' import { log } from './utils/logger' import { ErrorHandler } from './utils/errorHandler' import { useToastHelpers } from './components/Toast/ToastProvider' import { ProtocolOption } from './utils/protocolHelper' import { Machine } from './types' // Interface for connection data interface MachineConnection { machineId: string; connectionUrl: string; connectionId: string; connectedAt: Date; expiresAt: Date; // TTL expiration time from Redis ttlTimerId?: ReturnType; // Timer ID for auto-disconnect enableSftp?: boolean; // Flag to enable SFTP for SSH connections } function AppContent() { const selectedMachine = useMachineStore(state => state.selectedMachine); const machines = useMachineStore(state => state.machines); const savedMachines = useMachineStore(state => state.savedMachines); // For filtering saved-only operations const showAddMachineModal = useMachineStore(state => state.showAddMachineModal); const setShowAddMachineModal = useMachineStore(state => state.setShowAddMachineModal); const showSaveConfirmModal = useMachineStore(state => state.showSaveConfirmModal); const fetchSavedMachines = useMachineStore(state => state.fetchSavedMachines); const resetState = useMachineStore(state => state.resetState); const [isCredentialsModalOpen, setIsCredentialsModalOpen] = useState(false); // Store connections for each machine separately const [machineConnections, setMachineConnections] = useState>(() => { // Restore connections from localStorage on load try { const saved = localStorage.getItem('machine-connections'); if (saved) { const parsed = JSON.parse(saved); const restoredMap = new Map(); for (const [machineId, conn] of parsed) { restoredMap.set(machineId, { ...conn, connectedAt: new Date(conn.connectedAt), expiresAt: new Date(conn.expiresAt), // ttlTimerId not restored - will be recreated separately }); } log.info('app', 'Restored connections from localStorage', { count: restoredMap.size }); return restoredMap; } } catch (error) { log.error('app', 'Failed to restore connections from localStorage', { error }); } return new Map(); }); // Store machine availability statuses const [machineStatuses, setMachineStatuses] = useState>(new Map()); const [, setIsConnecting] = useState(false); const [currentUser, setCurrentUser] = useState(authService.getCurrentUser()); const [tokenWarning, setTokenWarning] = useState(false); const [isAuthenticated, setIsAuthenticated] = useState(authService.isAuthenticated()); // Bulk Operations State const [selectedMachineIds, setSelectedMachineIds] = useState([]); const [bulkHealthCheckInProgress, setBulkHealthCheckInProgress] = useState(false); const [bulkHealthCheckProgress, setBulkHealthCheckProgress] = useState({ total: 0, checked: 0, available: 0, unavailable: 0, currentMachine: undefined as string | undefined }); const [bulkHealthCheckResults, setBulkHealthCheckResults] = useState(null); const [showBulkResultsModal, setShowBulkResultsModal] = useState(false); // Bulk SSH Command State const [showSSHCommandModal, setShowSSHCommandModal] = useState(false); const [sshCommandResults, setSSHCommandResults] = useState(null); const [showSSHResultsModal, setShowSSHResultsModal] = useState(false); // Restore Connections State const [showRestoreConnectionsModal, setShowRestoreConnectionsModal] = useState(false); const [activeConnections, setActiveConnections] = useState([]); // WebSocket State const [_isWebSocketConnected, setIsWebSocketConnected] = useState(false); // Toast helpers for displaying errors const { error: showError, success: showSuccess, warning: showWarning } = useToastHelpers(); // Get current connection for selected machine const currentConnection = selectedMachine ? machineConnections.get(selectedMachine.id) : null; // Function to get information about all connections (for debugging) // const _getConnectionsInfo = () => { // const connections = Array.from(machineConnections.entries()).map(([machineId, conn]) => ({ // machineId, // connectionId: conn.connectionId, // connectedAt: conn.connectedAt.toISOString() // })); // // log.info('app', 'Active connections summary', { // totalConnections: connections.length, // connections // }); // // return connections; // }; // Save connections to localStorage on change useEffect(() => { try { // Convert Map to array for JSON storage const connectionsArray = Array.from(machineConnections.entries()).map(([machineId, conn]) => [ machineId, { machineId: conn.machineId, connectionUrl: conn.connectionUrl, connectionId: conn.connectionId, connectedAt: conn.connectedAt.toISOString(), expiresAt: conn.expiresAt.toISOString(), // ttlTimerId not saved - will be recreated on restore } ]); localStorage.setItem('machine-connections', JSON.stringify(connectionsArray)); if (machineConnections.size > 0) { log.debug('app', 'Machine connections saved to localStorage', { totalConnections: machineConnections.size, machineIds: Array.from(machineConnections.keys()) }); } } catch (error) { log.error('app', 'Failed to save connections to localStorage', { error }); } }, [machineConnections]); useEffect(() => { // Check authentication on load setIsAuthenticated(authService.isAuthenticated()); // Set token expiration handler authService.setOnTokenExpired(() => { log.warn('app', 'Token expired, forcing re-login'); // Clear all TTL timers before clearing connections machineConnections.forEach((connection) => { if (connection.ttlTimerId) { clearTimeout(connection.ttlTimerId); } }); // Disconnect WebSocket websocketNotificationService.disconnect(); setIsWebSocketConnected(false); // CRITICAL: Clear all state on token expiration resetState(); setCurrentUser(null); setMachineConnections(new Map()); // Clear all connections setMachineStatuses(new Map()); // Clear statuses setTokenWarning(false); setIsAuthenticated(false); // Clear localStorage localStorage.removeItem('machine-connections'); localStorage.removeItem('machine-store'); }); // Set token warning handler authService.setOnTokenWarning((timeLeft: number) => { if (timeLeft <= 60) { // Show warning if less than a minute left setTokenWarning(true); } }); // Cleanup all TTL timers on component unmount return () => { machineConnections.forEach((connection) => { if (connection.ttlTimerId) { clearTimeout(connection.ttlTimerId); } }); // Disconnect WebSocket websocketNotificationService.disconnect(); log.debug('app', 'Cleaned up all TTL timers and WebSocket on unmount'); }; }, [machineConnections]); // WebSocket Event Handlers useEffect(() => { if (!isAuthenticated) { return; } log.info('websocket', 'Setting up WebSocket event handlers'); // WebSocket message handler (listen to all events) const unsubscribeMessages = websocketNotificationService.on('*', (event: WebSocketEvent) => { log.info('websocket', 'Received WebSocket event', { type: event.type }); switch (event.type) { case 'connected': { log.info('websocket', 'WebSocket connected successfully'); setIsWebSocketConnected(true); break; } case 'connection_expired': { const { connection_id, hostname, protocol } = event.data; log.warn('websocket', 'Connection expired notification received', { connection_id, hostname, protocol }); // Find machine by connection_id and remove its connection const machineEntry = Array.from(machineConnections.entries()).find( ([, conn]) => conn.connectionId === connection_id ); if (machineEntry) { const [machineId, connection] = machineEntry; // Clear timer if exists if (connection.ttlTimerId) { clearTimeout(connection.ttlTimerId); } // Remove connection setMachineConnections(prev => { const newMap = new Map(prev); newMap.delete(machineId); return newMap; }); showWarning( 'Connection Expired', `Your connection to ${hostname} (${protocol.toUpperCase()}) has expired.` ); log.info('websocket', 'Removed expired connection', { machineId, connection_id }); } break; } case 'connection_will_expire': { const { connection_id, hostname, protocol, minutes_remaining } = event.data; log.warn('websocket', 'Connection will expire warning received', { connection_id, hostname, protocol, minutes_remaining }); showWarning( 'Connection Expiring Soon', `Your connection to ${hostname} (${protocol.toUpperCase()}) will expire in ${minutes_remaining} minutes.` ); break; } case 'connection_extended': { const { connection_id, hostname, new_expires_at, additional_minutes } = event.data; log.info('websocket', 'Connection extended notification received', { connection_id, hostname, new_expires_at, additional_minutes }); // Update expiresAt for connection const machineEntry = Array.from(machineConnections.entries()).find( ([, conn]) => conn.connectionId === connection_id ); if (machineEntry) { const [machineId] = machineEntry; setMachineConnections(prev => { const newMap = new Map(prev); const conn = newMap.get(machineId); if (conn) { conn.expiresAt = new Date(new_expires_at); } return newMap; }); showSuccess( 'Connection Extended', `Connection to ${hostname} extended by ${additional_minutes} minutes.` ); log.info('websocket', 'Updated connection expiration time', { machineId, new_expires_at }); } break; } case 'jwt_will_expire': { log.warn('websocket', 'JWT will expire soon', { data: event.data }); showWarning( 'Session Expiring Soon', `Your session will expire in ${event.data?.minutes_remaining || 5} minutes.` ); break; } case 'jwt_expired': { log.error('websocket', 'JWT expired', { data: event.data }); showError('Session Expired', 'Your session has expired. Please log in again.'); break; } default: log.debug('websocket', 'Unhandled WebSocket event type', { type: event.type }); } }); // Cleanup on unmount or logout return () => { log.info('websocket', 'Cleaning up WebSocket event handlers'); unsubscribeMessages(); }; }, [isAuthenticated, machineConnections, showWarning, showError, showSuccess]); // Periodic active connections check (every 30 seconds) useEffect(() => { if (!isAuthenticated || machineConnections.size === 0) { return; } log.info('app', 'Starting periodic connection check', { connectionCount: machineConnections.size }); const interval = setInterval(() => { const now = new Date(); machineConnections.forEach((connection, machineId) => { const timeUntilExpiry = connection.expiresAt.getTime() - now.getTime(); const minutesUntilExpiry = Math.floor(timeUntilExpiry / 60000); log.debug('app', 'Checking connection expiry', { machineId, minutesUntilExpiry, expiresAt: connection.expiresAt.toISOString() }); // If less than 0 minutes left - connection expired if (minutesUntilExpiry < 0) { log.warn('app', 'Connection expired locally (client-side check)', { machineId, connectionId: connection.connectionId, }); // Clear timer if exists if (connection.ttlTimerId) { clearTimeout(connection.ttlTimerId); } // Remove connection setMachineConnections(prev => { const newMap = new Map(prev); newMap.delete(machineId); return newMap; }); } }); }, 30000); // 30 seconds return () => { log.info('app', 'Stopping periodic connection check'); clearInterval(interval); }; }, [isAuthenticated, machineConnections]); const handleLogin = async (username: string, password: string) => { try { // CRITICAL: Clear all previous data before new login // This protects from data leaks between users log.info('app', 'Clearing previous session before new login'); // Clear TTL timers machineConnections.forEach((connection) => { if (connection.ttlTimerId) { clearTimeout(connection.ttlTimerId); } }); // Clear store and state resetState(); setMachineConnections(new Map()); setMachineStatuses(new Map()); setCurrentUser(null); log.info('app', 'Previous session cleared, proceeding with login'); await authService.login(username, password); const user = authService.getCurrentUser(); setCurrentUser(user); setIsAuthenticated(true); // Check user role and show appropriate notification if (user?.role === 'GUEST') { showWarning( 'Limited Access', 'You are logged in as GUEST. You can only view connections but cannot create new ones. Contact your administrator for USER role.' ); log.warn('app', 'User logged in with GUEST role - limited access', { username: user.username }); } else { showSuccess('Login successful', `Welcome, ${user?.username || 'User'}!`); log.info('app', 'User logged in successfully', { username: user?.username, role: user?.role }); } // Load user's saved machines try { await fetchSavedMachines(); log.info('app', 'Saved machines loaded successfully'); } catch (err) { log.warn('app', 'Failed to load saved machines', { error: err }); // Don't block login if machines fail to load } // Check active connections for restoration // Runs asynchronously, doesn't block login checkActiveConnections().catch(err => { log.error('app', 'Failed to check active connections', { error: err }); }); // Connect WebSocket for notifications try { const token = await authService.getToken(); if (token) { await websocketNotificationService.connect(token); log.info('app', 'WebSocket connected successfully after login'); } else { log.warn('app', 'No access token available for WebSocket connection'); } } catch (err) { log.error('app', 'Failed to connect WebSocket', { error: err }); // Don't block login if WebSocket fails to connect } } catch (error) { log.error('app', 'Login failed', { error }); const appError = await ErrorHandler.handleApiError(error); showError('Login failed', appError.userMessage); throw error; } }; const handleLogout = async () => { try { // Clear all TTL timers before logout machineConnections.forEach((connection) => { if (connection.ttlTimerId) { clearTimeout(connection.ttlTimerId); } }); await authService.logout(); // Disconnect WebSocket websocketNotificationService.disconnect(); setIsWebSocketConnected(false); log.info('app', 'WebSocket disconnected on logout'); // CRITICAL: Clear all state to prevent data leaks between users resetState(); setCurrentUser(null); setMachineConnections(new Map()); // Clear all connections setMachineStatuses(new Map()); // Clear statuses setIsAuthenticated(false); // Clear localStorage localStorage.removeItem('machine-connections'); localStorage.removeItem('machine-store'); log.info('app', 'User logged out, all data cleared (security)'); } catch (error) { log.error('app', 'Logout failed', { error }); } }; // Check active connections for restoration const checkActiveConnections = async () => { try { log.info('app', 'Checking for active connections to restore'); const connections = await guacamoleService.getActiveConnections(); if (connections.length > 0) { log.info('app', 'Found active connections', { count: connections.length, connections: connections.map(c => ({ id: c.connection_id, hostname: c.hostname, protocol: c.protocol, remaining_minutes: c.remaining_minutes })) }); setActiveConnections(connections); setShowRestoreConnectionsModal(true); } else { log.info('app', 'No active connections to restore'); } } catch (error) { log.error('app', 'Failed to check active connections', { error }); // Don't show error to user - not critical } }; // Restore connection const handleRestoreConnection = async (connection: ActiveConnection) => { try { log.info('app', 'Restoring connection', { connection_id: connection.connection_id, hostname: connection.hostname, protocol: connection.protocol }); if (!connection.connection_url) { throw new Error('Connection URL is not available'); } // Close modal window setShowRestoreConnectionsModal(false); // Open connection in new window (same as creating new) const connectionUrl = guacamoleService.getConnectionUrl(connection.connection_url); log.info('app', 'Opening restored connection', { connectionUrl: connectionUrl.substring(0, 100) + '...' }); // IMPROVED: Find existing machine by hostname // First check by name, then by ip let existingMachine = machines.find(m => m.name.toLowerCase() === connection.hostname.toLowerCase() || m.ip.toLowerCase() === connection.hostname.toLowerCase() ); // If machine not found, create temporary if (!existingMachine) { log.info('app', 'Machine not found in sidebar, creating temporary machine', { hostname: connection.hostname }); existingMachine = { id: `restored-${connection.connection_id}`, name: connection.hostname, ip: connection.hostname, status: 'running', os: 'unknown', hypervisor: 'unknown', specs: { cpu: 'unknown', ram: 'unknown', disk: 'unknown' }, testLinks: [], logs: [] }; } else { log.info('app', 'Found existing machine in sidebar', { machineId: existingMachine.id, machineName: existingMachine.name, hostname: connection.hostname }); } // Create connection record const newConnection: MachineConnection = { machineId: existingMachine.id, connectionUrl: connection.connection_url, connectionId: connection.connection_id, connectedAt: new Date(connection.created_at), expiresAt: new Date(connection.expires_at) }; // Add to Map setMachineConnections(prev => { const newMap = new Map(prev); newMap.set(existingMachine.id, newConnection); return newMap; }); showSuccess( 'Подключение восстановлено', `Восстановлено подключение к ${connection.hostname} (${connection.protocol.toUpperCase()}). Осталось ${connection.remaining_minutes} мин.` ); log.info('app', 'Connection restored successfully', { connection_id: connection.connection_id, hostname: connection.hostname, machineId: existingMachine.id, usedExisting: existingMachine.id !== `restored-${connection.connection_id}` }); } catch (error) { log.error('app', 'Failed to restore connection', { error }); const appError = await ErrorHandler.handleApiError(error); showError('Ошибка восстановления', appError.userMessage); } }; // Restore multiple connections const handleRestoreMultipleConnections = async (connections: ActiveConnection[]) => { if (connections.length === 0) { return; } log.info('app', 'Restoring multiple connections', { count: connections.length, connections: connections.map(c => ({ id: c.connection_id, hostname: c.hostname })) }); // Close modal immediately setShowRestoreConnectionsModal(false); let successCount = 0; let failedCount = 0; // Restore connections sequentially for (const connection of connections) { try { log.info('app', 'Restoring connection', { connection_id: connection.connection_id, hostname: connection.hostname, protocol: connection.protocol }); if (!connection.connection_url) { throw new Error('Connection URL is not available'); } // Find existing machine by hostname let existingMachine = machines.find(m => m.name.toLowerCase() === connection.hostname.toLowerCase() || m.ip.toLowerCase() === connection.hostname.toLowerCase() ); // If machine not found, create temporary if (!existingMachine) { existingMachine = { id: `restored-${connection.connection_id}`, name: connection.hostname, ip: connection.hostname, status: 'running', os: 'unknown', hypervisor: 'unknown', specs: { cpu: 'unknown', ram: 'unknown', disk: 'unknown' }, testLinks: [], logs: [] }; } // Create connection record const newConnection: MachineConnection = { machineId: existingMachine.id, connectionUrl: connection.connection_url, connectionId: connection.connection_id, connectedAt: new Date(connection.created_at), expiresAt: new Date(connection.expires_at), }; // Add to Map setMachineConnections(prev => { const newMap = new Map(prev); newMap.set(existingMachine.id, newConnection); return newMap; }); successCount++; log.info('app', 'Connection restored successfully', { connection_id: connection.connection_id, hostname: connection.hostname, machineId: existingMachine.id }); // Small delay between restorations await new Promise(resolve => setTimeout(resolve, 300)); } catch (error) { failedCount++; log.error('app', 'Failed to restore connection', { connection_id: connection.connection_id, hostname: connection.hostname, error }); } } // Show final notification if (successCount > 0 && failedCount === 0) { showSuccess( 'Подключения восстановлены', `Успешно восстановлено ${successCount} ${successCount === 1 ? 'подключение' : 'подключений'}` ); } else if (successCount > 0 && failedCount > 0) { showSuccess( 'Частичное восстановление', `Восстановлено: ${successCount}, ошибок: ${failedCount}` ); } else if (failedCount > 0) { showError( 'Ошибка восстановления', `Не удалось восстановить ${failedCount} ${failedCount === 1 ? 'подключение' : 'подключений'}` ); } log.info('app', 'Multiple connections restore completed', { total: connections.length, success: successCount, failed: failedCount }); }; // Check machine availability const checkMachineAvailability = async (machine: Machine) => { log.info('app', 'Checking machine availability', { machineId: machine.id, machineName: machine.name, port: machine.port, isSavedMachine: machine.hypervisor === 'Saved' }); // Set status to "checking" setMachineStatuses(prev => new Map(prev.set(machine.id, 'checking'))); try { // For saved machines use explicit port, for others - auto-detect const portToCheck = machine.port; // undefined for mock machines, explicit port for saved // Perform check via API const result = await machineAvailabilityService.checkAvailability(machine, portToCheck); // Update status based on result const newStatus: Machine['status'] = result.available ? 'running' : 'stopped'; setMachineStatuses(prev => new Map(prev.set(machine.id, newStatus))); log.info('app', 'Machine availability check completed', { machineId: machine.id, machineName: machine.name, port: result.port, available: result.available, responseTimeMs: result.responseTimeMs, newStatus }); } catch (error) { log.error('app', 'Failed to check machine availability', { machineId: machine.id, machineName: machine.name, error }); // On error set status to 'error' setMachineStatuses(prev => new Map(prev.set(machine.id, 'error'))); } }; const handleConnect = () => { if (!authService.isAuthenticated()) { setIsAuthenticated(false); return; } // Block connection for GUEST role if (currentUser?.role === 'GUEST') { showError( 'Access Denied', 'Your account has limited access (GUEST role). You can only view connections but cannot create new ones. Please contact your administrator to upgrade to USER role.' ); log.warn('app', 'Connection attempt blocked for GUEST role', { username: currentUser.username }); return; } if (selectedMachine) { setIsCredentialsModalOpen(true); } }; // Smart connect handler from Sidebar const handleSmartConnect = (machine: Machine, protocol?: ProtocolOption) => { if (!authService.isAuthenticated()) { setIsAuthenticated(false); return; } // Block connection for GUEST role if (currentUser?.role === 'GUEST') { showError( 'Access Denied', 'Your account has limited access (GUEST role). You can only view connections but cannot create new ones. Please contact your administrator to upgrade to USER role.' ); log.warn('app', 'Smart connection attempt blocked for GUEST role', { username: currentUser.username, machineId: machine.id }); return; } // If already has active connection to this machine const existingConnection = machineConnections.get(machine.id); if (existingConnection) { showWarning('Already connected', `You are already connected to ${machine.name}`); return; } // Set machine as selected and open connection modal useMachineStore.getState().selectMachine(machine); setIsCredentialsModalOpen(true); if (protocol) { showSuccess('Protocol selected', `Using ${protocol.label} for ${machine.name}`); log.info('app', 'Smart connection initiated', { machineId: machine.id, protocol: protocol.value, description: protocol.description }); } }; const handleMachineConnect = async ( username: string, password: string, protocol: string, sftpOptions?: { enableSftp: boolean; sftpRootDirectory: string } ) => { if (!selectedMachine) return; setIsConnecting(true); try { const response = await guacamoleService.createConnection(selectedMachine, { username, password, protocol, ...(sftpOptions && { enableSftp: sftpOptions.enableSftp, sftpRootDirectory: sftpOptions.sftpRootDirectory }) }); // Parse expires_at from ISO string to Date const expiresAt = new Date(response.expires_at); const now = new Date(); const ttlMs = expiresAt.getTime() - now.getTime(); log.info('app', 'Connection TTL setup', { machineId: selectedMachine.id, expiresAt: expiresAt.toISOString(), ttlMinutes: Math.round(ttlMs / 60000), ttlMs }); // Start timer for automatic disconnect on TTL expiration const ttlTimerId = setTimeout(() => { log.info('app', 'Connection TTL expired, auto-disconnecting', { machineId: selectedMachine.id, connectionId: response.connection_id, expiresAt: expiresAt.toISOString() }); // Remove connection (as if user clicked Disconnect) setMachineConnections(prev => { const newMap = new Map(prev); newMap.delete(selectedMachine.id); return newMap; }); // Show notification showWarning( 'Session expired', `Connection to ${selectedMachine.name} has expired after ${response.ttl_minutes} minutes` ); }, ttlMs); // Save connection for specific machine const newConnection: MachineConnection = { machineId: selectedMachine.id, connectionUrl: response.connection_url, connectionId: response.connection_id, connectedAt: now, expiresAt: expiresAt, ttlTimerId: ttlTimerId, // Save timer ID for cleanup enableSftp: sftpOptions?.enableSftp, // Save SFTP flag for tooltips }; setMachineConnections(prev => new Map(prev.set(selectedMachine.id, newConnection))); showSuccess( 'Connected', `Connected to ${selectedMachine.name}. Session expires in ${response.ttl_minutes} minutes` ); log.info('app', 'Connection established', { machineId: selectedMachine.id, connectionId: response.connection_id, expiresAt: expiresAt.toISOString() }); } catch (error) { log.error('app', 'Failed to connect to machine', { error }); const appError = await ErrorHandler.handleApiError(error); showError('Connection failed', appError.userMessage); throw error; } finally { setIsConnecting(false); } }; const handleDisconnect = async () => { if (!selectedMachine) return; // Clear TTL timer if exists const connection = machineConnections.get(selectedMachine.id); if (connection?.ttlTimerId) { clearTimeout(connection.ttlTimerId); log.debug('app', 'TTL timer cleared for manual disconnect', { machineId: selectedMachine.id }); } // CRITICAL: Delete connection from Redis and Guacamole via API if (connection) { try { await guacamoleService.deleteConnection(connection.connectionId); log.info('app', 'Connection deleted from server', { machineId: selectedMachine.id, connectionId: connection.connectionId }); } catch (error) { log.error('app', 'Failed to delete connection from server', { machineId: selectedMachine.id, connectionId: connection.connectionId, error }); // Continue client-side deletion even if API failed } } // Remove connection for specific machine from client state setMachineConnections(prev => { const newMap = new Map(prev); newMap.delete(selectedMachine.id); return newMap; }); log.info('app', 'Disconnected from machine', { machineId: selectedMachine.id }); }; /** * Extend TTL of active connection */ const handleExtendConnection = async (additionalMinutes: number = 60) => { if (!selectedMachine) return; const connection = machineConnections.get(selectedMachine.id); if (!connection) { showWarning('No Active Connection', 'There is no active connection to extend.'); return; } try { log.info('app', 'Extending connection TTL', { machineId: selectedMachine.id, connectionId: connection.connectionId, additionalMinutes }); const result = await guacamoleService.extendConnectionTTL(connection.connectionId, additionalMinutes); // Update expiresAt in state setMachineConnections(prev => { const newMap = new Map(prev); const conn = newMap.get(selectedMachine.id); if (conn) { conn.expiresAt = new Date(result.new_expires_at); } return newMap; }); showSuccess( 'Connection Extended', `Connection extended by ${result.additional_minutes} minutes.` ); log.info('app', 'Connection TTL extended successfully', { machineId: selectedMachine.id, connectionId: connection.connectionId, newExpiresAt: result.new_expires_at }); } catch (error) { log.error('app', 'Failed to extend connection TTL', { machineId: selectedMachine.id, connectionId: connection.connectionId, error }); const appError = await ErrorHandler.handleApiError(error); showError('Failed to Extend Connection', appError.userMessage); } }; // ======================================================================================== // Bulk Operations Handlers // ======================================================================================== // Handle bulk selection change from Sidebar const handleBulkSelectionChange = (machineIds: string[]) => { setSelectedMachineIds(machineIds); log.debug('app', 'Bulk selection changed', { count: machineIds.length }); }; // Clear bulk selection const clearBulkSelection = () => { setSelectedMachineIds([]); log.debug('app', 'Bulk selection cleared'); }; // Handle bulk health check const handleBulkHealthCheck = async () => { if (selectedMachineIds.length === 0) { showWarning('No Machines Selected', 'Please select machines to perform health check.'); return; } // Check role limits const limits = bulkOperationsApi.getRoleLimits(currentUser?.role || 'GUEST'); if (selectedMachineIds.length > limits.maxHealthCheck) { showError( 'Selection Limit Exceeded', `Your role (${currentUser?.role}) can check max ${limits.maxHealthCheck} machines at once. Please reduce selection.` ); return; } log.info('app', 'Starting bulk health check', { machineCount: selectedMachineIds.length, userRole: currentUser?.role }); // Initialize progress setBulkHealthCheckProgress({ total: selectedMachineIds.length, checked: 0, available: 0, unavailable: 0, currentMachine: undefined }); setBulkHealthCheckInProgress(true); try { // Execute bulk health check const results = await bulkOperationsApi.bulkHealthCheck({ machine_ids: selectedMachineIds, timeout: 5, check_port: true }); log.info('app', 'Bulk health check completed', { total: results.total, available: results.available, unavailable: results.unavailable, executionTime: results.execution_time_ms }); // Update progress to show completion setBulkHealthCheckProgress({ total: results.total, checked: results.total, available: results.available, unavailable: results.unavailable, currentMachine: undefined }); // Store results and show results modal setBulkHealthCheckResults(results); // Show completion notification showSuccess( 'Health Check Complete', `Checked ${results.total} machines: ${results.available} available, ${results.unavailable} unavailable` ); // Auto-open results modal after a short delay setTimeout(() => { setBulkHealthCheckInProgress(false); setShowBulkResultsModal(true); clearBulkSelection(); }, 1500); } catch (error) { log.error('app', 'Bulk health check failed', { error }); const errorDetails = await ErrorHandler.handleApiError(error); showError('Health Check Failed', errorDetails.userMessage); setBulkHealthCheckInProgress(false); } }; // Retry failed health checks const handleRetryFailed = async () => { if (!bulkHealthCheckResults) return; const failedMachineIds = bulkHealthCheckResults.results .filter((r: any) => !r.available) .map((r: any) => r.machine_id); if (failedMachineIds.length === 0) { showWarning('No Failed Machines', 'All machines are available.'); return; } log.info('app', 'Retrying failed health checks', { count: failedMachineIds.length }); setSelectedMachineIds(failedMachineIds); setShowBulkResultsModal(false); // Trigger health check with failed machines setTimeout(() => handleBulkHealthCheck(), 500); }; // ======================================================================================== // Bulk SSH Command Handlers // ======================================================================================== // Open SSH command modal const handleOpenSSHCommandModal = () => { if (selectedMachineIds.length === 0) { showWarning('No Machines Selected', 'Please select machines to execute SSH command.'); return; } if (currentUser?.role === 'GUEST') { showError('Access Denied', 'GUEST role cannot execute SSH commands. Please contact your administrator.'); return; } // Check role limits const limits = bulkOperationsApi.getRoleLimits(currentUser?.role || 'USER'); if (selectedMachineIds.length > limits.maxSSHCommand) { showError( 'Selection Limit Exceeded', `Your role (${currentUser?.role}) can execute commands on max ${limits.maxSSHCommand} machines at once.` ); return; } setShowSSHCommandModal(true); }; // Execute SSH command const handleExecuteSSHCommand = async ( command: string, mode: 'global' | 'custom', globalCreds?: { username: string; password: string }, customCreds?: { [machineId: string]: { username: string; password: string } } ) => { log.info('app', 'Starting bulk SSH command', { machineCount: selectedMachineIds.length, command: command.substring(0, 50), mode, userRole: currentUser?.role }); setShowSSHCommandModal(false); try { // Collect hostnames for all machines const machineIdsToUse = selectedMachineIds; const machineHostnames: { [machineId: string]: string } = {}; selectedMachineIds.forEach(id => { const machine = machines.find(m => m.id === id); if (machine) { // For saved machines use hostname from DB, for mock machines use IP const savedMachine = savedMachines.find(sm => sm.id === id); const hostname = savedMachine?.hostname || machine.ip || machine.name; machineHostnames[id] = hostname; } }); const results = await bulkOperationsApi.bulkSSHCommand({ machine_ids: machineIdsToUse, machine_hostnames: Object.keys(machineHostnames).length > 0 ? machineHostnames : undefined, command, credentials_mode: mode, global_credentials: globalCreds, machine_credentials: customCreds, timeout: 30 }); log.info('app', 'Bulk SSH command completed', { total: results.total, success: results.success, failed: results.failed }); setSSHCommandResults(results); showSuccess( 'SSH Command Complete', `Executed on ${results.total} machines: ${results.success} success, ${results.failed} failed` ); // Show results immediately setShowSSHResultsModal(true); clearBulkSelection(); } catch (error) { log.error('app', 'Bulk SSH command failed', { error }); const errorDetails = await ErrorHandler.handleApiError(error); showError('SSH Command Failed', errorDetails.userMessage); } }; // Retry failed SSH commands const handleRetryFailedSSH = async () => { if (!sshCommandResults) return; const failedMachineIds = sshCommandResults.results .filter((r: any) => r.status !== 'success') .map((r: any) => r.machine_id); if (failedMachineIds.length === 0) { showWarning('No Failed Machines', 'All machines executed successfully.'); return; } log.info('app', 'Retrying failed SSH commands', { count: failedMachineIds.length }); setSelectedMachineIds(failedMachineIds); setShowSSHResultsModal(false); setTimeout(() => handleOpenSSHCommandModal(), 500); }; // If user not authenticated, show Login Screen if (!isAuthenticated) { return ; } return (
{/* Header с информацией о пользователе */}
{currentUser && ( Logged in as: {currentUser.username} ({currentUser.role}) )} {machineConnections.size > 0 && (
{machineConnections.size} active connection{machineConnections.size !== 1 ? 's' : ''}
)} {tokenWarning && (
Session expires soon
)}
{selectedMachine ? (
{/* Machine Details Panel - 25% когда подключена консоль */}
{ // After deletion reset selected machine useMachineStore.getState().selectMachine(null); showSuccess('Machine deleted', 'Saved machine has been successfully deleted'); }} onExtendConnection={handleExtendConnection} />
{/* Connection Area - 75% экрана при подключении */} {machineConnections.size > 0 && (
{/* Рендерим все активные подключения, но показываем только текущее */} {Array.from(machineConnections.entries()).map(([machineId, connection]) => { const isVisible = selectedMachine?.id === machineId; const machine = useMachineStore.getState().machines.find(m => m.id === machineId); if (!machine) return null; return (
{ log.error('app', 'Guacamole viewer error', { error, machineId: machine.id }); // Remove specific connection on error setMachineConnections(prev => { const newMap = new Map(prev); newMap.delete(machine.id); return newMap; }); }} onDisconnect={async () => { // Get connection const conn = machineConnections.get(machine.id); // Clear TTL timer if exists if (conn?.ttlTimerId) { clearTimeout(conn.ttlTimerId); } // Delete from Redis and Guacamole via API if (conn) { try { await guacamoleService.deleteConnection(conn.connectionId); log.info('app', 'Connection deleted from server', { machineId: machine.id, connectionId: conn.connectionId }); } catch (error) { log.error('app', 'Failed to delete connection from server', { machineId: machine.id, error }); } } // Remove specific connection from client state setMachineConnections(prev => { const newMap = new Map(prev); newMap.delete(machine.id); return newMap; }); log.info('app', 'Disconnected from machine', { machineId: machine.id }); }} />
); })}
)}
{/* Action Panel - всегда внизу, статично */}
) : (

Select a machine from the sidebar

)}
setIsCredentialsModalOpen(false)} onSubmit={handleMachineConnect} /> setShowAddMachineModal(false)} onSuccess={() => { showSuccess('Machine saved', 'Machine has been successfully added to your profile'); fetchSavedMachines(); // Обновляем список }} /> {}} onSuccess={() => { showSuccess('Machine saved', 'Machine has been successfully added to your profile'); fetchSavedMachines(); // Обновляем список }} /> {/* Restore Connections Modal */} setShowRestoreConnectionsModal(false)} onSkip={() => setShowRestoreConnectionsModal(false)} /> {/* Bulk Operations Components */} 0} onClose={() => { setBulkHealthCheckInProgress(false); setShowBulkResultsModal(true); }} /> { setShowBulkResultsModal(false); setBulkHealthCheckResults(null); }} onRetryFailed={handleRetryFailed} /> {/* Bulk SSH Command Modals */} machines.find(m => m.id === id)) .filter((m): m is Machine => m !== undefined)} onClose={() => setShowSSHCommandModal(false)} onExecute={handleExecuteSSHCommand} userRole={currentUser?.role} /> { setShowSSHResultsModal(false); setSSHCommandResults(null); }} onRetryFailed={handleRetryFailedSSH} />
) } // Main application component with providers function App() { return ( ); } export default App