Files
NexaFabric/frontend/src/pages/Nodes.tsx
T
nessi 3bfd77a74a feat: add interface traffic counters as fallback telemetry when conntrack flows unavailable
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
2026-07-09 15:12:31 +02:00

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>
</>
);
}