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

+31
View File
@@ -412,6 +412,36 @@ export interface ProxmoxGuestsResponse {
summary: { total: number; running: number };
}
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 ProxmoxNodesResponse {
nodes: ProxmoxNodeStats[];
}
export interface SynologyVolume {
id: string;
status: string;
@@ -672,6 +702,7 @@ export const api = {
},
proxmox: {
guests: (integrationId: number) => request<ProxmoxGuestsResponse>(`/api/integrations/${integrationId}/proxmox/guests`),
nodes: (integrationId: number) => request<ProxmoxNodesResponse>(`/api/integrations/${integrationId}/proxmox/nodes`),
start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
request<void>(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }),
stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) =>
+132 -2
View File
@@ -6,6 +6,7 @@ import {
type IntegrationSummary,
type ProxmoxGuest,
type ProxmoxGuestsResponse,
type ProxmoxNodeStats,
} from "../api/client";
function proxmoxStatusBadge(status: string) {
@@ -25,6 +26,28 @@ function formatUptime(seconds: number | null): string {
return `${hours}h ${minutes}m`;
}
function formatBytes(bytes: number | null): string {
if (bytes === null) return "—";
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
let value = bytes;
let unit = 0;
while (value >= 1024 && unit < units.length - 1) {
value /= 1024;
unit++;
}
return `${value.toFixed(1)} ${units[unit]}`;
}
function usageBar(used: number | null, total: number | null) {
if (!total) return null;
const pct = Math.min(100, Math.round(((used ?? 0) / total) * 100));
return (
<div className="progress progress-sm mt-1">
<div className={`progress-bar ${pct > 90 ? "bg-red" : pct > 75 ? "bg-yellow" : "bg-green"}`} style={{ width: `${pct}%` }} />
</div>
);
}
export default function Proxmox({ user }: { user: CurrentUser }) {
const canEdit = user.role === "admin" || user.role === "operator";
@@ -36,6 +59,10 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
const [loading, setLoading] = useState(false);
const [actingOnGuest, setActingOnGuest] = useState<number | null>(null);
const [nodeStats, setNodeStats] = useState<ProxmoxNodeStats[] | null>(null);
const [nodeStatsError, setNodeStatsError] = useState<string | null>(null);
const [loadingNodeStats, setLoadingNodeStats] = useState(false);
useEffect(() => {
api.integrations
.list()
@@ -60,11 +87,23 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
.finally(() => setLoading(false));
}
function loadNodeStats(id: number) {
setLoadingNodeStats(true);
setNodeStatsError(null);
api.integrations.proxmox
.nodes(id)
.then((res) => setNodeStats(res.nodes))
.catch((err) => setNodeStatsError(err instanceof Error ? err.message : String(err)))
.finally(() => setLoadingNodeStats(false));
}
useEffect(() => {
if (selected?.enabled) {
loadGuests(selected.id);
loadNodeStats(selected.id);
} else {
setData(null);
setNodeStats(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedId]);
@@ -123,7 +162,97 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
</div>
</div>
) : (
<div className="card">
<>
{nodeStatsError && <div className="alert alert-danger">{nodeStatsError}</div>}
<div className="row row-cards mb-3">
{nodeStats?.map((n) => (
<div className="col-lg-6" key={n.node}>
<div className="card h-100">
<div className="card-header">
<h3 className="card-title">
{n.node}
{n.pveVersion && <span className="text-secondary fw-normal ms-2">{n.pveVersion}</span>}
</h3>
<div className="card-actions">
<button
className="btn btn-sm btn-outline-secondary"
onClick={() => selected && loadNodeStats(selected.id)}
disabled={loadingNodeStats}
>
{loadingNodeStats ? "Refreshing…" : "Refresh"}
</button>
</div>
</div>
<div className="card-body">
<div className="row g-3">
<div className="col-sm-6">
<div className="text-secondary small">Uptime</div>
<div>{formatUptime(n.uptime)}</div>
</div>
<div className="col-sm-6">
<div className="text-secondary small">CPU</div>
<div>
{n.cpuUsagePercent !== null ? `${n.cpuUsagePercent.toFixed(1)}%` : "—"}
{n.cpuCores ? <span className="text-secondary"> ({n.cpuCores} cores)</span> : null}
</div>
{n.loadAverage && (
<div className="text-secondary small">Load: {n.loadAverage.map((l) => l.toFixed(2)).join(", ")}</div>
)}
</div>
<div className="col-sm-6">
<div className="text-secondary small">Memory</div>
<div>
{formatBytes(n.memUsedBytes)} / {formatBytes(n.memTotalBytes)}
</div>
{usageBar(n.memUsedBytes, n.memTotalBytes)}
</div>
{!!n.swapTotalBytes && (
<div className="col-sm-6">
<div className="text-secondary small">Swap</div>
<div>
{formatBytes(n.swapUsedBytes)} / {formatBytes(n.swapTotalBytes)}
</div>
{usageBar(n.swapUsedBytes, n.swapTotalBytes)}
</div>
)}
</div>
{n.storages.length > 0 && (
<div className="mt-3">
<div className="text-secondary small mb-1">Storage</div>
<table className="table table-sm table-vcenter mb-0">
<tbody>
{n.storages.map((s) => (
<tr key={s.id}>
<td>
{s.id}
{!s.active && <span className="badge bg-secondary-lt text-secondary ms-1">Inactive</span>}
</td>
<td className="text-secondary">{s.type}</td>
<td>
{formatBytes(s.usedBytes)} / {formatBytes(s.totalBytes)}
{usageBar(s.usedBytes, s.totalBytes)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
))}
{nodeStats?.length === 0 && (
<div className="col-12">
<div className="card">
<div className="card-body text-secondary">No online Proxmox nodes found.</div>
</div>
</div>
)}
</div>
<div className="card">
<div className="card-header">
<h3 className="card-title">
VMs &amp; Containers
@@ -218,7 +347,8 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
</tbody>
</table>
</div>
</div>
</div>
</>
)}
</>
)}