Add server hardware/network detail view with Proxmox sync and agent reporting

Servers & Tasks only tracked scheduled tasks — there was no overview of
the servers themselves and no way to see CPU/RAM/disk/IP info. Add a
clickable server overview (visible to every role, not just admins) that
opens a per-server detail page.

- servers table gains an agent-reported hardware/network snapshot
  (IPs, CPU model/cores/load, memory, disks) and an optional link to a
  Proxmox VM/LXC (integration + node + guest type + vmid).
- Proxmox adapter gains getGuestDetail(): live cores/memory/disk from
  /config, live cpu/mem/uptime from /status/current, and IPs (LXC net
  config directly, QEMU via a best-effort guest-agent call that degrades
  gracefully when the agent isn't installed).
- New GET /api/servers/:id/detail combines whichever hardware source
  applies (live Proxmox vs. last agent report) with a DNS reverse-lookup
  against the DNS module's own record cache, so matching hostnames show
  up next to each IP. New PATCH /api/servers/:id manages the Proxmox
  link.
- agent/linux/report-tasks.sh now also collects and reports IPs,
  CPU/memory/disk info on every check-in (load-average-based CPU number,
  not instantaneous, to keep the agent a cheap oneshot).
- Extracted the per-server task table into a shared ServerTaskTable
  component so the all-servers view and the new detail page render
  tasks identically.

Verified server-side end-to-end against the real dev server (agent
report -> detail endpoint -> DNS match) and the Proxmox adapter against
a mock HTTPS server covering LXC/QEMU config parsing and the
guest-agent-unavailable fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
bobbanandClaude Sonnet 5 committed 2026-09-15 13:02:50 +02:00
1 parent 1f0e6a9d4c
commit b9409d3095
15 files changed
+2206 -140

No files matched your search

+114 -1
View File
@@ -38,14 +38,54 @@ export interface ProxmoxGuest {
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;
uptime: number | null;
ipAddresses: string[];
/** QEMU only — false when the guest agent call failed (not installed/running). Always true for LXC (IPs read from config, no agent needed). */
guestAgentAvailable: boolean;
}
export interface ProxmoxAdapter {
ping(): Promise<{ ok: boolean; latencyMs?: number; error?: string }>;
listGuests(): Promise<ProxmoxGuest[]>;
getGuestDetail(node: string, type: ProxmoxGuestType, vmid: number): Promise<ProxmoxGuestDetail>;
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>;
}
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;
@@ -147,6 +187,79 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
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[] = [];
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);
}
}
} 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;
}
}
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,
uptime: typeof status.uptime === "number" ? status.uptime : null,
ipAddresses,
guestAgentAvailable: type === "qemu" ? guestAgentAvailable : true,
};
}
async function statusAction(node: string, type: ProxmoxGuestType, vmid: number, action: string): Promise<void> {
await api("POST", `/nodes/${node}/${type}/${vmid}/status/${action}`);
}
@@ -165,5 +278,5 @@ export function createProxmoxAdapter(config: ProxmoxConfig): ProxmoxAdapter {
}
}
return { ping, listGuests, startGuest, stopGuest, restartGuest };
return { ping, listGuests, getGuestDetail, startGuest, stopGuest, restartGuest };
}