Files
vault/backend/src/providers/proxmox.js
T
2026-01-26 19:24:43 +00:00

361 lines
11 KiB
JavaScript

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
};
}