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:
1 parent
1ff59afb40
commit
2d062a9ac4
5 files changed
+258
-3
No files matched your search
@@ -43,8 +43,11 @@ All modules from the original plan are built:
|
||||
- **Tailscale** — device list with online/authorized status, and
|
||||
authorize/deauthorize/remove actions; a live device-count widget.
|
||||
- **Proxmox** — VM/LXC status across every node in the cluster, with
|
||||
start/restart/shutdown/stop actions; a live running/total widget.
|
||||
Supports self-signed certificates (common in homelab setups).
|
||||
start/restart/shutdown/stop actions; a live running/total widget. Each
|
||||
online node also gets its own host-stats card — uptime, CPU usage/cores/
|
||||
load average, RAM and swap usage, and per-storage usage (local, LVM-thin,
|
||||
ZFS, NFS, etc). Supports self-signed certificates (common in homelab
|
||||
setups).
|
||||
- **Synology** — volume and disk health (read-only by design).
|
||||
Supports self-signed certificates.
|
||||
- **Semaphore** — Ansible run status per template across every
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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,6 +162,96 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{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">
|
||||
@@ -219,6 +348,7 @@ export default function Proxmox({ user }: { user: CurrentUser }) {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
Reference in new issue
Block a user