Refactor proxmox-client.ts into smaller modules for better maintainability

This commit is contained in:
courtmanr@gmail.com
2025-03-08 19:55:55 +00:00
parent 2e8eefa233
commit dd04743545
7 changed files with 777 additions and 662 deletions
+5 -662
View File
@@ -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<string> {
// 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<string> {
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<ProxmoxNodeStatus> {
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<ProxmoxVM[]> {
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<ProxmoxContainer[]> {
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<boolean> {
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);
}
}
// Re-export the ProxmoxClient class
export { ProxmoxClient };
+175
View File
@@ -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);
}
+217
View File
@@ -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<ProxmoxVM[]> {
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<ProxmoxContainer[]> {
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<any> {
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;
}
}
+170
View File
@@ -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<string> { throw new Error('Not implemented'); }
extractIpAddress(host: string): string { throw new Error('Not implemented'); }
async getNodeNameAsync(): Promise<string> { throw new Error('Not implemented'); }
getNodeName(): string { throw new Error('Not implemented'); }
async getNodeStatus(): Promise<ProxmoxNodeStatus> { throw new Error('Not implemented'); }
async getVirtualMachines(): Promise<ProxmoxVM[]> { throw new Error('Not implemented'); }
async getContainers(): Promise<ProxmoxContainer[]> { throw new Error('Not implemented'); }
async getGuestResourceUsage(type: 'qemu' | 'lxc', vmid: number): Promise<any> { 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<boolean> {
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;
+103
View File
@@ -0,0 +1,103 @@
import { ProxmoxClient } from './index';
/**
* Discover the actual ProxMox node name
*/
export async function discoverNodeName(this: ProxmoxClient): Promise<string> {
// 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<string> {
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;
}
+91
View File
@@ -0,0 +1,91 @@
import { ProxmoxClient } from './index';
import { ProxmoxNodeStatus } from '../../types';
/**
* Get node status information
*/
export async function getNodeStatus(this: ProxmoxClient): Promise<ProxmoxNodeStatus> {
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'
}
};
}
}
+16
View File
@@ -0,0 +1,16 @@
import { ProxmoxNodeStatus, ProxmoxVM, ProxmoxContainer, ProxmoxEvent } from '../../types';
// Define method interfaces for the ProxmoxClient
export interface ProxmoxClientMethods {
discoverNodeName(): Promise<string>;
extractIpAddress(host: string): string;
getNodeNameAsync(): Promise<string>;
getNodeName(): string;
getNodeStatus(): Promise<ProxmoxNodeStatus>;
getVirtualMachines(): Promise<ProxmoxVM[]>;
getContainers(): Promise<ProxmoxContainer[]>;
getGuestResourceUsage(type: 'qemu' | 'lxc', vmid: number): Promise<any>;
subscribeToEvents(callback: (event: ProxmoxEvent) => void): Promise<() => void>;
determineEventType(event: any): 'node' | 'vm' | 'container' | 'storage' | 'pool';
setupEventPolling(): void;
}