From 8c59ab32d59c0d32ade3be4fd9cf48be3d7a3e5d Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 22:52:14 +0200 Subject: [PATCH] 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 --- frontend/src/App.tsx | 3 +- frontend/src/pages/Workloads.tsx | 228 +++++++++++++++++++++++++++++-- 2 files changed, 216 insertions(+), 15 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 9c0305f..45680dc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,7 +19,7 @@ import { ServiceCatalog } from "./pages/ServiceCatalog"; import { SetupWizard } from "./pages/SetupWizard"; import { TenantsProjects } from "./pages/TenantsProjects"; import { UsersRoles } from "./pages/UsersRoles"; -import { WorkloadDetail, Workloads } from "./pages/Workloads"; +import { WorkloadDetail, WorkloadFlows, Workloads } from "./pages/Workloads"; import { useTheme } from "./stores/theme"; const queryClient = new QueryClient(); @@ -50,6 +50,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Workloads.tsx b/frontend/src/pages/Workloads.tsx index 02ba0db..e559585 100644 --- a/frontend/src/pages/Workloads.tsx +++ b/frontend/src/pages/Workloads.tsx @@ -1,11 +1,11 @@ import { useMemo, useState } from "react"; 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 { api, Workload, WorkloadInsight } from "../api/client"; import { DataTable } from "../components/DataTable"; -import { secondaryButtonClass } from "../components/FormControls"; +import { inputClass, secondaryButtonClass, selectClass } from "../components/FormControls"; import { PageHeader } from "../components/PageHeader"; type TrafficSummary = { @@ -14,14 +14,19 @@ type TrafficSummary = { destination: string; sourceLabel: string; destinationLabel: string; + sourceIp: string; + destinationIp: string; protocol: string; port: string; + sourcePort: string; bytes: number; packets: number; count: number; decision: string; interfaceName: string; note: string; + collector: string; + observedAt: string; ipAddresses: string[]; matchingFirewallRules: Array>; matchingAuditPolicies: Array>; @@ -61,11 +66,14 @@ function summarizeTraffic(traffic: Array>) { for (const flow of traffic) { const source = String(flow.source ?? flow.source_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 destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination); const protocol = String(flow.protocol ?? "unknown"); 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 bytes = Number(flow.bytes ?? 0); const packets = Number(flow.packets ?? 0); @@ -84,6 +92,9 @@ function summarizeTraffic(traffic: Array>) { if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") { existing.decision = String(flow.decision ?? "observed"); } + if (!existing.observedAt && flow.observed_at) { + existing.observedAt = String(flow.observed_at); + } continue; } summaries.set(key, { @@ -92,14 +103,19 @@ function summarizeTraffic(traffic: Array>) { destination, sourceLabel, destinationLabel, + sourceIp, + destinationIp, protocol, port, + sourcePort, bytes: Number.isFinite(bytes) ? bytes : 0, packets: Number.isFinite(packets) ? packets : 0, count: 1, decision: String(flow.decision ?? "observed"), interfaceName: String(flow.interface ?? ""), note: String(flow.note ?? ""), + collector: String(flow.collector ?? ""), + observedAt: String(flow.observed_at ?? ""), ipAddresses, matchingFirewallRules, matchingAuditPolicies, @@ -117,6 +133,10 @@ function totalBytes(traffic: TrafficSummary[]) { 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[] }) { const top = traffic.slice(0, 5); 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 (
-
+
@@ -335,19 +355,30 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) { {traffic.map((flow) => ( - - + - + - - - + + + ))} @@ -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 ( +
+
+
Total Traffic
+
{formatBytes(totalBytes(traffic))}
+
+
+
Flows
+
{traffic.length}
+
+
+
Allowed / Blocked
+
{allowed} / {blocked}
+
+
+
Protocols
+
{protocols}
+
+
+ ); +} + +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 ( +
+
{title}
+
+ {top.length ? top.map((item) => ( +
+
+ {item.name} + {item.suffix ?? formatBytes(item.value)} +
+
+
+
+
+ )) :
No data for this filter.
} +
+
+ ); +} + +function aggregateBy(traffic: TrafficSummary[], label: (flow: TrafficSummary) => string, value: (flow: TrafficSummary) => number) { + const totals = new Map(); + 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() { const navigate = useNavigate(); const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api("/vms") }); @@ -483,8 +572,14 @@ export function WorkloadDetail() {
-
Flow Table
- {traffic.length ? :
No flow telemetry collected yet.
} +
+
Flow Table
+ + + Flow Analytics + +
+ {traffic.length ? :
No flow telemetry collected yet.
}
+
{endpointText(flow)}
-
{flow.ipAddresses.join(", ") || flow.interfaceName || "no endpoint metadata"}
+
+ {flow.sourceIp || flow.source} + -> + {flow.destinationIp || flow.destination} + {flow.collector ? {flow.collector} : null} +
{flow.protocol}{flow.port ? `:${flow.port}` : ""} +
{flow.protocol}{flow.port ? `:${flow.port}` : ""}
+ {flow.sourcePort ?
source {flow.sourcePort}
: null} +
{flow.decision.replace("_", " ")} {formatBytes(flow.bytes)}{flow.packets}{flow.count} sample{flow.count === 1 ? "" : "s"}{formatBytes(flow.bytes)}{flow.packets} +
{flow.count} sample{flow.count === 1 ? "" : "s"}
+ {flow.observedAt ?
{new Date(flow.observedAt).toLocaleString()}
: null} +