feat: add dedicated flow analytics page with filtering, aggregation charts, and enhanced traffic table

Add WorkloadFlows component with search/protocol/decision/port filters, implement FlowStatCards showing total traffic/flows/allowed/blocked/protocols, add TopFlowChart component for top conversations/destinations/protocols/packets with horizontal bars, implement aggregateBy helper to sum traffic by label function, extend TrafficSummary with sourceIp/destinationIp/sourcePort/collector/observedAt
This commit is contained in:
2026-07-09 22:52:14 +02:00
parent d651a11472
commit 8c59ab32d5
2 changed files with 216 additions and 15 deletions
+2 -1
View File
@@ -19,7 +19,7 @@ import { ServiceCatalog } from "./pages/ServiceCatalog";
import { SetupWizard } from "./pages/SetupWizard"; import { SetupWizard } from "./pages/SetupWizard";
import { TenantsProjects } from "./pages/TenantsProjects"; import { TenantsProjects } from "./pages/TenantsProjects";
import { UsersRoles } from "./pages/UsersRoles"; import { UsersRoles } from "./pages/UsersRoles";
import { WorkloadDetail, Workloads } from "./pages/Workloads"; import { WorkloadDetail, WorkloadFlows, Workloads } from "./pages/Workloads";
import { useTheme } from "./stores/theme"; import { useTheme } from "./stores/theme";
const queryClient = new QueryClient(); const queryClient = new QueryClient();
@@ -50,6 +50,7 @@ function AppRoutes() {
<Route path="nodes" element={<Nodes />} /> <Route path="nodes" element={<Nodes />} />
<Route path="workloads" element={<Workloads />} /> <Route path="workloads" element={<Workloads />} />
<Route path="workloads/:workloadId" element={<WorkloadDetail />} /> <Route path="workloads/:workloadId" element={<WorkloadDetail />} />
<Route path="workloads/:workloadId/flows" element={<WorkloadFlows />} />
<Route path="networks" element={<Networks />} /> <Route path="networks" element={<Networks />} />
<Route path="ipam" element={<Ipam />} /> <Route path="ipam" element={<Ipam />} />
<Route path="tenants" element={<TenantsProjects />} /> <Route path="tenants" element={<TenantsProjects />} />
+214 -14
View File
@@ -1,11 +1,11 @@
import { useMemo, useState } from "react"; import { useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query"; import { useQuery } from "@tanstack/react-query";
import { Activity, ArrowRight, CircuitBoard, Hash, Network, Shield, ShieldCheck } from "lucide-react"; import { Activity, ArrowRight, BarChart3, CircuitBoard, Filter, Hash, Network, Search, Shield, ShieldCheck } from "lucide-react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { api, Workload, WorkloadInsight } from "../api/client"; import { api, Workload, WorkloadInsight } from "../api/client";
import { DataTable } from "../components/DataTable"; import { DataTable } from "../components/DataTable";
import { secondaryButtonClass } from "../components/FormControls"; import { inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
import { PageHeader } from "../components/PageHeader"; import { PageHeader } from "../components/PageHeader";
type TrafficSummary = { type TrafficSummary = {
@@ -14,14 +14,19 @@ type TrafficSummary = {
destination: string; destination: string;
sourceLabel: string; sourceLabel: string;
destinationLabel: string; destinationLabel: string;
sourceIp: string;
destinationIp: string;
protocol: string; protocol: string;
port: string; port: string;
sourcePort: string;
bytes: number; bytes: number;
packets: number; packets: number;
count: number; count: number;
decision: string; decision: string;
interfaceName: string; interfaceName: string;
note: string; note: string;
collector: string;
observedAt: string;
ipAddresses: string[]; ipAddresses: string[];
matchingFirewallRules: Array<Record<string, unknown>>; matchingFirewallRules: Array<Record<string, unknown>>;
matchingAuditPolicies: Array<Record<string, unknown>>; matchingAuditPolicies: Array<Record<string, unknown>>;
@@ -61,11 +66,14 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
for (const flow of traffic) { for (const flow of traffic) {
const source = String(flow.source ?? flow.source_ip ?? "external"); const source = String(flow.source ?? flow.source_ip ?? "external");
const destination = String(flow.destination ?? flow.destination_ip ?? "external"); const destination = String(flow.destination ?? flow.destination_ip ?? "external");
const sourceIp = String(flow.source_ip ?? "");
const destinationIp = String(flow.destination_ip ?? "");
const sourceLabel = String(flow.source_label ?? flow.source_ip ?? source); const sourceLabel = String(flow.source_label ?? flow.source_ip ?? source);
const destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination); const destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination);
const protocol = String(flow.protocol ?? "unknown"); const protocol = String(flow.protocol ?? "unknown");
const port = String(flow.port ?? flow.destination_port ?? ""); const port = String(flow.port ?? flow.destination_port ?? "");
const key = [source, destination, protocol, port, String(flow.interface ?? "")].join("|"); const sourcePort = String(flow.source_port ?? "");
const key = [sourceIp || source, destinationIp || destination, protocol, sourcePort, port, String(flow.interface ?? ""), String(flow.decision ?? "")].join("|");
const existing = summaries.get(key); const existing = summaries.get(key);
const bytes = Number(flow.bytes ?? 0); const bytes = Number(flow.bytes ?? 0);
const packets = Number(flow.packets ?? 0); const packets = Number(flow.packets ?? 0);
@@ -84,6 +92,9 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") { if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") {
existing.decision = String(flow.decision ?? "observed"); existing.decision = String(flow.decision ?? "observed");
} }
if (!existing.observedAt && flow.observed_at) {
existing.observedAt = String(flow.observed_at);
}
continue; continue;
} }
summaries.set(key, { summaries.set(key, {
@@ -92,14 +103,19 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
destination, destination,
sourceLabel, sourceLabel,
destinationLabel, destinationLabel,
sourceIp,
destinationIp,
protocol, protocol,
port, port,
sourcePort,
bytes: Number.isFinite(bytes) ? bytes : 0, bytes: Number.isFinite(bytes) ? bytes : 0,
packets: Number.isFinite(packets) ? packets : 0, packets: Number.isFinite(packets) ? packets : 0,
count: 1, count: 1,
decision: String(flow.decision ?? "observed"), decision: String(flow.decision ?? "observed"),
interfaceName: String(flow.interface ?? ""), interfaceName: String(flow.interface ?? ""),
note: String(flow.note ?? ""), note: String(flow.note ?? ""),
collector: String(flow.collector ?? ""),
observedAt: String(flow.observed_at ?? ""),
ipAddresses, ipAddresses,
matchingFirewallRules, matchingFirewallRules,
matchingAuditPolicies, matchingAuditPolicies,
@@ -117,6 +133,10 @@ function totalBytes(traffic: TrafficSummary[]) {
return traffic.reduce((sum, flow) => sum + flow.bytes, 0); return traffic.reduce((sum, flow) => sum + flow.bytes, 0);
} }
function uniqueValues(values: string[]) {
return Array.from(new Set(values.filter(Boolean))).sort();
}
function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) { function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
const top = traffic.slice(0, 5); const top = traffic.slice(0, 5);
const max = Math.max(...top.map((flow) => flow.bytes), 1); const max = Math.max(...top.map((flow) => flow.bytes), 1);
@@ -318,10 +338,10 @@ function WorkloadFacts({ insight }: { insight: WorkloadInsight }) {
); );
} }
function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) { function TrafficTable({ traffic, dense = false }: { traffic: TrafficSummary[]; dense?: boolean }) {
return ( return (
<div className="overflow-hidden rounded-md border border-border"> <div className="overflow-hidden rounded-md border border-border">
<div className="max-h-[460px] overflow-auto"> <div className={dense ? "overflow-auto" : "max-h-[460px] overflow-auto"}>
<table className="w-full text-left text-sm"> <table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500"> <thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500">
<tr> <tr>
@@ -335,19 +355,30 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
</thead> </thead>
<tbody> <tbody>
{traffic.map((flow) => ( {traffic.map((flow) => (
<tr key={flow.key} className="border-t border-border"> <tr key={flow.key} className="border-t border-border align-top hover:bg-slate-50 dark:hover:bg-slate-900/50">
<td className="px-3 py-3"> <td className="min-w-[420px] px-3 py-3">
<div className="font-medium">{endpointText(flow)}</div> <div className="font-medium">{endpointText(flow)}</div>
<div className="text-xs text-slate-500">{flow.ipAddresses.join(", ") || flow.interfaceName || "no endpoint metadata"}</div> <div className="mt-1 flex flex-wrap gap-2 text-xs text-slate-500">
<span>{flow.sourceIp || flow.source}</span>
<span>-&gt;</span>
<span>{flow.destinationIp || flow.destination}</span>
{flow.collector ? <span className="rounded border border-border px-1.5">{flow.collector}</span> : null}
</div>
<FlowRuleContext flow={flow} /> <FlowRuleContext flow={flow} />
</td> </td>
<td className="px-3 py-3">{flow.protocol}{flow.port ? `:${flow.port}` : ""}</td> <td className="whitespace-nowrap px-3 py-3">
<div>{flow.protocol}{flow.port ? `:${flow.port}` : ""}</div>
{flow.sourcePort ? <div className="text-xs text-slate-500">source {flow.sourcePort}</div> : null}
</td>
<td className="px-3 py-3"> <td className="px-3 py-3">
<span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span> <span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span>
</td> </td>
<td className="px-3 py-3">{formatBytes(flow.bytes)}</td> <td className="whitespace-nowrap px-3 py-3 font-medium">{formatBytes(flow.bytes)}</td>
<td className="px-3 py-3">{flow.packets}</td> <td className="whitespace-nowrap px-3 py-3">{flow.packets}</td>
<td className="px-3 py-3">{flow.count} sample{flow.count === 1 ? "" : "s"}</td> <td className="whitespace-nowrap px-3 py-3">
<div>{flow.count} sample{flow.count === 1 ? "" : "s"}</div>
{flow.observedAt ? <div className="text-xs text-slate-500">{new Date(flow.observedAt).toLocaleString()}</div> : null}
</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -357,6 +388,64 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
); );
} }
function FlowStatCards({ traffic }: { traffic: TrafficSummary[] }) {
const blocked = traffic.filter((flow) => flow.decision.includes("block")).length;
const allowed = traffic.filter((flow) => flow.decision.includes("allow")).length;
const protocols = uniqueValues(traffic.map((flow) => flow.protocol)).length;
return (
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Total Traffic</div>
<div className="mt-1 text-xl font-semibold">{formatBytes(totalBytes(traffic))}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Flows</div>
<div className="mt-1 text-xl font-semibold">{traffic.length}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Allowed / Blocked</div>
<div className="mt-1 text-xl font-semibold">{allowed} / {blocked}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="text-xs text-slate-500">Protocols</div>
<div className="mt-1 text-xl font-semibold">{protocols}</div>
</div>
</div>
);
}
function TopFlowChart({ title, items }: { title: string; items: Array<{ name: string; value: number; suffix?: string }> }) {
const top = items.slice(0, 8);
const max = Math.max(...top.map((item) => item.value), 1);
return (
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><BarChart3 size={17} /> {title}</div>
<div className="space-y-2">
{top.length ? top.map((item) => (
<div key={item.name} className="grid gap-1">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="truncate">{item.name}</span>
<span className="shrink-0 text-slate-500">{item.suffix ?? formatBytes(item.value)}</span>
</div>
<div className="h-2 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
<div className="h-full rounded-full bg-accent" style={{ width: `${Math.max((item.value / max) * 100, 3)}%` }} />
</div>
</div>
)) : <div className="text-sm text-slate-500">No data for this filter.</div>}
</div>
</section>
);
}
function aggregateBy(traffic: TrafficSummary[], label: (flow: TrafficSummary) => string, value: (flow: TrafficSummary) => number) {
const totals = new Map<string, number>();
for (const flow of traffic) {
const key = label(flow);
totals.set(key, (totals.get(key) ?? 0) + value(flow));
}
return Array.from(totals.entries()).map(([name, total]) => ({ name, value: total })).sort((left, right) => right.value - left.value);
}
export function Workloads() { export function Workloads() {
const navigate = useNavigate(); const navigate = useNavigate();
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") }); const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
@@ -483,8 +572,14 @@ export function WorkloadDetail() {
<TrafficBars traffic={traffic} /> <TrafficBars traffic={traffic} />
</div> </div>
<div className="rounded-md border border-border bg-panel p-4"> <div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Flow Table</div> <div className="mb-3 flex items-center justify-between gap-3">
{traffic.length ? <TrafficTable traffic={traffic} /> : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div>} <div className="font-medium">Flow Table</div>
<Link className={`${secondaryButtonClass} h-9 px-3`} to={`/workloads/${insight.data.workload.id}/flows`}>
<BarChart3 size={16} />
Flow Analytics
</Link>
</div>
{traffic.length ? <TrafficTable traffic={traffic.slice(0, 12)} /> : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div>}
</div> </div>
</section> </section>
<aside className="space-y-4"> <aside className="space-y-4">
@@ -534,3 +629,108 @@ export function WorkloadDetail() {
</> </>
); );
} }
export function WorkloadFlows() {
const { workloadId } = useParams();
const [query, setQuery] = useState("");
const [protocol, setProtocol] = useState("all");
const [decision, setDecision] = useState("all");
const [port, setPort] = useState("");
const insight = useQuery({
queryKey: ["workload-insight", workloadId],
queryFn: () => api<WorkloadInsight>(`/vms/${workloadId}/insights`),
enabled: Boolean(workloadId),
});
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
const protocols = useMemo(() => uniqueValues(traffic.map((flow) => flow.protocol)), [traffic]);
const decisions = useMemo(() => uniqueValues(traffic.map((flow) => flow.decision)), [traffic]);
const filteredTraffic = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
const normalizedPort = port.trim();
return traffic.filter((flow) => {
const haystack = [
flow.source,
flow.destination,
flow.sourceLabel,
flow.destinationLabel,
flow.sourceIp,
flow.destinationIp,
flow.protocol,
flow.port,
flow.sourcePort,
flow.decision,
flow.collector,
].join(" ").toLowerCase();
if (normalizedQuery && !haystack.includes(normalizedQuery)) {
return false;
}
if (protocol !== "all" && flow.protocol !== protocol) {
return false;
}
if (decision !== "all" && flow.decision !== decision) {
return false;
}
if (normalizedPort && flow.port !== normalizedPort && flow.sourcePort !== normalizedPort) {
return false;
}
return true;
});
}, [decision, port, protocol, query, traffic]);
if (insight.isLoading) {
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading flow analytics...</div>;
}
if (!insight.data) {
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Flow analytics could not be loaded.</div>;
}
const endpointTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.bytes);
const destinationTotals = aggregateBy(filteredTraffic, (flow) => flow.destinationLabel, (flow) => flow.bytes);
const protocolTotals = aggregateBy(filteredTraffic, (flow) => flow.protocol, (flow) => flow.bytes);
const packetTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.packets).map((item) => ({ ...item, suffix: `${item.value} packets` }));
return (
<>
<PageHeader title={`${insight.data.workload.name} Flow Analytics`} subtitle="Search, filter, and inspect workload traffic decisions." />
<div className="mb-4 flex flex-wrap gap-2">
<Link className={secondaryButtonClass} to={`/workloads/${insight.data.workload.id}`}>Back to Workload</Link>
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
</div>
<div className="space-y-4">
<FlowStatCards traffic={filteredTraffic} />
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><Filter size={17} /> Filters</div>
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px_150px]">
<label className="relative">
<Search className="pointer-events-none absolute left-3 top-2.5 text-slate-500" size={16} />
<input className={`${inputClass} pl-9`} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search IP, workload, collector, protocol..." />
</label>
<select className={selectClass} value={protocol} onChange={(event) => setProtocol(event.target.value)}>
<option value="all">All protocols</option>
{protocols.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<select className={selectClass} value={decision} onChange={(event) => setDecision(event.target.value)}>
<option value="all">All decisions</option>
{decisions.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
<input className={inputClass} value={port} onChange={(event) => setPort(event.target.value)} placeholder="Port" />
</div>
</section>
<div className="grid gap-4 xl:grid-cols-2">
<TopFlowChart title="Top Conversations" items={endpointTotals} />
<TopFlowChart title="Top Destinations" items={destinationTotals} />
<TopFlowChart title="Protocol Traffic" items={protocolTotals} />
<TopFlowChart title="Packet Volume" items={packetTotals} />
</div>
<section className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center justify-between gap-3">
<div className="font-medium">All Flows</div>
<div className="text-xs text-slate-500">{filteredTraffic.length} of {traffic.length} flows · {formatBytes(totalBytes(filteredTraffic))}</div>
</div>
{filteredTraffic.length ? <TrafficTable traffic={filteredTraffic} dense /> : <div className="rounded-md border border-border p-4 text-sm text-slate-500">No flows match the current filters.</div>}
</section>
</div>
</>
);
}