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

+153 -2
View File
@@ -1,12 +1,14 @@
import { Router } from "express";
import { eq } from "drizzle-orm";
import { eq, and, inArray } from "drizzle-orm";
import { z } from "zod";
import { db } from "../db/client.js";
import { servers } from "../db/schema.js";
import { servers, integrations, dnsRecordsCache, dnsZonesCache, dnsProviders } from "../db/schema.js";
import { requireAuth, requireRole } from "../auth/middleware.js";
import { generateApiToken } from "../services/tokens.js";
import { recordAudit } from "../services/audit.js";
import { asyncHandler } from "../utils/asyncHandler.js";
import { loadIntegrationConfig } from "../integrations/loadIntegration.js";
import { createProxmoxAdapter, type ProxmoxGuestType } from "../integrations/proxmox/adapter.js";
export const serversRouter = Router();
serversRouter.use(requireAuth);
@@ -85,6 +87,155 @@ serversRouter.post("/:id/rotate-token", requireRole("admin"), asyncHandler(async
res.json({ server: serverOut, token });
}));
const updateServerSchema = z
.object({
name: z.string().min(1).max(100).optional(),
hostname: z.string().max(255).optional(),
description: z.string().max(500).optional(),
proxmoxIntegrationId: z.number().int().nullable().optional(),
proxmoxNode: z.string().nullable().optional(),
proxmoxGuestType: z.enum(["qemu", "lxc"]).nullable().optional(),
proxmoxVmid: z.number().int().nullable().optional(),
})
.refine(
(data) => {
const proxmoxFields = [data.proxmoxIntegrationId, data.proxmoxNode, data.proxmoxGuestType, data.proxmoxVmid];
if (proxmoxFields.every((f) => f === undefined)) return true;
const allNull = proxmoxFields.every((f) => f === null);
const allSet = proxmoxFields.every((f) => f !== undefined && f !== null);
return allNull || allSet;
},
{ message: "proxmoxIntegrationId/proxmoxNode/proxmoxGuestType/proxmoxVmid must be set together or all cleared to null" },
);
serversRouter.patch("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const parsed = updateServerSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
const [existing] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!existing) return res.status(404).json({ error: "not_found" });
if (parsed.data.proxmoxIntegrationId) {
const [integration] = await db
.select()
.from(integrations)
.where(eq(integrations.id, parsed.data.proxmoxIntegrationId))
.limit(1);
if (!integration || integration.type !== "proxmox") {
return res.status(400).json({ error: "invalid_proxmox_integration" });
}
}
const [updated] = await db.update(servers).set(parsed.data).where(eq(servers.id, id)).returning();
await recordAudit({
actor: req.currentUser!,
category: "server",
action: "update",
targetType: "server",
targetId: id,
detail: { name: updated.name },
});
const { apiTokenHash, ...serverOut } = updated;
res.json({ server: serverOut });
}));
serversRouter.get("/:id/detail", asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });
const [server] = await db.select().from(servers).where(eq(servers.id, id)).limit(1);
if (!server) return res.status(404).json({ error: "not_found" });
let ipAddresses: string[] = [];
let hardware: Record<string, unknown>;
if (server.proxmoxIntegrationId && server.proxmoxNode && server.proxmoxGuestType && server.proxmoxVmid !== null) {
const loaded = await loadIntegrationConfig(server.proxmoxIntegrationId);
if (!loaded || loaded.integration.type !== "proxmox") {
hardware = { source: "proxmox", error: "The linked Proxmox integration no longer exists or has changed type." };
} else {
try {
const adapter = createProxmoxAdapter(loaded.config as { url: string; tokenId: string; tokenSecret: string; insecure?: boolean });
const detail = await adapter.getGuestDetail(
server.proxmoxNode,
server.proxmoxGuestType as ProxmoxGuestType,
server.proxmoxVmid,
);
ipAddresses = detail.ipAddresses;
hardware = {
source: "proxmox",
integrationId: loaded.integration.id,
integrationName: loaded.integration.name,
status: detail.status,
cpuCores: detail.cpuCores,
cpuUsagePercent: detail.cpuUsagePercent,
memTotalBytes: detail.memoryBytes,
memUsedBytes: detail.memUsedBytes,
diskBytes: detail.diskBytes,
uptime: detail.uptime,
guestAgentAvailable: detail.guestAgentAvailable,
};
} catch (err) {
hardware = { source: "proxmox", error: err instanceof Error ? err.message : String(err) };
}
}
} else if (server.cpuModel || server.cpuCores || server.memTotalBytes || server.disks) {
ipAddresses = server.ipAddresses ? JSON.parse(server.ipAddresses) : [];
hardware = {
source: "agent",
cpuModel: server.cpuModel,
cpuCores: server.cpuCores,
cpuLoadPercent: server.cpuLoadPercent,
memTotalBytes: server.memTotalBytes,
memUsedBytes: server.memUsedBytes,
disks: server.disks ? JSON.parse(server.disks) : [],
};
} else {
hardware = { source: "none" };
}
let dnsMatches: { recordName: string; recordType: string; ip: string; zoneName: string | null; providerName: string; providerType: string }[] = [];
if (ipAddresses.length > 0) {
dnsMatches = await db
.select({
recordName: dnsRecordsCache.name,
recordType: dnsRecordsCache.type,
ip: dnsRecordsCache.content,
zoneName: dnsZonesCache.zoneName,
providerName: dnsProviders.name,
providerType: dnsProviders.providerType,
})
.from(dnsRecordsCache)
.innerJoin(dnsProviders, eq(dnsRecordsCache.providerId, dnsProviders.id))
.leftJoin(
dnsZonesCache,
and(eq(dnsZonesCache.providerId, dnsRecordsCache.providerId), eq(dnsZonesCache.zoneId, dnsRecordsCache.zoneId)),
)
.where(inArray(dnsRecordsCache.content, ipAddresses));
}
const {
apiTokenHash,
ipAddresses: _rawIpAddresses,
disks: _rawDisks,
cpuModel: _cpuModel,
cpuCores: _cpuCores,
cpuLoadPercent: _cpuLoadPercent,
memTotalBytes: _memTotalBytes,
memUsedBytes: _memUsedBytes,
...serverOut
} = server;
res.json({ server: serverOut, ipAddresses, hardware, dnsMatches });
}));
serversRouter.delete("/:id", requireRole("admin"), asyncHandler(async (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) return res.status(400).json({ error: "invalid_id" });