import { Empty } from 'antd'; import * as d3 from 'd3'; import { sankey as d3Sankey, sankeyLinkHorizontal, type SankeyGraph, type SankeyLink, type SankeyNode, } from 'd3-sankey'; import { useEffect, useMemo, useRef } from 'react'; import type { InterfaceProtocolPathEvidence } from '../../types/analysis'; import { buildDirectionalSankeyData, clamp, endpointText, formatTimestamp, protocolColor, scaleVisualFor, sankeyVisualWeight, useResponsiveChartSize, type TopologyData, type TopologyLink, type TopologyNode, } from './shared.tsx'; type ForceNode = d3.SimulationNodeDatum & TopologyNode; type ForceLink = d3.SimulationLinkDatum & TopologyLink; type SankeyNodeDatum = SankeyNode & TopologyNode; type SankeyLinkDatum = SankeyLink & TopologyLink; export function SankeyTopology({ data }: { data: TopologyData }) { const scaleVisual = (value: number) => scaleVisualFor('sankey', value); const svgRef = useRef(null); const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('sankey'); const maxNodesInLayer = useMemo( () => Math.max( data.nodes.filter((node) => node.kind === 'interface').length, data.nodes.filter((node) => node.kind === 'host').length, data.nodes.filter((node) => node.kind === 'ethernet').length, data.nodes.filter((node) => node.kind === 'ip').length, data.nodes.filter((node) => node.kind === 'protocol').length, 1, ), [data], ); const svgHeight = fitHeight(scaleVisual(maxNodesInLayer * 60 + 72)); useEffect(() => { if (!svgRef.current) return; const measuredWidth = svgRef.current.parentElement?.getBoundingClientRect().width ?? chartWidth; if (measuredWidth <= 0) return; const width = Math.floor(measuredWidth); const height = svgHeight; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); svg.attr('viewBox', `0 0 ${width} ${height}`); if (data.nodes.length === 0 || data.links.length === 0) { return; } const graph: SankeyGraph = { nodes: data.nodes.map((node) => ({ ...node })), links: data.links.map((link) => ({ ...link })), }; const sankeyLayout = d3Sankey() .nodeId((node) => node.id) .nodeWidth(scaleVisual(14)) .nodePadding(scaleVisual(maxNodesInLayer <= 4 ? 18 : maxNodesInLayer <= 8 ? 14 : 10)) .extent([ [scaleVisual(18), scaleVisual(20)], [width - scaleVisual(18), height - scaleVisual(20)], ]); const layout = sankeyLayout(graph); const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.4); linkLayer .selectAll('path') .data(layout.links as SankeyLinkDatum[]) .join('path') .attr('d', sankeyLinkHorizontal()) .attr('stroke', (link) => { const target = link.target as SankeyNodeDatum; if ((target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol) { return protocolColor(target.protocol); } return '#9aa7b5'; }) .attr('stroke-width', (link) => Math.max(scaleVisual(1), link.width || scaleVisual(1))) .append('title') .text((link) => `${link.label}\nPackets: ${link.packetCount}`); const nodeLayer = svg.append('g'); const node = nodeLayer .selectAll('g') .data(layout.nodes as SankeyNodeDatum[]) .join('g'); node .append('rect') .attr('x', (d) => d.x0 ?? 0) .attr('y', (d) => d.y0 ?? 0) .attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0)) .attr('height', (d) => Math.max(scaleVisual(8), (d.y1 ?? 0) - (d.y0 ?? 0))) .attr('fill', (d) => { if (d.kind === 'interface') return '#20405d'; if (d.kind === 'host') return '#d7e7f5'; if (d.kind === 'ethernet') return '#d7c09c'; if (d.kind === 'ip') return '#a8c8df'; return d.protocol ? protocolColor(d.protocol) : '#d8d8d8'; }) .attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none')) .append('title') .text((d) => `${d.label}\nPackets: ${d.packetCount}`); node .append('text') .attr('x', (d) => ((d.x0 ?? 0) < width / 2 ? (d.x1 ?? 0) + scaleVisual(6) : (d.x0 ?? 0) - scaleVisual(6))) .attr('y', (d) => ((d.y0 ?? 0) + (d.y1 ?? 0)) / 2) .attr('dy', '0.35em') .attr('text-anchor', (d) => ((d.x0 ?? 0) < width / 2 ? 'start' : 'end')) .attr('font-size', scaleVisual(11)) .attr('font-weight', (d) => (d.kind === 'interface' ? 700 : 500)) .attr('fill', '#22374f') .text((d) => (d.kind === 'host' ? (d.ipAddress ?? d.macAddress ?? d.label) : d.label)); }, [chartWidth, data, maxNodesInLayer, svgHeight]); if (data.nodes.length === 0 || data.links.length === 0) { return ; } return (
); } export function PacketPathLanes({ paths, includeEthernetLayer, includeIpLayer, }: { paths: InterfaceProtocolPathEvidence[]; includeEthernetLayer: boolean; includeIpLayer: boolean; }) { const scaleVisual = (value: number) => scaleVisualFor('parallel', value); const svgRef = useRef(null); const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('parallel'); const axisDefinitions = useMemo(() => { const stages = [ { id: 'src', label: 'Source IP/MAC', kind: 'endpoint' as const, value: (path: InterfaceProtocolPathEvidence) => endpointText(path.src_ip_address, path.src_mac_address), }, { id: 'ingress', label: 'Ingress', kind: 'interface' as const, value: (path: InterfaceProtocolPathEvidence) => path.ingress_interface ?? 'Unknown ingress', }, ...(includeEthernetLayer ? [ { id: 'ethernet', label: 'Ethernet', kind: 'ethernet' as const, value: (path: InterfaceProtocolPathEvidence) => path.ethernet_protocol ?? 'Unknown ethernet', }, ] : []), ...(includeIpLayer ? [ { id: 'ip', label: 'Internet Protocol', kind: 'ip' as const, value: (path: InterfaceProtocolPathEvidence) => path.ip_protocol ?? 'Unknown ip', }, ] : []), { id: 'protocol', label: 'App Protocol', kind: 'protocol' as const, value: (path: InterfaceProtocolPathEvidence) => path.protocol, }, { id: 'egress', label: 'Egress', kind: 'interface' as const, value: (path: InterfaceProtocolPathEvidence) => path.egress_interface ?? 'Unknown egress', }, { id: 'dst', label: 'Destination IP/MAC', kind: 'endpoint' as const, value: (path: InterfaceProtocolPathEvidence) => endpointText(path.dst_ip_address, path.dst_mac_address), }, ]; return stages.map((stage) => { const counts = new Map(); for (const path of paths) { const label = stage.value(path); counts.set(label, (counts.get(label) ?? 0) + path.packet_count); } const categories = Array.from(counts.entries()) .sort((left, right) => right[1] - left[1] || left[0].localeCompare(right[0])) .map(([label, packetCount]) => ({ label, packetCount })); return { ...stage, categories }; }); }, [paths, includeEthernetLayer, includeIpLayer]); const maxCategories = useMemo( () => Math.max(...axisDefinitions.map((axis) => axis.categories.length), 1), [axisDefinitions], ); const svgHeight = fitHeight(scaleVisual(maxCategories * 24 + 128)); useEffect(() => { if (!svgRef.current) return; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); if (paths.length === 0 || axisDefinitions.length === 0) { return; } const width = chartWidth; const height = svgHeight; const sideMargin = clamp(width * 0.1, scaleVisual(48), scaleVisual(110)); const margin = { top: scaleVisual(40), right: sideMargin, bottom: scaleVisual(24), left: sideMargin }; svg.attr('width', width).attr('height', height); svg.attr('viewBox', `0 0 ${width} ${height}`); const x = d3 .scalePoint() .domain(axisDefinitions.map((axis) => axis.id)) .range([margin.left, width - margin.right]) .padding(0.35); const yByAxis = new Map>(); for (const axis of axisDefinitions) { yByAxis.set( axis.id, d3 .scalePoint() .domain(axis.categories.map((category) => category.label)) .range([margin.top + scaleVisual(18), height - margin.bottom - scaleVisual(18)]) .padding(0.45), ); } svg .append('rect') .attr('x', 0) .attr('y', 0) .attr('width', width) .attr('height', height) .attr('rx', scaleVisual(18)) .attr('fill', '#fbfcfe'); const lineGenerator = d3 .line<{ x: number; y: number }>() .x((point) => point.x) .y((point) => point.y) .curve(d3.curveMonotoneX); const lineLayer = svg.append('g').attr('fill', 'none'); const lineSelection = lineLayer .selectAll('path.path-line') .data(paths) .join('path') .attr('class', 'path-line') .attr('d', (path) => { const points = axisDefinitions .map((axis) => { const axisX = x(axis.id); const axisY = yByAxis.get(axis.id)?.(axis.value(path)); if (axisX == null || axisY == null) return null; return { x: axisX, y: axisY }; }) .filter((point): point is { x: number; y: number } => point !== null); return lineGenerator(points) ?? ''; }) .attr('stroke', (path) => protocolColor(path.protocol)) .attr('stroke-opacity', (path) => clamp(0.14 + Math.log10(Math.max(path.packet_count, 1)) * 0.08, 0.14, 0.5)) .attr('stroke-width', (path) => clamp(Math.sqrt(Math.max(path.packet_count, 1)) * scaleVisual(1.1), scaleVisual(2.2), scaleVisual(8)), ); lineSelection .append('title') .text((path) => [ `${endpointText(path.src_ip_address, path.src_mac_address)} -> ${path.ingress_interface ?? 'Unknown ingress'}`, `${path.protocol} -> ${path.egress_interface ?? 'Unknown egress'}`, `${endpointText(path.dst_ip_address, path.dst_mac_address)}`, `Packets: ${path.packet_count}`, `Last seen: ${formatTimestamp(path.last_seen)}`, ].join('\n'), ); const axisLayer = svg.append('g'); const activeFilters = new Map>(); const pathMatchesFilters = (path: InterfaceProtocolPathEvidence) => axisDefinitions.every((axis) => { const allowed = activeFilters.get(axis.id); if (allowed == null || allowed.size === 0) return true; return allowed.has(axis.value(path)); }); const updateLineStyles = () => { lineSelection .attr('stroke-opacity', (path) => { const matches = pathMatchesFilters(path); if (matches) return clamp(0.2 + Math.log10(Math.max(path.packet_count, 1)) * 0.1, 0.2, 0.7); return 0.035; }) .attr('stroke-width', (path) => { if (!pathMatchesFilters(path)) return scaleVisual(1.2); return clamp( Math.sqrt(Math.max(path.packet_count, 1)) * scaleVisual(1.25), scaleVisual(2.4), scaleVisual(9), ); }); }; for (const [axisIndex, axis] of axisDefinitions.entries()) { const axisX = x(axis.id); const yScale = yByAxis.get(axis.id); if (axisX == null || yScale == null) continue; axisLayer .append('line') .attr('x1', axisX) .attr('x2', axisX) .attr('y1', margin.top) .attr('y2', height - margin.bottom) .attr('stroke', '#b8c6d5') .attr('stroke-width', scaleVisual(2)); axisLayer .append('text') .attr('x', axisX) .attr('y', margin.top - scaleVisual(14)) .attr('text-anchor', 'middle') .attr('font-size', scaleVisual(12)) .attr('font-weight', 700) .attr('fill', '#42586f') .text(axis.label); const labelAnchor = axisIndex < axisDefinitions.length / 2 ? 'end' : 'start'; const labelOffset = labelAnchor === 'end' ? -scaleVisual(10) : scaleVisual(10); axisLayer .selectAll(`circle.axis-${axis.id}`) .data(axis.categories) .join('circle') .attr('cx', axisX) .attr('cy', (category) => yScale(category.label) ?? height / 2) .attr('r', (category) => clamp(Math.sqrt(Math.max(category.packetCount, 1)) * scaleVisual(0.28), scaleVisual(3), scaleVisual(7)), ) .attr('fill', axis.kind === 'protocol' ? '#35566f' : axis.kind === 'interface' ? '#20405d' : '#8eaac4') .attr('opacity', 0.95); axisLayer .selectAll(`text.axis-label-${axis.id}`) .data(axis.categories) .join('text') .attr('x', axisX + labelOffset) .attr('y', (category) => (yScale(category.label) ?? height / 2) + scaleVisual(4)) .attr('text-anchor', labelAnchor) .attr('font-size', scaleVisual(11)) .attr('fill', '#41566d') .text((category) => category.label); const brush = d3 .brushY() .extent([ [axisX - scaleVisual(22), margin.top], [axisX + scaleVisual(22), height - margin.bottom], ]) .on('brush end', (event) => { const selection = event.selection as [number, number] | null; if (selection == null) { activeFilters.delete(axis.id); updateLineStyles(); return; } const [y0, y1] = selection[0] <= selection[1] ? selection : [selection[1], selection[0]]; const labels = axis.categories .filter((category) => { const yValue = yScale(category.label); return yValue != null && yValue >= y0 && yValue <= y1; }) .map((category) => category.label); activeFilters.set(axis.id, new Set(labels)); updateLineStyles(); }); const brushGroup = axisLayer.append('g').attr('class', `brush brush-${axis.id}`).call(brush); brushGroup.selectAll('.selection').attr('fill', '#8fb7d8').attr('fill-opacity', 0.18).attr('stroke', '#4f7ba3'); brushGroup.selectAll('.handle').attr('fill', '#4f7ba3').attr('fill-opacity', 0.9); } updateLineStyles(); }, [axisDefinitions, chartWidth, paths, svgHeight]); if (paths.length === 0) { return ; } return (
); } export function ForceTopology({ data }: { data: TopologyData }) { const scaleVisual = (value: number) => scaleVisualFor('force', value); const svgRef = useRef(null); const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('force'); const svgHeight = fitHeight(scaleVisual(Math.max(300, data.nodes.length * 18 + 180))); useEffect(() => { if (!svgRef.current) return; const width = chartWidth; const height = svgHeight; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); svg.attr('width', width).attr('height', height); svg.attr('viewBox', `0 0 ${width} ${height}`); if (data.nodes.length === 0 || data.links.length === 0) { return; } const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node })); const links: ForceLink[] = data.links.map((link) => ({ ...link })); const groupedNodes = { interface: nodes .filter((node) => node.kind === 'interface') .sort((left, right) => left.label.localeCompare(right.label)), host: nodes .filter((node) => node.kind === 'host') .sort((left, right) => (left.ipAddress ?? left.macAddress ?? left.label).localeCompare( right.ipAddress ?? right.macAddress ?? right.label, ), ), ethernet: nodes .filter((node) => node.kind === 'ethernet') .sort((left, right) => left.label.localeCompare(right.label)), ip: nodes.filter((node) => node.kind === 'ip').sort((left, right) => left.label.localeCompare(right.label)), protocol: nodes .filter((node) => node.kind === 'protocol') .sort((left, right) => left.label.localeCompare(right.label)), }; const distributedY = (group: ForceNode[], top: number, bottom: number) => { const targets = new Map(); if (group.length === 0) { return targets; } if (group.length === 1) { targets.set(group[0].id, (top + bottom) / 2); return targets; } const step = (bottom - top) / (group.length - 1); group.forEach((node, index) => targets.set(node.id, top + step * index)); return targets; }; const interfaceY = distributedY(groupedNodes.interface, scaleVisual(120), height - scaleVisual(120)); const hostY = distributedY(groupedNodes.host, scaleVisual(90), height - scaleVisual(90)); const ethernetY = distributedY(groupedNodes.ethernet, scaleVisual(120), height - scaleVisual(120)); const ipY = distributedY(groupedNodes.ip, scaleVisual(120), height - scaleVisual(120)); const protocolY = distributedY(groupedNodes.protocol, scaleVisual(120), height - scaleVisual(120)); const targetY = (node: ForceNode) => interfaceY.get(node.id) ?? hostY.get(node.id) ?? ethernetY.get(node.id) ?? ipY.get(node.id) ?? protocolY.get(node.id) ?? height / 2; const simulation = d3 .forceSimulation(nodes) .force( 'link', d3 .forceLink(links) .id((node) => node.id) .distance((link) => ((link.source as ForceNode).kind === 'interface' ? scaleVisual(250) : scaleVisual(200))) .strength((link) => ((link.source as ForceNode).kind === 'interface' ? 0.45 : 0.35)), ) .force('charge', d3.forceManyBody().strength(-scaleVisual(720))) .force( 'collision', d3.forceCollide().radius((node) => { if (node.kind === 'interface') return scaleVisual(52); if (node.kind === 'host') return scaleVisual(44); if (node.kind === 'ethernet') return scaleVisual(36); if (node.kind === 'ip') return scaleVisual(35); return scaleVisual(34); }), ) .force( 'x', d3 .forceX() .x((node) => { if (node.kind === 'interface') return width * 0.14; if (node.kind === 'host') return width * 0.34; if (node.kind === 'ethernet') return width * 0.54; if (node.kind === 'ip') return width * 0.72; return width * 0.88; }) .strength(0.42), ) .force( 'y', d3 .forceY() .y((node) => targetY(node)) .strength(0.22), ) .force('center', d3.forceCenter(width / 2, height / 2).strength(0.06)); svg .append('rect') .attr('x', 0) .attr('y', 0) .attr('width', width) .attr('height', height) .attr('rx', scaleVisual(18)) .attr('fill', '#fbfcfe'); const link = svg .append('g') .attr('stroke-opacity', 0.45) .selectAll('line') .data(links) .join('line') .attr('stroke', (d) => { const target = d.target as ForceNode; return (target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol ? protocolColor(target.protocol) : '#92a1b2'; }) .attr('stroke-width', (d) => scaleVisual(sankeyVisualWeight(d.packetCount))); link.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`); const node = svg.append('g').selectAll('g').data(nodes).join('g'); node .append('circle') .attr('r', (d) => { if (d.kind === 'interface') return scaleVisual(26); if (d.kind === 'host') return scaleVisual(22); if (d.kind === 'ethernet') return scaleVisual(19); if (d.kind === 'ip') return scaleVisual(18); return scaleVisual(18); }) .attr('fill', (d) => { if (d.kind === 'interface') return '#20405d'; if (d.kind === 'host') return '#d7e7f5'; if (d.kind === 'ethernet') return '#d7c09c'; if (d.kind === 'ip') return '#a8c8df'; return d.protocol ? protocolColor(d.protocol) : '#cfd7df'; }) .attr('stroke', (d) => (d.kind === 'host' ? '#8aa8c6' : '#ffffff')) .attr('stroke-width', scaleVisual(2)); node .append('text') .attr('text-anchor', 'middle') .attr('dy', scaleVisual(40)) .attr('font-size', scaleVisual(11)) .attr('font-weight', 600) .attr('fill', '#29445d') .text((d) => (d.kind === 'host' ? (d.ipAddress ?? d.macAddress ?? 'host') : d.label)); node.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`); simulation.on('tick', () => { link .attr('x1', (d) => (d.source as ForceNode).x ?? 0) .attr('y1', (d) => (d.source as ForceNode).y ?? 0) .attr('x2', (d) => (d.target as ForceNode).x ?? 0) .attr('y2', (d) => (d.target as ForceNode).y ?? 0); node.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`); }); return () => simulation.stop(); }, [chartWidth, data, svgHeight]); if (data.nodes.length === 0 || data.links.length === 0) { return ; } return (
); } export function ProtocolHeatmap({ data }: { data: TopologyData }) { const scaleVisual = (value: number) => scaleVisualFor('heatmap', value); const svgRef = useRef(null); const { containerRef, chartWidth, fitHeight } = useResponsiveChartSize('heatmap'); const maxProtocolLabelLength = useMemo( () => d3.max(data.protocols, (protocol) => protocol.length) ?? 0, [data.protocols], ); const topMargin = clamp( scaleVisual(maxProtocolLabelLength * 3.1 + 24), scaleVisual(56), scaleVisual(96), ); const svgHeight = fitHeight(Math.max(scaleVisual(320), topMargin + scaleVisual(54 + data.heatmapRows.length * 34))); useEffect(() => { if (!svgRef.current) return; const maxHostLabelLength = d3.max(data.heatmapRows, (row) => row.hostLabel.length) ?? 0; const margin = { top: topMargin, right: scaleVisual(22), bottom: scaleVisual(24), left: clamp(scaleVisual(maxHostLabelLength * 6.4 + 18), scaleVisual(150), scaleVisual(280)), }; const usableWidth = chartWidth; const cellHeight = scaleVisual(34); const width = usableWidth; const height = svgHeight; const svg = d3.select(svgRef.current); svg.selectAll('*').remove(); svg.attr('width', width).attr('height', height); svg.attr('viewBox', `0 0 ${width} ${height}`); if (data.protocols.length === 0 || data.heatmapRows.length === 0) { return; } const x = d3 .scaleBand() .domain(data.protocols) .range([margin.left, width - margin.right]) .paddingInner(0.08); const y = d3 .scaleBand() .domain(data.heatmapRows.map((row) => row.hostId)) .range([margin.top, height - margin.bottom]) .paddingInner(0.08); const maxValue = d3.max(data.heatmapRows.flatMap((row) => data.protocols.map((protocol) => row.values[protocol] || 0))) ?? 1; const color = d3.scaleSequential(d3.interpolateYlGnBu).domain([0, maxValue]); svg .append('rect') .attr('x', 0) .attr('y', 0) .attr('width', width) .attr('height', height) .attr('rx', scaleVisual(18)) .attr('fill', '#fbfcfe'); const cells = svg.append('g'); for (const row of data.heatmapRows) { for (const protocol of data.protocols) { const value = row.values[protocol] || 0; const cell = cells.append('g').attr('transform', `translate(${x(protocol) ?? 0},${y(row.hostId) ?? 0})`); cell .append('rect') .attr('width', x.bandwidth()) .attr('height', y.bandwidth()) .attr('rx', scaleVisual(8)) .attr('fill', value > 0 ? color(value) : '#eef3f8') .attr('stroke', '#dce5ef'); if (value > 0) { cell .append('text') .attr('x', x.bandwidth() / 2) .attr('y', y.bandwidth() / 2 + scaleVisual(4)) .attr('text-anchor', 'middle') .attr('font-size', scaleVisual(11)) .attr('font-weight', 700) .attr('fill', value > maxValue * 0.45 ? '#ffffff' : '#23415c') .text(value); } cell.append('title').text(`${row.hostLabel}\n${protocol}: ${value} packets`); } } svg .append('g') .selectAll('text.protocol-label') .data(data.protocols) .join('text') .attr('class', 'protocol-label') .attr('x', (protocol) => (x(protocol) ?? 0) + x.bandwidth() / 2) .attr('y', margin.top - scaleVisual(12)) .attr('transform', (protocol) => `rotate(-35, ${(x(protocol) ?? 0) + x.bandwidth() / 2}, ${margin.top - scaleVisual(12)})`) .attr('text-anchor', 'start') .attr('font-size', scaleVisual(12)) .attr('font-weight', 600) .attr('fill', '#29445d') .text((protocol) => protocol); svg .append('g') .selectAll('text.host-label') .data(data.heatmapRows) .join('text') .attr('class', 'host-label') .attr('x', margin.left - scaleVisual(12)) .attr('y', (row) => (y(row.hostId) ?? 0) + y.bandwidth() / 2 + scaleVisual(4)) .attr('text-anchor', 'end') .attr('font-size', scaleVisual(12)) .attr('fill', '#29445d') .text((row) => row.hostLabel); }, [chartWidth, data, svgHeight, topMargin]); if (data.protocols.length === 0 || data.heatmapRows.length === 0) { return ; } return (
); } export function DirectionalSankeyTopology({ paths, includeEthernetLayer, includeIpLayer, }: { paths: InterfaceProtocolPathEvidence[]; includeEthernetLayer: boolean; includeIpLayer: boolean; }) { const topologyData = useMemo( () => buildDirectionalSankeyData(paths, { includeEthernetLayer, includeIpLayer }), [paths, includeEthernetLayer, includeIpLayer], ); return ; }