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

+142
View File
@@ -0,0 +1,142 @@
import cronstrue from "cronstrue";
import { CronExpressionParser } from "cron-parser";
import type { TaskRecord } from "../api/client";
import { formatDateTime } from "../utils/date";
export const SCHEDULE_TYPE_LABELS: Record<string, string> = {
cron: "Cron jobs",
systemd_timer: "systemd timers",
docker: "Docker jobs",
backup: "Backups",
update: "Updates",
n8n_workflow: "n8n workflows",
manual: "Other",
};
function describeSchedule(task: TaskRecord): string {
if (!task.scheduleExpression) return "—";
if (task.scheduleType === "cron") {
try {
return cronstrue.toString(task.scheduleExpression, { verbose: false });
} catch {
return task.scheduleExpression;
}
}
return task.scheduleExpression;
}
interface NextRun {
date: Date;
estimated: boolean;
}
function computeNextRun(task: TaskRecord): NextRun | null {
if (task.nextRunAt) return { date: new Date(task.nextRunAt), estimated: false };
if (task.scheduleType !== "cron" || !task.scheduleExpression || task.isStale || !task.enabled) return null;
try {
const date = CronExpressionParser.parse(task.scheduleExpression).next().toDate();
return { date, estimated: true };
} catch {
return null;
}
}
export default function ServerTaskTable({
tasks,
canEditTasks,
onEdit,
onDelete,
}: {
tasks: TaskRecord[];
canEditTasks: boolean;
onEdit: (task: TaskRecord) => void;
onDelete: (task: TaskRecord) => void;
}) {
const byType = new Map<string, TaskRecord[]>();
for (const task of tasks) {
const list = byType.get(task.scheduleType) ?? [];
list.push(task);
byType.set(task.scheduleType, list);
}
return (
<>
{[...byType.entries()].map(([scheduleType, groupTasks]) => (
<div key={scheduleType}>
<div className="card-body py-2 bg-secondary-lt">
<strong>{SCHEDULE_TYPE_LABELS[scheduleType] ?? scheduleType}</strong>
</div>
<div className="table-responsive">
<table className="table table-vcenter card-table mb-0">
<thead>
<tr>
<th>Name</th>
<th>Command</th>
<th>Schedule</th>
<th>Next run</th>
<th>Status</th>
{canEditTasks && <th className="w-1">Actions</th>}
</tr>
</thead>
<tbody>
{groupTasks.map((task) => {
const nextRun = computeNextRun(task);
return (
<tr key={task.id}>
<td>{task.name}</td>
<td className="text-secondary">{task.command ?? "—"}</td>
<td>
<div>{describeSchedule(task)}</div>
{task.scheduleExpression && <span className="text-secondary small">{task.scheduleExpression}</span>}
</td>
<td>
{nextRun ? (
<span
title={
nextRun.estimated
? "Estimated from the cron expression in your browser's timezone — the server may run in a different timezone"
: undefined
}
>
{nextRun.estimated ? "~" : ""}
{formatDateTime(nextRun.date)}
</span>
) : (
"—"
)}
</td>
<td>
{task.isStale ? (
<span className="badge bg-red-lt text-red me-1">Missing</span>
) : task.enabled ? (
<span className="badge bg-green-lt text-green me-1">Enabled</span>
) : (
<span className="badge bg-secondary-lt text-secondary me-1">Disabled</span>
)}
{task.origin === "manual" && <span className="badge bg-blue-lt">Manual</span>}
</td>
{canEditTasks && (
<td>
{task.origin === "manual" && (
<div className="btn-list flex-nowrap">
<button className="btn btn-sm" onClick={() => onEdit(task)}>
Edit
</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => onDelete(task)}>
Delete
</button>
</div>
)}
</td>
)}
</tr>
);
})}
</tbody>
</table>
</div>
</div>
))}
</>
);
}