feat: add agent data detail modal with interface telemetry, conntrack flows, and raw payload viewer
CI / backend (push) Failing after 3s
CI / frontend (push) Failing after 30s

Add ScrollText icon button to nodes table to open agent data modal, implement agentFlows/agentInterfaces/agentConntrack helpers to safely extract telemetry from last_payload with type guards, add Modal with status/flows/conntrack summary cards, display interface list with operstate/vmid/rx/tx stats in scrollable container, show top 50 conntrack flows with source/destination/protocol/bytes/packets/state details
This commit is contained in:
2026-07-09 15:02:16 +02:00
parent 5040ac2f16
commit 8536014666
+90 -1
View File
@@ -1,5 +1,5 @@
import { useMutation, useQuery } from "@tanstack/react-query"; import { useMutation, useQuery } from "@tanstack/react-query";
import { Cpu, Copy, RadioTower } from "lucide-react"; import { Activity, Cpu, Copy, RadioTower, ScrollText } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
import { AgentInstallInfo, api, Node } from "../api/client"; import { AgentInstallInfo, api, Node } from "../api/client";
@@ -11,6 +11,7 @@ import { PageHeader } from "../components/PageHeader";
export function Nodes() { export function Nodes() {
const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api<Node[]>("/nodes/agents") }); const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api<Node[]>("/nodes/agents") });
const [selectedNode, setSelectedNode] = useState<Node | null>(null); const [selectedNode, setSelectedNode] = useState<Node | null>(null);
const [detailNode, setDetailNode] = useState<Node | null>(null);
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const installInfo = useMutation({ const installInfo = useMutation({
mutationFn: (node: Node) => api<AgentInstallInfo>(`/nodes/${node.id}/agent/install-info`), mutationFn: (node: Node) => api<AgentInstallInfo>(`/nodes/${node.id}/agent/install-info`),
@@ -28,6 +29,21 @@ export function Nodes() {
setCopied(true); setCopied(true);
} }
function agentFlows(node: Node) {
const flows = node.agent?.last_payload?.flows;
return Array.isArray(flows) ? flows : [];
}
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 ( return (
<> <>
<PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." /> <PageHeader title="Nodes" subtitle="Cluster nodes, capacity, health, and NexaFabric agent enrollment." />
@@ -56,6 +72,15 @@ export function Nodes() {
const node = row as unknown as Node; const node = row as unknown as Node;
return ( return (
<div className="flex justify-end gap-2"> <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 <button
className={iconButtonClass} className={iconButtonClass}
title="Install node agent" title="Install node agent"
@@ -93,6 +118,70 @@ export function Nodes() {
</button> </button>
</div> </div>
</Modal> </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>;
return (
<div key={index} className="border-b border-border px-3 py-2 text-xs last:border-0">
<div className="font-medium">{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>
</div>
);
}) : <div className="p-3 text-xs text-slate-500">No conntrack flows 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>
</> </>
); );
} }