feat: add IP-based flow labels with internal/external classification and redesign workload summary sidebar with compact flow visualization

Add flow_ip_label helper to format flow endpoints with IP addresses and internal/external classification based on subnet membership, extend workload_insights response with source_label/destination_label fields showing IP addresses with context, implement endpointText helper to display formatted flow labels in UI, add CompactFlowList component showing top 3 flows with protocol
This commit is contained in:
2026-07-09 15:42:56 +02:00
parent baa0d24eb4
commit 32906bca1e
2 changed files with 73 additions and 26 deletions
+10
View File
@@ -286,6 +286,12 @@ def flow_endpoint_label(owner: Workload | None, subnets: list[Subnet], value: st
return subnet_label_for_ip(subnets, value) or "external"
def flow_ip_label(owner: Workload | None, subnets: list[Subnet], value: str) -> str:
if owner:
return f"{value} (internal)"
return f"{value} ({'internal' if subnet_label_for_ip(subnets, value) else 'external'})"
def dashboard_top_talkers(db: Session) -> list[dict[str, int | str]]:
totals: dict[str, int] = {}
workloads = db.scalars(select(Workload)).all()
@@ -966,6 +972,8 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge
{
"source": flow_endpoint_label(source_owner, known_subnets, flow.source_ip),
"destination": flow_endpoint_label(destination_owner, known_subnets, flow.destination_ip),
"source_label": flow_ip_label(source_owner, known_subnets, flow.source_ip),
"destination_label": flow_ip_label(destination_owner, known_subnets, flow.destination_ip),
"source_ip": flow.source_ip,
"destination_ip": flow.destination_ip,
"protocol": flow.protocol,
@@ -989,6 +997,8 @@ def workload_insights(workload_id: str, _: CurrentUser, db: Session = Depends(ge
{
"source": workload.name,
"destination": "network",
"source_label": f"{workload_ips[0]} (internal)" if workload_ips else workload.name,
"destination_label": "network",
"interface": item.get("interface"),
"protocol": item.get("protocol") or "interface-counter",
"port": None,
+63 -26
View File
@@ -12,6 +12,8 @@ type TrafficSummary = {
key: string;
source: string;
destination: string;
sourceLabel: string;
destinationLabel: string;
protocol: string;
port: string;
bytes: number;
@@ -38,6 +40,8 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
for (const flow of traffic) {
const source = String(flow.source ?? flow.source_ip ?? "external");
const destination = String(flow.destination ?? flow.destination_ip ?? "external");
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("|");
@@ -56,6 +60,8 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
key,
source,
destination,
sourceLabel,
destinationLabel,
protocol,
port,
bytes: Number.isFinite(bytes) ? bytes : 0,
@@ -70,6 +76,10 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
return Array.from(summaries.values()).sort((left, right) => right.bytes - left.bytes);
}
function endpointText(flow: TrafficSummary) {
return `${flow.sourceLabel} -> ${flow.destinationLabel}`;
}
function totalBytes(traffic: TrafficSummary[]) {
return traffic.reduce((sum, flow) => sum + flow.bytes, 0);
}
@@ -82,11 +92,11 @@ function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
}
return (
<div className="space-y-2">
<div className="space-y-1.5">
{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="truncate">{endpointText(flow)}</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">
@@ -94,6 +104,41 @@ function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
</div>
</div>
))}
{Array.from({ length: Math.max(5 - top.length, 0) }).map((_, index) => (
<div key={`empty-${index}`} className="grid gap-1 opacity-40">
<div className="flex items-center justify-between gap-3 text-xs text-slate-500">
<span>No additional flow</span>
<span>0 B</span>
</div>
<div className="h-2 rounded-full bg-slate-200 dark:bg-slate-800" />
</div>
))}
</div>
);
}
function CompactFlowList({ traffic }: { traffic: TrafficSummary[] }) {
const top = traffic.filter((flow) => flow.protocol !== "interface-counter").slice(0, 3);
if (!top.length) {
const fallback = traffic.find((flow) => flow.protocol === "interface-counter");
return (
<div className="rounded-md border border-border p-2 text-xs text-slate-500">
{fallback ? `Interface counter fallback: ${formatBytes(fallback.bytes)} observed.` : "No flow telemetry collected yet."}
</div>
);
}
return (
<div className="divide-y divide-border rounded-md border border-border">
{top.map((flow) => (
<div key={flow.key} className="grid grid-cols-[1fr_auto] gap-3 px-3 py-2 text-xs">
<div className="min-w-0">
<div className="truncate font-medium">{endpointText(flow)}</div>
<div className="truncate text-slate-500">{flow.protocol}{flow.port ? `:${flow.port}` : ""} · {flow.decision}</div>
</div>
<div className="shrink-0 text-right text-slate-500">{formatBytes(flow.bytes)}</div>
</div>
))}
</div>
);
}
@@ -170,7 +215,7 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
{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="font-medium">{endpointText(flow)}</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>
@@ -210,66 +255,58 @@ export function Workloads() {
onRowClick={(row) => setSelectedId(String(row.id))}
/>
</section>
<aside className="space-y-4 rounded-md border border-border bg-panel p-4">
<aside className="space-y-3 rounded-md border border-border bg-panel p-3">
<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}`)}>
<button className={`${secondaryButtonClass} h-9 px-3`} onClick={() => navigate(`/workloads/${selected}`)}>
<ArrowRight size={16} />
Details
</button>
) : null}
</div>
{insight.data ? (
<div className="space-y-4 text-sm">
<div className="space-y-3 text-sm">
<header>
<div className="text-lg font-semibold">{insight.data.workload.name}</div>
<div className="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="rounded-md border border-border bg-canvas p-2">
<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="rounded-md border border-border bg-canvas p-2">
<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="rounded-md border border-border bg-canvas p-2">
<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>
<div className="mb-1.5 flex items-center gap-2 text-sm font-medium"><Network size={15} /> Assigned IPs</div>
{insight.data.assigned_ips.length ? (
<div className="flex flex-wrap gap-2">
{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>
<div key={ip.id} className="rounded-md border border-border px-2 py-1 text-xs">
<span className="font-medium">{ip.address}</span>
<span className="ml-2 text-slate-500">{ip.subnet_cidr ?? "unknown subnet"}</span>
</div>
))}
</div>
) : (
<div className="rounded-md border border-border p-3 text-xs text-slate-500">No assigned IP address was discovered yet.</div>
<div className="rounded-md border border-border p-2 text-xs text-slate-500">No assigned IP address was discovered yet.</div>
)}
</section>
<section>
<div className="mb-2 font-medium">Top Traffic</div>
<div className="mb-1.5 text-sm font-medium">Top Traffic</div>
<TrafficBars traffic={traffic} />
</section>
<section>
<div className="mb-2 font-medium">Top Flows</div>
<div className="space-y-2">
{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>
))}
{!traffic.length ? <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div> : null}
</div>
<div className="mb-1.5 text-sm font-medium">Top Flows</div>
<CompactFlowList traffic={traffic} />
</section>
</div>
) : (