From 853601466620af3f457ca8647f8890716b8a4c3d Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 15:02:16 +0200 Subject: [PATCH] feat: add agent data detail modal with interface telemetry, conntrack flows, and raw payload viewer 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 --- frontend/src/pages/Nodes.tsx | 91 +++++++++++++++++++++++++++++++++++- 1 file changed, 90 insertions(+), 1 deletion(-) diff --git a/frontend/src/pages/Nodes.tsx b/frontend/src/pages/Nodes.tsx index 4769b84..e0f6529 100644 --- a/frontend/src/pages/Nodes.tsx +++ b/frontend/src/pages/Nodes.tsx @@ -1,5 +1,5 @@ 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 { AgentInstallInfo, api, Node } from "../api/client"; @@ -11,6 +11,7 @@ import { PageHeader } from "../components/PageHeader"; export function Nodes() { const nodes = useQuery({ queryKey: ["nodes-agents"], queryFn: () => api("/nodes/agents") }); const [selectedNode, setSelectedNode] = useState(null); + const [detailNode, setDetailNode] = useState(null); const [copied, setCopied] = useState(false); const installInfo = useMutation({ mutationFn: (node: Node) => api(`/nodes/${node.id}/agent/install-info`), @@ -28,6 +29,21 @@ export function Nodes() { 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 : {}; + } + return ( <> @@ -56,6 +72,15 @@ export function Nodes() { const node = row as unknown as Node; return (
+
+ setDetailNode(null)}> + {detailNode ? ( +
+
+ + {detailNode.name} +
+
+
+
Status
+
{detailNode.agent?.status ?? "not_installed"}
+
+
+
Flows
+
{agentFlows(detailNode).length}
+
+
+
Conntrack
+
{String(agentConntrack(detailNode).count ?? "unknown")}
+
+
+
+
Interfaces
+
+ {agentInterfaces(detailNode).length ? agentInterfaces(detailNode).map((item, index) => { + const iface = item as Record; + return ( +
+
+
{String(iface.name)}
+
state {String(iface.operstate ?? "unknown")} {iface.vmid ? `· VMID ${String(iface.vmid)}` : ""}
+
+
+
rx {String(iface.rx_bytes ?? 0)}
+
tx {String(iface.tx_bytes ?? 0)}
+
+
+ ); + }) :
No interface telemetry in the last heartbeat.
} +
+
+
+
Flows
+
+ {agentFlows(detailNode).length ? agentFlows(detailNode).slice(0, 50).map((item, index) => { + const flow = item as Record; + return ( +
+
{String(flow.source_ip)}:{String(flow.source_port ?? "")} -> {String(flow.destination_ip)}:{String(flow.destination_port ?? "")}
+
{String(flow.protocol ?? "unknown")} · {String(flow.bytes ?? 0)} bytes · {String(flow.packets ?? 0)} packets · {String(flow.state ?? "unknown")}
+
+ ); + }) :
No conntrack flows were reported in the last heartbeat.
} +
+
+
+
Raw Payload
+
+                {JSON.stringify(detailNode.agent?.last_payload ?? {}, null, 2)}
+              
+
+
+ ) : null} +
); }