361 lines
11 KiB
JavaScript
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
|
|
};
|
|
}
|