initial commit

This commit is contained in:
2026-05-07 06:41:36 +01:00
commit 783400884a
23 changed files with 2904 additions and 0 deletions

21
src/app/api/lxc/route.js Normal file
View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { getNodes, getLXC } from '@/lib/proxmox';
/** @param {import('next').Request} _req */
export async function GET(_req) {
try {
const nodes = await getNodes();
const allLXC = [];
for (const node of nodes) {
try {
const lxcs = await getLXC(node.name);
allLXC.push(...lxcs.map((lxc) => ({ ...lxc, node: node.name })));
} catch { /* skip node */ }
}
return NextResponse.json({ data: allLXC, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getNodes } from '@/lib/proxmox';
/** @param {import('next').Request} _req */
export async function GET(_req) {
try {
const data = await getNodes();
return NextResponse.json({ data, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

View File

@@ -0,0 +1,12 @@
import { NextResponse } from 'next/server';
import { getSummary } from '@/lib/proxmox';
/** @param {import('next').Request} _req */
export async function GET(_req) {
try {
const data = await getSummary();
return NextResponse.json({ data, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

21
src/app/api/vms/route.js Normal file
View File

@@ -0,0 +1,21 @@
import { NextResponse } from 'next/server';
import { getNodes, getVMs } from '@/lib/proxmox';
/** @param {import('next').Request} _req */
export async function GET(_req) {
try {
const nodes = await getNodes();
const allVMs = [];
for (const node of nodes) {
try {
const vms = await getVMs(node.name);
allVMs.push(...vms.map((vm) => ({ ...vm, node: node.name })));
} catch { /* skip node */ }
}
return NextResponse.json({ data: allVMs, timestamp: Date.now() });
} catch (error) {
return NextResponse.json({ error: error.message, data: null }, { status: 502 });
}
}

22
src/app/globals.css Normal file
View File

@@ -0,0 +1,22 @@
@import "tailwindcss";
@theme {
--color-bg-primary: #0b0f19;
--color-bg-card: #111827;
--color-bg-card-hover: #1a2332;
--color-border-card: #1e293b;
--color-text-primary: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-accent: #3b82f6;
--color-status-green: #22c55e;
--color-status-red: #ef4444;
--color-status-yellow: #eab308;
--color-status-gray: #64748b;
}
@layer base {
html {
background-color: var(--color-bg-primary);
color: var(--color-text-primary);
}
}

19
src/app/layout.js Normal file
View File

@@ -0,0 +1,19 @@
import './globals.css';
import { Inter } from 'next/font/google';
const inter = Inter({ subsets: ['latin'] });
/** @type {import('next').Metadata} */
export const metadata = {
title: 'Proxmox Monitor',
description: 'Real-time dashboard for Proxmox VE',
};
/** @param {{ children: import('react').ReactNode }} props */
export default function RootLayout({ children }) {
return (
<html lang="en">
<body className={inter.className}>{children}</body>
</html>
);
}

6
src/app/page.js Normal file
View File

@@ -0,0 +1,6 @@
import Dashboard from '@/components/Dashboard';
/** @returns {import('react').ReactNode} */
export default function HomePage() {
return <Dashboard />;
}

132
src/components/Dashboard.js Normal file
View File

@@ -0,0 +1,132 @@
'use client';
import { useState } from 'react';
import useSWR from 'swr';
import NodeList from './NodeList';
import VMList from './VMList';
import LXCList from './LXCList';
import axios from 'axios';
const POLL_INTERVAL = Number(process.env.NEXT_PUBLIC_API_POLL_INTERVAL || 30000);
/**
* Fetch helper for SWR.
* @param {string} url
* @returns {Promise<{ data: *, error: string | null }>}
*/
async function fetchData(url) {
try {
const res = await axios.get(url);
return res.data;
} catch (err) {
const status = err.response?.status || 'Unknown';
const message = err.response?.data?.message || err.response?.data?.errors || err.message || String(err);
throw new Error(`API error ${status}: ${message}`);
}
}
/** @returns {import('react').ReactNode} */
export default function Dashboard() {
const [activeTab, setActiveTab] = useState('nodes');
const { data: nodesData, error: nodesError } = useSWR('/api/nodes', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const { data: vmsData, error: vmsError } = useSWR('/api/vms', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const { data: lxcData, error: lxcError } = useSWR('/api/lxc', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const { data: summaryData, error: summaryError } = useSWR('/api/status', fetchData, {
refreshInterval: POLL_INTERVAL,
});
const nodes = nodesData?.data ?? [];
const vms = vmsData?.data ?? [];
const lxc = lxcData?.data ?? [];
const summary = summaryData?.data ?? null;
const tabs = [
{ id: 'nodes', label: 'Nodes' },
{ id: 'vms', label: 'VMs' },
{ id: 'lxc', label: 'LXCs' },
];
return (
<div className="min-h-screen p-4 sm:p-6 lg:p-8">
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-text-primary">Proxmox Monitor</h1>
<p className="mt-1 text-sm text-text-secondary">
{nodes.length} node{nodes.length !== 1 ? 's' : ''} {summary?.runningVMs ?? 0} running Updated{' '}
{new Date().toLocaleTimeString()}
</p>
</div>
</div>
{/* Summary Cards */}
{summary && !summaryError && (
<div className="mb-6 grid gap-4 sm:grid-cols-4">
<div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Nodes</div>
<div className="mt-1 text-2xl font-bold text-text-primary">{summary.totalNodes}</div>
</div>
<div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Running VMs</div>
<div className="mt-1 text-2xl font-bold text-status-green">{summary.runningVMs}</div>
</div>
<div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Stopped VMs</div>
<div className="mt-1 text-2xl font-bold text-status-red">{summary.stoppedVMs}</div>
</div>
<div className="rounded-xl border border-border-card bg-bg-card p-4">
<div className="text-xs uppercase tracking-wider text-text-secondary">Total Memory</div>
<div className="mt-1 text-xl font-bold text-text-primary">
{summary.totalMemory?.total
? `${(summary.totalMemory.used / (1024 ** 3)).toFixed(1)} / ${(summary.totalMemory.total / (1024 ** 3)).toFixed(1)} GB`
: 'N/A'}
</div>
</div>
</div>
)}
{(summaryError || nodesError) && (
<div className="mb-6 rounded-xl border border-status-red/30 bg-status-red/10 p-4 text-sm text-status-red">
{summaryError || nodesError}
</div>
)}
{/* Tab navigation */}
<div className="mb-4 flex gap-2 border-b border-border-card">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={`px-4 py-2 text-sm font-medium transition-colors ${activeTab === tab.id
? 'border-b-2 border-accent text-text-primary'
: 'text-text-secondary hover:text-text-primary'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Tab content */}
<div>
{activeTab === 'nodes' && <NodeList nodes={nodes} />}
{activeTab === 'vms' && <VMList vms={vms} />}
{activeTab === 'lxc' && <LXCList lxc={lxc} />}
</div>
{/* Footer */}
<div className="mt-8 text-center text-xs text-text-secondary">
Polling every {POLL_INTERVAL / 1000}s Proxmox VE Monitor
</div>
</div>
);
}

90
src/components/LXCList.js Normal file
View File

@@ -0,0 +1,90 @@
import StatusBadge from './StatusBadge';
/**
* @param {{ lxc: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }> }} props
* @returns {import('react').ReactNode}
*/
export default function LXCList({ lxc }) {
if (!lxc?.length) {
return <p className="text-text-secondary text-sm">No LXC containers found.</p>;
}
const running = lxc.filter((item) => item.status === 'running');
const stopped = lxc.filter((item) => item.status !== 'running');
return (
<div className="space-y-6">
{running.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{running.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
<td className="px-4 py-2 text-text-secondary">{(item.cpu * 100).toFixed(1)}%</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.memory?.used || 0)} / {formatBytes(item.memory?.total || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{stopped.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">ID</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{stopped.map((item) => (
<tr key={item.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{item.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{item.name}</td>
<td className="px-4 py-2 text-text-secondary">{item.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(item.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={item.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
/** @param {number} bytes @returns {string} */
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(1)} ${units[i]}`;
}

View File

@@ -0,0 +1,81 @@
import StatusBadge from './StatusBadge';
/**
* Formats bytes to human-readable string.
* @param {number} bytes
* @returns {string}
*/
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) {
v /= 1024;
i++;
}
return `${v.toFixed(1)} ${units[i]}`;
}
/**
* Formats seconds to human-readable duration.
* @param {number} seconds
* @returns {string}
*/
function formatUptime(seconds) {
if (!seconds) return 'N/A';
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
return `${days}d ${hours}h`;
}
/**
* @param {{ node: { name: string; type: string; cpu: number; memory: { total: number; used: number }; status: string; uptime: number; load: number } }} props
* @returns {import('react').ReactNode}
*/
export default function NodeCard({ node }) {
const cpuPercent = Math.round((node.cpu || 0) * 100);
const memPercent = node.memory?.total ? Math.round((node.memory.used / node.memory.total) * 100) : 0;
return (
<div className="rounded-xl border border-border-card bg-bg-card p-5 transition-colors hover:bg-bg-card-hover">
<div className="mb-3 flex items-center justify-between">
<h3 className="text-lg font-semibold text-text-primary">{node.name}</h3>
<StatusBadge status={node.status} />
</div>
<div className="space-y-3 text-sm text-text-secondary">
<div className="flex justify-between">
<span>CPU</span>
<span className="text-text-primary">{cpuPercent}%</span>
</div>
<div className="h-2 w-full rounded-full bg-bg-primary">
<div
className="h-full rounded-full bg-accent transition-all"
style={{ width: `${Math.min(cpuPercent, 100)}%` }}
/>
</div>
<div className="flex justify-between">
<span>Memory</span>
<span className="text-text-primary">{formatBytes(node.memory?.used || 0)} / {formatBytes(node.memory?.total || 0)}</span>
</div>
<div className="h-2 w-full rounded-full bg-bg-primary">
<div
className={`h-full rounded-full transition-all ${
memPercent > 90 ? 'bg-status-red' :
memPercent > 70 ? 'bg-status-yellow' :
'bg-accent'
}`}
style={{ width: `${Math.min(memPercent, 100)}%` }}
/>
</div>
<div className="flex justify-between text-xs">
<span>Load: {node.load?.toFixed(2) || 'N/A'}</span>
<span>Uptime: {formatUptime(node.uptime)}</span>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,19 @@
import NodeCard from './NodeCard';
/**
* @param {{ nodes: Array<{ name: string; type: string; cpu: number; memory: { total: number; used: number }; status: string; uptime: number; load: number }> }} props
* @returns {import('react').ReactNode}
*/
export default function NodeList({ nodes }) {
if (!nodes?.length) {
return <p className="text-text-secondary text-sm">No nodes found.</p>;
}
return (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{nodes.map((node) => (
<NodeCard key={node.name} node={node} />
))}
</div>
);
}

View File

@@ -0,0 +1,25 @@
/**
* Renders a colored status badge.
* @param {{ status: string }} props
* @returns {import('react').ReactNode}
*/
export default function StatusBadge({ status }) {
const colors = {
running: 'bg-status-green/20 text-status-green border-status-green/30',
stopped: 'bg-status-red/20 text-status-red border-status-red/30',
paused: 'bg-status-yellow/20 text-status-yellow border-status-yellow/30',
};
const colorClass = colors[status] || 'bg-status-gray/20 text-status-gray border-status-gray/30';
return (
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-medium capitalize ${colorClass}`}>
<span className={`h-1.5 w-1.5 rounded-full ${
status === 'running' ? 'bg-status-green' :
status === 'stopped' ? 'bg-status-red' :
status === 'paused' ? 'bg-status-yellow' :
'bg-status-gray'
}`} />
{status}
</span>
);
}

90
src/components/VMList.js Normal file
View File

@@ -0,0 +1,90 @@
import StatusBadge from './StatusBadge';
/**
* @param {{ vms: Array<{ vmid: number; name: string; type: string; status: string; cpu: number; memory: { total: number; used: number }; disk: { total: number; used: number }; node: string }> }} props
* @returns {import('react').ReactNode}
*/
export default function VMList({ vms }) {
if (!vms?.length) {
return <p className="text-text-secondary text-sm">No VMs found.</p>;
}
const running = vms.filter((vm) => vm.status === 'running');
const stopped = vms.filter((vm) => vm.status !== 'running');
return (
<div className="space-y-6">
{running.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-green">Running ({running.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">CPU</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Memory</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{running.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
<td className="px-4 py-2 text-text-secondary">{(vm.cpu * 100).toFixed(1)}%</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.memory?.used || 0)} / {formatBytes(vm.memory?.total || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{stopped.length > 0 && (
<div>
<h4 className="mb-2 text-xs font-medium uppercase tracking-wider text-status-red">Stopped ({stopped.length})</h4>
<div className="overflow-x-auto rounded-xl border border-border-card bg-bg-card">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border-card">
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">VMID</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Name</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Node</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Disk</th>
<th className="px-4 py-2.5 text-left text-xs font-medium uppercase tracking-wider text-text-secondary">Status</th>
</tr>
</thead>
<tbody>
{stopped.map((vm) => (
<tr key={vm.vmid} className="border-b border-border-card last:border-0">
<td className="px-4 py-2 text-text-secondary">{vm.vmid}</td>
<td className="px-4 py-2 font-medium text-text-primary">{vm.name}</td>
<td className="px-4 py-2 text-text-secondary">{vm.node}</td>
<td className="px-4 py-2 text-text-secondary">{formatBytes(vm.disk?.used || 0)}</td>
<td className="px-4 py-2"><StatusBadge status={vm.status} /></td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
);
}
/** @param {number} bytes @returns {string} */
function formatBytes(bytes) {
if (!bytes) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let i = 0;
let v = bytes;
while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
return `${v.toFixed(1)} ${units[i]}`;
}

195
src/lib/proxmox.js Normal file
View File

@@ -0,0 +1,195 @@
import axios from 'axios';
function getEnv(key) {
const val = process.env[key];
if (!val) {
throw new Error(`Missing ${key} in environment variables.`);
}
return val;
}
/**
* Generates the Proxmox API2 Authorization header.
* Format: PVEAPIToken=<user>@<realm>!<token-id>=<secret>
* @param {string} _method
* @param {string} _path
* @param {string} _body
* @returns {{ Authorization: string }}
*/
function createAuthHeaders(_method, _path, _body) {
const TOKEN_ID = getEnv('PROXMOX_TOKEN_ID'); // e.g. "root@pam!monitorapp"
const TOKEN_SECRET = getEnv('PROXMOX_TOKEN_SECRET');
return { Authorization: `PVEAPIToken=${TOKEN_ID}=${TOKEN_SECRET}` };
}
/**
* Fetches the Proxmox API with authentication.
* @param {string} path
* @param {'GET'|'POST'|'PUT'|'DELETE'} [method='GET']
* @param {string} [body]
* @returns {Promise<*>}
*/
async function proxmoxFetch(path, method = 'GET', body) {
const headers = {
...createAuthHeaders(method, path, body),
'Content-Type': 'application/json',
};
const baseUrl = getEnv('PROXMOX_API_URL');
const base = baseUrl.replace(/\/+$/, '');
const cleanPath = path.replace(/^\/+/, '');
const fullUrl = `${base}/${cleanPath}`;
process.env.NODE_TLS_REJECT_UNAUTHORIZED = '0';
try {
const res = await axios({
url: fullUrl,
method,
headers,
data: body,
});
return res.data;
} catch (err) {
const status = err.response?.status || 'Unknown Status';
const message = err.response?.data?.message || err.response?.data || err.message;
throw new Error(`Proxmox API error ${status}: ${message}`);
}
}
/**
* @typedef {Object} ProxmoxNode
* @property {string} name
* @property {'node'} type
* @property {number} cpu
* @property {{ total: number; used: number }} memory
* @property {string} status
* @property {number} uptime
* @property {number} load
*/
/**
* Fetches all Proxmox nodes and returns mapped stats.
* @returns {Promise<ProxmoxNode[]>}
*/
export async function getNodes() {
const data = await proxmoxFetch('/nodes');
return (data.data || []).map((node) => ({
name: node.node,
type: 'node',
cpu: node.cpu ?? 0,
memory: {
total: node.maxmem ?? 0,
used: node.mem ?? 0,
},
status: node.status,
uptime: node.uptime ?? 0,
load: node.load ?? 0,
}));
}
/**
* @typedef {Object} ProxmoxVM
* @property {number} vmid
* @property {string} name
* @property {string} type
* @property {string} status
* @property {number} cpu
* @property {{ total: number; used: number }} memory
* @property {{ total: number; used: number }} disk
*/
/**
* Fetches QEMU VMs for a given node.
* @param {string} nodeName
* @returns {Promise<ProxmoxVM[]>}
*/
export async function getVMs(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/qemu`);
return (data.data || []).map((vm) => ({
vmid: vm.vmid,
name: vm.name || `VM ${vm.vmid}`,
type: 'qemu',
status: vm.status,
cpu: vm.cpu ?? 0,
memory: {
total: vm.maxmem ?? vm.maxmem ?? 0,
used: vm.mem ?? 0,
},
disk: {
total: 0,
used: vm.disk ?? 0,
},
}));
}
/**
* Fetches LXC containers for a given node.
* @param {string} nodeName
* @returns {Promise<ProxmoxVM[]>}
*/
export async function getLXC(nodeName) {
const data = await proxmoxFetch(`/nodes/${nodeName}/lxc`);
return (data.data || []).map((vm) => ({
vmid: vm.vmid,
name: vm.name || `LXC ${vm.vmid}`,
type: 'lxc',
status: vm.status,
cpu: vm.cpu ?? 0,
memory: {
total: vm.maxmem ?? vm.maxmem ?? 0,
used: vm.mem ?? 0,
},
disk: {
total: 0,
used: vm.disk ?? 0,
},
}));
}
/**
* @typedef {Object} SummaryStat
* @property {number} totalNodes
* @property {number} runningVMs
* @property {number} stoppedVMs
* @property {number} totalCPUs
* @property {{ total: number; used: number }} totalMemory
*/
/**
* Aggregated summary across all nodes.
* @returns {Promise<SummaryStat>}
*/
export async function getSummary() {
const nodes = await getNodes();
let totalVMs = [];
let totalLXC = [];
for (const node of nodes) {
try {
const vms = await getVMs(node.name);
totalVMs = totalVMs.concat(vms);
} catch { /* skip */ }
try {
const lxcs = await getLXC(node.name);
totalLXC = totalLXC.concat(lxcs);
} catch { /* skip */ }
}
const allVMs = [...totalVMs, ...totalLXC];
const runningVMs = allVMs.filter((vm) => vm.status === 'running').length;
const stoppedVMs = allVMs.filter((vm) => vm.status !== 'running').length;
const totalMemTotal = nodes.reduce((s, n) => s + (n.memory?.total || 0), 0);
const totalMemUsed = nodes.reduce((s, n) => s + (n.memory?.used || 0), 0);
return {
totalNodes: nodes.length,
runningVMs,
stoppedVMs,
totalCPUs: nodes.reduce((s, n) => s + n.cpu, 0),
totalMemory: { total: totalMemTotal, used: totalMemUsed },
};
}