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
@@ -9,6 +9,7 @@ import Secrets from "./pages/Secrets";
|
||||
import Ipam from "./pages/Ipam";
|
||||
import Dns from "./pages/Dns";
|
||||
import ServersTasks from "./pages/ServersTasks";
|
||||
import ServerDetail from "./pages/ServerDetail";
|
||||
import Integrations from "./pages/Integrations";
|
||||
import Settings from "./pages/Settings";
|
||||
import NotificationSettings from "./pages/settings/NotificationSettings";
|
||||
@@ -63,6 +64,7 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard user={user} />} />
|
||||
<Route path="/servers" element={<ServersTasks user={user} />} />
|
||||
<Route path="/servers/:id" element={<ServerDetail user={user} />} />
|
||||
<Route path="/dns" element={<Dns user={user} />} />
|
||||
<Route path="/ipam" element={<Ipam user={user} />} />
|
||||
<Route path="/secrets" element={<Secrets user={user} />} />
|
||||
|
||||
@@ -181,6 +181,54 @@ export interface ServerRecord {
|
||||
apiTokenPrefix: string;
|
||||
createdAt: string;
|
||||
lastSeenAt: string | null;
|
||||
proxmoxIntegrationId: number | null;
|
||||
proxmoxNode: string | null;
|
||||
proxmoxGuestType: "qemu" | "lxc" | null;
|
||||
proxmoxVmid: number | null;
|
||||
}
|
||||
|
||||
export interface ServerHardware {
|
||||
source: "proxmox" | "agent" | "none";
|
||||
error?: string;
|
||||
integrationId?: number;
|
||||
integrationName?: string;
|
||||
status?: string;
|
||||
cpuModel?: string;
|
||||
cpuCores?: number | null;
|
||||
cpuUsagePercent?: number | null;
|
||||
cpuLoadPercent?: number | null;
|
||||
memTotalBytes?: number | null;
|
||||
memUsedBytes?: number | null;
|
||||
diskBytes?: number | null;
|
||||
disks?: { mount: string; sizeBytes: number; usedBytes: number }[];
|
||||
uptime?: number | null;
|
||||
guestAgentAvailable?: boolean;
|
||||
}
|
||||
|
||||
export interface DnsMatch {
|
||||
recordName: string;
|
||||
recordType: string;
|
||||
ip: string;
|
||||
zoneName: string | null;
|
||||
providerName: string;
|
||||
providerType: string;
|
||||
}
|
||||
|
||||
export interface ServerDetail {
|
||||
server: ServerRecord;
|
||||
ipAddresses: string[];
|
||||
hardware: ServerHardware;
|
||||
dnsMatches: DnsMatch[];
|
||||
}
|
||||
|
||||
export interface ServerUpdateInput {
|
||||
name?: string;
|
||||
hostname?: string;
|
||||
description?: string;
|
||||
proxmoxIntegrationId?: number | null;
|
||||
proxmoxNode?: string | null;
|
||||
proxmoxGuestType?: "qemu" | "lxc" | null;
|
||||
proxmoxVmid?: number | null;
|
||||
}
|
||||
|
||||
export type ScheduleType = "cron" | "systemd_timer" | "docker" | "backup" | "update" | "n8n_workflow" | "manual";
|
||||
@@ -486,6 +534,9 @@ export const api = {
|
||||
request<{ server: ServerRecord; token: string }>(`/api/servers/${id}/rotate-token`, {
|
||||
method: "POST",
|
||||
}),
|
||||
update: (id: number, data: ServerUpdateInput) =>
|
||||
request<{ server: ServerRecord }>(`/api/servers/${id}`, { method: "PATCH", body: JSON.stringify(data) }),
|
||||
detail: (id: number) => request<ServerDetail>(`/api/servers/${id}/detail`),
|
||||
remove: (id: number) => request<void>(`/api/servers/${id}`, { method: "DELETE" }),
|
||||
},
|
||||
tasks: {
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,559 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
type IntegrationSummary,
|
||||
type ManualTaskInput,
|
||||
type ProxmoxGuest,
|
||||
type ScheduleType,
|
||||
type ServerDetail as ServerDetailResponse,
|
||||
type TaskRecord,
|
||||
} from "../api/client";
|
||||
import ServerTaskTable, { SCHEDULE_TYPE_LABELS } from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = Object.entries(SCHEDULE_TYPE_LABELS).map(
|
||||
([value, label]) => ({ value: value as ScheduleType, label }),
|
||||
);
|
||||
|
||||
function formatBytes(bytes: number | null | undefined): string {
|
||||
if (bytes === null || bytes === undefined) return "—";
|
||||
const units = ["B", "KB", "MB", "GB", "TB", "PB"];
|
||||
let value = bytes;
|
||||
let unit = 0;
|
||||
while (value >= 1024 && unit < units.length - 1) {
|
||||
value /= 1024;
|
||||
unit++;
|
||||
}
|
||||
return `${value.toFixed(1)} ${units[unit]}`;
|
||||
}
|
||||
|
||||
function formatUptime(seconds: number | null | undefined): string {
|
||||
if (!seconds) return "—";
|
||||
const days = Math.floor(seconds / 86400);
|
||||
const hours = Math.floor((seconds % 86400) / 3600);
|
||||
if (days > 0) return `${days}d ${hours}h`;
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}h ${minutes}m`;
|
||||
}
|
||||
|
||||
function usageBar(used: number | null | undefined, total: number | null | undefined) {
|
||||
if (!total) return null;
|
||||
const pct = Math.min(100, Math.round(((used ?? 0) / total) * 100));
|
||||
return (
|
||||
<div className="progress progress-sm mt-1">
|
||||
<div className={`progress-bar ${pct > 90 ? "bg-red" : pct > 75 ? "bg-yellow" : "bg-green"}`} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const emptyTaskForm = {
|
||||
scheduleType: "docker" as ScheduleType,
|
||||
name: "",
|
||||
command: "",
|
||||
scheduleExpression: "",
|
||||
enabled: true,
|
||||
};
|
||||
|
||||
export default function ServerDetail({ user }: { user: CurrentUser }) {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const serverId = Number(id);
|
||||
const isAdmin = user.role === "admin";
|
||||
const canEditTasks = user.role === "admin" || user.role === "operator";
|
||||
|
||||
const [detail, setDetail] = useState<ServerDetailResponse | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
|
||||
const [tasks, setTasks] = useState<TaskRecord[] | null>(null);
|
||||
const [taskForm, setTaskForm] = useState<typeof emptyTaskForm | null>(null);
|
||||
const [editingTask, setEditingTask] = useState<TaskRecord | null>(null);
|
||||
const [savingTask, setSavingTask] = useState(false);
|
||||
|
||||
const [proxmoxIntegrations, setProxmoxIntegrations] = useState<IntegrationSummary[] | null>(null);
|
||||
const [pickedIntegrationId, setPickedIntegrationId] = useState<number | null>(null);
|
||||
const [proxmoxGuests, setProxmoxGuests] = useState<ProxmoxGuest[] | null>(null);
|
||||
const [pickedGuestKey, setPickedGuestKey] = useState<string>("");
|
||||
const [linking, setLinking] = useState(false);
|
||||
const [linkError, setLinkError] = useState<string | null>(null);
|
||||
|
||||
const loadDetail = useCallback(() => {
|
||||
setRefreshing(true);
|
||||
return api.servers
|
||||
.detail(serverId)
|
||||
.then((res) => {
|
||||
setDetail(res);
|
||||
setError(null);
|
||||
})
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)))
|
||||
.finally(() => {
|
||||
setLoading(false);
|
||||
setRefreshing(false);
|
||||
});
|
||||
}, [serverId]);
|
||||
|
||||
const loadTasks = useCallback(() => {
|
||||
api.tasks
|
||||
.list({ serverId, includeStale: true })
|
||||
.then((res) => setTasks(res.tasks))
|
||||
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
|
||||
}, [serverId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadDetail();
|
||||
loadTasks();
|
||||
}, [loadDetail, loadTasks]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAdmin) return;
|
||||
api.integrations
|
||||
.list()
|
||||
.then((res) => setProxmoxIntegrations(res.integrations.filter((i) => i.type === "proxmox" && i.enabled)))
|
||||
.catch(() => setProxmoxIntegrations([]));
|
||||
}, [isAdmin]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pickedIntegrationId) {
|
||||
setProxmoxGuests(null);
|
||||
return;
|
||||
}
|
||||
api.integrations.proxmox
|
||||
.guests(pickedIntegrationId)
|
||||
.then((res) => setProxmoxGuests(res.guests))
|
||||
.catch(() => setProxmoxGuests([]));
|
||||
}, [pickedIntegrationId]);
|
||||
|
||||
async function linkProxmox() {
|
||||
if (!pickedIntegrationId || !pickedGuestKey) return;
|
||||
const guest = proxmoxGuests?.find((g) => `${g.node}/${g.type}/${g.vmid}` === pickedGuestKey);
|
||||
if (!guest) return;
|
||||
setLinking(true);
|
||||
setLinkError(null);
|
||||
try {
|
||||
await api.servers.update(serverId, {
|
||||
proxmoxIntegrationId: pickedIntegrationId,
|
||||
proxmoxNode: guest.node,
|
||||
proxmoxGuestType: guest.type,
|
||||
proxmoxVmid: guest.vmid,
|
||||
});
|
||||
await loadDetail();
|
||||
} catch (err) {
|
||||
setLinkError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLinking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function unlinkProxmox() {
|
||||
setLinking(true);
|
||||
setLinkError(null);
|
||||
try {
|
||||
await api.servers.update(serverId, {
|
||||
proxmoxIntegrationId: null,
|
||||
proxmoxNode: null,
|
||||
proxmoxGuestType: null,
|
||||
proxmoxVmid: null,
|
||||
});
|
||||
setPickedIntegrationId(null);
|
||||
setPickedGuestKey("");
|
||||
await loadDetail();
|
||||
} catch (err) {
|
||||
setLinkError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setLinking(false);
|
||||
}
|
||||
}
|
||||
|
||||
function openAddTask() {
|
||||
setEditingTask(null);
|
||||
setTaskForm({ ...emptyTaskForm });
|
||||
}
|
||||
|
||||
function openEditTask(task: TaskRecord) {
|
||||
setEditingTask(task);
|
||||
setTaskForm({
|
||||
scheduleType: task.scheduleType,
|
||||
name: task.name,
|
||||
command: task.command ?? "",
|
||||
scheduleExpression: task.scheduleExpression ?? "",
|
||||
enabled: task.enabled,
|
||||
});
|
||||
}
|
||||
|
||||
async function submitTask(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!taskForm) return;
|
||||
setSavingTask(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data: ManualTaskInput = {
|
||||
serverId,
|
||||
scheduleType: taskForm.scheduleType,
|
||||
name: taskForm.name,
|
||||
command: taskForm.command || undefined,
|
||||
scheduleExpression: taskForm.scheduleExpression || undefined,
|
||||
enabled: taskForm.enabled,
|
||||
};
|
||||
if (editingTask) {
|
||||
await api.tasks.update(editingTask.id, data);
|
||||
} else {
|
||||
await api.tasks.create(data);
|
||||
}
|
||||
setTaskForm(null);
|
||||
setEditingTask(null);
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
} finally {
|
||||
setSavingTask(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteTask(task: TaskRecord) {
|
||||
if (!confirm(`Delete "${task.name}"?`)) return;
|
||||
try {
|
||||
await api.tasks.remove(task.id);
|
||||
loadTasks();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) return <div className="text-secondary">Loading…</div>;
|
||||
if (error && !detail) return <div className="alert alert-danger">{error}</div>;
|
||||
if (!detail) return null;
|
||||
|
||||
const { server, hardware, ipAddresses, dnsMatches } = detail;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3">
|
||||
<Link to="/servers" className="text-secondary">
|
||||
← Servers & Tasks
|
||||
</Link>
|
||||
</div>
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<div>
|
||||
<h2 className="page-title mb-0">{server.name}</h2>
|
||||
{server.description && <div className="text-secondary">{server.description}</div>}
|
||||
</div>
|
||||
<button className="btn btn-outline-secondary ms-auto" onClick={loadDetail} disabled={refreshing}>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</div>
|
||||
{error && <div className="alert alert-danger">{error}</div>}
|
||||
|
||||
{isAdmin && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Proxmox link</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{server.proxmoxIntegrationId ? (
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<span className="text-secondary">
|
||||
Linked to {hardware.integrationName ?? "a Proxmox integration"} — {server.proxmoxGuestType}/{server.proxmoxVmid} on{" "}
|
||||
{server.proxmoxNode}
|
||||
</span>
|
||||
<button className="btn btn-outline-danger btn-sm ms-auto" onClick={unlinkProxmox} disabled={linking}>
|
||||
Unlink
|
||||
</button>
|
||||
</div>
|
||||
) : (proxmoxIntegrations?.length ?? 0) === 0 ? (
|
||||
<div className="text-secondary">No enabled Proxmox integrations configured yet.</div>
|
||||
) : (
|
||||
<div className="row g-2 align-items-end">
|
||||
<div className="col-md-4">
|
||||
<label className="form-label">Proxmox integration</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={pickedIntegrationId ?? ""}
|
||||
onChange={(e) => {
|
||||
setPickedIntegrationId(e.target.value ? Number(e.target.value) : null);
|
||||
setPickedGuestKey("");
|
||||
}}
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
{proxmoxIntegrations?.map((i) => (
|
||||
<option key={i.id} value={i.id}>
|
||||
{i.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-5">
|
||||
<label className="form-label">VM / LXC</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={pickedGuestKey}
|
||||
disabled={!proxmoxGuests}
|
||||
onChange={(e) => setPickedGuestKey(e.target.value)}
|
||||
>
|
||||
<option value="">Choose…</option>
|
||||
{proxmoxGuests?.map((g) => (
|
||||
<option key={`${g.node}/${g.type}/${g.vmid}`} value={`${g.node}/${g.type}/${g.vmid}`}>
|
||||
{g.name} ({g.type} #{g.vmid} on {g.node})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-3">
|
||||
<button className="btn btn-primary w-100" onClick={linkProxmox} disabled={linking || !pickedGuestKey}>
|
||||
{linking ? "Linking…" : "Link"}
|
||||
</button>
|
||||
</div>
|
||||
{linkError && (
|
||||
<div className="col-12">
|
||||
<div className="alert alert-danger mb-0 mt-2">{linkError}</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="row row-cards mb-3">
|
||||
<div className="col-lg-7">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Hardware & Network</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
{hardware.source === "none" && (
|
||||
<div className="text-secondary">
|
||||
No hardware data yet. Install the agent on this server, or link it to a Proxmox VM/LXC above.
|
||||
</div>
|
||||
)}
|
||||
{hardware.error && <div className="alert alert-danger">{hardware.error}</div>}
|
||||
{hardware.source === "proxmox" && !hardware.error && (
|
||||
<>
|
||||
<div className="text-secondary small mb-3">Synced live from Proxmox just now.</div>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-6">
|
||||
<div className="text-secondary small">CPU</div>
|
||||
<div>
|
||||
{hardware.cpuCores ?? "—"} cores
|
||||
{hardware.cpuUsagePercent !== null && hardware.cpuUsagePercent !== undefined
|
||||
? ` · ${hardware.cpuUsagePercent.toFixed(1)}% used`
|
||||
: ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
<div className="text-secondary small">Memory</div>
|
||||
<div>
|
||||
{formatBytes(hardware.memUsedBytes)} / {formatBytes(hardware.memTotalBytes)}
|
||||
</div>
|
||||
{usageBar(hardware.memUsedBytes, hardware.memTotalBytes)}
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
<div className="text-secondary small">Disk (allocated)</div>
|
||||
<div>{formatBytes(hardware.diskBytes)}</div>
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
<div className="text-secondary small">Uptime</div>
|
||||
<div>{formatUptime(hardware.uptime)}</div>
|
||||
</div>
|
||||
</div>
|
||||
{hardware.guestAgentAvailable === false && (
|
||||
<div className="text-secondary small mt-3">
|
||||
IP addresses unavailable — the QEMU guest agent isn't installed or running on this VM.
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{hardware.source === "agent" && (
|
||||
<>
|
||||
<div className="text-secondary small mb-3">
|
||||
Reported by the agent as of {server.lastSeenAt ? formatDateTime(new Date(server.lastSeenAt)) : "unknown"}.
|
||||
</div>
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-6">
|
||||
<div className="text-secondary small">CPU</div>
|
||||
<div>
|
||||
{hardware.cpuModel || "Unknown model"}
|
||||
{hardware.cpuCores ? ` · ${hardware.cpuCores} cores` : ""}
|
||||
</div>
|
||||
{hardware.cpuLoadPercent !== null && hardware.cpuLoadPercent !== undefined && (
|
||||
<div className="text-secondary small">Load: {hardware.cpuLoadPercent.toFixed(1)}%</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-sm-6">
|
||||
<div className="text-secondary small">Memory</div>
|
||||
<div>
|
||||
{formatBytes(hardware.memUsedBytes)} / {formatBytes(hardware.memTotalBytes)}
|
||||
</div>
|
||||
{usageBar(hardware.memUsedBytes, hardware.memTotalBytes)}
|
||||
</div>
|
||||
</div>
|
||||
{hardware.disks && hardware.disks.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-secondary small mb-1">Disks</div>
|
||||
<table className="table table-sm table-vcenter mb-0">
|
||||
<tbody>
|
||||
{hardware.disks.map((d) => (
|
||||
<tr key={d.mount}>
|
||||
<td>{d.mount}</td>
|
||||
<td>
|
||||
{formatBytes(d.usedBytes)} / {formatBytes(d.sizeBytes)}
|
||||
{usageBar(d.usedBytes, d.sizeBytes)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{ipAddresses.length > 0 && (
|
||||
<div className="mt-3">
|
||||
<div className="text-secondary small mb-1">IP addresses</div>
|
||||
<ul className="list-unstyled mb-0">
|
||||
{ipAddresses.map((ip) => {
|
||||
const matches = dnsMatches.filter((m) => m.ip === ip);
|
||||
return (
|
||||
<li key={ip} className="mb-1">
|
||||
<code>{ip}</code>
|
||||
{matches.length > 0 && (
|
||||
<span className="text-secondary">
|
||||
{" "}
|
||||
— {matches.map((m) => `${m.recordName} (${m.providerName})`).join(", ")}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="col-lg-5">
|
||||
<div className="card h-100">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">Identity</h3>
|
||||
</div>
|
||||
<div className="card-body">
|
||||
<dl className="row mb-0">
|
||||
<dt className="col-5">Hostname</dt>
|
||||
<dd className="col-7">{server.hostname ?? "—"}</dd>
|
||||
<dt className="col-5">OS type</dt>
|
||||
<dd className="col-7">{server.osType}</dd>
|
||||
<dt className="col-5">Token</dt>
|
||||
<dd className="col-7 text-secondary">{server.apiTokenPrefix}…</dd>
|
||||
<dt className="col-5">Created</dt>
|
||||
<dd className="col-7">{formatDateTime(new Date(server.createdAt))}</dd>
|
||||
<dt className="col-5">Last seen</dt>
|
||||
<dd className="col-7">{server.lastSeenAt ? formatDateTime(new Date(server.lastSeenAt)) : "never"}</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="d-flex align-items-center mb-3">
|
||||
<h3 className="mb-0">Tasks</h3>
|
||||
{canEditTasks && (
|
||||
<button className="btn btn-primary btn-sm ms-auto" onClick={openAddTask}>
|
||||
Add task
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{taskForm && (
|
||||
<div className="card mb-3">
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{editingTask ? "Edit task" : "Add a task manually"}</h3>
|
||||
</div>
|
||||
<form onSubmit={submitTask}>
|
||||
<div className="card-body row g-3">
|
||||
<div className="col-md-3">
|
||||
<label className="form-label">Schedule type</label>
|
||||
<select
|
||||
className="form-select"
|
||||
value={taskForm.scheduleType}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, scheduleType: e.target.value as ScheduleType })}
|
||||
>
|
||||
{SCHEDULE_TYPE_OPTIONS.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-md-9">
|
||||
<label className="form-label">Name</label>
|
||||
<input
|
||||
className="form-control"
|
||||
required
|
||||
placeholder="e.g. postgres-backup"
|
||||
value={taskForm.name}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Command / description</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="e.g. docker exec pg-backup /backup.sh"
|
||||
value={taskForm.command}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, command: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-md-6">
|
||||
<label className="form-label">Schedule</label>
|
||||
<input
|
||||
className="form-control"
|
||||
placeholder="e.g. daily at 02:00, or 0 2 * * *"
|
||||
value={taskForm.scheduleExpression}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, scheduleExpression: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="col-12">
|
||||
<label className="form-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="form-check-input"
|
||||
checked={taskForm.enabled}
|
||||
onChange={(e) => setTaskForm({ ...taskForm, enabled: e.target.checked })}
|
||||
/>
|
||||
<span className="form-check-label">Enabled</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div className="card-footer d-flex gap-2">
|
||||
<button type="submit" className="btn btn-primary" disabled={savingTask}>
|
||||
{editingTask ? "Save changes" : "Add task"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
onClick={() => {
|
||||
setTaskForm(null);
|
||||
setEditingTask(null);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card">
|
||||
{tasks && tasks.length === 0 && (
|
||||
<div className="card-body text-secondary">No scheduled tasks found for this server yet.</div>
|
||||
)}
|
||||
{tasks && tasks.length > 0 && (
|
||||
<ServerTaskTable tasks={tasks} canEditTasks={canEditTasks} onEdit={openEditTask} onDelete={deleteTask} />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+43
-133
@@ -1,6 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import cronstrue from "cronstrue";
|
||||
import { CronExpressionParser } from "cron-parser";
|
||||
import { Link } from "react-router-dom";
|
||||
import {
|
||||
api,
|
||||
type CurrentUser,
|
||||
@@ -10,18 +9,9 @@ import {
|
||||
type TaskRecord,
|
||||
} from "../api/client";
|
||||
import CopyButton from "../components/CopyButton";
|
||||
import ServerTaskTable from "../components/ServerTaskTable";
|
||||
import { formatDateTime } from "../utils/date";
|
||||
|
||||
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",
|
||||
};
|
||||
|
||||
const SCHEDULE_TYPE_OPTIONS: { value: ScheduleType; label: string }[] = [
|
||||
{ value: "docker", label: "Docker" },
|
||||
{ value: "cron", label: "Cron" },
|
||||
@@ -40,34 +30,6 @@ function uninstallCommand(): string {
|
||||
return `curl -fsSL ${window.location.origin}/agent/linux/uninstall.sh | sudo bash`;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
const emptyTaskForm = {
|
||||
scheduleType: "docker" as ScheduleType,
|
||||
name: "",
|
||||
@@ -355,7 +317,9 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
|
||||
<tbody>
|
||||
{servers.map((s) => (
|
||||
<tr key={s.id}>
|
||||
<td>{s.name}</td>
|
||||
<td>
|
||||
<Link to={`/servers/${s.id}`}>{s.name}</Link>
|
||||
</td>
|
||||
<td>{s.hostname ?? "—"}</td>
|
||||
<td className="text-secondary">{s.apiTokenPrefix}…</td>
|
||||
<td>{s.lastSeenAt ? formatDateTime(new Date(s.lastSeenAt)) : "never"}</td>
|
||||
@@ -388,6 +352,35 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{servers.length > 0 && (
|
||||
<div className="row row-cards mb-3">
|
||||
{servers.map((s) => (
|
||||
<div className="col-sm-6 col-lg-3" key={s.id}>
|
||||
<Link to={`/servers/${s.id}`} className="card card-sm text-reset text-decoration-none">
|
||||
<div className="card-body">
|
||||
<div className="d-flex align-items-center">
|
||||
<div className="flex-fill">
|
||||
<div className="font-weight-medium">{s.name}</div>
|
||||
<div className="text-secondary small">{s.hostname ?? "no hostname reported"}</div>
|
||||
</div>
|
||||
{s.proxmoxIntegrationId ? (
|
||||
<span className="badge bg-purple-lt text-purple">Proxmox</span>
|
||||
) : s.lastSeenAt ? (
|
||||
<span className="badge bg-green-lt text-green">Agent</span>
|
||||
) : (
|
||||
<span className="badge bg-secondary-lt text-secondary">No data</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-secondary small mt-2">
|
||||
{s.lastSeenAt ? `Last seen ${formatDateTime(new Date(s.lastSeenAt))}` : "Never reported"}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="card mb-3">
|
||||
<div className="card-body">
|
||||
<div className="row g-2 align-items-end">
|
||||
@@ -558,99 +551,16 @@ export default function ServersTasks({ user }: { user: CurrentUser }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groups.map((group) => {
|
||||
const byType = new Map<string, TaskRecord[]>();
|
||||
for (const task of group.tasks) {
|
||||
const list = byType.get(task.scheduleType) ?? [];
|
||||
list.push(task);
|
||||
byType.set(task.scheduleType, list);
|
||||
}
|
||||
return (
|
||||
<div className="card mb-3" key={group.serverId}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">{group.serverName}</h3>
|
||||
</div>
|
||||
{[...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={() => openEditTask(task)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="btn btn-sm btn-outline-danger" onClick={() => deleteTask(task)}>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{groups.map((group) => (
|
||||
<div className="card mb-3" key={group.serverId}>
|
||||
<div className="card-header">
|
||||
<h3 className="card-title">
|
||||
<Link to={`/servers/${group.serverId}`}>{group.serverName}</Link>
|
||||
</h3>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<ServerTaskTable tasks={group.tasks} canEditTasks={canEditTasks} onEdit={openEditTask} onDelete={deleteTask} />
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
|
||||
Reference in new issue
Block a user