Initial
This commit is contained in:
@@ -0,0 +1,31 @@
|
||||
export function detectServerType({ host = '', port }) {
|
||||
const normalizedHost = String(host).toLowerCase();
|
||||
const numericPort = Number(port);
|
||||
const hasPort = Number.isFinite(numericPort);
|
||||
|
||||
if (hasPort && numericPort === 8007) {
|
||||
return 'pbs';
|
||||
}
|
||||
|
||||
if (hasPort && numericPort === 8006) {
|
||||
return 'proxmox';
|
||||
}
|
||||
|
||||
if (hasPort && (numericPort === 443 || numericPort === 444)) {
|
||||
return 'truenas';
|
||||
}
|
||||
|
||||
if (normalizedHost.includes('pbs') || normalizedHost.includes('backup')) {
|
||||
return 'pbs';
|
||||
}
|
||||
|
||||
if (normalizedHost.includes('pve') || normalizedHost.includes('proxmox')) {
|
||||
return 'proxmox';
|
||||
}
|
||||
|
||||
if (normalizedHost.includes('truenas') || normalizedHost.includes('tn')) {
|
||||
return 'truenas';
|
||||
}
|
||||
|
||||
return 'proxmox';
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
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 Backup 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(`PBS API error (${response.status}).`);
|
||||
}
|
||||
const payload = await response.json();
|
||||
return payload?.data ?? payload;
|
||||
};
|
||||
|
||||
const requestJsonWithAuth = async (url, dispatcher, headersList) => {
|
||||
let lastError = null;
|
||||
for (const headers of headersList) {
|
||||
try {
|
||||
return await requestJson(url, dispatcher, headers);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
const message = String(error?.message || '');
|
||||
if (!message.includes('(401)') && !message.includes('(403)')) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('PBS API error.');
|
||||
};
|
||||
|
||||
const normalizeDatastore = (item) => {
|
||||
const name = item.store || item.name || item.datastore || item.id;
|
||||
const rawTotal = item.total ?? item.total_bytes ?? item.size ?? item.max ?? item.max_bytes ?? item.total_size;
|
||||
const rawUsed = item.used ?? item.used_bytes ?? item.used_size ?? item.bytes_used ?? item.usage;
|
||||
const rawAvail = item.avail ?? item.available ?? item.free ?? item.avail_bytes ?? item.free_bytes ?? item.available_bytes;
|
||||
|
||||
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 {
|
||||
name,
|
||||
total,
|
||||
used,
|
||||
avail
|
||||
};
|
||||
};
|
||||
|
||||
const fetchDatastores = async (baseUrl, dispatcher, headersList) => {
|
||||
const endpoints = [
|
||||
'/api2/json/status/datastore',
|
||||
'/api2/json/admin/datastore',
|
||||
'/api2/json/datastore'
|
||||
];
|
||||
|
||||
let lastError = null;
|
||||
for (const path of endpoints) {
|
||||
try {
|
||||
const endpoint = new URL(path, baseUrl);
|
||||
const data = await requestJsonWithAuth(endpoint, dispatcher, headersList);
|
||||
if (Array.isArray(data) && data.length > 0) {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
const fetchDatastoreDetails = async (baseUrl, dispatcher, headersList, name) => {
|
||||
const endpoints = [
|
||||
`/api2/json/admin/datastore/${encodeURIComponent(name)}/status`,
|
||||
`/api2/json/status/datastore/${encodeURIComponent(name)}`,
|
||||
`/api2/json/admin/datastore/${encodeURIComponent(name)}`,
|
||||
`/api2/json/datastore/${encodeURIComponent(name)}`
|
||||
];
|
||||
|
||||
let lastError = null;
|
||||
for (const path of endpoints) {
|
||||
try {
|
||||
const endpoint = new URL(path, baseUrl);
|
||||
const data = await requestJsonWithAuth(endpoint, dispatcher, headersList);
|
||||
if (data) {
|
||||
return data;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
}
|
||||
}
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export async function fetchPbsStorage(server) {
|
||||
const tokenId = server?.config?.apiTokenId;
|
||||
const tokenSecret = server?.config?.apiTokenSecret;
|
||||
if (!tokenId || !tokenSecret) {
|
||||
throw new Error('Proxmox Backup Server credentials missing.');
|
||||
}
|
||||
|
||||
const dispatcher = getDispatcher(server);
|
||||
const headersList = [
|
||||
{
|
||||
Accept: 'application/json',
|
||||
Authorization: `PBSAPIToken=${tokenId}=${tokenSecret}`
|
||||
},
|
||||
{
|
||||
Accept: 'application/json',
|
||||
Authorization: `PBSAPIToken=${tokenId}:${tokenSecret}`
|
||||
}
|
||||
];
|
||||
const baseUrl = buildBaseUrl(server);
|
||||
|
||||
const datastores = await fetchDatastores(baseUrl, dispatcher, headersList);
|
||||
if (!Array.isArray(datastores) || datastores.length === 0) {
|
||||
throw new Error('No Proxmox Backup Server datastores returned.');
|
||||
}
|
||||
|
||||
const normalized = await Promise.all(datastores.map(async (item) => {
|
||||
const candidate = normalizeDatastore(item);
|
||||
if (!candidate.name) {
|
||||
return null;
|
||||
}
|
||||
if (candidate.total > 0 || candidate.used > 0) {
|
||||
return candidate;
|
||||
}
|
||||
const details = await fetchDatastoreDetails(baseUrl, dispatcher, headersList, candidate.name);
|
||||
if (!details) {
|
||||
return candidate;
|
||||
}
|
||||
return normalizeDatastore({ ...details, store: candidate.name });
|
||||
}));
|
||||
|
||||
const pools = normalized.filter(Boolean);
|
||||
if (pools.length === 0) {
|
||||
throw new Error('No Proxmox Backup Server datastore capacity data returned.');
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
let usedBytes = 0;
|
||||
pools.forEach((pool) => {
|
||||
totalBytes += pool.total;
|
||||
usedBytes += pool.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 Backup Server',
|
||||
cluster: server.config?.cluster || 'Vault PBS',
|
||||
location: server.config?.location || server.host,
|
||||
pool: 'Datastores',
|
||||
totalTb,
|
||||
usedTb,
|
||||
totalBytes,
|
||||
usedBytes,
|
||||
pools: pools.map((pool) => ({
|
||||
name: pool.name,
|
||||
totalTb: bytesToTb(pool.total),
|
||||
usedTb: bytesToTb(pool.used),
|
||||
totalBytes: pool.total,
|
||||
usedBytes: pool.used
|
||||
})),
|
||||
iops: 0,
|
||||
latencyMs: 0,
|
||||
status: statusMeta.status,
|
||||
alerts: statusMeta.alerts
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
import { Agent } from 'undici';
|
||||
|
||||
const bytesToTb = (bytes) => bytes / (1024 ** 4);
|
||||
|
||||
const buildBaseUrl = (server) => {
|
||||
const rawHost = String(server.host || server.name || '').trim();
|
||||
if (!rawHost) {
|
||||
throw new Error('Missing host for TrueNAS 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 getStatus = (percent) => {
|
||||
if (percent >= 85) {
|
||||
return { status: 'critical', alerts: 2 };
|
||||
}
|
||||
return { status: 'healthy', alerts: 0 };
|
||||
};
|
||||
|
||||
const buildPoolEntry = (poolName, totalBytes, usedBytes) => ({
|
||||
name: poolName,
|
||||
totalTb: bytesToTb(totalBytes),
|
||||
usedTb: bytesToTb(usedBytes),
|
||||
totalBytes,
|
||||
usedBytes
|
||||
});
|
||||
|
||||
const getDispatcher = (server) => {
|
||||
if (server?.config?.allowInsecure === false) {
|
||||
return undefined;
|
||||
}
|
||||
return new Agent({ connect: { rejectUnauthorized: false } });
|
||||
};
|
||||
|
||||
const fetchWithAuth = async (url, apiKey, scheme, dispatcher) => fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
Authorization: `${scheme} ${apiKey}`
|
||||
},
|
||||
dispatcher
|
||||
});
|
||||
|
||||
export async function fetchTrueNasStorage(server) {
|
||||
const apiKey = server?.config?.apiKey;
|
||||
if (!apiKey) {
|
||||
throw new Error('TrueNAS credentials missing.');
|
||||
}
|
||||
|
||||
const baseUrl = buildBaseUrl(server);
|
||||
const endpoint = new URL('/api/v2.0/pool', baseUrl);
|
||||
|
||||
const dispatcher = getDispatcher(server);
|
||||
let response = await fetchWithAuth(endpoint, apiKey, 'Bearer', dispatcher);
|
||||
if (response.status === 401) {
|
||||
response = await fetchWithAuth(endpoint, apiKey, 'Token', dispatcher);
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`TrueNAS API error (${response.status}).`);
|
||||
}
|
||||
|
||||
const pools = await response.json();
|
||||
if (!Array.isArray(pools) || pools.length === 0) {
|
||||
throw new Error('No TrueNAS pool data returned.');
|
||||
}
|
||||
|
||||
let totalBytes = 0;
|
||||
let usedBytes = 0;
|
||||
|
||||
const perPool = pools.map((pool) => {
|
||||
const total = Number(pool.size || 0);
|
||||
const used = Number(pool.allocated || pool.used || 0);
|
||||
totalBytes += total;
|
||||
usedBytes += used;
|
||||
return buildPoolEntry(pool.name || 'zfs-pool', total, 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: 'TrueNAS SCALE',
|
||||
cluster: server.config?.cluster || 'Vault TrueNAS',
|
||||
location: server.config?.location || server.host,
|
||||
pool: 'All pools',
|
||||
totalTb,
|
||||
usedTb,
|
||||
totalBytes,
|
||||
usedBytes,
|
||||
pools: perPool,
|
||||
iops: 0,
|
||||
latencyMs: 0,
|
||||
status: statusMeta.status,
|
||||
alerts: statusMeta.alerts
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,298 @@
|
||||
import express from 'express';
|
||||
import cors from 'cors';
|
||||
import { fetchProxmoxStorage } from './providers/proxmox.js';
|
||||
import { fetchTrueNasStorage } from './providers/truenas.js';
|
||||
import { fetchPbsStorage } from './providers/pbs.js';
|
||||
import { addServer, applyServerLayout, deleteServer, readServers, setPoolsCollapsed, updateServer } from './store/servers.js';
|
||||
import { detectServerType } from './detect.js';
|
||||
|
||||
const app = express();
|
||||
const port = process.env.PORT || 5001;
|
||||
|
||||
const STORAGE_CACHE_TTL_MS = 15000;
|
||||
let storageCache = null;
|
||||
let storageRefresh = null;
|
||||
|
||||
app.use(cors());
|
||||
app.use(express.json());
|
||||
|
||||
const normalizeType = (type) => {
|
||||
if (!type) {
|
||||
return null;
|
||||
}
|
||||
const normalized = String(type).toLowerCase();
|
||||
if (normalized === 'truenas-scale' || normalized === 'truenas_scale') {
|
||||
return 'truenas';
|
||||
}
|
||||
if (normalized === 'pbs' || normalized === 'proxmox-backup' || normalized === 'proxmox-backup-server') {
|
||||
return 'pbs';
|
||||
}
|
||||
return normalized;
|
||||
};
|
||||
|
||||
const resolveStorageForServer = async (server) => {
|
||||
if (!server?.type) {
|
||||
throw new Error('Server type missing.');
|
||||
}
|
||||
|
||||
if (server.type === 'proxmox') {
|
||||
return fetchProxmoxStorage(server);
|
||||
}
|
||||
|
||||
if (server.type === 'truenas') {
|
||||
return fetchTrueNasStorage(server);
|
||||
}
|
||||
|
||||
if (server.type === 'pbs') {
|
||||
return fetchPbsStorage(server);
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported server type: ${server.type}`);
|
||||
};
|
||||
|
||||
app.get('/api/health', (_req, res) => {
|
||||
res.json({ ok: true, service: 'vault', uptime: process.uptime(), timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
app.get('/api/servers', async (_req, res) => {
|
||||
const servers = await readServers();
|
||||
res.json({ servers });
|
||||
});
|
||||
|
||||
app.post('/api/servers', async (req, res) => {
|
||||
const { name, host, port } = req.body ?? {};
|
||||
const trimmedHost = String(host || '').trim();
|
||||
|
||||
if (!trimmedHost) {
|
||||
res.status(400).json({ error: 'host is required.' });
|
||||
return;
|
||||
}
|
||||
|
||||
let numericPort = null;
|
||||
if (port !== undefined && port !== null && port !== '') {
|
||||
numericPort = Number(port);
|
||||
if (!Number.isInteger(numericPort) || numericPort <= 0) {
|
||||
res.status(400).json({ error: 'port must be a positive number.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const detectedType = detectServerType({ host: trimmedHost, port: numericPort });
|
||||
const resolvedPort = numericPort ?? (detectedType === 'proxmox' ? 8006 : detectedType === 'pbs' ? 8007 : 443);
|
||||
const resolvedName = String(name || '').trim() || trimmedHost;
|
||||
|
||||
const server = await addServer({
|
||||
name: resolvedName,
|
||||
host: trimmedHost,
|
||||
port: resolvedPort,
|
||||
type: detectedType,
|
||||
state: 'discovered'
|
||||
});
|
||||
|
||||
res.status(201).json({ id: server.id, detectedType, server });
|
||||
});
|
||||
|
||||
app.post('/api/servers/:id/config', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { type, config } = req.body ?? {};
|
||||
const normalizedType = normalizeType(type);
|
||||
const scheme = config?.scheme ? String(config.scheme).toLowerCase() : undefined;
|
||||
const allowInsecure = config?.allowInsecure ?? true;
|
||||
|
||||
if (!normalizedType || !['proxmox', 'truenas', 'pbs'].includes(normalizedType)) {
|
||||
res.status(400).json({ error: 'type must be proxmox, truenas, or pbs.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (scheme && !['http', 'https'].includes(scheme)) {
|
||||
res.status(400).json({ error: 'scheme must be http or https.' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalizedType === 'proxmox' || normalizedType === 'pbs') {
|
||||
if (!config?.apiTokenId || !config?.apiTokenSecret) {
|
||||
res.status(400).json({ error: 'Proxmox requires apiTokenId and apiTokenSecret.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (normalizedType === 'truenas') {
|
||||
if (!config?.apiKey) {
|
||||
res.status(400).json({ error: 'TrueNAS SCALE requires apiKey.' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const updated = await updateServer(id, {
|
||||
type: normalizedType,
|
||||
config: {
|
||||
...config,
|
||||
scheme,
|
||||
allowInsecure
|
||||
},
|
||||
state: 'configured',
|
||||
configuredAt: new Date().toISOString()
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Server not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ server: updated });
|
||||
});
|
||||
|
||||
app.patch('/api/servers/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { name } = req.body ?? {};
|
||||
let nextName = undefined;
|
||||
if (name !== undefined) {
|
||||
const trimmed = String(name).trim();
|
||||
nextName = trimmed.length ? trimmed : null;
|
||||
}
|
||||
|
||||
if (name !== undefined && nextName === undefined) {
|
||||
res.status(400).json({ error: 'name must be a string.' });
|
||||
return;
|
||||
}
|
||||
|
||||
const updated = await updateServer(id, {
|
||||
...(name !== undefined ? { name: nextName } : {})
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Server not found.' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({ server: updated });
|
||||
});
|
||||
|
||||
app.delete('/api/servers/:id', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const removed = await deleteServer(id);
|
||||
if (!removed) {
|
||||
res.status(404).json({ error: 'Server not found.' });
|
||||
return;
|
||||
}
|
||||
res.json({ ok: true, id });
|
||||
});
|
||||
|
||||
app.post('/api/servers/order', async (req, res) => {
|
||||
const { order, sizes } = req.body ?? {};
|
||||
const hasOrder = Array.isArray(order) && order.length > 0;
|
||||
const hasSizes = Array.isArray(sizes) && sizes.length > 0;
|
||||
|
||||
if (!hasOrder && !hasSizes) {
|
||||
res.status(400).json({ error: 'order or sizes must be provided.' });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const updated = await applyServerLayout({ order, sizes });
|
||||
res.json({ servers: updated });
|
||||
} catch (error) {
|
||||
res.status(400).json({ error: error.message || 'Failed to update server layout.' });
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/api/servers/:id/pools-collapsed', async (req, res) => {
|
||||
const { id } = req.params;
|
||||
const { collapsed } = req.body ?? {};
|
||||
const updated = await setPoolsCollapsed(id, collapsed);
|
||||
if (!updated) {
|
||||
res.status(404).json({ error: 'Server not found.' });
|
||||
return;
|
||||
}
|
||||
res.json({ server: updated });
|
||||
});
|
||||
|
||||
const buildStoragePayload = async () => {
|
||||
const stored = await readServers();
|
||||
const configured = stored.filter((server) => server.state === 'configured');
|
||||
|
||||
if (configured.length === 0) {
|
||||
return { source: 'empty', generatedAt: new Date().toISOString(), servers: [] };
|
||||
}
|
||||
|
||||
const results = await Promise.allSettled(configured.map((server) => resolveStorageForServer(server)));
|
||||
const servers = [];
|
||||
const errors = [];
|
||||
|
||||
results.forEach((result, index) => {
|
||||
const server = configured[index];
|
||||
if (result.status === 'fulfilled' && result.value) {
|
||||
const payload = Array.isArray(result.value) ? result.value[0] : result.value;
|
||||
if (payload) {
|
||||
servers.push({
|
||||
...payload,
|
||||
id: server.id,
|
||||
position: Number.isFinite(server.position) ? server.position : index,
|
||||
span: Number.isFinite(server.span) ? server.span : 1,
|
||||
poolsCollapsed: Boolean(server.poolsCollapsed),
|
||||
name: payload.name || server.name,
|
||||
host: server.host
|
||||
});
|
||||
}
|
||||
} else {
|
||||
let message = result.status === 'rejected' ? result.reason?.message : 'Failed to load storage.';
|
||||
const cause = result.status === 'rejected' ? result.reason?.cause : null;
|
||||
if (cause?.code) {
|
||||
message = `${cause.code}: ${cause.message || message}`;
|
||||
}
|
||||
errors.push({ id: server.id, name: server.name, message });
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
source: errors.length ? 'error' : 'live',
|
||||
generatedAt: new Date().toISOString(),
|
||||
servers,
|
||||
errors
|
||||
};
|
||||
};
|
||||
|
||||
const refreshStorageCache = async () => {
|
||||
try {
|
||||
const payload = await buildStoragePayload();
|
||||
storageCache = { timestamp: Date.now(), payload };
|
||||
return payload;
|
||||
} finally {
|
||||
storageRefresh = null;
|
||||
}
|
||||
};
|
||||
|
||||
app.get('/api/storage', async (_req, res) => {
|
||||
try {
|
||||
const now = Date.now();
|
||||
if (storageCache) {
|
||||
const age = now - storageCache.timestamp;
|
||||
if (age < STORAGE_CACHE_TTL_MS) {
|
||||
res.json({ ...storageCache.payload, cached: true });
|
||||
return;
|
||||
}
|
||||
if (!storageRefresh) {
|
||||
storageRefresh = refreshStorageCache();
|
||||
}
|
||||
res.json({ ...storageCache.payload, cached: true, stale: true });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!storageRefresh) {
|
||||
storageRefresh = refreshStorageCache();
|
||||
}
|
||||
const payload = await storageRefresh;
|
||||
res.json({ ...payload, cached: false });
|
||||
} catch (error) {
|
||||
res.status(200).json({
|
||||
source: 'error',
|
||||
generatedAt: new Date().toISOString(),
|
||||
servers: [],
|
||||
errors: [{ message: 'Failed to load live storage data.' }]
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.listen(port, () => {
|
||||
console.log(`Vault backend listening on port ${port}`);
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
import { promises as fs } from 'fs';
|
||||
import path from 'path';
|
||||
import { randomUUID } from 'crypto';
|
||||
|
||||
const dataDir = path.resolve(process.cwd(), 'data');
|
||||
const dataFile = path.join(dataDir, 'servers.json');
|
||||
|
||||
async function ensureStore() {
|
||||
await fs.mkdir(dataDir, { recursive: true });
|
||||
try {
|
||||
await fs.access(dataFile);
|
||||
} catch {
|
||||
await fs.writeFile(dataFile, '[]', 'utf-8');
|
||||
}
|
||||
}
|
||||
|
||||
export async function readServers() {
|
||||
await ensureStore();
|
||||
const raw = await fs.readFile(dataFile, 'utf-8');
|
||||
try {
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function writeServers(servers) {
|
||||
await ensureStore();
|
||||
await fs.writeFile(dataFile, JSON.stringify(servers, null, 2), 'utf-8');
|
||||
}
|
||||
|
||||
export async function addServer(payload) {
|
||||
const servers = await readServers();
|
||||
const timestamp = new Date().toISOString();
|
||||
const server = {
|
||||
id: randomUUID(),
|
||||
createdAt: timestamp,
|
||||
updatedAt: timestamp,
|
||||
position: servers.length,
|
||||
span: 1,
|
||||
poolsCollapsed: false,
|
||||
...payload
|
||||
};
|
||||
servers.push(server);
|
||||
await writeServers(servers);
|
||||
return server;
|
||||
}
|
||||
|
||||
export async function updateServer(id, patch) {
|
||||
const servers = await readServers();
|
||||
const index = servers.findIndex((server) => server.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
const updated = {
|
||||
...servers[index],
|
||||
...patch,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
servers[index] = updated;
|
||||
await writeServers(servers);
|
||||
return updated;
|
||||
}
|
||||
|
||||
export async function getServer(id) {
|
||||
const servers = await readServers();
|
||||
return servers.find((server) => server.id === id) || null;
|
||||
}
|
||||
|
||||
export async function reorderServers(order) {
|
||||
const servers = await readServers();
|
||||
const normalizedOrder = Array.isArray(order)
|
||||
? order.map((id) => String(id))
|
||||
: [];
|
||||
const seen = new Set();
|
||||
const duplicates = normalizedOrder.filter((id) => {
|
||||
if (seen.has(id)) {
|
||||
return true;
|
||||
}
|
||||
seen.add(id);
|
||||
return false;
|
||||
});
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error('Duplicate server ids in order payload.');
|
||||
}
|
||||
|
||||
const serverMap = new Map(servers.map((server) => [server.id, server]));
|
||||
const unknown = normalizedOrder.filter((id) => !serverMap.has(id));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown server id(s): ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
const ordered = normalizedOrder.map((id) => serverMap.get(id));
|
||||
const orderSet = new Set(normalizedOrder);
|
||||
const remaining = servers.filter((server) => !orderSet.has(server.id));
|
||||
const next = [...ordered, ...remaining];
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
const updated = next.map((server, index) => ({
|
||||
...server,
|
||||
position: index,
|
||||
updatedAt: server.position === index ? server.updatedAt : timestamp
|
||||
}));
|
||||
|
||||
await writeServers(updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
const clampSpan = (value) => {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return null;
|
||||
}
|
||||
const rounded = Math.round(parsed);
|
||||
return Math.min(4, Math.max(1, rounded));
|
||||
};
|
||||
|
||||
export async function applyServerLayout({ order, sizes }) {
|
||||
const servers = await readServers();
|
||||
let next = servers;
|
||||
let timestamp = new Date().toISOString();
|
||||
|
||||
if (Array.isArray(order) && order.length > 0) {
|
||||
const normalizedOrder = order.map((id) => String(id));
|
||||
const seen = new Set();
|
||||
const duplicates = normalizedOrder.filter((id) => {
|
||||
if (seen.has(id)) {
|
||||
return true;
|
||||
}
|
||||
seen.add(id);
|
||||
return false;
|
||||
});
|
||||
if (duplicates.length > 0) {
|
||||
throw new Error('Duplicate server ids in order payload.');
|
||||
}
|
||||
|
||||
const serverMap = new Map(next.map((server) => [server.id, server]));
|
||||
const unknown = normalizedOrder.filter((id) => !serverMap.has(id));
|
||||
if (unknown.length > 0) {
|
||||
throw new Error(`Unknown server id(s): ${unknown.join(', ')}`);
|
||||
}
|
||||
|
||||
const ordered = normalizedOrder.map((id) => serverMap.get(id));
|
||||
const orderSet = new Set(normalizedOrder);
|
||||
const remaining = next.filter((server) => !orderSet.has(server.id));
|
||||
next = [...ordered, ...remaining].map((server, index) => ({
|
||||
...server,
|
||||
position: index,
|
||||
updatedAt: server.position === index ? server.updatedAt : timestamp
|
||||
}));
|
||||
}
|
||||
|
||||
if (Array.isArray(sizes) && sizes.length > 0) {
|
||||
const sizeMap = new Map();
|
||||
sizes.forEach((entry) => {
|
||||
if (!entry?.id) {
|
||||
return;
|
||||
}
|
||||
const span = clampSpan(entry.span);
|
||||
if (span !== null) {
|
||||
sizeMap.set(String(entry.id), span);
|
||||
}
|
||||
});
|
||||
|
||||
if (sizeMap.size > 0) {
|
||||
next = next.map((server) => {
|
||||
if (!sizeMap.has(server.id)) {
|
||||
return server;
|
||||
}
|
||||
const span = sizeMap.get(server.id);
|
||||
if (server.span === span) {
|
||||
return server;
|
||||
}
|
||||
return {
|
||||
...server,
|
||||
span,
|
||||
updatedAt: timestamp
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
await writeServers(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
export async function deleteServer(id) {
|
||||
const servers = await readServers();
|
||||
const remaining = servers.filter((server) => server.id !== id);
|
||||
if (remaining.length === servers.length) {
|
||||
return null;
|
||||
}
|
||||
const timestamp = new Date().toISOString();
|
||||
const updated = remaining.map((server, index) => ({
|
||||
...server,
|
||||
position: index,
|
||||
updatedAt: server.position === index ? server.updatedAt : timestamp
|
||||
}));
|
||||
await writeServers(updated);
|
||||
return { id };
|
||||
}
|
||||
|
||||
export async function setPoolsCollapsed(id, collapsed) {
|
||||
const servers = await readServers();
|
||||
const index = servers.findIndex((server) => server.id === id);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
const updated = {
|
||||
...servers[index],
|
||||
poolsCollapsed: Boolean(collapsed),
|
||||
updatedAt: new Date().toISOString()
|
||||
};
|
||||
servers[index] = updated;
|
||||
await writeServers(servers);
|
||||
return updated;
|
||||
}
|
||||
Reference in New Issue
Block a user