Add interface_traffic collection in agent to aggregate VM/LXC network counters by vmid/nic with tap/fwln/fwpr/fwbr interface ranking, implement collect_interface_traffic to select best interface per VM NIC and format as flow-like records with rx/tx bytes/packets, add collect_flow_diagnostics to capture conntrack binary path and kernel bridge/netfilter settings for debugging, update workload_insights endpoint
202 lines
9.8 KiB
TypeScript
202 lines
9.8 KiB
TypeScript
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
import { Activity, Cpu, Copy, RadioTower, ScrollText } from "lucide-react";
|
|
import { useState } from "react";
|
|
|
|
import { AgentInstallInfo, api, Node } from "../api/client";
|
|
import { DataTable } from "../components/DataTable";
|
|
import { iconButtonClass, secondaryButtonClass } from "../components/FormControls";
|
|
import { LoadingOverlay } from "../components/LoadingOverlay";
|
|
import { Modal } from "../components/Modal";
|
|
import { PageHeader } from "../components/PageHeader";
|
|
|
|
export function Nodes() {
|
|
const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api<Node[]>("/nodes/agents") });
|
|
const [selectedNode, setSelectedNode] = useState<Node | null>(null);
|
|
const [detailNode, setDetailNode] = useState<Node | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const installInfo = useMutation({
|
|
mutationFn: (node: Node) => api<AgentInstallInfo>(`/nodes/${node.id}/agent/install-info`),
|
|
onSuccess: (_data, node) => {
|
|
setSelectedNode(node);
|
|
setCopied(false);
|
|
},
|
|
});
|
|
|
|
async function copyCommand() {
|
|
if (!installInfo.data) {
|
|
return;
|
|
}
|
|
await navigator.clipboard.writeText(installInfo.data.command);
|
|
setCopied(true);
|
|
}
|
|
|
|
function agentFlows(node: Node) {
|
|
const flows = node.agent?.last_payload?.flows;
|
|
const interfaceTraffic = node.agent?.last_payload?.interface_traffic;
|
|
if (Array.isArray(flows) && flows.length) {
|
|
return flows;
|
|
}
|
|
return Array.isArray(interfaceTraffic) ? interfaceTraffic : [];
|
|
}
|
|
|
|
function agentInterfaces(node: Node) {
|
|
const interfaces = node.agent?.last_payload?.interfaces;
|
|
return Array.isArray(interfaces) ? interfaces : [];
|
|
}
|
|
|
|
function agentConntrack(node: Node) {
|
|
const conntrack = node.agent?.last_payload?.conntrack;
|
|
return conntrack && typeof conntrack === "object" ? conntrack as Record<string, unknown> : {};
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
|
|
<LoadingOverlay open={installInfo.isPending} message="Generating node agent installer..." />
|
|
{nodes.isLoading ? <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading...</div> : null}
|
|
{nodes.error ? <div className="rounded-md border border-danger p-4 text-sm text-danger">Failed to load nodes.</div> : null}
|
|
{!nodes.isLoading && !nodes.error ? (
|
|
<DataTable
|
|
rows={(nodes.data ?? []) as unknown as Record<string, unknown>[]}
|
|
columns={[
|
|
{ key: "name", label: "Name" },
|
|
{ key: "status", label: "Status" },
|
|
{ key: "cpu_count", label: "CPU" },
|
|
{ key: "memory_mb", label: "Memory MB" },
|
|
{
|
|
key: "agent",
|
|
label: "Agent",
|
|
render: (row) => {
|
|
const node = row as unknown as Node;
|
|
return node.agent ? `${node.agent.status}${node.agent.version ? ` · ${node.agent.version}` : ""}` : "not_installed";
|
|
},
|
|
},
|
|
{
|
|
key: "actions",
|
|
label: "Actions",
|
|
render: (row) => {
|
|
const node = row as unknown as Node;
|
|
return (
|
|
<div className="flex justify-end gap-2">
|
|
<button
|
|
className={iconButtonClass}
|
|
title="View agent data"
|
|
aria-label={`View agent data for ${node.name}`}
|
|
disabled={!node.agent?.last_payload}
|
|
onClick={() => setDetailNode(node)}
|
|
>
|
|
<ScrollText size={16} />
|
|
</button>
|
|
<button
|
|
className={iconButtonClass}
|
|
title="Install node agent"
|
|
aria-label={`Install agent on ${node.name}`}
|
|
onClick={() => installInfo.mutate(node)}
|
|
>
|
|
<RadioTower size={16} />
|
|
</button>
|
|
</div>
|
|
);
|
|
},
|
|
},
|
|
]}
|
|
/>
|
|
) : null}
|
|
<Modal title="Install Node Agent" open={Boolean(selectedNode)} onClose={() => setSelectedNode(null)}>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 font-medium">
|
|
<Cpu size={18} />
|
|
{selectedNode?.name}
|
|
</div>
|
|
<div className="rounded-md border border-border bg-canvas p-3 text-xs text-slate-500 dark:text-slate-400">
|
|
Run this command as root on the Proxmox node. It installs the agent under <code>/opt/nexafabric-agent</code> and starts a systemd service.
|
|
</div>
|
|
<pre className="max-h-48 overflow-auto rounded-md border border-border bg-canvas p-3 text-xs">
|
|
{installInfo.data?.command ?? "Generating installer..."}
|
|
</pre>
|
|
<div className="grid gap-2 text-xs">
|
|
<span className="text-slate-500 dark:text-slate-400">Installer link</span>
|
|
<code className="break-all rounded-md border border-border bg-canvas p-3">{installInfo.data?.install_url ?? ""}</code>
|
|
</div>
|
|
<button className={secondaryButtonClass} disabled={!installInfo.data} onClick={copyCommand}>
|
|
<Copy size={16} />
|
|
{copied ? "Copied" : "Copy Command"}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
<Modal title="Agent Data" open={Boolean(detailNode)} onClose={() => setDetailNode(null)}>
|
|
{detailNode ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-2 font-medium">
|
|
<Activity size={18} />
|
|
{detailNode.name}
|
|
</div>
|
|
<div className="grid gap-3 sm:grid-cols-3">
|
|
<div className="rounded-md border border-border bg-canvas p-3">
|
|
<div className="text-xs text-slate-500 dark:text-slate-400">Status</div>
|
|
<div className="mt-1 text-sm font-medium">{detailNode.agent?.status ?? "not_installed"}</div>
|
|
</div>
|
|
<div className="rounded-md border border-border bg-canvas p-3">
|
|
<div className="text-xs text-slate-500 dark:text-slate-400">Flows</div>
|
|
<div className="mt-1 text-sm font-medium">{agentFlows(detailNode).length}</div>
|
|
</div>
|
|
<div className="rounded-md border border-border bg-canvas p-3">
|
|
<div className="text-xs text-slate-500 dark:text-slate-400">Conntrack</div>
|
|
<div className="mt-1 text-sm font-medium">{String(agentConntrack(detailNode).count ?? "unknown")}</div>
|
|
</div>
|
|
</div>
|
|
<section>
|
|
<div className="mb-2 text-sm font-medium">Interfaces</div>
|
|
<div className="max-h-48 overflow-auto rounded-md border border-border">
|
|
{agentInterfaces(detailNode).length ? agentInterfaces(detailNode).map((item, index) => {
|
|
const iface = item as Record<string, unknown>;
|
|
return (
|
|
<div key={`${String(iface.name)}-${index}`} className="grid grid-cols-[1fr_auto] gap-3 border-b border-border px-3 py-2 text-xs last:border-0">
|
|
<div>
|
|
<div className="font-medium">{String(iface.name)}</div>
|
|
<div className="text-slate-500">state {String(iface.operstate ?? "unknown")} {iface.vmid ? `· VMID ${String(iface.vmid)}` : ""}</div>
|
|
</div>
|
|
<div className="text-right text-slate-500">
|
|
<div>rx {String(iface.rx_bytes ?? 0)}</div>
|
|
<div>tx {String(iface.tx_bytes ?? 0)}</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}) : <div className="p-3 text-xs text-slate-500">No interface telemetry in the last heartbeat.</div>}
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<div className="mb-2 text-sm font-medium">Flows</div>
|
|
<div className="max-h-48 overflow-auto rounded-md border border-border">
|
|
{agentFlows(detailNode).length ? agentFlows(detailNode).slice(0, 50).map((item, index) => {
|
|
const flow = item as Record<string, unknown>;
|
|
const isInterfaceCounter = flow.protocol === "interface-counter";
|
|
return (
|
|
<div key={index} className="border-b border-border px-3 py-2 text-xs last:border-0">
|
|
<div className="font-medium">
|
|
{isInterfaceCounter
|
|
? `VMID ${String(flow.vmid)} ${String(flow.interface ?? "")}`
|
|
: `${String(flow.source_ip)}:${String(flow.source_port ?? "")} -> ${String(flow.destination_ip)}:${String(flow.destination_port ?? "")}`}
|
|
</div>
|
|
<div className="text-slate-500">
|
|
{String(flow.protocol ?? "unknown")} · {String(flow.bytes ?? 0)} bytes · {String(flow.packets ?? 0)} packets · {String(flow.state ?? "unknown")}
|
|
</div>
|
|
{isInterfaceCounter ? <div className="text-slate-500">rx {String(flow.rx_bytes ?? 0)} · tx {String(flow.tx_bytes ?? 0)}</div> : null}
|
|
</div>
|
|
);
|
|
}) : <div className="p-3 text-xs text-slate-500">No conntrack flows or interface counters were reported in the last heartbeat.</div>}
|
|
</div>
|
|
</section>
|
|
<section>
|
|
<div className="mb-2 text-sm font-medium">Raw Payload</div>
|
|
<pre className="max-h-60 overflow-auto rounded-md border border-border bg-canvas p-3 text-xs">
|
|
{JSON.stringify(detailNode.agent?.last_payload ?? {}, null, 2)}
|
|
</pre>
|
|
</section>
|
|
</div>
|
|
) : null}
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|