Initial
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user