Add host stats to the Proxmox page

The Proxmox page only showed guest (VM/LXC) status; there was no view
of the underlying host(s) themselves -- uptime, CPU/RAM usage, or how
full each storage pool is.

New adapter.listNodeStats() calls GET /nodes/{node}/status and GET
/nodes/{node}/storage for every online node (a node going down
shouldn't blank the whole page, same tolerance as listGuests()) and
returns uptime, CPU usage % + core count + load average, RAM/swap
usage, and per-storage (local, LVM-thin, ZFS, NFS, ...) used/total.
New GET /:id/proxmox/nodes route alongside the existing /guests one.

One card per online node now sits above the VM/LXC table, showing
these stats plus a small storage table with usage bars (reusing the
same green/yellow/red thresholds as the Servers detail page and the
Synology page). Handles a multi-node cluster by rendering one card
per node, and storages with no active/total data (e.g. an offline NFS
mount) render as an "Inactive" badge with blank usage instead of
throwing.

Verified end-to-end against a local mock Proxmox API server over real
TLS (a throwaway self-signed cert, matching how the adapter's
`insecure` option is meant to be used) since this sandbox can't reach
the user's actual Proxmox cluster -- confirmed CPU fraction-to-percent
conversion, load average parsing, byte fields, and that a
null-valued/inactive storage doesn't break parsing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-17 19:26:33 +02:00
1 parent 1ff59afb40
commit 2d062a9ac4
5 files changed
+260 -5

No files matched your search

+80 -1
View File
@@ -55,10 +55,37 @@ export interface ProxmoxGuestDetail {
guestAgentAvailable: boolean;
}
export interface ProxmoxStorage {
id: string;
type: string;
active: boolean;
shared: boolean;
totalBytes: number | null;
usedBytes: number | null;
availBytes: number | null;
}
export interface ProxmoxNodeStats {
node: string;
uptime: number | null;
cpuUsagePercent: number | null;
cpuCores: number | null;
loadAverage: [number, number, number] | null;
memTotalBytes: number | null;
memUsedBytes: number | null;
swapTotalBytes: number | null;
swapUsedBytes: number | null;
rootfsTotalBytes: number | null;
rootfsUsedBytes: number | null;
pveVersion: string | null;
storages: ProxmoxStorage[];
}
export interface ProxmoxAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listGuests(): Promise<ProxmoxGuest[]>;
getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail>;
listNodeStats(): Promise<ProxmoxNodeStats[]>;
startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
@@ -262,6 +289,58 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
};
}
async function getNodeStats(node: string): Promise<ProxmoxNodeStats> {
const [status, storages] = await Promise.all([
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 loadAverage: [number, number, number] | null =
loadavgRaw && loadavgRaw.length === 3 && loadavgRaw.every((n: number) => Number.isFinite(n))
? (loadavgRaw as [number, number, number])
: null;
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,
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,
storages: (Array.isArray(storages) ? storages : []).map((s: any) => ({
id: s.storage,
type: s.type,
active: !!s.active,
shared: !!s.shared,
totalBytes: typeof s.total === "number" ? s.total : null,
usedBytes: typeof s.used === "number" ? s.used : null,
availBytes: typeof s.avail === "number" ? s.avail : null,
})),
};
}
async function listNodeStats(): Promise<ProxmoxNodeStats[]> {
const nodes = await listNodes();
const perNode = await 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;
}
}),
);
return perNode.filter((n): n is ProxmoxNodeStats => n !== null);
}
async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`);
}
@@ -281,5 +360,5 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
}
}
return { ping, listGuests, getGuestDetail, startGuest, stopGuest, restartGuest, shutdownGuest };
return { ping, listGuests, getGuestDetail, listNodeStats, startGuest, stopGuest, restartGuest, shutdownGuest };
}
+12
View File
@@ -628,6 +628,18 @@ integrationsRouter.get("/:id/proxmox/guests", asyncHandler(async (req, res) => {
}
}));
integrationsRouter.get("/:id/proxmox/nodes", asyncHandler(async (req, res) => {
const found = await requireProxmoxAdapter(req, res);
if (!found) return;
try {
const nodes = await found.adapter.listNodeStats();
res.json({ nodes });
} catch (err) {
res.status(502).json({ error: err instanceof Error ? err.message : String(err) });
}
}));
const proxmoxActions = ["start", "stop", "restart", "shutdown"] as const;
for (const action of proxmoxActions) {