Files
Homelab-manager/server/src/integrations/proxmox/adapter.ts
T
bobbanandClaude Sonnet 5 81e55fc792 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 <noreply@anthropic.com>
2026-09-18 23:32:46 +02:00

450 lines
17 KiB
TypeScript

/**
* Proxmox VE adapter — uses the Proxmox VE REST API (api2/json).
* Requires config: url, tokenId, tokenSecret; optional: insecure
*
* Auth: `Authorization: PVEAPIToken=<tokenId>=<tokenSecret>` — see
* https://pve.proxmox.com/wiki/Proxmox_VE_API#API_Tokens
*
* Endpoints verified against Proxmox's own published API tree
* (https://pve.proxmox.com/pve-docs/api-viewer/apidoc.js): GET /nodes, GET
* /nodes/{node}/qemu, GET /nodes/{node}/lxc, and POST
* /nodes/{node}/{qemu,lxc}/{vmid}/status/{start,stop,reboot} (all
* token-auth-eligible per that spec's "allowtoken" flag).
*
* Proxmox commonly runs with a self-signed certificate in homelab setups, so
* (like the cPanel DNS adapter) this uses node:https directly rather than
* fetch, to support an "insecure" opt-out of certificate verification.
*/
import * as https from "node:https";
import { withDiagLogging } from "../../services/diagLog.js";
export interface ProxmoxConfig {
url: string;
tokenId: string;
tokenSecret: string;
insecure?: boolean;
}
export type ProxmoxGuestType = "qemu" | "lxc";
export interface ProxmoxGuest {
vmid: number;
name: string;
node: string;
type: ProxmoxGuestType;
status: string; // "running" | "stopped"
cpu: number | null;
maxmem: number | null;
mem: number | null;
uptime: number | null;
}
export interface ProxmoxGuestDetail {
vmid: number;
node: string;
type: ProxmoxGuestType;
name: string;
status: string;
cpuCores: number | null;
cpuUsagePercent: number | null;
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/disk usage read directly, no agent needed). */
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;
/** 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;
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>;
/** Graceful shutdown (ACPI power event for a VM, SIGTERM-then-wait for a container) — unlike stopGuest, this asks the guest OS to shut itself down. */
shutdownGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise<void>;
}
function parseSizeToBytes(size: string): number | null {
const match = size.match(/^(\d+(?:\.\d+)?)\s*([KMGT])?$/i);
if (!match) return null;
const value = parseFloat(match[1]);
const unit = (match[2] ?? "").toUpperCase();
const multiplier = ({ "": 1, K: 1024, M: 1024 ** 2, G: 1024 ** 3, T: 1024 ** 4 } as Record<string, number>)[unit] ?? 1;
return Math.round(value * multiplier);
}
function extractSizeParam(configValue: string): number | null {
const match = configValue.match(/(?:^|,)size=([\d.]+[KMGT]?)/i);
return match ? parseSizeToBytes(match[1]) : null;
}
function extractIpFromNetConfig(configValue: string): string | null {
const match = configValue.match(/(?:^|,)ip=([^,]+)/i);
if (!match) return null;
const ip = match[1];
if (ip.toLowerCase() === "dhcp" || ip.toLowerCase() === "manual") return null;
return ip.split("/")[0];
}
interface RawResponse {
status: number;
text: () => string;
}
function request(url: string, insecure: boolean, options: { method?: string; headers?: Record<string, string> } = {}): Promise<RawResponse> {
return new Promise((resolve, reject) => {
const parsed = new URL(url);
const req = https.request(
{
hostname: parsed.hostname,
port: parsed.port || 8006,
path: parsed.pathname + parsed.search,
method: options.method || "GET",
headers: options.headers || {},
rejectUnauthorized: !insecure,
},
(res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => {
body += chunk;
});
res.on("end", () => resolve({ status: res.statusCode ?? 0, text: () => body }));
},
);
req.on("error", reject);
req.end();
});
}
export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
const insecure = config.insecure === true;
function base() {
return config.url.replace(/\/$/, "");
}
function headers() {
return {
Authorization: `PVEAPIToken=${config.tokenId}=${config.tokenSecret}`,
Accept: "application/json",
};
}
async function api(method: string, path: string): Promise<any> {
const res = await request(`${base()}/api2/json${path}`, insecure, { method, headers: headers() });
let data: any = null;
try {
data = res.text() ? JSON.parse(res.text()) : null;
} catch {
// non-JSON error page
}
if (res.status < 200 || res.status >= 300) {
const message = data?.errors ? JSON.stringify(data.errors) : data?.message;
throw new Error(message || `Proxmox API error: HTTP ${res.status}`);
}
return data?.data;
}
async function listNodes(): Promise<string[]> {
const data = await api("GET", "/nodes");
return (Array.isArray(data) ? data : [])
.filter((n: any) => n.status === "online")
.map((n: any) => n.node);
}
async function listGuestsForNode(node: string, type: ProxmoxGuestType): Promise<ProxmoxGuest[]> {
const data = await api("GET", `/nodes/${node}/${type}`);
return (Array.isArray(data) ? data : []).map((g: any) => ({
vmid: g.vmid,
name: g.name,
node,
type,
status: g.status,
cpu: g.cpu ?? null,
maxmem: g.maxmem ?? null,
mem: g.mem ?? null,
uptime: g.uptime ?? null,
}));
}
async function listGuests(): Promise<ProxmoxGuest[]> {
const nodes = await listNodes();
const perNode = await Promise.all(
nodes.map(async (node) => {
try {
const [vms, containers] = await Promise.all([
listGuestsForNode(node, "qemu"),
listGuestsForNode(node, "lxc"),
]);
return [...vms, ...containers];
} catch {
// one unreachable/offline node shouldn't take down the whole dashboard view
return [];
}
}),
);
return perNode.flat();
}
async function getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail> {
const [config, status] = await Promise.all([
api("GET", `/nodes/${node}/${type}/${vmid}/config`),
api("GET", `/nodes/${node}/${type}/${vmid}/status/current`),
]);
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") {
cpuCores = typeof config.cores === "number" ? config.cores : null;
if (typeof config.rootfs === "string") {
diskBytes = extractSizeParam(config.rootfs);
}
for (const key of Object.keys(config)) {
if (/^net\d+$/.test(key) && typeof config[key] === "string") {
const ip = extractIpFromNetConfig(config[key]);
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;
let totalDisk = 0;
let foundDisk = false;
for (const key of Object.keys(config)) {
if (/^(scsi|virtio|sata|ide)\d+$/.test(key) && typeof config[key] === "string") {
const size = extractSizeParam(config[key]);
if (size !== null) {
totalDisk += size;
foundDisk = true;
}
}
}
diskBytes = foundDisk ? totalDisk : null;
try {
const agentData = await api("GET", `/nodes/${node}/qemu/${vmid}/agent/network-get-interfaces`);
const interfaces = agentData?.result ?? [];
for (const iface of interfaces) {
for (const addr of iface["ip-addresses"] ?? []) {
if (addr["ip-address-type"] === "ipv4" && addr["ip-address"] !== "127.0.0.1") {
ipAddresses.push(addr["ip-address"]);
}
}
}
} 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;
return {
vmid,
node,
type,
name: config.name ?? `${type}/${vmid}`,
status: status.status,
cpuCores,
cpuUsagePercent: typeof status.cpu === "number" ? status.cpu * 100 : null,
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,
};
}
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> {
// 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 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])
: null;
return {
node,
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,
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();
return Promise.all(
nodes.map(async (node) => {
try {
return await getNodeStats(node);
} 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));
}
}),
);
}
async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`);
}
const startGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "start");
const stopGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "stop");
const restartGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "reboot");
const shutdownGuest = (node: string, type: ProxmoxGuestType, vmid: number) => statusAction(node, type, vmid, "shutdown");
async function ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }> {
const start = Date.now();
try {
await api("GET", "/nodes");
return { ok: true, latencyMs: Date.now() - start };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
}
}
return withDiagLogging("proxmox", {
ping,
listGuests,
getGuestDetail,
listNodeStats,
startGuest,
stopGuest,
restartGuest,
shutdownGuest,
});
}