import { Agent } from 'undici'; const bytesToTb = (bytes) => bytes / 1e12; const buildBaseUrl = (server) => { const rawHost = String(server.host || server.name || '').trim(); if (!rawHost) { throw new Error('Missing host for Proxmox server.'); } const hasScheme = rawHost.startsWith('http://') || rawHost.startsWith('https://'); const scheme = server?.config?.scheme || 'https'; const url = new URL(hasScheme ? rawHost : `${scheme}://${rawHost}`); if (server.port && !url.port) { url.port = String(server.port); } return url; }; const getDispatcher = (server) => { if (server?.config?.allowInsecure === false) { return undefined; } return new Agent({ connect: { rejectUnauthorized: false } }); }; const getStatus = (percent) => { if (percent >= 85) { return { status: 'critical', alerts: 2 }; } return { status: 'healthy', alerts: 0 }; }; const requestJson = async (url, dispatcher, headers) => { const response = await fetch(url, { headers, dispatcher }); if (!response.ok) { throw new Error(`Proxmox API error (${response.status}).`); } const payload = await response.json(); return payload?.data ?? payload; }; const fetchClusterStorage = async (baseUrl, dispatcher, headers) => { const endpoint = new URL('/api2/json/cluster/resources', baseUrl); endpoint.searchParams.set('type', 'storage'); const data = await requestJson(endpoint, dispatcher, headers); return Array.isArray(data) ? data : []; }; const fetchNodes = async (baseUrl, dispatcher, headers) => { const endpoint = new URL('/api2/json/nodes', baseUrl); const data = await requestJson(endpoint, dispatcher, headers); return Array.isArray(data) ? data.map((node) => node.node).filter(Boolean) : []; }; const fetchNodeStorageList = async (baseUrl, dispatcher, headers, nodes) => { const storageLists = await Promise.all(nodes.map(async (node) => { const endpoint = new URL(`/api2/json/nodes/${encodeURIComponent(node)}/storage`, baseUrl); const data = await requestJson(endpoint, dispatcher, headers); const list = Array.isArray(data) ? data : []; return list.map((item) => ({ ...item, node })); })); return storageLists.flat(); }; const fetchNodeStorage = async (baseUrl, dispatcher, headers, node) => { const endpoint = new URL(`/api2/json/nodes/${encodeURIComponent(node)}/storage`, baseUrl); const data = await requestJson(endpoint, dispatcher, headers); const list = Array.isArray(data) ? data : []; return list.map((item) => ({ ...item, node })); }; const fetchStorageConfig = async (baseUrl, dispatcher, headers) => { const endpoint = new URL('/api2/json/storage', baseUrl); const data = await requestJson(endpoint, dispatcher, headers); return Array.isArray(data) ? data : []; }; const isLocalStorage = (item, configMap, node) => { const key = item.storage || item.id || item.name; const config = key ? configMap.get(key) : null; const shared = config?.shared ?? item.shared; if (shared === 1 || shared === true) { return false; } if (node && typeof config?.nodes === 'string') { const allowedNodes = config.nodes.split(',').map((entry) => entry.trim()).filter(Boolean); if (allowedNodes.length > 0 && !allowedNodes.includes(node)) { return false; } } const type = config?.type ?? item.type; if (!type) { return true; } const localTypes = new Set(['dir', 'lvm', 'lvmthin', 'zfspool', 'btrfs', 'zfs']); return localTypes.has(type); }; const pickNodeForStorage = (storage, nodes) => { if (!nodes.length) { return null; } const nodeList = typeof storage.nodes === 'string' ? storage.nodes.split(',').map((node) => node.trim()).filter(Boolean) : []; const match = nodeList.find((node) => nodes.includes(node)); return match || nodes[0]; }; const fetchStorageDetails = async (baseUrl, dispatcher, headers, node, storage) => { const endpoint = new URL(`/api2/json/nodes/${encodeURIComponent(node)}/storage/${encodeURIComponent(storage)}`, baseUrl); const data = await requestJson(endpoint, dispatcher, headers); return data || null; }; const normalizeResource = (item) => { const rawTotal = item.total ?? item.maxdisk ?? item.size; const rawUsed = item.used ?? item.disk; const rawAvail = item.avail; let total = Number(rawTotal ?? 0); let used = Number(rawUsed ?? 0); const avail = rawAvail !== undefined ? Number(rawAvail ?? 0) : null; if (!Number.isFinite(total)) { total = 0; } if (!Number.isFinite(used)) { used = 0; } if (total === 0 && avail !== null) { total = used > 0 ? used + avail : avail; } if (used === 0 && avail !== null && total > 0) { used = Math.max(0, total - avail); } return { storage: item.storage || item.id || item.name, node: item.node, total, used, avail: rawAvail }; }; const normalizeHost = (rawHost) => { if (!rawHost) { return ''; } try { return new URL(rawHost).hostname; } catch { return rawHost.split('/')[0].split(':')[0]; } }; const resolveNodeForServer = (server, nodes) => { const explicit = server?.config?.node; if (explicit) { return explicit; } if (nodes.length === 1) { return nodes[0]; } const host = normalizeHost(String(server.host || server.name || '').trim()).toLowerCase(); const hostShort = host.split('.')[0]; const match = nodes.find((node) => { const nodeLower = String(node).toLowerCase(); const nodeShort = nodeLower.split('.')[0]; return nodeLower === host || nodeShort === hostShort || nodeLower.startsWith(`${hostShort}.`); }); if (match) { return match; } throw new Error('Cannot map server to Proxmox node. Set config.node.'); }; const dedupeResources = (resources) => { const map = new Map(); resources.forEach((item) => { const key = item.storage || item.id || item.name; if (!key) { return; } const total = Number(item.total || 0); const current = map.get(key); if (!current || total > Number(current.total || 0)) { map.set(key, item); } }); return Array.from(map.values()); }; export async function fetchProxmoxStorage(server) { const tokenId = server?.config?.apiTokenId; const tokenSecret = server?.config?.apiTokenSecret; if (!tokenId || !tokenSecret) { throw new Error('Proxmox credentials missing.'); } const debugEnabled = ['1', 'true', 'yes'].includes(String(process.env.PROXMOX_DEBUG || '').toLowerCase()); const debug = (...args) => { if (debugEnabled) { console.log(...args); } }; const summarizeStorage = (list) => list.map((item) => ({ storage: item.storage || item.id || item.name, node: item.node, type: item.type, total: item.total ?? item.maxdisk ?? item.size, used: item.used ?? item.disk, avail: item.avail, shared: item.shared, active: item.active, content: item.content })); const dispatcher = getDispatcher(server); const headers = { Accept: 'application/json', Authorization: `PVEAPIToken=${tokenId}=${tokenSecret}` }; const baseUrl = buildBaseUrl(server); let resources = []; let storageConfigs = []; try { storageConfigs = await fetchStorageConfig(baseUrl, dispatcher, headers); } catch { storageConfigs = []; } const storageConfigMap = new Map(storageConfigs.map((storage) => [storage.storage, storage])); const nodes = await fetchNodes(baseUrl, dispatcher, headers); const node = resolveNodeForServer(server, nodes); resources = await fetchNodeStorage(baseUrl, dispatcher, headers, node); debug('Proxmox node storage raw', { node, storages: summarizeStorage(resources) }); resources = resources.filter((item) => isLocalStorage(item, storageConfigMap, node)); debug('Proxmox node storage filtered', { node, storages: summarizeStorage(resources) }); if (resources.length === 0 && storageConfigs.length > 0) { const storages = storageConfigs.filter((storage) => { if (storage.disable || storage.disabled) { return false; } if (!isLocalStorage(storage, storageConfigMap, node)) { return false; } const allowedNode = pickNodeForStorage(storage, nodes); return !allowedNode || allowedNode === node; }); const detailsList = await Promise.all(storages.map(async (storage) => { const details = await fetchStorageDetails(baseUrl, dispatcher, headers, node, storage.storage); if (!details) { return null; } debug('Proxmox storage details fallback', { node, storage: storage.storage, total: details.total, used: details.used, avail: details.avail }); return { storage: storage.storage, total: details.total, used: details.used, avail: details.avail, node }; })); resources = detailsList.filter(Boolean); } if (resources.length === 0) { throw new Error('No Proxmox storage data returned. Check storage permissions (Datastore.Audit) and storage visibility.'); } const uniqueResources = dedupeResources(resources); let resourcesForTotals = uniqueResources; let totalBytes = 0; let usedBytes = 0; let poolName = uniqueResources[0]?.storage || 'pve-storage'; let poolTotal = 0; const pools = []; const normalizedResources = await Promise.all(uniqueResources.map(async (item) => { const normalized = normalizeResource(item); if (!item.storage || normalized.total > 0 || normalized.used > 0) { return normalized; } const details = await fetchStorageDetails(baseUrl, dispatcher, headers, node, item.storage); if (!details || (Number(details.total || 0) === 0 && Number(details.used || 0) === 0)) { return normalized; } debug('Proxmox storage details', { node, storage: item.storage, total: details.total, used: details.used, avail: details.avail }); return normalizeResource({ storage: item.storage, node, total: details.total, used: details.used, avail: details.avail }); })); normalizedResources.forEach((item) => { totalBytes += item.total; usedBytes += item.used; if (item.total > poolTotal) { poolTotal = item.total; poolName = item.storage || poolName; } }); resourcesForTotals = normalizedResources; resourcesForTotals.forEach((item) => { pools.push({ name: item.storage || 'storage', totalTb: bytesToTb(item.total), usedTb: bytesToTb(item.used), totalBytes: item.total, usedBytes: item.used }); }); const totalTb = bytesToTb(totalBytes); const usedTb = bytesToTb(usedBytes); const usedPercent = totalBytes ? Math.round((usedBytes / totalBytes) * 100) : 0; const statusMeta = getStatus(usedPercent); return { id: server.id, name: server.name || server.host, platform: 'Proxmox', cluster: server.config?.cluster || 'Vault Proxmox', location: server.config?.location || server.host, pool: poolName, totalTb, usedTb, totalBytes, usedBytes, pools, iops: 0, latencyMs: 0, status: statusMeta.status, alerts: statusMeta.alerts }; }