feat: add dedicated flow analytics page with filtering, aggregation charts, and enhanced traffic table
Add WorkloadFlows component with search/protocol/decision/port filters, implement FlowStatCards showing total traffic/flows/allowed/blocked/protocols, add TopFlowChart component for top conversations/destinations/protocols/packets with horizontal bars, implement aggregateBy helper to sum traffic by label function, extend TrafficSummary with sourceIp/destinationIp/sourcePort/collector/observedAt
This commit is contained in:
@@ -19,7 +19,7 @@ import { ServiceCatalog } from "./pages/ServiceCatalog";
|
|||||||
import { SetupWizard } from "./pages/SetupWizard";
|
import { SetupWizard } from "./pages/SetupWizard";
|
||||||
import { TenantsProjects } from "./pages/TenantsProjects";
|
import { TenantsProjects } from "./pages/TenantsProjects";
|
||||||
import { UsersRoles } from "./pages/UsersRoles";
|
import { UsersRoles } from "./pages/UsersRoles";
|
||||||
import { WorkloadDetail, Workloads } from "./pages/Workloads";
|
import { WorkloadDetail, WorkloadFlows, Workloads } from "./pages/Workloads";
|
||||||
import { useTheme } from "./stores/theme";
|
import { useTheme } from "./stores/theme";
|
||||||
|
|
||||||
const queryClient = new QueryClient();
|
const queryClient = new QueryClient();
|
||||||
@@ -50,6 +50,7 @@ function AppRoutes() {
|
|||||||
<Route path="nodes" element={<Nodes />} />
|
<Route path="nodes" element={<Nodes />} />
|
||||||
<Route path="workloads" element={<Workloads />} />
|
<Route path="workloads" element={<Workloads />} />
|
||||||
<Route path="workloads/:workloadId" element={<WorkloadDetail />} />
|
<Route path="workloads/:workloadId" element={<WorkloadDetail />} />
|
||||||
|
<Route path="workloads/:workloadId/flows" element={<WorkloadFlows />} />
|
||||||
<Route path="networks" element={<Networks />} />
|
<Route path="networks" element={<Networks />} />
|
||||||
<Route path="ipam" element={<Ipam />} />
|
<Route path="ipam" element={<Ipam />} />
|
||||||
<Route path="tenants" element={<TenantsProjects />} />
|
<Route path="tenants" element={<TenantsProjects />} />
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Activity, ArrowRight, CircuitBoard, Hash, Network, Shield, ShieldCheck } from "lucide-react";
|
import { Activity, ArrowRight, BarChart3, CircuitBoard, Filter, Hash, Network, Search, Shield, ShieldCheck } from "lucide-react";
|
||||||
import { Link, useNavigate, useParams } from "react-router-dom";
|
import { Link, useNavigate, useParams } from "react-router-dom";
|
||||||
|
|
||||||
import { api, Workload, WorkloadInsight } from "../api/client";
|
import { api, Workload, WorkloadInsight } from "../api/client";
|
||||||
import { DataTable } from "../components/DataTable";
|
import { DataTable } from "../components/DataTable";
|
||||||
import { secondaryButtonClass } from "../components/FormControls";
|
import { inputClass, secondaryButtonClass, selectClass } from "../components/FormControls";
|
||||||
import { PageHeader } from "../components/PageHeader";
|
import { PageHeader } from "../components/PageHeader";
|
||||||
|
|
||||||
type TrafficSummary = {
|
type TrafficSummary = {
|
||||||
@@ -14,14 +14,19 @@ type TrafficSummary = {
|
|||||||
destination: string;
|
destination: string;
|
||||||
sourceLabel: string;
|
sourceLabel: string;
|
||||||
destinationLabel: string;
|
destinationLabel: string;
|
||||||
|
sourceIp: string;
|
||||||
|
destinationIp: string;
|
||||||
protocol: string;
|
protocol: string;
|
||||||
port: string;
|
port: string;
|
||||||
|
sourcePort: string;
|
||||||
bytes: number;
|
bytes: number;
|
||||||
packets: number;
|
packets: number;
|
||||||
count: number;
|
count: number;
|
||||||
decision: string;
|
decision: string;
|
||||||
interfaceName: string;
|
interfaceName: string;
|
||||||
note: string;
|
note: string;
|
||||||
|
collector: string;
|
||||||
|
observedAt: string;
|
||||||
ipAddresses: string[];
|
ipAddresses: string[];
|
||||||
matchingFirewallRules: Array<Record<string, unknown>>;
|
matchingFirewallRules: Array<Record<string, unknown>>;
|
||||||
matchingAuditPolicies: Array<Record<string, unknown>>;
|
matchingAuditPolicies: Array<Record<string, unknown>>;
|
||||||
@@ -61,11 +66,14 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
|
|||||||
for (const flow of traffic) {
|
for (const flow of traffic) {
|
||||||
const source = String(flow.source ?? flow.source_ip ?? "external");
|
const source = String(flow.source ?? flow.source_ip ?? "external");
|
||||||
const destination = String(flow.destination ?? flow.destination_ip ?? "external");
|
const destination = String(flow.destination ?? flow.destination_ip ?? "external");
|
||||||
|
const sourceIp = String(flow.source_ip ?? "");
|
||||||
|
const destinationIp = String(flow.destination_ip ?? "");
|
||||||
const sourceLabel = String(flow.source_label ?? flow.source_ip ?? source);
|
const sourceLabel = String(flow.source_label ?? flow.source_ip ?? source);
|
||||||
const destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination);
|
const destinationLabel = String(flow.destination_label ?? flow.destination_ip ?? destination);
|
||||||
const protocol = String(flow.protocol ?? "unknown");
|
const protocol = String(flow.protocol ?? "unknown");
|
||||||
const port = String(flow.port ?? flow.destination_port ?? "");
|
const port = String(flow.port ?? flow.destination_port ?? "");
|
||||||
const key = [source, destination, protocol, port, String(flow.interface ?? "")].join("|");
|
const sourcePort = String(flow.source_port ?? "");
|
||||||
|
const key = [sourceIp || source, destinationIp || destination, protocol, sourcePort, port, String(flow.interface ?? ""), String(flow.decision ?? "")].join("|");
|
||||||
const existing = summaries.get(key);
|
const existing = summaries.get(key);
|
||||||
const bytes = Number(flow.bytes ?? 0);
|
const bytes = Number(flow.bytes ?? 0);
|
||||||
const packets = Number(flow.packets ?? 0);
|
const packets = Number(flow.packets ?? 0);
|
||||||
@@ -84,6 +92,9 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
|
|||||||
if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") {
|
if (existing.decision === "observed" && String(flow.decision ?? "observed") !== "observed") {
|
||||||
existing.decision = String(flow.decision ?? "observed");
|
existing.decision = String(flow.decision ?? "observed");
|
||||||
}
|
}
|
||||||
|
if (!existing.observedAt && flow.observed_at) {
|
||||||
|
existing.observedAt = String(flow.observed_at);
|
||||||
|
}
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
summaries.set(key, {
|
summaries.set(key, {
|
||||||
@@ -92,14 +103,19 @@ function summarizeTraffic(traffic: Array<Record<string, unknown>>) {
|
|||||||
destination,
|
destination,
|
||||||
sourceLabel,
|
sourceLabel,
|
||||||
destinationLabel,
|
destinationLabel,
|
||||||
|
sourceIp,
|
||||||
|
destinationIp,
|
||||||
protocol,
|
protocol,
|
||||||
port,
|
port,
|
||||||
|
sourcePort,
|
||||||
bytes: Number.isFinite(bytes) ? bytes : 0,
|
bytes: Number.isFinite(bytes) ? bytes : 0,
|
||||||
packets: Number.isFinite(packets) ? packets : 0,
|
packets: Number.isFinite(packets) ? packets : 0,
|
||||||
count: 1,
|
count: 1,
|
||||||
decision: String(flow.decision ?? "observed"),
|
decision: String(flow.decision ?? "observed"),
|
||||||
interfaceName: String(flow.interface ?? ""),
|
interfaceName: String(flow.interface ?? ""),
|
||||||
note: String(flow.note ?? ""),
|
note: String(flow.note ?? ""),
|
||||||
|
collector: String(flow.collector ?? ""),
|
||||||
|
observedAt: String(flow.observed_at ?? ""),
|
||||||
ipAddresses,
|
ipAddresses,
|
||||||
matchingFirewallRules,
|
matchingFirewallRules,
|
||||||
matchingAuditPolicies,
|
matchingAuditPolicies,
|
||||||
@@ -117,6 +133,10 @@ function totalBytes(traffic: TrafficSummary[]) {
|
|||||||
return traffic.reduce((sum, flow) => sum + flow.bytes, 0);
|
return traffic.reduce((sum, flow) => sum + flow.bytes, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function uniqueValues(values: string[]) {
|
||||||
|
return Array.from(new Set(values.filter(Boolean))).sort();
|
||||||
|
}
|
||||||
|
|
||||||
function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
|
function TrafficBars({ traffic }: { traffic: TrafficSummary[] }) {
|
||||||
const top = traffic.slice(0, 5);
|
const top = traffic.slice(0, 5);
|
||||||
const max = Math.max(...top.map((flow) => flow.bytes), 1);
|
const max = Math.max(...top.map((flow) => flow.bytes), 1);
|
||||||
@@ -318,10 +338,10 @@ function WorkloadFacts({ insight }: { insight: WorkloadInsight }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
|
function TrafficTable({ traffic, dense = false }: { traffic: TrafficSummary[]; dense?: boolean }) {
|
||||||
return (
|
return (
|
||||||
<div className="overflow-hidden rounded-md border border-border">
|
<div className="overflow-hidden rounded-md border border-border">
|
||||||
<div className="max-h-[460px] overflow-auto">
|
<div className={dense ? "overflow-auto" : "max-h-[460px] overflow-auto"}>
|
||||||
<table className="w-full text-left text-sm">
|
<table className="w-full text-left text-sm">
|
||||||
<thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500">
|
<thead className="sticky top-0 bg-panel text-xs uppercase text-slate-500">
|
||||||
<tr>
|
<tr>
|
||||||
@@ -335,19 +355,30 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
|
|||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{traffic.map((flow) => (
|
{traffic.map((flow) => (
|
||||||
<tr key={flow.key} className="border-t border-border">
|
<tr key={flow.key} className="border-t border-border align-top hover:bg-slate-50 dark:hover:bg-slate-900/50">
|
||||||
<td className="px-3 py-3">
|
<td className="min-w-[420px] px-3 py-3">
|
||||||
<div className="font-medium">{endpointText(flow)}</div>
|
<div className="font-medium">{endpointText(flow)}</div>
|
||||||
<div className="text-xs text-slate-500">{flow.ipAddresses.join(", ") || flow.interfaceName || "no endpoint metadata"}</div>
|
<div className="mt-1 flex flex-wrap gap-2 text-xs text-slate-500">
|
||||||
|
<span>{flow.sourceIp || flow.source}</span>
|
||||||
|
<span>-></span>
|
||||||
|
<span>{flow.destinationIp || flow.destination}</span>
|
||||||
|
{flow.collector ? <span className="rounded border border-border px-1.5">{flow.collector}</span> : null}
|
||||||
|
</div>
|
||||||
<FlowRuleContext flow={flow} />
|
<FlowRuleContext flow={flow} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3">{flow.protocol}{flow.port ? `:${flow.port}` : ""}</td>
|
<td className="whitespace-nowrap px-3 py-3">
|
||||||
|
<div>{flow.protocol}{flow.port ? `:${flow.port}` : ""}</div>
|
||||||
|
{flow.sourcePort ? <div className="text-xs text-slate-500">source {flow.sourcePort}</div> : null}
|
||||||
|
</td>
|
||||||
<td className="px-3 py-3">
|
<td className="px-3 py-3">
|
||||||
<span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span>
|
<span className={`inline-flex rounded-md border px-2 py-1 text-xs ${decisionClass(flow.decision)}`}>{flow.decision.replace("_", " ")}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-3">{formatBytes(flow.bytes)}</td>
|
<td className="whitespace-nowrap px-3 py-3 font-medium">{formatBytes(flow.bytes)}</td>
|
||||||
<td className="px-3 py-3">{flow.packets}</td>
|
<td className="whitespace-nowrap px-3 py-3">{flow.packets}</td>
|
||||||
<td className="px-3 py-3">{flow.count} sample{flow.count === 1 ? "" : "s"}</td>
|
<td className="whitespace-nowrap px-3 py-3">
|
||||||
|
<div>{flow.count} sample{flow.count === 1 ? "" : "s"}</div>
|
||||||
|
{flow.observedAt ? <div className="text-xs text-slate-500">{new Date(flow.observedAt).toLocaleString()}</div> : null}
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
</tbody>
|
</tbody>
|
||||||
@@ -357,6 +388,64 @@ function TrafficTable({ traffic }: { traffic: TrafficSummary[] }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function FlowStatCards({ traffic }: { traffic: TrafficSummary[] }) {
|
||||||
|
const blocked = traffic.filter((flow) => flow.decision.includes("block")).length;
|
||||||
|
const allowed = traffic.filter((flow) => flow.decision.includes("allow")).length;
|
||||||
|
const protocols = uniqueValues(traffic.map((flow) => flow.protocol)).length;
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
<div className="rounded-md border border-border bg-panel p-3">
|
||||||
|
<div className="text-xs text-slate-500">Total Traffic</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{formatBytes(totalBytes(traffic))}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-panel p-3">
|
||||||
|
<div className="text-xs text-slate-500">Flows</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{traffic.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-panel p-3">
|
||||||
|
<div className="text-xs text-slate-500">Allowed / Blocked</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{allowed} / {blocked}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md border border-border bg-panel p-3">
|
||||||
|
<div className="text-xs text-slate-500">Protocols</div>
|
||||||
|
<div className="mt-1 text-xl font-semibold">{protocols}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopFlowChart({ title, items }: { title: string; items: Array<{ name: string; value: number; suffix?: string }> }) {
|
||||||
|
const top = items.slice(0, 8);
|
||||||
|
const max = Math.max(...top.map((item) => item.value), 1);
|
||||||
|
return (
|
||||||
|
<section className="rounded-md border border-border bg-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2 font-medium"><BarChart3 size={17} /> {title}</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{top.length ? top.map((item) => (
|
||||||
|
<div key={item.name} className="grid gap-1">
|
||||||
|
<div className="flex items-center justify-between gap-3 text-xs">
|
||||||
|
<span className="truncate">{item.name}</span>
|
||||||
|
<span className="shrink-0 text-slate-500">{item.suffix ?? formatBytes(item.value)}</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((item.value / max) * 100, 3)}%` }} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)) : <div className="text-sm text-slate-500">No data for this filter.</div>}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateBy(traffic: TrafficSummary[], label: (flow: TrafficSummary) => string, value: (flow: TrafficSummary) => number) {
|
||||||
|
const totals = new Map<string, number>();
|
||||||
|
for (const flow of traffic) {
|
||||||
|
const key = label(flow);
|
||||||
|
totals.set(key, (totals.get(key) ?? 0) + value(flow));
|
||||||
|
}
|
||||||
|
return Array.from(totals.entries()).map(([name, total]) => ({ name, value: total })).sort((left, right) => right.value - left.value);
|
||||||
|
}
|
||||||
|
|
||||||
export function Workloads() {
|
export function Workloads() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
const workloads = useQuery({ queryKey: ["workloads"], queryFn: () => api<Workload[]>("/vms") });
|
||||||
@@ -483,8 +572,14 @@ export function WorkloadDetail() {
|
|||||||
<TrafficBars traffic={traffic} />
|
<TrafficBars traffic={traffic} />
|
||||||
</div>
|
</div>
|
||||||
<div className="rounded-md border border-border bg-panel p-4">
|
<div className="rounded-md border border-border bg-panel p-4">
|
||||||
<div className="mb-3 font-medium">Flow Table</div>
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
{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 className="font-medium">Flow Table</div>
|
||||||
|
<Link className={`${secondaryButtonClass} h-9 px-3`} to={`/workloads/${insight.data.workload.id}/flows`}>
|
||||||
|
<BarChart3 size={16} />
|
||||||
|
Flow Analytics
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
{traffic.length ? <TrafficTable traffic={traffic.slice(0, 12)} /> : <div className="rounded-md border border-border p-3 text-xs text-slate-500">No flow telemetry collected yet.</div>}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<aside className="space-y-4">
|
<aside className="space-y-4">
|
||||||
@@ -534,3 +629,108 @@ export function WorkloadDetail() {
|
|||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function WorkloadFlows() {
|
||||||
|
const { workloadId } = useParams();
|
||||||
|
const [query, setQuery] = useState("");
|
||||||
|
const [protocol, setProtocol] = useState("all");
|
||||||
|
const [decision, setDecision] = useState("all");
|
||||||
|
const [port, setPort] = useState("");
|
||||||
|
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]);
|
||||||
|
const protocols = useMemo(() => uniqueValues(traffic.map((flow) => flow.protocol)), [traffic]);
|
||||||
|
const decisions = useMemo(() => uniqueValues(traffic.map((flow) => flow.decision)), [traffic]);
|
||||||
|
const filteredTraffic = useMemo(() => {
|
||||||
|
const normalizedQuery = query.trim().toLowerCase();
|
||||||
|
const normalizedPort = port.trim();
|
||||||
|
return traffic.filter((flow) => {
|
||||||
|
const haystack = [
|
||||||
|
flow.source,
|
||||||
|
flow.destination,
|
||||||
|
flow.sourceLabel,
|
||||||
|
flow.destinationLabel,
|
||||||
|
flow.sourceIp,
|
||||||
|
flow.destinationIp,
|
||||||
|
flow.protocol,
|
||||||
|
flow.port,
|
||||||
|
flow.sourcePort,
|
||||||
|
flow.decision,
|
||||||
|
flow.collector,
|
||||||
|
].join(" ").toLowerCase();
|
||||||
|
if (normalizedQuery && !haystack.includes(normalizedQuery)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (protocol !== "all" && flow.protocol !== protocol) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (decision !== "all" && flow.decision !== decision) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (normalizedPort && flow.port !== normalizedPort && flow.sourcePort !== normalizedPort) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}, [decision, port, protocol, query, traffic]);
|
||||||
|
|
||||||
|
if (insight.isLoading) {
|
||||||
|
return <div className="rounded-md border border-border bg-panel p-8 text-sm">Loading flow analytics...</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!insight.data) {
|
||||||
|
return <div className="rounded-md border border-danger p-4 text-sm text-danger">Flow analytics could not be loaded.</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const endpointTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.bytes);
|
||||||
|
const destinationTotals = aggregateBy(filteredTraffic, (flow) => flow.destinationLabel, (flow) => flow.bytes);
|
||||||
|
const protocolTotals = aggregateBy(filteredTraffic, (flow) => flow.protocol, (flow) => flow.bytes);
|
||||||
|
const packetTotals = aggregateBy(filteredTraffic, (flow) => endpointText(flow), (flow) => flow.packets).map((item) => ({ ...item, suffix: `${item.value} packets` }));
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<PageHeader title={`${insight.data.workload.name} Flow Analytics`} subtitle="Search, filter, and inspect workload traffic decisions." />
|
||||||
|
<div className="mb-4 flex flex-wrap gap-2">
|
||||||
|
<Link className={secondaryButtonClass} to={`/workloads/${insight.data.workload.id}`}>Back to Workload</Link>
|
||||||
|
<Link className={secondaryButtonClass} to="/workloads">Back to VMs/LXCs</Link>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-4">
|
||||||
|
<FlowStatCards traffic={filteredTraffic} />
|
||||||
|
<section className="rounded-md border border-border bg-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center gap-2 font-medium"><Filter size={17} /> Filters</div>
|
||||||
|
<div className="grid gap-3 lg:grid-cols-[minmax(220px,1fr)_180px_180px_150px]">
|
||||||
|
<label className="relative">
|
||||||
|
<Search className="pointer-events-none absolute left-3 top-2.5 text-slate-500" size={16} />
|
||||||
|
<input className={`${inputClass} pl-9`} value={query} onChange={(event) => setQuery(event.target.value)} placeholder="Search IP, workload, collector, protocol..." />
|
||||||
|
</label>
|
||||||
|
<select className={selectClass} value={protocol} onChange={(event) => setProtocol(event.target.value)}>
|
||||||
|
<option value="all">All protocols</option>
|
||||||
|
{protocols.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<select className={selectClass} value={decision} onChange={(event) => setDecision(event.target.value)}>
|
||||||
|
<option value="all">All decisions</option>
|
||||||
|
{decisions.map((item) => <option key={item} value={item}>{item}</option>)}
|
||||||
|
</select>
|
||||||
|
<input className={inputClass} value={port} onChange={(event) => setPort(event.target.value)} placeholder="Port" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<div className="grid gap-4 xl:grid-cols-2">
|
||||||
|
<TopFlowChart title="Top Conversations" items={endpointTotals} />
|
||||||
|
<TopFlowChart title="Top Destinations" items={destinationTotals} />
|
||||||
|
<TopFlowChart title="Protocol Traffic" items={protocolTotals} />
|
||||||
|
<TopFlowChart title="Packet Volume" items={packetTotals} />
|
||||||
|
</div>
|
||||||
|
<section className="rounded-md border border-border bg-panel p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<div className="font-medium">All Flows</div>
|
||||||
|
<div className="text-xs text-slate-500">{filteredTraffic.length} of {traffic.length} flows · {formatBytes(totalBytes(filteredTraffic))}</div>
|
||||||
|
</div>
|
||||||
|
{filteredTraffic.length ? <TrafficTable traffic={filteredTraffic} dense /> : <div className="rounded-md border border-border p-4 text-sm text-slate-500">No flows match the current filters.</div>}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user