init
This commit is contained in:
15
mc_test/src/env.d.ts
vendored
Executable file
15
mc_test/src/env.d.ts
vendored
Executable file
@ -0,0 +1,15 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
interface ImportMetaEnv {
|
||||
readonly VITE_APP_TITLE: string
|
||||
readonly DEV: boolean
|
||||
readonly PROD: boolean
|
||||
readonly MODE: string
|
||||
readonly VITE_GUACAMOLE_BASE_URL: string
|
||||
readonly VITE_GUACAMOLE_USERNAME: string
|
||||
readonly VITE_GUACAMOLE_PASSWORD: string
|
||||
}
|
||||
|
||||
interface ImportMeta {
|
||||
readonly env: ImportMetaEnv
|
||||
}
|
||||
304
mc_test/src/main/electron.ts
Executable file
304
mc_test/src/main/electron.ts
Executable file
@ -0,0 +1,304 @@
|
||||
const { app, BrowserWindow, ipcMain, safeStorage } = require('electron')
|
||||
const path = require('path')
|
||||
import { log } from './logger'
|
||||
import { LogLevel, MachineAction, MachineResponse } from '../types/electron'
|
||||
import { Machine } from '../renderer/types'
|
||||
|
||||
// Temporary storage for demo
|
||||
const machinesStore: Record<string, Machine> = {
|
||||
'test-vm-01': {
|
||||
id: 'test-vm-01',
|
||||
name: 'Test VM 1',
|
||||
status: 'running',
|
||||
os: 'Windows 10',
|
||||
ip: '192.168.1.100',
|
||||
hypervisor: 'VMware',
|
||||
specs: {
|
||||
cpu: '4 cores',
|
||||
ram: '8GB',
|
||||
disk: '256GB'
|
||||
},
|
||||
testLinks: [
|
||||
{ name: 'RDP', url: 'rdp://192.168.1.100' },
|
||||
{ name: 'Web Console', url: 'https://192.168.1.100:8080' }
|
||||
],
|
||||
logs: [
|
||||
{ timestamp: new Date().toISOString(), message: 'System started', level: 'info' }
|
||||
]
|
||||
}
|
||||
};
|
||||
|
||||
// Logging event interface
|
||||
interface LogEvent {
|
||||
level: LogLevel;
|
||||
tag: string;
|
||||
message: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
function createWindow() {
|
||||
log.info('startup', 'Creating main window...')
|
||||
|
||||
const mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, '..', 'preload.js'),
|
||||
},
|
||||
})
|
||||
|
||||
// Additional CSP configuration via session - DISABLED for debugging
|
||||
/*
|
||||
mainWindow.webContents.session.webRequest.onHeadersReceived((details: any, callback: any) => {
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
||||
const cspValue = isDev
|
||||
? "default-src 'self' 'unsafe-inline' 'unsafe-eval'; script-src 'self' 'unsafe-inline' 'unsafe-eval' http://localhost:* ws://localhost:*; style-src 'self' 'unsafe-inline' http://localhost:*; connect-src 'self' http://localhost:* https://localhost:* ws://localhost:* wss://localhost:*; frame-src 'self' http://localhost:* https://localhost:*; img-src 'self' data: blob: http://localhost:*; font-src 'self' data: http://localhost:*; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';"
|
||||
: "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' https://mc.exbytestudios.com; frame-src 'self' https://mc.exbytestudios.com; img-src 'self' data: blob:; font-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none';";
|
||||
|
||||
callback({
|
||||
responseHeaders: {
|
||||
...details.responseHeaders,
|
||||
'Content-Security-Policy': [cspValue]
|
||||
}
|
||||
});
|
||||
});
|
||||
*/
|
||||
|
||||
// In development, load from Vite dev server
|
||||
const VITE_DEV_SERVER_URL = process.env.VITE_DEV_SERVER_URL;
|
||||
log.debug('startup', 'VITE_DEV_SERVER_URL:', { url: VITE_DEV_SERVER_URL })
|
||||
|
||||
if (VITE_DEV_SERVER_URL) {
|
||||
log.info('startup', 'Loading from dev server', { url: VITE_DEV_SERVER_URL })
|
||||
mainWindow.loadURL(VITE_DEV_SERVER_URL);
|
||||
mainWindow.webContents.openDevTools();
|
||||
} else {
|
||||
const indexPath = path.join(__dirname, '..', 'renderer', 'index.html')
|
||||
log.info('startup', 'Loading from file', { path: indexPath })
|
||||
console.log('Attempting to load file:', indexPath);
|
||||
mainWindow.loadFile(indexPath);
|
||||
}
|
||||
|
||||
// Add page load debugging
|
||||
mainWindow.webContents.on('did-finish-load', () => {
|
||||
log.info('startup', 'Page finished loading');
|
||||
console.log('Page loaded');
|
||||
});
|
||||
|
||||
mainWindow.webContents.on('did-fail-load', (_event: any, errorCode: any, errorDescription: any) => {
|
||||
log.error('startup', 'Page failed to load', { errorCode, errorDescription });
|
||||
console.error('Page load error:', errorCode, errorDescription);
|
||||
});
|
||||
|
||||
// Force open DevTools for debugging
|
||||
mainWindow.webContents.openDevTools();
|
||||
|
||||
// Handle window close
|
||||
mainWindow.on('closed', () => {
|
||||
log.info('window', 'Main window closed, quitting application')
|
||||
app.quit();
|
||||
})
|
||||
}
|
||||
|
||||
// This method will be called when Electron has finished initialization
|
||||
app.whenReady().then(() => {
|
||||
log.info('startup', 'Application ready, initializing...')
|
||||
createWindow()
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
log.info('window', 'No windows available, creating new window')
|
||||
createWindow()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
if (process.platform !== 'darwin') {
|
||||
log.info('window', 'All windows closed, quitting application')
|
||||
app.quit()
|
||||
}
|
||||
})
|
||||
|
||||
// Handle logging events from renderer process
|
||||
ipcMain.on('log-event', (_event: any, { level, tag, message, context }: LogEvent) => {
|
||||
log[level](tag, message, context)
|
||||
})
|
||||
|
||||
// IPC event handlers
|
||||
|
||||
// Connect to machine
|
||||
ipcMain.handle('connect-to-machine', async (_event: any, machine: Machine): Promise<MachineResponse> => {
|
||||
log.info('connect-machine', 'Connection request received', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name
|
||||
});
|
||||
|
||||
try {
|
||||
// TODO: Real connection logic will be added later
|
||||
return {
|
||||
success: true,
|
||||
message: 'Connection successful',
|
||||
details: { machineId: machine.id }
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('connect-machine', 'Connection failed', {
|
||||
machineId: machine.id,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Control machine state
|
||||
ipcMain.handle('control-machine', async (_event: any, { machineId, action }: { machineId: string; action: MachineAction }): Promise<MachineResponse> => {
|
||||
log.info('control-machine', `Machine ${action} requested`, { machineId, action });
|
||||
|
||||
try {
|
||||
const machine = machinesStore[machineId];
|
||||
if (!machine) {
|
||||
throw new Error(`Machine ${machineId} not found`);
|
||||
}
|
||||
|
||||
// Simulate state change
|
||||
switch (action) {
|
||||
case 'start':
|
||||
machine.status = 'running';
|
||||
machine.logs.unshift({
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Machine started',
|
||||
level: 'info'
|
||||
});
|
||||
break;
|
||||
case 'stop':
|
||||
machine.status = 'stopped';
|
||||
machine.logs.unshift({
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Machine stopped',
|
||||
level: 'info'
|
||||
});
|
||||
break;
|
||||
case 'restart':
|
||||
machine.status = 'stopped';
|
||||
machine.logs.unshift({
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Machine restarting',
|
||||
level: 'info'
|
||||
});
|
||||
setTimeout(() => {
|
||||
machine.status = 'running';
|
||||
machine.logs.unshift({
|
||||
timestamp: new Date().toISOString(),
|
||||
message: 'Machine restarted successfully',
|
||||
level: 'info'
|
||||
});
|
||||
log.info('control-machine', 'Machine restarted', { machineId });
|
||||
}, 2000);
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: `Machine ${action} successful`,
|
||||
details: { machineId, newStatus: machine.status }
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('control-machine', `Machine ${action} failed`, {
|
||||
machineId,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Get machine status
|
||||
ipcMain.handle('get-machine-status', async (_event: any, machineId: string): Promise<MachineResponse> => {
|
||||
log.debug('get-machine-status', 'Status requested', { machineId });
|
||||
|
||||
const machine = machinesStore[machineId];
|
||||
if (!machine) {
|
||||
throw new Error(`Machine ${machineId} not found`);
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Status retrieved',
|
||||
status: machine.status
|
||||
};
|
||||
});
|
||||
|
||||
// Get machine list
|
||||
ipcMain.handle('get-machine-list', async (): Promise<MachineResponse> => {
|
||||
log.debug('get-machine-list', 'Machine list requested');
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: 'Machine list retrieved',
|
||||
machines: Object.values(machinesStore)
|
||||
};
|
||||
});
|
||||
|
||||
// FIXED: Secure storage (Electron safeStorage) for XSS protection
|
||||
|
||||
// Check encryption availability
|
||||
ipcMain.handle('is-encryption-available', async (): Promise<boolean> => {
|
||||
try {
|
||||
return safeStorage.isEncryptionAvailable();
|
||||
} catch (error) {
|
||||
log.error('safe-storage', 'Failed to check encryption availability', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
// Encrypt data
|
||||
ipcMain.handle('encrypt-data', async (_event: any, plaintext: string): Promise<string> => {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('Encryption not available on this platform');
|
||||
}
|
||||
|
||||
const buffer = safeStorage.encryptString(plaintext);
|
||||
const encrypted = buffer.toString('base64');
|
||||
|
||||
log.debug('safe-storage', 'Data encrypted successfully', {
|
||||
plaintextLength: plaintext.length,
|
||||
encryptedLength: encrypted.length
|
||||
});
|
||||
|
||||
return encrypted;
|
||||
} catch (error) {
|
||||
log.error('safe-storage', 'Failed to encrypt data', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
|
||||
// Decrypt data
|
||||
ipcMain.handle('decrypt-data', async (_event: any, encrypted: string): Promise<string> => {
|
||||
try {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
throw new Error('Encryption not available on this platform');
|
||||
}
|
||||
|
||||
const buffer = Buffer.from(encrypted, 'base64');
|
||||
const plaintext = safeStorage.decryptString(buffer);
|
||||
|
||||
log.debug('safe-storage', 'Data decrypted successfully', {
|
||||
encryptedLength: encrypted.length,
|
||||
plaintextLength: plaintext.length
|
||||
});
|
||||
|
||||
return plaintext;
|
||||
} catch (error) {
|
||||
log.error('safe-storage', 'Failed to decrypt data', {
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
});
|
||||
106
mc_test/src/main/logger.ts
Executable file
106
mc_test/src/main/logger.ts
Executable file
@ -0,0 +1,106 @@
|
||||
import winston from 'winston';
|
||||
import DailyRotateFile from 'winston-daily-rotate-file';
|
||||
import path from 'path';
|
||||
|
||||
// Log interface with additional fields
|
||||
interface LogInfo extends winston.LogEntry {
|
||||
tag?: string;
|
||||
context?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
// Create custom format
|
||||
const customFormat = winston.format.printf(({ level, message, timestamp, tag, context }) => {
|
||||
const contextStr = context ? ` | context: ${JSON.stringify(context)}` : '';
|
||||
return `[${timestamp}] [${tag || 'main'}] [${level.toUpperCase()}]: ${message}${contextStr}`;
|
||||
});
|
||||
|
||||
// Logger configuration
|
||||
const logger = winston.createLogger({
|
||||
level: process.env.NODE_ENV === 'development' ? 'debug' : 'info',
|
||||
format: winston.format.combine(
|
||||
winston.format.timestamp({
|
||||
format: 'YYYY-MM-DD HH:mm:ss'
|
||||
}),
|
||||
winston.format.errors({ stack: true }),
|
||||
customFormat
|
||||
),
|
||||
transports: [
|
||||
// File logging with rotation
|
||||
new DailyRotateFile({
|
||||
dirname: path.join(process.cwd(), 'logs'),
|
||||
filename: 'app-%DATE%.log',
|
||||
datePattern: 'YYYY-MM-DD',
|
||||
zippedArchive: true,
|
||||
maxSize: '20m',
|
||||
maxFiles: '14d',
|
||||
level: 'info'
|
||||
}),
|
||||
// Console logging with colors
|
||||
new winston.transports.Console({
|
||||
format: winston.format.combine(
|
||||
winston.format.colorize(),
|
||||
customFormat
|
||||
)
|
||||
})
|
||||
]
|
||||
});
|
||||
|
||||
// Handle unhandled exceptions and promises
|
||||
logger.exceptions.handle(
|
||||
new winston.transports.File({ filename: path.join(process.cwd(), 'logs', 'exceptions.log') })
|
||||
);
|
||||
logger.rejections.handle(
|
||||
new winston.transports.File({ filename: path.join(process.cwd(), 'logs', 'rejections.log') })
|
||||
);
|
||||
|
||||
// Logger method types
|
||||
type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||
type LogMethod = (tag: string, message: string, context?: Record<string, unknown>) => void;
|
||||
|
||||
// Create methods for different log levels
|
||||
const createLogMethod = (level: LogLevel): LogMethod => {
|
||||
return (tag: string, message: string, context?: Record<string, unknown>) => {
|
||||
logger.log({
|
||||
level,
|
||||
message,
|
||||
tag,
|
||||
context
|
||||
} as LogInfo);
|
||||
};
|
||||
};
|
||||
|
||||
// Export logger interface
|
||||
export const log = {
|
||||
error: createLogMethod('error'),
|
||||
warn: createLogMethod('warn'),
|
||||
info: createLogMethod('info'),
|
||||
debug: createLogMethod('debug'),
|
||||
// Method for logging unhandled errors
|
||||
fatal: (error: Error, context?: Record<string, unknown>) => {
|
||||
logger.error({
|
||||
level: 'error',
|
||||
message: error.message,
|
||||
tag: 'uncaught',
|
||||
context: {
|
||||
...context,
|
||||
stack: error.stack
|
||||
}
|
||||
} as LogInfo);
|
||||
}
|
||||
};
|
||||
|
||||
// Configure unhandled error handlers
|
||||
process.on('uncaughtException', (error) => {
|
||||
log.fatal(error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
||||
process.on('unhandledRejection', (reason) => {
|
||||
if (reason instanceof Error) {
|
||||
log.fatal(reason);
|
||||
} else {
|
||||
log.error('unhandledRejection', 'Unhandled promise rejection', { reason });
|
||||
}
|
||||
});
|
||||
|
||||
export default log;
|
||||
47
mc_test/src/preload.ts
Executable file
47
mc_test/src/preload.ts
Executable file
@ -0,0 +1,47 @@
|
||||
import { LogLevel, MachineAction, ElectronAPI } from './types/electron';
|
||||
import { Machine } from './renderer/types';
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// the ipcRenderer without exposing the entire object
|
||||
contextBridge.exposeInMainWorld(
|
||||
'electronAPI', {
|
||||
// Connect to machine
|
||||
connectToMachine: (machine: Machine) =>
|
||||
ipcRenderer.invoke('connect-to-machine', machine),
|
||||
|
||||
// Control machine state
|
||||
controlMachine: (machineId: string, action: MachineAction) =>
|
||||
ipcRenderer.invoke('control-machine', { machineId, action }),
|
||||
|
||||
// Get machine status
|
||||
getMachineStatus: (machineId: string) =>
|
||||
ipcRenderer.invoke('get-machine-status', machineId),
|
||||
|
||||
// Get machine list
|
||||
getMachineList: () =>
|
||||
ipcRenderer.invoke('get-machine-list'),
|
||||
|
||||
// Logging
|
||||
logEvent: (level: LogLevel, tag: string, message: string, context?: Record<string, unknown>) =>
|
||||
ipcRenderer.send('log-event', { level, tag, message, context }),
|
||||
|
||||
// FIXED: Secure storage for JWT tokens (XSS protection)
|
||||
encryptData: (plaintext: string) =>
|
||||
ipcRenderer.invoke('encrypt-data', plaintext),
|
||||
|
||||
decryptData: (encrypted: string) =>
|
||||
ipcRenderer.invoke('decrypt-data', encrypted),
|
||||
|
||||
// Check safeStorage availability
|
||||
isEncryptionAvailable: () =>
|
||||
ipcRenderer.invoke('is-encryption-available'),
|
||||
} as ElectronAPI
|
||||
);
|
||||
|
||||
// TypeScript types
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
1544
mc_test/src/renderer/App.tsx
Executable file
1544
mc_test/src/renderer/App.tsx
Executable file
File diff suppressed because it is too large
Load Diff
149
mc_test/src/renderer/components/ActionPanel.tsx
Executable file
149
mc_test/src/renderer/components/ActionPanel.tsx
Executable file
@ -0,0 +1,149 @@
|
||||
import { useState } from 'react'
|
||||
import { Play, Power, HeartPulse, AlertCircle, Sparkles } from 'lucide-react'
|
||||
import { Machine } from '../types'
|
||||
import { log } from '../utils/logger'
|
||||
import { useMachineStore } from '../store/machineStore'
|
||||
import { Button } from './shared/Button'
|
||||
|
||||
interface ActionPanelProps {
|
||||
machine: Machine;
|
||||
onConnect?: () => void;
|
||||
isConnected?: boolean;
|
||||
onDisconnect?: () => void;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
function ActionPanel({ machine, onConnect, isConnected, onDisconnect, userRole }: ActionPanelProps) {
|
||||
const [isLoading, setIsLoading] = useState<{[key: string]: boolean}>({
|
||||
connect: false,
|
||||
health: false,
|
||||
issue: false
|
||||
});
|
||||
const { controlMachine: _controlMachine } = useMachineStore();
|
||||
|
||||
const setActionLoading = (action: string, loading: boolean) => {
|
||||
setIsLoading(prev => ({ ...prev, [action]: loading }));
|
||||
};
|
||||
|
||||
const handleConnect = async () => {
|
||||
if (isConnected && onDisconnect) {
|
||||
onDisconnect();
|
||||
} else if (onConnect) {
|
||||
onConnect();
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
const handleHealthCheck = async () => {
|
||||
setActionLoading('health', true);
|
||||
try {
|
||||
log.info('action-panel', 'Starting health check', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name
|
||||
});
|
||||
|
||||
// TODO: Implement health check logic
|
||||
await new Promise(resolve => setTimeout(resolve, 1500)); // Имитация запроса
|
||||
|
||||
log.info('action-panel', 'Health check completed', {
|
||||
machineId: machine.id,
|
||||
status: 'healthy' // Пример результата
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('action-panel', 'Health check failed', {
|
||||
machineId: machine.id,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
setActionLoading('health', false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateIssue = async () => {
|
||||
setActionLoading('issue', true);
|
||||
try {
|
||||
log.info('action-panel', 'Creating issue', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name
|
||||
});
|
||||
|
||||
// TODO: Implement issue creation logic
|
||||
await new Promise(resolve => setTimeout(resolve, 1000)); // Имитация запроса
|
||||
|
||||
log.info('action-panel', 'Issue created successfully', {
|
||||
machineId: machine.id,
|
||||
issueId: 'MOCK-123' // Пример ID созданной задачи
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('action-panel', 'Failed to create issue', {
|
||||
machineId: machine.id,
|
||||
error: error instanceof Error ? error.message : String(error)
|
||||
});
|
||||
} finally {
|
||||
setActionLoading('issue', false);
|
||||
}
|
||||
};
|
||||
|
||||
const isGuestRole = userRole === 'GUEST';
|
||||
const isConnectDisabled = isGuestRole && !isConnected; // Disable only for GUEST when not connected
|
||||
|
||||
return (
|
||||
<div className="bg-white border-t border-kaspersky-border p-4 shadow-kaspersky select-none">
|
||||
<div className="flex justify-end gap-3">
|
||||
<Button
|
||||
variant={isConnected ? 'danger' : 'primary'}
|
||||
size="md"
|
||||
onClick={handleConnect}
|
||||
isLoading={isLoading.connect}
|
||||
disabled={isConnectDisabled}
|
||||
leftIcon={isConnected ? <Power size={18} /> : <Play size={18} />}
|
||||
title={isConnectDisabled ? 'Access denied: GUEST role cannot create connections. Contact your administrator.' : undefined}
|
||||
>
|
||||
{isConnected ? 'Disconnect' : 'Connect'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
onClick={handleHealthCheck}
|
||||
isLoading={isLoading.health}
|
||||
leftIcon={<HeartPulse size={18} />}
|
||||
>
|
||||
{isLoading.health ? 'Checking...' : 'Health Check'}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
onClick={handleCreateIssue}
|
||||
isLoading={isLoading.issue}
|
||||
leftIcon={<AlertCircle size={18} />}
|
||||
>
|
||||
{isLoading.issue ? 'Creating...' : 'Create Issue'}
|
||||
</Button>
|
||||
|
||||
{/* LLM Help кнопка - показывается только при активном подключении */}
|
||||
{isConnected && (
|
||||
<div className="relative">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="md"
|
||||
disabled
|
||||
leftIcon={<Sparkles size={18} />}
|
||||
className="opacity-50 cursor-not-allowed"
|
||||
>
|
||||
LLM Help
|
||||
</Button>
|
||||
{/* Бейдж SOON */}
|
||||
<span className="absolute -top-1 -right-1 bg-gradient-to-r from-kaspersky-primary to-kaspersky-danger text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wide shadow-sm">
|
||||
Soon
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ActionPanel
|
||||
393
mc_test/src/renderer/components/AddMachineModal.tsx
Executable file
393
mc_test/src/renderer/components/AddMachineModal.tsx
Executable file
@ -0,0 +1,393 @@
|
||||
/**
|
||||
* Modal for adding new machine
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { X, Save, Plus } from 'lucide-react';
|
||||
import { useMachineStore } from '../store/machineStore';
|
||||
import { SavedMachineCreate } from '../services/saved-machines-api';
|
||||
import { log } from '../utils/logger';
|
||||
import { Button } from './shared/Button';
|
||||
|
||||
interface AddMachineModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const AddMachineModal: React.FC<AddMachineModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const { createSavedMachine, setShowSaveConfirmModal, setMachineToSave } = useMachineStore();
|
||||
|
||||
const [formData, setFormData] = useState<SavedMachineCreate>({
|
||||
name: '',
|
||||
hostname: '',
|
||||
port: 3389,
|
||||
protocol: 'rdp',
|
||||
os: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
is_favorite: false
|
||||
});
|
||||
|
||||
const [tagInput, setTagInput] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [askToSave, setAskToSave] = useState(true);
|
||||
|
||||
const handleInputChange = (
|
||||
e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>
|
||||
) => {
|
||||
const { name, value, type } = e.target;
|
||||
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: type === 'number' ? parseInt(value) : value
|
||||
}));
|
||||
};
|
||||
|
||||
const handleCheckboxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, checked } = e.target;
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
[name]: checked
|
||||
}));
|
||||
};
|
||||
|
||||
const handleAddTag = () => {
|
||||
if (tagInput.trim() && !formData.tags?.includes(tagInput.trim())) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: [...(prev.tags || []), tagInput.trim()]
|
||||
}));
|
||||
setTagInput('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveTag = (tagToRemove: string) => {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
tags: prev.tags?.filter(tag => tag !== tagToRemove) || []
|
||||
}));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
// Validation
|
||||
if (!formData.name.trim()) {
|
||||
setError('Название машины обязательно');
|
||||
return;
|
||||
}
|
||||
if (!formData.hostname.trim()) {
|
||||
setError('Хост обязателен');
|
||||
return;
|
||||
}
|
||||
if (formData.port < 1 || formData.port > 65535) {
|
||||
setError('Порт должен быть от 1 до 65535');
|
||||
return;
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
// If "ask before saving" is selected
|
||||
if (askToSave) {
|
||||
setMachineToSave(formData);
|
||||
setShowSaveConfirmModal(true);
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise save immediately
|
||||
setLoading(true);
|
||||
try {
|
||||
log.info('AddMachineModal', 'Creating machine', { name: formData.name });
|
||||
|
||||
await createSavedMachine(formData);
|
||||
|
||||
log.info('AddMachineModal', 'Machine created successfully');
|
||||
|
||||
// Reset form
|
||||
resetForm();
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Не удалось создать машину';
|
||||
log.error('AddMachineModal', 'Failed to create machine', { error: errorMessage });
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
setFormData({
|
||||
name: '',
|
||||
hostname: '',
|
||||
port: 3389,
|
||||
protocol: 'rdp',
|
||||
os: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
is_favorite: false
|
||||
});
|
||||
setTagInput('');
|
||||
setError(null);
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
resetForm();
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 p-4 backdrop-blur-sm">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto border border-kaspersky-border select-none">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<h2 className="text-2xl font-bold text-kaspersky-text-dark">Добавить машину</h2>
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="text-kaspersky-text-light hover:text-kaspersky-danger transition-colors p-1 rounded-lg hover:bg-kaspersky-bg"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-kaspersky-danger bg-opacity-10 border border-kaspersky-danger rounded-lg text-kaspersky-danger text-sm font-medium animate-shake">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* Name */}
|
||||
<div>
|
||||
<label htmlFor="name" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Название *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="name"
|
||||
name="name"
|
||||
value={formData.name}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
placeholder="Например: Production Server"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hostname */}
|
||||
<div>
|
||||
<label htmlFor="hostname" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Хост (IP или домен) *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="hostname"
|
||||
name="hostname"
|
||||
value={formData.hostname}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
placeholder="192.168.1.100 или example.com"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Operating System */}
|
||||
<div>
|
||||
<label htmlFor="os" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Операционная система
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="os"
|
||||
name="os"
|
||||
value={formData.os}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
placeholder="Windows Server 2019, Ubuntu 22.04, etc."
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Protocol and Port */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label htmlFor="protocol" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Протокол *
|
||||
</label>
|
||||
<select
|
||||
id="protocol"
|
||||
name="protocol"
|
||||
value={formData.protocol}
|
||||
onChange={handleInputChange}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
required
|
||||
disabled={loading}
|
||||
>
|
||||
<option value="rdp">RDP</option>
|
||||
<option value="ssh">SSH</option>
|
||||
<option value="vnc">VNC</option>
|
||||
<option value="telnet">Telnet</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="port" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Порт *
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="port"
|
||||
name="port"
|
||||
value={formData.port}
|
||||
onChange={handleInputChange}
|
||||
min="1"
|
||||
max="65535"
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
required
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div>
|
||||
<label htmlFor="description" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Описание
|
||||
</label>
|
||||
<textarea
|
||||
id="description"
|
||||
name="description"
|
||||
value={formData.description}
|
||||
onChange={handleInputChange}
|
||||
rows={3}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white resize-none transition-all duration-200 select-text"
|
||||
placeholder="Опциональное описание машины"
|
||||
disabled={loading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Tags */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Теги
|
||||
</label>
|
||||
<div className="flex gap-2 mb-2">
|
||||
<input
|
||||
type="text"
|
||||
value={tagInput}
|
||||
onChange={(e) => setTagInput(e.target.value)}
|
||||
onKeyPress={(e) => e.key === 'Enter' && (e.preventDefault(), handleAddTag())}
|
||||
className="flex-1 px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
placeholder="Введите тег и нажмите Enter"
|
||||
disabled={loading}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={handleAddTag}
|
||||
variant="secondary"
|
||||
disabled={loading || !tagInput.trim()}
|
||||
leftIcon={<Plus size={16} />}
|
||||
>
|
||||
Добавить
|
||||
</Button>
|
||||
</div>
|
||||
{formData.tags && formData.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1.5 bg-kaspersky-primary bg-opacity-10 text-kaspersky-primary rounded-lg text-sm font-medium border border-kaspersky-primary border-opacity-30"
|
||||
>
|
||||
{tag}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRemoveTag(tag)}
|
||||
className="hover:text-kaspersky-danger transition-colors ml-1"
|
||||
disabled={loading}
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Is Favorite */}
|
||||
<div className="flex items-center gap-3 p-3 bg-kaspersky-bg rounded-lg border border-kaspersky-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="is_favorite"
|
||||
name="is_favorite"
|
||||
checked={formData.is_favorite}
|
||||
onChange={handleCheckboxChange}
|
||||
className="w-5 h-5 text-kaspersky-primary bg-white border-2 border-kaspersky-border rounded focus:ring-2 focus:ring-kaspersky-primary cursor-pointer"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="is_favorite" className="text-sm font-medium text-kaspersky-text-dark cursor-pointer flex-1">
|
||||
⭐ Добавить в избранное
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Ask to save */}
|
||||
<div className="flex items-center gap-3 p-3 bg-kaspersky-bg rounded-lg border border-kaspersky-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="askToSave"
|
||||
checked={askToSave}
|
||||
onChange={(e) => setAskToSave(e.target.checked)}
|
||||
className="w-5 h-5 text-kaspersky-primary bg-white border-2 border-kaspersky-border rounded focus:ring-2 focus:ring-kaspersky-primary cursor-pointer"
|
||||
disabled={loading}
|
||||
/>
|
||||
<label htmlFor="askToSave" className="text-sm font-medium text-kaspersky-text-dark cursor-pointer flex-1">
|
||||
💬 Спросить подтверждение перед сохранением
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3 pt-4">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
disabled={loading}
|
||||
isLoading={loading}
|
||||
leftIcon={!loading ? <Save size={18} /> : undefined}
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={handleClose}
|
||||
disabled={loading}
|
||||
leftIcon={<X size={18} />}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
120
mc_test/src/renderer/components/BulkActionsToolbar.tsx
Executable file
120
mc_test/src/renderer/components/BulkActionsToolbar.tsx
Executable file
@ -0,0 +1,120 @@
|
||||
/**
|
||||
* BulkActionsToolbar Component
|
||||
*
|
||||
* Floating action bar that appears when machines are selected in bulk mode.
|
||||
* Provides quick access to bulk operations:
|
||||
* - Health Check
|
||||
* - Run Command (future)
|
||||
* - Multi-Connect (future)
|
||||
* - Update Tags (future)
|
||||
*/
|
||||
|
||||
import { Activity, X, Terminal, Grid, Tag } from 'lucide-react';
|
||||
import { Button } from './shared/Button';
|
||||
|
||||
interface BulkActionsToolbarProps {
|
||||
selectedCount: number;
|
||||
onHealthCheck: () => void;
|
||||
onSSHCommand: () => void;
|
||||
onClearSelection: () => void;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export function BulkActionsToolbar({
|
||||
selectedCount,
|
||||
onHealthCheck,
|
||||
onSSHCommand,
|
||||
onClearSelection,
|
||||
userRole
|
||||
}: BulkActionsToolbarProps) {
|
||||
|
||||
if (selectedCount === 0) {
|
||||
return null; // Don't render if no machines selected
|
||||
}
|
||||
|
||||
const isGuestRole = userRole === 'GUEST';
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-6 left-1/2 transform -translate-x-1/2 z-50">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border-2 border-kaspersky-primary px-6 py-4 flex items-center gap-4">
|
||||
{/* Selection Counter */}
|
||||
<div className="flex items-center gap-2 pr-4 border-r border-kaspersky-border">
|
||||
<div className="w-8 h-8 rounded-full bg-kaspersky-primary bg-opacity-10 flex items-center justify-center">
|
||||
<span className="text-sm font-bold text-kaspersky-primary">{selectedCount}</span>
|
||||
</div>
|
||||
<span className="text-sm font-medium text-kaspersky-text-dark">
|
||||
{selectedCount === 1 ? 'machine selected' : 'machines selected'}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="flex items-center gap-2">
|
||||
|
||||
{/* Health Check */}
|
||||
<Button
|
||||
onClick={onHealthCheck}
|
||||
variant="primary"
|
||||
size="md"
|
||||
leftIcon={<Activity size={18} />}
|
||||
title="Check availability of selected machines"
|
||||
>
|
||||
Health Check
|
||||
</Button>
|
||||
|
||||
{/* Run Command */}
|
||||
<Button
|
||||
onClick={onSSHCommand}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
leftIcon={<Terminal size={18} />}
|
||||
disabled={isGuestRole}
|
||||
title={isGuestRole ? "GUEST role cannot execute SSH commands" : "Run SSH command on selected machines"}
|
||||
>
|
||||
Run Command
|
||||
</Button>
|
||||
|
||||
{/* Multi-Connect (Future - Disabled for now) */}
|
||||
<Button
|
||||
onClick={() => {}}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
leftIcon={<Grid size={18} />}
|
||||
disabled
|
||||
title="Coming soon: Connect to multiple machines"
|
||||
>
|
||||
Multi-Connect
|
||||
</Button>
|
||||
|
||||
{/* Update Tags (Future - Disabled for now) */}
|
||||
<Button
|
||||
onClick={() => {}}
|
||||
variant="secondary"
|
||||
size="md"
|
||||
leftIcon={<Tag size={18} />}
|
||||
disabled
|
||||
title="Coming soon: Update tags for selected machines"
|
||||
>
|
||||
Update Tags
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Clear Selection */}
|
||||
<button
|
||||
onClick={onClearSelection}
|
||||
className="ml-4 pl-4 border-l border-kaspersky-border text-kaspersky-text-light hover:text-kaspersky-danger transition-colors"
|
||||
title="Clear selection"
|
||||
>
|
||||
<X size={20} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Guest Warning */}
|
||||
{isGuestRole && (
|
||||
<div className="absolute -top-12 left-1/2 transform -translate-x-1/2 bg-kaspersky-warning text-white px-4 py-2 rounded-lg text-sm font-medium shadow-lg whitespace-nowrap">
|
||||
⚠️ GUEST role: Limited access to bulk operations
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
168
mc_test/src/renderer/components/BulkHealthCheckProgressModal.tsx
Executable file
168
mc_test/src/renderer/components/BulkHealthCheckProgressModal.tsx
Executable file
@ -0,0 +1,168 @@
|
||||
/**
|
||||
* BulkHealthCheckProgressModal Component
|
||||
*
|
||||
* Displays real-time progress of bulk health check operations.
|
||||
* Shows:
|
||||
* - Total machines being checked
|
||||
* - Completion percentage
|
||||
* - Live status updates
|
||||
* - Cancel button
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Activity, X, CheckCircle, XCircle, Clock } from 'lucide-react';
|
||||
import { Button } from './shared/Button';
|
||||
|
||||
interface ProgressData {
|
||||
total: number;
|
||||
checked: number;
|
||||
available: number;
|
||||
unavailable: number;
|
||||
currentMachine?: string;
|
||||
}
|
||||
|
||||
interface BulkHealthCheckProgressModalProps {
|
||||
isOpen: boolean;
|
||||
progress: ProgressData;
|
||||
isComplete: boolean;
|
||||
onClose: () => void;
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
export function BulkHealthCheckProgressModal({
|
||||
isOpen,
|
||||
progress,
|
||||
isComplete,
|
||||
onClose,
|
||||
onCancel
|
||||
}: BulkHealthCheckProgressModalProps) {
|
||||
|
||||
const [animatedProgress, setAnimatedProgress] = useState(0);
|
||||
|
||||
// Animate progress bar
|
||||
useEffect(() => {
|
||||
const targetProgress = progress.total > 0 ? (progress.checked / progress.total) * 100 : 0;
|
||||
const timer = setTimeout(() => setAnimatedProgress(targetProgress), 100);
|
||||
return () => clearTimeout(timer);
|
||||
}, [progress.checked, progress.total]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const percentage = progress.total > 0 ? Math.round((progress.checked / progress.total) * 100) : 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-lg w-full mx-4 select-none">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-kaspersky-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-kaspersky-primary bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<Activity className={`w-6 h-6 text-kaspersky-primary ${!isComplete ? 'animate-pulse' : ''}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-kaspersky-text-dark">
|
||||
{isComplete ? 'Health Check Complete' : 'Checking Machines...'}
|
||||
</h2>
|
||||
<p className="text-sm text-kaspersky-text-light">
|
||||
{isComplete
|
||||
? `Checked ${progress.total} machine${progress.total !== 1 ? 's' : ''}`
|
||||
: `${progress.checked} of ${progress.total} machines checked`
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isComplete && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Progress Content */}
|
||||
<div className="p-6 space-y-4">
|
||||
|
||||
{/* Progress Bar */}
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-sm font-medium text-kaspersky-text-dark">Progress</span>
|
||||
<span className="text-sm font-bold text-kaspersky-primary">{percentage}%</span>
|
||||
</div>
|
||||
<div className="w-full h-3 bg-kaspersky-bg rounded-full overflow-hidden">
|
||||
<div
|
||||
className={`h-full transition-all duration-300 ${
|
||||
isComplete
|
||||
? 'bg-kaspersky-success'
|
||||
: 'bg-kaspersky-primary'
|
||||
}`}
|
||||
style={{ width: `${animatedProgress}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Current Machine */}
|
||||
{!isComplete && progress.currentMachine && (
|
||||
<div className="flex items-center gap-2 text-sm text-kaspersky-text-light">
|
||||
<Clock size={16} className="animate-spin" />
|
||||
<span>Checking: <span className="font-medium text-kaspersky-text-dark">{progress.currentMachine}</span></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Statistics */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="bg-kaspersky-bg p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-kaspersky-text-dark">{progress.total}</div>
|
||||
<div className="text-xs text-kaspersky-text-light mt-1">Total</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-3 rounded-lg text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<CheckCircle size={16} className="text-kaspersky-success" />
|
||||
<span className="text-2xl font-bold text-kaspersky-success">{progress.available}</span>
|
||||
</div>
|
||||
<div className="text-xs text-kaspersky-success mt-1">Available</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-red-50 p-3 rounded-lg text-center">
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<XCircle size={16} className="text-kaspersky-danger" />
|
||||
<span className="text-2xl font-bold text-kaspersky-danger">{progress.unavailable}</span>
|
||||
</div>
|
||||
<div className="text-xs text-kaspersky-danger mt-1">Unavailable</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-kaspersky-border flex justify-end gap-3">
|
||||
{!isComplete && onCancel && (
|
||||
<Button
|
||||
onClick={onCancel}
|
||||
variant="secondary"
|
||||
leftIcon={<X size={18} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{isComplete && (
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="primary"
|
||||
leftIcon={<CheckCircle size={18} />}
|
||||
>
|
||||
View Results
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
290
mc_test/src/renderer/components/BulkResultsModal.tsx
Executable file
290
mc_test/src/renderer/components/BulkResultsModal.tsx
Executable file
@ -0,0 +1,290 @@
|
||||
/**
|
||||
* BulkResultsModal Component
|
||||
*
|
||||
* Displays detailed results of bulk operations.
|
||||
* Features:
|
||||
* - Tabbed view (All / Available / Unavailable)
|
||||
* - Sortable table
|
||||
* - Export to CSV
|
||||
* - Retry failed operations
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { X, CheckCircle, XCircle, Clock, Download, AlertCircle } from 'lucide-react';
|
||||
import { Button } from './shared/Button';
|
||||
import { BulkHealthCheckResponse } from '../services/bulk-operations-api';
|
||||
|
||||
interface BulkResultsModalProps {
|
||||
isOpen: boolean;
|
||||
results: BulkHealthCheckResponse | null;
|
||||
onClose: () => void;
|
||||
onRetryFailed?: () => void;
|
||||
}
|
||||
|
||||
type FilterTab = 'all' | 'available' | 'unavailable';
|
||||
|
||||
export function BulkResultsModal({
|
||||
isOpen,
|
||||
results,
|
||||
onClose,
|
||||
onRetryFailed
|
||||
}: BulkResultsModalProps) {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>('all');
|
||||
const [sortBy, setSortBy] = useState<'name' | 'status' | 'response_time'>('name');
|
||||
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('asc');
|
||||
|
||||
// Filter and sort results
|
||||
const filteredResults = useMemo(() => {
|
||||
if (!results) return [];
|
||||
|
||||
let filtered = results.results;
|
||||
|
||||
// Apply filter
|
||||
switch (activeTab) {
|
||||
case 'available':
|
||||
filtered = filtered.filter(r => r.available);
|
||||
break;
|
||||
case 'unavailable':
|
||||
filtered = filtered.filter(r => !r.available);
|
||||
break;
|
||||
default:
|
||||
// all
|
||||
break;
|
||||
}
|
||||
|
||||
// Apply sort
|
||||
return [...filtered].sort((a, b) => {
|
||||
let comparison = 0;
|
||||
|
||||
switch (sortBy) {
|
||||
case 'name':
|
||||
comparison = a.machine_name.localeCompare(b.machine_name);
|
||||
break;
|
||||
case 'status':
|
||||
comparison = a.status.localeCompare(b.status);
|
||||
break;
|
||||
case 'response_time':
|
||||
comparison = (a.response_time_ms || 9999) - (b.response_time_ms || 9999);
|
||||
break;
|
||||
}
|
||||
|
||||
return sortOrder === 'asc' ? comparison : -comparison;
|
||||
});
|
||||
}, [results, activeTab, sortBy, sortOrder]);
|
||||
|
||||
// Export to CSV
|
||||
const handleExportCSV = () => {
|
||||
if (!results) return;
|
||||
|
||||
const csvHeaders = ['Machine Name', 'Hostname', 'Status', 'Available', 'Response Time (ms)', 'Error'];
|
||||
const csvRows = results.results.map(r => [
|
||||
r.machine_name,
|
||||
r.hostname,
|
||||
r.status,
|
||||
r.available ? 'Yes' : 'No',
|
||||
r.response_time_ms?.toString() || 'N/A',
|
||||
r.error || ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
csvHeaders.join(','),
|
||||
...csvRows.map(row => row.map(cell => `"${cell}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `health-check-results-${new Date().toISOString()}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
// Toggle sort
|
||||
const handleSort = (column: typeof sortBy) => {
|
||||
if (sortBy === column) {
|
||||
setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
setSortBy(column);
|
||||
setSortOrder('asc');
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !results) return null;
|
||||
|
||||
const hasFailures = results.unavailable > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-4xl w-full mx-4 max-h-[90vh] flex flex-col select-none">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-kaspersky-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-kaspersky-text-dark">Health Check Results</h2>
|
||||
<p className="text-sm text-kaspersky-text-light mt-1">
|
||||
Completed in {(results.execution_time_ms / 1000).toFixed(2)}s
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-4 gap-3 mt-4">
|
||||
<div className="bg-kaspersky-bg p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-kaspersky-text-dark">{results.total}</div>
|
||||
<div className="text-xs text-kaspersky-text-light mt-1">Total</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-kaspersky-success">{results.available}</div>
|
||||
<div className="text-xs text-kaspersky-success mt-1">Available</div>
|
||||
</div>
|
||||
<div className="bg-red-50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-kaspersky-danger">{results.unavailable}</div>
|
||||
<div className="text-xs text-kaspersky-danger mt-1">Unavailable</div>
|
||||
</div>
|
||||
<div className="bg-blue-50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-blue-600">{results.success}</div>
|
||||
<div className="text-xs text-blue-600 mt-1">Success</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-kaspersky-border px-6">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'all'
|
||||
? 'border-kaspersky-primary text-kaspersky-primary'
|
||||
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
|
||||
}`}
|
||||
>
|
||||
All ({results.total})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('available')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'available'
|
||||
? 'border-kaspersky-success text-kaspersky-success'
|
||||
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
|
||||
}`}
|
||||
>
|
||||
Available ({results.available})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('unavailable')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'unavailable'
|
||||
? 'border-kaspersky-danger text-kaspersky-danger'
|
||||
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
|
||||
}`}
|
||||
>
|
||||
Unavailable ({results.unavailable})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<table className="w-full">
|
||||
<thead>
|
||||
<tr className="border-b border-kaspersky-border">
|
||||
<th className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark">Status</th>
|
||||
<th
|
||||
className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark cursor-pointer hover:text-kaspersky-primary"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Machine Name {sortBy === 'name' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark">Hostname</th>
|
||||
<th
|
||||
className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark cursor-pointer hover:text-kaspersky-primary"
|
||||
onClick={() => handleSort('response_time')}
|
||||
>
|
||||
Response {sortBy === 'response_time' && (sortOrder === 'asc' ? '↑' : '↓')}
|
||||
</th>
|
||||
<th className="text-left p-2 text-sm font-semibold text-kaspersky-text-dark">Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredResults.map((result) => (
|
||||
<tr key={result.machine_id} className="border-b border-kaspersky-border hover:bg-kaspersky-bg transition-colors">
|
||||
<td className="p-2">
|
||||
<div title={result.available ? "Available" : result.status === 'timeout' ? "Timeout" : "Unavailable"}>
|
||||
{result.available ? (
|
||||
<CheckCircle size={18} className="text-kaspersky-success" />
|
||||
) : result.status === 'timeout' ? (
|
||||
<Clock size={18} className="text-kaspersky-warning" />
|
||||
) : (
|
||||
<XCircle size={18} className="text-kaspersky-danger" />
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="p-2 text-sm font-medium text-kaspersky-text-dark">{result.machine_name}</td>
|
||||
<td className="p-2 text-sm text-kaspersky-text-light">{result.hostname}</td>
|
||||
<td className="p-2 text-sm text-kaspersky-text-light">
|
||||
{result.response_time_ms ? `${result.response_time_ms}ms` : 'N/A'}
|
||||
</td>
|
||||
<td className="p-2 text-sm text-kaspersky-danger">
|
||||
{result.error && (
|
||||
<div className="flex items-center gap-1" title={result.error}>
|
||||
<AlertCircle size={14} />
|
||||
<span className="truncate max-w-[200px]">{result.error}</span>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{filteredResults.length === 0 && (
|
||||
<div className="text-center py-8 text-kaspersky-text-light">
|
||||
No results to display
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-kaspersky-border flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
{hasFailures && onRetryFailed && (
|
||||
<Button
|
||||
onClick={onRetryFailed}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<AlertCircle size={16} />}
|
||||
>
|
||||
Retry Failed ({results.unavailable})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleExportCSV}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<Download size={16} />}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
378
mc_test/src/renderer/components/BulkSSHCommandModal.tsx
Executable file
378
mc_test/src/renderer/components/BulkSSHCommandModal.tsx
Executable file
@ -0,0 +1,378 @@
|
||||
/**
|
||||
* BulkSSHCommandModal Component
|
||||
*
|
||||
* Modal for executing SSH command on multiple machines.
|
||||
*
|
||||
* 2 credential modes:
|
||||
* 1. 'global' - Single credentials for all machines (enter manually)
|
||||
* 2. 'custom' - Individual credentials for each machine
|
||||
*
|
||||
* Passwords are NOT saved and transmitted via HTTPS
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Terminal, X, Users, User } from 'lucide-react';
|
||||
import { Button } from './shared/Button';
|
||||
import { Machine } from '../types';
|
||||
|
||||
type CredentialsMode = 'global' | 'custom';
|
||||
|
||||
interface GlobalCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
interface CustomCredentials {
|
||||
[machineId: string]: {
|
||||
username: string;
|
||||
password: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface BulkSSHCommandModalProps {
|
||||
isOpen: boolean;
|
||||
selectedMachines: Machine[];
|
||||
onClose: () => void;
|
||||
onExecute: (command: string, mode: CredentialsMode, globalCreds?: GlobalCredentials, customCreds?: CustomCredentials) => void;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export function BulkSSHCommandModal({
|
||||
isOpen,
|
||||
selectedMachines,
|
||||
onClose,
|
||||
onExecute,
|
||||
userRole
|
||||
}: BulkSSHCommandModalProps) {
|
||||
|
||||
const [command, setCommand] = useState('');
|
||||
const [credentialsMode, setCredentialsMode] = useState<CredentialsMode>('global');
|
||||
const [globalCredentials, setGlobalCredentials] = useState<GlobalCredentials>({
|
||||
username: '',
|
||||
password: ''
|
||||
});
|
||||
const [customCredentials, setCustomCredentials] = useState<CustomCredentials>({});
|
||||
|
||||
// Command whitelist for USER role
|
||||
const isUserRole = userRole === 'USER';
|
||||
const allowedCommands = [
|
||||
'uptime',
|
||||
'df -h',
|
||||
'free -m',
|
||||
'top -bn1',
|
||||
'systemctl status',
|
||||
'docker ps',
|
||||
'ps aux',
|
||||
'ls -la',
|
||||
'cat /etc/os-release',
|
||||
'hostname'
|
||||
];
|
||||
|
||||
// Check if command is allowed
|
||||
const isCommandAllowed = !isUserRole || allowedCommands.some(cmd =>
|
||||
command.trim().startsWith(cmd)
|
||||
);
|
||||
|
||||
// Initialize custom credentials for machines
|
||||
useEffect(() => {
|
||||
if (credentialsMode === 'custom') {
|
||||
const initialCreds: CustomCredentials = {};
|
||||
selectedMachines.forEach(machine => {
|
||||
if (!customCredentials[machine.id]) {
|
||||
initialCreds[machine.id] = { username: '', password: '' };
|
||||
}
|
||||
});
|
||||
setCustomCredentials(prev => ({ ...prev, ...initialCreds }));
|
||||
}
|
||||
}, [credentialsMode, selectedMachines]);
|
||||
|
||||
// Handle execute
|
||||
const handleExecute = () => {
|
||||
if (!command.trim()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isCommandAllowed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (credentialsMode === 'global') {
|
||||
if (!globalCredentials.username || !globalCredentials.password) {
|
||||
return;
|
||||
}
|
||||
onExecute(command, 'global', globalCredentials);
|
||||
} else {
|
||||
// custom mode
|
||||
// Validate all machines have credentials
|
||||
const allHaveCreds = selectedMachines.every(m =>
|
||||
customCredentials[m.id]?.username && customCredentials[m.id]?.password
|
||||
);
|
||||
if (!allHaveCreds) {
|
||||
return;
|
||||
}
|
||||
onExecute(command, 'custom', undefined, customCredentials);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle custom credential change
|
||||
const handleCustomCredChange = (machineId: string, field: 'username' | 'password', value: string) => {
|
||||
setCustomCredentials(prev => ({
|
||||
...prev,
|
||||
[machineId]: {
|
||||
...prev[machineId],
|
||||
[field]: value
|
||||
}
|
||||
}));
|
||||
};
|
||||
|
||||
// Copy global credentials to all machines
|
||||
const copyToAllMachines = () => {
|
||||
if (!globalCredentials.username || !globalCredentials.password) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newCreds: CustomCredentials = {};
|
||||
selectedMachines.forEach(machine => {
|
||||
newCreds[machine.id] = {
|
||||
username: globalCredentials.username,
|
||||
password: globalCredentials.password
|
||||
};
|
||||
});
|
||||
setCustomCredentials(newCreds);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-3xl w-full mx-4 max-h-[90vh] flex flex-col select-none">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-kaspersky-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-kaspersky-primary bg-opacity-10 rounded-full flex items-center justify-center">
|
||||
<Terminal className="w-6 h-6 text-kaspersky-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-kaspersky-text-dark">Execute SSH Command</h2>
|
||||
<p className="text-sm text-kaspersky-text-light">
|
||||
Run command on {selectedMachines.length} machine{selectedMachines.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6 space-y-6">
|
||||
|
||||
{/* Command Input */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Command *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={command}
|
||||
onChange={e => setCommand(e.target.value)}
|
||||
placeholder={isUserRole ? "e.g. uptime, df -h, free -m" : "e.g. systemctl restart nginx"}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 font-mono select-text"
|
||||
/>
|
||||
|
||||
{/* Command validation message */}
|
||||
{isUserRole && !isCommandAllowed && command.trim() && (
|
||||
<p className="mt-2 text-sm text-kaspersky-danger">
|
||||
⚠️ This command is not in the whitelist. Allowed: {allowedCommands.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{isUserRole && (
|
||||
<p className="mt-2 text-sm text-kaspersky-text-light">
|
||||
ℹ️ USER role: Only whitelist commands allowed
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Credentials Mode Selector */}
|
||||
<div>
|
||||
<label className="block text-sm font-semibold text-kaspersky-text-dark mb-3">
|
||||
Credentials Mode *
|
||||
</label>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
{/* Mode 1: Global */}
|
||||
<button
|
||||
onClick={() => setCredentialsMode('global')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
credentialsMode === 'global'
|
||||
? 'border-kaspersky-primary bg-kaspersky-primary bg-opacity-10'
|
||||
: 'border-kaspersky-border bg-kaspersky-bg hover:border-kaspersky-primary hover:border-opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<Users size={24} className={credentialsMode === 'global' ? 'text-kaspersky-primary' : 'text-kaspersky-text-light'} />
|
||||
<span className="text-sm font-medium text-kaspersky-text-dark">Global</span>
|
||||
<span className="text-xs text-kaspersky-text-light text-center">
|
||||
Same for all machines
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{/* Mode 2: Custom */}
|
||||
<button
|
||||
onClick={() => setCredentialsMode('custom')}
|
||||
className={`p-4 rounded-lg border-2 transition-all ${
|
||||
credentialsMode === 'custom'
|
||||
? 'border-kaspersky-primary bg-kaspersky-primary bg-opacity-10'
|
||||
: 'border-kaspersky-border bg-kaspersky-bg hover:border-kaspersky-primary hover:border-opacity-50'
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<User size={24} className={credentialsMode === 'custom' ? 'text-kaspersky-primary' : 'text-kaspersky-text-light'} />
|
||||
<span className="text-sm font-medium text-kaspersky-text-dark">Custom</span>
|
||||
<span className="text-xs text-kaspersky-text-light text-center">
|
||||
Per-machine credentials
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Global Credentials Input */}
|
||||
{credentialsMode === 'global' && (
|
||||
<div className="bg-kaspersky-bg border border-kaspersky-border rounded-lg p-4 space-y-3">
|
||||
<h4 className="font-semibold text-kaspersky-text-dark">Global Credentials</h4>
|
||||
<p className="text-sm text-kaspersky-text-light">
|
||||
These credentials will be used for all {selectedMachines.length} machines
|
||||
</p>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kaspersky-text-dark mb-1">Username</label>
|
||||
<input
|
||||
type="text"
|
||||
value={globalCredentials.username}
|
||||
onChange={e => setGlobalCredentials(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="root"
|
||||
className="w-full px-3 py-2 bg-white border border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary select-text"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-kaspersky-text-dark mb-1">Password</label>
|
||||
<input
|
||||
type="password"
|
||||
value={globalCredentials.password}
|
||||
onChange={e => setGlobalCredentials(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="••••••••"
|
||||
className="w-full px-3 py-2 bg-white border border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary select-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom Credentials Input */}
|
||||
{credentialsMode === 'custom' && (
|
||||
<div className="bg-kaspersky-bg border border-kaspersky-border rounded-lg p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="font-semibold text-kaspersky-text-dark">Custom Credentials Per Machine</h4>
|
||||
<Button
|
||||
onClick={copyToAllMachines}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
disabled={!globalCredentials.username || !globalCredentials.password}
|
||||
>
|
||||
Copy from Global
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Quick global fill (helper) */}
|
||||
<div className="bg-yellow-50 border border-yellow-200 rounded p-3 space-y-2">
|
||||
<p className="text-xs text-yellow-800 font-medium">Quick Fill (optional)</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={globalCredentials.username}
|
||||
onChange={e => setGlobalCredentials(prev => ({ ...prev, username: e.target.value }))}
|
||||
placeholder="Username"
|
||||
className="px-2 py-1 text-sm bg-white border border-yellow-300 rounded select-text"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={globalCredentials.password}
|
||||
onChange={e => setGlobalCredentials(prev => ({ ...prev, password: e.target.value }))}
|
||||
placeholder="Password"
|
||||
className="px-2 py-1 text-sm bg-white border border-yellow-300 rounded select-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Machine credentials list */}
|
||||
<div className="max-h-60 overflow-y-auto space-y-3">
|
||||
{selectedMachines.map(machine => (
|
||||
<div key={machine.id} className="bg-white border border-kaspersky-border rounded p-3 space-y-2">
|
||||
<div className="font-medium text-sm text-kaspersky-text-dark">{machine.name}</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customCredentials[machine.id]?.username || ''}
|
||||
onChange={e => handleCustomCredChange(machine.id, 'username', e.target.value)}
|
||||
placeholder="Username"
|
||||
className="px-2 py-1 text-sm bg-kaspersky-bg border border-kaspersky-border rounded focus:outline-none focus:border-kaspersky-primary select-text"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={customCredentials[machine.id]?.password || ''}
|
||||
onChange={e => handleCustomCredChange(machine.id, 'password', e.target.value)}
|
||||
placeholder="Password"
|
||||
className="px-2 py-1 text-sm bg-kaspersky-bg border border-kaspersky-border rounded focus:outline-none focus:border-kaspersky-primary select-text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-kaspersky-border flex justify-between items-center">
|
||||
<div className="text-sm text-kaspersky-text-light">
|
||||
{credentialsMode === 'global' && `Same credentials for all ${selectedMachines.length} machines`}
|
||||
{credentialsMode === 'custom' && `Custom credentials for each machine`}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="secondary"
|
||||
leftIcon={<X size={18} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleExecute}
|
||||
variant="primary"
|
||||
leftIcon={<Terminal size={18} />}
|
||||
disabled={!command.trim() || !isCommandAllowed ||
|
||||
(credentialsMode === 'global' && (!globalCredentials.username || !globalCredentials.password)) ||
|
||||
(credentialsMode === 'custom' && !selectedMachines.every(m => customCredentials[m.id]?.username && customCredentials[m.id]?.password))
|
||||
}
|
||||
>
|
||||
Execute on {selectedMachines.length} Machine{selectedMachines.length !== 1 ? 's' : ''}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
367
mc_test/src/renderer/components/BulkSSHResultsModal.tsx
Executable file
367
mc_test/src/renderer/components/BulkSSHResultsModal.tsx
Executable file
@ -0,0 +1,367 @@
|
||||
/**
|
||||
* BulkSSHResultsModal Component
|
||||
*
|
||||
* Displays detailed results of bulk SSH command execution.
|
||||
* Features:
|
||||
* - Tabbed view (All / Success / Failed)
|
||||
* - Expandable stdout/stderr
|
||||
* - Export to CSV
|
||||
* - Retry failed
|
||||
*/
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import { X, CheckCircle, XCircle, Terminal, Download, AlertCircle, ChevronDown, ChevronUp } from 'lucide-react';
|
||||
import { Button } from './shared/Button';
|
||||
|
||||
interface SSHResult {
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
hostname: string;
|
||||
status: 'success' | 'failed' | 'timeout' | 'no_credentials';
|
||||
exit_code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
execution_time_ms?: number;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
interface SSHResponse {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: SSHResult[];
|
||||
execution_time_ms: number;
|
||||
command: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
interface BulkSSHResultsModalProps {
|
||||
isOpen: boolean;
|
||||
results: SSHResponse | null;
|
||||
onClose: () => void;
|
||||
onRetryFailed?: () => void;
|
||||
}
|
||||
|
||||
type FilterTab = 'all' | 'success' | 'failed';
|
||||
|
||||
export function BulkSSHResultsModal({
|
||||
isOpen,
|
||||
results,
|
||||
onClose,
|
||||
onRetryFailed
|
||||
}: BulkSSHResultsModalProps) {
|
||||
|
||||
const [activeTab, setActiveTab] = useState<FilterTab>('all');
|
||||
const [expandedMachines, setExpandedMachines] = useState<Set<string>>(new Set());
|
||||
|
||||
// Filter results
|
||||
const filteredResults = useMemo(() => {
|
||||
if (!results) return [];
|
||||
|
||||
switch (activeTab) {
|
||||
case 'success':
|
||||
return results.results.filter(r => r.status === 'success');
|
||||
case 'failed':
|
||||
return results.results.filter(r => r.status !== 'success');
|
||||
default:
|
||||
return results.results;
|
||||
}
|
||||
}, [results, activeTab]);
|
||||
|
||||
// Toggle expand/collapse for machine
|
||||
const toggleExpand = (machineId: string) => {
|
||||
setExpandedMachines(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(machineId)) {
|
||||
newSet.delete(machineId);
|
||||
} else {
|
||||
newSet.add(machineId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
// Expand all
|
||||
const expandAll = () => {
|
||||
setExpandedMachines(new Set(filteredResults.map(r => r.machine_id)));
|
||||
};
|
||||
|
||||
// Collapse all
|
||||
const collapseAll = () => {
|
||||
setExpandedMachines(new Set());
|
||||
};
|
||||
|
||||
// Export to CSV
|
||||
const handleExportCSV = () => {
|
||||
if (!results) return;
|
||||
|
||||
const csvHeaders = ['Machine Name', 'Hostname', 'Status', 'Exit Code', 'Stdout', 'Stderr', 'Error'];
|
||||
const csvRows = results.results.map(r => [
|
||||
r.machine_name,
|
||||
r.hostname,
|
||||
r.status,
|
||||
r.exit_code?.toString() || 'N/A',
|
||||
r.stdout || '',
|
||||
r.stderr || '',
|
||||
r.error || ''
|
||||
]);
|
||||
|
||||
const csvContent = [
|
||||
csvHeaders.join(','),
|
||||
...csvRows.map(row => row.map(cell => `"${cell.replace(/"/g, '""')}"`).join(','))
|
||||
].join('\n');
|
||||
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const link = document.createElement('a');
|
||||
link.href = URL.createObjectURL(blob);
|
||||
link.download = `ssh-results-${new Date().toISOString()}.csv`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
if (!isOpen || !results) return null;
|
||||
|
||||
const hasFailures = results.failed > 0;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-[100]">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-5xl w-full mx-4 max-h-[90vh] flex flex-col select-none">
|
||||
|
||||
{/* Header */}
|
||||
<div className="p-6 border-b border-kaspersky-border">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-kaspersky-text-dark">SSH Command Results</h2>
|
||||
<p className="text-sm text-kaspersky-text-light mt-1">
|
||||
Completed in {(results.execution_time_ms / 1000).toFixed(2)}s
|
||||
</p>
|
||||
<code className="text-xs bg-kaspersky-bg px-2 py-1 rounded mt-2 inline-block">
|
||||
{results.command}
|
||||
</code>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-kaspersky-text-light hover:text-kaspersky-text-dark transition-colors"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Summary Stats */}
|
||||
<div className="grid grid-cols-3 gap-3 mt-4">
|
||||
<div className="bg-kaspersky-bg p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-kaspersky-text-dark">{results.total}</div>
|
||||
<div className="text-xs text-kaspersky-text-light mt-1">Total</div>
|
||||
</div>
|
||||
<div className="bg-green-50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-kaspersky-success">{results.success}</div>
|
||||
<div className="text-xs text-kaspersky-success mt-1">Success</div>
|
||||
</div>
|
||||
<div className="bg-red-50 p-3 rounded-lg text-center">
|
||||
<div className="text-2xl font-bold text-kaspersky-danger">{results.failed}</div>
|
||||
<div className="text-xs text-kaspersky-danger mt-1">Failed</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b border-kaspersky-border px-6 justify-between items-center">
|
||||
<div className="flex">
|
||||
<button
|
||||
onClick={() => setActiveTab('all')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'all'
|
||||
? 'border-kaspersky-primary text-kaspersky-primary'
|
||||
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
|
||||
}`}
|
||||
>
|
||||
All ({results.total})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('success')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'success'
|
||||
? 'border-kaspersky-success text-kaspersky-success'
|
||||
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
|
||||
}`}
|
||||
>
|
||||
Success ({results.success})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab('failed')}
|
||||
className={`px-4 py-3 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === 'failed'
|
||||
? 'border-kaspersky-danger text-kaspersky-danger'
|
||||
: 'border-transparent text-kaspersky-text-light hover:text-kaspersky-text-dark'
|
||||
}`}
|
||||
>
|
||||
Failed ({results.failed})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse controls */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={expandAll}
|
||||
className="text-xs text-kaspersky-primary hover:underline"
|
||||
>
|
||||
Expand All
|
||||
</button>
|
||||
<span className="text-kaspersky-text-light">|</span>
|
||||
<button
|
||||
onClick={collapseAll}
|
||||
className="text-xs text-kaspersky-primary hover:underline"
|
||||
>
|
||||
Collapse All
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Results List */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="space-y-2">
|
||||
{filteredResults.map((result) => {
|
||||
const isExpanded = expandedMachines.has(result.machine_id);
|
||||
const hasOutput = result.stdout || result.stderr;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={result.machine_id}
|
||||
className="border border-kaspersky-border rounded-lg overflow-hidden"
|
||||
>
|
||||
{/* Machine Header */}
|
||||
<button
|
||||
onClick={() => hasOutput && toggleExpand(result.machine_id)}
|
||||
className="w-full p-4 flex items-center justify-between hover:bg-kaspersky-bg transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Status Icon */}
|
||||
<div title={result.status}>
|
||||
{result.status === 'success' ? (
|
||||
<CheckCircle size={20} className="text-kaspersky-success" />
|
||||
) : result.status === 'timeout' ? (
|
||||
<AlertCircle size={20} className="text-kaspersky-warning" />
|
||||
) : (
|
||||
<XCircle size={20} className="text-kaspersky-danger" />
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Machine Info */}
|
||||
<div className="text-left">
|
||||
<div className="font-medium text-kaspersky-text-dark">{result.machine_name}</div>
|
||||
<div className="text-sm text-kaspersky-text-light">{result.hostname}</div>
|
||||
</div>
|
||||
|
||||
{/* Exit Code Badge */}
|
||||
{result.exit_code !== undefined && (
|
||||
<span className={`px-2 py-1 rounded text-xs font-mono ${
|
||||
result.exit_code === 0
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-red-100 text-red-800'
|
||||
}`}>
|
||||
Exit: {result.exit_code}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Error Badge */}
|
||||
{result.error && (
|
||||
<span className="px-2 py-1 rounded text-xs bg-red-100 text-red-800 max-w-xs truncate">
|
||||
{result.error}
|
||||
</span>
|
||||
)}
|
||||
|
||||
{/* Execution Time */}
|
||||
{result.execution_time_ms && (
|
||||
<span className="text-xs text-kaspersky-text-light">
|
||||
{result.execution_time_ms}ms
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expand/Collapse Icon */}
|
||||
{hasOutput && (
|
||||
<div>
|
||||
{isExpanded ? (
|
||||
<ChevronUp size={20} className="text-kaspersky-text-light" />
|
||||
) : (
|
||||
<ChevronDown size={20} className="text-kaspersky-text-light" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Expanded Output */}
|
||||
{isExpanded && hasOutput && (
|
||||
<div className="border-t border-kaspersky-border bg-kaspersky-bg p-4 space-y-3">
|
||||
{/* Stdout */}
|
||||
{result.stdout && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-kaspersky-text-dark mb-1">STDOUT:</div>
|
||||
<pre className="bg-white border border-kaspersky-border rounded p-3 text-xs overflow-x-auto font-mono whitespace-pre-wrap">
|
||||
{result.stdout}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Stderr */}
|
||||
{result.stderr && (
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-kaspersky-danger mb-1">STDERR:</div>
|
||||
<pre className="bg-red-50 border border-red-200 rounded p-3 text-xs overflow-x-auto font-mono whitespace-pre-wrap text-red-800">
|
||||
{result.stderr}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{filteredResults.length === 0 && (
|
||||
<div className="text-center py-8 text-kaspersky-text-light">
|
||||
No results to display
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-6 border-t border-kaspersky-border flex justify-between items-center">
|
||||
<div className="flex gap-2">
|
||||
{hasFailures && onRetryFailed && (
|
||||
<Button
|
||||
onClick={onRetryFailed}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<Terminal size={16} />}
|
||||
>
|
||||
Retry Failed ({results.failed})
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
onClick={handleExportCSV}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
leftIcon={<Download size={16} />}
|
||||
>
|
||||
Export CSV
|
||||
</Button>
|
||||
<Button
|
||||
onClick={onClose}
|
||||
variant="primary"
|
||||
size="sm"
|
||||
>
|
||||
Close
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
145
mc_test/src/renderer/components/ConnectionStatusBadge.tsx
Executable file
145
mc_test/src/renderer/components/ConnectionStatusBadge.tsx
Executable file
@ -0,0 +1,145 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Wifi, WifiOff, Clock, AlertCircle, Shield } from 'lucide-react';
|
||||
import { Machine } from '../types';
|
||||
import { ProtocolHelper, OSInfo } from '../utils/protocolHelper';
|
||||
|
||||
interface ConnectionStatusBadgeProps {
|
||||
machine: Machine;
|
||||
isConnected: boolean;
|
||||
connectionTime?: Date;
|
||||
protocol?: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ConnectionStatusBadge: React.FC<ConnectionStatusBadgeProps> = ({
|
||||
machine,
|
||||
isConnected,
|
||||
connectionTime,
|
||||
protocol,
|
||||
className = ''
|
||||
}) => {
|
||||
const osInfo: OSInfo = useMemo(() => {
|
||||
return ProtocolHelper.parseOS(machine.os);
|
||||
}, [machine.os]);
|
||||
|
||||
const getProtocolIcon = (proto?: string) => {
|
||||
if (!proto) return null;
|
||||
|
||||
const protocolIcons: Record<string, string> = {
|
||||
rdp: '🖥️',
|
||||
vnc: '🖱️',
|
||||
ssh: '⌨️',
|
||||
telnet: '📡'
|
||||
};
|
||||
|
||||
return protocolIcons[proto.toLowerCase()] || '🔗';
|
||||
};
|
||||
|
||||
const formatConnectionTime = (time: Date) => {
|
||||
const now = new Date();
|
||||
const diff = Math.floor((now.getTime() - time.getTime()) / 1000);
|
||||
|
||||
if (diff < 60) return `${diff}s`;
|
||||
if (diff < 3600) return `${Math.floor(diff / 60)}m`;
|
||||
if (diff < 86400) return `${Math.floor(diff / 3600)}h`;
|
||||
return `${Math.floor(diff / 86400)}d`;
|
||||
};
|
||||
|
||||
const getStatusConfig = () => {
|
||||
if (isConnected) {
|
||||
return {
|
||||
icon: <Wifi size={14} className="text-green-600" />,
|
||||
text: 'Connected',
|
||||
bgColor: 'bg-green-50',
|
||||
borderColor: 'border-green-200',
|
||||
textColor: 'text-green-800',
|
||||
animation: 'animate-pulse'
|
||||
};
|
||||
}
|
||||
|
||||
switch (machine.status) {
|
||||
case 'running':
|
||||
return {
|
||||
icon: <WifiOff size={14} className="text-gray-500" />,
|
||||
text: 'Available',
|
||||
bgColor: 'bg-gray-50',
|
||||
borderColor: 'border-gray-200',
|
||||
textColor: 'text-gray-700'
|
||||
};
|
||||
case 'stopped':
|
||||
return {
|
||||
icon: <AlertCircle size={14} className="text-red-500" />,
|
||||
text: 'Offline',
|
||||
bgColor: 'bg-red-50',
|
||||
borderColor: 'border-red-200',
|
||||
textColor: 'text-red-700'
|
||||
};
|
||||
default:
|
||||
return {
|
||||
icon: <Clock size={14} className="text-yellow-500" />,
|
||||
text: 'Unknown',
|
||||
bgColor: 'bg-yellow-50',
|
||||
borderColor: 'border-yellow-200',
|
||||
textColor: 'text-yellow-700'
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const statusConfig = getStatusConfig();
|
||||
const osIcon = osInfo.family === 'windows' ? '🖥️' :
|
||||
osInfo.family === 'linux' ? '🐧' :
|
||||
osInfo.family === 'macos' ? '🍎' : '💻';
|
||||
|
||||
return (
|
||||
<div className={`inline-flex items-center gap-2 ${className}`}>
|
||||
{/* Main status badge */}
|
||||
<div
|
||||
className={`
|
||||
flex items-center gap-2 px-3 py-1.5 rounded-full border
|
||||
${statusConfig.bgColor} ${statusConfig.borderColor} ${statusConfig.textColor}
|
||||
${statusConfig.animation || ''}
|
||||
transition-all duration-200
|
||||
`}
|
||||
>
|
||||
{statusConfig.icon}
|
||||
<span className="text-xs font-medium">{statusConfig.text}</span>
|
||||
|
||||
{isConnected && connectionTime && (
|
||||
<>
|
||||
<span className="text-gray-400">•</span>
|
||||
<span className="text-xs">{formatConnectionTime(connectionTime)}</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Protocol indicator */}
|
||||
{protocol && isConnected && (
|
||||
<div className="flex items-center gap-1 px-2 py-1 bg-blue-50 border border-blue-200 rounded text-xs text-blue-700">
|
||||
<span>{getProtocolIcon(protocol)}</span>
|
||||
<span className="font-medium">{protocol.toUpperCase()}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OS indicator */}
|
||||
<div
|
||||
className="flex items-center gap-1 px-2 py-1 bg-gray-100 border border-gray-200 rounded text-xs text-gray-600"
|
||||
title={machine.os}
|
||||
>
|
||||
<span>{osIcon}</span>
|
||||
<span className="hidden sm:inline capitalize">{osInfo.family}</span>
|
||||
</div>
|
||||
|
||||
{/* Security indicator for secure protocols */}
|
||||
{protocol && ['ssh', 'rdp'].includes(protocol.toLowerCase()) && (
|
||||
<div
|
||||
className="flex items-center px-2 py-1 bg-green-50 border border-green-200 rounded"
|
||||
title="Secure connection"
|
||||
>
|
||||
<Shield size={12} className="text-green-600" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ConnectionStatusBadge;
|
||||
98
mc_test/src/renderer/components/ErrorBoundary.tsx
Executable file
98
mc_test/src/renderer/components/ErrorBoundary.tsx
Executable file
@ -0,0 +1,98 @@
|
||||
import { Component, ErrorInfo, ReactNode } from 'react';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
interface Props {
|
||||
children: ReactNode;
|
||||
fallback?: ReactNode;
|
||||
onError?: (error: Error, errorInfo: ErrorInfo) => void;
|
||||
}
|
||||
|
||||
interface State {
|
||||
hasError: boolean;
|
||||
error?: Error;
|
||||
errorId?: string;
|
||||
}
|
||||
|
||||
export class ErrorBoundary extends Component<Props, State> {
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.state = { hasError: false };
|
||||
}
|
||||
|
||||
static getDerivedStateFromError(error: Error): State {
|
||||
return {
|
||||
hasError: true,
|
||||
error,
|
||||
errorId: Math.random().toString(36).substr(2, 9)
|
||||
};
|
||||
}
|
||||
|
||||
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
|
||||
const errorId = this.state.errorId;
|
||||
|
||||
// Log error
|
||||
log.error('error-boundary', 'React Error Boundary caught an error', {
|
||||
errorId,
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
componentStack: errorInfo.componentStack,
|
||||
});
|
||||
|
||||
// Call callback if provided
|
||||
this.props.onError?.(error, errorInfo);
|
||||
}
|
||||
|
||||
private handleTryAgain = () => {
|
||||
this.setState({ hasError: false, error: undefined, errorId: undefined });
|
||||
};
|
||||
|
||||
private handleReload = () => {
|
||||
window.location.reload();
|
||||
};
|
||||
|
||||
render() {
|
||||
if (this.state.hasError) {
|
||||
return this.props.fallback || (
|
||||
<div className="flex flex-col items-center justify-center min-h-[400px] p-6">
|
||||
<div className="bg-red-50 border border-red-200 rounded-lg p-6 max-w-md w-full">
|
||||
<div className="flex items-center mb-4">
|
||||
<svg className="w-6 h-6 text-red-600 mr-3" 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>
|
||||
<h3 className="text-lg font-medium text-red-800">Something went wrong</h3>
|
||||
</div>
|
||||
<p className="text-red-700 mb-4">
|
||||
An unexpected error occurred. Our team has been notified.
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={this.handleTryAgain}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
<button
|
||||
onClick={this.handleReload}
|
||||
className="px-4 py-2 border border-red-300 text-red-700 rounded hover:bg-red-50 transition-colors"
|
||||
>
|
||||
Reload Page
|
||||
</button>
|
||||
</div>
|
||||
{process.env.NODE_ENV === 'development' && (
|
||||
<details className="mt-4">
|
||||
<summary className="text-sm text-red-600 cursor-pointer hover:text-red-700">
|
||||
Error Details (Development)
|
||||
</summary>
|
||||
<pre className="mt-2 text-xs text-red-800 bg-red-100 p-2 rounded overflow-auto max-h-32">
|
||||
{this.state.error?.stack}
|
||||
</pre>
|
||||
</details>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
245
mc_test/src/renderer/components/GuacamoleViewer.tsx
Executable file
245
mc_test/src/renderer/components/GuacamoleViewer.tsx
Executable file
@ -0,0 +1,245 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Machine } from '../types';
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
interface GuacamoleViewerProps {
|
||||
machine: Machine;
|
||||
connectionUrl: string;
|
||||
enableSftp?: boolean;
|
||||
onError?: (error: Error) => void;
|
||||
onDisconnect?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* GuacamoleViewer component
|
||||
*
|
||||
* KNOWN BEHAVIOR: You may see "DELETE /guacamole/api/session 403 Forbidden" in DevTools.
|
||||
* This is EXPECTED and NOT an error:
|
||||
*
|
||||
* - Guacamole Client checks localStorage for old sessions when loading
|
||||
* - It tries to clean up expired sessions via DELETE /guacamole/api/session
|
||||
* - Old session tokens are invalid, resulting in 403 Forbidden
|
||||
* - This is "best effort cleanup" and doesn't affect functionality
|
||||
* - We manage all connections properly through our API endpoints
|
||||
*
|
||||
* This can be safely ignored - it's standard Guacamole Client behavior.
|
||||
*/
|
||||
function GuacamoleViewer({ machine, connectionUrl, enableSftp, onError, onDisconnect }: GuacamoleViewerProps) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<Error | null>(null);
|
||||
const [isConsoleActive, setIsConsoleActive] = useState(false);
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Effect for loading iframe (only on first initialization)
|
||||
useEffect(() => {
|
||||
const connectToGuacamole = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
log.info('guacamole-viewer', 'Initializing Guacamole session', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name,
|
||||
connectionUrl: connectionUrl
|
||||
});
|
||||
|
||||
if (iframeRef.current) {
|
||||
// Load URL only if iframe is empty
|
||||
if (!iframeRef.current.src || iframeRef.current.src === 'about:blank') {
|
||||
log.debug('guacamole-viewer', 'Loading connection URL', {
|
||||
url: connectionUrl
|
||||
});
|
||||
iframeRef.current.src = connectionUrl;
|
||||
} else {
|
||||
log.debug('guacamole-viewer', 'iframe already loaded, keeping existing session');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Add handler to track iframe loading
|
||||
const handleLoad = () => {
|
||||
setLoading(false);
|
||||
log.info('guacamole-viewer', 'Guacamole session loaded', {
|
||||
machineId: machine.id,
|
||||
});
|
||||
|
||||
// Automatically activate console after loading
|
||||
setTimeout(() => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.focus();
|
||||
setIsConsoleActive(true);
|
||||
}
|
||||
}, 500);
|
||||
};
|
||||
|
||||
const handleError = () => {
|
||||
const error = new Error('Failed to load Guacamole session');
|
||||
setError(error);
|
||||
setLoading(false);
|
||||
onError?.(error);
|
||||
};
|
||||
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.addEventListener('load', handleLoad);
|
||||
iframeRef.current.addEventListener('error', handleError);
|
||||
|
||||
return () => {
|
||||
iframeRef.current?.removeEventListener('load', handleLoad);
|
||||
iframeRef.current?.removeEventListener('error', handleError);
|
||||
};
|
||||
}
|
||||
} catch (err) {
|
||||
const error = err instanceof Error ? err : new Error('Unknown error occurred');
|
||||
log.error('guacamole-viewer', 'Connection failed', {
|
||||
machineId: machine.id,
|
||||
error: error.message
|
||||
});
|
||||
setError(error);
|
||||
onError?.(error);
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
connectToGuacamole();
|
||||
|
||||
return () => {
|
||||
log.info('guacamole-viewer', 'Cleaning up connection', {
|
||||
machineId: machine.id
|
||||
});
|
||||
setIsConsoleActive(false);
|
||||
};
|
||||
}, [machine.id]);
|
||||
|
||||
// Deactivate console on outside click
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(event.target as Node) && isConsoleActive) {
|
||||
setIsConsoleActive(false);
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.blur();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => {
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isConsoleActive]);
|
||||
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center bg-red-50 text-red-600 p-4 rounded-kaspersky">
|
||||
<div className="text-center">
|
||||
<p className="font-medium mb-2">Connection Error</p>
|
||||
<p className="text-sm mb-4">{error.message}</p>
|
||||
<div className="flex gap-2 justify-center">
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="px-4 py-2 bg-red-600 text-white rounded-kaspersky hover:bg-red-700 transition-colors"
|
||||
>
|
||||
Retry
|
||||
</button>
|
||||
<button
|
||||
onClick={onDisconnect}
|
||||
className="px-4 py-2 bg-gray-600 text-white rounded-kaspersky hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
Disconnect
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full h-full flex flex-col bg-kaspersky-bg">
|
||||
{/* Область для Guacamole - занимает всё пространство */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`flex-1 relative bg-kaspersky-bg overflow-hidden ${
|
||||
isConsoleActive ? 'ring-2 ring-kaspersky-primary ring-opacity-50' : ''
|
||||
}`}
|
||||
>
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-kaspersky-bg bg-opacity-75 z-10">
|
||||
<div className="text-center">
|
||||
<div className="w-8 h-8 border-4 border-kaspersky-base border-t-transparent rounded-full animate-spin mx-auto"></div>
|
||||
<p className="mt-2 text-kaspersky-text">Connecting to {machine.name}...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Прозрачный overlay для активации консоли при клике */}
|
||||
{!loading && !isConsoleActive && (
|
||||
<div
|
||||
className="absolute inset-0 z-10 cursor-pointer"
|
||||
onClick={() => {
|
||||
if (iframeRef.current) {
|
||||
iframeRef.current.focus();
|
||||
setIsConsoleActive(true);
|
||||
}
|
||||
}}
|
||||
title="Click to activate console"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Индикатор фокуса консоли с подсказками */}
|
||||
{!loading && (
|
||||
<div className="absolute top-4 left-1/2 transform -translate-x-1/2 z-20 pointer-events-none select-none">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
{/* Индикатор статуса консоли */}
|
||||
<div
|
||||
className={`flex items-center gap-3 px-4 py-2 rounded-lg text-sm font-semibold transition-all duration-300 ${
|
||||
isConsoleActive
|
||||
? 'bg-kaspersky-primary text-white shadow-kaspersky-lg'
|
||||
: 'bg-kaspersky-secondary bg-opacity-90 text-kaspersky-text-white'
|
||||
}`}
|
||||
>
|
||||
<div className={`w-3 h-3 rounded-full transition-all duration-300 ${
|
||||
isConsoleActive ? 'bg-kaspersky-danger animate-pulse' : 'bg-kaspersky-text-lighter'
|
||||
}`}></div>
|
||||
<span>{isConsoleActive ? 'Console Active' : 'Console Inactive'}</span>
|
||||
</div>
|
||||
|
||||
{/* Подсказка с клавиатурными сочетаниями */}
|
||||
<div className="bg-kaspersky-secondary bg-opacity-75 backdrop-blur-sm text-kaspersky-text-white px-3 py-1.5 rounded-md text-xs font-medium">
|
||||
{enableSftp ? (
|
||||
// Tooltip for SSH with SFTP
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-75">Settings & SFTP:</span>
|
||||
<kbd className="px-1.5 py-0.5 bg-kaspersky-base bg-opacity-50 rounded text-[10px] font-mono">
|
||||
Ctrl+Shift+Alt
|
||||
</kbd>
|
||||
</div>
|
||||
) : (
|
||||
// Tooltip for settings only
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="opacity-75">Settings:</span>
|
||||
<kbd className="px-1.5 py-0.5 bg-kaspersky-base bg-opacity-50 rounded text-[10px] font-mono">
|
||||
Ctrl+Shift+Alt
|
||||
</kbd>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
className="w-full h-full border-0"
|
||||
title={`Remote Desktop - ${machine.name}`}
|
||||
allow="clipboard-read; clipboard-write; keyboard-map *; pointer-lock"
|
||||
sandbox="allow-same-origin allow-scripts allow-forms allow-popups allow-pointer-lock allow-modals allow-downloads"
|
||||
onFocus={() => setIsConsoleActive(true)}
|
||||
onBlur={() => setIsConsoleActive(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default GuacamoleViewer;
|
||||
53
mc_test/src/renderer/components/LazyComponents.tsx
Executable file
53
mc_test/src/renderer/components/LazyComponents.tsx
Executable file
@ -0,0 +1,53 @@
|
||||
import { lazy, Suspense } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Loading spinner component
|
||||
*/
|
||||
const LoadingSpinner: React.FC<{ message?: string }> = ({ message = 'Loading...' }) => (
|
||||
<div className="flex flex-col items-center justify-center p-8">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-kaspersky-base mb-3"></div>
|
||||
<p className="text-kaspersky-text text-sm">{message}</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
/**
|
||||
* Lazy loading of heavy components
|
||||
*/
|
||||
export const LazyGuacamoleViewer = lazy(() =>
|
||||
import('./GuacamoleViewer').then((module) => ({ default: module.default }))
|
||||
);
|
||||
|
||||
export const LazyMachineCredentialsModal = lazy(() =>
|
||||
import('./MachineCredentialsModal').then((module) => ({ default: module.default }))
|
||||
);
|
||||
|
||||
/**
|
||||
* HOC for wrapping components in Suspense
|
||||
*/
|
||||
export const withSuspense = <P extends object>(
|
||||
Component: React.ComponentType<P>,
|
||||
loadingMessage?: string
|
||||
) => {
|
||||
const WrappedComponent: React.FC<P> = (props) => (
|
||||
<Suspense fallback={<LoadingSpinner message={loadingMessage} />}>
|
||||
<Component {...props} />
|
||||
</Suspense>
|
||||
);
|
||||
|
||||
WrappedComponent.displayName = `withSuspense(${Component.displayName || Component.name})`;
|
||||
return WrappedComponent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ready-to-use components with Suspense
|
||||
*/
|
||||
export const GuacamoleViewerWithSuspense = withSuspense(
|
||||
LazyGuacamoleViewer,
|
||||
'Loading remote connection...'
|
||||
);
|
||||
|
||||
export const MachineCredentialsModalWithSuspense = withSuspense(
|
||||
LazyMachineCredentialsModal,
|
||||
'Loading connection form...'
|
||||
);
|
||||
116
mc_test/src/renderer/components/LoginModal.tsx
Executable file
116
mc_test/src/renderer/components/LoginModal.tsx
Executable file
@ -0,0 +1,116 @@
|
||||
import React, { useState } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { InputSanitizer } from '../utils/sanitizer';
|
||||
|
||||
interface LoginModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const LoginModal: React.FC<LoginModalProps> = ({ isOpen, onClose, onLogin }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Sanitize and validate data before sending
|
||||
const sanitizedUsername = InputSanitizer.sanitizeUsername(username);
|
||||
InputSanitizer.validatePassword(password);
|
||||
|
||||
await onLogin(sanitizedUsername, password);
|
||||
// Clear form after successful login
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Login failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div className="bg-white rounded-lg p-6 w-full max-w-md shadow-xl">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-kaspersky-text">Login to Remote Access</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-500 hover:text-gray-700 transition-colors"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="username" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-kaspersky-base focus:border-transparent"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="password" className="block text-sm font-medium text-gray-700 mb-1">
|
||||
Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-kaspersky-base focus:border-transparent"
|
||||
required
|
||||
disabled={isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="bg-red-50 border border-red-200 text-red-600 px-3 py-2 rounded-md text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-kaspersky-base text-white py-2 px-4 rounded-md hover:bg-kaspersky-dark transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isLoading ? 'Logging in...' : 'Login'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
className="flex-1 bg-gray-200 text-gray-700 py-2 px-4 rounded-md hover:bg-gray-300 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginModal;
|
||||
317
mc_test/src/renderer/components/LoginScreen.tsx
Executable file
317
mc_test/src/renderer/components/LoginScreen.tsx
Executable file
@ -0,0 +1,317 @@
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { Shield, Lock, User, AlertCircle, Clock, Building2 } from 'lucide-react';
|
||||
import { InputSanitizer } from '../utils/sanitizer';
|
||||
|
||||
interface LoginScreenProps {
|
||||
onLogin: (username: string, password: string) => Promise<void>;
|
||||
}
|
||||
|
||||
const LoginScreen: React.FC<LoginScreenProps> = ({ onLogin }) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [focusedField, setFocusedField] = useState<'username' | 'password' | null>(null);
|
||||
const [showInactivityWarning, setShowInactivityWarning] = useState(false);
|
||||
|
||||
// Timer for auto-clearing form on inactivity
|
||||
const inactivityTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const INACTIVITY_TIMEOUT = 5 * 60 * 1000; // 5 minutes in milliseconds
|
||||
|
||||
// Function to clear form on inactivity
|
||||
const clearFormDueToInactivity = () => {
|
||||
if (username || password) {
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
setError(null);
|
||||
setShowInactivityWarning(true);
|
||||
|
||||
// Hide warning after 5 seconds
|
||||
setTimeout(() => {
|
||||
setShowInactivityWarning(false);
|
||||
}, 5000);
|
||||
}
|
||||
};
|
||||
|
||||
// Reset timer on user activity
|
||||
const resetInactivityTimer = () => {
|
||||
// Clear previous timer
|
||||
if (inactivityTimerRef.current) {
|
||||
clearTimeout(inactivityTimerRef.current);
|
||||
}
|
||||
|
||||
// Set new 5 minute timer
|
||||
inactivityTimerRef.current = setTimeout(() => {
|
||||
clearFormDueToInactivity();
|
||||
}, INACTIVITY_TIMEOUT);
|
||||
};
|
||||
|
||||
// Track activity
|
||||
const handleActivity = () => {
|
||||
setShowInactivityWarning(false);
|
||||
resetInactivityTimer();
|
||||
};
|
||||
|
||||
// Set timer on mount and clear on unmount
|
||||
useEffect(() => {
|
||||
resetInactivityTimer();
|
||||
|
||||
// Cleanup on unmount
|
||||
return () => {
|
||||
if (inactivityTimerRef.current) {
|
||||
clearTimeout(inactivityTimerRef.current);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Сбрасываем таймер при изменении полей формы
|
||||
useEffect(() => {
|
||||
if (username || password) {
|
||||
resetInactivityTimer();
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [username, password]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Санитизируем и валидируем данные перед отправкой
|
||||
const sanitizedUsername = InputSanitizer.sanitizeUsername(username);
|
||||
InputSanitizer.validatePassword(password);
|
||||
|
||||
await onLogin(sanitizedUsername, password);
|
||||
// Очищаем форму после успешного входа
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Authentication failed. Please check your credentials and try again.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-screen w-full bg-gradient-to-br from-kaspersky-secondary via-kaspersky-primary-dark to-kaspersky-secondary animate-gradient flex items-center justify-center p-4 overflow-hidden"
|
||||
onMouseMove={handleActivity}
|
||||
onClick={handleActivity}
|
||||
onKeyDown={handleActivity}
|
||||
>
|
||||
{/* Animated background pattern */}
|
||||
<div className="absolute inset-0 opacity-10 overflow-hidden">
|
||||
<div className="absolute inset-0 w-full h-full animate-pattern" style={{
|
||||
backgroundImage: `radial-gradient(circle at 2px 2px, rgba(0, 168, 142, 0.4) 1px, transparent 0)`,
|
||||
backgroundSize: '40px 40px'
|
||||
}} />
|
||||
</div>
|
||||
|
||||
{/* Login Card */}
|
||||
<div className="relative z-10 w-full max-w-md select-none">
|
||||
{/* Logo/Header Section */}
|
||||
<div className="text-center mb-8">
|
||||
<div className="inline-flex items-center justify-center w-20 h-20 bg-kaspersky-primary rounded-2xl shadow-kaspersky-xl mb-6 transform hover:scale-105 transition-transform duration-300">
|
||||
<Shield size={48} className="text-white" strokeWidth={1.5} />
|
||||
</div>
|
||||
<h1 className="text-4xl font-bold text-white mb-2 tracking-tight">
|
||||
URG Duty
|
||||
</h1>
|
||||
<p className="text-kaspersky-text-lighter text-lg">
|
||||
Remote Access Control Center
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Login Form Card */}
|
||||
<div className="bg-white/95 backdrop-blur-sm rounded-2xl shadow-kaspersky-xl p-8">
|
||||
{/* Header */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-semibold text-kaspersky-text-dark mb-2">
|
||||
Sign In
|
||||
</h2>
|
||||
<p className="text-kaspersky-text-light text-sm">
|
||||
Enter your credentials to access the system
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Inactivity Warning Message */}
|
||||
{showInactivityWarning && (
|
||||
<div className="mb-6 bg-orange-50 border-l-4 border-orange-500 rounded-lg p-4 flex items-start gap-3 animate-fadeIn select-none">
|
||||
<Clock size={20} className="text-orange-500 flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-orange-700">
|
||||
Форма очищена
|
||||
</p>
|
||||
<p className="text-sm text-kaspersky-text-light mt-1">
|
||||
Форма была очищена из-за неактивности (5 минут)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className="mb-6 bg-red-50 border-l-4 border-kaspersky-danger rounded-lg p-4 flex items-start gap-3 animate-shake select-none">
|
||||
<AlertCircle size={20} className="text-kaspersky-danger flex-shrink-0 mt-0.5" />
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-kaspersky-danger">
|
||||
Authentication Failed
|
||||
</p>
|
||||
<p className="text-sm text-kaspersky-text-light mt-1">
|
||||
{error}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Form */}
|
||||
<form onSubmit={handleSubmit} className="space-y-6">
|
||||
{/* Username Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="block text-sm font-medium text-kaspersky-text-dark mb-2"
|
||||
>
|
||||
Username
|
||||
</label>
|
||||
<div className={`relative transition-all duration-300 ${
|
||||
focusedField === 'username' ? 'transform scale-[1.02]' : ''
|
||||
}`}>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<User
|
||||
size={18}
|
||||
className={`transition-colors duration-300 ${
|
||||
focusedField === 'username' ? 'text-kaspersky-primary' : 'text-kaspersky-text-lighter'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
onFocus={() => setFocusedField('username')}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
className={`w-full pl-10 pr-4 py-3 border-2 rounded-lg font-kaspersky
|
||||
transition-all duration-300 outline-none
|
||||
${focusedField === 'username'
|
||||
? 'border-kaspersky-primary bg-kaspersky-bg-card shadow-kaspersky'
|
||||
: 'border-kaspersky-border bg-kaspersky-bg-card hover:border-kaspersky-border-dark'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100
|
||||
`}
|
||||
placeholder="Enter your username"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password Field */}
|
||||
<div>
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="block text-sm font-medium text-kaspersky-text-dark mb-2"
|
||||
>
|
||||
Password
|
||||
</label>
|
||||
<div className={`relative transition-all duration-300 ${
|
||||
focusedField === 'password' ? 'transform scale-[1.02]' : ''
|
||||
}`}>
|
||||
<div className="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
||||
<Lock
|
||||
size={18}
|
||||
className={`transition-colors duration-300 ${
|
||||
focusedField === 'password' ? 'text-kaspersky-primary' : 'text-kaspersky-text-lighter'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
onFocus={() => setFocusedField('password')}
|
||||
onBlur={() => setFocusedField(null)}
|
||||
className={`w-full pl-10 pr-4 py-3 border-2 rounded-lg font-kaspersky
|
||||
transition-all duration-300 outline-none
|
||||
${focusedField === 'password'
|
||||
? 'border-kaspersky-primary bg-kaspersky-bg-card shadow-kaspersky'
|
||||
: 'border-kaspersky-border bg-kaspersky-bg-card hover:border-kaspersky-border-dark'
|
||||
}
|
||||
disabled:opacity-50 disabled:cursor-not-allowed disabled:bg-gray-100
|
||||
`}
|
||||
placeholder="Enter your password"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Submit Button */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={isLoading || !username || !password}
|
||||
className={`
|
||||
w-full py-3 px-6 rounded-lg font-semibold text-white
|
||||
transition-all duration-300 transform
|
||||
${isLoading || !username || !password
|
||||
? 'bg-kaspersky-text-lighter cursor-not-allowed opacity-60'
|
||||
: 'bg-kaspersky-primary hover:bg-kaspersky-primary-dark hover:shadow-kaspersky-lg active:scale-[0.98]'
|
||||
}
|
||||
disabled:transform-none
|
||||
focus:outline-none focus:ring-4 focus:ring-kaspersky-primary/30
|
||||
`}
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="flex items-center justify-center gap-2">
|
||||
<svg className="animate-spin h-5 w-5" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
|
||||
</svg>
|
||||
Authenticating...
|
||||
</span>
|
||||
) : (
|
||||
'Sign In'
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Divider */}
|
||||
<div className="relative">
|
||||
<div className="absolute inset-0 flex items-center">
|
||||
<div className="w-full border-t border-gray-300"></div>
|
||||
</div>
|
||||
<div className="relative flex justify-center text-sm">
|
||||
<span className="px-2 bg-white/95 backdrop-blur-sm text-gray-500">or</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ADFS Button (Coming Soon) */}
|
||||
<div className="relative">
|
||||
<button
|
||||
type="button"
|
||||
disabled
|
||||
className="w-full py-3 px-6 rounded-lg font-semibold text-gray-500 bg-gray-200 cursor-not-allowed opacity-60 flex items-center justify-center gap-3"
|
||||
>
|
||||
<Building2 size={20} className="text-gray-500" />
|
||||
<span>Sign in with ADFS</span>
|
||||
</button>
|
||||
{/* Бейдж SOON */}
|
||||
<span className="absolute -top-1 -right-1 bg-gradient-to-r from-kaspersky-primary to-kaspersky-danger text-white text-[9px] font-bold px-1.5 py-0.5 rounded-full uppercase tracking-wide shadow-sm">
|
||||
Soon
|
||||
</span>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginScreen;
|
||||
|
||||
332
mc_test/src/renderer/components/MachineCredentialsModal.tsx
Executable file
332
mc_test/src/renderer/components/MachineCredentialsModal.tsx
Executable file
@ -0,0 +1,332 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { X, Info, AlertTriangle, CheckCircle, LogIn } from 'lucide-react';
|
||||
import { Machine } from '../types';
|
||||
import { ProtocolHelper, ProtocolOption, OSInfo } from '../utils/protocolHelper';
|
||||
import { InputSanitizer } from '../utils/sanitizer';
|
||||
import { Button } from './shared/Button';
|
||||
|
||||
interface MachineCredentialsModalProps {
|
||||
isOpen: boolean;
|
||||
machine: Machine | null;
|
||||
onClose: () => void;
|
||||
onSubmit: (
|
||||
username: string,
|
||||
password: string,
|
||||
protocol: string,
|
||||
sftpOptions?: {
|
||||
enableSftp: boolean;
|
||||
sftpRootDirectory: string;
|
||||
}
|
||||
) => Promise<void>;
|
||||
}
|
||||
|
||||
const MachineCredentialsModal: React.FC<MachineCredentialsModalProps> = ({
|
||||
isOpen,
|
||||
machine,
|
||||
onClose,
|
||||
onSubmit
|
||||
}) => {
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [protocol, setProtocol] = useState('');
|
||||
const [port, setPort] = useState('');
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// SFTP parameters for SSH
|
||||
const [enableSftp, setEnableSftp] = useState(true);
|
||||
const [sftpRootDirectory, setSftpRootDirectory] = useState('/');
|
||||
|
||||
// Parse machine OS and get recommended protocols
|
||||
const osInfo: OSInfo = useMemo(() => {
|
||||
return machine ? ProtocolHelper.parseOS(machine.os) : { family: 'unknown' };
|
||||
}, [machine?.os]);
|
||||
|
||||
const availableProtocols: ProtocolOption[] = useMemo(() => {
|
||||
return ProtocolHelper.getRecommendedProtocols(osInfo);
|
||||
}, [osInfo]);
|
||||
|
||||
const compatibilityInfo = useMemo(() => {
|
||||
return ProtocolHelper.getCompatibilityInfo(osInfo);
|
||||
}, [osInfo]);
|
||||
|
||||
const selectedProtocolInfo = useMemo(() => {
|
||||
return availableProtocols.find(p => p.value === protocol);
|
||||
}, [protocol, availableProtocols]);
|
||||
|
||||
// Set default protocol and port when machine changes
|
||||
useEffect(() => {
|
||||
if (machine && availableProtocols.length > 0) {
|
||||
const defaultProtocol = ProtocolHelper.getDefaultProtocol(osInfo);
|
||||
setProtocol(defaultProtocol.value);
|
||||
setPort(defaultProtocol.defaultPort.toString());
|
||||
}
|
||||
}, [machine, osInfo, availableProtocols]);
|
||||
|
||||
// Update port when protocol changes
|
||||
useEffect(() => {
|
||||
if (selectedProtocolInfo) {
|
||||
setPort(selectedProtocolInfo.defaultPort.toString());
|
||||
}
|
||||
}, [selectedProtocolInfo]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setIsLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Санитизируем данные перед отправкой
|
||||
const sanitizedUsername = InputSanitizer.sanitizeUsername(username);
|
||||
InputSanitizer.validatePassword(password);
|
||||
const sanitizedProtocol = InputSanitizer.sanitizeProtocol(protocol);
|
||||
const portNumber = InputSanitizer.validatePort(port);
|
||||
|
||||
// Валидируем порт для выбранного протокола
|
||||
const portValidation = ProtocolHelper.validatePort(protocol, portNumber);
|
||||
if (!portValidation.valid) {
|
||||
throw new Error('Invalid port for selected protocol');
|
||||
}
|
||||
|
||||
// Передаем SFTP параметры, если протокол SSH
|
||||
const sftpOptions = protocol === 'ssh' ? {
|
||||
enableSftp,
|
||||
sftpRootDirectory: sftpRootDirectory.trim() || '/'
|
||||
} : undefined;
|
||||
|
||||
await onSubmit(sanitizedUsername, password, sanitizedProtocol, sftpOptions);
|
||||
|
||||
// Clear form after successful connection
|
||||
setUsername('');
|
||||
setPassword('');
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Connection failed');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen || !machine) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-60 flex items-center justify-center z-50 backdrop-blur-sm">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg p-6 w-full max-w-md shadow-2xl border border-kaspersky-border select-none">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h2 className="text-2xl font-bold text-kaspersky-text-dark">
|
||||
Connect to {machine.name}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-kaspersky-text-light hover:text-kaspersky-danger transition-colors p-1 rounded-lg hover:bg-kaspersky-bg"
|
||||
disabled={isLoading}
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 p-4 bg-kaspersky-bg rounded-lg border border-kaspersky-border">
|
||||
<div className="flex items-center gap-3 mb-2">
|
||||
<span className="text-2xl">{osInfo.family === 'windows' ? '🖥️' : osInfo.family === 'linux' ? '🐧' : osInfo.family === 'macos' ? '🍎' : '💻'}</span>
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-kaspersky-text-dark">{machine.name}</p>
|
||||
<p className="text-xs text-kaspersky-text-light">{machine.ip} • {machine.os}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Compatibility info */}
|
||||
{compatibilityInfo.recommendations.length > 0 && (
|
||||
<div className="mt-3 p-3 bg-kaspersky-primary bg-opacity-10 border border-kaspersky-primary border-opacity-30 rounded-lg text-xs">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<CheckCircle size={14} className="text-kaspersky-primary" />
|
||||
<span className="font-semibold text-kaspersky-primary">Recommendation</span>
|
||||
</div>
|
||||
<p className="text-kaspersky-text-dark">{compatibilityInfo.recommendations[0]}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{compatibilityInfo.warnings.length > 0 && (
|
||||
<div className="mt-2 p-3 bg-kaspersky-warning bg-opacity-10 border border-kaspersky-warning border-opacity-30 rounded-lg text-xs">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<AlertTriangle size={14} className="text-kaspersky-warning" />
|
||||
<span className="font-semibold text-kaspersky-warning">Warning</span>
|
||||
</div>
|
||||
<p className="text-kaspersky-text-dark">{compatibilityInfo.warnings[0]}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label htmlFor="protocol" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Connection Protocol
|
||||
</label>
|
||||
<select
|
||||
id="protocol"
|
||||
value={protocol}
|
||||
onChange={(e) => setProtocol(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{availableProtocols.map((protocolOption) => (
|
||||
<option key={protocolOption.value} value={protocolOption.value}>
|
||||
{protocolOption.icon} {protocolOption.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Protocol info */}
|
||||
{selectedProtocolInfo && (
|
||||
<div className="mt-2 p-3 bg-kaspersky-bg border border-kaspersky-border rounded-lg text-xs">
|
||||
<div className="flex items-center gap-1.5 mb-1">
|
||||
<Info size={14} className="text-kaspersky-primary" />
|
||||
<span className="font-semibold text-kaspersky-text-dark">Protocol Info</span>
|
||||
</div>
|
||||
<p className="text-kaspersky-text mb-1">{selectedProtocolInfo.description}</p>
|
||||
{selectedProtocolInfo.requirements && (
|
||||
<div>
|
||||
<span className="font-semibold text-kaspersky-text-dark">Requirements:</span>
|
||||
<ul className="list-disc list-inside text-kaspersky-text mt-1">
|
||||
{selectedProtocolInfo.requirements.map((req, index) => (
|
||||
<li key={index}>{req}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="port" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Port
|
||||
</label>
|
||||
<input
|
||||
type="number"
|
||||
id="port"
|
||||
value={port}
|
||||
onChange={(e) => setPort(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
required
|
||||
disabled={isLoading}
|
||||
min="1"
|
||||
max="65535"
|
||||
placeholder={selectedProtocolInfo?.defaultPort.toString()}
|
||||
/>
|
||||
{selectedProtocolInfo && (
|
||||
<p className="text-xs text-kaspersky-text-light mt-1">
|
||||
Default port for {selectedProtocolInfo.label}: {selectedProtocolInfo.defaultPort}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="machine-username" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Machine Username
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="machine-username"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
required
|
||||
disabled={isLoading}
|
||||
autoFocus
|
||||
placeholder="Administrator"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label htmlFor="machine-password" className="block text-sm font-semibold text-kaspersky-text-dark mb-2">
|
||||
Machine Password
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="machine-password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="w-full px-4 py-2.5 bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
required
|
||||
disabled={isLoading}
|
||||
placeholder="••••••••"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* SFTP параметры - показываем только для SSH */}
|
||||
{protocol === 'ssh' && (
|
||||
<div className="space-y-3 p-4 bg-kaspersky-primary bg-opacity-10 border border-kaspersky-primary border-opacity-30 rounded-lg">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-semibold text-kaspersky-primary">📁 SFTP File Transfer</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 p-3 bg-kaspersky-bg-card rounded-lg border border-kaspersky-border">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="enable-sftp"
|
||||
checked={enableSftp}
|
||||
onChange={(e) => setEnableSftp(e.target.checked)}
|
||||
className="w-5 h-5 text-kaspersky-primary bg-white border-2 border-kaspersky-border rounded focus:ring-2 focus:ring-kaspersky-primary cursor-pointer"
|
||||
disabled={isLoading}
|
||||
/>
|
||||
<label htmlFor="enable-sftp" className="text-sm font-medium text-kaspersky-text-dark cursor-pointer flex-1">
|
||||
Enable file browser (download/upload with drag'n'drop)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{enableSftp && (
|
||||
<div>
|
||||
<label htmlFor="sftp-root-directory" className="block text-xs font-semibold text-kaspersky-text-dark mb-2">
|
||||
Root Directory (optional)
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="sftp-root-directory"
|
||||
value={sftpRootDirectory}
|
||||
onChange={(e) => setSftpRootDirectory(e.target.value)}
|
||||
className="w-full px-3 py-2 text-sm bg-kaspersky-bg border-2 border-kaspersky-border rounded-lg text-kaspersky-text-dark focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-200 select-text"
|
||||
disabled={isLoading}
|
||||
placeholder="/"
|
||||
/>
|
||||
<p className="text-xs text-kaspersky-text-light mt-1">
|
||||
Restrict SFTP access to this directory (default: /)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="bg-kaspersky-danger bg-opacity-10 border border-kaspersky-danger text-kaspersky-danger px-4 py-3 rounded-lg text-sm font-medium animate-shake">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex gap-3 pt-2">
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
fullWidth
|
||||
disabled={isLoading}
|
||||
isLoading={isLoading}
|
||||
leftIcon={!isLoading ? <LogIn size={18} /> : undefined}
|
||||
>
|
||||
{isLoading ? 'Connecting...' : 'Connect'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
onClick={onClose}
|
||||
disabled={isLoading}
|
||||
leftIcon={<X size={18} />}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MachineCredentialsModal;
|
||||
352
mc_test/src/renderer/components/MachineDetails.tsx
Executable file
352
mc_test/src/renderer/components/MachineDetails.tsx
Executable file
@ -0,0 +1,352 @@
|
||||
import { Machine } from '../types'
|
||||
import { Trash2, Clock } from 'lucide-react'
|
||||
import { useMachineStore } from '../store/machineStore'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { log } from '../utils/logger'
|
||||
|
||||
interface MachineDetailsProps {
|
||||
machine: Machine;
|
||||
machineStatus?: Machine['status'];
|
||||
isConnected?: boolean;
|
||||
connectionInfo?: {
|
||||
connectionId: string;
|
||||
connectedAt: Date;
|
||||
expiresAt: Date;
|
||||
};
|
||||
onDelete?: () => void;
|
||||
onExtendConnection?: (additionalMinutes?: number) => Promise<void>;
|
||||
}
|
||||
|
||||
function MachineDetails({ machine, machineStatus, isConnected = false, connectionInfo, onDelete, onExtendConnection }: MachineDetailsProps) {
|
||||
// Use passed status or fallback to machine status
|
||||
const currentStatus = machineStatus || machine.status;
|
||||
|
||||
const { deleteSavedMachine } = useMachineStore();
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
// Connection timer state (updates every second)
|
||||
const [connectionDuration, setConnectionDuration] = useState<string>('');
|
||||
|
||||
// Time until TTL expiry state
|
||||
const [timeUntilExpiry, setTimeUntilExpiry] = useState<{ minutes: number; isWarning: boolean }>({
|
||||
minutes: 0,
|
||||
isWarning: false,
|
||||
});
|
||||
|
||||
// Extension process state
|
||||
const [isExtending, setIsExtending] = useState(false);
|
||||
|
||||
// Determine if machine is "saved" (not mock data)
|
||||
const isSavedMachine = machine.hypervisor === 'Saved';
|
||||
|
||||
// Update connection timer every second
|
||||
useEffect(() => {
|
||||
if (!isConnected || !connectionInfo) {
|
||||
setConnectionDuration('');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateDuration = () => {
|
||||
const now = Date.now();
|
||||
const connectedAt = connectionInfo.connectedAt.getTime();
|
||||
const diffMs = now - connectedAt;
|
||||
const diffSeconds = Math.floor(diffMs / 1000);
|
||||
const diffMinutes = Math.floor(diffSeconds / 60);
|
||||
const diffHours = Math.floor(diffMinutes / 60);
|
||||
|
||||
if (diffHours > 0) {
|
||||
setConnectionDuration(`${diffHours}h ${diffMinutes % 60}m`);
|
||||
} else if (diffMinutes > 0) {
|
||||
setConnectionDuration(`${diffMinutes}m ${diffSeconds % 60}s`);
|
||||
} else if (diffSeconds > 5) {
|
||||
setConnectionDuration(`${diffSeconds}s`);
|
||||
} else {
|
||||
setConnectionDuration('just now');
|
||||
}
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateDuration();
|
||||
|
||||
// And every second
|
||||
const interval = setInterval(updateDuration, 1000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isConnected, connectionInfo]);
|
||||
|
||||
// Track time until TTL expiry
|
||||
useEffect(() => {
|
||||
if (!isConnected || !connectionInfo?.expiresAt) {
|
||||
setTimeUntilExpiry({ minutes: 0, isWarning: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const updateTimeUntilExpiry = () => {
|
||||
const now = Date.now();
|
||||
const expiresAt = connectionInfo.expiresAt.getTime();
|
||||
const diffMs = expiresAt - now;
|
||||
const minutesRemaining = Math.floor(diffMs / 60000);
|
||||
|
||||
setTimeUntilExpiry({
|
||||
minutes: minutesRemaining,
|
||||
isWarning: minutesRemaining <= 10 && minutesRemaining > 0, // Warning if 10 minutes or less remaining
|
||||
});
|
||||
};
|
||||
|
||||
// Update immediately
|
||||
updateTimeUntilExpiry();
|
||||
|
||||
// And every 30 seconds (doesn't need to be as frequent as duration)
|
||||
const interval = setInterval(updateTimeUntilExpiry, 30000);
|
||||
|
||||
return () => clearInterval(interval);
|
||||
}, [isConnected, connectionInfo]);
|
||||
|
||||
// Connection extension handler
|
||||
const handleExtend = async () => {
|
||||
if (!onExtendConnection || isExtending) return;
|
||||
|
||||
setIsExtending(true);
|
||||
try {
|
||||
await onExtendConnection(60); // Extend by 60 minutes by default
|
||||
log.info('machine-details', 'Connection extended successfully');
|
||||
} catch (error) {
|
||||
log.error('machine-details', 'Failed to extend connection', { error });
|
||||
} finally {
|
||||
setIsExtending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!isSavedMachine) return;
|
||||
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
log.info('machine-details', 'Deleting saved machine', { machineId: machine.id });
|
||||
await deleteSavedMachine(machine.id);
|
||||
log.info('machine-details', 'Saved machine deleted successfully');
|
||||
setShowDeleteConfirm(false);
|
||||
|
||||
if (onDelete) {
|
||||
onDelete();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('machine-details', 'Failed to delete machine', { error });
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="flex-1 p-6 overflow-auto select-none">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg p-6 shadow-kaspersky">
|
||||
<div className="mb-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-2xl font-semibold text-kaspersky-text-dark break-words">{machine.name}</h1>
|
||||
<div className="flex items-center mt-2 gap-4 flex-wrap">
|
||||
{/* If connected - show only Connected block with status indicator color */}
|
||||
{isConnected && connectionInfo ? (
|
||||
<>
|
||||
<div className="flex items-center px-3 py-1.5 bg-kaspersky-primary bg-opacity-10 border border-kaspersky-primary border-opacity-30 rounded-lg">
|
||||
{/* Indicator color reflects machine availability status */}
|
||||
<div
|
||||
className={`w-2.5 h-2.5 rounded-full mr-2 shadow-sm ${
|
||||
currentStatus === 'running' ? 'bg-kaspersky-success animate-pulse' :
|
||||
currentStatus === 'stopped' ? 'bg-kaspersky-danger' :
|
||||
currentStatus === 'checking' ? 'bg-kaspersky-warning animate-pulse' :
|
||||
currentStatus === 'unknown' ? 'bg-gray-400' :
|
||||
'bg-kaspersky-primary animate-pulse'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-xs text-kaspersky-primary font-semibold">
|
||||
Connected {connectionDuration ? `(${connectionDuration})` : ''}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Кнопка продления - показываем всегда когда подключены */}
|
||||
{onExtendConnection && (
|
||||
<button
|
||||
onClick={handleExtend}
|
||||
disabled={isExtending}
|
||||
className={`flex items-center gap-2 px-3 py-1.5 rounded-lg transition-all duration-200 ${
|
||||
timeUntilExpiry.isWarning
|
||||
? 'bg-kaspersky-warning bg-opacity-10 border border-kaspersky-warning border-opacity-30 text-kaspersky-warning hover:bg-opacity-20 animate-pulse'
|
||||
: 'bg-kaspersky-success bg-opacity-10 border border-kaspersky-success border-opacity-30 text-kaspersky-success hover:bg-opacity-20'
|
||||
} disabled:opacity-50 disabled:cursor-not-allowed`}
|
||||
title={`Extend connection by 60 minutes (${timeUntilExpiry.minutes}m remaining)`}
|
||||
>
|
||||
<Clock className="w-3.5 h-3.5" />
|
||||
<span className="text-xs font-semibold">
|
||||
{isExtending ? 'Extending...' : `Extend (${timeUntilExpiry.minutes}m left)`}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
/* If not connected - show regular availability status */
|
||||
<div className="flex items-center">
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full mr-2 shadow-sm ${
|
||||
currentStatus === 'running' ? 'bg-kaspersky-success' :
|
||||
currentStatus === 'stopped' ? 'bg-kaspersky-danger' :
|
||||
currentStatus === 'checking' ? 'bg-kaspersky-warning animate-pulse' :
|
||||
currentStatus === 'unknown' ? 'bg-gray-400' :
|
||||
'bg-kaspersky-warning'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm text-kaspersky-text capitalize font-medium">
|
||||
{currentStatus === 'checking' ? 'Checking...' : currentStatus}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Button для сохраненных машин */}
|
||||
{isSavedMachine && (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
disabled={isDeleting || isConnected}
|
||||
className="flex-shrink-0 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
|
||||
title={isConnected ? "Disconnect first to delete" : "Delete saved machine"}
|
||||
>
|
||||
<Trash2 size={18} />
|
||||
<span className="text-sm font-medium">Delete</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={isSavedMachine ? "flex flex-col gap-6" : "grid grid-cols-2 gap-6"}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4 text-kaspersky-primary">System Information</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">Operating System</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.os}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">Hostname</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.ip}</div>
|
||||
</div>
|
||||
{machine.port && (
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">Port</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.port}</div>
|
||||
</div>
|
||||
)}
|
||||
{machine.protocol && (
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">Protocol</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.protocol.toUpperCase()}</div>
|
||||
</div>
|
||||
)}
|
||||
{/* Показываем Hypervisor только для НЕ сохраненных машин */}
|
||||
{!isSavedMachine && (
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">Hypervisor</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.hypervisor}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Показываем Hardware Specifications только для НЕ сохраненных машин */}
|
||||
{!isSavedMachine && (
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold mb-4 text-kaspersky-primary">Hardware Specifications</h2>
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">CPU</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.specs.cpu}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">Memory</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.specs.ram}</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-kaspersky-text-light font-medium">Storage</label>
|
||||
<div className="text-kaspersky-text-dark font-medium">{machine.specs.disk}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-medium mb-4 text-kaspersky-primary">Test Links</h2>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{machine.testLinks?.map((link, index) => (
|
||||
<a
|
||||
key={index}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-block px-3 py-1 bg-kaspersky-primary text-white rounded-lg hover:bg-kaspersky-primary-dark hover:shadow-kaspersky transition-all duration-300"
|
||||
>
|
||||
{link.name}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6">
|
||||
<h2 className="text-lg font-semibold mb-4 text-kaspersky-primary">Execution Log</h2>
|
||||
<div className="bg-kaspersky-bg rounded-lg p-4 font-mono text-sm max-h-48 overflow-auto border border-kaspersky-border">
|
||||
{machine.logs?.map((log, index) => (
|
||||
<div key={index} className="text-kaspersky-text mb-1">
|
||||
<span className="text-kaspersky-text-light">[{new Date(log.timestamp).toLocaleString()}]</span>
|
||||
<span className={`ml-2 font-medium ${
|
||||
log.level === 'error' ? 'text-kaspersky-danger' :
|
||||
log.level === 'warn' ? 'text-kaspersky-warning' :
|
||||
'text-kaspersky-text-dark'
|
||||
}`}>
|
||||
[{log.level.toUpperCase()}] {log.message}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg p-6 max-w-md w-full shadow-2xl border border-kaspersky-border">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="p-3 bg-red-600 bg-opacity-20 rounded-full">
|
||||
<Trash2 size={24} className="text-red-500" />
|
||||
</div>
|
||||
<h3 className="text-xl font-semibold text-kaspersky-text-dark">Delete Machine</h3>
|
||||
</div>
|
||||
|
||||
<p className="text-kaspersky-text mb-6">
|
||||
Are you sure you want to delete <strong className="text-kaspersky-text-dark">{machine.name}</strong>?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed font-medium"
|
||||
>
|
||||
{isDeleting ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
disabled={isDeleting}
|
||||
className="flex-1 px-4 py-2 bg-kaspersky-bg hover:bg-kaspersky-border text-kaspersky-text-dark rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed font-medium border border-kaspersky-border"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default MachineDetails
|
||||
216
mc_test/src/renderer/components/QuickActionsMenu.tsx
Executable file
216
mc_test/src/renderer/components/QuickActionsMenu.tsx
Executable file
@ -0,0 +1,216 @@
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { MoreVertical, Square, RotateCcw, Settings, Info, Wifi, Terminal, Monitor } from 'lucide-react';
|
||||
import { Machine } from '../types';
|
||||
import { ProtocolHelper, ProtocolOption, OSInfo } from '../utils/protocolHelper';
|
||||
|
||||
interface QuickActionsMenuProps {
|
||||
machine: Machine;
|
||||
isConnected?: boolean;
|
||||
onConnect: (protocol: ProtocolOption) => void;
|
||||
onDisconnect?: () => void;
|
||||
onRestart?: () => void;
|
||||
onPowerOff?: () => void;
|
||||
onSettings?: () => void;
|
||||
onInfo?: () => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const QuickActionsMenu: React.FC<QuickActionsMenuProps> = ({
|
||||
machine,
|
||||
isConnected = false,
|
||||
onConnect,
|
||||
onDisconnect,
|
||||
onRestart,
|
||||
onPowerOff,
|
||||
onSettings,
|
||||
onInfo,
|
||||
className = ''
|
||||
}) => {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
const osInfo: OSInfo = useMemo(() => {
|
||||
return ProtocolHelper.parseOS(machine.os);
|
||||
}, [machine.os]);
|
||||
|
||||
const availableProtocols = useMemo(() => {
|
||||
return ProtocolHelper.getRecommendedProtocols(osInfo);
|
||||
}, [osInfo]);
|
||||
|
||||
const isRunning = machine.status === 'running';
|
||||
|
||||
const handleActionClick = (action: () => void) => {
|
||||
action();
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
const handleProtocolConnect = (protocol: ProtocolOption) => {
|
||||
onConnect(protocol);
|
||||
setIsOpen(false);
|
||||
};
|
||||
|
||||
// Close menu on outside click
|
||||
React.useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
const target = event.target as Element;
|
||||
if (!target.closest('.quick-actions-menu')) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (isOpen) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
return (
|
||||
<div className={`relative quick-actions-menu ${className}`}>
|
||||
<button
|
||||
onClick={() => setIsOpen(!isOpen)}
|
||||
className="p-2 text-gray-500 hover:text-gray-700 hover:bg-gray-100 rounded-md transition-colors"
|
||||
title="Quick Actions"
|
||||
>
|
||||
<MoreVertical size={16} />
|
||||
</button>
|
||||
|
||||
{isOpen && (
|
||||
<div className="absolute right-0 top-full mt-1 w-48 bg-white border border-gray-200 rounded-lg shadow-lg z-50 overflow-hidden">
|
||||
{/* Connection Actions */}
|
||||
{!isConnected && isRunning && (
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Connect via
|
||||
</div>
|
||||
{availableProtocols.map((protocol) => (
|
||||
<button
|
||||
key={protocol.value}
|
||||
onClick={() => handleProtocolConnect(protocol)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
|
||||
>
|
||||
<span className="text-lg">{protocol.icon}</span>
|
||||
<div className="font-medium text-gray-900">{protocol.label}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Disconnect Action */}
|
||||
{isConnected && onDisconnect && (
|
||||
<div className="border-b border-gray-100">
|
||||
<button
|
||||
onClick={() => handleActionClick(onDisconnect)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3 text-red-600"
|
||||
>
|
||||
<Wifi size={16} />
|
||||
<span>Disconnect</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Actions */}
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Quick Actions
|
||||
</div>
|
||||
|
||||
{/* Remote Desktop shortcut */}
|
||||
{osInfo.family === 'windows' && (
|
||||
<button
|
||||
onClick={() => handleProtocolConnect(availableProtocols.find(p => p.value === 'rdp')!)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
|
||||
disabled={!isRunning}
|
||||
>
|
||||
<Monitor size={16} className={isRunning ? 'text-blue-600' : 'text-gray-400'} />
|
||||
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
|
||||
Remote Desktop
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* SSH Terminal shortcut */}
|
||||
{['linux', 'macos', 'unix'].includes(osInfo.family) && (
|
||||
<button
|
||||
onClick={() => handleProtocolConnect(availableProtocols.find(p => p.value === 'ssh')!)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
|
||||
disabled={!isRunning}
|
||||
>
|
||||
<Terminal size={16} className={isRunning ? 'text-green-600' : 'text-gray-400'} />
|
||||
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
|
||||
SSH Terminal
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Power Management */}
|
||||
<div className="border-b border-gray-100">
|
||||
<div className="px-3 py-2 text-xs font-medium text-gray-500 uppercase tracking-wide">
|
||||
Power Management
|
||||
</div>
|
||||
|
||||
{onRestart && (
|
||||
<button
|
||||
onClick={() => handleActionClick(onRestart)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
|
||||
disabled={!isRunning}
|
||||
>
|
||||
<RotateCcw size={16} className={isRunning ? 'text-orange-600' : 'text-gray-400'} />
|
||||
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
|
||||
Restart
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onPowerOff && (
|
||||
<button
|
||||
onClick={() => handleActionClick(onPowerOff)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
|
||||
disabled={!isRunning}
|
||||
>
|
||||
<Square size={16} className={isRunning ? 'text-red-600' : 'text-gray-400'} />
|
||||
<span className={isRunning ? 'text-gray-900' : 'text-gray-400'}>
|
||||
Power Off
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Settings and Info */}
|
||||
<div>
|
||||
{onSettings && (
|
||||
<button
|
||||
onClick={() => handleActionClick(onSettings)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
|
||||
>
|
||||
<Settings size={16} className="text-gray-600" />
|
||||
<span>Settings</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{onInfo && (
|
||||
<button
|
||||
onClick={() => handleActionClick(onInfo)}
|
||||
className="w-full px-3 py-2 text-left text-sm hover:bg-gray-50 flex items-center gap-3"
|
||||
>
|
||||
<Info size={16} className="text-blue-600" />
|
||||
<span>Machine Info</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Machine status footer */}
|
||||
<div className="bg-gray-50 px-3 py-2 text-xs text-gray-500">
|
||||
Status: <span className={`font-medium ${
|
||||
machine.status === 'running' ? 'text-green-600' :
|
||||
machine.status === 'stopped' ? 'text-red-600' : 'text-yellow-600'
|
||||
}`}>
|
||||
{machine.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default QuickActionsMenu;
|
||||
253
mc_test/src/renderer/components/RestoreConnectionsModal.tsx
Executable file
253
mc_test/src/renderer/components/RestoreConnectionsModal.tsx
Executable file
@ -0,0 +1,253 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { X, Monitor, Clock, RefreshCw, XCircle, CheckSquare, Square } from 'lucide-react';
|
||||
import type { ActiveConnection } from '../services/guacamole-api';
|
||||
|
||||
interface RestoreConnectionsModalProps {
|
||||
isOpen: boolean;
|
||||
connections: ActiveConnection[];
|
||||
onRestore: (connection: ActiveConnection) => void;
|
||||
onRestoreMultiple: (connections: ActiveConnection[]) => void;
|
||||
onClose: () => void;
|
||||
onSkip: () => void;
|
||||
}
|
||||
|
||||
const RestoreConnectionsModal: React.FC<RestoreConnectionsModalProps> = ({
|
||||
isOpen,
|
||||
connections,
|
||||
onRestore,
|
||||
onRestoreMultiple,
|
||||
onClose,
|
||||
onSkip
|
||||
}) => {
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// Reset selection when modal closes
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const toggleSelection = (connectionId: string) => {
|
||||
setSelectedIds(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(connectionId)) {
|
||||
newSet.delete(connectionId);
|
||||
} else {
|
||||
newSet.add(connectionId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
};
|
||||
|
||||
const selectAll = () => {
|
||||
setSelectedIds(new Set(connections.map(c => c.connection_id)));
|
||||
};
|
||||
|
||||
const deselectAll = () => {
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const handleRestoreSelected = () => {
|
||||
const selectedConnections = connections.filter(c => selectedIds.has(c.connection_id));
|
||||
if (selectedConnections.length > 0) {
|
||||
onRestoreMultiple(selectedConnections);
|
||||
setSelectedIds(new Set());
|
||||
}
|
||||
};
|
||||
|
||||
const handleRestoreAll = () => {
|
||||
onRestoreMultiple(connections);
|
||||
setSelectedIds(new Set());
|
||||
};
|
||||
|
||||
const getProtocolColor = (protocol: string) => {
|
||||
switch (protocol.toLowerCase()) {
|
||||
case 'rdp':
|
||||
return 'bg-blue-100 text-blue-700 border-blue-200';
|
||||
case 'ssh':
|
||||
return 'bg-green-100 text-green-700 border-green-200';
|
||||
case 'vnc':
|
||||
return 'bg-purple-100 text-purple-700 border-purple-200';
|
||||
default:
|
||||
return 'bg-gray-100 text-gray-700 border-gray-200';
|
||||
}
|
||||
};
|
||||
|
||||
const formatTimeRemaining = (minutes: number): string => {
|
||||
if (minutes < 60) {
|
||||
return `${minutes} мин`;
|
||||
}
|
||||
const hours = Math.floor(minutes / 60);
|
||||
const mins = minutes % 60;
|
||||
return mins > 0 ? `${hours}ч ${mins}м` : `${hours}ч`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl shadow-2xl w-full max-w-2xl max-h-[80vh] flex flex-col select-none">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-6 border-b border-gray-200">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-12 h-12 bg-kaspersky-primary/10 rounded-xl flex items-center justify-center">
|
||||
<RefreshCw className="text-kaspersky-primary" size={24} />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold text-kaspersky-text-dark">
|
||||
Восстановить подключения
|
||||
</h2>
|
||||
<p className="text-sm text-kaspersky-text-light mt-1">
|
||||
У вас есть {connections.length} активных {connections.length === 1 ? 'подключение' : 'подключения'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-gray-400 hover:text-gray-600 transition-colors p-2 hover:bg-gray-100 rounded-lg"
|
||||
>
|
||||
<X size={24} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="flex-1 overflow-y-auto p-6">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<p className="text-kaspersky-text">
|
||||
Выберите подключения для восстановления:
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={selectAll}
|
||||
className="text-xs text-kaspersky-primary hover:text-kaspersky-primary-dark font-medium transition-colors"
|
||||
>
|
||||
Выбрать все
|
||||
</button>
|
||||
<span className="text-gray-300">|</span>
|
||||
<button
|
||||
onClick={deselectAll}
|
||||
className="text-xs text-kaspersky-text-light hover:text-kaspersky-text font-medium transition-colors"
|
||||
>
|
||||
Снять выбор
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
{connections.map((connection) => {
|
||||
const isSelected = selectedIds.has(connection.connection_id);
|
||||
return (
|
||||
<div
|
||||
key={connection.connection_id}
|
||||
className={`border-2 rounded-xl p-4 transition-all duration-200 hover:shadow-md cursor-pointer group ${
|
||||
isSelected
|
||||
? 'border-kaspersky-primary bg-kaspersky-primary/5'
|
||||
: 'border-kaspersky-border hover:border-kaspersky-primary'
|
||||
}`}
|
||||
onClick={() => toggleSelection(connection.connection_id)}
|
||||
>
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-start gap-3 flex-1">
|
||||
{/* Чекбокс */}
|
||||
<div className="flex-shrink-0 pt-1">
|
||||
<div className={`w-5 h-5 rounded border-2 flex items-center justify-center transition-all ${
|
||||
isSelected
|
||||
? 'bg-kaspersky-primary border-kaspersky-primary'
|
||||
: 'border-gray-300 group-hover:border-kaspersky-primary'
|
||||
}`}>
|
||||
{isSelected ? (
|
||||
<CheckSquare size={16} className="text-white" strokeWidth={3} />
|
||||
) : (
|
||||
<Square size={16} className="text-transparent" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-10 h-10 bg-kaspersky-bg rounded-lg flex items-center justify-center flex-shrink-0 group-hover:bg-kaspersky-primary/10 transition-colors">
|
||||
<Monitor className="text-kaspersky-primary" size={20} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-kaspersky-text-dark text-lg mb-1 truncate">
|
||||
{connection.hostname}
|
||||
</h3>
|
||||
<div className="flex flex-wrap items-center gap-2 mb-2">
|
||||
<span className={`px-2 py-1 rounded-md text-xs font-medium border ${getProtocolColor(connection.protocol)}`}>
|
||||
{connection.protocol.toUpperCase()}
|
||||
</span>
|
||||
<span className="text-xs text-kaspersky-text-light flex items-center gap-1">
|
||||
<Clock size={12} />
|
||||
Осталось: {formatTimeRemaining(connection.remaining_minutes)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-kaspersky-text-lighter">
|
||||
Создано: {new Date(connection.created_at).toLocaleString('ru-RU', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRestore(connection);
|
||||
}}
|
||||
className="ml-4 px-4 py-2 bg-kaspersky-primary hover:bg-kaspersky-primary-dark text-white rounded-lg transition-colors font-medium text-sm flex items-center gap-2 whitespace-nowrap"
|
||||
>
|
||||
<RefreshCw size={16} />
|
||||
Восстановить
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between gap-3 p-6 border-t border-gray-200 bg-gray-50">
|
||||
<p className="text-sm text-kaspersky-text-light">
|
||||
{selectedIds.size > 0
|
||||
? `Выбрано: ${selectedIds.size} из ${connections.length}`
|
||||
: 'Выберите подключения или создайте новое'
|
||||
}
|
||||
</p>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={onSkip}
|
||||
className="px-6 py-2.5 bg-white border-2 border-gray-300 text-gray-700 rounded-lg hover:bg-gray-50 transition-colors font-medium flex items-center gap-2"
|
||||
>
|
||||
<XCircle size={18} />
|
||||
Создать новое
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRestoreAll}
|
||||
className="px-6 py-2.5 bg-kaspersky-secondary hover:bg-kaspersky-secondary/90 text-white rounded-lg transition-colors font-medium flex items-center gap-2"
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Восстановить всё
|
||||
</button>
|
||||
<button
|
||||
onClick={handleRestoreSelected}
|
||||
disabled={selectedIds.size === 0}
|
||||
className={`px-6 py-2.5 rounded-lg transition-colors font-medium flex items-center gap-2 ${
|
||||
selectedIds.size === 0
|
||||
? 'bg-gray-300 text-gray-500 cursor-not-allowed'
|
||||
: 'bg-kaspersky-primary hover:bg-kaspersky-primary-dark text-white'
|
||||
}`}
|
||||
>
|
||||
<RefreshCw size={18} />
|
||||
Восстановить выбранные
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RestoreConnectionsModal;
|
||||
|
||||
176
mc_test/src/renderer/components/SaveConfirmationModal.tsx
Executable file
176
mc_test/src/renderer/components/SaveConfirmationModal.tsx
Executable file
@ -0,0 +1,176 @@
|
||||
/**
|
||||
* Machine save confirmation modal
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { Save, X } from 'lucide-react';
|
||||
import { useMachineStore } from '../store/machineStore';
|
||||
import { log } from '../utils/logger';
|
||||
import { Button } from './shared/Button';
|
||||
|
||||
interface SaveConfirmationModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onSuccess?: () => void;
|
||||
}
|
||||
|
||||
export const SaveConfirmationModal: React.FC<SaveConfirmationModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
onSuccess
|
||||
}) => {
|
||||
const {
|
||||
machineToSave,
|
||||
createSavedMachine,
|
||||
setMachineToSave,
|
||||
setShowSaveConfirmModal
|
||||
} = useMachineStore();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!machineToSave) {
|
||||
setError('Нет данных для сохранения');
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
log.info('SaveConfirmationModal', 'Saving machine', { name: machineToSave.name });
|
||||
|
||||
await createSavedMachine(machineToSave);
|
||||
|
||||
log.info('SaveConfirmationModal', 'Machine saved successfully');
|
||||
|
||||
// Сбрасываем состояние
|
||||
setMachineToSave(null);
|
||||
setShowSaveConfirmModal(false);
|
||||
|
||||
if (onSuccess) {
|
||||
onSuccess();
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
const errorMessage = err instanceof Error ? err.message : 'Не удалось сохранить машину';
|
||||
log.error('SaveConfirmationModal', 'Failed to save machine', { error: errorMessage });
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setMachineToSave(null);
|
||||
setShowSaveConfirmModal(false);
|
||||
setError(null);
|
||||
onClose();
|
||||
};
|
||||
|
||||
if (!isOpen || !machineToSave) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-kaspersky-bg-card rounded-lg shadow-2xl border border-kaspersky-border max-w-md w-full select-none">
|
||||
<div className="p-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 mb-6">
|
||||
<div className="flex-shrink-0 w-12 h-12 bg-kaspersky-primary bg-opacity-10 rounded-full flex items-center justify-center border-2 border-kaspersky-primary border-opacity-30">
|
||||
<Save className="w-6 h-6 text-kaspersky-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-xl font-bold text-kaspersky-text-dark">Сохранить машину?</h2>
|
||||
<p className="text-sm text-kaspersky-text-light">Подтвердите сохранение в профиль</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 p-3 bg-kaspersky-danger bg-opacity-10 border border-kaspersky-danger rounded text-kaspersky-danger text-sm">
|
||||
⚠️ {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Machine Info */}
|
||||
<div className="mb-6 p-4 bg-kaspersky-bg rounded-lg border border-kaspersky-border space-y-3">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-kaspersky-text-light text-sm font-medium">Название:</span>
|
||||
<span className="text-kaspersky-text-dark font-semibold">{machineToSave.name}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-kaspersky-text-light text-sm font-medium">Хост:</span>
|
||||
<span className="text-kaspersky-text-dark font-mono text-sm">{machineToSave.hostname}:{machineToSave.port}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-kaspersky-text-light text-sm font-medium">Протокол:</span>
|
||||
<span className="text-kaspersky-primary uppercase text-sm font-bold">{machineToSave.protocol}</span>
|
||||
</div>
|
||||
{machineToSave.description && (
|
||||
<div className="pt-3 border-t border-kaspersky-border">
|
||||
<span className="text-kaspersky-text-light text-sm font-medium block mb-1">Описание:</span>
|
||||
<span className="text-kaspersky-text text-sm">{machineToSave.description}</span>
|
||||
</div>
|
||||
)}
|
||||
{machineToSave.tags && machineToSave.tags.length > 0 && (
|
||||
<div className="pt-3 border-t border-kaspersky-border">
|
||||
<span className="text-kaspersky-text-light text-sm font-medium block mb-2">Теги:</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{machineToSave.tags.map((tag, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className="px-2.5 py-1 bg-kaspersky-primary bg-opacity-20 text-kaspersky-primary rounded-md text-xs font-medium border border-kaspersky-primary border-opacity-30"
|
||||
>
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{machineToSave.is_favorite && (
|
||||
<div className="pt-3 border-t border-kaspersky-border flex items-center gap-2">
|
||||
<svg className="w-5 h-5 text-kaspersky-warning" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||
</svg>
|
||||
<span className="text-kaspersky-warning text-sm font-semibold">В избранном</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="mb-6 p-3 bg-kaspersky-primary bg-opacity-5 rounded-lg border border-kaspersky-primary border-opacity-20">
|
||||
<p className="text-kaspersky-text text-sm">
|
||||
💾 Машина будет сохранена в вашем профиле и доступна из любой сессии.
|
||||
Учетные данные для подключения будут запрашиваться отдельно при каждом подключении.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex gap-3">
|
||||
<Button
|
||||
onClick={handleConfirm}
|
||||
disabled={loading}
|
||||
variant="primary"
|
||||
fullWidth
|
||||
isLoading={loading}
|
||||
leftIcon={!loading ? <Save size={18} /> : undefined}
|
||||
>
|
||||
{loading ? 'Сохранение...' : 'Сохранить'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleCancel}
|
||||
disabled={loading}
|
||||
variant="secondary"
|
||||
leftIcon={<X size={18} />}
|
||||
>
|
||||
Отмена
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
315
mc_test/src/renderer/components/Sidebar.tsx
Executable file
315
mc_test/src/renderer/components/Sidebar.tsx
Executable file
@ -0,0 +1,315 @@
|
||||
import { useEffect, memo, useMemo, useCallback, useState } from 'react'
|
||||
import { Search, Plus } from 'lucide-react'
|
||||
import { log } from '../utils/logger'
|
||||
import { useMachineStore } from '../store/machineStore'
|
||||
import SmartConnectionButton from './SmartConnectionButton'
|
||||
import { ProtocolOption } from '../utils/protocolHelper'
|
||||
import { Machine } from '../types'
|
||||
import { Button } from './shared/Button'
|
||||
|
||||
interface SidebarProps {
|
||||
onConnect?: (machine: Machine, protocol?: ProtocolOption) => void;
|
||||
machineStatuses?: Map<string, Machine['status']>;
|
||||
onMachineSelected?: (machine: Machine) => void;
|
||||
machineConnections?: Map<string, any>;
|
||||
userRole?: string;
|
||||
onBulkSelectionChange?: (selectedMachineIds: string[]) => void;
|
||||
}
|
||||
|
||||
function Sidebar({ onConnect, machineStatuses, onMachineSelected, machineConnections, userRole, onBulkSelectionChange }: SidebarProps = {}) {
|
||||
const {
|
||||
machines,
|
||||
selectedMachine,
|
||||
selectMachine,
|
||||
fetchMachines,
|
||||
loading,
|
||||
searchQuery,
|
||||
setSearchQuery,
|
||||
filteredMachines,
|
||||
setShowAddMachineModal
|
||||
} = useMachineStore();
|
||||
|
||||
// ========================================================================================
|
||||
// Bulk Selection State
|
||||
// ========================================================================================
|
||||
const [selectedMachineIds, setSelectedMachineIds] = useState<Set<string>>(new Set());
|
||||
const [isBulkMode, setIsBulkMode] = useState(false);
|
||||
|
||||
// Memoized filtered machines (declared early to avoid usage before declaration)
|
||||
const memoizedFilteredMachines = useMemo(() => filteredMachines, [filteredMachines]);
|
||||
|
||||
// Toggle bulk selection mode
|
||||
const toggleBulkMode = useCallback(() => {
|
||||
setIsBulkMode(prev => !prev);
|
||||
if (isBulkMode) {
|
||||
// Exiting bulk mode - clear selection
|
||||
setSelectedMachineIds(new Set());
|
||||
onBulkSelectionChange?.([]);
|
||||
}
|
||||
}, [isBulkMode, onBulkSelectionChange]);
|
||||
|
||||
// Toggle individual machine selection
|
||||
const toggleMachineSelection = useCallback((machineId: string) => {
|
||||
setSelectedMachineIds(prev => {
|
||||
const newSelection = new Set(prev);
|
||||
if (newSelection.has(machineId)) {
|
||||
newSelection.delete(machineId);
|
||||
} else {
|
||||
newSelection.add(machineId);
|
||||
}
|
||||
onBulkSelectionChange?.(Array.from(newSelection));
|
||||
return newSelection;
|
||||
});
|
||||
}, [onBulkSelectionChange]);
|
||||
|
||||
// Select all machines
|
||||
const selectAllMachines = useCallback(() => {
|
||||
const allIds = new Set(memoizedFilteredMachines.map(m => m.id));
|
||||
setSelectedMachineIds(allIds);
|
||||
onBulkSelectionChange?.(Array.from(allIds));
|
||||
}, [memoizedFilteredMachines, onBulkSelectionChange]);
|
||||
|
||||
// Deselect all machines
|
||||
const deselectAllMachines = useCallback(() => {
|
||||
setSelectedMachineIds(new Set());
|
||||
onBulkSelectionChange?.([]);
|
||||
}, [onBulkSelectionChange]);
|
||||
|
||||
// Memoized: are all machines selected?
|
||||
const allSelected = useMemo(() => {
|
||||
if (memoizedFilteredMachines.length === 0) return false;
|
||||
return memoizedFilteredMachines.every(m => selectedMachineIds.has(m.id));
|
||||
}, [memoizedFilteredMachines, selectedMachineIds]);
|
||||
|
||||
// Clear selection when filteredMachines changes
|
||||
useEffect(() => {
|
||||
if (isBulkMode) {
|
||||
setSelectedMachineIds(new Set());
|
||||
onBulkSelectionChange?.([]);
|
||||
}
|
||||
}, [searchQuery]); // Reset selection on search change
|
||||
|
||||
/**
|
||||
* Get machine status (from props or use default)
|
||||
*/
|
||||
const getMachineStatus = useCallback(
|
||||
(machineId: string): Machine['status'] => {
|
||||
return machineStatuses?.get(machineId) || 'unknown';
|
||||
},
|
||||
[machineStatuses]
|
||||
);
|
||||
|
||||
/**
|
||||
* Get status indicator color
|
||||
*/
|
||||
const getStatusColor = useCallback((status: Machine['status']): string => {
|
||||
switch (status) {
|
||||
case 'running':
|
||||
return 'bg-kaspersky-success shadow-green-300 shadow-sm';
|
||||
case 'stopped':
|
||||
return 'bg-kaspersky-danger shadow-red-300 shadow-sm';
|
||||
case 'checking':
|
||||
return 'bg-kaspersky-warning shadow-yellow-300 shadow-sm animate-pulse';
|
||||
case 'error':
|
||||
return 'bg-kaspersky-warning shadow-yellow-300 shadow-sm';
|
||||
case 'unknown':
|
||||
default:
|
||||
return 'bg-gray-400 shadow-gray-300 shadow-sm';
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Debug log on component mount
|
||||
*/
|
||||
useEffect(() => {
|
||||
log.info('sidebar', 'Component mounted');
|
||||
return () => {
|
||||
log.info('sidebar', 'Component unmounted');
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Debug log on machines change
|
||||
*/
|
||||
useEffect(() => {
|
||||
log.info('sidebar', 'Machines updated', {
|
||||
count: machines.length,
|
||||
machines: machines.map((m) => ({ id: m.id, name: m.name, status: m.status })),
|
||||
});
|
||||
}, [machines]);
|
||||
|
||||
/**
|
||||
* Initial load and periodic refresh
|
||||
*/
|
||||
useEffect(() => {
|
||||
log.info('sidebar', 'Starting initial fetch');
|
||||
fetchMachines();
|
||||
// Refresh machine list every 30 seconds
|
||||
const interval = setInterval(fetchMachines, 30000);
|
||||
return () => clearInterval(interval);
|
||||
}, [fetchMachines]);
|
||||
|
||||
const handleSearch = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
setSearchQuery(event.target.value);
|
||||
},
|
||||
[setSearchQuery]
|
||||
);
|
||||
|
||||
const handleConnect = useCallback(
|
||||
(machine: Machine, protocol?: ProtocolOption) => {
|
||||
selectMachine(machine);
|
||||
onConnect?.(machine, protocol);
|
||||
},
|
||||
[selectMachine, onConnect]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="w-96 bg-white border-r border-kaspersky-border shadow-kaspersky select-none">
|
||||
<div className="p-4">
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search machines..."
|
||||
value={searchQuery}
|
||||
onChange={handleSearch}
|
||||
className="w-full pl-10 pr-4 py-2 bg-kaspersky-bg text-kaspersky-text rounded-lg border-2 border-kaspersky-border focus:outline-none focus:border-kaspersky-primary focus:bg-white transition-all duration-300 select-text"
|
||||
/>
|
||||
<div className="absolute left-3 top-1/2 transform -translate-y-1/2">
|
||||
<Search size={18} className="text-kaspersky-text-lighter" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Machine Button */}
|
||||
<div className="mt-3">
|
||||
<Button
|
||||
variant="primary"
|
||||
size="md"
|
||||
fullWidth
|
||||
onClick={() => setShowAddMachineModal(true)}
|
||||
leftIcon={<Plus size={18} />}
|
||||
>
|
||||
Add Custom Machine
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk Selection Controls */}
|
||||
<div className="border-t border-kaspersky-border pt-3 px-4 pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<button
|
||||
onClick={toggleBulkMode}
|
||||
className={`text-sm font-medium transition-colors ${
|
||||
isBulkMode
|
||||
? 'text-kaspersky-primary'
|
||||
: 'text-kaspersky-text-light hover:text-kaspersky-text-dark'
|
||||
}`}
|
||||
>
|
||||
{isBulkMode ? '✓ Bulk Mode' : 'Bulk Select'}
|
||||
</button>
|
||||
|
||||
{isBulkMode && memoizedFilteredMachines.length > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-kaspersky-text-light">
|
||||
{selectedMachineIds.size} selected
|
||||
</span>
|
||||
<label className="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allSelected}
|
||||
onChange={() => allSelected ? deselectAllMachines() : selectAllMachines()}
|
||||
className="w-4 h-4 text-kaspersky-primary bg-kaspersky-bg border-kaspersky-border rounded focus:ring-kaspersky-primary focus:ring-2"
|
||||
/>
|
||||
<span className="text-sm text-kaspersky-text-light">All</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-y-auto h-[calc(100vh-11rem)]">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center h-32">
|
||||
<div className="w-8 h-8 border-4 border-kaspersky-primary border-t-transparent rounded-full animate-spin"></div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1 p-2">
|
||||
{memoizedFilteredMachines.map((machine) => (
|
||||
<div
|
||||
key={machine.id}
|
||||
className={`rounded-lg transition-all duration-200 ${
|
||||
selectedMachine?.id === machine.id
|
||||
? 'bg-kaspersky-primary/10 border-2 border-kaspersky-primary shadow-kaspersky'
|
||||
: 'hover:bg-kaspersky-bg border-2 border-transparent hover:shadow-sm'
|
||||
}`}
|
||||
>
|
||||
<div className="p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
{/* Bulk Selection Checkbox */}
|
||||
{isBulkMode && (
|
||||
<label
|
||||
className="flex-shrink-0 mr-2 cursor-pointer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedMachineIds.has(machine.id)}
|
||||
onChange={() => toggleMachineSelection(machine.id)}
|
||||
className="w-4 h-4 text-kaspersky-primary bg-kaspersky-bg border-kaspersky-border rounded focus:ring-kaspersky-primary focus:ring-2"
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
if (!isBulkMode) {
|
||||
selectMachine(machine);
|
||||
// Trigger availability check on machine click
|
||||
if (onMachineSelected) {
|
||||
onMachineSelected(machine);
|
||||
}
|
||||
} else {
|
||||
// In bulk mode, clicking selects/deselects
|
||||
toggleMachineSelection(machine.id);
|
||||
}
|
||||
}}
|
||||
className="flex items-center text-left flex-1 min-w-0"
|
||||
>
|
||||
{!isBulkMode && (
|
||||
<div
|
||||
className={`w-3 h-3 rounded-full mr-3 flex-shrink-0 ${getStatusColor(getMachineStatus(machine.id))}`}
|
||||
title={`Status: ${getMachineStatus(machine.id)}`}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-kaspersky-text-dark truncate">{machine.name}</div>
|
||||
<div className="text-sm text-kaspersky-text-light">
|
||||
<span className="truncate">{machine.ip}</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{onConnect && !isBulkMode && (
|
||||
<div className="ml-3 flex-shrink-0">
|
||||
<SmartConnectionButton
|
||||
machine={machine}
|
||||
onConnect={handleConnect}
|
||||
isConnected={machineConnections?.has(machine.id)}
|
||||
userRole={userRole}
|
||||
size="sm"
|
||||
className="w-auto"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(Sidebar)
|
||||
96
mc_test/src/renderer/components/SmartConnectionButton.tsx
Executable file
96
mc_test/src/renderer/components/SmartConnectionButton.tsx
Executable file
@ -0,0 +1,96 @@
|
||||
import React, { useMemo } from 'react';
|
||||
import { Play, AlertCircle } from 'lucide-react';
|
||||
import { Machine } from '../types';
|
||||
import { ProtocolHelper, ProtocolOption, OSInfo } from '../utils/protocolHelper';
|
||||
|
||||
interface SmartConnectionButtonProps {
|
||||
machine: Machine;
|
||||
onConnect: (machine: Machine, suggestedProtocol?: ProtocolOption) => void;
|
||||
isConnected?: boolean;
|
||||
userRole?: string;
|
||||
className?: string;
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
}
|
||||
|
||||
const SmartConnectionButton: React.FC<SmartConnectionButtonProps> = ({
|
||||
machine,
|
||||
onConnect,
|
||||
isConnected = false,
|
||||
userRole,
|
||||
className = '',
|
||||
size = 'md'
|
||||
}) => {
|
||||
const osInfo: OSInfo = useMemo(() => {
|
||||
return ProtocolHelper.parseOS(machine.os);
|
||||
}, [machine.os]);
|
||||
|
||||
const recommendedProtocol = useMemo(() => {
|
||||
return ProtocolHelper.getDefaultProtocol(osInfo);
|
||||
}, [osInfo]);
|
||||
|
||||
const compatibilityInfo = useMemo(() => {
|
||||
return ProtocolHelper.getCompatibilityInfo(osInfo);
|
||||
}, [osInfo]);
|
||||
|
||||
const hasWarnings = compatibilityInfo.warnings.length > 0;
|
||||
const isGuestRole = userRole === 'GUEST';
|
||||
const isDisabled = isGuestRole && !isConnected; // Disable only for GUEST when not connected
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'px-3 py-1.5 text-xs',
|
||||
md: 'px-3 py-2 text-sm',
|
||||
lg: 'px-4 py-3 text-base'
|
||||
};
|
||||
|
||||
const iconSizes = {
|
||||
sm: 12,
|
||||
md: 16,
|
||||
lg: 20
|
||||
};
|
||||
|
||||
const handleClick = () => {
|
||||
if (!isDisabled) {
|
||||
onConnect(machine, recommendedProtocol);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={handleClick}
|
||||
disabled={isDisabled}
|
||||
className={`
|
||||
flex items-center gap-2
|
||||
min-w-[125px]
|
||||
${isDisabled
|
||||
? 'bg-gray-400 cursor-not-allowed opacity-60'
|
||||
: isConnected
|
||||
? 'bg-green-600 hover:bg-green-700'
|
||||
: 'bg-kaspersky-base hover:bg-kaspersky-dark'
|
||||
}
|
||||
text-white rounded-md transition-all duration-200
|
||||
font-medium
|
||||
${sizeClasses[size]}
|
||||
${hasWarnings && !isDisabled ? 'ring-2 ring-yellow-400 ring-opacity-50' : ''}
|
||||
${className}
|
||||
`}
|
||||
title={
|
||||
isDisabled
|
||||
? 'Access denied: GUEST role cannot create connections. Contact your administrator.'
|
||||
: isConnected
|
||||
? `Active session via ${recommendedProtocol.label}`
|
||||
: `Connect via ${recommendedProtocol.label} (${recommendedProtocol.description})`
|
||||
}
|
||||
>
|
||||
<span className="text-lg w-6 flex justify-center">{recommendedProtocol.icon}</span>
|
||||
<Play size={iconSizes[size]} className={isConnected ? 'text-green-200' : ''} />
|
||||
<span className="flex-1 text-left">{isConnected ? 'Active' : 'Connect'}</span>
|
||||
{hasWarnings && !isConnected && <AlertCircle size={iconSizes[size]} className="text-yellow-300" />}
|
||||
</button>
|
||||
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default SmartConnectionButton;
|
||||
91
mc_test/src/renderer/components/Toast/ToastContainer.tsx
Executable file
91
mc_test/src/renderer/components/Toast/ToastContainer.tsx
Executable file
@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Info, X } from 'lucide-react';
|
||||
import { Toast, useToast } from './ToastProvider';
|
||||
|
||||
const ToastItem: React.FC<{ toast: Toast }> = ({ toast }) => {
|
||||
const { removeToast } = useToast();
|
||||
|
||||
const getToastStyles = (type: Toast['type']) => {
|
||||
const baseStyles = "flex items-start p-4 mb-3 rounded-lg shadow-kaspersky border-2 transition-all duration-300 ease-in-out transform hover:scale-[1.02] animate-fadeIn";
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return `${baseStyles} bg-kaspersky-success bg-opacity-10 border-kaspersky-success text-kaspersky-success`;
|
||||
case 'error':
|
||||
return `${baseStyles} bg-kaspersky-danger bg-opacity-10 border-kaspersky-danger text-kaspersky-danger`;
|
||||
case 'warning':
|
||||
return `${baseStyles} bg-kaspersky-warning bg-opacity-10 border-kaspersky-warning text-kaspersky-warning`;
|
||||
case 'info':
|
||||
return `${baseStyles} bg-kaspersky-primary bg-opacity-10 border-kaspersky-primary text-kaspersky-primary`;
|
||||
default:
|
||||
return `${baseStyles} bg-kaspersky-bg-card border-kaspersky-border text-kaspersky-text`;
|
||||
}
|
||||
};
|
||||
|
||||
const getIcon = (type: Toast['type']) => {
|
||||
const iconClasses = "w-5 h-5 mr-3 mt-0.5 flex-shrink-0";
|
||||
|
||||
switch (type) {
|
||||
case 'success':
|
||||
return <CheckCircle2 className={`${iconClasses} text-kaspersky-success`} />;
|
||||
case 'error':
|
||||
return <XCircle className={`${iconClasses} text-kaspersky-danger`} />;
|
||||
case 'warning':
|
||||
return <AlertTriangle className={`${iconClasses} text-kaspersky-warning`} />;
|
||||
case 'info':
|
||||
return <Info className={`${iconClasses} text-kaspersky-primary`} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
removeToast(toast.id);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={getToastStyles(toast.type)}>
|
||||
{getIcon(toast.type)}
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<h4 className="font-semibold text-sm text-kaspersky-text-dark">{toast.title}</h4>
|
||||
{toast.message && (
|
||||
<p className="mt-1 text-sm text-kaspersky-text opacity-90">{toast.message}</p>
|
||||
)}
|
||||
|
||||
{toast.action && (
|
||||
<button
|
||||
onClick={toast.action.onClick}
|
||||
className="mt-2 text-sm font-semibold underline hover:no-underline transition-all hover:opacity-80"
|
||||
>
|
||||
{toast.action.label}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleClose}
|
||||
className="ml-3 flex-shrink-0 p-1 rounded-lg hover:bg-black hover:bg-opacity-10 transition-all duration-200"
|
||||
aria-label="Close notification"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const ToastContainer: React.FC = () => {
|
||||
const { toasts } = useToast();
|
||||
|
||||
if (toasts.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed top-4 right-4 z-50 max-w-sm w-full space-y-2">
|
||||
{toasts.map((toast) => (
|
||||
<ToastItem key={toast.id} toast={toast} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
99
mc_test/src/renderer/components/Toast/ToastProvider.tsx
Executable file
99
mc_test/src/renderer/components/Toast/ToastProvider.tsx
Executable file
@ -0,0 +1,99 @@
|
||||
import React, { createContext, useContext, useState, ReactNode, useCallback } from 'react';
|
||||
import { ToastContainer } from './ToastContainer';
|
||||
|
||||
export interface Toast {
|
||||
id: string;
|
||||
type: 'success' | 'error' | 'warning' | 'info';
|
||||
title: string;
|
||||
message?: string;
|
||||
duration?: number;
|
||||
action?: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
};
|
||||
}
|
||||
|
||||
interface ToastContextType {
|
||||
toasts: Toast[];
|
||||
addToast: (toast: Omit<Toast, 'id'>) => string;
|
||||
removeToast: (id: string) => void;
|
||||
clearToasts: () => void;
|
||||
updateToast: (id: string, updates: Partial<Toast>) => void;
|
||||
}
|
||||
|
||||
const ToastContext = createContext<ToastContextType | undefined>(undefined);
|
||||
|
||||
export const ToastProvider: React.FC<{ children: ReactNode }> = ({ children }) => {
|
||||
const [toasts, setToasts] = useState<Toast[]>([]);
|
||||
|
||||
const addToast = useCallback((toast: Omit<Toast, 'id'>): string => {
|
||||
const id = Math.random().toString(36).substr(2, 9);
|
||||
const newToast: Toast = { ...toast, id };
|
||||
|
||||
setToasts(prev => [...prev, newToast]);
|
||||
|
||||
// Auto remove after duration
|
||||
const duration = toast.duration ?? (toast.type === 'error' ? 8000 : 4000);
|
||||
if (duration > 0) {
|
||||
setTimeout(() => removeToast(id), duration);
|
||||
}
|
||||
|
||||
return id;
|
||||
}, []);
|
||||
|
||||
const removeToast = useCallback((id: string) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
}, []);
|
||||
|
||||
const clearToasts = useCallback(() => {
|
||||
setToasts([]);
|
||||
}, []);
|
||||
|
||||
const updateToast = useCallback((id: string, updates: Partial<Toast>) => {
|
||||
setToasts(prev => prev.map(toast =>
|
||||
toast.id === id ? { ...toast, ...updates } : toast
|
||||
));
|
||||
}, []);
|
||||
|
||||
const value: ToastContextType = {
|
||||
toasts,
|
||||
addToast,
|
||||
removeToast,
|
||||
clearToasts,
|
||||
updateToast
|
||||
};
|
||||
|
||||
return (
|
||||
<ToastContext.Provider value={value}>
|
||||
{children}
|
||||
<ToastContainer />
|
||||
</ToastContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export const useToast = () => {
|
||||
const context = useContext(ToastContext);
|
||||
if (!context) {
|
||||
throw new Error('useToast must be used within ToastProvider');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// Helpers for quick toast creation
|
||||
export const useToastHelpers = () => {
|
||||
const { addToast } = useToast();
|
||||
|
||||
return {
|
||||
success: (title: string, message?: string, duration?: number) =>
|
||||
addToast({ type: 'success', title, message, duration }),
|
||||
|
||||
error: (title: string, message?: string, duration?: number) =>
|
||||
addToast({ type: 'error', title, message, duration }),
|
||||
|
||||
warning: (title: string, message?: string, duration?: number) =>
|
||||
addToast({ type: 'warning', title, message, duration }),
|
||||
|
||||
info: (title: string, message?: string, duration?: number) =>
|
||||
addToast({ type: 'info', title, message, duration }),
|
||||
};
|
||||
};
|
||||
96
mc_test/src/renderer/components/shared/Button.tsx
Executable file
96
mc_test/src/renderer/components/shared/Button.tsx
Executable file
@ -0,0 +1,96 @@
|
||||
import React, { ButtonHTMLAttributes } from 'react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'success' | 'ghost';
|
||||
export type ButtonSize = 'sm' | 'md' | 'lg';
|
||||
|
||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant;
|
||||
size?: ButtonSize;
|
||||
isLoading?: boolean;
|
||||
leftIcon?: React.ReactNode;
|
||||
rightIcon?: React.ReactNode;
|
||||
fullWidth?: boolean;
|
||||
}
|
||||
|
||||
const variantClasses: Record<ButtonVariant, string> = {
|
||||
primary: `
|
||||
bg-kaspersky-primary text-white
|
||||
hover:bg-kaspersky-primary-dark hover:shadow-kaspersky-lg
|
||||
active:bg-kaspersky-primary-dark active:scale-[0.98]
|
||||
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
|
||||
focus:outline-none focus:ring-4 focus:ring-kaspersky-primary/30
|
||||
`,
|
||||
secondary: `
|
||||
bg-kaspersky-secondary text-white
|
||||
hover:bg-opacity-90 hover:shadow-kaspersky
|
||||
active:bg-opacity-80 active:scale-[0.98]
|
||||
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
|
||||
focus:outline-none focus:ring-4 focus:ring-kaspersky-secondary/30
|
||||
`,
|
||||
danger: `
|
||||
bg-kaspersky-danger text-white
|
||||
hover:bg-red-700 hover:shadow-kaspersky-lg
|
||||
active:bg-red-800 active:scale-[0.98]
|
||||
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
|
||||
focus:outline-none focus:ring-4 focus:ring-kaspersky-danger/30
|
||||
`,
|
||||
success: `
|
||||
bg-kaspersky-success text-white
|
||||
hover:bg-green-600 hover:shadow-kaspersky-lg
|
||||
active:bg-green-700 active:scale-[0.98]
|
||||
disabled:bg-kaspersky-text-lighter disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:shadow-none
|
||||
focus:outline-none focus:ring-4 focus:ring-kaspersky-success/30
|
||||
`,
|
||||
ghost: `
|
||||
bg-transparent text-kaspersky-text-dark border-2 border-kaspersky-border
|
||||
hover:bg-kaspersky-bg hover:border-kaspersky-border-dark
|
||||
active:bg-kaspersky-bg-card active:scale-[0.98]
|
||||
disabled:text-kaspersky-text-lighter disabled:border-kaspersky-border-light disabled:cursor-not-allowed disabled:opacity-60
|
||||
focus:outline-none focus:ring-4 focus:ring-kaspersky-primary/20
|
||||
`,
|
||||
};
|
||||
|
||||
const sizeClasses: Record<ButtonSize, string> = {
|
||||
sm: 'px-3 py-1.5 text-sm',
|
||||
md: 'px-4 py-2 text-base',
|
||||
lg: 'px-6 py-3 text-lg',
|
||||
};
|
||||
|
||||
export const Button: React.FC<ButtonProps> = ({
|
||||
variant = 'primary',
|
||||
size = 'md',
|
||||
isLoading = false,
|
||||
leftIcon,
|
||||
rightIcon,
|
||||
fullWidth = false,
|
||||
className = '',
|
||||
children,
|
||||
disabled,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<button
|
||||
className={`
|
||||
${variantClasses[variant]}
|
||||
${sizeClasses[size]}
|
||||
${fullWidth ? 'w-full' : ''}
|
||||
font-semibold rounded-lg
|
||||
transition-all duration-300 transform
|
||||
flex items-center justify-center gap-2
|
||||
disabled:transform-none
|
||||
${className}
|
||||
`.trim().replace(/\s+/g, ' ')}
|
||||
disabled={disabled || isLoading}
|
||||
{...props}
|
||||
>
|
||||
{isLoading && <Loader2 size={16} className="animate-spin" />}
|
||||
{!isLoading && leftIcon && <span className="flex-shrink-0">{leftIcon}</span>}
|
||||
<span>{children}</span>
|
||||
{!isLoading && rightIcon && <span className="flex-shrink-0">{rightIcon}</span>}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
export default Button;
|
||||
|
||||
37
mc_test/src/renderer/config/api.ts
Executable file
37
mc_test/src/renderer/config/api.ts
Executable file
@ -0,0 +1,37 @@
|
||||
// API Configuration
|
||||
export const API_CONFIG = {
|
||||
// Client on test.exbytestudios.com, API on mc.exbytestudios.com
|
||||
BASE_URL: import.meta.env.VITE_API_URL || 'https://mc.exbytestudios.com',
|
||||
ENDPOINTS: {
|
||||
AUTH: {
|
||||
LOGIN: '/api/auth/login',
|
||||
REVOKE: '/api/auth/revoke',
|
||||
},
|
||||
CONNECTIONS: {
|
||||
CREATE: '/api/connections',
|
||||
LIST: '/api/connections',
|
||||
DELETE: (id: string) => `/api/connections/${id}`,
|
||||
EXTEND: (id: string) => `/api/connections/${id}/extend`,
|
||||
},
|
||||
MACHINES: {
|
||||
CHECK_AVAILABILITY: '/api/machines/check-availability',
|
||||
SAVED: '/api/machines/saved',
|
||||
SAVED_BY_ID: (id: string) => `/api/machines/saved/${id}`,
|
||||
SAVED_CONNECT: (id: string) => `/api/machines/saved/${id}/connect`,
|
||||
},
|
||||
BULK: {
|
||||
HEALTH_CHECK: '/api/bulk/health-check',
|
||||
SSH_COMMAND: '/api/bulk/ssh-command',
|
||||
},
|
||||
SECURITY: {
|
||||
CERTIFICATE_PINS: '/api/security/certificate-pins',
|
||||
},
|
||||
HEALTH: {
|
||||
CHECK: '/api/health',
|
||||
},
|
||||
},
|
||||
TIMEOUTS: {
|
||||
DEFAULT: 30000, // 30 seconds
|
||||
LONG: 60000, // 60 seconds
|
||||
},
|
||||
};
|
||||
27
mc_test/src/renderer/main.tsx
Executable file
27
mc_test/src/renderer/main.tsx
Executable file
@ -0,0 +1,27 @@
|
||||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
import './styles/index.css'
|
||||
import { CSPManager } from './utils/csp'
|
||||
|
||||
// Debug: output that main.tsx is loading
|
||||
console.log('🚀 main.tsx loading...');
|
||||
|
||||
// Apply CSP automatically on initialization
|
||||
console.log('🔒 Applying Content Security Policy...');
|
||||
CSPManager.applyCSP();
|
||||
CSPManager.setupCSPReporting();
|
||||
|
||||
try {
|
||||
const root = ReactDOM.createRoot(document.getElementById('root')!);
|
||||
console.log('✅ ReactDOM.createRoot successfully created');
|
||||
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>,
|
||||
);
|
||||
console.log('✅ App successfully rendered');
|
||||
} catch (error) {
|
||||
console.error('❌ Rendering error:', error);
|
||||
}
|
||||
117
mc_test/src/renderer/mocks/mockMachines.ts
Executable file
117
mc_test/src/renderer/mocks/mockMachines.ts
Executable file
@ -0,0 +1,117 @@
|
||||
import type { Machine } from '../types';
|
||||
|
||||
export const mockMachines: Machine[] = [
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
name: 'EP-SN-01',
|
||||
status: 'running',
|
||||
os: 'Ubuntu 22.04 LTS',
|
||||
ip: '192.168.200.2',
|
||||
hypervisor: 'VMware ESXi',
|
||||
specs: {
|
||||
cpu: 'Intel Core i7-9700K',
|
||||
ram: '16 GB',
|
||||
disk: '512 GB SSD'
|
||||
},
|
||||
testLinks: [
|
||||
{ name: 'RDP Connection', url: 'ssh://192.168.200.3:22' },
|
||||
{ name: 'Web Server', url: 'http://192.168.200.3:8080' },
|
||||
{ name: 'Admin Panel', url: 'http://192.168.200.3:8081/admin' }
|
||||
],
|
||||
logs: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: 'Machine started successfully'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 300000).toISOString(),
|
||||
level: 'info',
|
||||
message: 'RDP service is running'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000002',
|
||||
name: 'EP-Test-01',
|
||||
status: 'running',
|
||||
os: 'Ubuntu 22.04 LTS',
|
||||
ip: import.meta.env.VITE_DEV_HOST || '192.168.200.10',
|
||||
hypervisor: 'KVM',
|
||||
specs: {
|
||||
cpu: 'AMD Ryzen 7 5700G',
|
||||
ram: '8 GB',
|
||||
disk: '256 GB SSD'
|
||||
},
|
||||
testLinks: [
|
||||
{ name: 'SSH Connection', url: `ssh://${import.meta.env.VITE_DEV_HOST || '192.168.200.10'}:22` },
|
||||
{ name: 'Web Server', url: `http://${import.meta.env.VITE_DEV_HOST || '192.168.200.10'}:80` },
|
||||
{ name: 'SFTP', url: `sftp://${import.meta.env.VITE_DEV_HOST || '192.168.200.10'}:22` }
|
||||
],
|
||||
logs: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: 'SSH service is running'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 600000).toISOString(),
|
||||
level: 'info',
|
||||
message: 'System update completed'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000003',
|
||||
name: 'VNC Desktop',
|
||||
status: 'running',
|
||||
os: 'CentOS 8',
|
||||
ip: '192.168.1.100',
|
||||
hypervisor: 'VMware vSphere',
|
||||
specs: {
|
||||
cpu: 'Intel Xeon E5-2680',
|
||||
ram: '12 GB',
|
||||
disk: '1 TB HDD'
|
||||
},
|
||||
testLinks: [
|
||||
{ name: 'VNC Connection', url: 'vnc://192.168.1.100:5901' },
|
||||
{ name: 'Desktop Environment', url: 'http://192.168.1.100:6080' }
|
||||
],
|
||||
logs: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: 'VNC server started'
|
||||
},
|
||||
{
|
||||
timestamp: new Date(Date.now() - 900000).toISOString(),
|
||||
level: 'warn',
|
||||
message: 'High CPU usage detected'
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
id: '00000000-0000-0000-0000-000000000004',
|
||||
name: 'Local Test Machine',
|
||||
status: 'running',
|
||||
os: 'Windows 11 Pro',
|
||||
ip: '127.0.0.1',
|
||||
hypervisor: 'Local',
|
||||
specs: {
|
||||
cpu: 'Intel Core i5-12400',
|
||||
ram: '16 GB',
|
||||
disk: '512 GB NVMe'
|
||||
},
|
||||
testLinks: [
|
||||
{ name: 'Local RDP', url: 'rdp://127.0.0.1:3389' },
|
||||
{ name: 'Localhost Web', url: 'http://127.0.0.1:8080' }
|
||||
],
|
||||
logs: [
|
||||
{
|
||||
timestamp: new Date().toISOString(),
|
||||
level: 'info',
|
||||
message: 'Local machine ready for testing'
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
468
mc_test/src/renderer/services/CertificatePinning.ts
Executable file
468
mc_test/src/renderer/services/CertificatePinning.ts
Executable file
@ -0,0 +1,468 @@
|
||||
/**
|
||||
* Certificate Pinning Service for MITM attack protection
|
||||
*/
|
||||
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
export interface CertificateInfo {
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
issuer: string;
|
||||
subject: string;
|
||||
validFrom: string;
|
||||
validTo: string;
|
||||
}
|
||||
|
||||
export interface PinnedCertificate {
|
||||
hostname: string;
|
||||
fingerprint: string;
|
||||
algorithm: string;
|
||||
lastVerified: string;
|
||||
}
|
||||
|
||||
interface CertificatePinConfig {
|
||||
pins: Record<string, string[]>;
|
||||
backup_pins: Record<string, string[]>;
|
||||
rotation_schedule: {
|
||||
primary_pin_ttl_days: number;
|
||||
backup_pin_ttl_days: number;
|
||||
next_rotation: string;
|
||||
};
|
||||
fallback_enabled: boolean;
|
||||
version: string;
|
||||
}
|
||||
|
||||
interface PinCache {
|
||||
config: CertificatePinConfig;
|
||||
lastUpdated: number;
|
||||
ttl: number;
|
||||
}
|
||||
|
||||
const MILLISECONDS_PER_DAY = 1000 * 60 * 60 * 24;
|
||||
|
||||
const CERTIFICATE_CONSTANTS = {
|
||||
CACHE_TTL: 24 * 60 * 60 * 1000, // 24 hours
|
||||
MAX_AGE_DAYS: 365,
|
||||
HTTPS_PROTOCOL: 'https:',
|
||||
} as const;
|
||||
|
||||
const HASH_ALGORITHM = {
|
||||
SHA256: 'sha256',
|
||||
SHA1: 'sha1',
|
||||
} as const;
|
||||
|
||||
const HEX_FORMAT = {
|
||||
RADIX: 16,
|
||||
PAD_LENGTH: 2,
|
||||
PAD_CHAR: '0',
|
||||
} as const;
|
||||
|
||||
export class CertificatePinningService {
|
||||
public static pinCache: PinCache | null = null;
|
||||
public static readonly CACHE_TTL = CERTIFICATE_CONSTANTS.CACHE_TTL;
|
||||
|
||||
/**
|
||||
* INSTRUCTIONS FOR OBTAINING CERTIFICATE FINGERPRINT:
|
||||
*
|
||||
* 1. To get SHA-256 fingerprint of certificate, run:
|
||||
* openssl s_client -connect DOMAIN:443 -servername DOMAIN | openssl x509 -pubkey -noout | openssl pkey -pubin -outform der | openssl dgst -sha256 -binary | openssl enc -base64
|
||||
*
|
||||
* 2. Alternative method via curl:
|
||||
* curl -sI https://DOMAIN | openssl s_client -connect DOMAIN:443 -servername DOMAIN | openssl x509 -fingerprint -sha256 -noout
|
||||
*
|
||||
* 3. For browser, use Developer Tools > Security > Certificate Details
|
||||
*
|
||||
* IMPORTANT: Replace PLACEHOLDER values with real fingerprints before production deployment!
|
||||
*/
|
||||
private static readonly PINNED_CERTIFICATES: PinnedCertificate[] = [
|
||||
{
|
||||
hostname: import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com',
|
||||
fingerprint: 'PLACEHOLDER_REAL_CERTIFICATE_FINGERPRINT_HERE',
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
},
|
||||
{
|
||||
hostname: import.meta.env.VITE_BACKUP_DOMAIN || 'backup.mc.exbytestudios.com',
|
||||
fingerprint: 'PLACEHOLDER_BACKUP_CERTIFICATE_FINGERPRINT_HERE',
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
},
|
||||
];
|
||||
|
||||
private static readonly MAX_CERTIFICATE_AGE_DAYS = CERTIFICATE_CONSTANTS.MAX_AGE_DAYS;
|
||||
|
||||
/**
|
||||
* Verify certificate for host
|
||||
*/
|
||||
static async verifyCertificate(hostname: string): Promise<boolean> {
|
||||
try {
|
||||
if (typeof window !== 'undefined' && (window as any).electronAPI) {
|
||||
const certificate = await (window as any).electronAPI.getCertificate(hostname);
|
||||
return this.validateCertificate(hostname, certificate);
|
||||
}
|
||||
|
||||
return this.validateCertificateBrowser(hostname);
|
||||
} catch (error) {
|
||||
console.error('Certificate verification failed:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate certificate against pinned certificates
|
||||
*/
|
||||
private static validateCertificate(hostname: string, certificate: CertificateInfo): boolean {
|
||||
const pinnedCert = this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname);
|
||||
|
||||
if (!pinnedCert) {
|
||||
console.warn(`No pinned certificate found for hostname: ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (certificate.fingerprint.toLowerCase() !== pinnedCert.fingerprint.toLowerCase()) {
|
||||
console.error(`Certificate fingerprint mismatch for ${hostname}:`, {
|
||||
expected: pinnedCert.fingerprint,
|
||||
actual: certificate.fingerprint,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
if (certificate.algorithm !== pinnedCert.algorithm) {
|
||||
console.error(`Certificate algorithm mismatch for ${hostname}:`, {
|
||||
expected: pinnedCert.algorithm,
|
||||
actual: certificate.algorithm,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validTo = new Date(certificate.validTo);
|
||||
const now = new Date();
|
||||
|
||||
if (validTo < now) {
|
||||
console.error(`Certificate expired for ${hostname}:`, {
|
||||
validTo: certificate.validTo,
|
||||
now: now.toISOString(),
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const validFrom = new Date(certificate.validFrom);
|
||||
const ageInDays = (now.getTime() - validFrom.getTime()) / MILLISECONDS_PER_DAY;
|
||||
|
||||
if (ageInDays > this.MAX_CERTIFICATE_AGE_DAYS) {
|
||||
console.warn(`Certificate is too old for ${hostname}:`, {
|
||||
ageInDays,
|
||||
maxAge: this.MAX_CERTIFICATE_AGE_DAYS,
|
||||
});
|
||||
}
|
||||
|
||||
console.log(`Certificate validation passed for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validation for browser environment (limited)
|
||||
*/
|
||||
private static validateCertificateBrowser(hostname: string): boolean {
|
||||
if (window.location.protocol !== CERTIFICATE_CONSTANTS.HTTPS_PROTOCOL) {
|
||||
console.error('Non-HTTPS connection detected');
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedHostname = this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname);
|
||||
if (!expectedHostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
console.log(`HTTPS validation passed for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned certificate information for host
|
||||
*/
|
||||
static getPinnedCertificate(hostname: string): PinnedCertificate | null {
|
||||
return this.PINNED_CERTIFICATES.find((cert) => cert.hostname === hostname) || null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Add new pinned certificate
|
||||
*/
|
||||
static addPinnedCertificate(certificate: PinnedCertificate): void {
|
||||
const existingIndex = this.PINNED_CERTIFICATES.findIndex(
|
||||
(cert) => cert.hostname === certificate.hostname
|
||||
);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
this.PINNED_CERTIFICATES[existingIndex] = certificate;
|
||||
} else {
|
||||
this.PINNED_CERTIFICATES.push(certificate);
|
||||
}
|
||||
|
||||
console.log(`Added pinned certificate for ${certificate.hostname}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove pinned certificate
|
||||
*/
|
||||
static removePinnedCertificate(hostname: string): boolean {
|
||||
const index = this.PINNED_CERTIFICATES.findIndex((cert) => cert.hostname === hostname);
|
||||
|
||||
if (index >= 0) {
|
||||
this.PINNED_CERTIFICATES.splice(index, 1);
|
||||
console.log(`Removed pinned certificate for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pinned certificates
|
||||
*/
|
||||
static getAllPinnedCertificates(): PinnedCertificate[] {
|
||||
return [...this.PINNED_CERTIFICATES];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pinned certificate exists for host
|
||||
*/
|
||||
static hasPinnedCertificate(hostname: string): boolean {
|
||||
return this.PINNED_CERTIFICATES.some((cert) => cert.hostname === hostname);
|
||||
}
|
||||
|
||||
/**
|
||||
* Update certificate fingerprint
|
||||
*/
|
||||
static updateCertificateFingerprint(hostname: string, newFingerprint: string): boolean {
|
||||
const certificate = this.getPinnedCertificate(hostname);
|
||||
|
||||
if (certificate) {
|
||||
certificate.fingerprint = newFingerprint;
|
||||
certificate.lastVerified = new Date().toISOString();
|
||||
console.log(`Updated certificate fingerprint for ${hostname}`);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate all pinned certificates
|
||||
*/
|
||||
static async validateAllCertificates(): Promise<Record<string, boolean>> {
|
||||
const results: Record<string, boolean> = {};
|
||||
|
||||
for (const certificate of this.PINNED_CERTIFICATES) {
|
||||
try {
|
||||
results[certificate.hostname] = await this.verifyCertificate(certificate.hostname);
|
||||
} catch (error) {
|
||||
console.error(`Failed to validate certificate for ${certificate.hostname}:`, error);
|
||||
results[certificate.hostname] = false;
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pinned certificates statistics
|
||||
*/
|
||||
static getStatistics(): {
|
||||
totalCertificates: number;
|
||||
certificatesByAlgorithm: Record<string, number>;
|
||||
oldestCertificate: PinnedCertificate | null;
|
||||
newestCertificate: PinnedCertificate | null;
|
||||
} {
|
||||
const certificates = this.PINNED_CERTIFICATES;
|
||||
|
||||
const certificatesByAlgorithm = certificates.reduce(
|
||||
(acc, cert) => {
|
||||
acc[cert.algorithm] = (acc[cert.algorithm] || 0) + 1;
|
||||
return acc;
|
||||
},
|
||||
{} as Record<string, number>
|
||||
);
|
||||
|
||||
const sortedByDate = [...certificates].sort(
|
||||
(a, b) => new Date(a.lastVerified).getTime() - new Date(b.lastVerified).getTime()
|
||||
);
|
||||
|
||||
return {
|
||||
totalCertificates: certificates.length,
|
||||
certificatesByAlgorithm,
|
||||
oldestCertificate: sortedByDate[0] || null,
|
||||
newestCertificate: sortedByDate[sortedByDate.length - 1] || null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const FINGERPRINT_PATTERNS = {
|
||||
SHA256: /^[a-f0-9]{64}$/,
|
||||
SHA1: /^[a-f0-9]{40}$/,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Certificate utilities
|
||||
*/
|
||||
export class CertificateUtils {
|
||||
/**
|
||||
* Compute SHA-256 fingerprint of certificate
|
||||
*/
|
||||
static async computeSHA256Fingerprint(certificateData: ArrayBuffer): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-256', certificateData);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray
|
||||
.map((b) => b.toString(HEX_FORMAT.RADIX).padStart(HEX_FORMAT.PAD_LENGTH, HEX_FORMAT.PAD_CHAR))
|
||||
.join(':')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute SHA-1 fingerprint of certificate
|
||||
*/
|
||||
static async computeSHA1Fingerprint(certificateData: ArrayBuffer): Promise<string> {
|
||||
const hashBuffer = await crypto.subtle.digest('SHA-1', certificateData);
|
||||
const hashArray = Array.from(new Uint8Array(hashBuffer));
|
||||
return hashArray
|
||||
.map((b) => b.toString(HEX_FORMAT.RADIX).padStart(HEX_FORMAT.PAD_LENGTH, HEX_FORMAT.PAD_CHAR))
|
||||
.join(':')
|
||||
.toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Format fingerprint for display
|
||||
*/
|
||||
static formatFingerprint(fingerprint: string): string {
|
||||
return fingerprint
|
||||
.toUpperCase()
|
||||
.replace(/(.{2})/g, '$1:')
|
||||
.slice(0, -1);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate fingerprint format
|
||||
*/
|
||||
static isValidFingerprint(fingerprint: string): boolean {
|
||||
const cleanFingerprint = fingerprint.replace(/:/g, '').toLowerCase();
|
||||
return (
|
||||
FINGERPRINT_PATTERNS.SHA1.test(cleanFingerprint) ||
|
||||
FINGERPRINT_PATTERNS.SHA256.test(cleanFingerprint)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load current certificate pins from server
|
||||
*/
|
||||
async loadDynamicPins(baseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`${baseUrl}${API_CONFIG.ENDPOINTS.SECURITY.CERTIFICATE_PINS}`,
|
||||
{
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to load certificate pins: ${response.status}`);
|
||||
}
|
||||
|
||||
const config: CertificatePinConfig = await response.json();
|
||||
|
||||
CertificatePinningService.pinCache = {
|
||||
config,
|
||||
lastUpdated: Date.now(),
|
||||
ttl: CertificatePinningService.CACHE_TTL,
|
||||
};
|
||||
|
||||
this.updateStaticPins(config);
|
||||
|
||||
console.log('Certificate pins loaded successfully', {
|
||||
version: config.version,
|
||||
hosts: Object.keys(config.pins),
|
||||
fallbackEnabled: config.fallback_enabled,
|
||||
});
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('Failed to load dynamic certificate pins:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update static pins from configuration
|
||||
*/
|
||||
public updateStaticPins(config: CertificatePinConfig): void {
|
||||
const newPinnedCerts: PinnedCertificate[] = [];
|
||||
|
||||
for (const [hostname, pins] of Object.entries(config.pins)) {
|
||||
for (const pin of pins) {
|
||||
newPinnedCerts.push({
|
||||
hostname,
|
||||
fingerprint: pin,
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (config.fallback_enabled) {
|
||||
for (const [hostname, pins] of Object.entries(config.backup_pins)) {
|
||||
for (const pin of pins) {
|
||||
newPinnedCerts.push({
|
||||
hostname,
|
||||
fingerprint: pin,
|
||||
algorithm: HASH_ALGORITHM.SHA256,
|
||||
lastVerified: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(CertificatePinningService as any).PINNED_CERTIFICATES = newPinnedCerts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if pins cache is valid
|
||||
*/
|
||||
isPinCacheValid(): boolean {
|
||||
if (!CertificatePinningService.pinCache) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
return (
|
||||
now - CertificatePinningService.pinCache.lastUpdated <
|
||||
CertificatePinningService.pinCache.ttl
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pins cache information
|
||||
*/
|
||||
static getPinCacheInfo(): { cached: boolean; lastUpdated?: string; ttl?: number } {
|
||||
if (!CertificatePinningService.pinCache) {
|
||||
return { cached: false };
|
||||
}
|
||||
|
||||
return {
|
||||
cached: true,
|
||||
lastUpdated: new Date(CertificatePinningService.pinCache.lastUpdated).toISOString(),
|
||||
ttl: CertificatePinningService.pinCache.ttl,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear pins cache
|
||||
*/
|
||||
static clearPinCache(): void {
|
||||
CertificatePinningService.pinCache = null;
|
||||
}
|
||||
}
|
||||
|
||||
export const certificatePinning = new CertificatePinningService();
|
||||
export const certificateUtils = CertificateUtils;
|
||||
261
mc_test/src/renderer/services/EncryptionService.ts
Executable file
261
mc_test/src/renderer/services/EncryptionService.ts
Executable file
@ -0,0 +1,261 @@
|
||||
import { log } from '../utils/logger';
|
||||
|
||||
/**
|
||||
* Client-side password encryption service
|
||||
* Uses Web Crypto API with AES-256-GCM algorithm
|
||||
*/
|
||||
class EncryptionService {
|
||||
private encryptionKey: CryptoKey | null = null;
|
||||
|
||||
/**
|
||||
* Initialize encryption key
|
||||
* @param keyBase64 Base64 encoded key from server
|
||||
*/
|
||||
async initializeKey(keyBase64: string): Promise<void> {
|
||||
try {
|
||||
// this._keyBase64 = keyBase64;
|
||||
|
||||
// Decode Base64 key
|
||||
const keyBuffer = this.base64ToArrayBuffer(keyBase64);
|
||||
|
||||
// Import key for use with Web Crypto API
|
||||
this.encryptionKey = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
keyBuffer,
|
||||
{ name: 'AES-GCM' },
|
||||
false, // not exportable
|
||||
['encrypt', 'decrypt']
|
||||
);
|
||||
|
||||
log.info('encryption-service', 'Encryption key initialized successfully');
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Failed to initialize encryption key', { error });
|
||||
throw new Error('Failed to initialize encryption key');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Encrypt password using AES-GCM and AEAD
|
||||
* @param password Password in plain text
|
||||
* @param sessionId Session ID for AAD
|
||||
* @returns Encrypted password in Base64
|
||||
*/
|
||||
async encryptPassword(password: string, sessionId?: string): Promise<{encrypted: string, nonce: string, timestamp: number}> {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error('Encryption key not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// CRITICAL: Generate unique IV (12 bytes for GCM)
|
||||
const iv = crypto.getRandomValues(new Uint8Array(12));
|
||||
|
||||
// Convert password to ArrayBuffer
|
||||
const passwordBuffer = new TextEncoder().encode(password);
|
||||
|
||||
// AAD used ONLY for saved machines (when sessionId exists)
|
||||
// For temporary connection passwords AAD is NOT needed
|
||||
const encryptParams: AesGcmParams = {
|
||||
name: 'AES-GCM',
|
||||
iv: iv
|
||||
};
|
||||
|
||||
let timestamp = 0;
|
||||
let clientNonce: Uint8Array | undefined;
|
||||
|
||||
// If sessionId provided - use AAD for additional security
|
||||
if (sessionId) {
|
||||
timestamp = Date.now();
|
||||
clientNonce = crypto.getRandomValues(new Uint8Array(16));
|
||||
const aadData = {
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
clientNonce: Array.from(clientNonce)
|
||||
};
|
||||
encryptParams.additionalData = new TextEncoder().encode(JSON.stringify(aadData));
|
||||
}
|
||||
|
||||
// Encrypt
|
||||
const encryptedBuffer = await crypto.subtle.encrypt(
|
||||
encryptParams,
|
||||
this.encryptionKey,
|
||||
passwordBuffer
|
||||
);
|
||||
|
||||
// Combine IV + encrypted data
|
||||
const combined = new Uint8Array(iv.length + encryptedBuffer.byteLength);
|
||||
combined.set(iv, 0);
|
||||
combined.set(new Uint8Array(encryptedBuffer), iv.length);
|
||||
|
||||
// Convert to Base64
|
||||
const encrypted = this.arrayBufferToBase64(combined.buffer);
|
||||
const nonce = clientNonce ? this.arrayBufferToBase64(clientNonce.buffer as ArrayBuffer) : '';
|
||||
|
||||
log.debug('encryption-service', 'Password encrypted successfully', {
|
||||
passwordLength: password.length,
|
||||
encryptedLength: encrypted.length,
|
||||
sessionId: sessionId,
|
||||
usesAAD: !!sessionId
|
||||
});
|
||||
|
||||
return {
|
||||
encrypted,
|
||||
nonce,
|
||||
timestamp
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Failed to encrypt password', { error });
|
||||
throw new Error('Password encryption failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Decrypt password (for testing)
|
||||
* @param encryptedPassword Base64 encrypted string
|
||||
* @param sessionId Session ID for AAD (optional - for testing)
|
||||
* @param nonce Client nonce for AAD (optional - for testing)
|
||||
* @param timestamp Timestamp for AAD (optional - for testing)
|
||||
* @returns Password in plain text
|
||||
*/
|
||||
async decryptPassword(
|
||||
encryptedPassword: string,
|
||||
sessionId?: string,
|
||||
nonce?: string,
|
||||
timestamp?: number
|
||||
): Promise<string> {
|
||||
if (!this.encryptionKey) {
|
||||
throw new Error('Encryption key not initialized');
|
||||
}
|
||||
|
||||
try {
|
||||
// Decode Base64
|
||||
const combined = this.base64ToArrayBuffer(encryptedPassword);
|
||||
|
||||
// Extract IV (first 12 bytes) and encrypted data
|
||||
const iv = combined.slice(0, 12);
|
||||
const encryptedData = combined.slice(12);
|
||||
|
||||
// FIXED: Create AAD if parameters provided
|
||||
let additionalData: Uint8Array | undefined = undefined;
|
||||
if (sessionId && nonce && timestamp) {
|
||||
const clientNonceArray = new Uint8Array(this.base64ToArrayBuffer(nonce));
|
||||
const aadData = {
|
||||
sessionId: sessionId,
|
||||
timestamp: timestamp,
|
||||
clientNonce: Array.from(clientNonceArray)
|
||||
};
|
||||
additionalData = new TextEncoder().encode(JSON.stringify(aadData));
|
||||
}
|
||||
|
||||
// Decrypt
|
||||
const decryptedBuffer = await crypto.subtle.decrypt(
|
||||
{
|
||||
name: 'AES-GCM',
|
||||
iv: iv,
|
||||
...(additionalData && { additionalData }) // Add AAD if exists
|
||||
},
|
||||
this.encryptionKey,
|
||||
encryptedData
|
||||
);
|
||||
|
||||
// Convert back to string
|
||||
const password = new TextDecoder().decode(decryptedBuffer);
|
||||
|
||||
log.debug('encryption-service', 'Password decrypted successfully');
|
||||
|
||||
return password;
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Failed to decrypt password', { error });
|
||||
throw new Error('Password decryption failed');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if encryption key is initialized
|
||||
*/
|
||||
isInitialized(): boolean {
|
||||
return this.encryptionKey !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current encryption status
|
||||
*/
|
||||
getStatus(): {
|
||||
initialized: boolean;
|
||||
} {
|
||||
return {
|
||||
initialized: this.isInitialized(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear encryption key
|
||||
*/
|
||||
clearKey(): void {
|
||||
this.encryptionKey = null;
|
||||
log.info('encryption-service', 'Encryption key cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert Base64 string to ArrayBuffer
|
||||
*/
|
||||
private base64ToArrayBuffer(base64: string): ArrayBuffer {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert ArrayBuffer to Base64 string
|
||||
*/
|
||||
private arrayBufferToBase64(buffer: ArrayBuffer): string {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
/**
|
||||
* Test encryption/decryption
|
||||
*/
|
||||
async testEncryption(testPassword: string = 'test123', sessionId: string = 'test-session'): Promise<boolean> {
|
||||
try {
|
||||
if (!this.isInitialized()) {
|
||||
throw new Error('Encryption key not initialized');
|
||||
}
|
||||
|
||||
// Encrypt test password
|
||||
const encryptionResult = await this.encryptPassword(testPassword, sessionId);
|
||||
|
||||
// FIXED: Decrypt with same AAD parameters
|
||||
const decrypted = await this.decryptPassword(
|
||||
encryptionResult.encrypted,
|
||||
sessionId,
|
||||
encryptionResult.nonce,
|
||||
encryptionResult.timestamp
|
||||
);
|
||||
|
||||
// Check that we got the same password
|
||||
const success = decrypted === testPassword;
|
||||
|
||||
log.info('encryption-service', 'Encryption test completed', {
|
||||
success,
|
||||
testPasswordLength: testPassword.length,
|
||||
encryptedLength: encryptionResult.encrypted.length
|
||||
});
|
||||
|
||||
return success;
|
||||
} catch (error) {
|
||||
log.error('encryption-service', 'Encryption test failed', { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Export singleton instance
|
||||
export const encryptionService = new EncryptionService();
|
||||
export default encryptionService;
|
||||
160
mc_test/src/renderer/services/SignatureVerificationService.ts
Executable file
160
mc_test/src/renderer/services/SignatureVerificationService.ts
Executable file
@ -0,0 +1,160 @@
|
||||
import { log } from '../utils/logger';
|
||||
import * as nacl from 'tweetnacl';
|
||||
|
||||
const ED25519_PUBLIC_KEY_SIZE = 32;
|
||||
const KEY_PREVIEW_LENGTH = 50;
|
||||
|
||||
const PEM_HEADER = {
|
||||
BEGIN: '-----BEGIN PUBLIC KEY-----',
|
||||
END: '-----END PUBLIC KEY-----',
|
||||
} as const;
|
||||
|
||||
type Environment = 'production' | 'staging' | 'development';
|
||||
|
||||
/**
|
||||
* Service for verifying Ed25519 signatures of server keys
|
||||
* Uses TweetNaCl for cross-browser compatibility
|
||||
*/
|
||||
export class SignatureVerificationService {
|
||||
private static readonly TRUSTED_SIGNING_KEYS: Record<Environment, string> = {
|
||||
production:
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
|
||||
staging:
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
|
||||
development:
|
||||
'LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQUU5TEUwQ2t2SGRGcFRGVHZORkVLbkZqMFJZQkc1dVYrQ0F0TkJVR2ZSUHM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=',
|
||||
};
|
||||
|
||||
/**
|
||||
* Verify Ed25519 signature of server public key using TweetNaCl
|
||||
* @param serverPublicKeyPem PEM-encoded server public key
|
||||
* @param signatureBase64 Base64-encoded signature
|
||||
* @param environment Environment (production, staging, development)
|
||||
* @returns true if signature is valid, false otherwise
|
||||
*/
|
||||
static async verifyServerKeySignature(
|
||||
serverPublicKeyPem: string,
|
||||
signatureBase64: string,
|
||||
environment: Environment = 'production'
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
log.info('signature-verification', 'Starting server key signature verification (TweetNaCl)', {
|
||||
environment,
|
||||
serverKeyLength: serverPublicKeyPem.length,
|
||||
signatureLength: signatureBase64.length,
|
||||
});
|
||||
|
||||
const trustedSigningKeyPem = this.TRUSTED_SIGNING_KEYS[environment];
|
||||
if (!trustedSigningKeyPem) {
|
||||
log.error('signature-verification', 'No trusted signing key found for environment', {
|
||||
environment,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
const trustedSigningKey = this.extractEd25519PublicKeyFromSPKI(trustedSigningKeyPem);
|
||||
const signature = this.base64ToUint8Array(signatureBase64);
|
||||
|
||||
// CRITICAL: Server sends base64(PEM), need to decode
|
||||
const serverPublicKeyPemDecoded = atob(serverPublicKeyPem);
|
||||
|
||||
// IMPORTANT: Server signs PEM bytes (with headers)
|
||||
// So we use the same PEM bytes for verification (not DER)
|
||||
const serverPublicKeyBytes = new TextEncoder().encode(serverPublicKeyPemDecoded);
|
||||
|
||||
log.debug('signature-verification', 'Verification data prepared', {
|
||||
trustedKeyLength: trustedSigningKey.length,
|
||||
signatureLength: signature.length,
|
||||
messageLength: serverPublicKeyBytes.length,
|
||||
});
|
||||
|
||||
const isValid = nacl.sign.detached.verify(
|
||||
serverPublicKeyBytes,
|
||||
signature,
|
||||
trustedSigningKey
|
||||
);
|
||||
|
||||
if (isValid) {
|
||||
log.info('signature-verification', 'Server key signature verification successful');
|
||||
} else {
|
||||
log.warn('signature-verification', 'Server key signature verification failed');
|
||||
}
|
||||
|
||||
return isValid;
|
||||
} catch (error) {
|
||||
log.error('signature-verification', 'Server key signature verification error', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
environment,
|
||||
});
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract raw Ed25519 public key (32 bytes) from base64(PEM) SPKI format
|
||||
*
|
||||
* SPKI format for Ed25519:
|
||||
* - Total size: ~44 bytes
|
||||
* - Last 32 bytes: raw Ed25519 public key
|
||||
*
|
||||
* @param pemKeyBase64 Base64-encoded PEM string
|
||||
* @returns Uint8Array (32 bytes) - raw Ed25519 public key
|
||||
*/
|
||||
private static extractEd25519PublicKeyFromSPKI(pemKeyBase64: string): Uint8Array {
|
||||
try {
|
||||
const pemString = atob(pemKeyBase64);
|
||||
|
||||
log.debug('signature-verification', 'Decoded PEM from base64', {
|
||||
pemLength: pemString.length,
|
||||
hasPemHeaders: pemString.includes(PEM_HEADER.BEGIN),
|
||||
});
|
||||
|
||||
const pemContent = pemString
|
||||
.replace(new RegExp(PEM_HEADER.BEGIN, 'g'), '')
|
||||
.replace(new RegExp(PEM_HEADER.END, 'g'), '')
|
||||
.replace(/\s/g, '');
|
||||
|
||||
const spkiBytes = this.base64ToUint8Array(pemContent);
|
||||
|
||||
log.debug('signature-verification', 'Extracted SPKI bytes', {
|
||||
spkiLength: spkiBytes.length,
|
||||
});
|
||||
|
||||
// SPKI contains headers (~12 bytes) + raw key (32 bytes)
|
||||
if (spkiBytes.length < ED25519_PUBLIC_KEY_SIZE) {
|
||||
throw new Error(
|
||||
`SPKI too short: ${spkiBytes.length} bytes (expected >=${ED25519_PUBLIC_KEY_SIZE})`
|
||||
);
|
||||
}
|
||||
|
||||
const rawPublicKey = spkiBytes.slice(spkiBytes.length - ED25519_PUBLIC_KEY_SIZE);
|
||||
|
||||
log.info('signature-verification', 'Ed25519 raw public key extracted', {
|
||||
rawKeyLength: rawPublicKey.length,
|
||||
});
|
||||
|
||||
return rawPublicKey;
|
||||
} catch (error) {
|
||||
log.error('signature-verification', 'Failed to extract Ed25519 public key from SPKI', {
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
keyLength: pemKeyBase64.length,
|
||||
keyPreview: pemKeyBase64.substring(0, KEY_PREVIEW_LENGTH) + '...',
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert base64 to Uint8Array
|
||||
*/
|
||||
private static base64ToUint8Array(base64: string): Uint8Array {
|
||||
const binaryString = atob(base64);
|
||||
const bytes = new Uint8Array(binaryString.length);
|
||||
for (let i = 0; i < binaryString.length; i++) {
|
||||
bytes[i] = binaryString.charCodeAt(i);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
}
|
||||
|
||||
export const signatureVerification = new SignatureVerificationService();
|
||||
45
mc_test/src/renderer/services/api/axios-config.ts
Executable file
45
mc_test/src/renderer/services/api/axios-config.ts
Executable file
@ -0,0 +1,45 @@
|
||||
import axios from 'axios';
|
||||
import { log } from '../../utils/logger';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: import.meta.env.VITE_GUACAMOLE_BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
// Request interceptor
|
||||
api.interceptors.request.use(
|
||||
(config) => {
|
||||
log.info('guacamole-api', `Request: ${config.method?.toUpperCase()} ${config.url}`, {
|
||||
headers: config.headers,
|
||||
params: config.params,
|
||||
data: config.data,
|
||||
});
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
log.error('guacamole-api', 'Request error:', { error: error.message });
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// Response interceptor
|
||||
api.interceptors.response.use(
|
||||
(response) => {
|
||||
log.info('guacamole-api', `Response: ${response.status} ${response.config.url}`, {
|
||||
data: response.data,
|
||||
});
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
log.error('guacamole-api', 'Response error:', {
|
||||
status: error.response?.status,
|
||||
data: error.response?.data,
|
||||
message: error.message,
|
||||
});
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default api;
|
||||
377
mc_test/src/renderer/services/auth-service.ts
Executable file
377
mc_test/src/renderer/services/auth-service.ts
Executable file
@ -0,0 +1,377 @@
|
||||
import { log } from '../utils/logger';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import { ApiClient } from '../utils/apiClient';
|
||||
|
||||
interface LoginResponse {
|
||||
access_token: string;
|
||||
token_type: string;
|
||||
expires_in: number;
|
||||
user_info: {
|
||||
username: string;
|
||||
role: string;
|
||||
permissions: string[];
|
||||
full_name?: string;
|
||||
email?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface StoredAuth {
|
||||
token: string;
|
||||
expiresAt: number;
|
||||
userInfo: LoginResponse['user_info'];
|
||||
}
|
||||
|
||||
class AuthService {
|
||||
private static readonly AUTH_KEY = 'mcc_auth';
|
||||
private tokenCheckInterval: NodeJS.Timeout | null = null;
|
||||
private onTokenExpiredCallback: (() => void) | null = null;
|
||||
private onTokenWarningCallback: ((timeLeft: number) => void) | null = null;
|
||||
|
||||
// Cache for synchronous access
|
||||
private currentToken: string | null = null;
|
||||
private currentUser: StoredAuth['userInfo'] | null = null;
|
||||
|
||||
constructor() {
|
||||
// Restore auth on startup
|
||||
this.initializeAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize auth on application startup
|
||||
* Restores token and user from safeStorage
|
||||
*/
|
||||
private async initializeAuth() {
|
||||
try {
|
||||
const stored = await this.getStoredAuth();
|
||||
if (stored && Date.now() < stored.expiresAt) {
|
||||
// CRITICAL CHECK: JWT must start with "eyJ" (base64-encoded JSON header)
|
||||
// If token is invalid (encrypted blob from old version), clear storage
|
||||
if (!stored.token || !stored.token.startsWith('eyJ')) {
|
||||
log.error('auth-service', 'Invalid token format in storage, clearing auth', {
|
||||
tokenPrefix: stored.token?.substring(0, 10) || 'empty',
|
||||
tokenLength: stored.token?.length || 0
|
||||
});
|
||||
this.clearAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
this.currentToken = stored.token;
|
||||
this.currentUser = stored.userInfo;
|
||||
|
||||
// Start token monitoring
|
||||
this.startTokenMonitoring();
|
||||
|
||||
log.info('auth-service', 'Auth restored from storage', {
|
||||
username: stored.userInfo.username,
|
||||
tokenPrefix: stored.token.substring(0, 20) + '...'
|
||||
});
|
||||
} else {
|
||||
log.debug('auth-service', 'No valid stored auth found');
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Failed to initialize auth', { error });
|
||||
this.clearAuth();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* User authentication
|
||||
*/
|
||||
async login(username: string, password: string): Promise<LoginResponse> {
|
||||
try {
|
||||
log.info('auth-service', 'Starting login', { username });
|
||||
|
||||
this.clearAuth();
|
||||
log.info('auth-service', 'Previous session cleared before new login');
|
||||
|
||||
const data: LoginResponse = await ApiClient.post(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.LOGIN}`,
|
||||
{ username, password },
|
||||
{
|
||||
timeout: 10000,
|
||||
retryConfig: { maxRetries: 2 }
|
||||
}
|
||||
);
|
||||
|
||||
await this.saveAuth(data);
|
||||
this.startTokenMonitoring();
|
||||
|
||||
log.info('auth-service', 'Login successful', {
|
||||
username: data.user_info.username,
|
||||
role: data.user_info.role
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Login failed', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Logout
|
||||
*/
|
||||
async logout(): Promise<void> {
|
||||
try {
|
||||
// Use cached token
|
||||
if (this.currentToken) {
|
||||
// Use new endpoint for token revocation
|
||||
await ApiClient.post(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.AUTH.REVOKE}`,
|
||||
{},
|
||||
ApiClient.withAuth(this.currentToken, {
|
||||
timeout: 5000, // Short timeout for logout
|
||||
retryConfig: { maxRetries: 1 } // Minimum retry for logout
|
||||
})
|
||||
).catch(() => {
|
||||
// Ignore errors on logout
|
||||
});
|
||||
|
||||
log.info('auth-service', 'Token revoked on server');
|
||||
}
|
||||
} finally {
|
||||
// Always clear local storage
|
||||
this.clearAuth();
|
||||
this.stopTokenMonitoring();
|
||||
|
||||
log.info('auth-service', 'Logged out');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current token
|
||||
* FIXED: async for safeStorage support
|
||||
*/
|
||||
async getToken(): Promise<string | null> {
|
||||
const stored = await this.getStoredAuth();
|
||||
if (!stored) return null;
|
||||
|
||||
// Check if token expired
|
||||
if (Date.now() >= stored.expiresAt) {
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
|
||||
return stored.token;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current user info (SYNCHRONOUS)
|
||||
* Used for UI - fast check without async
|
||||
*/
|
||||
getCurrentUser(): StoredAuth['userInfo'] | null {
|
||||
// Check memory cache
|
||||
if (this.currentUser) {
|
||||
return this.currentUser;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is authenticated (SYNCHRONOUS)
|
||||
* Used for UI - fast check without async
|
||||
*/
|
||||
isAuthenticated(): boolean {
|
||||
return this.currentUser !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers for authenticated requests (SYNCHRONOUS)
|
||||
* Used for API requests
|
||||
*/
|
||||
getAuthHeaders(): Record<string, string> {
|
||||
if (!this.currentToken) {
|
||||
log.warn('auth-service', 'No token available for auth headers');
|
||||
return {};
|
||||
}
|
||||
|
||||
// Check that token is valid before sending
|
||||
if (!this.currentToken.startsWith('eyJ')) {
|
||||
log.error('auth-service', 'Current token is invalid (not a JWT), clearing auth', {
|
||||
tokenPrefix: this.currentToken.substring(0, 10),
|
||||
tokenLength: this.currentToken.length
|
||||
});
|
||||
this.clearAuth();
|
||||
return {};
|
||||
}
|
||||
|
||||
log.debug('auth-service', 'Providing auth headers', {
|
||||
tokenPrefix: this.currentToken.substring(0, 30) + '...'
|
||||
});
|
||||
|
||||
return {
|
||||
'Authorization': `Bearer ${this.currentToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Save authentication data
|
||||
* FIXED: Uses Electron safeStorage for XSS protection
|
||||
*/
|
||||
private async saveAuth(loginResponse: LoginResponse): Promise<void> {
|
||||
// Validate JWT token before saving
|
||||
if (!loginResponse.access_token || !loginResponse.access_token.startsWith('eyJ')) {
|
||||
log.error('auth-service', 'Received invalid JWT token from API', {
|
||||
tokenPrefix: loginResponse.access_token?.substring(0, 10) || 'empty'
|
||||
});
|
||||
throw new Error('Invalid JWT token received from server');
|
||||
}
|
||||
|
||||
const auth: StoredAuth = {
|
||||
token: loginResponse.access_token,
|
||||
expiresAt: Date.now() + (loginResponse.expires_in * 1000),
|
||||
userInfo: loginResponse.user_info,
|
||||
};
|
||||
|
||||
try {
|
||||
// Check Electron API availability (for dev vs prod)
|
||||
const hasElectronAPI = typeof window !== 'undefined' &&
|
||||
window.electronAPI !== undefined &&
|
||||
typeof window.electronAPI.isEncryptionAvailable === 'function';
|
||||
|
||||
if (hasElectronAPI) {
|
||||
// Check safeStorage availability
|
||||
const isAvailable = await window.electronAPI.isEncryptionAvailable();
|
||||
|
||||
if (isAvailable) {
|
||||
// SECURED: Use Electron safeStorage
|
||||
const authJson = JSON.stringify(auth);
|
||||
const encrypted = await window.electronAPI.encryptData(authJson);
|
||||
localStorage.setItem(AuthService.AUTH_KEY, encrypted);
|
||||
log.info('auth-service', 'Auth data saved securely with safeStorage');
|
||||
} else {
|
||||
// Fallback for platforms without safeStorage
|
||||
log.warn('auth-service', 'safeStorage not available, using localStorage (less secure)');
|
||||
localStorage.setItem(AuthService.AUTH_KEY, JSON.stringify(auth));
|
||||
}
|
||||
} else {
|
||||
// Dev mode: no Electron API, use regular localStorage
|
||||
log.info('auth-service', 'Running in dev mode, using localStorage');
|
||||
localStorage.setItem(AuthService.AUTH_KEY, JSON.stringify(auth));
|
||||
}
|
||||
|
||||
// Update cache for synchronous access
|
||||
this.currentToken = auth.token;
|
||||
this.currentUser = auth.userInfo;
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Failed to save auth data', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get stored authentication data
|
||||
* FIXED: Decrypts data from safeStorage
|
||||
*/
|
||||
private async getStoredAuth(): Promise<StoredAuth | null> {
|
||||
try {
|
||||
const stored = localStorage.getItem(AuthService.AUTH_KEY);
|
||||
if (!stored) return null;
|
||||
|
||||
// Check Electron API availability
|
||||
const hasElectronAPI = typeof window !== 'undefined' &&
|
||||
window.electronAPI !== undefined &&
|
||||
typeof window.electronAPI.decryptData === 'function';
|
||||
|
||||
// FIXED: Check if data is encrypted by first character
|
||||
// Plain JSON always starts with '{', encrypted data does not
|
||||
const isPlainJSON = stored.trim().startsWith('{');
|
||||
|
||||
if (!isPlainJSON && hasElectronAPI) {
|
||||
try {
|
||||
// Data is encrypted - decrypt via safeStorage
|
||||
log.debug('auth-service', 'Decrypting stored auth with safeStorage');
|
||||
const decrypted = await window.electronAPI.decryptData(stored);
|
||||
return JSON.parse(decrypted);
|
||||
} catch (decryptError) {
|
||||
// Possibly corrupted data
|
||||
log.error('auth-service', 'Failed to decrypt stored auth, clearing storage', { decryptError });
|
||||
this.clearAuth();
|
||||
return null;
|
||||
}
|
||||
} else {
|
||||
// Unencrypted data (dev mode, old format or fallback)
|
||||
log.debug('auth-service', 'Loading plain JSON auth from storage');
|
||||
return JSON.parse(stored);
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('auth-service', 'Failed to parse stored auth', { error });
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear authentication data
|
||||
*/
|
||||
private clearAuth(): void {
|
||||
localStorage.removeItem(AuthService.AUTH_KEY);
|
||||
|
||||
// Clear cache
|
||||
this.currentToken = null;
|
||||
this.currentUser = null;
|
||||
|
||||
log.info('auth-service', 'Auth data cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for token expiration handling
|
||||
*/
|
||||
setOnTokenExpired(callback: () => void): void {
|
||||
this.onTokenExpiredCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set callback for token expiration warning
|
||||
*/
|
||||
setOnTokenWarning(callback: (timeLeft: number) => void): void {
|
||||
this.onTokenWarningCallback = callback;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start token monitoring
|
||||
*/
|
||||
private startTokenMonitoring(): void {
|
||||
this.stopTokenMonitoring();
|
||||
|
||||
// FIXED: Check token every 30 seconds with async getStoredAuth support
|
||||
this.tokenCheckInterval = setInterval(async () => {
|
||||
const stored = await this.getStoredAuth();
|
||||
if (!stored) return;
|
||||
|
||||
// If token expires within next 5 minutes, notify
|
||||
const timeUntilExpiry = stored.expiresAt - Date.now();
|
||||
const fiveMinutes = 5 * 60 * 1000;
|
||||
|
||||
if (timeUntilExpiry <= fiveMinutes) {
|
||||
log.warn('auth-service', 'Token expires soon', {
|
||||
timeUntilExpiry: Math.round(timeUntilExpiry / 1000)
|
||||
});
|
||||
|
||||
if (timeUntilExpiry <= 0) {
|
||||
log.error('auth-service', 'Token expired, logging out');
|
||||
this.clearAuth();
|
||||
this.stopTokenMonitoring();
|
||||
|
||||
if (this.onTokenExpiredCallback) {
|
||||
this.onTokenExpiredCallback();
|
||||
}
|
||||
} else if (this.onTokenWarningCallback) {
|
||||
this.onTokenWarningCallback(Math.round(timeUntilExpiry / 1000));
|
||||
}
|
||||
}
|
||||
}, 30000); // Check every 30 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop token monitoring
|
||||
*/
|
||||
private stopTokenMonitoring(): void {
|
||||
if (this.tokenCheckInterval) {
|
||||
clearInterval(this.tokenCheckInterval);
|
||||
this.tokenCheckInterval = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
export default authService;
|
||||
232
mc_test/src/renderer/services/bulk-operations-api.ts
Executable file
232
mc_test/src/renderer/services/bulk-operations-api.ts
Executable file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Bulk Operations API Service
|
||||
*
|
||||
* Handles mass operations on multiple machines:
|
||||
* - Health checks
|
||||
* - SSH command execution (future)
|
||||
* - Multi-connect (future)
|
||||
*/
|
||||
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import { authService } from './auth-service';
|
||||
|
||||
// ========================================================================================
|
||||
// Constants
|
||||
// ========================================================================================
|
||||
|
||||
const DEFAULT_TIMEOUT = {
|
||||
HEALTH_CHECK: 5, // seconds
|
||||
SSH_COMMAND: 30, // seconds
|
||||
} as const;
|
||||
|
||||
const HTTP_STATUS = {
|
||||
FORBIDDEN: 403,
|
||||
} as const;
|
||||
|
||||
const ROLE_LIMITS = {
|
||||
GUEST: { maxHealthCheck: 10, maxSSHCommand: 0 },
|
||||
USER: { maxHealthCheck: 50, maxSSHCommand: 20 },
|
||||
ADMIN: { maxHealthCheck: 200, maxSSHCommand: 100 },
|
||||
SUPER_ADMIN: { maxHealthCheck: 200, maxSSHCommand: 100 },
|
||||
} as const;
|
||||
|
||||
const DEFAULT_CHECK_PORT = true;
|
||||
|
||||
// ========================================================================================
|
||||
// Types
|
||||
// ========================================================================================
|
||||
|
||||
export type HealthCheckStatus = 'success' | 'failed' | 'timeout';
|
||||
export type SSHCommandStatus = 'success' | 'failed' | 'timeout' | 'no_credentials';
|
||||
export type CredentialsMode = 'global' | 'custom';
|
||||
export type UserRole = keyof typeof ROLE_LIMITS;
|
||||
|
||||
export interface BulkHealthCheckRequest {
|
||||
machine_ids: string[];
|
||||
timeout?: number;
|
||||
check_port?: boolean;
|
||||
}
|
||||
|
||||
export interface BulkHealthCheckResult {
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
hostname: string;
|
||||
status: HealthCheckStatus;
|
||||
available: boolean;
|
||||
response_time_ms?: number;
|
||||
error?: string;
|
||||
checked_at: string;
|
||||
}
|
||||
|
||||
export interface BulkHealthCheckResponse {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
available: number;
|
||||
unavailable: number;
|
||||
results: BulkHealthCheckResult[];
|
||||
execution_time_ms: number;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
// SSH Command Types
|
||||
export interface SSHCredentials {
|
||||
username: string;
|
||||
password: string;
|
||||
}
|
||||
|
||||
export interface BulkSSHCommandRequest {
|
||||
machine_ids: string[];
|
||||
machine_hostnames?: Record<string, string>; // For non-saved machines
|
||||
command: string;
|
||||
credentials_mode: CredentialsMode;
|
||||
global_credentials?: SSHCredentials;
|
||||
machine_credentials?: Record<string, SSHCredentials>;
|
||||
timeout?: number;
|
||||
}
|
||||
|
||||
export interface BulkSSHCommandResult {
|
||||
machine_id: string;
|
||||
machine_name: string;
|
||||
hostname: string;
|
||||
status: SSHCommandStatus;
|
||||
exit_code?: number;
|
||||
stdout?: string;
|
||||
stderr?: string;
|
||||
error?: string;
|
||||
execution_time_ms?: number;
|
||||
executed_at: string;
|
||||
}
|
||||
|
||||
export interface BulkSSHCommandResponse {
|
||||
total: number;
|
||||
success: number;
|
||||
failed: number;
|
||||
results: BulkSSHCommandResult[];
|
||||
execution_time_ms: number;
|
||||
command: string;
|
||||
started_at: string;
|
||||
completed_at: string;
|
||||
}
|
||||
|
||||
// ========================================================================================
|
||||
// API Service
|
||||
// ========================================================================================
|
||||
|
||||
class BulkOperationsApiService {
|
||||
|
||||
/**
|
||||
* Bulk machine availability check
|
||||
*
|
||||
* Role-based limits:
|
||||
* - GUEST: max 10 machines
|
||||
* - USER: max 50 machines
|
||||
* - ADMIN: max 200 machines
|
||||
*/
|
||||
async bulkHealthCheck(request: BulkHealthCheckRequest): Promise<BulkHealthCheckResponse> {
|
||||
try {
|
||||
const token = await authService.getToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.BULK.HEALTH_CHECK}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
machine_ids: request.machine_ids,
|
||||
timeout: request.timeout ?? DEFAULT_TIMEOUT.HEALTH_CHECK,
|
||||
check_port: request.check_port ?? DEFAULT_CHECK_PORT
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
|
||||
if (response.status === HTTP_STATUS.FORBIDDEN) {
|
||||
throw new Error(errorData.detail || 'Permission denied. Check role-based limits.');
|
||||
}
|
||||
|
||||
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BulkHealthCheckResponse = await response.json();
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to perform bulk health check');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Bulk SSH command execution
|
||||
*
|
||||
* Role-based limits:
|
||||
* - GUEST: forbidden
|
||||
* - USER: max 20 machines, whitelist commands only
|
||||
* - ADMIN: max 100 machines, any commands
|
||||
*/
|
||||
async bulkSSHCommand(request: BulkSSHCommandRequest): Promise<BulkSSHCommandResponse> {
|
||||
try {
|
||||
const token = await authService.getToken();
|
||||
|
||||
if (!token) {
|
||||
throw new Error('Authentication token not found. Please log in again.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.BULK.SSH_COMMAND}`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': `Bearer ${token}`
|
||||
},
|
||||
body: JSON.stringify({
|
||||
machine_ids: request.machine_ids,
|
||||
machine_hostnames: request.machine_hostnames,
|
||||
command: request.command,
|
||||
credentials_mode: request.credentials_mode,
|
||||
global_credentials: request.global_credentials,
|
||||
machine_credentials: request.machine_credentials,
|
||||
timeout: request.timeout ?? DEFAULT_TIMEOUT.SSH_COMMAND
|
||||
})
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({ detail: 'Unknown error' }));
|
||||
|
||||
if (response.status === HTTP_STATUS.FORBIDDEN) {
|
||||
throw new Error(errorData.detail || 'Permission denied. Check role and command whitelist.');
|
||||
}
|
||||
|
||||
throw new Error(errorData.detail || `Request failed with status ${response.status}`);
|
||||
}
|
||||
|
||||
const data: BulkSSHCommandResponse = await response.json();
|
||||
return data;
|
||||
|
||||
} catch (error) {
|
||||
if (error instanceof Error) {
|
||||
throw error;
|
||||
}
|
||||
throw new Error('Failed to execute bulk SSH command');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get role-based limit for bulk operations
|
||||
*/
|
||||
getRoleLimits(role: string): { maxHealthCheck: number; maxSSHCommand: number } {
|
||||
return ROLE_LIMITS[role as UserRole] || ROLE_LIMITS.GUEST;
|
||||
}
|
||||
}
|
||||
|
||||
export const bulkOperationsApi = new BulkOperationsApiService();
|
||||
|
||||
366
mc_test/src/renderer/services/guacamole-api.ts
Executable file
366
mc_test/src/renderer/services/guacamole-api.ts
Executable file
@ -0,0 +1,366 @@
|
||||
import { log } from '../utils/logger';
|
||||
import { authService } from './auth-service';
|
||||
import { Machine } from '../types';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
const DEFAULT_TTL_MINUTES = 60;
|
||||
const DEFAULT_EXTEND_MINUTES = 60;
|
||||
const MIN_REMAINING_MINUTES = 0;
|
||||
|
||||
const HTTP_STATUS = {
|
||||
UNAUTHORIZED: 401,
|
||||
FORBIDDEN: 403,
|
||||
NOT_FOUND: 404,
|
||||
} as const;
|
||||
|
||||
const SESSION_EXPIRED_PATTERNS = [
|
||||
'Session expired',
|
||||
'Invalid session',
|
||||
'encryption session has expired',
|
||||
] as const;
|
||||
|
||||
const ERROR_MESSAGES = {
|
||||
SESSION_EXPIRED: 'Your session has expired. Please log in again.',
|
||||
CONNECTION_NOT_FOUND: 'Connection not found or already expired',
|
||||
PERMISSION_DENIED: 'You do not have permission to extend this connection',
|
||||
} as const;
|
||||
|
||||
const PROTOCOL_SSH = 'ssh';
|
||||
const DEFAULT_PROTOCOL = 'rdp';
|
||||
|
||||
interface ConnectionRequest {
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
username?: string;
|
||||
password?: string;
|
||||
port?: number;
|
||||
ttl_minutes?: number;
|
||||
// SFTP parameters for SSH connections
|
||||
enable_sftp?: boolean;
|
||||
sftp_root_directory?: string;
|
||||
sftp_server_alive_interval?: number;
|
||||
}
|
||||
|
||||
interface ConnectionResponse {
|
||||
connection_id: string;
|
||||
connection_url: string;
|
||||
status: string;
|
||||
expires_at: string;
|
||||
ttl_minutes: number;
|
||||
}
|
||||
|
||||
interface ActiveConnection {
|
||||
connection_id: string;
|
||||
connection_url: string | null;
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
owner_username: string;
|
||||
owner_role: string;
|
||||
created_at: string;
|
||||
expires_at: string;
|
||||
ttl_minutes: number;
|
||||
remaining_minutes: number;
|
||||
status: 'active' | 'expired';
|
||||
}
|
||||
|
||||
interface ConnectionsListResponse {
|
||||
total_connections: number;
|
||||
active_connections: number;
|
||||
connections: ActiveConnection[];
|
||||
}
|
||||
|
||||
class GuacamoleService {
|
||||
|
||||
/**
|
||||
* Check if error detail indicates session expiry
|
||||
*/
|
||||
private isSessionExpiredError(errorDetail?: string): boolean {
|
||||
if (!errorDetail) return false;
|
||||
return SESSION_EXPIRED_PATTERNS.some(pattern =>
|
||||
errorDetail.includes(pattern)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 401 Unauthorized response
|
||||
* Checks for session expiry and logs out if needed
|
||||
*/
|
||||
private async handle401Error(response: Response): Promise<void> {
|
||||
if (response.status !== HTTP_STATUS.UNAUTHORIZED) {
|
||||
return;
|
||||
}
|
||||
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
if (this.isSessionExpiredError(errorData.detail)) {
|
||||
await authService.logout().catch(() => {});
|
||||
throw new Error(ERROR_MESSAGES.SESSION_EXPIRED);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create connection to machine
|
||||
*/
|
||||
async createConnection(
|
||||
machine: Machine,
|
||||
credentials?: {
|
||||
username: string;
|
||||
password: string;
|
||||
protocol?: string;
|
||||
enableSftp?: boolean;
|
||||
sftpRootDirectory?: string;
|
||||
sftpServerAliveInterval?: number;
|
||||
}
|
||||
): Promise<ConnectionResponse> {
|
||||
try {
|
||||
log.info('guacamole-service', 'Creating connection', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name,
|
||||
ipAddress: machine.ip,
|
||||
hypervisor: machine.hypervisor,
|
||||
hostnameToUse: machine.hypervisor === 'Saved' ? machine.ip : machine.name
|
||||
});
|
||||
|
||||
const protocol = credentials?.protocol || DEFAULT_PROTOCOL;
|
||||
|
||||
const request: ConnectionRequest = {
|
||||
hostname: machine.hypervisor === 'Saved' ? machine.ip : machine.name,
|
||||
protocol: protocol,
|
||||
ttl_minutes: DEFAULT_TTL_MINUTES,
|
||||
...(credentials?.username && { username: credentials.username }),
|
||||
...(credentials?.password && { password: credentials.password }),
|
||||
// SFTP parameters for SSH (enabled by default for SSH)
|
||||
...(protocol === PROTOCOL_SSH && {
|
||||
enable_sftp: credentials?.enableSftp !== undefined ? credentials.enableSftp : true,
|
||||
...(credentials?.sftpRootDirectory && { sftp_root_directory: credentials.sftpRootDirectory }),
|
||||
...(credentials?.sftpServerAliveInterval && { sftp_server_alive_interval: credentials.sftpServerAliveInterval })
|
||||
})
|
||||
};
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
// CSRF token NOT needed for JWT API
|
||||
// JWT Bearer tokens are not vulnerable to CSRF attacks
|
||||
|
||||
log.info('guacamole-service', 'Sending request', {
|
||||
url: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`,
|
||||
method: 'POST',
|
||||
headers: Object.keys(headers),
|
||||
hasAuthHeader: !!headers['Authorization'],
|
||||
requestBody: request
|
||||
});
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(request)
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
// Handle 401 Unauthorized (Session expired)
|
||||
if (response.status === HTTP_STATUS.UNAUTHORIZED) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
log.error('guacamole-service', 'Session expired or invalid', {
|
||||
errorDetail: errorData.detail,
|
||||
machineId: machine.id
|
||||
});
|
||||
await this.handle401Error(response);
|
||||
}
|
||||
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
|
||||
log.error('guacamole-service', 'API request failed', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
errorData,
|
||||
url: `${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.CREATE}`,
|
||||
requestBody: request
|
||||
});
|
||||
throw new Error(errorData.detail || `Failed to create connection: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: ConnectionResponse = await response.json();
|
||||
|
||||
log.info('guacamole-service', 'Connection created successfully', {
|
||||
machineId: machine.id,
|
||||
connectionId: data.connection_id,
|
||||
expiresAt: data.expires_at
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to create connection', {
|
||||
machineId: machine.id,
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of active connections
|
||||
*/
|
||||
async listConnections(): Promise<any[]> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.LIST}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
throw new Error(`Failed to list connections: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const connections = await response.json();
|
||||
|
||||
log.info('guacamole-service', 'Connections list retrieved', {
|
||||
count: connections.length
|
||||
});
|
||||
|
||||
return connections;
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to list connections', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get active connections for restoration
|
||||
* Returns only connections with 'active' status and valid connection_url
|
||||
*/
|
||||
async getActiveConnections(): Promise<ActiveConnection[]> {
|
||||
try {
|
||||
log.info('guacamole-service', 'Fetching active connections for restoration');
|
||||
|
||||
const headers: Record<string, string> = {
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.LIST}`, {
|
||||
method: 'GET',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
throw new Error(`Failed to get active connections: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data: ConnectionsListResponse = await response.json();
|
||||
|
||||
// Filter only active connections with valid connection_url
|
||||
const activeConnections = data.connections.filter(
|
||||
conn => conn.status === 'active' &&
|
||||
conn.connection_url !== null &&
|
||||
conn.remaining_minutes > MIN_REMAINING_MINUTES
|
||||
);
|
||||
|
||||
log.info('guacamole-service', 'Active connections retrieved', {
|
||||
total: data.total_connections,
|
||||
active: activeConnections.length,
|
||||
restorable: activeConnections.filter(c => c.connection_url).length
|
||||
});
|
||||
|
||||
return activeConnections;
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to get active connections', {
|
||||
error: error instanceof Error ? error.message : 'Unknown error'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete connection
|
||||
*/
|
||||
async deleteConnection(connectionId: string): Promise<void> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.DELETE(connectionId)}`, {
|
||||
method: 'DELETE',
|
||||
headers
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
throw new Error(`Failed to delete connection: ${response.statusText}`);
|
||||
}
|
||||
|
||||
log.info('guacamole-service', 'Connection deleted', { connectionId });
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to delete connection', { connectionId, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend TTL of active connection
|
||||
*/
|
||||
async extendConnectionTTL(connectionId: string, additionalMinutes: number = DEFAULT_EXTEND_MINUTES): Promise<{ new_expires_at: string; additional_minutes: number }> {
|
||||
try {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
...authService.getAuthHeaders()
|
||||
};
|
||||
|
||||
const response = await fetch(`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.CONNECTIONS.EXTEND(connectionId)}`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ additional_minutes: additionalMinutes })
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
await this.handle401Error(response);
|
||||
|
||||
if (response.status === HTTP_STATUS.NOT_FOUND) {
|
||||
throw new Error(ERROR_MESSAGES.CONNECTION_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (response.status === HTTP_STATUS.FORBIDDEN) {
|
||||
throw new Error(ERROR_MESSAGES.PERMISSION_DENIED);
|
||||
}
|
||||
|
||||
throw new Error(`Failed to extend connection: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
log.info('guacamole-service', 'Connection TTL extended', {
|
||||
connectionId,
|
||||
additionalMinutes,
|
||||
newExpiresAt: data.new_expires_at
|
||||
});
|
||||
|
||||
return {
|
||||
new_expires_at: data.new_expires_at,
|
||||
additional_minutes: data.additional_minutes
|
||||
};
|
||||
} catch (error) {
|
||||
log.error('guacamole-service', 'Failed to extend connection TTL', { connectionId, additionalMinutes, error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection URL
|
||||
* This method now simply returns the URL from API response
|
||||
*/
|
||||
getConnectionUrl(connectionUrl: string): string {
|
||||
return connectionUrl;
|
||||
}
|
||||
}
|
||||
|
||||
export const guacamoleService = new GuacamoleService();
|
||||
export default guacamoleService;
|
||||
export type { ActiveConnection, ConnectionResponse, ConnectionsListResponse };
|
||||
176
mc_test/src/renderer/services/machine-availability.ts
Executable file
176
mc_test/src/renderer/services/machine-availability.ts
Executable file
@ -0,0 +1,176 @@
|
||||
import { API_CONFIG } from '../config/api';
|
||||
import { authService } from './auth-service';
|
||||
import { log } from '../utils/logger';
|
||||
import type { Machine } from '../types';
|
||||
|
||||
/**
|
||||
* Result of machine availability check
|
||||
*/
|
||||
export interface MachineAvailabilityResult {
|
||||
available: boolean;
|
||||
hostname: string;
|
||||
port: number;
|
||||
responseTimeMs?: number;
|
||||
checkedAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request body for availability check
|
||||
*/
|
||||
interface AvailabilityCheckRequest {
|
||||
hostname: string;
|
||||
port: number;
|
||||
}
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
RDP: 3389,
|
||||
SSH: 22,
|
||||
} as const;
|
||||
|
||||
const OS_IDENTIFIER = {
|
||||
WINDOWS: 'windows',
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Service for checking machine availability
|
||||
*
|
||||
* Performs machine availability checks via API server
|
||||
* to avoid network restrictions on the client side.
|
||||
*/
|
||||
export class MachineAvailabilityService {
|
||||
/**
|
||||
* Determine default port based on machine OS
|
||||
*
|
||||
* Simple logic: Windows → RDP (3389), everything else → SSH (22)
|
||||
*/
|
||||
private static getDefaultPort(machine: Machine): number {
|
||||
const os = machine.os.toLowerCase();
|
||||
|
||||
if (os.includes(OS_IDENTIFIER.WINDOWS)) {
|
||||
return DEFAULT_PORTS.RDP;
|
||||
}
|
||||
|
||||
return DEFAULT_PORTS.SSH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an unavailable result object
|
||||
*/
|
||||
private static createUnavailableResult(
|
||||
hostname: string,
|
||||
port: number
|
||||
): MachineAvailabilityResult {
|
||||
return {
|
||||
available: false,
|
||||
hostname,
|
||||
port,
|
||||
checkedAt: new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check machine availability
|
||||
*
|
||||
* @param machine - Machine to check
|
||||
* @param port - Port to check (optional, determined by OS)
|
||||
* @returns Availability check result
|
||||
*/
|
||||
static async checkAvailability(
|
||||
machine: Machine,
|
||||
port?: number
|
||||
): Promise<MachineAvailabilityResult> {
|
||||
try {
|
||||
const targetPort = port || this.getDefaultPort(machine);
|
||||
|
||||
log.info('machine-availability', 'Checking machine availability', {
|
||||
machineId: machine.id,
|
||||
hostname: machine.name,
|
||||
os: machine.os,
|
||||
port: targetPort,
|
||||
portSource: port ? 'explicit' : 'auto-detected',
|
||||
});
|
||||
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
const requestBody: AvailabilityCheckRequest = {
|
||||
hostname: machine.name,
|
||||
port: targetPort,
|
||||
};
|
||||
|
||||
const response = await fetch(
|
||||
`${API_CONFIG.BASE_URL}${API_CONFIG.ENDPOINTS.MACHINES.CHECK_AVAILABILITY}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...headers,
|
||||
},
|
||||
body: JSON.stringify(requestBody),
|
||||
}
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text().catch(() => 'Unknown error');
|
||||
log.error('machine-availability', 'Check failed', {
|
||||
hostname: machine.name,
|
||||
status: response.status,
|
||||
error: errorText,
|
||||
});
|
||||
|
||||
return this.createUnavailableResult(machine.name, targetPort);
|
||||
}
|
||||
|
||||
const data: MachineAvailabilityResult = await response.json();
|
||||
|
||||
log.info('machine-availability', 'Check completed', {
|
||||
machineId: machine.id,
|
||||
hostname: data.hostname,
|
||||
available: data.available,
|
||||
responseTimeMs: data.responseTimeMs,
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
const targetPort = port || this.getDefaultPort(machine);
|
||||
|
||||
log.error('machine-availability', 'Check failed with exception', {
|
||||
hostname: machine.name,
|
||||
error,
|
||||
});
|
||||
|
||||
return this.createUnavailableResult(machine.name, targetPort);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check availability of multiple machines simultaneously
|
||||
*
|
||||
* @param machines - Array of machines to check
|
||||
* @param port - Port to check (optional)
|
||||
* @returns Map with check results (machineId -> result)
|
||||
*/
|
||||
static async checkMultiple(
|
||||
machines: Machine[],
|
||||
port?: number
|
||||
): Promise<Map<string, MachineAvailabilityResult>> {
|
||||
const results = new Map<string, MachineAvailabilityResult>();
|
||||
|
||||
const promises = machines.map(async (machine) => {
|
||||
const result = await this.checkAvailability(machine, port);
|
||||
return { machineId: machine.id, result };
|
||||
});
|
||||
|
||||
const completed = await Promise.allSettled(promises);
|
||||
|
||||
completed.forEach((promise) => {
|
||||
if (promise.status === 'fulfilled') {
|
||||
results.set(promise.value.machineId, promise.value.result);
|
||||
}
|
||||
});
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
|
||||
export const machineAvailabilityService = MachineAvailabilityService;
|
||||
|
||||
320
mc_test/src/renderer/services/saved-machines-api.ts
Executable file
320
mc_test/src/renderer/services/saved-machines-api.ts
Executable file
@ -0,0 +1,320 @@
|
||||
/**
|
||||
* API Service for working with saved machines
|
||||
*/
|
||||
|
||||
import { ApiClient } from '../utils/apiClient';
|
||||
import { authService } from './auth-service';
|
||||
import { log } from '../utils/logger';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
export type Protocol = 'rdp' | 'ssh' | 'vnc' | 'telnet';
|
||||
|
||||
export interface ConnectionStats {
|
||||
total_connections: number;
|
||||
last_connection?: string;
|
||||
successful_connections: number;
|
||||
failed_connections: number;
|
||||
}
|
||||
|
||||
export interface ConnectionRecordResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
history_id: string;
|
||||
}
|
||||
|
||||
export interface SavedMachineCreate {
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: Protocol;
|
||||
os?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedMachineUpdate {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
port?: number;
|
||||
protocol?: Protocol;
|
||||
os?: string;
|
||||
description?: string;
|
||||
tags?: string[];
|
||||
is_favorite?: boolean;
|
||||
}
|
||||
|
||||
export interface SavedMachine {
|
||||
id: string;
|
||||
user_id: string;
|
||||
name: string;
|
||||
hostname: string;
|
||||
port: number;
|
||||
protocol: Protocol;
|
||||
os?: string;
|
||||
description?: string;
|
||||
tags: string[];
|
||||
is_favorite: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
last_connected_at?: string;
|
||||
connection_stats?: ConnectionStats;
|
||||
}
|
||||
|
||||
export interface SavedMachineList {
|
||||
total: number;
|
||||
machines: SavedMachine[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Service for working with saved machines
|
||||
*/
|
||||
export class SavedMachinesApiService {
|
||||
private static baseUrl = `${API_CONFIG.BASE_URL}/api/machines/saved`;
|
||||
|
||||
/**
|
||||
* Get list of all user's saved machines
|
||||
*/
|
||||
static async getSavedMachines(includeStats: boolean = false): Promise<SavedMachineList> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Fetching saved machines', { includeStats });
|
||||
|
||||
const response = await ApiClient.get<SavedMachineList>(
|
||||
`${this.baseUrl}?include_stats=${includeStats}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machines fetched successfully', {
|
||||
total: response.total
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to fetch saved machines', { error });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get specific machine by ID
|
||||
*/
|
||||
static async getSavedMachine(machineId: string): Promise<SavedMachine> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Fetching saved machine', { machineId });
|
||||
|
||||
const response = await ApiClient.get<SavedMachine>(
|
||||
`${this.baseUrl}/${machineId}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine fetched successfully', {
|
||||
machineId,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to fetch saved machine', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create new saved machine
|
||||
*/
|
||||
static async createSavedMachine(machine: SavedMachineCreate): Promise<SavedMachine> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Creating saved machine', {
|
||||
name: machine.name,
|
||||
hostname: machine.hostname,
|
||||
protocol: machine.protocol
|
||||
});
|
||||
|
||||
const response = await ApiClient.post<SavedMachine>(
|
||||
this.baseUrl,
|
||||
machine,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine created successfully', {
|
||||
machineId: response.id,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to create saved machine', {
|
||||
name: machine.name,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update saved machine
|
||||
*/
|
||||
static async updateSavedMachine(
|
||||
machineId: string,
|
||||
updates: SavedMachineUpdate
|
||||
): Promise<SavedMachine> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Updating saved machine', {
|
||||
machineId,
|
||||
updates: Object.keys(updates)
|
||||
});
|
||||
|
||||
const response = await ApiClient.put<SavedMachine>(
|
||||
`${this.baseUrl}/${machineId}`,
|
||||
updates,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine updated successfully', {
|
||||
machineId,
|
||||
name: response.name
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to update saved machine', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete saved machine
|
||||
*/
|
||||
static async deleteSavedMachine(machineId: string): Promise<void> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Deleting saved machine', { machineId });
|
||||
|
||||
await ApiClient.delete(
|
||||
`${this.baseUrl}/${machineId}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Saved machine deleted successfully', {
|
||||
machineId
|
||||
});
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to delete saved machine', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Record connection to machine
|
||||
*/
|
||||
static async connectToSavedMachine(
|
||||
machineId: string
|
||||
): Promise<ConnectionRecordResponse> {
|
||||
try {
|
||||
const headers = await authService.getAuthHeaders();
|
||||
|
||||
log.info('saved-machines-api', 'Recording connection to saved machine', {
|
||||
machineId
|
||||
});
|
||||
|
||||
const response = await ApiClient.post<ConnectionRecordResponse>(
|
||||
`${this.baseUrl}/${machineId}/connect`,
|
||||
{},
|
||||
{ headers }
|
||||
);
|
||||
|
||||
log.info('saved-machines-api', 'Connection recorded successfully', {
|
||||
machineId,
|
||||
historyId: response.history_id
|
||||
});
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
log.error('saved-machines-api', 'Failed to record connection', {
|
||||
machineId,
|
||||
error
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle favorite status
|
||||
*/
|
||||
static async toggleFavorite(
|
||||
machineId: string,
|
||||
isFavorite: boolean
|
||||
): Promise<SavedMachine> {
|
||||
return this.updateSavedMachine(machineId, { is_favorite: isFavorite });
|
||||
}
|
||||
|
||||
/**
|
||||
* Update machine tags
|
||||
*/
|
||||
static async updateTags(machineId: string, tags: string[]): Promise<SavedMachine> {
|
||||
return this.updateSavedMachine(machineId, { tags });
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter machines by tags
|
||||
*/
|
||||
static filterByTags(machines: SavedMachine[], tags: string[]): SavedMachine[] {
|
||||
if (!tags || tags.length === 0) {
|
||||
return machines;
|
||||
}
|
||||
|
||||
return machines.filter((machine) => tags.some((tag) => machine.tags.includes(tag)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter machines by protocol
|
||||
*/
|
||||
static filterByProtocol(machines: SavedMachine[], protocol: string): SavedMachine[] {
|
||||
return machines.filter((machine) => machine.protocol === protocol);
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter only favorites
|
||||
*/
|
||||
static filterFavorites(machines: SavedMachine[]): SavedMachine[] {
|
||||
return machines.filter((machine) => machine.is_favorite);
|
||||
}
|
||||
|
||||
/**
|
||||
* Search by name or hostname
|
||||
*/
|
||||
static search(machines: SavedMachine[], query: string): SavedMachine[] {
|
||||
if (!query) {
|
||||
return machines;
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase();
|
||||
return machines.filter(
|
||||
(machine) =>
|
||||
machine.name.toLowerCase().includes(lowerQuery) ||
|
||||
machine.hostname.toLowerCase().includes(lowerQuery) ||
|
||||
machine.description?.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const savedMachinesApi = SavedMachinesApiService;
|
||||
|
||||
384
mc_test/src/renderer/services/websocket-notifications.ts
Executable file
384
mc_test/src/renderer/services/websocket-notifications.ts
Executable file
@ -0,0 +1,384 @@
|
||||
/**
|
||||
* WebSocket Service for real-time notifications
|
||||
*
|
||||
* Events:
|
||||
* - connection_expired: Connection expired
|
||||
* - connection_deleted: Connection deleted
|
||||
* - connection_will_expire: Connection will expire soon (in 5 min)
|
||||
* - connection_extended: Connection extended
|
||||
* - jwt_will_expire: JWT will expire soon (in 5 min)
|
||||
* - jwt_expired: JWT expired
|
||||
*/
|
||||
|
||||
import { log } from '../utils/logger';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
export type WebSocketEventType =
|
||||
| 'connection_expired'
|
||||
| 'connection_deleted'
|
||||
| 'connection_will_expire'
|
||||
| 'connection_extended'
|
||||
| 'jwt_will_expire'
|
||||
| 'jwt_expired'
|
||||
| 'connected'
|
||||
| 'ping'
|
||||
| 'pong';
|
||||
|
||||
export interface WebSocketEvent {
|
||||
type: WebSocketEventType;
|
||||
timestamp: string;
|
||||
data?: any;
|
||||
}
|
||||
|
||||
export interface WebSocketMessage {
|
||||
type: string;
|
||||
token?: string;
|
||||
timestamp?: string;
|
||||
}
|
||||
|
||||
export interface ConnectionExpiredEvent {
|
||||
connection_id: string;
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ConnectionWillExpireEvent {
|
||||
connection_id: string;
|
||||
hostname: string;
|
||||
protocol: string;
|
||||
minutes_remaining: number;
|
||||
}
|
||||
|
||||
export interface ConnectionExtendedEvent {
|
||||
connection_id: string;
|
||||
hostname: string;
|
||||
new_expires_at: string;
|
||||
additional_minutes: number;
|
||||
}
|
||||
|
||||
type EventHandler = (event: WebSocketEvent) => void;
|
||||
|
||||
const WEBSOCKET_CONFIG = {
|
||||
MAX_RECONNECT_ATTEMPTS: 10,
|
||||
INITIAL_RECONNECT_DELAY: 1000, // 1 second
|
||||
MAX_RECONNECT_DELAY: 30000, // 30 seconds
|
||||
PING_INTERVAL: 25000, // 25 seconds
|
||||
CONNECTION_TIMEOUT: 5000, // 5 seconds
|
||||
EXPONENTIAL_BACKOFF_BASE: 2,
|
||||
} as const;
|
||||
|
||||
const WEBSOCKET_PATH = '/ws/notifications';
|
||||
const ALL_EVENTS_KEY = '*';
|
||||
|
||||
const PROTOCOL_REPLACEMENT = {
|
||||
HTTPS: 'wss://',
|
||||
HTTP: 'ws://',
|
||||
} as const;
|
||||
|
||||
class WebSocketNotificationService {
|
||||
private ws: WebSocket | null = null;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private reconnectAttempts = 0;
|
||||
private readonly maxReconnectAttempts = WEBSOCKET_CONFIG.MAX_RECONNECT_ATTEMPTS;
|
||||
private readonly reconnectDelay = WEBSOCKET_CONFIG.INITIAL_RECONNECT_DELAY;
|
||||
private readonly maxReconnectDelay = WEBSOCKET_CONFIG.MAX_RECONNECT_DELAY;
|
||||
private isIntentionallyDisconnected = false;
|
||||
private pingTimer: NodeJS.Timeout | null = null;
|
||||
private readonly pingInterval = WEBSOCKET_CONFIG.PING_INTERVAL;
|
||||
|
||||
private eventHandlers: Map<string, Set<EventHandler>> = new Map();
|
||||
|
||||
/**
|
||||
* Connect to WebSocket server
|
||||
*/
|
||||
connect(token: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
log.info('websocket', 'Already connected');
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
|
||||
this.isIntentionallyDisconnected = false;
|
||||
|
||||
// Determine WebSocket URL
|
||||
const wsUrl = API_CONFIG.BASE_URL
|
||||
.replace('https://', PROTOCOL_REPLACEMENT.HTTPS)
|
||||
.replace('http://', PROTOCOL_REPLACEMENT.HTTP);
|
||||
const fullUrl = `${wsUrl}${WEBSOCKET_PATH}`;
|
||||
|
||||
log.info('websocket', 'Connecting to WebSocket', { url: fullUrl });
|
||||
|
||||
try {
|
||||
this.ws = new WebSocket(fullUrl);
|
||||
|
||||
this.ws.onopen = () => {
|
||||
log.info('websocket', 'WebSocket connected');
|
||||
|
||||
// Send authentication
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'auth',
|
||||
token
|
||||
}));
|
||||
|
||||
log.info('websocket', 'Sent authentication');
|
||||
|
||||
// Wait for connected confirmation
|
||||
const connectTimeout = setTimeout(() => {
|
||||
log.error('websocket', 'Connection confirmation timeout');
|
||||
this.disconnect();
|
||||
reject(new Error('Connection confirmation timeout'));
|
||||
}, WEBSOCKET_CONFIG.CONNECTION_TIMEOUT);
|
||||
|
||||
// Temporary handler for connected
|
||||
const tempOnMessage = (event: MessageEvent) => {
|
||||
try {
|
||||
const message: WebSocketEvent = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'connected') {
|
||||
clearTimeout(connectTimeout);
|
||||
log.info('websocket', 'Authentication confirmed', message.data);
|
||||
|
||||
// Reset reconnect attempts
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// Start ping timer
|
||||
this.startPingTimer();
|
||||
|
||||
// Set main message handler
|
||||
if (this.ws) {
|
||||
this.ws.onmessage = (e) => this.handleMessage(e);
|
||||
}
|
||||
|
||||
resolve();
|
||||
}
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Failed to parse auth response', { error });
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onmessage = tempOnMessage;
|
||||
}
|
||||
};
|
||||
|
||||
this.ws.onerror = (error) => {
|
||||
log.error('websocket', 'WebSocket error', { error });
|
||||
reject(error);
|
||||
};
|
||||
|
||||
this.ws.onclose = (event) => {
|
||||
log.info('websocket', 'WebSocket closed', {
|
||||
code: event.code,
|
||||
reason: event.reason,
|
||||
wasClean: event.wasClean
|
||||
});
|
||||
|
||||
this.stopPingTimer();
|
||||
|
||||
// Auto-reconnect if not intentionally disconnected
|
||||
if (!this.isIntentionallyDisconnected && this.reconnectAttempts < this.maxReconnectAttempts) {
|
||||
this.scheduleReconnect(token);
|
||||
}
|
||||
};
|
||||
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Failed to create WebSocket', { error });
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from WebSocket server
|
||||
*/
|
||||
disconnect(): void {
|
||||
this.isIntentionallyDisconnected = true;
|
||||
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
|
||||
this.stopPingTimer();
|
||||
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
|
||||
log.info('websocket', 'Disconnected');
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule reconnection with exponential backoff
|
||||
*/
|
||||
private scheduleReconnect(token: string): void {
|
||||
if (this.reconnectTimer) {
|
||||
return; // Already scheduled
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
|
||||
// Exponential backoff: 1s, 2s, 4s, 8s, 16s, 30s, 30s, ...
|
||||
const delay = Math.min(
|
||||
this.reconnectDelay * Math.pow(WEBSOCKET_CONFIG.EXPONENTIAL_BACKOFF_BASE, this.reconnectAttempts - 1),
|
||||
this.maxReconnectDelay
|
||||
);
|
||||
|
||||
log.info('websocket', 'Scheduling reconnect', {
|
||||
attempt: this.reconnectAttempts,
|
||||
delay: `${delay}ms`
|
||||
});
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect(token).catch((error) => {
|
||||
log.error('websocket', 'Reconnect failed', { error });
|
||||
});
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start ping timer for keep-alive
|
||||
*/
|
||||
private startPingTimer(): void {
|
||||
this.stopPingTimer();
|
||||
|
||||
this.pingTimer = setInterval(() => {
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'ping',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
|
||||
log.debug('websocket', 'Sent ping');
|
||||
}
|
||||
}, this.pingInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop ping timer
|
||||
*/
|
||||
private stopPingTimer(): void{
|
||||
if (this.pingTimer) {
|
||||
clearInterval(this.pingTimer);
|
||||
this.pingTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming message
|
||||
*/
|
||||
private handleMessage(event: MessageEvent): void {
|
||||
try {
|
||||
const message: WebSocketEvent = JSON.parse(event.data);
|
||||
|
||||
if (message.type === 'pong') {
|
||||
log.debug('websocket', 'Received pong');
|
||||
return;
|
||||
}
|
||||
|
||||
if (message.type === 'ping') {
|
||||
// Respond with pong
|
||||
if (this.ws && this.ws.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify({
|
||||
type: 'pong',
|
||||
timestamp: new Date().toISOString()
|
||||
}));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
log.info('websocket', 'Received event', {
|
||||
type: message.type,
|
||||
timestamp: message.timestamp
|
||||
});
|
||||
|
||||
// Call handlers for this event type
|
||||
const handlers = this.eventHandlers.get(message.type);
|
||||
if (handlers) {
|
||||
handlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Event handler error', {
|
||||
type: message.type,
|
||||
error
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Also call handlers for '*' (all events)
|
||||
const allHandlers = this.eventHandlers.get(ALL_EVENTS_KEY);
|
||||
if (allHandlers) {
|
||||
allHandlers.forEach(handler => {
|
||||
try {
|
||||
handler(message);
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Global event handler error', {
|
||||
type: message.type,
|
||||
error
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
log.error('websocket', 'Failed to parse message', { error });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Subscribe to events
|
||||
*/
|
||||
on(eventType: string, handler: EventHandler): () => void {
|
||||
if (!this.eventHandlers.has(eventType)) {
|
||||
this.eventHandlers.set(eventType, new Set());
|
||||
}
|
||||
|
||||
this.eventHandlers.get(eventType)!.add(handler);
|
||||
|
||||
log.info('websocket', 'Registered event handler', { eventType });
|
||||
|
||||
// Return unsubscribe function
|
||||
return () => {
|
||||
const handlers = this.eventHandlers.get(eventType);
|
||||
if (handlers) {
|
||||
handlers.delete(handler);
|
||||
|
||||
if (handlers.size === 0) {
|
||||
this.eventHandlers.delete(eventType);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Unsubscribe from all events
|
||||
*/
|
||||
removeAllListeners(): void {
|
||||
this.eventHandlers.clear();
|
||||
log.info('websocket', 'Removed all event handlers');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check connection status
|
||||
*/
|
||||
isConnected(): boolean {
|
||||
return this.ws !== null && this.ws.readyState === WebSocket.OPEN;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get number of reconnect attempts
|
||||
*/
|
||||
getReconnectAttempts(): number {
|
||||
return this.reconnectAttempts;
|
||||
}
|
||||
}
|
||||
|
||||
// Singleton instance
|
||||
export const websocketNotificationService = new WebSocketNotificationService();
|
||||
|
||||
495
mc_test/src/renderer/store/machineStore.ts
Executable file
495
mc_test/src/renderer/store/machineStore.ts
Executable file
@ -0,0 +1,495 @@
|
||||
import { create } from 'zustand'
|
||||
import { persist, createJSONStorage } from 'zustand/middleware'
|
||||
import { Machine, LogEntry } from '../types'
|
||||
import { log } from '../utils/logger'
|
||||
import { mockMachines } from '../mocks/mockMachines'
|
||||
import { guacamoleService } from '../services/guacamole-api'
|
||||
import { savedMachinesApi, SavedMachine, SavedMachineCreate, SavedMachineUpdate } from '../services/saved-machines-api'
|
||||
|
||||
const DEFAULT_OS = 'Unknown';
|
||||
const SAVED_HYPERVISOR = 'Saved';
|
||||
const SPEC_NOT_AVAILABLE = 'N/A';
|
||||
|
||||
/**
|
||||
* Convert SavedMachine to Machine for display in Sidebar
|
||||
*/
|
||||
function convertSavedMachineToMachine(saved: SavedMachine): Machine {
|
||||
return {
|
||||
id: saved.id,
|
||||
name: saved.name,
|
||||
status: 'unknown',
|
||||
os: saved.os && saved.os.trim() !== '' ? saved.os : DEFAULT_OS,
|
||||
ip: saved.hostname,
|
||||
hypervisor: SAVED_HYPERVISOR,
|
||||
port: saved.port,
|
||||
protocol: saved.protocol,
|
||||
specs: {
|
||||
cpu: SPEC_NOT_AVAILABLE,
|
||||
ram: SPEC_NOT_AVAILABLE,
|
||||
disk: SPEC_NOT_AVAILABLE
|
||||
},
|
||||
// Mark as saved machine to distinguish from mock
|
||||
testLinks: [{
|
||||
url: `${saved.protocol}://${saved.hostname}:${saved.port}`,
|
||||
name: `${saved.protocol.toUpperCase()} (Port ${saved.port})`
|
||||
}],
|
||||
logs: []
|
||||
};
|
||||
}
|
||||
|
||||
interface MachineState {
|
||||
machines: Machine[];
|
||||
selectedMachine: Machine | null;
|
||||
loading: boolean;
|
||||
error: string | null;
|
||||
guacamoleVisible: boolean;
|
||||
searchQuery: string;
|
||||
filteredMachines: Machine[];
|
||||
|
||||
// Saved machines
|
||||
savedMachines: SavedMachine[];
|
||||
savedMachinesLoading: boolean;
|
||||
showAddMachineModal: boolean;
|
||||
showSaveConfirmModal: boolean;
|
||||
machineToSave: SavedMachineCreate | null;
|
||||
|
||||
// Actions
|
||||
setMachines: (machines: Machine[]) => void;
|
||||
selectMachine: (machine: Machine | null) => void;
|
||||
setLoading: (loading: boolean) => void;
|
||||
setError: (error: string | null) => void;
|
||||
setGuacamoleVisible: (visible: boolean) => void;
|
||||
setSearchQuery: (query: string) => void;
|
||||
setShowAddMachineModal: (show: boolean) => void;
|
||||
setShowSaveConfirmModal: (show: boolean) => void;
|
||||
setMachineToSave: (machine: SavedMachineCreate | null) => void;
|
||||
|
||||
// Async actions
|
||||
fetchMachines: () => Promise<void>;
|
||||
connectToMachine: (machine: Machine) => Promise<void>;
|
||||
controlMachine: (machineId: string, action: 'start' | 'stop' | 'restart') => Promise<void>;
|
||||
connectGuacamole: (machine: Machine) => Promise<void>;
|
||||
|
||||
// Saved machines actions
|
||||
fetchSavedMachines: (includeStats?: boolean) => Promise<void>;
|
||||
createSavedMachine: (machine: SavedMachineCreate) => Promise<SavedMachine>;
|
||||
updateSavedMachine: (machineId: string, updates: SavedMachineUpdate) => Promise<SavedMachine>;
|
||||
deleteSavedMachine: (machineId: string) => Promise<void>;
|
||||
toggleFavorite: (machineId: string) => Promise<void>;
|
||||
connectToSavedMachine: (machineId: string) => Promise<void>;
|
||||
|
||||
// Utility actions
|
||||
resetState: () => void;
|
||||
}
|
||||
|
||||
export const useMachineStore = create<MachineState>()(
|
||||
persist(
|
||||
(set, get) => {
|
||||
// Add debug log when creating store
|
||||
log.info('store', 'Initializing machine store with persistence');
|
||||
|
||||
return {
|
||||
machines: [],
|
||||
selectedMachine: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
guacamoleVisible: false,
|
||||
searchQuery: '',
|
||||
filteredMachines: [],
|
||||
|
||||
// Saved machines state
|
||||
savedMachines: [],
|
||||
savedMachinesLoading: false,
|
||||
showAddMachineModal: false,
|
||||
showSaveConfirmModal: false,
|
||||
machineToSave: null,
|
||||
|
||||
// Synchronous actions
|
||||
setMachines: (machines) => {
|
||||
log.info('store', 'Setting machines', { count: machines.length });
|
||||
set({
|
||||
machines,
|
||||
filteredMachines: machines.filter(machine =>
|
||||
machine.name.toLowerCase().includes(get().searchQuery.toLowerCase()) ||
|
||||
machine.ip.toLowerCase().includes(get().searchQuery.toLowerCase())
|
||||
)
|
||||
});
|
||||
},
|
||||
|
||||
selectMachine: (machine) => set({ selectedMachine: machine }),
|
||||
setLoading: (loading) => set({ loading }),
|
||||
setError: (error) => set({ error }),
|
||||
setGuacamoleVisible: (visible) => set({ guacamoleVisible: visible }),
|
||||
setShowAddMachineModal: (show) => set({ showAddMachineModal: show }),
|
||||
setShowSaveConfirmModal: (show) => set({ showSaveConfirmModal: show }),
|
||||
setMachineToSave: (machine) => set({ machineToSave: machine }),
|
||||
|
||||
setSearchQuery: (query) => {
|
||||
const { machines } = get();
|
||||
set({
|
||||
searchQuery: query,
|
||||
filteredMachines: machines.filter(machine =>
|
||||
machine.name.toLowerCase().includes(query.toLowerCase()) ||
|
||||
machine.ip.toLowerCase().includes(query.toLowerCase())
|
||||
)
|
||||
});
|
||||
},
|
||||
|
||||
// Asynchronous actions
|
||||
fetchMachines: async () => {
|
||||
const { setLoading, setError, savedMachines } = get();
|
||||
setLoading(true);
|
||||
try {
|
||||
if (import.meta.env.DEV) {
|
||||
// In development mode, merge saved machines with mock data
|
||||
const savedAsMachines = savedMachines.map(convertSavedMachineToMachine);
|
||||
const allMachines = [...savedAsMachines, ...mockMachines];
|
||||
|
||||
log.info('store', 'Merging saved and mock machines', {
|
||||
savedCount: savedAsMachines.length,
|
||||
mockCount: mockMachines.length,
|
||||
totalCount: allMachines.length
|
||||
});
|
||||
|
||||
set({
|
||||
machines: allMachines,
|
||||
filteredMachines: allMachines.filter(machine =>
|
||||
machine.name.toLowerCase().includes(get().searchQuery.toLowerCase()) ||
|
||||
machine.ip.toLowerCase().includes(get().searchQuery.toLowerCase())
|
||||
)
|
||||
});
|
||||
setError(null);
|
||||
} else {
|
||||
const result = await window.electronAPI.getMachineList();
|
||||
if (result.success && result.machines) {
|
||||
log.info('store', 'Machines fetched successfully', {
|
||||
count: result.machines.length
|
||||
});
|
||||
|
||||
// In production, also merge with saved machines
|
||||
const savedAsMachines = savedMachines.map(convertSavedMachineToMachine);
|
||||
const allMachines = [...savedAsMachines, ...result.machines];
|
||||
|
||||
set({
|
||||
machines: allMachines,
|
||||
filteredMachines: allMachines.filter(machine =>
|
||||
machine.name.toLowerCase().includes(get().searchQuery.toLowerCase()) ||
|
||||
machine.ip.toLowerCase().includes(get().searchQuery.toLowerCase())
|
||||
)
|
||||
});
|
||||
setError(null);
|
||||
} else {
|
||||
throw new Error(result.message || 'Failed to fetch machines');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch machines';
|
||||
log.error('store', 'Failed to fetch machines', { error: errorMessage });
|
||||
setError(errorMessage);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
},
|
||||
|
||||
connectToMachine: async (machine) => {
|
||||
const { setError } = get();
|
||||
try {
|
||||
log.info('store', 'Connecting to machine', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name
|
||||
});
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// In development mode, simulate successful connection
|
||||
log.info('store', 'Mock connection successful');
|
||||
setError(null);
|
||||
} else {
|
||||
const result = await window.electronAPI.connectToMachine(machine);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
log.info('store', 'Connected to machine successfully', {
|
||||
machineId: machine.id
|
||||
});
|
||||
setError(null);
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to machine';
|
||||
log.error('store', 'Connection failed', {
|
||||
machineId: machine.id,
|
||||
error: errorMessage
|
||||
});
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
controlMachine: async (machineId, action) => {
|
||||
const { setError, fetchMachines, machines } = get();
|
||||
try {
|
||||
log.info('store', `Controlling machine: ${action}`, { machineId });
|
||||
|
||||
if (import.meta.env.DEV) {
|
||||
// In development mode, simulate state change
|
||||
const updatedMachines = machines.map(machine => {
|
||||
if (machine.id === machineId) {
|
||||
const newLog: LogEntry = {
|
||||
timestamp: new Date().toISOString(),
|
||||
message: `Machine ${action} successful`,
|
||||
level: 'info'
|
||||
};
|
||||
return {
|
||||
...machine,
|
||||
status: action === 'start' ? 'running' : action === 'stop' ? 'stopped' : machine.status,
|
||||
logs: [newLog, ...machine.logs]
|
||||
};
|
||||
}
|
||||
return machine;
|
||||
});
|
||||
set({ machines: updatedMachines });
|
||||
log.info('store', `Mock machine ${action} successful`, { machineId });
|
||||
setError(null);
|
||||
} else {
|
||||
const result = await window.electronAPI.controlMachine(machineId, action);
|
||||
if (!result.success) {
|
||||
throw new Error(result.message);
|
||||
}
|
||||
log.info('store', `Machine ${action} successful`, { machineId });
|
||||
setError(null);
|
||||
await fetchMachines();
|
||||
}
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : `Failed to ${action} machine`;
|
||||
log.error('store', `Machine ${action} failed`, {
|
||||
machineId,
|
||||
error: errorMessage
|
||||
});
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
connectGuacamole: async (machine) => {
|
||||
const { setError, setGuacamoleVisible } = get();
|
||||
try {
|
||||
log.info('store', 'Connecting to Guacamole', {
|
||||
machineId: machine.id,
|
||||
machineName: machine.name
|
||||
});
|
||||
|
||||
// In development mode, just show component
|
||||
if (import.meta.env.DEV) {
|
||||
setGuacamoleVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
// In production, create connection
|
||||
const result = await guacamoleService.createConnection(machine);
|
||||
if (!result.connection_url) {
|
||||
throw new Error('Failed to create Guacamole connection');
|
||||
}
|
||||
|
||||
setGuacamoleVisible(true);
|
||||
setError(null);
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to Guacamole';
|
||||
log.error('store', 'Guacamole connection failed', {
|
||||
machineId: machine.id,
|
||||
error: errorMessage
|
||||
});
|
||||
setError(errorMessage);
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================================
|
||||
// Saved Machines Actions
|
||||
// ========================================================================================
|
||||
|
||||
fetchSavedMachines: async (includeStats = false) => {
|
||||
set({ savedMachinesLoading: true });
|
||||
try {
|
||||
log.info('store', 'Fetching saved machines', { includeStats });
|
||||
|
||||
const result = await savedMachinesApi.getSavedMachines(includeStats);
|
||||
|
||||
set({
|
||||
savedMachines: result.machines,
|
||||
savedMachinesLoading: false
|
||||
});
|
||||
|
||||
log.info('store', 'Saved machines fetched successfully', {
|
||||
total: result.total
|
||||
});
|
||||
|
||||
// After loading saved machines, update general list
|
||||
// (fetchMachines will merge savedMachines with mock/electron data)
|
||||
await get().fetchMachines();
|
||||
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to fetch saved machines';
|
||||
log.error('store', 'Failed to fetch saved machines', { error: errorMessage });
|
||||
set({
|
||||
error: errorMessage,
|
||||
savedMachinesLoading: false
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
createSavedMachine: async (machine) => {
|
||||
try {
|
||||
log.info('store', 'Creating saved machine', { name: machine.name });
|
||||
|
||||
const created = await savedMachinesApi.createSavedMachine(machine);
|
||||
|
||||
// Add to savedMachines
|
||||
set(state => ({
|
||||
savedMachines: [...state.savedMachines, created]
|
||||
}));
|
||||
|
||||
// Update general list (fetchMachines will merge savedMachines with mock)
|
||||
await get().fetchMachines();
|
||||
|
||||
log.info('store', 'Saved machine created', {
|
||||
id: created.id,
|
||||
name: created.name
|
||||
});
|
||||
|
||||
return created;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to create saved machine';
|
||||
log.error('store', 'Failed to create saved machine', { error: errorMessage });
|
||||
set({ error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
updateSavedMachine: async (machineId, updates) => {
|
||||
try {
|
||||
log.info('store', 'Updating saved machine', { machineId, updates });
|
||||
|
||||
const updated = await savedMachinesApi.updateSavedMachine(machineId, updates);
|
||||
|
||||
// Update in savedMachines
|
||||
set(state => ({
|
||||
savedMachines: state.savedMachines.map(m =>
|
||||
m.id === machineId ? updated : m
|
||||
)
|
||||
}));
|
||||
|
||||
// Update general list
|
||||
await get().fetchMachines();
|
||||
|
||||
log.info('store', 'Saved machine updated', { id: updated.id });
|
||||
|
||||
return updated;
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to update saved machine';
|
||||
log.error('store', 'Failed to update saved machine', { error: errorMessage });
|
||||
set({ error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
deleteSavedMachine: async (machineId) => {
|
||||
try {
|
||||
log.info('store', 'Deleting saved machine', { machineId });
|
||||
|
||||
await savedMachinesApi.deleteSavedMachine(machineId);
|
||||
|
||||
// Remove from savedMachines
|
||||
set(state => ({
|
||||
savedMachines: state.savedMachines.filter(m => m.id !== machineId)
|
||||
}));
|
||||
|
||||
// Update general list
|
||||
await get().fetchMachines();
|
||||
|
||||
log.info('store', 'Saved machine deleted', { machineId });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to delete saved machine';
|
||||
log.error('store', 'Failed to delete saved machine', { error: errorMessage });
|
||||
set({ error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
toggleFavorite: async (machineId) => {
|
||||
try {
|
||||
const machine = get().savedMachines.find(m => m.id === machineId);
|
||||
if (!machine) {
|
||||
throw new Error('Machine not found');
|
||||
}
|
||||
|
||||
log.info('store', 'Toggling favorite', { machineId, current: machine.is_favorite });
|
||||
|
||||
await get().updateSavedMachine(machineId, {
|
||||
is_favorite: !machine.is_favorite
|
||||
});
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to toggle favorite';
|
||||
log.error('store', 'Failed to toggle favorite', { error: errorMessage });
|
||||
set({ error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
connectToSavedMachine: async (machineId) => {
|
||||
try {
|
||||
log.info('store', 'Connecting to saved machine', { machineId });
|
||||
|
||||
// Record connection in history
|
||||
await savedMachinesApi.connectToSavedMachine(machineId);
|
||||
|
||||
// Update local list
|
||||
await get().fetchSavedMachines();
|
||||
|
||||
log.info('store', 'Connected to saved machine', { machineId });
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : 'Failed to connect to saved machine';
|
||||
log.error('store', 'Failed to connect to saved machine', { error: errorMessage });
|
||||
set({ error: errorMessage });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
// ========================================================================================
|
||||
// Utility Actions
|
||||
// ========================================================================================
|
||||
|
||||
resetState: () => {
|
||||
log.info('store', 'Resetting all state (logout/user switch)');
|
||||
|
||||
set({
|
||||
machines: [],
|
||||
selectedMachine: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
guacamoleVisible: false,
|
||||
searchQuery: '',
|
||||
filteredMachines: [],
|
||||
savedMachines: [],
|
||||
savedMachinesLoading: false,
|
||||
showAddMachineModal: false,
|
||||
showSaveConfirmModal: false,
|
||||
machineToSave: null
|
||||
});
|
||||
|
||||
log.info('store', 'State reset complete');
|
||||
}
|
||||
};
|
||||
},
|
||||
{
|
||||
name: 'machine-store', // Unique name for localStorage
|
||||
storage: createJSONStorage(() => localStorage),
|
||||
// Choose what to persist (don't persist loading states and modals)
|
||||
partialize: (state) => ({
|
||||
machines: state.machines,
|
||||
selectedMachine: state.selectedMachine,
|
||||
savedMachines: state.savedMachines,
|
||||
searchQuery: state.searchQuery,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
77
mc_test/src/renderer/styles/fonts.css
Executable file
77
mc_test/src/renderer/styles/fonts.css
Executable file
@ -0,0 +1,77 @@
|
||||
@font-face {
|
||||
font-family: 'Kaspersky Sans Text';
|
||||
src: url('/fonts/KasperskySansText-Regular.woff2') format('woff2'),
|
||||
url('/fonts/KasperskySansText-Regular.woff') format('woff'),
|
||||
url('/fonts/KasperskySansText-Regular.ttf') format('truetype'),
|
||||
url('/fonts/KasperskySansText-Regular.eot') format('embedded-opentype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Kaspersky Sans Text';
|
||||
src: url('/fonts/KasperskySansText-Medium.woff2') format('woff2'),
|
||||
url('/fonts/KasperskySansText-Medium.woff') format('woff'),
|
||||
url('/fonts/KasperskySansText-Medium.ttf') format('truetype'),
|
||||
url('/fonts/KasperskySansText-Medium.eot') format('embedded-opentype');
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Kaspersky Sans Text';
|
||||
src: url('/fonts/KasperskySansText-DemiBold.woff2') format('woff2'),
|
||||
url('/fonts/KasperskySansText-DemiBold.woff') format('woff'),
|
||||
url('/fonts/KasperskySansText-DemiBold.ttf') format('truetype'),
|
||||
url('/fonts/KasperskySansText-DemiBold.eot') format('embedded-opentype');
|
||||
font-weight: 600;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Kaspersky Sans Text';
|
||||
src: url('/fonts/KasperskySansText-Bold.woff2') format('woff2'),
|
||||
url('/fonts/KasperskySansText-Bold.woff') format('woff'),
|
||||
url('/fonts/KasperskySansText-Bold.ttf') format('truetype'),
|
||||
url('/fonts/KasperskySansText-Bold.eot') format('embedded-opentype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Kaspersky Sans Text';
|
||||
src: url('/fonts/KasperskySansText-ExtraBold.woff2') format('woff2'),
|
||||
url('/fonts/KasperskySansText-ExtraBold.woff') format('woff'),
|
||||
url('/fonts/KasperskySansText-ExtraBold.ttf') format('truetype'),
|
||||
url('/fonts/KasperskySansText-ExtraBold.eot') format('embedded-opentype');
|
||||
font-weight: 800;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
/* Mono variant for logs */
|
||||
@font-face {
|
||||
font-family: 'Kaspersky Sans Mono';
|
||||
src: url('/fonts/KasperskySansMono-Regular.woff2') format('woff2'),
|
||||
url('/fonts/KasperskySansMono-Regular.woff') format('woff'),
|
||||
url('/fonts/KasperskySansMono-Regular.ttf') format('truetype'),
|
||||
url('/fonts/KasperskySansMono-Regular.eot') format('embedded-opentype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Kaspersky Sans Mono';
|
||||
src: url('/fonts/KasperskySansMono-Bold.woff2') format('woff2'),
|
||||
url('/fonts/KasperskySansMono-Bold.woff') format('woff'),
|
||||
url('/fonts/KasperskySansMono-Bold.ttf') format('truetype'),
|
||||
url('/fonts/KasperskySansMono-Bold.eot') format('embedded-opentype');
|
||||
font-weight: 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
108
mc_test/src/renderer/styles/index.css
Executable file
108
mc_test/src/renderer/styles/index.css
Executable file
@ -0,0 +1,108 @@
|
||||
@import 'tailwindcss/base';
|
||||
@import 'tailwindcss/components';
|
||||
@import 'tailwindcss/utilities';
|
||||
@import './fonts.css';
|
||||
|
||||
@layer base {
|
||||
html {
|
||||
font-family: 'Kaspersky Sans Text', system-ui, sans-serif;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: theme('colors.kaspersky.bg.DEFAULT');
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: theme('colors.kaspersky.accent');
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: theme('colors.kaspersky.primary');
|
||||
}
|
||||
|
||||
/* Connection panel slide-in animation */
|
||||
@keyframes slide-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateX(100%);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Shake animation for errors */
|
||||
@keyframes shake {
|
||||
0%, 100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
10%, 30%, 50%, 70%, 90% {
|
||||
transform: translateX(-8px);
|
||||
}
|
||||
20%, 40%, 60%, 80% {
|
||||
transform: translateX(8px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-shake {
|
||||
animation: shake 0.6s ease-in-out;
|
||||
}
|
||||
|
||||
/* Fade in animation */
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-fadeIn {
|
||||
animation: fadeIn 0.3s ease-in;
|
||||
}
|
||||
|
||||
/* Animated gradient background */
|
||||
@keyframes gradient-shift {
|
||||
0% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
50% {
|
||||
background-position: 100% 50%;
|
||||
}
|
||||
100% {
|
||||
background-position: 0% 50%;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-gradient {
|
||||
background-size: 200% 200%;
|
||||
animation: gradient-shift 15s ease infinite;
|
||||
}
|
||||
|
||||
/* Animated background pattern */
|
||||
@keyframes pattern-move {
|
||||
0% {
|
||||
background-position: 0 0;
|
||||
}
|
||||
100% {
|
||||
background-position: 40px 40px;
|
||||
}
|
||||
}
|
||||
|
||||
.animate-pattern {
|
||||
animation: pattern-move 20s linear infinite;
|
||||
}
|
||||
28
mc_test/src/renderer/types.ts
Executable file
28
mc_test/src/renderer/types.ts
Executable file
@ -0,0 +1,28 @@
|
||||
export interface TestLink {
|
||||
name: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
message: string;
|
||||
level: 'error' | 'warn' | 'info' | 'debug';
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'running' | 'stopped' | 'error' | 'unknown' | 'checking';
|
||||
os: string;
|
||||
ip: string;
|
||||
hypervisor: string;
|
||||
specs: {
|
||||
cpu: string;
|
||||
ram: string;
|
||||
disk: string;
|
||||
};
|
||||
testLinks: TestLink[];
|
||||
logs: LogEntry[];
|
||||
port?: number; // Порт для сохраненных машин (используется при проверке доступности)
|
||||
protocol?: string; // Протокол для сохраненных машин
|
||||
}
|
||||
26
mc_test/src/renderer/types/guacamole.ts
Executable file
26
mc_test/src/renderer/types/guacamole.ts
Executable file
@ -0,0 +1,26 @@
|
||||
export interface GuacamoleConnection {
|
||||
connectionId: string;
|
||||
name: string;
|
||||
protocol: 'rdp' | 'vnc' | 'ssh';
|
||||
status: 'connected' | 'disconnected' | 'error';
|
||||
lastActive?: string;
|
||||
}
|
||||
|
||||
export interface GuacamoleAuthToken {
|
||||
authToken: string;
|
||||
username: string;
|
||||
dataSource: string;
|
||||
availableDataSources: string[];
|
||||
}
|
||||
|
||||
export interface GuacamoleError {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
type: 'INVALID_CREDENTIALS' | 'NOT_FOUND' | 'PERMISSION_DENIED' | 'UNKNOWN';
|
||||
}
|
||||
|
||||
export interface GuacamoleConnectionResponse {
|
||||
success: boolean;
|
||||
error?: GuacamoleError;
|
||||
connection?: GuacamoleConnection;
|
||||
}
|
||||
53
mc_test/src/renderer/types/index.ts
Executable file
53
mc_test/src/renderer/types/index.ts
Executable file
@ -0,0 +1,53 @@
|
||||
export type LogLevel = 'info' | 'warn' | 'error';
|
||||
|
||||
export interface LogEntry {
|
||||
timestamp: string;
|
||||
level: LogLevel;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface TestLink {
|
||||
url: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Machine {
|
||||
id: string;
|
||||
name: string;
|
||||
status: 'running' | 'stopped' | 'error' | 'unknown' | 'checking';
|
||||
os: string;
|
||||
ip: string; // ✅ Исправлено: было ipAddress, стало ip (соответствует реальным данным)
|
||||
cpu: string;
|
||||
memory: number;
|
||||
storage: number;
|
||||
testLinks?: TestLink[];
|
||||
logs?: LogEntry[];
|
||||
guacamole?: {
|
||||
connection?: GuacamoleConnection;
|
||||
lastConnectionTime?: string;
|
||||
autoReconnect?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
export interface GuacamoleAuthToken {
|
||||
authToken: string;
|
||||
}
|
||||
|
||||
export interface GuacamoleConnection {
|
||||
connectionId: string;
|
||||
name: string;
|
||||
protocol: string;
|
||||
parameters: Record<string, string>;
|
||||
}
|
||||
|
||||
export interface GuacamoleError {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
type: 'INVALID_CREDENTIALS' | 'PERMISSION_DENIED' | 'NOT_FOUND' | 'UNKNOWN';
|
||||
}
|
||||
|
||||
export interface GuacamoleConnectionResponse {
|
||||
success: boolean;
|
||||
connection?: GuacamoleConnection;
|
||||
error?: GuacamoleError;
|
||||
}
|
||||
300
mc_test/src/renderer/utils/apiClient.ts
Executable file
300
mc_test/src/renderer/utils/apiClient.ts
Executable file
@ -0,0 +1,300 @@
|
||||
import { ErrorHandler } from './errorHandler';
|
||||
import { log } from './logger';
|
||||
import { certificatePinning } from '../services/CertificatePinning';
|
||||
import { API_CONFIG } from '../config/api';
|
||||
|
||||
interface RetryConfig {
|
||||
maxRetries: number;
|
||||
baseDelay: number;
|
||||
maxDelay: number;
|
||||
retryCondition?: (error: unknown) => boolean;
|
||||
}
|
||||
|
||||
interface RequestConfig extends RequestInit {
|
||||
timeout?: number;
|
||||
retryConfig?: Partial<RetryConfig>;
|
||||
}
|
||||
|
||||
export class ApiClient {
|
||||
private static defaultRetryConfig: RetryConfig = {
|
||||
maxRetries: 3,
|
||||
baseDelay: 1000,
|
||||
maxDelay: 10000,
|
||||
retryCondition: (error) => {
|
||||
if (error instanceof Response) {
|
||||
// Retry on server errors and rate limiting
|
||||
return error.status >= 500 || error.status === 429;
|
||||
}
|
||||
// Retry on network errors
|
||||
return error instanceof TypeError && error.message.includes('fetch');
|
||||
}
|
||||
};
|
||||
|
||||
private static defaultTimeout = 30000; // 30 seconds
|
||||
|
||||
// CSRF token removed - not needed for JWT authentication
|
||||
// JWT Bearer tokens are not vulnerable to CSRF attacks
|
||||
|
||||
/**
|
||||
* Execute HTTP request with retry logic and timeout
|
||||
*/
|
||||
static async fetchWithRetry<T>(
|
||||
url: string,
|
||||
options: RequestConfig = {}
|
||||
): Promise<T> {
|
||||
const {
|
||||
timeout = this.defaultTimeout,
|
||||
retryConfig = {},
|
||||
...fetchOptions
|
||||
} = options;
|
||||
|
||||
// Add Authorization token if not explicitly set
|
||||
if (!(fetchOptions.headers as Record<string, string>)?.['Authorization']) {
|
||||
try {
|
||||
const authService = (await import('../services/auth-service')).authService;
|
||||
const token = authService.getToken();
|
||||
if (token) {
|
||||
fetchOptions.headers = {
|
||||
...fetchOptions.headers,
|
||||
'Authorization': `Bearer ${token}`
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
// Ignore errors when getting token
|
||||
}
|
||||
}
|
||||
|
||||
// CSRF token removed - not needed for JWT API
|
||||
|
||||
// Certificate pinning check for production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
try {
|
||||
const hostname = new URL(url).hostname;
|
||||
|
||||
// Load dynamic pins if cache is invalid
|
||||
if (!(certificatePinning as any).isPinCacheValid()) {
|
||||
try {
|
||||
await (certificatePinning as any).loadDynamicPins(API_CONFIG.BASE_URL);
|
||||
} catch (error) {
|
||||
log.warn('api-client', 'Failed to load dynamic certificate pins, using static pins', { error });
|
||||
}
|
||||
}
|
||||
|
||||
const isValid = await (certificatePinning as any).verifyCertificate(hostname);
|
||||
if (!isValid) {
|
||||
const error = new Error(`Certificate pinning validation failed for ${hostname}`);
|
||||
log.error('api-client', 'Certificate pinning failed', { hostname, url });
|
||||
throw error;
|
||||
}
|
||||
log.debug('api-client', 'Certificate pinning validation passed', { hostname });
|
||||
} catch (error) {
|
||||
log.error('api-client', 'Certificate pinning error', { error, url });
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
const config = { ...this.defaultRetryConfig, ...retryConfig };
|
||||
let lastError: unknown;
|
||||
let attempt = 0;
|
||||
|
||||
while (attempt <= config.maxRetries) {
|
||||
try {
|
||||
log.debug('api-client', `Attempting request (${attempt + 1}/${config.maxRetries + 1})`, {
|
||||
url,
|
||||
method: fetchOptions.method || 'GET',
|
||||
attempt: attempt + 1
|
||||
});
|
||||
|
||||
// Create AbortController for timeout
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
const response = await fetch(url, {
|
||||
...fetchOptions,
|
||||
signal: controller.signal
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
if (!response.ok) {
|
||||
const error = response.clone();
|
||||
|
||||
// Check if request should be retried
|
||||
if (attempt < config.maxRetries && config.retryCondition?.(error)) {
|
||||
lastError = error;
|
||||
const delay = this.calculateDelay(attempt, config);
|
||||
|
||||
log.warn('api-client', `Request failed, retrying in ${delay}ms`, {
|
||||
url,
|
||||
status: response.status,
|
||||
attempt: attempt + 1,
|
||||
nextRetryIn: delay
|
||||
});
|
||||
|
||||
await this.delay(delay);
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Successful response
|
||||
log.info('api-client', 'Request successful', {
|
||||
url,
|
||||
status: response.status,
|
||||
attempt: attempt + 1
|
||||
});
|
||||
|
||||
// Try parsing JSON if content exists
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await response.json();
|
||||
}
|
||||
|
||||
// Return empty object for successful requests without JSON
|
||||
return {} as T;
|
||||
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
|
||||
// Check if request should be retried
|
||||
if (attempt < config.maxRetries && config.retryCondition?.(error)) {
|
||||
const delay = this.calculateDelay(attempt, config);
|
||||
|
||||
log.warn('api-client', `Request failed with error, retrying in ${delay}ms`, {
|
||||
url,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
attempt: attempt + 1,
|
||||
nextRetryIn: delay
|
||||
});
|
||||
|
||||
await this.delay(delay);
|
||||
attempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Maximum retry attempts exhausted
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle final error
|
||||
log.error('api-client', 'Request failed after all retries', {
|
||||
url,
|
||||
totalAttempts: attempt + 1,
|
||||
error: lastError
|
||||
});
|
||||
|
||||
const appError = await ErrorHandler.handleApiError(lastError);
|
||||
throw appError;
|
||||
}
|
||||
|
||||
/**
|
||||
* GET request
|
||||
*/
|
||||
static async get<T>(url: string, options: RequestConfig = {}): Promise<T> {
|
||||
return this.fetchWithRetry<T>(url, {
|
||||
...options,
|
||||
method: 'GET'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* POST request
|
||||
*/
|
||||
static async post<T>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
options: RequestConfig = {}
|
||||
): Promise<T> {
|
||||
return this.fetchWithRetry<T>(url, {
|
||||
...options,
|
||||
method: 'POST',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* PUT request
|
||||
*/
|
||||
static async put<T>(
|
||||
url: string,
|
||||
body?: unknown,
|
||||
options: RequestConfig = {}
|
||||
): Promise<T> {
|
||||
return this.fetchWithRetry<T>(url, {
|
||||
...options,
|
||||
method: 'PUT',
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options.headers
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* DELETE request
|
||||
*/
|
||||
static async delete<T>(url: string, options: RequestConfig = {}): Promise<T> {
|
||||
return this.fetchWithRetry<T>(url, {
|
||||
...options,
|
||||
method: 'DELETE'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff and jitter
|
||||
*/
|
||||
private static calculateDelay(attempt: number, config: RetryConfig): number {
|
||||
// Exponential backoff: baseDelay * 2^attempt
|
||||
const exponentialDelay = config.baseDelay * Math.pow(2, attempt);
|
||||
|
||||
// Add jitter (randomness) up to 10% of delay
|
||||
const jitter = Math.random() * 0.1 * exponentialDelay;
|
||||
|
||||
// Apply maximum delay
|
||||
return Math.min(exponentialDelay + jitter, config.maxDelay);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delay
|
||||
*/
|
||||
private static delay(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Create request configuration with authorization
|
||||
*/
|
||||
static withAuth(token: string, options: RequestConfig = {}): RequestConfig {
|
||||
return {
|
||||
...options,
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
...options.headers
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Check network status (API availability)
|
||||
*/
|
||||
static async checkHealth(baseUrl: string): Promise<boolean> {
|
||||
try {
|
||||
await this.get(`${baseUrl}/health`, {
|
||||
timeout: 5000,
|
||||
retryConfig: { maxRetries: 1 }
|
||||
});
|
||||
return true;
|
||||
} catch (error) {
|
||||
log.warn('api-client', 'Health check failed', { error });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
312
mc_test/src/renderer/utils/csp.ts
Executable file
312
mc_test/src/renderer/utils/csp.ts
Executable file
@ -0,0 +1,312 @@
|
||||
/**
|
||||
* Content Security Policy utilities for Electron app
|
||||
*/
|
||||
|
||||
export interface CSPConfig {
|
||||
defaultSrc: readonly string[];
|
||||
scriptSrc: readonly string[];
|
||||
styleSrc: readonly string[];
|
||||
imgSrc: readonly string[];
|
||||
fontSrc: readonly string[];
|
||||
connectSrc: readonly string[];
|
||||
frameSrc: readonly string[];
|
||||
objectSrc: readonly string[];
|
||||
baseUri: readonly string[];
|
||||
formAction: readonly string[];
|
||||
frameAncestors: readonly string[];
|
||||
}
|
||||
|
||||
type CSPDirective = keyof CSPConfig;
|
||||
|
||||
const NONCE_PLACEHOLDER = "'nonce-{NONCE}'" as const;
|
||||
const NONCE_SIZE = 16;
|
||||
|
||||
export class CSPManager {
|
||||
private static nonce: string | null = null;
|
||||
|
||||
private static readonly DEVELOPMENT_CONFIG: CSPConfig = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
"'unsafe-inline'", // Required for Vite in dev mode
|
||||
"'unsafe-eval'", // Required for React DevTools
|
||||
"http://localhost:*"
|
||||
],
|
||||
styleSrc: [
|
||||
"'self'",
|
||||
"'unsafe-inline'", // Required for styled-components/emotion
|
||||
"http://localhost:*"
|
||||
],
|
||||
imgSrc: [
|
||||
"'self'",
|
||||
"data:",
|
||||
"blob:",
|
||||
"http://localhost:*"
|
||||
],
|
||||
fontSrc: [
|
||||
"'self'",
|
||||
"data:",
|
||||
"http://localhost:*"
|
||||
],
|
||||
connectSrc: [
|
||||
"'self'",
|
||||
"http://localhost:*",
|
||||
"https://localhost:*",
|
||||
"ws://localhost:*",
|
||||
"wss://localhost:*"
|
||||
],
|
||||
frameSrc: [
|
||||
"'self'",
|
||||
"http://localhost:8080", // Guacamole in development
|
||||
"https://localhost:8080"
|
||||
],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"]
|
||||
};
|
||||
|
||||
private static readonly PRODUCTION_CONFIG: CSPConfig = {
|
||||
defaultSrc: ["'self'"],
|
||||
scriptSrc: [
|
||||
"'self'",
|
||||
"'nonce-{NONCE}'" // Nonce for inline scripts
|
||||
],
|
||||
styleSrc: [
|
||||
"'self'",
|
||||
"'nonce-{NONCE}'", // Nonce for inline styles
|
||||
"https://fonts.googleapis.com"
|
||||
],
|
||||
imgSrc: [
|
||||
"'self'",
|
||||
"data:",
|
||||
"blob:"
|
||||
],
|
||||
fontSrc: [
|
||||
"'self'",
|
||||
"data:",
|
||||
"https://fonts.gstatic.com"
|
||||
],
|
||||
connectSrc: [
|
||||
"'self'",
|
||||
`https://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}`,
|
||||
`wss://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}`
|
||||
],
|
||||
frameSrc: [
|
||||
"'self'",
|
||||
`https://${import.meta.env.VITE_PROD_DOMAIN || 'mc.exbytestudios.com'}`
|
||||
],
|
||||
objectSrc: ["'none'"],
|
||||
baseUri: ["'self'"],
|
||||
formAction: ["'self'"],
|
||||
frameAncestors: ["'none'"]
|
||||
};
|
||||
|
||||
/**
|
||||
* Generate random nonce
|
||||
*/
|
||||
static generateNonce(): string {
|
||||
const array = new Uint8Array(NONCE_SIZE);
|
||||
crypto.getRandomValues(array);
|
||||
return Array.from(array, (byte) => byte.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Set nonce for use in CSP
|
||||
*/
|
||||
static setNonce(nonce: string): void {
|
||||
this.nonce = nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current nonce
|
||||
*/
|
||||
static getNonce(): string | null {
|
||||
return this.nonce;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get CSP configuration for current environment
|
||||
* Dynamically adds API URL from env variables
|
||||
*/
|
||||
static getConfig(): CSPConfig {
|
||||
const baseConfig = this.getBaseConfig();
|
||||
const apiUrl = import.meta.env.VITE_API_URL;
|
||||
|
||||
if (!apiUrl) {
|
||||
return baseConfig;
|
||||
}
|
||||
|
||||
return this.addApiUrlToConfig(baseConfig, apiUrl);
|
||||
}
|
||||
|
||||
private static getBaseConfig(): CSPConfig {
|
||||
return process.env.NODE_ENV === 'development'
|
||||
? this.DEVELOPMENT_CONFIG
|
||||
: this.PRODUCTION_CONFIG;
|
||||
}
|
||||
|
||||
private static addApiUrlToConfig(config: CSPConfig, apiUrl: string): CSPConfig {
|
||||
try {
|
||||
const url = new URL(apiUrl);
|
||||
const { domain, wssDomain } = this.buildDomains(url);
|
||||
|
||||
const updatedConfig: CSPConfig = { ...config };
|
||||
|
||||
if (!config.connectSrc.includes(domain)) {
|
||||
updatedConfig.connectSrc = [...config.connectSrc, domain, wssDomain];
|
||||
}
|
||||
|
||||
if (!config.frameSrc.includes(domain)) {
|
||||
updatedConfig.frameSrc = [...config.frameSrc, domain];
|
||||
}
|
||||
|
||||
console.log('CSP updated with API domain:', domain);
|
||||
return updatedConfig;
|
||||
} catch (error) {
|
||||
console.error('Failed to parse VITE_API_URL for CSP:', error);
|
||||
return config;
|
||||
}
|
||||
}
|
||||
|
||||
private static buildDomains(url: URL): { domain: string; wssDomain: string } {
|
||||
const protocol = url.protocol.replace(':', '');
|
||||
const portSuffix = url.port ? `:${url.port}` : '';
|
||||
const domain = `${protocol}://${url.hostname}${portSuffix}`;
|
||||
const wssProtocol = protocol === 'https' ? 'wss' : 'ws';
|
||||
const wssDomain = `${wssProtocol}://${url.hostname}${portSuffix}`;
|
||||
|
||||
return { domain, wssDomain };
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate CSP string from configuration
|
||||
*/
|
||||
static generateCSPString(config: CSPConfig = this.getConfig()): string {
|
||||
const directives: string[] = [
|
||||
this.buildDirective('default-src', config.defaultSrc),
|
||||
this.buildDirective('script-src', config.scriptSrc),
|
||||
this.buildDirective('style-src', config.styleSrc),
|
||||
this.buildDirective('img-src', config.imgSrc),
|
||||
this.buildDirective('font-src', config.fontSrc),
|
||||
this.buildDirective('connect-src', config.connectSrc),
|
||||
this.buildDirective('frame-src', config.frameSrc),
|
||||
this.buildDirective('object-src', config.objectSrc),
|
||||
this.buildDirective('base-uri', config.baseUri),
|
||||
this.buildDirective('form-action', config.formAction),
|
||||
this.buildDirective('frame-ancestors', config.frameAncestors),
|
||||
];
|
||||
|
||||
return directives.join(' ').trim();
|
||||
}
|
||||
|
||||
private static buildDirective(name: string, sources: readonly string[]): string {
|
||||
const processed = this.processSources([...sources]);
|
||||
return `${name} ${processed.join(' ')};`;
|
||||
}
|
||||
|
||||
private static processSources(sources: string[]): string[] {
|
||||
return sources.map((source) => {
|
||||
if (source === NONCE_PLACEHOLDER && this.nonce) {
|
||||
return `'nonce-${this.nonce}'`;
|
||||
}
|
||||
return source;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply CSP to document
|
||||
*/
|
||||
static applyCSP(config: CSPConfig = this.getConfig()): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
// Generate nonce for production
|
||||
if (process.env.NODE_ENV === 'production' && !this.nonce) {
|
||||
this.nonce = this.generateNonce();
|
||||
}
|
||||
|
||||
// Remove existing CSP meta tag
|
||||
const existingCSP = document.querySelector('meta[http-equiv="Content-Security-Policy"]');
|
||||
if (existingCSP) {
|
||||
existingCSP.remove();
|
||||
}
|
||||
|
||||
// Create new CSP meta tag
|
||||
const meta = document.createElement('meta');
|
||||
meta.setAttribute('http-equiv', 'Content-Security-Policy');
|
||||
meta.setAttribute('content', this.generateCSPString(config));
|
||||
|
||||
document.head.appendChild(meta);
|
||||
|
||||
// Add nonce to meta tag for JavaScript access
|
||||
if (this.nonce) {
|
||||
const nonceMeta = document.createElement('meta');
|
||||
nonceMeta.setAttribute('name', 'csp-nonce');
|
||||
nonceMeta.setAttribute('content', this.nonce);
|
||||
document.head.appendChild(nonceMeta);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup CSP violation reporting
|
||||
*/
|
||||
static setupCSPReporting(): void {
|
||||
if (typeof document === 'undefined') return;
|
||||
|
||||
document.addEventListener('securitypolicyviolation', (event) => {
|
||||
console.warn('CSP Violation:', {
|
||||
directive: event.violatedDirective,
|
||||
blockedURI: event.blockedURI,
|
||||
lineNumber: event.lineNumber,
|
||||
columnNumber: event.columnNumber,
|
||||
sourceFile: event.sourceFile,
|
||||
sample: event.sample
|
||||
});
|
||||
|
||||
// In production, send to server
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
// fetch('/api/csp-violation', {
|
||||
// method: 'POST',
|
||||
// headers: { 'Content-Type': 'application/json' },
|
||||
// body: JSON.stringify({
|
||||
// directive: event.violatedDirective,
|
||||
// blockedURI: event.blockedURI,
|
||||
// sourceFile: event.sourceFile,
|
||||
// timestamp: new Date().toISOString()
|
||||
// })
|
||||
// });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate CSP configuration
|
||||
*/
|
||||
static validateConfig(config: CSPConfig): string[] {
|
||||
const errors: string[] = [];
|
||||
|
||||
// Check for dangerous directives
|
||||
if (config.scriptSrc.includes("'unsafe-inline'")) {
|
||||
errors.push("script-src contains 'unsafe-inline' - security risk");
|
||||
}
|
||||
|
||||
if (config.scriptSrc.includes("'unsafe-eval'")) {
|
||||
errors.push("script-src contains 'unsafe-eval' - security risk");
|
||||
}
|
||||
|
||||
if (config.objectSrc.includes("'self'") || config.objectSrc.length === 0) {
|
||||
errors.push("object-src should be 'none' for security");
|
||||
}
|
||||
|
||||
if (config.frameAncestors.includes("'self'")) {
|
||||
errors.push("frame-ancestors should be 'none' for clickjacking protection");
|
||||
}
|
||||
|
||||
return errors;
|
||||
}
|
||||
}
|
||||
|
||||
// Export convenience functions
|
||||
export const generateCSP = CSPManager.generateCSPString;
|
||||
export const applyCSP = CSPManager.applyCSP;
|
||||
export const setupCSPReporting = CSPManager.setupCSPReporting;
|
||||
332
mc_test/src/renderer/utils/errorHandler.ts
Executable file
332
mc_test/src/renderer/utils/errorHandler.ts
Executable file
@ -0,0 +1,332 @@
|
||||
import { log } from './logger';
|
||||
|
||||
export enum ErrorType {
|
||||
NETWORK = 'NETWORK',
|
||||
AUTH = 'AUTH',
|
||||
VALIDATION = 'VALIDATION',
|
||||
PERMISSION = 'PERMISSION',
|
||||
GUACAMOLE = 'GUACAMOLE',
|
||||
ENCRYPTION = 'ENCRYPTION',
|
||||
UNKNOWN = 'UNKNOWN'
|
||||
}
|
||||
|
||||
export interface AppError {
|
||||
type: ErrorType;
|
||||
message: string;
|
||||
code?: string;
|
||||
details?: unknown;
|
||||
timestamp: Date;
|
||||
userMessage: string;
|
||||
retryable: boolean;
|
||||
}
|
||||
|
||||
interface ErrorData {
|
||||
detail?: string;
|
||||
message?: string;
|
||||
required_role?: string;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export class ErrorHandler {
|
||||
/**
|
||||
* Create standardized application error
|
||||
*/
|
||||
static createError(
|
||||
type: ErrorType,
|
||||
message: string,
|
||||
userMessage?: string,
|
||||
details?: unknown,
|
||||
retryable: boolean = false
|
||||
): AppError {
|
||||
const error: AppError = {
|
||||
type,
|
||||
message,
|
||||
userMessage: userMessage || this.getDefaultUserMessage(type),
|
||||
details,
|
||||
timestamp: new Date(),
|
||||
retryable
|
||||
};
|
||||
|
||||
// Log error
|
||||
log.error('error-handler', `${type} error occurred`, {
|
||||
type,
|
||||
message,
|
||||
userMessage: error.userMessage,
|
||||
retryable,
|
||||
details
|
||||
});
|
||||
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle API request errors
|
||||
*/
|
||||
static async handleApiError(error: unknown): Promise<AppError> {
|
||||
// Response object with HTTP error
|
||||
if (error instanceof Response) {
|
||||
const errorData = await this.parseErrorResponse(error);
|
||||
return this.handleHttpError(error, errorData);
|
||||
}
|
||||
|
||||
// Network or other errors
|
||||
return this.handleNonHttpError(error);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse error response from API
|
||||
*/
|
||||
private static async parseErrorResponse(response: Response): Promise<ErrorData> {
|
||||
try {
|
||||
const contentType = response.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return await response.json() as ErrorData;
|
||||
}
|
||||
const text = await response.text();
|
||||
return { message: text };
|
||||
} catch (parseError) {
|
||||
log.warn('error-handler', 'Failed to parse error response', { parseError });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle HTTP errors
|
||||
*/
|
||||
private static handleHttpError(response: Response, errorData: ErrorData): AppError {
|
||||
|
||||
switch (response.status) {
|
||||
case 401:
|
||||
return this.handle401Error(response, errorData);
|
||||
|
||||
case 403:
|
||||
return this.handle403Error(response, errorData);
|
||||
|
||||
case 404:
|
||||
return this.createError(
|
||||
ErrorType.NETWORK,
|
||||
'Resource not found',
|
||||
'The requested resource was not found.',
|
||||
{ status: response.status, details: errorData }
|
||||
);
|
||||
|
||||
case 422:
|
||||
return this.createError(
|
||||
ErrorType.VALIDATION,
|
||||
'Validation error',
|
||||
errorData.detail || 'Please check your input and try again.',
|
||||
{ status: response.status, details: errorData }
|
||||
);
|
||||
|
||||
case 429:
|
||||
return this.createError(
|
||||
ErrorType.NETWORK,
|
||||
'Too many requests',
|
||||
'Too many requests. Please wait a moment and try again.',
|
||||
{ status: response.status, details: errorData },
|
||||
true
|
||||
);
|
||||
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
return this.createError(
|
||||
ErrorType.NETWORK,
|
||||
'Server error',
|
||||
'A server error occurred. Please try again later.',
|
||||
{ status: response.status, details: errorData },
|
||||
true
|
||||
);
|
||||
|
||||
default:
|
||||
return this.createError(
|
||||
ErrorType.NETWORK,
|
||||
`HTTP ${response.status}: ${response.statusText}`,
|
||||
'A network error occurred. Please try again.',
|
||||
{ status: response.status, details: errorData },
|
||||
response.status >= 500
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 401 Unauthorized errors
|
||||
*/
|
||||
private static handle401Error(response: Response, errorData: ErrorData): AppError {
|
||||
const isLoginError =
|
||||
errorData.detail?.includes('Invalid username or password') ||
|
||||
errorData.detail?.includes('Login blocked');
|
||||
|
||||
const userMessage = isLoginError
|
||||
? errorData.detail!
|
||||
: 'Your session has expired. Please log in again.';
|
||||
|
||||
return this.createError(
|
||||
ErrorType.AUTH,
|
||||
'Unauthorized access',
|
||||
userMessage,
|
||||
{ status: response.status, details: errorData }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle 403 Forbidden errors
|
||||
*/
|
||||
private static handle403Error(response: Response, errorData: ErrorData): AppError {
|
||||
let userMessage = 'You don\'t have permission to perform this action.';
|
||||
|
||||
if (errorData.message?.includes('create_connections') ||
|
||||
errorData.required_role?.includes('privileges')) {
|
||||
userMessage = 'Your account has limited access (GUEST role). You can only view connections but cannot create new ones. Please contact your administrator to upgrade your account to USER role for full access.';
|
||||
} else if (errorData.message) {
|
||||
userMessage = `Access denied: ${errorData.message}`;
|
||||
}
|
||||
|
||||
return this.createError(
|
||||
ErrorType.PERMISSION,
|
||||
'Forbidden access',
|
||||
userMessage,
|
||||
{ status: response.status, details: errorData }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle non-HTTP errors (network, encryption, etc.)
|
||||
*/
|
||||
private static handleNonHttpError(error: unknown): AppError {
|
||||
if (error instanceof Error) {
|
||||
if (error.name === 'TypeError' && error.message.includes('fetch')) {
|
||||
return this.createError(
|
||||
ErrorType.NETWORK,
|
||||
'Network connection failed',
|
||||
'Unable to connect to the server. Please check your internet connection.',
|
||||
{ originalError: error.message },
|
||||
true // Retryable
|
||||
);
|
||||
}
|
||||
|
||||
if (error.name === 'AbortError') {
|
||||
return this.createError(
|
||||
ErrorType.NETWORK,
|
||||
'Request timeout',
|
||||
'The request took too long. Please try again.',
|
||||
{ originalError: error.message },
|
||||
true // Retryable
|
||||
);
|
||||
}
|
||||
|
||||
// Encryption errors
|
||||
if (error.message.includes('encryption') || error.message.includes('crypto')) {
|
||||
return this.createError(
|
||||
ErrorType.ENCRYPTION,
|
||||
'Encryption error',
|
||||
'A security error occurred. Please try logging in again.',
|
||||
{ originalError: error.message }
|
||||
);
|
||||
}
|
||||
|
||||
// Guacamole errors
|
||||
if (error.message.includes('guacamole') || error.message.includes('connection')) {
|
||||
return this.createError(
|
||||
ErrorType.GUACAMOLE,
|
||||
'Connection error',
|
||||
'Failed to connect to the remote machine. Please check credentials and try again.',
|
||||
{ originalError: error.message },
|
||||
true // Retryable
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Unknown error
|
||||
return this.createError(
|
||||
ErrorType.UNKNOWN,
|
||||
error instanceof Error ? error.message : String(error),
|
||||
'An unexpected error occurred. Please try again.',
|
||||
{ originalError: error }
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if operation should be retried
|
||||
*/
|
||||
static shouldRetry(error: AppError, attempt: number, maxAttempts: number = 3): boolean {
|
||||
if (attempt >= maxAttempts) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error.retryable;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default user message
|
||||
*/
|
||||
private static getDefaultUserMessage(type: ErrorType): string {
|
||||
switch (type) {
|
||||
case ErrorType.NETWORK:
|
||||
return 'A network error occurred. Please try again.';
|
||||
case ErrorType.AUTH:
|
||||
return 'Authentication failed. Please log in again.';
|
||||
case ErrorType.VALIDATION:
|
||||
return 'Please check your input and try again.';
|
||||
case ErrorType.PERMISSION:
|
||||
return 'You don\'t have permission to perform this action.';
|
||||
case ErrorType.GUACAMOLE:
|
||||
return 'Failed to connect to the remote machine. Please try again.';
|
||||
case ErrorType.ENCRYPTION:
|
||||
return 'A security error occurred. Please try again.';
|
||||
default:
|
||||
return 'An unexpected error occurred. Please try again.';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format error for UI display
|
||||
*/
|
||||
static formatErrorForUI(error: AppError): {
|
||||
title: string;
|
||||
message: string;
|
||||
type: 'error' | 'warning';
|
||||
action?: string;
|
||||
} {
|
||||
let title: string;
|
||||
let type: 'error' | 'warning' = 'error';
|
||||
let action: string | undefined;
|
||||
|
||||
switch (error.type) {
|
||||
case ErrorType.AUTH:
|
||||
title = 'Authentication Error';
|
||||
action = 'Please log in again';
|
||||
break;
|
||||
case ErrorType.NETWORK:
|
||||
title = 'Connection Error';
|
||||
type = error.retryable ? 'warning' : 'error';
|
||||
action = error.retryable ? 'Retrying...' : undefined;
|
||||
break;
|
||||
case ErrorType.VALIDATION:
|
||||
title = 'Input Error';
|
||||
type = 'warning';
|
||||
break;
|
||||
case ErrorType.PERMISSION:
|
||||
title = 'Access Denied';
|
||||
break;
|
||||
case ErrorType.GUACAMOLE:
|
||||
title = 'Connection Failed';
|
||||
action = 'Check credentials and try again';
|
||||
break;
|
||||
case ErrorType.ENCRYPTION:
|
||||
title = 'Security Error';
|
||||
action = 'Please restart the application';
|
||||
break;
|
||||
default:
|
||||
title = 'Error';
|
||||
}
|
||||
|
||||
return {
|
||||
title,
|
||||
message: error.userMessage,
|
||||
type,
|
||||
action
|
||||
};
|
||||
}
|
||||
}
|
||||
51
mc_test/src/renderer/utils/logger.ts
Executable file
51
mc_test/src/renderer/utils/logger.ts
Executable file
@ -0,0 +1,51 @@
|
||||
import { LogLevel } from '../../types/electron';
|
||||
|
||||
type LogMethod = (tag: string, message: string, context?: Record<string, unknown>) => void;
|
||||
|
||||
type ConsoleMethod = 'log' | 'info' | 'warn' | 'error';
|
||||
|
||||
class Logger {
|
||||
private readonly isDevelopment = import.meta.env.DEV;
|
||||
|
||||
private getConsoleMethod(level: LogLevel): ConsoleMethod {
|
||||
return level === 'debug' ? 'log' : level;
|
||||
}
|
||||
|
||||
private createLogMethod(level: LogLevel): LogMethod {
|
||||
return (tag: string, message: string, context?: Record<string, unknown>): void => {
|
||||
// Always output to console in development mode
|
||||
if (this.isDevelopment) {
|
||||
const contextStr = context ? ` | context: ${JSON.stringify(context)}` : '';
|
||||
const consoleMethod = this.getConsoleMethod(level);
|
||||
console[consoleMethod](`[${tag}] ${message}${contextStr}`);
|
||||
}
|
||||
|
||||
// Send to main process via IPC only if electronAPI is available
|
||||
this.sendToMainProcess(level, tag, message, context);
|
||||
};
|
||||
}
|
||||
|
||||
private sendToMainProcess(
|
||||
level: LogLevel,
|
||||
tag: string,
|
||||
message: string,
|
||||
context?: Record<string, unknown>
|
||||
): void {
|
||||
try {
|
||||
if (window.electronAPI?.logEvent) {
|
||||
window.electronAPI.logEvent(level, tag, message, context);
|
||||
}
|
||||
} catch (error) {
|
||||
// Fallback to console if IPC fails
|
||||
console.error('Failed to send log to main process:', error);
|
||||
}
|
||||
}
|
||||
|
||||
public readonly debug = this.createLogMethod('debug');
|
||||
public readonly info = this.createLogMethod('info');
|
||||
public readonly warn = this.createLogMethod('warn');
|
||||
public readonly error = this.createLogMethod('error');
|
||||
}
|
||||
|
||||
export const log = new Logger();
|
||||
export default log;
|
||||
309
mc_test/src/renderer/utils/protocolHelper.ts
Executable file
309
mc_test/src/renderer/utils/protocolHelper.ts
Executable file
@ -0,0 +1,309 @@
|
||||
export type OSFamily = 'windows' | 'linux' | 'macos' | 'unix' | 'unknown';
|
||||
export type ProtocolType = 'rdp' | 'vnc' | 'ssh' | 'telnet';
|
||||
|
||||
export interface ProtocolOption {
|
||||
value: ProtocolType;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
defaultPort: number;
|
||||
requirements?: readonly string[];
|
||||
}
|
||||
|
||||
export interface OSInfo {
|
||||
family: OSFamily;
|
||||
version?: string;
|
||||
architecture?: string;
|
||||
}
|
||||
|
||||
export interface CompatibilityInfo {
|
||||
recommendations: readonly string[];
|
||||
warnings: readonly string[];
|
||||
tips: readonly string[];
|
||||
}
|
||||
|
||||
export interface PortValidation {
|
||||
valid: boolean;
|
||||
suggestion?: number;
|
||||
}
|
||||
|
||||
const VERSION_PATTERNS = {
|
||||
WINDOWS: [
|
||||
/windows\s*(\d+)/i,
|
||||
/windows\s*(xp|vista|7|8|8\.1|10|11)/i,
|
||||
/windows\s*(server\s*\d+)/i,
|
||||
],
|
||||
LINUX: [
|
||||
/ubuntu\s*(\d+\.\d+)/i,
|
||||
/debian\s*(\d+)/i,
|
||||
/centos\s*(\d+)/i,
|
||||
/rhel\s*(\d+)/i,
|
||||
],
|
||||
MACOS: [
|
||||
/macos\s*(\d+\.\d+)/i,
|
||||
/mac\s*os\s*(\d+\.\d+)/i,
|
||||
],
|
||||
} as const;
|
||||
|
||||
export class ProtocolHelper {
|
||||
private static readonly PROTOCOLS: Readonly<Record<ProtocolType, ProtocolOption>> = {
|
||||
rdp: {
|
||||
value: 'rdp',
|
||||
label: 'RDP',
|
||||
description: 'Remote Desktop Protocol - Native Windows remote access',
|
||||
icon: '🖥️',
|
||||
defaultPort: 3389,
|
||||
requirements: ['Windows OS', 'Remote Desktop enabled']
|
||||
},
|
||||
vnc: {
|
||||
value: 'vnc',
|
||||
label: 'VNC',
|
||||
description: 'Virtual Network Computing - Cross-platform remote desktop',
|
||||
icon: '🖱️',
|
||||
defaultPort: 5900,
|
||||
requirements: ['VNC Server installed']
|
||||
},
|
||||
ssh: {
|
||||
value: 'ssh',
|
||||
label: 'SSH',
|
||||
description: 'Secure Shell - Command line access',
|
||||
icon: '⌨️',
|
||||
defaultPort: 22,
|
||||
requirements: ['SSH Server enabled']
|
||||
},
|
||||
telnet: {
|
||||
value: 'telnet',
|
||||
label: 'Telnet',
|
||||
description: 'Telnet - Legacy command line access (not secure)',
|
||||
icon: '📡',
|
||||
defaultPort: 23,
|
||||
requirements: ['Telnet Server enabled']
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse OS string into structured information
|
||||
*/
|
||||
static parseOS(osString: string): OSInfo {
|
||||
const os = osString.toLowerCase();
|
||||
|
||||
if (os.includes('windows')) {
|
||||
return {
|
||||
family: 'windows',
|
||||
version: this.extractWindowsVersion(os),
|
||||
architecture: this.extractArchitecture(os)
|
||||
};
|
||||
}
|
||||
|
||||
if (os.includes('ubuntu') || os.includes('debian') || os.includes('centos') ||
|
||||
os.includes('rhel') || os.includes('linux')) {
|
||||
return {
|
||||
family: 'linux',
|
||||
version: this.extractLinuxVersion(os),
|
||||
architecture: this.extractArchitecture(os)
|
||||
};
|
||||
}
|
||||
|
||||
if (os.includes('macos') || os.includes('mac os') || os.includes('darwin')) {
|
||||
return {
|
||||
family: 'macos',
|
||||
version: this.extractMacVersion(os),
|
||||
architecture: this.extractArchitecture(os)
|
||||
};
|
||||
}
|
||||
|
||||
if (os.includes('unix') || os.includes('solaris') || os.includes('aix')) {
|
||||
return {
|
||||
family: 'unix',
|
||||
architecture: this.extractArchitecture(os)
|
||||
};
|
||||
}
|
||||
|
||||
return { family: 'unknown' };
|
||||
}
|
||||
|
||||
/**
|
||||
* Get recommended protocols for given OS
|
||||
*/
|
||||
static getRecommendedProtocols(osInfo: OSInfo): ProtocolOption[] {
|
||||
const protocols: ProtocolOption[] = [];
|
||||
|
||||
switch (osInfo.family) {
|
||||
case 'windows':
|
||||
// Windows: RDP first, then VNC and SSH
|
||||
protocols.push(
|
||||
this.PROTOCOLS.rdp,
|
||||
this.PROTOCOLS.vnc,
|
||||
this.PROTOCOLS.ssh
|
||||
);
|
||||
break;
|
||||
|
||||
case 'linux':
|
||||
// Linux: SSH first, then VNC
|
||||
protocols.push(
|
||||
this.PROTOCOLS.ssh,
|
||||
this.PROTOCOLS.vnc
|
||||
);
|
||||
// For Linux with GUI, add Telnet as fallback
|
||||
if (this.hasDesktopEnvironment(osInfo)) {
|
||||
protocols.push(this.PROTOCOLS.telnet);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'macos':
|
||||
// macOS: SSH first, VNC second
|
||||
protocols.push(
|
||||
this.PROTOCOLS.ssh,
|
||||
this.PROTOCOLS.vnc
|
||||
);
|
||||
break;
|
||||
|
||||
case 'unix':
|
||||
// Unix systems: SSH and Telnet
|
||||
protocols.push(
|
||||
this.PROTOCOLS.ssh,
|
||||
this.PROTOCOLS.telnet
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
// Unknown OS: show all options
|
||||
protocols.push(
|
||||
this.PROTOCOLS.ssh,
|
||||
this.PROTOCOLS.vnc,
|
||||
this.PROTOCOLS.rdp,
|
||||
this.PROTOCOLS.telnet
|
||||
);
|
||||
}
|
||||
|
||||
return protocols;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get default protocol for OS
|
||||
*/
|
||||
static getDefaultProtocol(osInfo: OSInfo): ProtocolOption {
|
||||
const recommended = this.getRecommendedProtocols(osInfo);
|
||||
return recommended[0] || this.PROTOCOLS.ssh;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get additional compatibility information
|
||||
*/
|
||||
static getCompatibilityInfo(osInfo: OSInfo): CompatibilityInfo {
|
||||
const info: {
|
||||
recommendations: string[];
|
||||
warnings: string[];
|
||||
tips: string[];
|
||||
} = {
|
||||
recommendations: [],
|
||||
warnings: [],
|
||||
tips: [],
|
||||
};
|
||||
|
||||
switch (osInfo.family) {
|
||||
case 'windows':
|
||||
info.recommendations.push('RDP provides the best experience for Windows machines');
|
||||
info.tips.push('Ensure Remote Desktop is enabled in Windows settings');
|
||||
if (osInfo.version && osInfo.version.includes('home')) {
|
||||
info.warnings.push('Windows Home editions may have limited RDP support');
|
||||
}
|
||||
break;
|
||||
|
||||
case 'linux':
|
||||
info.recommendations.push('SSH is the most reliable option for Linux systems');
|
||||
info.tips.push('VNC requires a desktop environment and VNC server installation');
|
||||
break;
|
||||
|
||||
case 'macos':
|
||||
info.recommendations.push('SSH works best for macOS command line access');
|
||||
info.tips.push('Enable "Remote Login" in System Preferences for SSH');
|
||||
info.warnings.push('VNC may require additional setup on macOS');
|
||||
break;
|
||||
|
||||
default:
|
||||
info.tips.push('Try SSH first as it\'s widely supported');
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate port for protocol
|
||||
*/
|
||||
static validatePort(protocol: string, port: number): PortValidation {
|
||||
const protocolInfo = this.PROTOCOLS[protocol as ProtocolType];
|
||||
if (!protocolInfo) {
|
||||
return { valid: false };
|
||||
}
|
||||
|
||||
if (port === protocolInfo.defaultPort) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
const alternativePorts = this.getAlternativePorts(protocol);
|
||||
if (alternativePorts.includes(port)) {
|
||||
return { valid: true };
|
||||
}
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
suggestion: protocolInfo.defaultPort,
|
||||
};
|
||||
}
|
||||
|
||||
private static extractVersion(os: string, patterns: readonly RegExp[]): string {
|
||||
for (const pattern of patterns) {
|
||||
const match = os.match(pattern);
|
||||
if (match?.[1]) return match[1];
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static extractWindowsVersion(os: string): string {
|
||||
return this.extractVersion(os, VERSION_PATTERNS.WINDOWS);
|
||||
}
|
||||
|
||||
private static extractLinuxVersion(os: string): string {
|
||||
return this.extractVersion(os, VERSION_PATTERNS.LINUX);
|
||||
}
|
||||
|
||||
private static extractMacVersion(os: string): string {
|
||||
return this.extractVersion(os, VERSION_PATTERNS.MACOS);
|
||||
}
|
||||
|
||||
private static extractArchitecture(os: string): string {
|
||||
if (os.includes('x64') || os.includes('x86_64') || os.includes('amd64')) {
|
||||
return 'x64';
|
||||
}
|
||||
if (os.includes('x86') || os.includes('i386')) {
|
||||
return 'x86';
|
||||
}
|
||||
if (os.includes('arm64') || os.includes('aarch64')) {
|
||||
return 'arm64';
|
||||
}
|
||||
if (os.includes('arm')) {
|
||||
return 'arm';
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
private static hasDesktopEnvironment(osInfo: OSInfo): boolean {
|
||||
// Simple heuristic for detecting GUI presence
|
||||
return osInfo.version?.includes('desktop') ||
|
||||
osInfo.version?.includes('gnome') ||
|
||||
osInfo.version?.includes('kde') ||
|
||||
!osInfo.version?.includes('server');
|
||||
}
|
||||
|
||||
private static getAlternativePorts(protocol: string): number[] {
|
||||
const alternatives: Record<string, number[]> = {
|
||||
rdp: [3389, 3390, 3391],
|
||||
vnc: [5900, 5901, 5902, 5903],
|
||||
ssh: [22, 2222, 2022],
|
||||
telnet: [23, 2323]
|
||||
};
|
||||
|
||||
return alternatives[protocol] || [];
|
||||
}
|
||||
}
|
||||
236
mc_test/src/renderer/utils/sanitizer.ts
Executable file
236
mc_test/src/renderer/utils/sanitizer.ts
Executable file
@ -0,0 +1,236 @@
|
||||
const REGEX_PATTERNS = {
|
||||
IPV4: /^(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/,
|
||||
IPV6: /^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$/,
|
||||
MACHINE_NAME: /[^a-zA-Z0-9\-_\s]/g,
|
||||
USERNAME: /[^a-z0-9@._-]/g,
|
||||
CONTROL_CHARS: /[\x00-\x1f\x7f]/,
|
||||
TEXT_SANITIZE: /[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]/g,
|
||||
} as const;
|
||||
|
||||
const VALIDATION_LIMITS = {
|
||||
MACHINE_NAME: { MAX: 50 },
|
||||
USERNAME: { MIN: 3, MAX: 50 },
|
||||
PASSWORD: { MIN: 8, MAX: 128 },
|
||||
TEXT: { MAX: 1000 },
|
||||
PORT: { MIN: 1, MAX: 65535 },
|
||||
} as const;
|
||||
|
||||
const ALLOWED_PROTOCOLS = ['rdp', 'vnc', 'ssh', 'telnet'] as const;
|
||||
type AllowedProtocol = typeof ALLOWED_PROTOCOLS[number];
|
||||
|
||||
export class InputSanitizer {
|
||||
/**
|
||||
* Sanitize and validate IP address
|
||||
*/
|
||||
static sanitizeIpAddress(ip: string): string {
|
||||
const cleanIp = ip.trim().replace(/[^0-9.:a-fA-F]/g, '');
|
||||
|
||||
if (REGEX_PATTERNS.IPV4.test(cleanIp) || REGEX_PATTERNS.IPV6.test(cleanIp)) {
|
||||
return cleanIp;
|
||||
}
|
||||
|
||||
throw new Error('Invalid IP address format');
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize machine name
|
||||
*/
|
||||
static sanitizeMachineName(name: string): string {
|
||||
this.validateStringInput(name, 'Machine name');
|
||||
|
||||
const sanitized = name.trim().replace(REGEX_PATTERNS.MACHINE_NAME, '');
|
||||
|
||||
if (sanitized.length === 0) {
|
||||
throw new Error('Machine name cannot be empty');
|
||||
}
|
||||
|
||||
if (sanitized.length > VALIDATION_LIMITS.MACHINE_NAME.MAX) {
|
||||
throw new Error(`Machine name too long (max ${VALIDATION_LIMITS.MACHINE_NAME.MAX} characters)`);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize username
|
||||
*/
|
||||
static sanitizeUsername(username: string): string {
|
||||
this.validateStringInput(username, 'Username');
|
||||
|
||||
const sanitized = username.trim().toLowerCase().replace(REGEX_PATTERNS.USERNAME, '');
|
||||
|
||||
if (sanitized.length < VALIDATION_LIMITS.USERNAME.MIN) {
|
||||
throw new Error(`Username too short (min ${VALIDATION_LIMITS.USERNAME.MIN} characters)`);
|
||||
}
|
||||
|
||||
if (sanitized.length > VALIDATION_LIMITS.USERNAME.MAX) {
|
||||
throw new Error(`Username too long (max ${VALIDATION_LIMITS.USERNAME.MAX} characters)`);
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate password (no sanitization, only validation)
|
||||
*/
|
||||
static validatePassword(password: string): boolean {
|
||||
this.validateStringInput(password, 'Password');
|
||||
|
||||
if (password.length < VALIDATION_LIMITS.PASSWORD.MIN) {
|
||||
throw new Error(`Password too short (min ${VALIDATION_LIMITS.PASSWORD.MIN} characters)`);
|
||||
}
|
||||
|
||||
if (password.length > VALIDATION_LIMITS.PASSWORD.MAX) {
|
||||
throw new Error(`Password too long (max ${VALIDATION_LIMITS.PASSWORD.MAX} characters)`);
|
||||
}
|
||||
|
||||
if (REGEX_PATTERNS.CONTROL_CHARS.test(password)) {
|
||||
throw new Error('Password contains invalid characters');
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize URL for Guacamole
|
||||
*/
|
||||
static sanitizeUrl(url: string): string {
|
||||
if (!url || typeof url !== 'string') {
|
||||
throw new Error('URL is required');
|
||||
}
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(url.trim());
|
||||
|
||||
// Allow only HTTP/HTTPS
|
||||
if (!['http:', 'https:'].includes(parsedUrl.protocol)) {
|
||||
throw new Error('Invalid protocol. Only HTTP and HTTPS are allowed');
|
||||
}
|
||||
|
||||
// Check that host is not localhost in production
|
||||
if (process.env.NODE_ENV === 'production' &&
|
||||
['localhost', '127.0.0.1', '0.0.0.0'].includes(parsedUrl.hostname)) {
|
||||
throw new Error('Localhost URLs not allowed in production');
|
||||
}
|
||||
|
||||
return parsedUrl.toString();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('Invalid URL')) {
|
||||
throw new Error('Invalid URL format');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize general purpose text fields
|
||||
*/
|
||||
static sanitizeText(text: string, maxLength: number = VALIDATION_LIMITS.TEXT.MAX): string {
|
||||
if (!text || typeof text !== 'string') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return text
|
||||
.replace(REGEX_PATTERNS.TEXT_SANITIZE, '')
|
||||
.trim()
|
||||
.slice(0, maxLength);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate port number
|
||||
*/
|
||||
static validatePort(port: string | number): number {
|
||||
const portNum = typeof port === 'string' ? parseInt(port, 10) : port;
|
||||
|
||||
if (
|
||||
isNaN(portNum) ||
|
||||
portNum < VALIDATION_LIMITS.PORT.MIN ||
|
||||
portNum > VALIDATION_LIMITS.PORT.MAX
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid port number (must be ${VALIDATION_LIMITS.PORT.MIN}-${VALIDATION_LIMITS.PORT.MAX})`
|
||||
);
|
||||
}
|
||||
|
||||
return portNum;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize connection protocol
|
||||
*/
|
||||
static sanitizeProtocol(protocol: string): AllowedProtocol {
|
||||
this.validateStringInput(protocol, 'Protocol');
|
||||
|
||||
const sanitized = protocol.toLowerCase().trim();
|
||||
|
||||
if (!ALLOWED_PROTOCOLS.includes(sanitized as AllowedProtocol)) {
|
||||
throw new Error(`Invalid protocol. Allowed: ${ALLOWED_PROTOCOLS.join(', ')}`);
|
||||
}
|
||||
|
||||
return sanitized as AllowedProtocol;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate string input
|
||||
*/
|
||||
private static validateStringInput(value: string, fieldName: string): void {
|
||||
if (!value || typeof value !== 'string') {
|
||||
throw new Error(`${fieldName} is required`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// React hook for form sanitization
|
||||
export const useSanitizedForm = () => {
|
||||
const sanitizeFormData = (data: Record<string, unknown>) => {
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(data)) {
|
||||
if (typeof value === 'string') {
|
||||
try {
|
||||
switch (key) {
|
||||
case 'ip':
|
||||
case 'ipAddress':
|
||||
sanitized[key] = InputSanitizer.sanitizeIpAddress(value);
|
||||
break;
|
||||
case 'name':
|
||||
case 'machineName':
|
||||
case 'displayName':
|
||||
sanitized[key] = InputSanitizer.sanitizeMachineName(value);
|
||||
break;
|
||||
case 'username':
|
||||
case 'login':
|
||||
sanitized[key] = InputSanitizer.sanitizeUsername(value);
|
||||
break;
|
||||
case 'password':
|
||||
InputSanitizer.validatePassword(value);
|
||||
sanitized[key] = value; // Password is not sanitized, only validated
|
||||
break;
|
||||
case 'url':
|
||||
case 'connectionUrl':
|
||||
sanitized[key] = InputSanitizer.sanitizeUrl(value);
|
||||
break;
|
||||
case 'port':
|
||||
sanitized[key] = InputSanitizer.validatePort(value);
|
||||
break;
|
||||
case 'protocol':
|
||||
sanitized[key] = InputSanitizer.sanitizeProtocol(value);
|
||||
break;
|
||||
default:
|
||||
// For other fields, apply basic sanitization
|
||||
sanitized[key] = InputSanitizer.sanitizeText(value);
|
||||
}
|
||||
} catch (error) {
|
||||
// Propagate validation errors up
|
||||
throw new Error(`${key}: ${error instanceof Error ? error.message : 'Invalid value'}`);
|
||||
}
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
return { sanitizeFormData };
|
||||
};
|
||||
31
mc_test/src/types/electron.d.ts
vendored
Executable file
31
mc_test/src/types/electron.d.ts
vendored
Executable file
@ -0,0 +1,31 @@
|
||||
import { Machine } from '../renderer/types';
|
||||
|
||||
export type LogLevel = 'error' | 'warn' | 'info' | 'debug';
|
||||
export type MachineAction = 'start' | 'stop' | 'restart';
|
||||
|
||||
export interface MachineResponse {
|
||||
success: boolean;
|
||||
message: string;
|
||||
machines?: Machine[];
|
||||
status?: 'running' | 'stopped' | 'error' | 'unknown' | 'checking';
|
||||
details?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
export interface ElectronAPI {
|
||||
connectToMachine: (machine: Machine) => Promise<MachineResponse>;
|
||||
controlMachine: (machineId: string, action: MachineAction) => Promise<MachineResponse>;
|
||||
getMachineStatus: (machineId: string) => Promise<MachineResponse>;
|
||||
getMachineList: () => Promise<MachineResponse>;
|
||||
logEvent: (level: LogLevel, tag: string, message: string, context?: Record<string, unknown>) => void;
|
||||
|
||||
// ✅ ИСПРАВЛЕНО: Безопасное хранилище (Electron safeStorage)
|
||||
encryptData: (plaintext: string) => Promise<string>;
|
||||
decryptData: (encrypted: string) => Promise<string>;
|
||||
isEncryptionAvailable: () => Promise<boolean>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
electronAPI: ElectronAPI;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user