Files
pulse/server/index.js
T

1339 lines
59 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
let pbsApiClient = null; // Initialize PBS client as null
let pbsConfig = null; // Initialize PBS config as null
// --- Load PBS Configuration (if provided) ---
// Check for User/Password first
if (process.env.PBS_HOST && process.env.PBS_USER && process.env.PBS_PASSWORD) {
const pbsHost = process.env.PBS_HOST;
const pbsUser = process.env.PBS_USER;
const pbsPassword = process.env.PBS_PASSWORD;
const pbsRealm = process.env.PBS_REALM || 'pbs'; // Default realm 'pbs'
// Basic placeholder check for PBS user/pass vars
const pbsPlaceholders = placeholderValues.filter(p =>
pbsHost.includes(p) || pbsUser.includes(p) || pbsPassword.includes(p)
);
if (pbsPlaceholders.length > 0) {
console.warn(`WARN: Skipping PBS configuration. The following variables seem to contain placeholder values: ${pbsPlaceholders.join(', ')}`);
} else {
pbsConfig = {
id: 'pbs_primary_userpass',
authMethod: 'userpass', // Indicate auth method
name: process.env.PBS_NODE_NAME || pbsHost,
host: pbsHost,
port: process.env.PBS_PORT || '8007',
user: pbsUser,
password: pbsPassword,
realm: pbsRealm,
nodeName: process.env.PBS_NODE_NAME,
allowSelfSignedCerts: process.env.PBS_ALLOW_SELF_SIGNED_CERTS !== 'false',
enabled: true
};
console.log(`INFO: Found PBS configuration (User/Password): ${pbsConfig.name} (${pbsConfig.host})`);
}
// Check for Token second (fallback)
} else if (process.env.PBS_HOST && process.env.PBS_TOKEN_ID && process.env.PBS_TOKEN_SECRET) {
const pbsHost = process.env.PBS_HOST;
const pbsTokenId = process.env.PBS_TOKEN_ID;
const pbsTokenSecret = process.env.PBS_TOKEN_SECRET;
// Basic placeholder check for PBS token vars
const pbsPlaceholders = placeholderValues.filter(p =>
pbsHost.includes(p) || pbsTokenId.includes(p) || pbsTokenSecret.includes(p)
);
if (pbsPlaceholders.length > 0) {
console.warn(`WARN: Skipping PBS configuration (Token). The following variables seem to contain placeholder values: ${pbsPlaceholders.join(', ')}`);
} else {
pbsConfig = {
id: 'pbs_primary_token',
authMethod: 'token', // Indicate auth method
name: process.env.PBS_NODE_NAME || pbsHost,
host: pbsHost,
port: process.env.PBS_PORT || '8007',
tokenId: pbsTokenId,
tokenSecret: pbsTokenSecret,
nodeName: process.env.PBS_NODE_NAME,
allowSelfSignedCerts: process.env.PBS_ALLOW_SELF_SIGNED_CERTS !== 'false',
enabled: true
};
console.log(`INFO: Found PBS configuration (API Token): ${pbsConfig.name} (${pbsConfig.host})`);
}
} else if (process.env.PBS_HOST || process.env.PBS_TOKEN_ID || process.env.PBS_TOKEN_SECRET || process.env.PBS_USER || process.env.PBS_PASSWORD) {
// Warn if some but not all required PBS vars are set
console.warn("WARN: Partial PBS configuration found. Please set PBS_HOST and either (PBS_TOKEN_ID + PBS_TOKEN_SECRET) or (PBS_USER + PBS_PASSWORD) to enable PBS monitoring.");
}
// --- End PBS Configuration Loading ---
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})`);
});
// --- Create PBS API Client (if configured) ---
async function getPbsAuthTicketAndSetupClient(config) {
const pbsBaseURL = config.host.includes('://')
? `${config.host}` // Assume full URL if :// is present
: `https://${config.host}:${config.port}`;
const loginUrl = `${pbsBaseURL}/api2/json/access/ticket`;
const httpsAgent = new https.Agent({
rejectUnauthorized: !config.allowSelfSignedCerts
});
try {
console.log(`INFO: Attempting PBS login for user ${config.user}...`);
const response = await axios.post(loginUrl, `username=${encodeURIComponent(config.user)}&password=${encodeURIComponent(config.password)}`, {
httpsAgent: httpsAgent,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }
});
const ticket = response.data.data.ticket;
const csrfToken = response.data.data.CSRFPreventionToken;
const username = response.data.data.username;
if (!ticket || !csrfToken) {
throw new Error("Login response missing ticket or CSRF token.");
}
console.log(`INFO: PBS login successful for ${username}. Setting up authenticated client.`);
// Create the authenticated client instance
const pbsAuthenticatedClient = axios.create({
baseURL: `${pbsBaseURL}/api2/json`,
httpsAgent: httpsAgent,
headers: {
'Content-Type': 'application/json'
}
});
// Apply interceptor for cookie and CSRF token
pbsAuthenticatedClient.interceptors.request.use(reqConfig => {
reqConfig.headers['CSRFPreventionToken'] = csrfToken;
reqConfig.headers['Cookie'] = `PBSAuthCookie=${ticket}`;
return reqConfig;
});
// Apply retry logic
axiosRetry(pbsAuthenticatedClient, {
retries: 3,
retryDelay: (retryCount, error) => {
console.warn(`Retrying authenticated PBS API request (attempt ${retryCount}) due to error: ${error.message}`);
return axiosRetry.exponentialDelay(retryCount);
},
retryCondition: (error) => {
// Also retry on 401/403 in case ticket expires or CSRF mismatch?
// For now, just standard retry conditions.
return axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error);
},
});
return { client: pbsAuthenticatedClient, config: config }; // Return the fully configured client object
} catch (error) {
console.error(`ERROR: PBS login failed for user ${config.user}: ${error.message}`);
if (error.response) {
console.error(`ERROR: PBS login response status: ${error.response.status}`);
console.error(`ERROR: PBS login response data: ${JSON.stringify(error.response.data)}`);
}
return null; // Indicate failure
}
}
async function initializePbsClient() {
if (pbsConfig) {
if (pbsConfig.authMethod === 'userpass') {
pbsApiClient = await getPbsAuthTicketAndSetupClient(pbsConfig);
if (pbsApiClient) {
console.log(`INFO: Initialized API client for PBS (User/Password Auth): ${pbsConfig.name} (${pbsConfig.host})`);
} else {
console.error("ERROR: Failed to initialize PBS client using User/Password.");
}
} else if (pbsConfig.authMethod === 'token') {
// Original Token Auth Logic
const pbsBaseURL = pbsConfig.host.includes('://')
? `${pbsConfig.host}/api2/json`
: `https://${pbsConfig.host}:${pbsConfig.port}/api2/json`;
const pbsAxiosInstance = axios.create({
baseURL: pbsBaseURL,
httpsAgent: new https.Agent({
rejectUnauthorized: !pbsConfig.allowSelfSignedCerts
}),
headers: {
'Content-Type': 'application/json'
}
});
pbsAxiosInstance.interceptors.request.use(config => {
config.headers.Authorization = `PBSAPIToken ${pbsConfig.tokenId}:${pbsConfig.tokenSecret}`;
return config;
});
axiosRetry(pbsAxiosInstance, {
retries: 3,
retryDelay: (retryCount, error) => {
console.warn(`Retrying PBS API request (Token Auth - attempt ${retryCount}) due to error: ${error.message}`);
return axiosRetry.exponentialDelay(retryCount);
},
retryCondition: (error) => {
return axiosRetry.isNetworkError(error) || axiosRetry.isRetryableError(error);
},
});
pbsApiClient = { client: pbsAxiosInstance, config: pbsConfig };
console.log(`INFO: Initialized API client for PBS (Token Auth): ${pbsConfig.name} (${pbsConfig.host})`);
}
}
}
if (Object.keys(apiClients).length === 0 && !pbsApiClient) {
console.error("\n--- Configuration Error ---");
console.error("No enabled Proxmox VE or PBS 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, '../src/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 pbsData = { tasks: {}, datastores: [], nodeName: null, status: 'initializing' }; // Re-add global pbsData
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...");
let aggregatedResult = { nodes: [], vms: [], containers: [], pbs: pbsData || {} }; // Initialize with current PBS data
// --- PVE Data Fetching ---
const pveEndpointIds = Object.keys(apiClients).filter(id => !apiClients[id].isPbs); // Exclude PBS client if present
// Initialize temporary accumulators for PVE data
let tempNodes = [];
let tempVms = [];
let tempContainers = [];
if (pveEndpointIds.length > 0) {
const pvePromises = pveEndpointIds.map(endpointId =>
(async () => {
const { client: apiClient, config } = apiClients[endpointId];
const endpointName = config.name || endpointId; // Use configured name or ID
console.log(`[Discovery Cycle] Fetching PVE discovery data for endpoint: ${endpointName}`);
try {
// Get all nodes for the endpoint
const nodesResponse = await apiClient.get('/nodes');
const nodes = nodesResponse.data.data; // Assuming structure { data: { data: [...] } }
if (!nodes || !Array.isArray(nodes)) {
console.warn(`[Discovery Cycle - ${endpointName}] No nodes found or unexpected format.`);
return { endpointId: endpointName, status: 'fulfilled', value: { nodes: [], vms: [], containers: [] } }; // Return empty structure on node failure
}
// Fetch VMs and Containers for each node in parallel
const guestPromises = nodes.map(node => fetchDataForNode(apiClient, endpointName, node.node)); // Pass endpointName
const guestResults = await Promise.allSettled(guestPromises);
let endpointNodes = [];
let endpointVms = [];
let endpointContainers = [];
// Process node info first
nodes.forEach(nodeInfo => {
endpointNodes.push({
...nodeInfo,
endpointId: endpointName, // Add endpointId/name
id: `${endpointName}-${nodeInfo.node}` // Create unique ID
});
});
// Process results for VMs and Containers
guestResults.forEach(result => {
if (result.status === 'fulfilled' && result.value) {
// Add endpointId to each vm and container
result.value.vms.forEach(vm => {
vm.endpointId = endpointId; // Use the internal endpointId, not endpointName
vm.id = `${endpointName}-${vm.node}-${vm.vmid}`; // Keep unique ID using endpointName
});
result.value.containers.forEach(ct => {
ct.endpointId = endpointId; // Use the internal endpointId, not endpointName
ct.id = `${endpointName}-${ct.node}-${ct.vmid}`; // Keep unique ID using endpointName
});
endpointVms.push(...result.value.vms);
endpointContainers.push(...result.value.containers);
} else if (result.status === 'rejected') {
// Log node-specific failure if needed, but continue processing others
console.error(`[Discovery Cycle - ${endpointName}] Failed fetching guest data for a node: ${result.reason?.message || result.reason}`);
}
});
console.log(`[Discovery Cycle - ${endpointName}] Completed. Found: ${endpointNodes.length} nodes, ${endpointVms.length} VMs, ${endpointContainers.length} containers.`);
// Return combined results for this endpoint
return { endpointId: endpointName, status: 'fulfilled', value: { nodes: endpointNodes, vms: endpointVms, containers: endpointContainers } };
} catch (error) {
const status = error.response?.status ? ` (Status: ${error.response.status})` : '';
console.error(`[Discovery Cycle - ${endpointName}] Error fetching discovery data${status}: ${error.message}`);
// Return a rejected status for this specific endpoint promise
// Ensure 'reason' has necessary details if possible
return { endpointId: endpointName, status: 'rejected', reason: error.message || String(error) };
}
})() // Immediately invoke the async function
);
const pveOutcomes = await Promise.allSettled(pvePromises);
// Process results from all PVE endpoints
pveOutcomes.forEach(endpointOutcome => {
if (endpointOutcome.status === 'fulfilled' && endpointOutcome.value.status === 'fulfilled' && endpointOutcome.value.value) {
// Successfully fetched data for this endpoint
const { nodes, vms, containers } = endpointOutcome.value.value;
const successfulEndpointId = endpointOutcome.value.endpointId; // Use the returned endpointId/name
// console.log(`[Discovery Cycle] Accumulating data from endpoint: ${successfulEndpointId}`); // Debug log
tempNodes.push(...nodes);
tempVms.push(...vms);
tempContainers.push(...containers);
} else {
// Handle endpoint failures (either outer promise rejected or inner fetch failed)
const failedEndpointId = endpointOutcome.status === 'fulfilled' ? endpointOutcome.value.endpointId : endpointOutcome.reason?.endpointId || 'Unknown Endpoint'; // Try to get endpoint ID
const reason = endpointOutcome.status === 'rejected'
? (endpointOutcome.reason?.message || endpointOutcome.reason)
: (endpointOutcome.value.reason || 'Unknown error'); // Reason from inner failure
console.error(`[Discovery Cycle] Failed PVE discovery for endpoint: ${failedEndpointId}. Reason: ${reason}`);
}
// Deprecated logging, keep for now if needed:
/* else if (endpointOutcome.status === 'rejected') { // Outer promise rejected
// Extract endpoint ID if possible from error or context if you modify the promise creation
const failedEndpointId = 'Unknown Endpoint'; // Placeholder - needs better error handling context
const reason = endpointOutcome.reason?.message || endpointOutcome.reason;
console.error(`[Discovery Cycle] Failed to process PVE endpoint discovery promise for ${failedEndpointId}: ${reason}`);
} else if (endpointOutcome.value.status !== 'fulfilled'){ // Catches inner rejections
console.error(`[Discovery Cycle] Failed PVE discovery for endpoint: ${endpointOutcome.value.endpointId}`);
} */
});
} else {
console.log("[Discovery Cycle] No PVE endpoints configured.");
}
// --- Fetch PBS Data (if configured) ---
let newPbsData = null; // Use null to indicate fetch status
if (pbsApiClient) {
console.log("INFO: Fetching PBS discovery data...");
let pbsNodeName = pbsApiClient.config.nodeName; // Use configured name if available
// Ensure pbsApiClient.client exists before trying to use it
if (!pbsApiClient.client) {
console.error("ERROR: pbsApiClient.client is not initialized!");
} else {
if (!pbsNodeName) {
pbsNodeName = await fetchPbsNodeName(pbsApiClient); // Pass the whole object
// Store detected name back in config for future use within this run
if (pbsNodeName && pbsNodeName !== 'localhost') {
pbsApiClient.config.nodeName = pbsNodeName;
}
}
try {
const [tasks, datastores] = await Promise.all([
fetchPbsTaskData(pbsApiClient, pbsNodeName), // Pass the whole object
fetchPbsDatastoreData(pbsApiClient) // Pass the whole object
]);
// Assign fetched data to the newPbsData object *only on success*
newPbsData = { tasks, datastores, nodeName: pbsNodeName, status: 'ok' };
console.log("INFO: Finished fetching PBS discovery data successfully.");
} catch (pbsError) {
console.error(`ERROR: Failed fetching PBS data during discovery cycle: ${pbsError.message}`);
// Do NOT update pbsData here, let it keep the old value
// newPbsData remains null to indicate failure of this fetch attempt
if (pbsError.response?.status === 401) {
console.error("ERROR: PBS API Authentication Expired or Invalid (401). Re-login needed.");
// Future improvement: Trigger re-login here
}
}
}
} else {
console.log("[Discovery Cycle] No PBS client configured.");
// Set specific status if unconfigured
newPbsData = { tasks: {}, datastores: [], nodeName: null, status: 'unconfigured' };
}
// --- End Fetch PBS Data ---
// --- Update Global State ---
// Only update pbsData if the fetch was successful (newPbsData is not null)
if (newPbsData !== null) {
pbsData = newPbsData;
}
// Always update PVE data using the accumulated temporary variables
aggregatedResult.nodes = tempNodes;
aggregatedResult.vms = tempVms;
aggregatedResult.containers = tempContainers;
// Add the *final, potentially updated* pbsData state to the result to be emitted
aggregatedResult.pbs = pbsData;
if (DEBUG_METRICS) {
console.log(`[Discovery Cycle] Aggregated PVE results. Total: ${aggregatedResult.nodes.length} nodes, ${aggregatedResult.vms.length} VMs, ${aggregatedResult.containers.length} containers across ${pveEndpointIds.length} configured PVE endpoints.`);
}
console.log('INFO: Discovery cycle completed.');
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`)
]);
// ---> ADDED: Log raw disk values from API <---
const rawCurrentData = currentData?.data?.data || null;
if (rawCurrentData) {
console.log(`[Metrics Fetch - ${endpointName}/${nodeName}/${type}/${vmid}] Raw API current status: diskread=${rawCurrentData.diskread}, diskwrite=${rawCurrentData.diskwrite}`);
}
// ---> END ADDED SECTION <---
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}`);
// ---> NEW: Send initial PBS status immediately <---
let initialPbsStatus = 'unconfigured';
if (pbsConfig) {
initialPbsStatus = 'configured'; // Assume configured if ENV vars are set
// We don't know the *actual* connection status yet,
// discovery cycle will provide 'ok' or 'error' later.
}
console.log(`[socket] Sending initial PBS status to new client: ${initialPbsStatus}`);
socket.emit('pbsInitialStatus', { status: initialPbsStatus });
// ---> END NEW SECTION <---
// Send initial PVE/Metric data immediately if available
if (currentNodes.length > 0 || currentVms.length > 0 || currentContainers.length > 0) {
console.log('[socket] Sending existing PVE/Metric data to new client.');
// Send existing data but ensure pbs status reflects the initial one sent above,
// as the global pbsData might not be initialized or might be stale if discovery hasn't run.
let pbsToSend = pbsData || { tasks: {}, datastores: [], nodeName: null, status: initialPbsStatus };
// Make sure the status aligns with what we just sent in pbsInitialStatus if global state is uninit
if (pbsToSend.status === 'initializing') {
pbsToSend.status = initialPbsStatus;
}
socket.emit('rawData', {
nodes: currentNodes,
vms: currentVms,
containers: currentContainers,
metrics: currentMetrics,
pbs: pbsToSend // Send current global PBS state or initial status
});
} else {
// If no data yet, trigger a discovery cycle (if not already running)
console.log('[socket] No PVE 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(); // This now returns {nodes, vms, containers, pbs}
// Update global state variables from the returned data
currentNodes = discoveryData.nodes || []; // Use fallback for safety
currentVms = discoveryData.vms || [];
currentContainers = discoveryData.containers || [];
// Only update pbsData if it's present in the result (handles initial state)
if (discoveryData.pbs) {
pbsData = discoveryData.pbs;
} else {
// If fetchDiscoveryData somehow didn't return pbs, keep old state or default
pbsData = pbsData || { tasks: {}, datastores: [], nodeName: null, status: 'error' };
}
// Emit combined data using the updated global variables
if (io.engine.clientsCount > 0) {
if (DEBUG_METRICS) {
console.log('[Discovery Cycle] Emitting updated structural data including PBS.');
}
io.emit('rawData', {
nodes: currentNodes,
vms: currentVms,
containers: currentContainers,
pbs: pbsData // Use the updated global pbs state
});
}
} 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);
// ---> MODIFIED: Avoid clearing metrics on transient fetch failure <---
if (fetchedMetrics && fetchedMetrics.length > 0) {
// Success: Update metrics if data was actually returned
currentMetrics = fetchedMetrics;
console.log(`[Metrics Cycle] Successfully updated metrics for ${currentMetrics.length} guests.`);
} else if (fetchedMetrics && fetchedMetrics.length === 0) {
// Fetch likely failed temporarily, keep previous metrics
console.warn('[Metrics Cycle] fetchMetricsData returned empty array despite running guests. Preserving previous metrics state.');
// Do NOT update currentMetrics = [] here
} else {
// Handle unexpected null/undefined return from fetchMetricsData (shouldn't happen)
console.error('[Metrics Cycle] fetchMetricsData returned unexpected value. Preserving previous metrics state.', fetchedMetrics);
}
// ---> END MODIFICATION <---
// Emit combined data (always emit, even if metrics weren't updated this cycle)
io.emit('rawData', {
nodes: currentNodes, // Send current nodes/vms/cts state
vms: currentVms,
containers: currentContainers,
pbs: pbsData, // ADDED: Include current PBS state
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 - THIS IS CORRECT
// Emit state update even if metrics were cleared
io.emit('rawData', {
nodes: currentNodes, vms: currentVms,
containers: currentContainers, metrics: currentMetrics,
pbs: pbsData // ADDED: Include current PBS state
});
}
} 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 ---
// --- PBS Data Fetching Functions ---
async function fetchPbsNodeName(pbsClient) {
// Attempts to fetch the node name from the PBS API
try {
const response = await pbsClient.client.get('/nodes');
if (response.data && response.data.data && response.data.data.length > 0) {
// Assuming the first node listed is the one we're connected to
const nodeName = response.data.data[0].node;
console.log(`INFO: Detected PBS node name: ${nodeName}`);
return nodeName;
} else {
console.warn("WARN: Could not automatically detect PBS node name from API. Response format unexpected.", response.data);
return 'localhost'; // Fallback
}
} catch (error) {
console.error(`ERROR: Failed to fetch PBS nodes list: ${error.message}`);
return 'localhost'; // Fallback on error
}
}
async function fetchPbsTaskData(pbsClient, nodeName) {
// Fetches recent backup task history from PBS
if (!nodeName) {
console.warn("WARN: Cannot fetch PBS task data without node name.");
return { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } };
}
try {
// Fetch tasks from the last 7 days (adjust as needed)
const sinceTimestamp = Math.floor((Date.now() - 7 * 24 * 60 * 60 * 1000) / 1000);
const response = await pbsClient.client.get(`/nodes/${nodeName}/tasks`, {
params: {
// typefilter: 'backup', // Filter specifically for backup tasks if needed - type seems inconsistent?
since: sinceTimestamp,
limit: 200, // Limit the number of tasks fetched
errors: 1 // Include tasks with errors
}
});
let tasks = response.data && response.data.data ? response.data.data : [];
// Filter further if needed - e.g., task 'type' might be 'backup', 'sync', 'gc', 'verify'
// We might only care about 'backup' type for this summary
tasks = tasks.filter(task => task.type === 'backup' || task.worker_type === 'backup'); // Adjust filter based on actual API response
let okCount = 0;
let failedCount = 0;
let lastOkTimestamp = 0;
let lastFailedTimestamp = 0;
tasks.forEach(task => {
if (task.status === 'OK') {
okCount++;
if (task.endtime > lastOkTimestamp) lastOkTimestamp = task.endtime;
} else if (task.status && task.status !== 'running') { // Count non-OK, non-running tasks as failed
failedCount++;
if (task.endtime > lastFailedTimestamp) lastFailedTimestamp = task.endtime;
}
});
console.log(`INFO: Fetched ${tasks.length} PBS backup tasks. OK: ${okCount}, Failed: ${failedCount}`);
return {
recentTasks: tasks.slice(0, 20), // Return only the latest 20 for potential UI display
summary: {
ok: okCount,
failed: failedCount,
total: tasks.length,
lastOk: lastOkTimestamp || null,
lastFailed: lastFailedTimestamp || null,
}
};
} catch (error) {
console.error(`ERROR: Failed to fetch PBS task data for node ${nodeName}: ${error.message}`);
if (error.response?.status === 401) {
console.error("ERROR: PBS API authentication failed (401). Check PBS_TOKEN_ID and PBS_TOKEN_SECRET.");
} else if (error.response?.status === 403) {
console.error("ERROR: PBS API authorization failed (403). Check token permissions.");
}
// Return empty data on error
return { recentTasks: [], summary: { ok: 0, failed: 0, total: 0, lastOk: null, lastFailed: null } };
}
}
async function fetchPbsDatastoreData(pbsClient) {
// Fetches datastore usage details from PBS using the /status/datastore-usage endpoint
let datastores = [];
try {
// Fetch usage stats for all datastores at once
const usageResponse = await pbsClient.client.get('/status/datastore-usage');
const usageData = usageResponse.data && usageResponse.data.data ? usageResponse.data.data : [];
if (usageData.length > 0) {
console.log(`INFO: Fetched status for ${usageData.length} PBS datastores via /status/datastore-usage.`);
// Map the received data to the expected format
datastores = usageData.map(ds => ({
name: ds.store, // Field name might be 'store' or 'name'
path: ds.path || 'N/A', // Path might not be directly in usage stats
total: ds.total,
used: ds.used,
available: ds.avail, // Field name might be 'avail' or 'available'
// Add other relevant fields if available, e.g., gc status
gcStatus: ds['garbage-collection-status'] || 'unknown'
}));
} else {
console.warn("WARN: PBS /status/datastore-usage returned empty data. Falling back to /config/datastore.");
// Fallback to fetching config if usage endpoint is empty
throw new Error("Empty data from /status/datastore-usage");
}
} catch (usageError) {
console.error(`ERROR: Failed to fetch PBS datastore usage via /status/datastore-usage: ${usageError.message}. Trying fallback /config/datastore.`);
// --- Fallback Logic ---
try {
const configResponse = await pbsClient.client.get('/config/datastore');
const datastoresConfig = configResponse.data && configResponse.data.data ? configResponse.data.data : [];
if (datastoresConfig.length > 0) {
console.log(`INFO: Fetched config for ${datastoresConfig.length} PBS datastores (fallback). Status unavailable.`);
datastores = datastoresConfig.map(dsConfig => ({
name: dsConfig.name,
path: dsConfig.path,
total: null,
used: null,
available: null,
gcStatus: 'unknown (config only)'
}));
}
} catch (configError) {
console.error(`ERROR: Fallback fetch of PBS datastore config failed: ${configError.message}`);
if (configError.response?.status === 401 || configError.response?.status === 403) {
console.error("ERROR: PBS API authentication/authorization failed for datastore config access.");
}
// Keep datastores as empty array if both attempts fail
}
// --- End Fallback ---
}
return datastores;
}
// --- END PBS Data Fetching Functions ---
// Start the server
async function startServer() {
await initializePbsClient(); // Initialize PBS client (might fail)
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, '../src/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}`));
}
});
}
startServer();