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>
450 lines
17 KiB
TypeScript
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,
|
|
});
|
|
}
|