From 10e9406510f2acbc668bcd46a829fe1e75a8f407 Mon Sep 17 00:00:00 2001 From: nessi Date: Thu, 9 Jul 2026 15:26:15 +0200 Subject: [PATCH] feat: add workload detail page with traffic visualization, protocol distribution, and flow aggregation Add WorkloadDetail component with dedicated route for per-workload traffic analysis, implement summarizeTraffic to aggregate flows by 5-tuple with byte/packet totals and IP address collection, add TrafficBars component showing top 5 flows with horizontal bar charts, implement ProtocolChart with color-coded protocol distribution and percentage breakdown, add TrafficTable with scrollable flow list showing --- frontend/src/App.tsx | 3 +- frontend/src/pages/Workloads.tsx | 345 +++++++++++++++++++++++++++---- 2 files changed, 308 insertions(+), 40 deletions(-) diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 45943a2..9c0305f 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 { Workloads } from "./pages/Workloads"; +import { WorkloadDetail, Workloads } from "./pages/Workloads"; import { useTheme } from "./stores/theme"; const queryClient = new QueryClient(); @@ -49,6 +49,7 @@ function AppRoutes() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/frontend/src/pages/Workloads.tsx b/frontend/src/pages/Workloads.tsx index f7ee2c3..d114311 100644 --- a/frontend/src/pages/Workloads.tsx +++ b/frontend/src/pages/Workloads.tsx @@ -1,12 +1,193 @@ -import { useState } from "react"; +import { useMemo, useState } from "react"; import { useQuery } from "@tanstack/react-query"; -import { Activity, CircuitBoard, Hash, Network, ShieldCheck } from "lucide-react"; +import { Activity, ArrowRight, CircuitBoard, Hash, Network, 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 { PageHeader } from "../components/PageHeader"; +type TrafficSummary = { + key: string; + source: string; + destination: string; + protocol: string; + port: string; + bytes: number; + packets: number; + count: number; + decision: string; + interfaceName: string; + note: string; + ipAddresses: string[]; +}; + +function formatBytes(value: unknown) { + const bytes = Number(value ?? 0); + if (!Number.isFinite(bytes) || bytes <= 0) { + return "0 B"; + } + const units = ["B", "KB", "MB", "GB", "TB"]; + const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1); + return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`; +} + +function summarizeTraffic(traffic: Array>) { + const summaries = new Map(); + for (const flow of traffic) { + const source = String(flow.source ?? flow.source_ip ?? "external"); + const destination = String(flow.destination ?? flow.destination_ip ?? "external"); + 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 existing = summaries.get(key); + const bytes = Number(flow.bytes ?? 0); + const packets = Number(flow.packets ?? 0); + const ipAddresses = Array.isArray(flow.ip_addresses) ? flow.ip_addresses.map(String) : []; + if (existing) { + existing.bytes += Number.isFinite(bytes) ? bytes : 0; + existing.packets += Number.isFinite(packets) ? packets : 0; + existing.count += 1; + existing.ipAddresses = Array.from(new Set([...existing.ipAddresses, ...ipAddresses])); + continue; + } + summaries.set(key, { + key, + source, + destination, + protocol, + port, + 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 ?? ""), + ipAddresses, + }); + } + return Array.from(summaries.values()).sort((left, right) => right.bytes - left.bytes); +} + +function totalBytes(traffic: TrafficSummary[]) { + return traffic.reduce((sum, flow) => sum + flow.bytes, 0); +} + +function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) { + const top = traffic.slice(0, 5); + const max = Math.max(...top.map((flow) => flow.bytes), 1); + if (!top.length) { + return
No traffic data yet.
; + } + + return ( +
+ {top.map((flow) => ( +
+
+ {flow.source} {"->"} {flow.destination} + {formatBytes(flow.bytes)} +
+
+
+
+
+ ))} +
+ ); +} + +function ProtocolChart({ traffic }: { traffic: TrafficSummary[] }) { + const protocolTotals = Array.from( + traffic.reduce((map, flow) => map.set(flow.protocol, (map.get(flow.protocol) ?? 0) + flow.bytes), new Map()), + ).sort((left, right) => right[1] - left[1]); + const total = protocolTotals.reduce((sum, [, bytes]) => sum + bytes, 0); + const palette = ["#2dd4bf", "#60a5fa", "#f59e0b", "#f472b6", "#a78bfa"]; + + if (!total) { + return
No protocol split available.
; + } + + return ( +
+
+ {protocolTotals.map(([protocol, bytes], index) => { + const width = (bytes / total) * 100; + return
; + })} +
+
+ {protocolTotals.map(([protocol, bytes], index) => ( + + + {protocol} {formatBytes(bytes)} + + ))} +
+
+ ); +} + +function WorkloadFacts({ insight }: { insight: WorkloadInsight }) { + return ( +
+
+
Type
+
{insight.workload.kind}
+
+
+
Status
+
{insight.workload.status}
+
+
+
VMID
+
{insight.workload.external_id}
+
+
+
Decision
+
{insight.effective_decision}
+
+
+ ); +} + +function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) { + return ( +
+
+ + + + + + + + + + + + {traffic.map((flow) => ( + + + + + + + + ))} + +
FlowProtocolTrafficPacketsSeen
+
{flow.source} {"->"} {flow.destination}
+
{flow.ipAddresses.join(", ") || flow.interfaceName || "no endpoint metadata"}
+
{flow.protocol}{flow.port ? `:${flow.port}` : ""}{formatBytes(flow.bytes)}{flow.packets}{flow.count} sample{flow.count === 1 ? "" : "s"}
+
+
+ ); +} + export function Workloads() { + const navigate = useNavigate(); const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api("/vms") }); const [selectedId, setSelectedId] = useState(""); const selected = selectedId || workloads.data?.[0]?.id || ""; @@ -15,11 +196,12 @@ export function Workloads() { queryFn: () => api(`/vms/${selected}/insights`), enabled: Boolean(selected), }); + const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]); return ( <> -
+
[]} @@ -28,24 +210,41 @@ export function Workloads() { onRowClick={(row) => setSelectedId(String(row.id))} />
-