diff --git a/README.md b/README.md index 43e7fb9..8d5880a 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/server/src/integrations/proxmox/adapter.ts b/server/src/integrations/proxmox/adapter.ts index 9c2127d..ce6f5c0 100644 --- a/server/src/integrations/proxmox/adapter.ts +++ b/server/src/integrations/proxmox/adapter.ts @@ -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; getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise; + listNodeStats(): Promise; startGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; stopGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; restartGuest(node: string, type: ProxmoxGuestType, vmid: number): Promise; @@ -262,6 +289,58 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter { }; } + async function getNodeStats(node: string): Promise { + 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 { + 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 { 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 }; } diff --git a/server/src/routes/integrations.ts b/server/src/routes/integrations.ts index 4ac7bd8..8344cef 100644 --- a/server/src/routes/integrations.ts +++ b/server/src/routes/integrations.ts @@ -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) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 787eb85..9ba4821 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -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(`/api/integrations/${integrationId}/proxmox/guests`), + nodes: (integrationId: number) => request(`/api/integrations/${integrationId}/proxmox/nodes`), start: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => request(`/api/integrations/${integrationId}/proxmox/nodes/${node}/${type}/${vmid}/start`, { method: "POST" }), stop: (integrationId: number, node: string, type: ProxmoxGuestType, vmid: number) => diff --git a/web/src/pages/Proxmox.tsx b/web/src/pages/Proxmox.tsx index f46ee61..d4cd80d 100644 --- a/web/src/pages/Proxmox.tsx +++ b/web/src/pages/Proxmox.tsx @@ -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 ( +
+
90 ? "bg-red" : pct > 75 ? "bg-yellow" : "bg-green"}`} style={{ width: `${pct}%` }} /> +
+ ); +} + 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(null); + const [nodeStats, setNodeStats] = useState(null); + const [nodeStatsError, setNodeStatsError] = useState(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 }) {
) : ( -
+ <> + {nodeStatsError &&
{nodeStatsError}
} +
+ {nodeStats?.map((n) => ( +
+
+
+

+ {n.node} + {n.pveVersion && {n.pveVersion}} +

+
+ +
+
+
+
+
+
Uptime
+
{formatUptime(n.uptime)}
+
+
+
CPU
+
+ {n.cpuUsagePercent !== null ? `${n.cpuUsagePercent.toFixed(1)}%` : "—"} + {n.cpuCores ? ({n.cpuCores} cores) : null} +
+ {n.loadAverage && ( +
Load: {n.loadAverage.map((l) => l.toFixed(2)).join(", ")}
+ )} +
+
+
Memory
+
+ {formatBytes(n.memUsedBytes)} / {formatBytes(n.memTotalBytes)} +
+ {usageBar(n.memUsedBytes, n.memTotalBytes)} +
+ {!!n.swapTotalBytes && ( +
+
Swap
+
+ {formatBytes(n.swapUsedBytes)} / {formatBytes(n.swapTotalBytes)} +
+ {usageBar(n.swapUsedBytes, n.swapTotalBytes)} +
+ )} +
+ + {n.storages.length > 0 && ( +
+
Storage
+ + + {n.storages.map((s) => ( + + + + + + ))} + +
+ {s.id} + {!s.active && Inactive} + {s.type} + {formatBytes(s.usedBytes)} / {formatBytes(s.totalBytes)} + {usageBar(s.usedBytes, s.totalBytes)} +
+
+ )} +
+
+
+ ))} + {nodeStats?.length === 0 && ( +
+
+
No online Proxmox nodes found.
+
+
+ )} +
+ +

VMs & Containers @@ -218,7 +347,8 @@ export default function Proxmox({ user }: { user: CurrentUser }) {

-
+
+ )} )}