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:
1 parent
1f0e6a9d4c
commit
b9409d3095
15 files changed
+2206
-140
No files matched your search
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user