1544 lines
54 KiB
TypeScript
Executable File
1544 lines
54 KiB
TypeScript
Executable File
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
|