Files
Remote-Control-Center/mc_test/src/renderer/App.tsx
2025-11-25 09:56:15 +03:00

1544 lines
54 KiB
TypeScript
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<typeof setTimeout>; // 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<Map<string, MachineConnection>>(() => {
// Restore connections from localStorage on load
try {
const saved = localStorage.getItem('machine-connections');
if (saved) {
const parsed = JSON.parse(saved);
const restoredMap = new Map<string, MachineConnection>();
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<Map<string, Machine['status']>>(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<string[]>([]);
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<any>(null);
const [showBulkResultsModal, setShowBulkResultsModal] = useState(false);
// Bulk SSH Command State
const [showSSHCommandModal, setShowSSHCommandModal] = useState(false);
const [sshCommandResults, setSSHCommandResults] = useState<any>(null);
const [showSSHResultsModal, setShowSSHResultsModal] = useState(false);
// Restore Connections State
const [showRestoreConnectionsModal, setShowRestoreConnectionsModal] = useState(false);
const [activeConnections, setActiveConnections] = useState<ActiveConnection[]>([]);
// 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 <LoginScreen onLogin={handleLogin} />;
}
return (
<div className="flex h-screen bg-kaspersky-bg font-kaspersky">
<Sidebar
onConnect={handleSmartConnect}
machineStatuses={machineStatuses}
onMachineSelected={checkMachineAvailability}
machineConnections={machineConnections}
userRole={currentUser?.role}
onBulkSelectionChange={handleBulkSelectionChange}
/>
<div className="flex-1 flex flex-col">
{/* Header с информацией о пользователе */}
<div className="bg-white shadow-sm px-6 py-3 flex justify-between items-center select-none">
<div className="flex items-center gap-4">
{currentUser && (
<span className="text-sm text-gray-600">
Logged in as: <strong>{currentUser.username}</strong> ({currentUser.role})
</span>
)}
{machineConnections.size > 0 && (
<div className="flex items-center gap-2 px-3 py-1 bg-green-100 border border-green-300 rounded-md">
<svg className="w-4 h-4 text-green-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
<span className="text-sm text-green-700 font-medium">
{machineConnections.size} active connection{machineConnections.size !== 1 ? 's' : ''}
</span>
</div>
)}
{tokenWarning && (
<div className="flex items-center gap-2 px-3 py-1 bg-yellow-100 border border-yellow-300 rounded-md">
<svg className="w-4 h-4 text-yellow-600" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.732-.833-2.5 0L4.268 18.5c-.77.833.192 2.5 1.732 2.5z" />
</svg>
<span className="text-sm text-yellow-700 font-medium">Session expires soon</span>
</div>
)}
</div>
<button
onClick={handleLogout}
className="text-sm text-red-600 hover:text-red-700 transition-colors select-none"
>
Logout
</button>
</div>
{selectedMachine ? (
<div className="flex-1 flex flex-col">
<div className="flex-1 flex">
{/* Machine Details Panel - 25% когда подключена консоль */}
<div className={`${currentConnection ? 'w-1/4' : 'flex-1'} min-w-[320px] flex flex-col ${currentConnection ? 'border-r border-gray-200' : ''} transition-all duration-500`}>
<MachineDetails
machine={selectedMachine}
machineStatus={machineStatuses.get(selectedMachine.id)}
isConnected={!!currentConnection}
connectionInfo={currentConnection ? {
connectionId: currentConnection.connectionId,
connectedAt: currentConnection.connectedAt,
expiresAt: currentConnection.expiresAt
} : undefined}
onDelete={() => {
// After deletion reset selected machine
useMachineStore.getState().selectMachine(null);
showSuccess('Machine deleted', 'Saved machine has been successfully deleted');
}}
onExtendConnection={handleExtendConnection}
/>
</div>
{/* Connection Area - 75% экрана при подключении */}
{machineConnections.size > 0 && (
<div className="flex-1 flex flex-col animate-slide-in relative">
{/* Рендерим все активные подключения, но показываем только текущее */}
{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 (
<div
key={`connection-${machineId}-${connection.connectionId}`}
className={`absolute inset-0 flex flex-col ${isVisible ? 'z-10' : 'z-0 pointer-events-none opacity-0'} transition-opacity duration-300`}
>
<GuacamoleViewerWithSuspense
machine={machine}
connectionUrl={connection.connectionUrl}
enableSftp={connection.enableSftp}
onError={(error: unknown) => {
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
});
}}
/>
</div>
);
})}
</div>
)}
</div>
{/* Action Panel - всегда внизу, статично */}
<ActionPanel
machine={selectedMachine}
onConnect={handleConnect}
isConnected={!!currentConnection}
onDisconnect={handleDisconnect}
userRole={currentUser?.role}
/>
</div>
) : (
<div className="flex-1 flex items-center justify-center text-kaspersky-text select-none">
<p>Select a machine from the sidebar</p>
</div>
)}
</div>
<MachineCredentialsModalWithSuspense
isOpen={isCredentialsModalOpen}
machine={selectedMachine}
onClose={() => setIsCredentialsModalOpen(false)}
onSubmit={handleMachineConnect}
/>
<AddMachineModal
isOpen={showAddMachineModal}
onClose={() => setShowAddMachineModal(false)}
onSuccess={() => {
showSuccess('Machine saved', 'Machine has been successfully added to your profile');
fetchSavedMachines(); // Обновляем список
}}
/>
<SaveConfirmationModal
isOpen={showSaveConfirmModal}
onClose={() => {}}
onSuccess={() => {
showSuccess('Machine saved', 'Machine has been successfully added to your profile');
fetchSavedMachines(); // Обновляем список
}}
/>
{/* Restore Connections Modal */}
<RestoreConnectionsModal
isOpen={showRestoreConnectionsModal}
connections={activeConnections}
onRestore={handleRestoreConnection}
onRestoreMultiple={handleRestoreMultipleConnections}
onClose={() => setShowRestoreConnectionsModal(false)}
onSkip={() => setShowRestoreConnectionsModal(false)}
/>
{/* Bulk Operations Components */}
<BulkActionsToolbar
selectedCount={selectedMachineIds.length}
onHealthCheck={handleBulkHealthCheck}
onSSHCommand={handleOpenSSHCommandModal}
onClearSelection={clearBulkSelection}
userRole={currentUser?.role}
/>
<BulkHealthCheckProgressModal
isOpen={bulkHealthCheckInProgress}
progress={bulkHealthCheckProgress}
isComplete={bulkHealthCheckProgress.checked === bulkHealthCheckProgress.total && bulkHealthCheckProgress.total > 0}
onClose={() => {
setBulkHealthCheckInProgress(false);
setShowBulkResultsModal(true);
}}
/>
<BulkResultsModal
isOpen={showBulkResultsModal}
results={bulkHealthCheckResults}
onClose={() => {
setShowBulkResultsModal(false);
setBulkHealthCheckResults(null);
}}
onRetryFailed={handleRetryFailed}
/>
{/* Bulk SSH Command Modals */}
<BulkSSHCommandModal
isOpen={showSSHCommandModal}
selectedMachines={selectedMachineIds
.map(id => machines.find(m => m.id === id))
.filter((m): m is Machine => m !== undefined)}
onClose={() => setShowSSHCommandModal(false)}
onExecute={handleExecuteSSHCommand}
userRole={currentUser?.role}
/>
<BulkSSHResultsModal
isOpen={showSSHResultsModal}
results={sshCommandResults}
onClose={() => {
setShowSSHResultsModal(false);
setSSHCommandResults(null);
}}
onRetryFailed={handleRetryFailedSSH}
/>
</div>
)
}
// Main application component with providers
function App() {
return (
<ToastProvider>
<ErrorBoundary>
<AppContent />
</ErrorBoundary>
</ToastProvider>
);
}
export default App