From 81e55fc792df22b305502c1bb9ef6beca1f594b8 Mon Sep 17 00:00:00 2001 From: Bobban Rydh Date: Fri, 18 Sep 2026 23:32:46 +0200 Subject: [PATCH] Show per-disk usage for Proxmox-linked servers too, not just a total The agent path showed a per-mount usage table; a Proxmox-linked server only ever got a single "Disk (allocated)" figure, since that's all the VM/LXC config alone can tell you -- it's the attached disk's declared size, not how full it actually is inside the guest. Fix the actual gap instead of just matching the display: fetch real usage where Proxmox can see it. adapter.getGuestDetail() gained a `disks` field (same {mount, sizeBytes, usedBytes} shape the agent already reports, so the frontend renders both identically): - LXC: the host can read straight into the container's root filesystem, no agent needed -- status/current's disk/maxdisk fields are real usage, not just allocation. - QEMU: the hypervisor can't see inside a virtual disk at all without help, so this calls the QEMU guest agent's get-fsinfo command (same "gracefully degrade if the agent's missing/older" tolerance already used for its IP-address lookup, and independent of it -- one command failing doesn't take out the other). Pseudo-filesystems (tmpfs, etc.) are filtered out by checking for a non-empty backing `disk` array, the common convention for this endpoint. Extracted the disks-table JSX (previously only in the agent branch) into a shared DisksTable component and used it in both branches, and added a note explaining an empty result when a running QEMU VM's guest agent doesn't support get-fsinfo (an older agent version). Verified against a mock Proxmox API over real TLS: an LXC's root usage, a QEMU VM's real fsinfo mounts (with the disk-less tmpfs entry correctly filtered), and a QEMU VM whose get-fsinfo fails outright -- confirming that degrades to an empty disks list without throwing and without affecting the separate network-get-interfaces result. Co-Authored-By: Claude Sonnet 5 --- README.md | 7 ++- server/src/integrations/proxmox/adapter.ts | 39 ++++++++++++++++- server/src/routes/servers.ts | 1 + web/src/pages/ServerDetail.tsx | 51 ++++++++++++++-------- 4 files changed, 77 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 287c168..e2378b5 100644 --- a/README.md +++ b/README.md @@ -49,8 +49,11 @@ All modules from the original plan are built: clicking a server opens its detail page with CPU/RAM/disk status, IP addresses, matching DNS names (looked up from the DNS module's cache), and its scheduled tasks — live hardware from Proxmox for VM/LXC-backed - servers, or from the agent's own hardware report for everything else. - Proxmox-linked servers also get start/stop/restart buttons right on the + servers, or from the agent's own hardware report for everything else, + both showing the same per-disk usage breakdown (an LXC's root filesystem + read straight from the host; a QEMU VM's actual mounts via its guest + agent, alongside the allocated size Proxmox already knew about without + one). Proxmox-linked servers also get start/stop/restart buttons right on the detail page. The detail page also has an **Admin Links** section (operator/admin to add/edit/remove) for bookmarking that server's own admin UIs — Dockge, Webmin, Cockpit, Portainer, or anything else reachable diff --git a/server/src/integrations/proxmox/adapter.ts b/server/src/integrations/proxmox/adapter.ts index 975a426..5649f91 100644 --- a/server/src/integrations/proxmox/adapter.ts +++ b/server/src/integrations/proxmox/adapter.ts @@ -50,9 +50,17 @@ export interface ProxmoxGuestDetail { memoryBytes: number | null; memUsedBytes: number | null; diskBytes: number | null; + /** + * Per-mount usage where Proxmox can actually see it: the LXC root + * filesystem (host can see straight into it, no agent needed) or, for a + * QEMU VM, whatever the QEMU guest agent reports from inside the guest. + * Empty when neither is available (e.g. no guest agent) — diskBytes above + * (allocated size) is still shown in that case, just not usage. + */ + disks: { mount: string; sizeBytes: number; usedBytes: number }[]; uptime: number | null; ipAddresses: string[]; - /** QEMU only — false when the guest agent call failed (not installed/running). Always true for LXC (IPs read from config, no agent needed). */ + /** QEMU only — false when the guest agent call failed (not installed/running). Always true for LXC (IPs/disk usage read directly, no agent needed). */ guestAgentAvailable: boolean; } @@ -228,6 +236,7 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { let cpuCores: number | null = null; let diskBytes: number | null = null; const ipAddresses: string[] = []; + const disks: { mount: string; sizeBytes: number; usedBytes: number }[] = []; let guestAgentAvailable = true; if (type === "lxc") { @@ -241,6 +250,12 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { if (ip) ipAddresses.push(ip); } } + // The host can see straight into an LXC's root filesystem — no agent + // needed — but the API only exposes the root mount this way, not any + // additional mount points configured on the container. + if (typeof status.disk === "number" && typeof status.maxdisk === "number" && status.maxdisk > 0) { + disks.push({ mount: "/", sizeBytes: status.maxdisk, usedBytes: status.disk }); + } } else { const sockets = typeof config.sockets === "number" ? config.sockets : 1; cpuCores = typeof config.cores === "number" ? config.cores * sockets : null; @@ -271,6 +286,27 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { } catch { guestAgentAvailable = false; } + + // Unlike LXC, the hypervisor can't see inside a QEMU disk image at + // all — actual filesystem usage only exists if the guest agent + // reports it from inside the guest, same availability caveat as the + // network call above (a separate try/catch since one agent command + // failing, e.g. on an older guest agent version, shouldn't hide IPs + // the other command already got, or vice versa). + try { + const fsData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/get-fsinfo`); + for (const fs of fsData?.result ?? []) { + // Entries with no backing "disk" (tmpfs, proc, overlay, snap loop + // mounts, ...) aren't real storage — skip them, same convention + // widely used for this endpoint. + if (!Array.isArray(fs.disk) || fs.disk.length === 0) continue; + if (typeof fs["total-bytes"] !== "number" || typeof fs["used-bytes"] !== "number") continue; + disks.push({ mount: fs.mountpoint ?? fs.name ?? "?", sizeBytes: fs["total-bytes"], usedBytes: fs["used-bytes"] }); + } + } catch { + // Guest agent unavailable or too old to support get-fsinfo — leave + // disks empty, diskBytes (allocated size) is still shown. + } } const memoryBytes = typeof config.memory === "number" ? config.memory * 1024 * 1024 : null; @@ -286,6 +322,7 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { memoryBytes, memUsedBytes: typeof status.mem === "number" ? status.mem : null, diskBytes, + disks, uptime: typeof status.uptime === "number" ? status.uptime : null, ipAddresses, guestAgentAvailable: type === "qemu" ? guestAgentAvailable : true, diff --git a/server/src/routes/servers.ts b/server/src/routes/servers.ts index 55859e6..95be6a9 100644 --- a/server/src/routes/servers.ts +++ b/server/src/routes/servers.ts @@ -180,6 +180,7 @@ serversRouter.get("/:id/detail", asyncHandler(async (req, res) => { memTotalBytes: detail.memoryBytes, memUsedBytes: detail.memUsedBytes, diskBytes: detail.diskBytes, + disks: detail.disks, uptime: detail.uptime, guestAgentAvailable: detail.guestAgentAvailable, }; diff --git a/web/src/pages/ServerDetail.tsx b/web/src/pages/ServerDetail.tsx index 852adac..e217dc7 100644 --- a/web/src/pages/ServerDetail.tsx +++ b/web/src/pages/ServerDetail.tsx @@ -49,6 +49,28 @@ function usageBar(used: number | null | undefined, total: number | null | undefi ); } +function DisksTable({ disks }: { disks: { mount: string; sizeBytes: number; usedBytes: number }[] }) { + if (disks.length === 0) return null; + return ( +
+
Disks
+ + + {disks.map((d) => ( + + + + + ))} + +
{d.mount} + {formatBytes(d.usedBytes)} / {formatBytes(d.sizeBytes)} + {usageBar(d.usedBytes, d.sizeBytes)} +
+
+ ); +} + const emptyTaskForm = { scheduleType: "docker" as ScheduleType, name: "", @@ -570,11 +592,21 @@ export default function ServerDetail({ user }: { user: CurrentUser }) {
{formatUptime(hardware.uptime)}
+ {hardware.guestAgentAvailable === false && (
IP addresses unavailable — the QEMU guest agent isn't installed or running on this VM.
)} + {server.proxmoxGuestType === "qemu" && + hardware.guestAgentAvailable !== false && + !hardware.disks?.length && + hardware.status === "running" && ( +
+ Per-disk usage unavailable — the QEMU guest agent doesn't support reporting it (or is still + starting up). +
+ )} )} {hardware.source === "agent" && ( @@ -601,24 +633,7 @@ export default function ServerDetail({ user }: { user: CurrentUser }) { {usageBar(hardware.memUsedBytes, hardware.memTotalBytes)} - {hardware.disks && hardware.disks.length > 0 && ( -
-
Disks
- - - {hardware.disks.map((d) => ( - - - - - ))} - -
{d.mount} - {formatBytes(d.usedBytes)} / {formatBytes(d.sizeBytes)} - {usageBar(d.usedBytes, d.sizeBytes)} -
-
- )} + )}