mirror of
https://github.com/rcourtman/Pulse.git
synced 2026-09-21 18:53:37 +00:00
969 lines
41 KiB
JavaScript
969 lines
41 KiB
JavaScript
// require('dotenv').config(); // Load environment variables from .env file
|
|
|
|
// --- BEGIN Environment Variable Validation ---
|
|
const primaryRequiredEnvVars = [
|
|
'PROXMOX_HOST',
|
|
'PROXMOX_TOKEN_ID',
|
|
'PROXMOX_TOKEN_SECRET'
|
|
];
|
|
const placeholderValues = [
|
|
'your-proxmox-ip-or-hostname',
|
|
'your-api-token-id@pam!your-token-name',
|
|
'your-api-token-secret-uuid',
|
|
'your-password' // Added just in case password fallback is used without token
|
|
];
|
|
|
|
let missingVars = [];
|
|
let placeholderVars = [];
|
|
|
|
// Check primary vars
|
|
primaryRequiredEnvVars.forEach(varName => {
|
|
const value = process.env[varName];
|
|
if (!value) {
|
|
missingVars.push(varName);
|
|
} else if (placeholderValues.some(placeholder => value.includes(placeholder))) {
|
|
placeholderVars.push(varName);
|
|
}
|
|
});
|
|
|
|
if (missingVars.length > 0 || placeholderVars.length > 0) {
|
|
console.error('\n--- Configuration Error (Primary Endpoint) ---');
|
|
if (missingVars.length > 0) {
|
|
console.error(`Missing required environment variables: ${missingVars.join(', ')}. These are typically set via docker-compose.yml or a .env file.`);
|
|
}
|
|
if (placeholderVars.length > 0) {
|
|
console.error(`The following primary environment variables seem to contain placeholder values: ${placeholderVars.join(', ')}.`);
|
|
}
|
|
console.error('Please ensure valid Proxmox connection details are provided via environment variables.');
|
|
console.error('Refer to server/.env.example for the required variable names and format.\n');
|
|
process.exit(1); // Exit if primary configuration is invalid
|
|
}
|
|
|
|
// --- Load All Proxmox Endpoint Configurations ---
|
|
const endpoints = [];
|
|
|
|
// Load primary endpoint (index 0)
|
|
endpoints.push({
|
|
id: 'primary', // Identifier for this endpoint
|
|
name: process.env.PROXMOX_NODE_NAME || process.env.PROXMOX_HOST, // Use host if name not set
|
|
host: process.env.PROXMOX_HOST,
|
|
port: process.env.PROXMOX_PORT || '8006',
|
|
tokenId: process.env.PROXMOX_TOKEN_ID,
|
|
tokenSecret: process.env.PROXMOX_TOKEN_SECRET,
|
|
enabled: process.env.PROXMOX_ENABLED !== 'false', // Currently unused, but kept for potential future use
|
|
allowSelfSignedCerts: process.env.PROXMOX_ALLOW_SELF_SIGNED_CERTS !== 'false',
|
|
credentials: { // Keep password fallback possibility
|
|
username: process.env.PROXMOX_USERNAME,
|
|
password: process.env.PROXMOX_PASSWORD,
|
|
realm: process.env.PROXMOX_REALM || 'pam'
|
|
}
|
|
});
|
|
|
|
// Load additional endpoints (PROXMOX_HOST_2, _3, ...)
|
|
let i = 2;
|
|
while (process.env[`PROXMOX_HOST_${i}`]) {
|
|
const host = process.env[`PROXMOX_HOST_${i}`];
|
|
const tokenId = process.env[`PROXMOX_TOKEN_ID_${i}`];
|
|
const tokenSecret = process.env[`PROXMOX_TOKEN_SECRET_${i}`];
|
|
|
|
// Minimal validation for additional endpoints
|
|
if (!tokenId || !tokenSecret) {
|
|
console.warn(`WARN: Skipping endpoint ${i} (Host: ${host}). Missing PROXMOX_TOKEN_ID_${i} or PROXMOX_TOKEN_SECRET_${i}.`);
|
|
i++;
|
|
continue;
|
|
}
|
|
if (placeholderValues.some(p => host.includes(p) || tokenId.includes(p) || tokenSecret.includes(p))) {
|
|
console.warn(`WARN: Skipping endpoint ${i} (Host: ${host}). Environment variables seem to contain placeholder values.`);
|
|
i++;
|
|
continue;
|
|
}
|
|
|
|
endpoints.push({
|
|
id: `endpoint_${i}`, // Unique ID for this endpoint
|
|
name: process.env[`PROXMOX_NODE_NAME_${i}`] || host, // Use host if name not set
|
|
host: host,
|
|
port: process.env[`PROXMOX_PORT_${i}`] || '8006',
|
|
tokenId: tokenId,
|
|
tokenSecret: tokenSecret,
|
|
enabled: process.env[`PROXMOX_ENABLED_${i}`] !== 'false',
|
|
allowSelfSignedCerts: process.env[`PROXMOX_ALLOW_SELF_SIGNED_CERTS_${i}`] !== 'false',
|
|
credentials: { // Allow fallback for additional endpoints too
|
|
username: process.env[`PROXMOX_USERNAME_${i}`],
|
|
password: process.env[`PROXMOX_PASSWORD_${i}`],
|
|
realm: process.env[`PROXMOX_REALM_${i}`] || 'pam'
|
|
}
|
|
});
|
|
i++;
|
|
}
|
|
|
|
if (endpoints.length > 1) {
|
|
console.log(`INFO: Loaded configuration for ${endpoints.length} Proxmox endpoints.`);
|
|
}
|
|
|
|
// --- END Configuration Loading ---
|
|
|
|
const fs = require('fs'); // Add fs module
|
|
const express = require('express');
|
|
const http = require('http');
|
|
const path = require('path');
|
|
const cors = require('cors');
|
|
const { Server } = require('socket.io');
|
|
const axios = require('axios');
|
|
const https = require('https');
|
|
const axiosRetry = require('axios-retry').default; // Import axios-retry
|
|
|
|
// Development specific dependencies
|
|
let chokidar;
|
|
if (process.env.NODE_ENV === 'development') {
|
|
try {
|
|
chokidar = require('chokidar');
|
|
} catch (e) {
|
|
console.warn('chokidar is not installed. Hot reload requires chokidar: npm install --save-dev chokidar');
|
|
}
|
|
}
|
|
|
|
// --- Create API Clients for Each Endpoint ---
|
|
const apiClients = {}; // Use an object to store clients, keyed by endpoint.id
|
|
|
|
endpoints.forEach(endpoint => {
|
|
if (!endpoint.enabled) {
|
|
console.log(`INFO: Skipping disabled endpoint: ${endpoint.name} (${endpoint.host})`);
|
|
return; // Skip disabled endpoints
|
|
}
|
|
|
|
const baseURL = endpoint.host.includes('://')
|
|
? `${endpoint.host}/api2/json`
|
|
: `https://${endpoint.host}:${endpoint.port}/api2/json`;
|
|
|
|
const apiClient = axios.create({
|
|
baseURL: baseURL,
|
|
httpsAgent: new https.Agent({
|
|
rejectUnauthorized: !endpoint.allowSelfSignedCerts
|
|
}),
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
}
|
|
});
|
|
|
|
// Add request interceptor for authentication (specific to this endpoint)
|
|
apiClient.interceptors.request.use(config => {
|
|
// Add API token authentication
|
|
if (endpoint.tokenId && endpoint.tokenSecret) {
|
|
config.headers.Authorization = `PVEAPIToken=${endpoint.tokenId}=${endpoint.tokenSecret}`;
|
|
}
|
|
// Fallback to password auth if configured FOR THIS ENDPOINT
|
|
else if (endpoint.credentials && endpoint.credentials.username && endpoint.credentials.password) {
|
|
const { username, password, realm } = endpoint.credentials;
|
|
config.headers.Authorization = `Basic ${Buffer.from(`${username}@${realm}:${password}`).toString('base64')}`;
|
|
if (!realm) console.warn(`WARN: Using password auth for endpoint ${endpoint.name} without a realm specified. Defaulting to 'pam'.`);
|
|
} else {
|
|
// Should not happen due to validation, but log just in case
|
|
console.error(`ERROR: Endpoint ${endpoint.name} has neither token nor full username/password credentials configured.`);
|
|
}
|
|
return config;
|
|
});
|
|
|
|
// Apply retry logic to the axios instance
|
|
axiosRetry(apiClient, {
|
|
retries: 3, // Number of retries
|
|
retryDelay: (retryCount, error) => {
|
|
console.warn(`Retrying API request for ${endpoint.name} (attempt ${retryCount}) due to error: ${error.message}`);
|
|
return axiosRetry.exponentialDelay(retryCount); // Exponential backoff
|
|
},
|
|
retryCondition: (error) => {
|
|
// Retry on network errors or specific status codes
|
|
return (
|
|
axiosRetry.isNetworkError(error) ||
|
|
axiosRetry.isRetryableError(error) || // Includes 5xx errors by default
|
|
error.response?.status === 596 // Specifically retry on 596
|
|
);
|
|
},
|
|
});
|
|
|
|
apiClients[endpoint.id] = { client: apiClient, config: endpoint }; // Store both client and config
|
|
console.log(`INFO: Initialized API client for endpoint: ${endpoint.name} (${endpoint.host})`);
|
|
});
|
|
|
|
if (Object.keys(apiClients).length === 0) {
|
|
console.error("\n--- Configuration Error ---");
|
|
console.error("No enabled Proxmox endpoints could be configured. Please check your .env file and environment variables.");
|
|
process.exit(1);
|
|
}
|
|
// --- End API Client Creation ---
|
|
|
|
// Server configuration
|
|
const DEBUG_METRICS = false; // Set to true to show detailed metrics logs
|
|
const PORT = 7655; // Using a different port from the main server
|
|
|
|
// --- Define Update Intervals ---
|
|
// How often to fetch dynamic metrics (CPU, Mem, IO) for running guests
|
|
const METRIC_UPDATE_INTERVAL = 2000; // Default: 2 seconds
|
|
// How often to fetch structural data (node list, guest lists, node status)
|
|
const DISCOVERY_UPDATE_INTERVAL = 30000; // Default: 30 seconds
|
|
|
|
// Create Express app
|
|
const app = express();
|
|
|
|
// Middleware
|
|
app.use(cors());
|
|
app.use(express.json());
|
|
app.use(express.static(path.join(__dirname, '../public')));
|
|
|
|
// Allow iframe embedding
|
|
app.use((req, res, next) => {
|
|
res.removeHeader('X-Frame-Options'); // Remove default Express header if present
|
|
res.setHeader('Content-Security-Policy', "frame-ancestors *"); // Allow embedding from any origin
|
|
next();
|
|
});
|
|
|
|
// --- Add API endpoint for version ---
|
|
let appVersion = 'unknown';
|
|
try {
|
|
const packageJsonPath = path.join(__dirname, '../package.json');
|
|
const packageJsonContent = fs.readFileSync(packageJsonPath, 'utf8');
|
|
const packageJson = JSON.parse(packageJsonContent);
|
|
appVersion = packageJson.version || 'unknown';
|
|
} catch (error) {
|
|
console.error('Error reading version from package.json:', error.message);
|
|
}
|
|
|
|
app.get('/api/version', (req, res) => {
|
|
res.json({ version: appVersion });
|
|
});
|
|
// --- End API endpoint ---
|
|
|
|
// --- Add API endpoint for Storage ---
|
|
app.get('/api/storage', async (req, res) => {
|
|
// Refactored to query all configured endpoints
|
|
const aggregatedStorageData = {};
|
|
let hasDataFromAnyEndpoint = false;
|
|
|
|
const endpointIds = Object.keys(apiClients);
|
|
if (endpointIds.length === 0) {
|
|
console.warn('/api/storage: No API clients configured.');
|
|
return res.json({}); // Return empty if no clients
|
|
}
|
|
|
|
// Process each endpoint concurrently
|
|
const endpointPromises = endpointIds.map(async (endpointId) => {
|
|
const { client: apiClient, config: endpointConfig } = apiClients[endpointId];
|
|
const endpointName = endpointConfig.name || endpointId; // For logging
|
|
const endpointStorageData = {}; // Storage data specifically for this endpoint
|
|
let nodesToQuery = [];
|
|
let discoveryFailed = false;
|
|
|
|
// --- Step 1: Discover nodes for this endpoint ---
|
|
try {
|
|
const nodesResponse = await apiClient.get('/nodes');
|
|
const basicNodeInfo = nodesResponse.data.data || [];
|
|
nodesToQuery = basicNodeInfo.map(n => n.node).filter(Boolean);
|
|
|
|
if (nodesToQuery.length === 0) {
|
|
console.warn(`/api/storage (${endpointName}): /nodes returned 0 nodes. Attempting single node discovery.`);
|
|
try {
|
|
const versionResponse = await apiClient.get('/version');
|
|
const discoveredNodeName = versionResponse.data.data.node;
|
|
if (discoveredNodeName) {
|
|
nodesToQuery = [discoveredNodeName];
|
|
} else {
|
|
throw new Error("Could not determine node name from /version.");
|
|
}
|
|
} catch (discoveryError) {
|
|
console.error(`/api/storage (${endpointName}): Single node discovery failed: ${discoveryError.message}`);
|
|
discoveryFailed = true;
|
|
}
|
|
}
|
|
} catch (error) {
|
|
console.warn(`/api/storage (${endpointName}): Failed to fetch /nodes (${error.message}). Attempting single node discovery.`);
|
|
try {
|
|
const versionResponse = await apiClient.get('/version');
|
|
const discoveredNodeName = versionResponse.data.data.node;
|
|
if (discoveredNodeName) {
|
|
nodesToQuery = [discoveredNodeName];
|
|
console.log(`/api/storage (${endpointName}): Discovered single node via /version: ${discoveredNodeName}`);
|
|
} else {
|
|
throw new Error("Could not determine node name from /version.");
|
|
}
|
|
} catch (discoveryError) {
|
|
console.error(`/api/storage (${endpointName}): Single node discovery failed after /nodes error: ${discoveryError.message}`);
|
|
discoveryFailed = true;
|
|
}
|
|
}
|
|
|
|
// If discovery failed for this endpoint, log and skip to the next endpoint
|
|
if (discoveryFailed) {
|
|
console.error(`/api/storage (${endpointName}): Failed to discover any nodes. Skipping storage fetch for this endpoint.`);
|
|
return null; // Indicate failure for this endpoint
|
|
}
|
|
|
|
// --- Step 2: Fetch storage for discovered nodes in this endpoint ---
|
|
const storagePromises = nodesToQuery.map(async (nodeName) => {
|
|
if (!nodeName) {
|
|
console.warn(`/api/storage (${endpointName}): Skipping node with missing name.`);
|
|
return; // Skip if node name is somehow invalid
|
|
}
|
|
try {
|
|
const response = await apiClient.get(`/nodes/${nodeName}/storage`);
|
|
// Add the fetched storage data under the nodeName key
|
|
// Note: If node names collide across endpoints, the last one processed will overwrite previous ones.
|
|
endpointStorageData[nodeName] = response.data.data || [];
|
|
} catch (err) {
|
|
console.error(`/api/storage (${endpointName}): Error fetching storage for node ${nodeName}: ${err.message}`);
|
|
// Store an error object instead of data for this node
|
|
endpointStorageData[nodeName] = {
|
|
error: `Failed to fetch storage: ${err.message}`,
|
|
endpointId: endpointId,
|
|
endpointName: endpointName
|
|
};
|
|
}
|
|
});
|
|
|
|
await Promise.allSettled(storagePromises);
|
|
|
|
// If we got any data (even errors) for nodes in this endpoint, return it
|
|
if (Object.keys(endpointStorageData).length > 0) {
|
|
// console.log(`/api/storage (${endpointName}): Fetched storage data for ${Object.keys(endpointStorageData).length} nodes.`);
|
|
return endpointStorageData;
|
|
} else if (nodesToQuery.length > 0) {
|
|
console.warn(`/api/storage (${endpointName}): Discovered ${nodesToQuery.length} nodes but failed to fetch storage for any of them.`);
|
|
return null; // Indicate failure if discovery worked but storage fetch failed completely
|
|
} else {
|
|
// Should only happen if discovery found 0 nodes initially
|
|
console.log(`/api/storage (${endpointName}): No nodes discovered, no storage data fetched.`);
|
|
return null;
|
|
}
|
|
}); // End map over endpointIds
|
|
|
|
// Process results from all endpoints
|
|
const allEndpointResults = await Promise.allSettled(endpointPromises);
|
|
|
|
allEndpointResults.forEach(endpointOutcome => {
|
|
if (endpointOutcome.status === 'fulfilled' && endpointOutcome.value) {
|
|
// Merge the storage data from this endpoint into the aggregated result
|
|
Object.assign(aggregatedStorageData, endpointOutcome.value);
|
|
hasDataFromAnyEndpoint = true; // Mark that we got some data
|
|
} else if (endpointOutcome.status === 'rejected') {
|
|
// Log errors from processing an endpoint promise itself (less likely)
|
|
console.error(`/api/storage: Error processing endpoint storage fetch promise: ${endpointOutcome.reason}`);
|
|
}
|
|
// We already logged errors for endpoints where value is null inside the map
|
|
});
|
|
|
|
if (hasDataFromAnyEndpoint) {
|
|
if (DEBUG_METRICS) {
|
|
console.log(`/api/storage: Returning aggregated storage data for ${Object.keys(aggregatedStorageData).length} nodes across all endpoints.`);
|
|
}
|
|
} else {
|
|
console.warn(`/api/storage: Failed to fetch any storage data from any configured endpoint.`);
|
|
}
|
|
|
|
res.json(aggregatedStorageData); // Return the aggregated data (or empty object if all failed)
|
|
});
|
|
// --- End Storage API endpoint ---
|
|
|
|
// Create HTTP server
|
|
const server = http.createServer(app);
|
|
|
|
// Create Socket.IO server with CORS configuration
|
|
const io = new Server(server, {
|
|
cors: {
|
|
origin: "*",
|
|
methods: ["GET", "POST"]
|
|
}
|
|
});
|
|
|
|
// --- Global State Variables ---
|
|
// These will hold the latest fetched data
|
|
let currentNodes = [];
|
|
let currentVms = [];
|
|
let currentContainers = [];
|
|
let currentMetrics = [];
|
|
let isDiscoveryRunning = false; // Prevent concurrent discovery runs
|
|
let isMetricsRunning = false; // Prevent concurrent metric runs
|
|
let discoveryTimeoutId = null;
|
|
let metricTimeoutId = null;
|
|
// --- End Global State ---
|
|
|
|
// Helper function to fetch data for a single node WITHIN a specific endpoint
|
|
// Added apiClient and endpointId parameters
|
|
async function fetchDataForNode(apiClient, endpointId, nodeName) {
|
|
const nodeData = {
|
|
vms: [],
|
|
containers: [],
|
|
metrics: []
|
|
};
|
|
|
|
try {
|
|
// Fetch VMs
|
|
const vmsResponse = await apiClient.get(`/nodes/${nodeName}/qemu`);
|
|
if (vmsResponse.data.data && Array.isArray(vmsResponse.data.data)) {
|
|
// Add endpointId, node, and type to each VM
|
|
nodeData.vms = vmsResponse.data.data.map(vm => ({
|
|
...vm,
|
|
node: nodeName,
|
|
endpointId: endpointId,
|
|
type: 'qemu' // **** ADD TYPE ****
|
|
}));
|
|
|
|
// Collect metrics for running VMs in parallel
|
|
const vmMetricPromises = nodeData.vms
|
|
.filter(vm => vm.status === 'running')
|
|
.map(async (vm) => {
|
|
try {
|
|
const [rrdData, currentData] = await Promise.all([
|
|
apiClient.get(`/nodes/${nodeName}/qemu/${vm.vmid}/rrddata`, { params: { timeframe: 'hour', cf: 'AVERAGE' } }),
|
|
apiClient.get(`/nodes/${nodeName}/qemu/${vm.vmid}/status/current`)
|
|
]);
|
|
|
|
let metricData = {
|
|
id: vm.vmid,
|
|
guestName: vm.name,
|
|
node: nodeName,
|
|
type: 'qemu',
|
|
endpointId: endpointId,
|
|
data: [],
|
|
current: currentData?.data?.data || null
|
|
};
|
|
if (rrdData?.data?.data?.length > 0) metricData.data = rrdData.data.data;
|
|
return metricData; // Return successful metric data
|
|
} catch (err) {
|
|
// Add status code to error log
|
|
const status = err.response?.status ? ` (Status: ${err.response.status})` : '';
|
|
console.error(`[Metrics] Failed to get metrics for VM ${vm.vmid} on node ${nodeName} (Endpoint: ${endpointId})${status}: ${err.message}`);
|
|
return null; // Return null on error for this specific VM
|
|
}
|
|
});
|
|
const vmMetricsResults = await Promise.allSettled(vmMetricPromises);
|
|
vmMetricsResults.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
nodeData.metrics.push(result.value);
|
|
}
|
|
// Optionally log rejected promises if needed:
|
|
// else if (result.status === 'rejected') { console.error(...) }
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Add status code to error log
|
|
const status = err.response?.status ? ` (Status: ${err.response.status})` : '';
|
|
console.error(`[Discovery] Error fetching VMs from node ${nodeName} (Endpoint: ${endpointId})${status}: ${err.message}`);
|
|
// Continue to fetch containers even if VMs fail
|
|
}
|
|
|
|
try {
|
|
// Fetch containers
|
|
const ctsResponse = await apiClient.get(`/nodes/${nodeName}/lxc`);
|
|
if (ctsResponse.data.data && Array.isArray(ctsResponse.data.data)) {
|
|
// Add endpointId, node, and type to each container
|
|
nodeData.containers = ctsResponse.data.data.map(ct => ({
|
|
...ct,
|
|
node: nodeName,
|
|
endpointId: endpointId,
|
|
type: 'lxc' // **** ADD TYPE ****
|
|
}));
|
|
|
|
// Collect metrics for running containers in parallel
|
|
const ctMetricPromises = nodeData.containers
|
|
.filter(ct => ct.status === 'running')
|
|
.map(async (ct) => {
|
|
try {
|
|
const [rrdData, currentData] = await Promise.all([
|
|
apiClient.get(`/nodes/${nodeName}/lxc/${ct.vmid}/rrddata`, { params: { timeframe: 'hour', cf: 'AVERAGE' } }),
|
|
apiClient.get(`/nodes/${nodeName}/lxc/${ct.vmid}/status/current`)
|
|
]);
|
|
|
|
let metricData = {
|
|
id: ct.vmid,
|
|
guestName: ct.name,
|
|
node: nodeName,
|
|
type: 'lxc',
|
|
endpointId: endpointId,
|
|
data: [],
|
|
current: currentData?.data?.data || null
|
|
};
|
|
if (rrdData?.data?.data?.length > 0) metricData.data = rrdData.data.data;
|
|
return metricData; // Return successful metric data
|
|
} catch (err) {
|
|
// Add status code to error log
|
|
const status = err.response?.status ? ` (Status: ${err.response.status})` : '';
|
|
console.error(`[Metrics] Failed to get metrics for container ${ct.vmid} on node ${nodeName} (Endpoint: ${endpointId})${status}: ${err.message}`);
|
|
return null; // Return null on error for this specific container
|
|
}
|
|
});
|
|
|
|
const ctMetricsResults = await Promise.allSettled(ctMetricPromises);
|
|
ctMetricsResults.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
nodeData.metrics.push(result.value);
|
|
}
|
|
// Optionally log rejected promises if needed
|
|
});
|
|
}
|
|
} catch (err) {
|
|
// Add status code to error log
|
|
const status = err.response?.status ? ` (Status: ${err.response.status})` : '';
|
|
console.error(`[Discovery] Error fetching containers from node ${nodeName} (Endpoint: ${endpointId})${status}: ${err.message}`);
|
|
}
|
|
|
|
// Return collected data (VMs, Containers) for this node from this endpoint
|
|
// Metrics are handled separately in fetchMetricsData
|
|
return {
|
|
vms: nodeData.vms,
|
|
containers: nodeData.containers
|
|
};
|
|
}
|
|
|
|
// --- Refactored Data Fetching Logic ---
|
|
|
|
/**
|
|
* Fetches structural data: node list, node statuses, VM list, Container list
|
|
* across ALL configured endpoints.
|
|
*/
|
|
async function fetchDiscoveryData() {
|
|
console.log('[Discovery Cycle] Starting fetch across all endpoints...');
|
|
const aggregatedResult = {
|
|
nodes: [],
|
|
vms: [],
|
|
containers: []
|
|
};
|
|
|
|
// Get a list of endpoint IDs to iterate over
|
|
const endpointIds = Object.keys(apiClients);
|
|
if (endpointIds.length === 0) {
|
|
console.warn("[Discovery Cycle] No API clients configured. Skipping fetch.");
|
|
return aggregatedResult; // Return empty if no clients
|
|
}
|
|
|
|
// Use Promise.allSettled to fetch from all endpoints concurrently
|
|
const discoveryPromises = endpointIds.map(async (endpointId) => {
|
|
const { client: apiClient, config: endpointConfig } = apiClients[endpointId];
|
|
const endpointName = endpointConfig.name || endpointId; // For logging
|
|
const endpointResult = { nodes: [], vms: [], containers: [] }; // Results for this specific endpoint
|
|
let nodesToQuery = [];
|
|
let basicNodeInfo = []; // Basic info from /nodes for this endpoint
|
|
|
|
console.log(`[Discovery Cycle] Fetching discovery data for endpoint: ${endpointName}`);
|
|
|
|
// --- Step 1: Fetch Node List and Basic Info for this endpoint ---
|
|
try {
|
|
const nodesResponse = await apiClient.get('/nodes');
|
|
basicNodeInfo = nodesResponse.data.data || [];
|
|
nodesToQuery = basicNodeInfo.map(n => n.node).filter(Boolean);
|
|
|
|
if (nodesToQuery.length === 0) {
|
|
console.warn(`[Discovery Cycle - ${endpointName}] /nodes returned 0 nodes. Attempting single node discovery.`);
|
|
try {
|
|
const versionResponse = await apiClient.get('/version');
|
|
const discoveredNodeName = versionResponse.data.data.node;
|
|
if (discoveredNodeName) {
|
|
nodesToQuery = [discoveredNodeName];
|
|
// Synthesize basic info for the single node
|
|
basicNodeInfo = [{ node: discoveredNodeName, ip: 'unknown', status: 'unknown' }];
|
|
} else {
|
|
throw new Error("Could not determine node name from /version.");
|
|
}
|
|
} catch (discoveryError) {
|
|
// Add status code to error log
|
|
const status = discoveryError.response?.status ? ` (Status: ${discoveryError.response.status})` : '';
|
|
console.error(`[Discovery Cycle - ${endpointName}] Single node discovery failed${status}: ${discoveryError.message}. Cannot proceed for this endpoint.`);
|
|
return { status: 'rejected', value: null, endpointId: endpointId, reason: discoveryError }; // Indicate failure for this endpoint
|
|
}
|
|
}
|
|
} catch (error) {
|
|
// Add status code to error log
|
|
const status = error.response?.status ? ` (Status: ${error.response.status})` : '';
|
|
console.warn(`[Discovery Cycle - ${endpointName}] Failed to fetch /nodes${status} (${error.message}). Attempting single node discovery.`);
|
|
try {
|
|
const versionResponse = await apiClient.get('/version');
|
|
const discoveredNodeName = versionResponse.data.data.node;
|
|
if (discoveredNodeName) {
|
|
nodesToQuery = [discoveredNodeName];
|
|
basicNodeInfo = [{ node: discoveredNodeName, ip: 'unknown', status: 'unknown' }];
|
|
} else {
|
|
throw new Error("Could not determine node name from /version.");
|
|
}
|
|
} catch (discoveryError) {
|
|
// Add status code to error log
|
|
const status = discoveryError.response?.status ? ` (Status: ${discoveryError.response.status})` : '';
|
|
console.error(`[Discovery Cycle - ${endpointName}] Single node discovery failed after /nodes error${status}: ${discoveryError.message}. Cannot proceed for this endpoint.`);
|
|
return { status: 'rejected', value: null, endpointId: endpointId, reason: discoveryError }; // Indicate failure for this endpoint
|
|
}
|
|
}
|
|
|
|
// --- Step 2: Fetch Detailed Status for Each Node in this endpoint ---
|
|
const nodeStatusPromises = nodesToQuery.map(async (nodeName) => {
|
|
if (!nodeName) return null;
|
|
try {
|
|
const statusResponse = await apiClient.get(`/nodes/${nodeName}/status`);
|
|
let statusData = statusResponse.data.data;
|
|
const basicInfo = basicNodeInfo.find(n => n.node === nodeName);
|
|
|
|
// Ensure statusData is an object and add necessary fields
|
|
if (typeof statusData !== 'object' || statusData === null) {
|
|
statusData = {}; // Initialize if not an object
|
|
}
|
|
if (!statusData.node) { statusData.node = nodeName; }
|
|
statusData.ip = basicInfo?.ip || 'fetch_error'; // Add IP from basic info
|
|
statusData.endpointId = endpointId; // **** ADD ENDPOINT ID ****
|
|
statusData.endpointName = endpointName; // Add human-readable name too
|
|
return statusData;
|
|
|
|
} catch (statusError) {
|
|
// Add status code to error log
|
|
const status = statusError.response?.status ? ` (Status: ${statusError.response.status})` : '';
|
|
console.warn(`[Discovery Cycle - ${endpointName}] Could not fetch status for node ${nodeName}${status}: ${statusError.message}`);
|
|
const basicInfo = basicNodeInfo.find(n => n.node === nodeName);
|
|
// Return a synthesized offline status object
|
|
return {
|
|
node: nodeName,
|
|
status: 'offline', // Explicitly mark as offline on error
|
|
ip: basicInfo?.ip || 'fetch_error',
|
|
cpu: 0, // Provide default numeric values
|
|
memory: { total: 0, used: 0 },
|
|
rootfs: { total: 0, used: 0 },
|
|
swap: { total: 0, used: 0 },
|
|
uptime: 0,
|
|
endpointId: endpointId, // **** ADD ENDPOINT ID ****
|
|
endpointName: endpointName
|
|
};
|
|
}
|
|
});
|
|
const detailedNodeResults = await Promise.allSettled(nodeStatusPromises);
|
|
endpointResult.nodes = detailedNodeResults
|
|
.filter(result => result.status === 'fulfilled' && result.value)
|
|
.map(result => result.value);
|
|
|
|
// Fallback if all status calls failed for this endpoint but /nodes worked
|
|
if (endpointResult.nodes.length === 0 && basicNodeInfo.length > 0) {
|
|
console.warn(`[Discovery Cycle - ${endpointName}] All node status fetches failed. Falling back to basic node info from /nodes.`);
|
|
endpointResult.nodes = basicNodeInfo.map(node => ({
|
|
...node,
|
|
ip: node.ip || 'unknown',
|
|
status: 'unknown', // Mark status as unknown
|
|
cpu: 0, memory: { total: 0, used: 0 }, rootfs: { total: 0, used: 0 },
|
|
swap: { total: 0, used: 0 }, uptime: 0,
|
|
endpointId: endpointId, // **** ADD ENDPOINT ID ****
|
|
endpointName: endpointName
|
|
}));
|
|
}
|
|
|
|
|
|
// --- Step 3: Fetch VM/Container Lists for Each Node in this endpoint ---
|
|
// Only query nodes we successfully got status for (or synthesized)
|
|
const finalNodeNames = endpointResult.nodes.map(n => n.node).filter(Boolean);
|
|
const guestListPromises = finalNodeNames.map(async (nodeName) => {
|
|
try {
|
|
// Use the modified fetchDataForNode which now requires apiClient and endpointId
|
|
// It returns { vms: [], containers: [] } with endpointId already added
|
|
// Pass the endpointId explicitly here
|
|
const nodeGuestData = await fetchDataForNode(apiClient, endpointId, nodeName);
|
|
// The endpointId and type are added within fetchDataForNode now
|
|
// return { vms: nodeGuestData.vms, containers: nodeGuestData.containers };
|
|
// Simplified return, type/endpointId added in fetchDataForNode
|
|
return nodeGuestData;
|
|
} catch (guestError) {
|
|
// Add status code to error log
|
|
const status = guestError.response?.status ? ` (Status: ${guestError.response.status})` : '';
|
|
console.error(`[Discovery Cycle - ${endpointName}] Error fetching guest data for node ${nodeName}${status}: ${guestError.message}`);
|
|
return { vms: [], containers: [] }; // Return empty on error for this node
|
|
}
|
|
});
|
|
|
|
const guestListResults = await Promise.allSettled(guestListPromises);
|
|
guestListResults.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
endpointResult.vms.push(...result.value.vms);
|
|
endpointResult.containers.push(...result.value.containers);
|
|
}
|
|
});
|
|
|
|
console.log(`[Discovery Cycle - ${endpointName}] Completed. Found: ${endpointResult.nodes.length} nodes, ${endpointResult.vms.length} VMs, ${endpointResult.containers.length} containers.`);
|
|
return { status: 'fulfilled', value: endpointResult, endpointId: endpointId }; // Return structured result
|
|
|
|
}); // End map over endpointIds
|
|
|
|
// Process results from all endpoints
|
|
const allEndpointResults = await Promise.allSettled(discoveryPromises);
|
|
|
|
allEndpointResults.forEach(endpointOutcome => {
|
|
if (endpointOutcome.status === 'fulfilled' && endpointOutcome.value.status === 'fulfilled') {
|
|
const endpointData = endpointOutcome.value.value;
|
|
aggregatedResult.nodes.push(...endpointData.nodes);
|
|
aggregatedResult.vms.push(...endpointData.vms);
|
|
aggregatedResult.containers.push(...endpointData.containers);
|
|
} else if (endpointOutcome.status === 'rejected') {
|
|
// Log errors from the outer Promise.allSettled (e.g., client setup issues or inner rejects)
|
|
const reason = endpointOutcome.reason || endpointOutcome.value?.reason || 'Unknown reason';
|
|
const failedEndpointId = endpointOutcome.value?.endpointId || 'Unknown endpoint';
|
|
console.error(`[Discovery Cycle] Failed to process endpoint discovery promise for ${failedEndpointId}: ${reason}`);
|
|
} else if (endpointOutcome.value.status !== 'fulfilled'){
|
|
// Log errors from the inner endpoint processing (already logged, but maybe add summary)
|
|
console.error(`[Discovery Cycle] Failed discovery for endpoint: ${endpointOutcome.value.endpointId}`);
|
|
}
|
|
});
|
|
|
|
if (DEBUG_METRICS) {
|
|
console.log(`[Discovery Cycle] Aggregated results. Total: ${aggregatedResult.nodes.length} nodes, ${aggregatedResult.vms.length} VMs, ${aggregatedResult.containers.length} containers across ${endpointIds.length} configured endpoints.`);
|
|
}
|
|
return aggregatedResult;
|
|
}
|
|
|
|
/**
|
|
* Fetches dynamic metric data ONLY for currently known running VMs and Containers
|
|
* across ALL configured endpoints.
|
|
*/
|
|
async function fetchMetricsData(runningVms, runningContainers) {
|
|
console.log(`[Metrics Cycle] Starting metrics fetch for ${runningVms.length} VMs, ${runningContainers.length} Containers...`);
|
|
const allMetrics = [];
|
|
const metricPromises = [];
|
|
|
|
// Group running guests by endpointId and then by nodeName
|
|
const guestsByEndpointNode = {};
|
|
|
|
[...runningVms, ...runningContainers].forEach(guest => {
|
|
if (!guest.endpointId || !guest.node || !guest.vmid || !guest.type) {
|
|
console.warn(`[Metrics Cycle] Skipping guest with missing info:`, guest);
|
|
return;
|
|
}
|
|
const { endpointId, node, vmid, type, name } = guest; // Include name for logging
|
|
|
|
if (!guestsByEndpointNode[endpointId]) {
|
|
guestsByEndpointNode[endpointId] = {};
|
|
}
|
|
if (!guestsByEndpointNode[endpointId][node]) {
|
|
guestsByEndpointNode[endpointId][node] = [];
|
|
}
|
|
// Store minimal info needed for fetch, ensuring name exists
|
|
guestsByEndpointNode[endpointId][node].push({ vmid, type, name: name || 'unknown' });
|
|
});
|
|
|
|
// Iterate through endpoints that have running guests
|
|
for (const endpointId in guestsByEndpointNode) {
|
|
if (!apiClients[endpointId]) {
|
|
console.warn(`[Metrics Cycle] API client for endpoint ${endpointId} not found. Skipping metrics fetch for its guests.`);
|
|
continue;
|
|
}
|
|
const { client: apiClient, config: endpointConfig } = apiClients[endpointId];
|
|
const endpointName = endpointConfig.name || endpointId;
|
|
|
|
// Iterate through nodes within this endpoint that have running guests
|
|
for (const nodeName in guestsByEndpointNode[endpointId]) {
|
|
const guestsOnNode = guestsByEndpointNode[endpointId][nodeName];
|
|
|
|
// Create promises for fetching metrics for guests on this specific node
|
|
guestsOnNode.forEach(guestInfo => {
|
|
const { vmid, type, name: guestName } = guestInfo;
|
|
metricPromises.push(
|
|
(async () => {
|
|
try {
|
|
const pathPrefix = type === 'qemu' ? 'qemu' : 'lxc';
|
|
const [rrdData, currentData] = await Promise.all([
|
|
apiClient.get(`/nodes/${nodeName}/${pathPrefix}/${vmid}/rrddata`, { params: { timeframe: 'hour', cf: 'AVERAGE' } }),
|
|
apiClient.get(`/nodes/${nodeName}/${pathPrefix}/${vmid}/status/current`)
|
|
]);
|
|
|
|
const metricData = {
|
|
id: vmid,
|
|
guestName: guestName, // Keep guest name
|
|
node: nodeName,
|
|
type: type,
|
|
endpointId: endpointId, // Add endpointId
|
|
endpointName: endpointName, // Add readable name
|
|
data: rrdData?.data?.data?.length > 0 ? rrdData.data.data : [],
|
|
current: currentData?.data?.data || null
|
|
};
|
|
return metricData;
|
|
} catch (err) {
|
|
// Log error but don't crash the whole cycle
|
|
// Add status code to error log
|
|
const status = err.response?.status ? ` (Status: ${err.response.status})` : '';
|
|
// Check if error is due to guest being stopped (400 Bad Request often indicates this for status/current)
|
|
if (err.response && err.response.status === 400) {
|
|
console.warn(`[Metrics Cycle - ${endpointName}] Guest ${type} ${vmid} (${guestName}) on node ${nodeName} might be stopped or inaccessible (Status: 400). Skipping metrics.`);
|
|
} else {
|
|
console.error(`[Metrics Cycle - ${endpointName}] Failed to get metrics for ${type} ${vmid} (${guestName}) on node ${nodeName}${status}: ${err.message}`);
|
|
}
|
|
return null; // Return null on error for this specific guest
|
|
}
|
|
})() // Immediately invoke the async function
|
|
);
|
|
}); // End foreach guest on node
|
|
} // End foreach node in endpoint
|
|
} // End foreach endpoint
|
|
|
|
// Wait for all metric fetch promises to settle
|
|
const metricResults = await Promise.allSettled(metricPromises);
|
|
|
|
metricResults.forEach(result => {
|
|
if (result.status === 'fulfilled' && result.value) {
|
|
allMetrics.push(result.value);
|
|
}
|
|
// Optional: Log rejected promises if needed (errors are already logged individually)
|
|
// else if (result.status === 'rejected') { console.error(...) }
|
|
});
|
|
|
|
if (DEBUG_METRICS) {
|
|
console.log(`[Metrics Cycle] Completed. Fetched metrics for ${allMetrics.length} running guests.`);
|
|
}
|
|
return allMetrics;
|
|
}
|
|
|
|
// --- Socket.io connection handling (Initial data fetch needs update) ---
|
|
io.on('connection', (socket) => {
|
|
console.log(`[socket] Client connected. Total clients: ${io.engine.clientsCount}`);
|
|
|
|
// Send initial data immediately if available
|
|
if (currentNodes.length > 0 || currentVms.length > 0 || currentContainers.length > 0) {
|
|
console.log('[socket] Sending existing data to new client.');
|
|
socket.emit('rawData', {
|
|
nodes: currentNodes,
|
|
vms: currentVms,
|
|
containers: currentContainers,
|
|
metrics: currentMetrics
|
|
});
|
|
} else {
|
|
// If no data yet, trigger a discovery cycle (if not already running)
|
|
console.log('[socket] No data yet, triggering initial discovery for new client...');
|
|
if (!isDiscoveryRunning) {
|
|
runDiscoveryCycle();
|
|
}
|
|
}
|
|
|
|
// Handle disconnect
|
|
socket.on('disconnect', () => {
|
|
setTimeout(() => {
|
|
console.log(`[socket] Client disconnected. Total clients: ${io.engine.clientsCount}`);
|
|
// Optional: Stop polling if client count drops to 0? (Handled in run cycles)
|
|
}, 100);
|
|
});
|
|
});
|
|
|
|
// --- New Update Cycle Logic ---
|
|
|
|
// Discovery Cycle Runner
|
|
async function runDiscoveryCycle() {
|
|
if (isDiscoveryRunning) {
|
|
// console.log('[Discovery Cycle] Already running, skipping.');
|
|
return;
|
|
}
|
|
isDiscoveryRunning = true;
|
|
|
|
try {
|
|
const discoveryData = await fetchDiscoveryData();
|
|
// Update global state
|
|
currentNodes = discoveryData.nodes;
|
|
currentVms = discoveryData.vms;
|
|
currentContainers = discoveryData.containers;
|
|
|
|
// Emit combined data only if clients are connected
|
|
if (io.engine.clientsCount > 0) {
|
|
if (DEBUG_METRICS) {
|
|
console.log('[Discovery Cycle] Emitting updated structural data.');
|
|
}
|
|
io.emit('rawData', {
|
|
nodes: currentNodes,
|
|
vms: currentVms,
|
|
containers: currentContainers,
|
|
metrics: currentMetrics // Send latest (potentially empty) metrics
|
|
});
|
|
}
|
|
} catch (error) {
|
|
console.error(`[Discovery Cycle] Error during execution: ${error.message}`, error.stack);
|
|
} finally {
|
|
isDiscoveryRunning = false;
|
|
// Schedule the next discovery cycle
|
|
scheduleNextDiscovery();
|
|
}
|
|
}
|
|
|
|
// Metric Cycle Runner
|
|
async function runMetricCycle() {
|
|
if (isMetricsRunning) {
|
|
// console.log('[Metrics Cycle] Already running, skipping.');
|
|
return;
|
|
}
|
|
// Only run if clients are connected
|
|
if (io.engine.clientsCount === 0) {
|
|
// console.log('[Metrics Cycle] No clients connected, skipping fetch.');
|
|
scheduleNextMetric(); // Still schedule next check
|
|
return;
|
|
}
|
|
|
|
isMetricsRunning = true;
|
|
|
|
try {
|
|
// Filter for running guests based on the MOST RECENT state from discovery
|
|
const runningVms = currentVms.filter(vm => vm.status === 'running');
|
|
const runningContainers = currentContainers.filter(ct => ct.status === 'running');
|
|
|
|
if (runningVms.length > 0 || runningContainers.length > 0) {
|
|
// Fetch metrics using the dedicated function
|
|
const fetchedMetrics = await fetchMetricsData(runningVms, runningContainers);
|
|
currentMetrics = fetchedMetrics; // Update global metrics state
|
|
// Emit combined data
|
|
// console.log('[Metrics Cycle] Emitting updated metrics data.'); // Reduced verbosity
|
|
io.emit('rawData', {
|
|
nodes: currentNodes, // Send current nodes/vms/cts state
|
|
vms: currentVms,
|
|
containers: currentContainers,
|
|
metrics: currentMetrics // Send the newly fetched metrics
|
|
});
|
|
} else {
|
|
// console.log('[Metrics Cycle] No running guests found, skipping metric fetch.');
|
|
currentMetrics = []; // Clear metrics if no guests running
|
|
// Emit state update even if metrics were cleared
|
|
io.emit('rawData', {
|
|
nodes: currentNodes, vms: currentVms,
|
|
containers: currentContainers, metrics: currentMetrics
|
|
});
|
|
}
|
|
|
|
} catch (error) {
|
|
console.error(`[Metrics Cycle] Error during execution: ${error.message}`, error.stack);
|
|
} finally {
|
|
isMetricsRunning = false;
|
|
// Schedule the next metric cycle
|
|
scheduleNextMetric();
|
|
}
|
|
}
|
|
|
|
// Schedulers using setTimeout
|
|
function scheduleNextDiscovery() {
|
|
if (discoveryTimeoutId) clearTimeout(discoveryTimeoutId);
|
|
discoveryTimeoutId = setTimeout(runDiscoveryCycle, DISCOVERY_UPDATE_INTERVAL);
|
|
}
|
|
|
|
function scheduleNextMetric() {
|
|
if (metricTimeoutId) clearTimeout(metricTimeoutId);
|
|
metricTimeoutId = setTimeout(runMetricCycle, METRIC_UPDATE_INTERVAL);
|
|
}
|
|
|
|
// Start the initial cycles
|
|
console.log('Starting initial data fetch cycles...');
|
|
runDiscoveryCycle(); // Run discovery first
|
|
// Metrics will be triggered after discovery or by its own timer if clients connect later
|
|
scheduleNextMetric(); // Start scheduling metrics right away
|
|
|
|
// --- End New Update Cycle Logic ---
|
|
|
|
// Start the server
|
|
server.listen(PORT, () => {
|
|
console.log(`Server listening on port ${PORT}`);
|
|
|
|
// Setup hot reload in development mode
|
|
if (process.env.NODE_ENV === 'development' && chokidar) {
|
|
const publicPath = path.join(__dirname, '../public');
|
|
console.log(`Watching for changes in ${publicPath}`);
|
|
const watcher = chokidar.watch(publicPath, {
|
|
ignored: /(^|[\\\/])\./, // ignore dotfiles
|
|
persistent: true,
|
|
ignoreInitial: true // Don't trigger on initial scan
|
|
});
|
|
|
|
watcher.on('change', (filePath) => {
|
|
// console.log(`File changed: ${filePath}. Triggering hot reload.`);
|
|
io.emit('hotReload'); // Notify clients to reload
|
|
});
|
|
|
|
watcher.on('error', error => console.error(`Watcher error: ${error}`));
|
|
}
|
|
}); |