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
This commit is contained in:
2026-07-09 15:26:15 +02:00
parent 4ceb4489c5
commit 10e9406510
2 changed files with 308 additions and 40 deletions
+2 -1
View File
@@ -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() {
<Route path="clusters" element={<Clusters />} />
<Route path="nodes" element={<Nodes />} />
<Route path="workloads" element={<Workloads />} />
<Route path="workloads/:workloadId" element={<WorkloadDetail />} />
<Route path="networks" element={<Networks />} />
<Route path="ipam" element={<Ipam />} />
<Route path="tenants" element={<TenantsProjects />} />
+306 -39
View File
@@ -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<Record<string, unknown>>) {
const summaries = new Map<string, TrafficSummary>();
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 <div className="rounded-md border border-border p-3 text-xs text-slate-500">No traffic data yet.</div>;
}
return (
<div className="space-y-2">
{top.map((flow) => (
<div key={flow.key} className="grid gap-1">
<div className="flex items-center justify-between gap-3 text-xs">
<span className="truncate">{flow.source} {"->"} {flow.destination}</span>
<span className="shrink-0 text-slate-500">{formatBytes(flow.bytes)}</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((flow.bytes / max) * 100, 4)}%` }} />
</div>
</div>
))}
</div>
);
}
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<string, number>()),
).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 <div className="rounded-md border border-border p-3 text-xs text-slate-500">No protocol split available.</div>;
}
return (
<div className="space-y-3">
<div className="flex h-3 overflow-hidden rounded-full bg-slate-200 dark:bg-slate-800">
{protocolTotals.map(([protocol, bytes], index) => {
const width = (bytes / total) * 100;
return <div key={protocol} title={protocol} style={{ width: `${width}%`, backgroundColor: palette[index % palette.length] }} />;
})}
</div>
<div className="flex flex-wrap gap-2 text-xs">
{protocolTotals.map(([protocol, bytes], index) => (
<span key={protocol} className="inline-flex items-center gap-2 rounded-md border border-border px-2 py-1">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: palette[index % palette.length] }} />
{protocol} {formatBytes(bytes)}
</span>
))}
</div>
</div>
);
}
function WorkloadFacts({ insight }: { insight: WorkloadInsight }) {
return (
<div className="grid gap-3 md:grid-cols-4">
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><CircuitBoard size={14} /> Type</div>
<div className="mt-1 font-medium">{insight.workload.kind}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><Activity size={14} /> Status</div>
<div className="mt-1 font-medium">{insight.workload.status}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><Hash size={14} /> VMID</div>
<div className="mt-1 font-medium">{insight.workload.external_id}</div>
</div>
<div className="rounded-md border border-border bg-panel p-3">
<div className="flex items-center gap-2 text-xs text-slate-500"><ShieldCheck size={14} /> Decision</div>
<div className="mt-1 font-medium">{insight.effective_decision}</div>
</div>
</div>
);
}
function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
return (
<div className="overflow-hidden rounded-md border border-border">
<div className="max-h-[460px] overflow-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500">
<tr>
<th className="px-3 py-2 font-medium">Flow</th>
<th className="px-3 py-2 font-medium">Protocol</th>
<th className="px-3 py-2 font-medium">Traffic</th>
<th className="px-3 py-2 font-medium">Packets</th>
<th className="px-3 py-2 font-medium">Seen</th>
</tr>
</thead>
<tbody>
{traffic.map((flow) => (
<tr key={flow.key} className="border-t border-border">
<td className="px-3 py-3">
<div className="font-medium">{flow.source} {"->"} {flow.destination}</div>
<div className="text-xs text-slate-500">{flow.ipAddresses.join(", ") || flow.interfaceName || "no endpoint metadata"}</div>
</td>
<td className="px-3 py-3">{flow.protocol}{flow.port ? `:${flow.port}` : ""}</td>
<td className="px-3 py-3">{formatBytes(flow.bytes)}</td>
<td className="px-3 py-3">{flow.packets}</td>
<td className="px-3 py-3">{flow.count} sample{flow.count === 1 ? "" : "s"}</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
);
}
export function Workloads() {
const navigate = useNavigate();
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
const [selectedId, setSelectedId] = useState("");
const selected = selectedId || workloads.data?.[0]?.id || "";
@@ -15,11 +196,12 @@ export function Workloads() {
queryFn: () => api<WorkloadInsight>(`/vms/${selected}/insights`),
enabled: Boolean(selected),
});
const traffic = useMemo(() => summarizeTraffic(insight.data?.traffic ?? []), [insight.data?.traffic]);
return (
<>
<PageHeader title="VMs/LXCs" subtitle="Inspect workloads, observed traffic, and matching policy decisions." />
<div className="grid gap-4 xl:grid-cols-[1fr_440px]">
<div className="grid gap-4 xl:grid-cols-[minmax(0,1fr)_420px]">
<section className="space-y-3">
<DataTable
rows={(workloads.data ?? []) as unknown as Record<string, unknown>[]}
@@ -28,24 +210,41 @@ export function Workloads() {
onRowClick={(row) => setSelectedId(String(row.id))}
/>
</section>
<aside className="rounded-md border border-border bg-panel p-4">
<div className="mb-4 flex items-center gap-2 font-medium"><Activity size={18} /> Workload Detail</div>
<aside className="space-y-4 rounded-md border border-border bg-panel p-4">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 font-medium"><Activity size={18} /> Workload Summary</div>
{selected ? (
<button className={secondaryButtonClass} onClick={() => navigate(`/workloads/${selected}`)}>
<ArrowRight size={16} />
Details
</button>
) : null}
</div>
{insight.data ? (
<div className="space-y-4 text-sm">
<header className="rounded-md border border-border bg-canvas p-4">
<div className="mb-3 text-lg font-semibold">{insight.data.workload.name}</div>
<div className="grid gap-2 text-xs text-slate-500 sm:grid-cols-2">
<div className="flex items-center gap-2"><CircuitBoard size={14} /> Type: {insight.data.workload.kind}</div>
<div className="flex items-center gap-2"><Activity size={14} /> Status: {insight.data.workload.status}</div>
<div className="flex items-center gap-2"><Hash size={14} /> VMID: {insight.data.workload.external_id}</div>
<div className="flex items-center gap-2"><ShieldCheck size={14} /> Decision: {insight.data.effective_decision}</div>
</div>
<header>
<div className="text-lg font-semibold">{insight.data.workload.name}</div>
<div className="mt-1 text-xs text-slate-500">{insight.data.workload.kind} · {insight.data.workload.status} · VMID {insight.data.workload.external_id}</div>
</header>
<div className="grid grid-cols-3 gap-2">
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500">Traffic</div>
<div className="mt-1 font-medium">{formatBytes(totalBytes(traffic))}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500">Flows</div>
<div className="mt-1 font-medium">{traffic.length}</div>
</div>
<div className="rounded-md border border-border bg-canvas p-3">
<div className="text-xs text-slate-500">IPs</div>
<div className="mt-1 font-medium">{insight.data.assigned_ips.length}</div>
</div>
</div>
<section>
<div className="mb-2 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div>
{insight.data.assigned_ips.length ? (
<div className="flex flex-wrap gap-2">
{insight.data.assigned_ips.map((ip) => (
{insight.data.assigned_ips.slice(0, 4).map((ip) => (
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs">
<div className="font-medium">{ip.address}</div>
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</div>
@@ -53,40 +252,25 @@ export function Workloads() {
))}
</div>
) : (
<div className="rounded-md border border-border p-3 text-xs text-slate-500">No assigned IP address was discovered for this workload yet.</div>
<div className="rounded-md border border-border p-3 text-xs text-slate-500">No assigned IP address was discovered yet.</div>
)}
</section>
<section>
<div className="mb-2 font-medium">Traffic</div>
<div className="space-y-2">
{insight.data.traffic.length ? insight.data.traffic.map((flow, index) => (
<div key={index} className="rounded-md border border-border p-3">
<div>{String(flow.source)} {String(flow.destination)}</div>
<div className="text-xs text-slate-500">{String(flow.protocol)}:{String(flow.port)} · {String(flow.bytes)} bytes · {String(flow.decision)}</div>
{flow.protocol === "interface-counter" ? <div className="mt-1 text-xs text-slate-500">Interface: {String(flow.interface ?? "unknown")} · rx {String(flow.rx_bytes ?? 0)} · tx {String(flow.tx_bytes ?? 0)}</div> : null}
{Array.isArray(flow.ip_addresses) ? <div className="mt-1 text-xs text-slate-500">IPs: {flow.ip_addresses.join(", ")}</div> : null}
{flow.note ? <div className="mt-1 text-xs text-slate-500">{String(flow.note)}</div> : null}
</div>
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No real traffic telemetry has been collected yet. Install the node agent or enable a flow source to populate this section.</div>}
</div>
<div className="mb-2 font-medium">Top Traffic</div>
<TrafficBars traffic={traffic} />
</section>
<section>
<div className="mb-2 font-medium">Matching Policies</div>
<div className="mb-2 font-medium">Top Flows</div>
<div className="space-y-2">
{insight.data.matching_policies.length ? insight.data.matching_policies.map((policy) => (
<div key={policy.id} className="rounded-md border border-border p-3">
<div>{policy.name}</div>
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
{traffic.slice(0, 5).map((flow) => (
<div key={flow.key} className="rounded-md border border-border p-3">
<div className="truncate font-medium">{flow.source} {"->"} {flow.destination}</div>
<div className="text-xs text-slate-500">{flow.protocol}{flow.port ? `:${flow.port}` : ""} · {formatBytes(flow.bytes)} · {flow.decision}</div>
</div>
)) : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No matching policy for this workload yet.</div>}
))}
{!traffic.length ? <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div> : null}
</div>
</section>
{insight.data.audit_mode_notes.length ? (
<section>
<div className="mb-2 font-medium">Audit Mode</div>
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
</section>
) : null}
</div>
) : (
<div className="text-sm text-slate-500">Select a workload.</div>
@@ -96,3 +280,86 @@ export function Workloads() {
</>
);
}
export function WorkloadDetail() {
const { workloadId } = useParams();
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]);
if (insight.isLoading) {
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading workload...</div>;
}
if (!insight.data) {
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Workload details could not be loaded.</div>;
}
return (
<>
<PageHeader title={insight.data.workload.name} subtitle="Detailed workload traffic, addressing, and policy context." />
<div className="mb-4">
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
</div>
<div className="space-y-4">
<WorkloadFacts insight={insight.data} />
<div className="grid gap-4 xl:grid-cols-[1fr_360px]">
<section className="space-y-4">
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center justify-between">
<div className="font-medium">Traffic Distribution</div>
<div className="text-xs text-slate-500">{traffic.length} aggregated flows · {formatBytes(totalBytes(traffic))}</div>
</div>
<TrafficBars traffic={traffic} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Flow Table</div>
{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>
</section>
<aside className="space-y-4">
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Protocol Split</div>
<ProtocolChart traffic={traffic} />
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 flex items-center gap-2 font-medium"><Network size={16} /> Assigned IPs</div>
<div className="space-y-2">
{insight.data.assigned_ips.map((ip) => (
<div key={ip.id} className="rounded-md border border-border px-3 py-2 text-xs">
<div className="font-medium">{ip.address}</div>
<div className="text-slate-500">{ip.subnet_cidr ?? "unknown subnet"} · {ip.status}</div>
</div>
))}
{!insight.data.assigned_ips.length ? <div className="text-xs text-slate-500">No assigned IPs discovered.</div> : null}
</div>
</div>
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Matching Policies</div>
<div className="space-y-2">
{insight.data.matching_policies.map((policy) => (
<div key={policy.id} className="rounded-md border border-border p-3 text-sm">
<div className="font-medium">{policy.name}</div>
<div className="text-xs text-slate-500">v{policy.version} · {policy.enforcement_mode}</div>
</div>
))}
{!insight.data.matching_policies.length ? <div className="text-xs text-slate-500">No matching policies.</div> : null}
</div>
</div>
{insight.data.audit_mode_notes.length ? (
<div className="rounded-md border border-border bg-panel p-4">
<div className="mb-3 font-medium">Audit Mode</div>
<div className="space-y-2">
{insight.data.audit_mode_notes.map((note) => <div key={note} className="rounded-md border border-border p-3 text-xs">{note}</div>)}
</div>
</div>
) : null}
</aside>
</div>
</div>
</>
);
}