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:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
Reference in New Issue
Block a user