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:
@@ -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 />} />
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user