Files
Homelab-manager/server/src/routes/agentReport.ts
T
bobbanandClaude Sonnet 5 b9409d3095 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>
2026-09-15 13:02:50 +02:00

73 lines
2.4 KiB
TypeScript

import { Router } from "express";
import { z } from "zod";
import { db } from "../db/client.js";
import { hashToken } from "../services/tokens.js";
import { syncServerTasks } from "../services/taskSync.js";
import { asyncHandler } from "../utils/asyncHandler.js";
export const agentReportRouter = Router();
const systemSchema = z.object({
ip_addresses: z.array(z.string()).optional(),
cpu: z.object({ model: z.string().optional(), cores: z.number().optional(), load_percent: z.number().nullable().optional() }).optional(),
memory: z.object({ total_bytes: z.number().optional(), used_bytes: z.number().optional() }).optional(),
disks: z.array(z.object({ mount: z.string(), size_bytes: z.number(), used_bytes: z.number() })).optional(),
});
const reportSchema = z.object({
hostname: z.string().max(255).optional(),
os_type: z.string().optional(),
reported_at: z.string().optional(),
system: systemSchema.optional(),
tasks: z.array(
z.object({
schedule_type: z.enum(["cron", "systemd_timer"]),
name: z.string().min(1),
command: z.string().optional(),
schedule_expression: z.string().optional(),
source: z.string().optional(),
enabled: z.boolean().optional(),
next_run_at: z.string().optional(),
metadata: z.unknown().optional(),
}),
),
});
agentReportRouter.post("/", asyncHandler(async (req, res) => {
const authHeader = req.header("authorization") ?? "";
const match = authHeader.match(/^Bearer\s+(.+)$/i);
if (!match) {
return res.status(401).json({ error: "missing_token" });
}
const tokenHash = hashToken(match[1]);
const server = await db.query.servers.findFirst({
where: (s, { eq }) => eq(s.apiTokenHash, tokenHash),
});
if (!server) {
return res.status(401).json({ error: "invalid_token" });
}
const parsed = reportSchema.safeParse(req.body);
if (!parsed.success) {
return res.status(400).json({ error: "invalid_body", details: parsed.error.flatten() });
}
await syncServerTasks(server.id, {
hostname: parsed.data.hostname,
system: parsed.data.system,
tasks: parsed.data.tasks.map((t) => ({
scheduleType: t.schedule_type,
name: t.name,
command: t.command,
scheduleExpression: t.schedule_expression,
source: t.source,
enabled: t.enabled,
nextRunAt: t.next_run_at,
metadata: t.metadata,
})),
});
res.status(202).json({ ok: true, taskCount: parsed.data.tasks.length });
}));