diff --git a/server/src/integrations/proxmox/adapter.ts b/server/src/integrations/proxmox/adapter.ts index ce6f5c0..0a473db 100644 --- a/server/src/integrations/proxmox/adapter.ts +++ b/server/src/integrations/proxmox/adapter.ts @@ -67,6 +67,8 @@ export interface ProxmoxStorage { export interface ProxmoxNodeStats { node: string; + /** Set (with every other field null/empty) when this node's status/storage couldn't be fetched — e.g. the API token lacks Sys.Audit/Datastore.Audit. */ + error: string | null; uptime: number | null; cpuUsagePercent: number | null; cpuCores: number | null; @@ -289,13 +291,46 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { }; } + const emptyNodeStats = (node: string, error: string | null): ProxmoxNodeStats => ({ + node, + error, + uptime: null, + cpuUsagePercent: null, + cpuCores: null, + loadAverage: null, + memTotalBytes: null, + memUsedBytes: null, + swapTotalBytes: null, + swapUsedBytes: null, + rootfsTotalBytes: null, + rootfsUsedBytes: null, + pveVersion: null, + storages: [], + }); + + function errorMessage(reason: unknown): string { + return reason instanceof Error ? reason.message : String(reason); + } + async function getNodeStats(node: string): Promise { - const [status, storages] = await Promise.all([ + // Host status and storage usage need different ACL privileges + // (Sys.Audit vs Datastore.Audit) — a token scoped only for VM/LXC + // management (this integration's original scope) may have one but not + // the other, so fetch them independently rather than losing both to + // Promise.all's fail-fast behavior. + const [statusResult, storageResult] = await Promise.allSettled([ api("GET", `/nodes/${node}/status`), api("GET", `/nodes/${node}/storage`), ]); - const loadavgRaw = Array.isArray(status.loadavg) ? status.loadavg.map((v: string) => Number(v)) : null; + const status = statusResult.status === "fulfilled" ? statusResult.value : null; + const storages = storageResult.status === "fulfilled" ? storageResult.value : null; + + const errors: string[] = []; + if (statusResult.status === "rejected") errors.push(`host stats: ${errorMessage(statusResult.reason)}`); + if (storageResult.status === "rejected") errors.push(`storage: ${errorMessage(storageResult.reason)}`); + + const loadavgRaw = Array.isArray(status?.loadavg) ? status.loadavg.map((v: string) => Number(v)) : null; const loadAverage: [number, number, number] | null = loadavgRaw && loadavgRaw.length === 3 && loadavgRaw.every((n: number) => Number.isFinite(n)) ? (loadavgRaw as [number, number, number]) @@ -303,17 +338,18 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { return { node, - uptime: typeof status.uptime === "number" ? status.uptime : null, - cpuUsagePercent: typeof status.cpu === "number" ? status.cpu * 100 : null, - cpuCores: typeof status.cpuinfo?.cpus === "number" ? status.cpuinfo.cpus : null, + error: errors.length > 0 ? errors.join("; ") : null, + uptime: typeof status?.uptime === "number" ? status.uptime : null, + cpuUsagePercent: typeof status?.cpu === "number" ? status.cpu * 100 : null, + cpuCores: typeof status?.cpuinfo?.cpus === "number" ? status.cpuinfo.cpus : null, loadAverage, - memTotalBytes: typeof status.memory?.total === "number" ? status.memory.total : null, - memUsedBytes: typeof status.memory?.used === "number" ? status.memory.used : null, - swapTotalBytes: typeof status.swap?.total === "number" ? status.swap.total : null, - swapUsedBytes: typeof status.swap?.used === "number" ? status.swap.used : null, - rootfsTotalBytes: typeof status.rootfs?.total === "number" ? status.rootfs.total : null, - rootfsUsedBytes: typeof status.rootfs?.used === "number" ? status.rootfs.used : null, - pveVersion: typeof status.pveversion === "string" ? status.pveversion : null, + memTotalBytes: typeof status?.memory?.total === "number" ? status.memory.total : null, + memUsedBytes: typeof status?.memory?.used === "number" ? status.memory.used : null, + swapTotalBytes: typeof status?.swap?.total === "number" ? status.swap.total : null, + swapUsedBytes: typeof status?.swap?.used === "number" ? status.swap.used : null, + rootfsTotalBytes: typeof status?.rootfs?.total === "number" ? status.rootfs.total : null, + rootfsUsedBytes: typeof status?.rootfs?.used === "number" ? status.rootfs.used : null, + pveVersion: typeof status?.pveversion === "string" ? status.pveversion : null, storages: (Array.isArray(storages) ? storages : []).map((s: any) => ({ id: s.storage, type: s.type, @@ -328,17 +364,19 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { async function listNodeStats(): Promise { const nodes = await listNodes(); - const perNode = await Promise.all( + return Promise.all( nodes.map(async (node) => { try { return await getNodeStats(node); - } catch { - // one unreachable/offline node shouldn't take down the whole stats view - return null; + } catch (err) { + // Surface the failure on this node's card instead of silently + // dropping it — that previously showed a misleading "no online + // nodes" empty state even when nodes existed but stats couldn't + // be fetched (e.g. missing ACL privileges on the API token). + return emptyNodeStats(node, errorMessage(err)); } }), ); - return perNode.filter((n): n is ProxmoxNodeStats => n !== null); } async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 9ba4821..cfed53f 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -424,6 +424,7 @@ export interface ProxmoxStorage { export interface ProxmoxNodeStats { node: string; + error: string | null; uptime: number | null; cpuUsagePercent: number | null; cpuCores: number | null; diff --git a/web/src/pages/Proxmox.tsx b/web/src/pages/Proxmox.tsx index d4cd80d..072fd54 100644 --- a/web/src/pages/Proxmox.tsx +++ b/web/src/pages/Proxmox.tsx @@ -184,6 +184,7 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
+ {n.error &&
{n.error}
}
Uptime