From dd04743545b9dbaf783eb0b07d7edbaf5ca99b7e Mon Sep 17 00:00:00 2001 From: "courtmanr@gmail.com" Date: Sat, 8 Mar 2025 19:55:55 +0000 Subject: [PATCH] Refactor proxmox-client.ts into smaller modules for better maintainability --- src/api/proxmox-client.ts | 667 +----------------------------- src/api/proxmox/events.ts | 175 ++++++++ src/api/proxmox/guests.ts | 217 ++++++++++ src/api/proxmox/index.ts | 170 ++++++++ src/api/proxmox/node-discovery.ts | 103 +++++ src/api/proxmox/node-status.ts | 91 ++++ src/api/proxmox/types.ts | 16 + 7 files changed, 777 insertions(+), 662 deletions(-) create mode 100644 src/api/proxmox/events.ts create mode 100644 src/api/proxmox/guests.ts create mode 100644 src/api/proxmox/index.ts create mode 100644 src/api/proxmox/node-discovery.ts create mode 100644 src/api/proxmox/node-status.ts create mode 100644 src/api/proxmox/types.ts diff --git a/src/api/proxmox-client.ts b/src/api/proxmox-client.ts index d46ff0cb8..ed967de00 100644 --- a/src/api/proxmox-client.ts +++ b/src/api/proxmox-client.ts @@ -1,664 +1,7 @@ -import axios, { AxiosInstance } from 'axios'; -import https from 'https'; -import { EventEmitter } from 'events'; -import { createLogger } from '../utils/logger'; -import { NodeConfig, ProxmoxNodeStatus, ProxmoxVM, ProxmoxContainer, ProxmoxEvent } from '../types'; -import { formatSafeTokenId } from '../utils/config-validator'; -import { formatBytes, bytesToMB, mbToBytes } from '../utils/format'; -import config from '../config'; -import winston from 'winston'; +// This file will be refactored into smaller modules +// Import from the new modules instead -export class ProxmoxClient extends EventEmitter { - private config: NodeConfig; - private logger: winston.Logger; - private client: AxiosInstance | null = null; - private retryAttempts: number = 3; - private retryDelayMs: number = 5000; - private eventLastTimestamp = 0; - private isMockData: boolean = false; - private nodeName = ''; +import { ProxmoxClient } from './proxmox'; - constructor(config: NodeConfig, ignoreSSLErrors: boolean = false) { - super(); - this.config = config; - this.logger = createLogger('ProxmoxClient', config.id); - - // Check if this is a mock data client - this.isMockData = process.env.USE_MOCK_DATA === 'true' || process.env.MOCK_DATA_ENABLED === 'true'; - - if (this.isMockData) { - this.logger.info('Mock data mode enabled. Using mock data server.'); - } else { - // Get timeout from environment variable or use default - const apiTimeoutMs = parseInt(process.env.API_TIMEOUT_MS || '60000', 10); - this.retryAttempts = parseInt(process.env.API_RETRY_ATTEMPTS || '3', 10); - this.retryDelayMs = parseInt(process.env.API_RETRY_DELAY_MS || '5000', 10); - - // Create axios instance with base configuration - const axiosConfig = { - baseURL: `${config.host}/api2/json`, - headers: { - Authorization: `PVEAPIToken=${config.tokenId}=${config.tokenSecret}` - }, - timeout: apiTimeoutMs, - httpsAgent: new https.Agent({ - rejectUnauthorized: !ignoreSSLErrors - }) - }; - - this.client = axios.create(axiosConfig); - this.logger.info(`ProxMox API client created with timeout: ${apiTimeoutMs}ms and ${this.retryAttempts} retry attempts`); - - // Add request interceptor for logging - this.client.interceptors.request.use(request => { - this.logger.debug(`API Request: ${request.method?.toUpperCase()} ${request.url}`, { - params: request.params - }); - return request; - }); - - // Add response interceptor for logging - this.client.interceptors.response.use( - response => { - this.logger.debug(`API Response: ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`, { - data: response.data - }); - return response; - }, - async error => { - if (error.response) { - this.logger.error(`API Error: ${error.response.status} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, { - data: error.response.data - }); - } else { - this.logger.error(`API Error: ${error.message}`, { error }); - } - - // Implement retry logic for network errors and timeouts - const config = error.config; - - // Only retry on network errors or timeouts, not on 4xx or 5xx responses - if (!error.response && config && (!config.retryCount || config.retryCount < this.retryAttempts)) { - config.retryCount = config.retryCount || 0; - config.retryCount++; - - this.logger.warn(`Retrying request (attempt ${config.retryCount}/${this.retryAttempts}): ${config.method?.toUpperCase()} ${config.url}`); - - // Use configured retry delay instead of exponential backoff - const delay = this.retryDelayMs; - await new Promise(resolve => setTimeout(resolve, delay)); - - return this.client(config); - } - - return Promise.reject(error); - } - ); - } - } - - /** - * Discover the actual ProxMox node name - */ - async discoverNodeName(): Promise { - // If we already discovered the node name, return it - if (this.nodeName) { - return this.nodeName; - } - - if (!this.client) { - this.logger.error('Client is not initialized'); - return this.getNodeName(); - } - - try { - // Get the list of nodes from the API - const response = await this.client.get('/nodes'); - - if (response.data && response.data.data) { - const nodes = response.data.data; - const ipAddress = this.extractIpAddress(this.config.host); - - // Try to find a node that matches our IP address - const matchingNode = nodes.find((node: any) => { - // Try to match by IP address if available in the API response - if (node.ip && node.ip === ipAddress) { - return true; - } - - // Otherwise, try to match by node ID or name - return node.id === this.config.id || node.name === this.config.name; - }); - - if (matchingNode) { - this.nodeName = matchingNode.node; - this.logger.info(`Discovered node name: ${this.nodeName}`); - return this.nodeName; - } - - // If we can't find a direct match, try to access each node's status endpoint - for (const node of nodes) { - try { - if (this.client) { - await this.client.get(`/nodes/${node.node}/status`); - this.nodeName = node.node; - this.logger.info(`Discovered node name by status check: ${this.nodeName}`); - return this.nodeName; - } - } catch (error) { - // This node is not accessible to us, try the next one - } - } - } - } catch (error) { - this.logger.error('Failed to discover node name from API', { error }); - } - - // Fallback to the old method if we can't discover the node name - return this.getNodeName(); - } - - /** - * Extract IP address from host URL - */ - private extractIpAddress(host: string): string { - try { - const url = new URL(host); - return url.hostname; - } catch (error) { - this.logger.error('Failed to extract IP address from host', { host, error }); - return host; - } - } - - /** - * Get the node name, discovering it if necessary - */ - private async getNodeNameAsync(): Promise { - if (!this.nodeName) { - this.nodeName = await this.discoverNodeName(); - } - return this.nodeName; - } - - /** - * Get node name (legacy method, will be deprecated) - */ - private getNodeName(): string { - // In ProxMox, the node name is typically the hostname of the server - // We'll try to get it from the API, but for now use a hardcoded value based on the node ID - // This assumes your node IDs in the config match the actual ProxMox node names - if (this.config.id === 'node-1') { - return 'pve'; // Typical default name for the first node in a ProxMox cluster - } else if (this.config.id === 'node-2') { - return 'pve2'; // Typical name for the second node - } - - // Fallback to the old method if we can't determine the node name - const url = new URL(this.config.host); - return url.hostname; - } - - /** - * Get node status information - */ - async getNodeStatus(): Promise { - try { - this.logger.debug('Getting node status...'); - const nodeName = await this.getNodeNameAsync(); - this.logger.debug(`Using node name: ${nodeName}`); - - const response = await this.client.get(`/nodes/${nodeName}/status`); - this.logger.debug('Node status response received', { status: response.status }); - const data = response.data.data; - - return { - id: this.config.id, - name: nodeName, - configName: this.config.name, - status: 'online', // If we get a successful response, the node is online - uptime: data.uptime, - cpu: data.cpu, - memory: { - total: data.memory.total, - used: data.memory.used, - free: data.memory.free, - usedPercentage: (data.memory.used / data.memory.total) * 100 - }, - swap: { - total: data.swap.total, - used: data.swap.used, - free: data.swap.free, - usedPercentage: data.swap.total > 0 ? (data.swap.used / data.swap.total) * 100 : 0 - }, - disk: { - total: data.rootfs.total, - used: data.rootfs.used, - free: data.rootfs.free, - usedPercentage: (data.rootfs.used / data.rootfs.total) * 100 - }, - loadAverage: data.loadavg, - cpuInfo: { - cores: data.cpuinfo.cores, - sockets: data.cpuinfo.sockets, - model: data.cpuinfo.model - } - }; - } catch (error) { - this.logger.error('Failed to get node status', { error }); - - // Return offline status on error - this.logger.debug('Returning offline status due to error'); - return { - id: this.config.id, - name: this.nodeName || this.config.name, // Use discovered node name if available, otherwise config name - configName: this.config.name, - status: 'offline', - uptime: 0, - cpu: 0, - memory: { - total: 0, - used: 0, - free: 0, - usedPercentage: 0 - }, - swap: { - total: 0, - used: 0, - free: 0, - usedPercentage: 0 - }, - disk: { - total: 0, - used: 0, - free: 0, - usedPercentage: 0 - }, - loadAverage: [0, 0, 0], - cpuInfo: { - cores: 0, - sockets: 0, - model: 'Unknown' - } - }; - } - } - - /** - * Get all virtual machines for the node - */ - async getVirtualMachines(): Promise { - try { - const nodeName = await this.getNodeNameAsync(); - const response = await this.client.get(`/nodes/${nodeName}/qemu`); - const vms = response.data.data || []; - - // Create an array of promises to fetch detailed resource usage for each VM - const vmPromises = vms.map(async (vm: any) => { - try { - // Get detailed resource usage for this VM - const resourceData = await this.getGuestResourceUsage('qemu', vm.vmid); - - // Use memory values from resource data if available, otherwise fall back to VM data - const memory = resourceData.mem !== undefined ? resourceData.mem : vm.mem; - const maxmem = resourceData.maxmem !== undefined ? resourceData.maxmem : vm.maxmem; - - // Use disk values from resource data if available - const disk = resourceData.disk !== undefined ? resourceData.disk : vm.disk; - const maxdisk = resourceData.maxdisk !== undefined ? resourceData.maxdisk : vm.maxdisk; - - return { - id: `${this.config.id}-vm-${vm.vmid}`, - name: vm.name, - status: vm.status, - node: this.config.id, - vmid: vm.vmid, - cpus: vm.cpus, - cpu: resourceData.cpu, - memory: memory, - maxmem: maxmem, - disk: disk, - maxdisk: maxdisk, - uptime: vm.uptime || 0, - netin: resourceData.netin || vm.netin || 0, - netout: resourceData.netout || vm.netout || 0, - diskread: resourceData.diskread || vm.diskread || 0, - diskwrite: resourceData.diskwrite || vm.diskwrite || 0, - template: vm.template === 1, - type: 'qemu' - } as ProxmoxVM; - } catch (error) { - // If we fail to get detailed resource usage, just return basic VM data - this.logger.warn(`Failed to get detailed resource usage for VM ${vm.vmid}`, { error }); - - return { - id: `${this.config.id}-vm-${vm.vmid}`, - name: vm.name, - status: vm.status, - node: this.config.id, - vmid: vm.vmid, - cpus: vm.cpus, - memory: vm.mem, - maxmem: vm.maxmem, - disk: vm.disk, - maxdisk: vm.maxdisk, - uptime: vm.uptime || 0, - netin: vm.netin || 0, - netout: vm.netout || 0, - diskread: vm.diskread || 0, - diskwrite: vm.diskwrite || 0, - template: vm.template === 1, - type: 'qemu' - } as ProxmoxVM; - } - }); - - // Wait for all VM resource data to be fetched - return await Promise.all(vmPromises); - } catch (error) { - this.logger.error('Failed to get virtual machines', { error }); - throw error; - } - } - - /** - * Get all containers on the node with optimized polling - */ - async getContainers(): Promise { - try { - const nodeName = await this.getNodeNameAsync(); - - // First, get the list of all containers - const response = await this.client.get(`/nodes/${nodeName}/lxc`); - const containers = response.data.data || []; - - // Create a batch of promises to get container status - // We'll process them in smaller batches to avoid overwhelming the API - const batchSize = 3; // Process 3 containers at a time - const results: ProxmoxContainer[] = []; - - // Process containers in batches - for (let i = 0; i < containers.length; i += batchSize) { - const batch = containers.slice(i, i + batchSize); - const batchPromises = batch.map(async (container: any) => { - try { - // Get the container status first - const statusResponse = await this.client.get(`/nodes/${nodeName}/lxc/${container.vmid}/status/current`); - const status = statusResponse.data.data; - - // Log the raw status data for debugging - this.logger.debug(`Raw container status for ${container.vmid}:`, { status }); - - // Get detailed resource usage for this container - let resourceData = { cpu: 0, netin: 0, netout: 0, diskread: 0, diskwrite: 0 }; - try { - resourceData = await this.getGuestResourceUsage('lxc', container.vmid); - // Log the raw resource data for debugging - this.logger.debug(`Raw resource data for container ${container.vmid}:`, { resourceData }); - } catch (error) { - this.logger.warn(`Failed to get detailed resource usage for Container ${container.vmid}`, { error }); - } - - // Use fallback values from status if resource data is missing or zero - return { - id: `${this.config.id}-ct-${container.vmid}`, - name: container.name, - status: status.status, - node: this.config.id, - vmid: container.vmid, - cpus: status.cpus || container.cpus || 1, - // Use CPU usage from detailed resource data or fallback to status - // Do NOT multiply by 100 - use the raw value from ProxMox - cpu: (resourceData.cpu !== undefined && resourceData.cpu !== null) ? - resourceData.cpu : - (status.cpu !== undefined && status.cpu !== null) ? - status.cpu : 0, - memory: status.mem || 0, - maxmem: status.maxmem || container.maxmem || 0, - disk: status.disk || 0, - maxdisk: status.maxdisk || container.maxdisk || 0, - uptime: status.uptime || 0, - netin: resourceData.netin || status.netin || 0, - netout: resourceData.netout || status.netout || 0, - diskread: resourceData.diskread || status.diskread || 0, - diskwrite: resourceData.diskwrite || status.diskwrite || 0, - template: container.template === 1, - type: 'lxc' - } as ProxmoxContainer; - } catch (error) { - this.logger.error(`Failed to get status for container ${container.vmid}`, { error }); - // Return a basic container object with limited information - return { - id: `${this.config.id}-ct-${container.vmid}`, - name: container.name, - status: container.status, - node: this.config.id, - vmid: container.vmid, - cpus: container.cpus || 1, - memory: container.mem || 0, - maxmem: container.maxmem || 0, - disk: container.disk || 0, - maxdisk: container.maxdisk || 0, - uptime: 0, - netin: 0, - netout: 0, - diskread: 0, - diskwrite: 0, - template: container.template === 1, - type: 'lxc' - } as ProxmoxContainer; - } - }); - - // Wait for this batch to complete before moving to the next - const batchResults = await Promise.all(batchPromises); - results.push(...batchResults); - - // Add a small delay between batches to avoid overwhelming the API - if (i + batchSize < containers.length) { - await new Promise(resolve => setTimeout(resolve, 500)); - } - } - - return results; - } catch (error) { - this.logger.error('Failed to get containers', { error }); - return []; - } - } - - /** - * Get VM or container resource usage - */ - async getGuestResourceUsage(type: 'qemu' | 'lxc', vmid: number) { - try { - const nodeName = await this.getNodeNameAsync(); - const response = await this.client.get(`/nodes/${nodeName}/${type}/${vmid}/status/current`); - return response.data.data; - } catch (error) { - this.logger.error(`Failed to get ${type} resource usage for VMID ${vmid}`, { error }); - throw error; - } - } - - /** - * Subscribe to events - */ - async subscribeToEvents(callback: (event: ProxmoxEvent) => void): Promise<() => void> { - // Get the last event timestamp if we don't have one - if (!this.eventLastTimestamp) { - try { - const nodeName = await this.getNodeNameAsync(); - const response = await this.client.get(`/nodes/${nodeName}/tasks`); - const tasks = response.data.data || []; - if (tasks.length > 0) { - this.eventLastTimestamp = Math.floor(tasks[0].starttime); - } else { - this.eventLastTimestamp = Math.floor(Date.now() / 1000); - } - } catch (error) { - this.logger.error('Failed to get initial event timestamp', { error }); - this.eventLastTimestamp = Math.floor(Date.now() / 1000); - } - } - - // Set up polling interval - this is a fallback method - // ProxMox doesn't have a true WebSocket event API, but we can optimize our polling - // to be more responsive and efficient - - let isPolling = false; - let currentPollingInterval = config.eventPollingIntervalMs; - let lastEventTime = Date.now(); - let consecutiveEmptyPolls = 0; - - this.logger.info(`Setting up event polling with base interval: ${config.eventPollingIntervalMs}ms`); - - // Function to perform the actual polling - const pollForEvents = async () => { - if (isPolling) return; - - isPolling = true; - try { - const nodeName = await this.getNodeNameAsync(); - const response = await this.client.get(`/nodes/${nodeName}/tasks`, { - params: { - start: this.eventLastTimestamp + 1, - limit: 50 - } - }); - - const events = response.data.data || []; - - // Adaptive polling logic - if (events.length > 0) { - // Activity detected - increase polling frequency temporarily - lastEventTime = Date.now(); - consecutiveEmptyPolls = 0; - - // Update the last timestamp - this.eventLastTimestamp = Math.max( - this.eventLastTimestamp, - ...events.map((e: any) => Math.floor(e.starttime)) - ); - - // Process events - events.forEach((event: any) => { - callback({ - id: event.upid, - node: this.config.id, - type: this.determineEventType(event), - eventTime: Math.floor(event.starttime * 1000), - user: event.user, - description: event.status || event.type, - details: { - type: event.type, - status: event.status, - vmid: event.vmid - } - }); - }); - - // If we received events, poll again very quickly to get any follow-up events - // This makes the system much more responsive when events are occurring - setTimeout(pollForEvents, 500); // Quick follow-up poll after 500ms - } else { - // No events - track consecutive empty polls - consecutiveEmptyPolls++; - - // If there was recent activity (within 10 seconds), keep polling more frequently - const timeSinceLastEvent = Date.now() - lastEventTime; - if (timeSinceLastEvent < 10000) { - // Recent activity - poll again quickly - setTimeout(pollForEvents, Math.min(1000, currentPollingInterval)); - } - } - } catch (error) { - this.logger.error('Failed to poll for events', { error }); - } finally { - isPolling = false; - } - }; - - // Initial poll - pollForEvents(); - - // Regular polling interval as a fallback - const intervalId = setInterval(pollForEvents, currentPollingInterval); - - // Return a function to unsubscribe - return () => { - clearInterval(intervalId); - }; - } - - /** - * Test the connection to the ProxMox API - */ - async testConnection(): Promise { - try { - // First try to discover the node name - const nodeName = await this.discoverNodeName(); - - // Then try to access the node's status endpoint - await this.client.get(`/nodes/${nodeName}/status`); - - this.logger.info('Connection test successful'); - return true; - } catch (error) { - this.logger.error('Connection test failed', { error }); - return false; - } - } - - /** - * Determine the event type based on the event data - */ - private determineEventType(event: any): 'node' | 'vm' | 'container' | 'storage' | 'pool' { - if (event.type.startsWith('qemu')) { - return 'vm'; - } else if (event.type.startsWith('lxc')) { - return 'container'; - } else if (event.type.startsWith('storage')) { - return 'storage'; - } else if (event.type.startsWith('pool')) { - return 'pool'; - } else { - return 'node'; - } - } - - /** - * Set up event polling - */ - setupEventPolling(): void { - // Use the existing subscribeToEvents method to set up polling - if (this.isMockData) { - this.logger.info('Mock data mode enabled, skipping event polling setup'); - return; - } - - this.subscribeToEvents((event: ProxmoxEvent) => { - this.emit('event', event); - }).catch(error => { - this.logger.error('Failed to set up event polling', { error }); - }); - - // Set up periodic polling for node status, VMs, and containers - setInterval(async () => { - try { - if (this.client) { - const status = await this.getNodeStatus(); - this.emit('nodeStatus', status); - - const vms = await this.getVirtualMachines(); - this.emit('vmList', vms); - - const containers = await this.getContainers(); - this.emit('containerList', containers); - } - } catch (error) { - this.logger.error('Error during periodic polling', { error }); - } - }, config.nodePollingIntervalMs || 30000); - } -} \ No newline at end of file +// Re-export the ProxmoxClient class +export { ProxmoxClient }; \ No newline at end of file diff --git a/src/api/proxmox/events.ts b/src/api/proxmox/events.ts new file mode 100644 index 000000000..5c641e015 --- /dev/null +++ b/src/api/proxmox/events.ts @@ -0,0 +1,175 @@ +import { ProxmoxClient } from './index'; +import { ProxmoxEvent } from '../../types'; +import config from '../../config'; + +/** + * Subscribe to events + */ +export async function subscribeToEvents(this: ProxmoxClient, callback: (event: ProxmoxEvent) => void): Promise<() => void> { + // Get the last event timestamp if we don't have one + if (!this.eventLastTimestamp) { + try { + const nodeName = await this.getNodeNameAsync(); + + if (!this.client) { + throw new Error('Client is not initialized'); + } + + const response = await this.client.get(`/nodes/${nodeName}/tasks`); + const tasks = response.data.data || []; + if (tasks.length > 0) { + this.eventLastTimestamp = Math.floor(tasks[0].starttime); + } else { + this.eventLastTimestamp = Math.floor(Date.now() / 1000); + } + } catch (error) { + this.logger.error('Failed to get initial event timestamp', { error }); + this.eventLastTimestamp = Math.floor(Date.now() / 1000); + } + } + + // Set up polling interval - this is a fallback method + // ProxMox doesn't have a true WebSocket event API, but we can optimize our polling + // to be more responsive and efficient + + let isPolling = false; + let currentPollingInterval = config.eventPollingIntervalMs; + let lastEventTime = Date.now(); + let consecutiveEmptyPolls = 0; + + this.logger.info(`Setting up event polling with base interval: ${config.eventPollingIntervalMs}ms`); + + // Function to perform the actual polling + const pollForEvents = async () => { + if (isPolling) return; + + isPolling = true; + try { + const nodeName = await this.getNodeNameAsync(); + + if (!this.client) { + throw new Error('Client is not initialized'); + } + + const response = await this.client.get(`/nodes/${nodeName}/tasks`, { + params: { + start: this.eventLastTimestamp + 1, + limit: 50 + } + }); + + const events = response.data.data || []; + + // Adaptive polling logic + if (events.length > 0) { + // Activity detected - increase polling frequency temporarily + lastEventTime = Date.now(); + consecutiveEmptyPolls = 0; + + // Update the last timestamp + this.eventLastTimestamp = Math.max( + this.eventLastTimestamp, + ...events.map((e: any) => Math.floor(e.starttime)) + ); + + // Process events + events.forEach((event: any) => { + callback({ + id: event.upid, + node: this.config.id, + type: this.determineEventType(event), + eventTime: Math.floor(event.starttime * 1000), + user: event.user, + description: event.status || event.type, + details: { + type: event.type, + status: event.status, + vmid: event.vmid + } + }); + }); + + // If we received events, poll again very quickly to get any follow-up events + // This makes the system much more responsive when events are occurring + setTimeout(pollForEvents, 500); // Quick follow-up poll after 500ms + } else { + // No events - track consecutive empty polls + consecutiveEmptyPolls++; + + // If there was recent activity (within 10 seconds), keep polling more frequently + const timeSinceLastEvent = Date.now() - lastEventTime; + if (timeSinceLastEvent < 10000) { + // Recent activity - poll again quickly + setTimeout(pollForEvents, Math.min(1000, currentPollingInterval)); + } + } + } catch (error) { + this.logger.error('Failed to poll for events', { error }); + } finally { + isPolling = false; + } + }; + + // Initial poll + pollForEvents(); + + // Regular polling interval as a fallback + const intervalId = setInterval(pollForEvents, currentPollingInterval); + + // Return a function to unsubscribe + return () => { + clearInterval(intervalId); + }; +} + +/** + * Determine the event type based on the event data + */ +export function determineEventType(this: ProxmoxClient, event: any): 'node' | 'vm' | 'container' | 'storage' | 'pool' { + if (event.type.startsWith('qemu')) { + return 'vm'; + } else if (event.type.startsWith('lxc')) { + return 'container'; + } else if (event.type.startsWith('storage')) { + return 'storage'; + } else if (event.type.startsWith('pool')) { + return 'pool'; + } else { + return 'node'; + } +} + +/** + * Set up event polling + */ +export function setupEventPolling(this: ProxmoxClient): void { + // Use the existing subscribeToEvents method to set up polling + if (this.isMockData) { + this.logger.info('Mock data mode enabled, skipping event polling setup'); + return; + } + + this.subscribeToEvents((event: ProxmoxEvent) => { + this.emit('event', event); + }).catch(error => { + this.logger.error('Failed to set up event polling', { error }); + }); + + // Set up periodic polling for node status, VMs, and containers + setInterval(async () => { + try { + if (this.client) { + const status = await this.getNodeStatus(); + this.emit('nodeStatus', status); + + const vms = await this.getVirtualMachines(); + this.emit('vmList', vms); + + const containers = await this.getContainers(); + this.emit('containerList', containers); + } + } catch (error) { + this.logger.error('Error during periodic polling', { error }); + } + }, config.nodePollingIntervalMs || 30000); +} \ No newline at end of file diff --git a/src/api/proxmox/guests.ts b/src/api/proxmox/guests.ts new file mode 100644 index 000000000..9bbbc78db --- /dev/null +++ b/src/api/proxmox/guests.ts @@ -0,0 +1,217 @@ +import { ProxmoxClient } from './index'; +import { ProxmoxVM, ProxmoxContainer } from '../../types'; + +/** + * Get all virtual machines for the node + */ +export async function getVirtualMachines(this: ProxmoxClient): Promise { + try { + const nodeName = await this.getNodeNameAsync(); + + if (!this.client) { + throw new Error('Client is not initialized'); + } + + const response = await this.client.get(`/nodes/${nodeName}/qemu`); + const vms = response.data.data || []; + + // Create an array of promises to fetch detailed resource usage for each VM + const vmPromises = vms.map(async (vm: any) => { + try { + // Get detailed resource usage for this VM + const resourceData = await this.getGuestResourceUsage('qemu', vm.vmid); + + // Use memory values from resource data if available, otherwise fall back to VM data + const memory = resourceData.mem !== undefined ? resourceData.mem : vm.mem; + const maxmem = resourceData.maxmem !== undefined ? resourceData.maxmem : vm.maxmem; + + // Use disk values from resource data if available + const disk = resourceData.disk !== undefined ? resourceData.disk : vm.disk; + const maxdisk = resourceData.maxdisk !== undefined ? resourceData.maxdisk : vm.maxdisk; + + return { + id: `${this.config.id}-vm-${vm.vmid}`, + name: vm.name, + status: vm.status, + node: this.config.id, + vmid: vm.vmid, + cpus: vm.cpus, + cpu: resourceData.cpu, + memory: memory, + maxmem: maxmem, + disk: disk, + maxdisk: maxdisk, + uptime: vm.uptime || 0, + netin: resourceData.netin || vm.netin || 0, + netout: resourceData.netout || vm.netout || 0, + diskread: resourceData.diskread || vm.diskread || 0, + diskwrite: resourceData.diskwrite || vm.diskwrite || 0, + template: vm.template === 1, + type: 'qemu' + } as ProxmoxVM; + } catch (error) { + // If we fail to get detailed resource usage, just return basic VM data + this.logger.warn(`Failed to get detailed resource usage for VM ${vm.vmid}`, { error }); + + return { + id: `${this.config.id}-vm-${vm.vmid}`, + name: vm.name, + status: vm.status, + node: this.config.id, + vmid: vm.vmid, + cpus: vm.cpus, + memory: vm.mem, + maxmem: vm.maxmem, + disk: vm.disk, + maxdisk: vm.maxdisk, + uptime: vm.uptime || 0, + netin: vm.netin || 0, + netout: vm.netout || 0, + diskread: vm.diskread || 0, + diskwrite: vm.diskwrite || 0, + template: vm.template === 1, + type: 'qemu' + } as ProxmoxVM; + } + }); + + // Wait for all VM resource data to be fetched + return await Promise.all(vmPromises); + } catch (error) { + this.logger.error('Failed to get virtual machines', { error }); + throw error; + } +} + +/** + * Get all containers on the node with optimized polling + */ +export async function getContainers(this: ProxmoxClient): Promise { + try { + const nodeName = await this.getNodeNameAsync(); + + if (!this.client) { + throw new Error('Client is not initialized'); + } + + // First, get the list of all containers + const response = await this.client.get(`/nodes/${nodeName}/lxc`); + const containers = response.data.data || []; + + // Create a batch of promises to get container status + // We'll process them in smaller batches to avoid overwhelming the API + const batchSize = 3; // Process 3 containers at a time + const results: ProxmoxContainer[] = []; + + // Process containers in batches + for (let i = 0; i < containers.length; i += batchSize) { + const batch = containers.slice(i, i + batchSize); + const batchPromises = batch.map(async (container: any) => { + try { + if (!this.client) { + throw new Error('Client is not initialized'); + } + + // Get the container status first + const statusResponse = await this.client.get(`/nodes/${nodeName}/lxc/${container.vmid}/status/current`); + const status = statusResponse.data.data; + + // Log the raw status data for debugging + this.logger.debug(`Raw container status for ${container.vmid}:`, { status }); + + // Get detailed resource usage for this container + let resourceData = { cpu: 0, netin: 0, netout: 0, diskread: 0, diskwrite: 0 }; + try { + resourceData = await this.getGuestResourceUsage('lxc', container.vmid); + // Log the raw resource data for debugging + this.logger.debug(`Raw resource data for container ${container.vmid}:`, { resourceData }); + } catch (error) { + this.logger.warn(`Failed to get detailed resource usage for Container ${container.vmid}`, { error }); + } + + // Use fallback values from status if resource data is missing or zero + return { + id: `${this.config.id}-ct-${container.vmid}`, + name: container.name, + status: status.status, + node: this.config.id, + vmid: container.vmid, + cpus: status.cpus || container.cpus || 1, + // Use CPU usage from detailed resource data or fallback to status + // Do NOT multiply by 100 - use the raw value from ProxMox + cpu: (resourceData.cpu !== undefined && resourceData.cpu !== null) ? + resourceData.cpu : + (status.cpu !== undefined && status.cpu !== null) ? + status.cpu : 0, + memory: status.mem || 0, + maxmem: status.maxmem || container.maxmem || 0, + disk: status.disk || 0, + maxdisk: status.maxdisk || container.maxdisk || 0, + uptime: status.uptime || 0, + netin: resourceData.netin || status.netin || 0, + netout: resourceData.netout || status.netout || 0, + diskread: resourceData.diskread || status.diskread || 0, + diskwrite: resourceData.diskwrite || status.diskwrite || 0, + template: container.template === 1, + type: 'lxc' + } as ProxmoxContainer; + } catch (error) { + this.logger.error(`Failed to get status for container ${container.vmid}`, { error }); + // Return a basic container object with limited information + return { + id: `${this.config.id}-ct-${container.vmid}`, + name: container.name, + status: container.status, + node: this.config.id, + vmid: container.vmid, + cpus: container.cpus || 1, + memory: container.mem || 0, + maxmem: container.maxmem || 0, + disk: container.disk || 0, + maxdisk: container.maxdisk || 0, + uptime: 0, + netin: 0, + netout: 0, + diskread: 0, + diskwrite: 0, + template: container.template === 1, + type: 'lxc' + } as ProxmoxContainer; + } + }); + + // Wait for this batch to complete before moving to the next + const batchResults = await Promise.all(batchPromises); + results.push(...batchResults); + + // Add a small delay between batches to avoid overwhelming the API + if (i + batchSize < containers.length) { + await new Promise(resolve => setTimeout(resolve, 500)); + } + } + + return results; + } catch (error) { + this.logger.error('Failed to get containers', { error }); + return []; + } +} + +/** + * Get VM or container resource usage + */ +export async function getGuestResourceUsage(this: ProxmoxClient, type: 'qemu' | 'lxc', vmid: number): Promise { + try { + const nodeName = await this.getNodeNameAsync(); + + if (!this.client) { + throw new Error('Client is not initialized'); + } + + const response = await this.client.get(`/nodes/${nodeName}/${type}/${vmid}/status/current`); + return response.data.data; + } catch (error) { + this.logger.error(`Failed to get ${type} resource usage for VMID ${vmid}`, { error }); + throw error; + } +} \ No newline at end of file diff --git a/src/api/proxmox/index.ts b/src/api/proxmox/index.ts new file mode 100644 index 000000000..a1dd2a512 --- /dev/null +++ b/src/api/proxmox/index.ts @@ -0,0 +1,170 @@ +import { EventEmitter } from 'events'; +import axios, { AxiosInstance } from 'axios'; +import https from 'https'; +import { createLogger } from '../../utils/logger'; +import { NodeConfig, ProxmoxNodeStatus, ProxmoxVM, ProxmoxContainer, ProxmoxEvent } from '../../types'; +import { formatSafeTokenId } from '../../utils/config-validator'; +import { formatBytes, bytesToMB, mbToBytes } from '../../utils/format'; +import config from '../../config'; +import winston from 'winston'; +import { ProxmoxClientMethods } from './types'; + +// Define the class without the method implementations +export class ProxmoxClient extends EventEmitter implements ProxmoxClientMethods { + config: NodeConfig; + logger: winston.Logger; + client: AxiosInstance | null = null; + retryAttempts: number = 3; + retryDelayMs: number = 5000; + eventLastTimestamp = 0; + isMockData: boolean = false; + nodeName = ''; + + constructor(config: NodeConfig, ignoreSSLErrors: boolean = false) { + super(); + this.config = config; + this.logger = createLogger('ProxmoxClient', config.id); + + // Check if this is a mock data client + this.isMockData = process.env.USE_MOCK_DATA === 'true' || process.env.MOCK_DATA_ENABLED === 'true'; + + if (this.isMockData) { + this.logger.info('Mock data mode enabled. Using mock data server.'); + } else { + // Get timeout from environment variable or use default + const apiTimeoutMs = parseInt(process.env.API_TIMEOUT_MS || '60000', 10); + this.retryAttempts = parseInt(process.env.API_RETRY_ATTEMPTS || '3', 10); + this.retryDelayMs = parseInt(process.env.API_RETRY_DELAY_MS || '5000', 10); + + // Create axios instance with base configuration + const axiosConfig = { + baseURL: `${config.host}/api2/json`, + headers: { + Authorization: `PVEAPIToken=${config.tokenId}=${config.tokenSecret}` + }, + timeout: apiTimeoutMs, + httpsAgent: new https.Agent({ + rejectUnauthorized: !ignoreSSLErrors + }) + }; + + this.client = axios.create(axiosConfig); + this.logger.info(`ProxMox API client created with timeout: ${apiTimeoutMs}ms and ${this.retryAttempts} retry attempts`); + + // Add request interceptor for logging + this.client.interceptors.request.use(request => { + this.logger.debug(`API Request: ${request.method?.toUpperCase()} ${request.url}`, { + params: request.params + }); + return request; + }); + + // Add response interceptor for logging + this.client.interceptors.response.use( + response => { + this.logger.debug(`API Response: ${response.status} ${response.config.method?.toUpperCase()} ${response.config.url}`, { + data: response.data + }); + return response; + }, + async error => { + if (error.response) { + this.logger.error(`API Error: ${error.response.status} ${error.config?.method?.toUpperCase()} ${error.config?.url}`, { + data: error.response.data + }); + } else { + this.logger.error(`API Error: ${error.message}`, { error }); + } + + // Implement retry logic for network errors and timeouts + const config = error.config; + + // Only retry on network errors or timeouts, not on 4xx or 5xx responses + if (!error.response && config && (!config.retryCount || config.retryCount < this.retryAttempts)) { + config.retryCount = config.retryCount || 0; + config.retryCount++; + + this.logger.warn(`Retrying request (attempt ${config.retryCount}/${this.retryAttempts}): ${config.method?.toUpperCase()} ${config.url}`); + + // Use configured retry delay instead of exponential backoff + const delay = this.retryDelayMs; + await new Promise(resolve => setTimeout(resolve, delay)); + + return this.client(config); + } + + return Promise.reject(error); + } + ); + } + } + + // Method stubs that will be implemented by the prototype assignments + async discoverNodeName(): Promise { throw new Error('Not implemented'); } + extractIpAddress(host: string): string { throw new Error('Not implemented'); } + async getNodeNameAsync(): Promise { throw new Error('Not implemented'); } + getNodeName(): string { throw new Error('Not implemented'); } + async getNodeStatus(): Promise { throw new Error('Not implemented'); } + async getVirtualMachines(): Promise { throw new Error('Not implemented'); } + async getContainers(): Promise { throw new Error('Not implemented'); } + async getGuestResourceUsage(type: 'qemu' | 'lxc', vmid: number): Promise { throw new Error('Not implemented'); } + async subscribeToEvents(callback: (event: ProxmoxEvent) => void): Promise<() => void> { throw new Error('Not implemented'); } + determineEventType(event: any): 'node' | 'vm' | 'container' | 'storage' | 'pool' { throw new Error('Not implemented'); } + setupEventPolling(): void { throw new Error('Not implemented'); } + + /** + * Test the connection to the ProxMox API + */ + async testConnection(): Promise { + try { + // First try to discover the node name + const nodeName = await this.discoverNodeName(); + + // Then try to access the node's status endpoint + if (this.client) { + await this.client.get(`/nodes/${nodeName}/status`); + } + + this.logger.info('Connection test successful'); + return true; + } catch (error) { + this.logger.error('Connection test failed', { error }); + return false; + } + } +} + +// Import functionality after the class definition to avoid circular dependencies +import { + discoverNodeName, + extractIpAddress, + getNodeNameAsync, + getNodeName +} from './node-discovery'; + +import { getNodeStatus } from './node-status'; + +import { + getVirtualMachines, + getContainers, + getGuestResourceUsage +} from './guests'; + +import { + subscribeToEvents, + determineEventType, + setupEventPolling +} from './events'; + +// Assign methods to the prototype +ProxmoxClient.prototype.discoverNodeName = discoverNodeName; +ProxmoxClient.prototype.extractIpAddress = extractIpAddress; +ProxmoxClient.prototype.getNodeNameAsync = getNodeNameAsync; +ProxmoxClient.prototype.getNodeName = getNodeName; +ProxmoxClient.prototype.getNodeStatus = getNodeStatus; +ProxmoxClient.prototype.getVirtualMachines = getVirtualMachines; +ProxmoxClient.prototype.getContainers = getContainers; +ProxmoxClient.prototype.getGuestResourceUsage = getGuestResourceUsage; +ProxmoxClient.prototype.subscribeToEvents = subscribeToEvents; +ProxmoxClient.prototype.determineEventType = determineEventType; +ProxmoxClient.prototype.setupEventPolling = setupEventPolling; \ No newline at end of file diff --git a/src/api/proxmox/node-discovery.ts b/src/api/proxmox/node-discovery.ts new file mode 100644 index 000000000..1e1c03583 --- /dev/null +++ b/src/api/proxmox/node-discovery.ts @@ -0,0 +1,103 @@ +import { ProxmoxClient } from './index'; + +/** + * Discover the actual ProxMox node name + */ +export async function discoverNodeName(this: ProxmoxClient): Promise { + // If we already discovered the node name, return it + if (this.nodeName) { + return this.nodeName; + } + + if (!this.client) { + this.logger.error('Client is not initialized'); + return this.getNodeName(); + } + + try { + // Get the list of nodes from the API + const response = await this.client.get('/nodes'); + + if (response.data && response.data.data) { + const nodes = response.data.data; + const ipAddress = this.extractIpAddress(this.config.host); + + // Try to find a node that matches our IP address + const matchingNode = nodes.find((node: any) => { + // Try to match by IP address if available in the API response + if (node.ip && node.ip === ipAddress) { + return true; + } + + // Otherwise, try to match by node ID or name + return node.id === this.config.id || node.name === this.config.name; + }); + + if (matchingNode) { + this.nodeName = matchingNode.node; + this.logger.info(`Discovered node name: ${this.nodeName}`); + return this.nodeName; + } + + // If we can't find a direct match, try to access each node's status endpoint + for (const node of nodes) { + try { + if (this.client) { + await this.client.get(`/nodes/${node.node}/status`); + this.nodeName = node.node; + this.logger.info(`Discovered node name by status check: ${this.nodeName}`); + return this.nodeName; + } + } catch (error) { + // This node is not accessible to us, try the next one + } + } + } + } catch (error) { + this.logger.error('Failed to discover node name from API', { error }); + } + + // Fallback to the old method if we can't discover the node name + return this.getNodeName(); +} + +/** + * Extract IP address from host URL + */ +export function extractIpAddress(this: ProxmoxClient, host: string): string { + try { + const url = new URL(host); + return url.hostname; + } catch (error) { + this.logger.error('Failed to extract IP address from host', { host, error }); + return host; + } +} + +/** + * Get the node name, discovering it if necessary + */ +export async function getNodeNameAsync(this: ProxmoxClient): Promise { + if (!this.nodeName) { + this.nodeName = await this.discoverNodeName(); + } + return this.nodeName; +} + +/** + * Get node name (legacy method, will be deprecated) + */ +export function getNodeName(this: ProxmoxClient): string { + // In ProxMox, the node name is typically the hostname of the server + // We'll try to get it from the API, but for now use a hardcoded value based on the node ID + // This assumes your node IDs in the config match the actual ProxMox node names + if (this.config.id === 'node-1') { + return 'pve'; // Typical default name for the first node in a ProxMox cluster + } else if (this.config.id === 'node-2') { + return 'pve2'; // Typical name for the second node + } + + // Fallback to the old method if we can't determine the node name + const url = new URL(this.config.host); + return url.hostname; +} \ No newline at end of file diff --git a/src/api/proxmox/node-status.ts b/src/api/proxmox/node-status.ts new file mode 100644 index 000000000..47a3f1281 --- /dev/null +++ b/src/api/proxmox/node-status.ts @@ -0,0 +1,91 @@ +import { ProxmoxClient } from './index'; +import { ProxmoxNodeStatus } from '../../types'; + +/** + * Get node status information + */ +export async function getNodeStatus(this: ProxmoxClient): Promise { + try { + this.logger.debug('Getting node status...'); + const nodeName = await this.getNodeNameAsync(); + this.logger.debug(`Using node name: ${nodeName}`); + + if (!this.client) { + throw new Error('Client is not initialized'); + } + + const response = await this.client.get(`/nodes/${nodeName}/status`); + this.logger.debug('Node status response received', { status: response.status }); + const data = response.data.data; + + return { + id: this.config.id, + name: nodeName, + configName: this.config.name, + status: 'online', // If we get a successful response, the node is online + uptime: data.uptime, + cpu: data.cpu, + memory: { + total: data.memory.total, + used: data.memory.used, + free: data.memory.free, + usedPercentage: (data.memory.used / data.memory.total) * 100 + }, + swap: { + total: data.swap.total, + used: data.swap.used, + free: data.swap.free, + usedPercentage: data.swap.total > 0 ? (data.swap.used / data.swap.total) * 100 : 0 + }, + disk: { + total: data.rootfs.total, + used: data.rootfs.used, + free: data.rootfs.free, + usedPercentage: (data.rootfs.used / data.rootfs.total) * 100 + }, + loadAverage: data.loadavg, + cpuInfo: { + cores: data.cpuinfo.cores, + sockets: data.cpuinfo.sockets, + model: data.cpuinfo.model + } + }; + } catch (error) { + this.logger.error('Failed to get node status', { error }); + + // Return offline status on error + this.logger.debug('Returning offline status due to error'); + return { + id: this.config.id, + name: this.nodeName || this.config.name, // Use discovered node name if available, otherwise config name + configName: this.config.name, + status: 'offline', + uptime: 0, + cpu: 0, + memory: { + total: 0, + used: 0, + free: 0, + usedPercentage: 0 + }, + swap: { + total: 0, + used: 0, + free: 0, + usedPercentage: 0 + }, + disk: { + total: 0, + used: 0, + free: 0, + usedPercentage: 0 + }, + loadAverage: [0, 0, 0], + cpuInfo: { + cores: 0, + sockets: 0, + model: 'Unknown' + } + }; + } +} \ No newline at end of file diff --git a/src/api/proxmox/types.ts b/src/api/proxmox/types.ts new file mode 100644 index 000000000..d14df3c51 --- /dev/null +++ b/src/api/proxmox/types.ts @@ -0,0 +1,16 @@ +import { ProxmoxNodeStatus, ProxmoxVM, ProxmoxContainer, ProxmoxEvent } from '../../types'; + +// Define method interfaces for the ProxmoxClient +export interface ProxmoxClientMethods { + discoverNodeName(): Promise; + extractIpAddress(host: string): string; + getNodeNameAsync(): Promise; + getNodeName(): string; + getNodeStatus(): Promise; + getVirtualMachines(): Promise; + getContainers(): Promise; + getGuestResourceUsage(type: 'qemu' | 'lxc', vmid: number): Promise; + subscribeToEvents(callback: (event: ProxmoxEvent) => void): Promise<() => void>; + determineEventType(event: any): 'node' | 'vm' | 'container' | 'storage' | 'pool'; + setupEventPolling(): void; +} \ No newline at end of file