Fix "No online Proxmox nodes found" masking real host-stats errors

listNodeStats() was silently dropping any node whose /status or
/storage call failed and filtering it out of the result — with every
node dropped, the page showed the same "no nodes" empty state as a
genuinely nodeless tailnet, even though the node was online and the
existing VM/LXC table below it worked fine.

The likely real cause: Sys.Audit (host status) and Datastore.Audit
(storage) are different ACL privileges from the VM/LXC management
scope this integration originally needed, so a token created before
this feature existed may lack them.

getNodeStats() now fetches /status and /storage independently via
Promise.allSettled instead of Promise.all, so one endpoint failing
doesn't discard data the other successfully returned, and records a
per-endpoint error message. listNodeStats() no longer filters failed
nodes out at all -- it always returns one entry per online node, with
`error` set and the rest of the fields null when nothing could be
fetched. The Proxmox page now shows that error inline on the node's
card instead of it vanishing.

Verified against a mock Proxmox API returning 403 on /storage only
(partial data still shows) and on both endpoints (node stays visible
with its error surfaced, not silently dropped) — reproducing the
reported bug and confirming the fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 19:49:16 +02:00
1 parent 2d062a9ac4
commit 5919929dfb
3 files changed
+57 -17

No files matched your search

+55 -17
View File
@@ -67,6 +67,8 @@ export interface ProxmoxStorage {
export interface ProxmoxNodeStats { export interface ProxmoxNodeStats {
node: string; 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; uptime: number | null;
cpuUsagePercent: number | null; cpuUsagePercent: number | null;
cpuCores: 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<ProxmoxNodeStats> { async function getNodeStats(node: string): Promise<ProxmoxNodeStats> {
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}/status`),
api("GET", `/nodes/${node}/storage`), 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 = const loadAverage: [number, number, number] | null =
loadavgRaw && loadavgRaw.length === 3 && loadavgRaw.every((n: number) => Number.isFinite(n)) loadavgRaw && loadavgRaw.length === 3 && loadavgRaw.every((n: number) => Number.isFinite(n))
? (loadavgRaw as [number, number, number]) ? (loadavgRaw as [number, number, number])
@@ -303,17 +338,18 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
return { return {
node, node,
uptime: typeof status.uptime === "number" ? status.uptime : null, error: errors.length > 0 ? errors.join("; ") : null,
cpuUsagePercent: typeof status.cpu === "number" ? status.cpu * 100 : null, uptime: typeof status?.uptime === "number" ? status.uptime : null,
cpuCores: typeof status.cpuinfo?.cpus === "number" ? status.cpuinfo.cpus : null, cpuUsagePercent: typeof status?.cpu === "number" ? status.cpu * 100 : null,
cpuCores: typeof status?.cpuinfo?.cpus === "number" ? status.cpuinfo.cpus : null,
loadAverage, loadAverage,
memTotalBytes: typeof status.memory?.total === "number" ? status.memory.total : null, memTotalBytes: typeof status?.memory?.total === "number" ? status.memory.total : null,
memUsedBytes: typeof status.memory?.used === "number" ? status.memory.used : null, memUsedBytes: typeof status?.memory?.used === "number" ? status.memory.used : null,
swapTotalBytes: typeof status.swap?.total === "number" ? status.swap.total : null, swapTotalBytes: typeof status?.swap?.total === "number" ? status.swap.total : null,
swapUsedBytes: typeof status.swap?.used === "number" ? status.swap.used : null, swapUsedBytes: typeof status?.swap?.used === "number" ? status.swap.used : null,
rootfsTotalBytes: typeof status.rootfs?.total === "number" ? status.rootfs.total : null, rootfsTotalBytes: typeof status?.rootfs?.total === "number" ? status.rootfs.total : null,
rootfsUsedBytes: typeof status.rootfs?.used === "number" ? status.rootfs.used : null, rootfsUsedBytes: typeof status?.rootfs?.used === "number" ? status.rootfs.used : null,
pveVersion: typeof status.pveversion === "string" ? status.pveversion : null, pveVersion: typeof status?.pveversion === "string" ? status.pveversion : null,
storages: (Array.isArray(storages) ? storages : []).map((s: any) => ({ storages: (Array.isArray(storages) ? storages : []).map((s: any) => ({
id: s.storage, id: s.storage,
type: s.type, type: s.type,
@@ -328,17 +364,19 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
async function listNodeStats(): Promise<ProxmoxNodeStats[]> { async function listNodeStats(): Promise<ProxmoxNodeStats[]> {
const nodes = await listNodes(); const nodes = await listNodes();
const perNode = await Promise.all( return Promise.all(
nodes.map(async (node) => { nodes.map(async (node) => {
try { try {
return await getNodeStats(node); return await getNodeStats(node);
} catch { } catch (err) {
// one unreachable/offline node shouldn't take down the whole stats view // Surface the failure on this node's card instead of silently
return null; // 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<void> { async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
+1
View File
@@ -424,6 +424,7 @@ export interface ProxmoxStorage {
export interface ProxmoxNodeStats { export interface ProxmoxNodeStats {
node: string; node: string;
error: string | null;
uptime: number | null; uptime: number | null;
cpuUsagePercent: number | null; cpuUsagePercent: number | null;
cpuCores: number | null; cpuCores: number | null;
+1
View File
@@ -184,6 +184,7 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
</div> </div>
</div> </div>
<div className="card-body"> <div className="card-body">
{n.error && <div className="alert alert-danger">{n.error}</div>}
<div className="row g-3"> <div className="row g-3">
<div className="col-sm-6"> <div className="col-sm-6">
<div className="text-secondary small">Uptime</div> <div className="text-secondary small">Uptime</div>