add visualizations, better scaling
This commit is contained in:
769
frontend/src/components/analysis/TopologyViews.tsx
Normal file
769
frontend/src/components/analysis/TopologyViews.tsx
Normal file
@@ -0,0 +1,769 @@
|
||||
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<ForceNode> & TopologyLink;
|
||||
type SankeyNodeDatum = SankeyNode<TopologyNode, TopologyLink> & TopologyNode;
|
||||
type SankeyLinkDatum = SankeyLink<TopologyNode, TopologyLink> & TopologyLink;
|
||||
|
||||
export function SankeyTopology({ data }: { data: TopologyData }) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('sankey', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(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<TopologyNode, TopologyLink> = {
|
||||
nodes: data.nodes.map((node) => ({ ...node })),
|
||||
links: data.links.map((link) => ({ ...link })),
|
||||
};
|
||||
|
||||
const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>()
|
||||
.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 <Empty description="No interface, host, and protocol relationships found yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: 'auto', display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PacketPathLanes({
|
||||
paths,
|
||||
includeEthernetLayer,
|
||||
includeIpLayer,
|
||||
}: {
|
||||
paths: InterfaceProtocolPathEvidence[];
|
||||
includeEthernetLayer: boolean;
|
||||
includeIpLayer: boolean;
|
||||
}) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('parallel', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(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<string, number>();
|
||||
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<string>()
|
||||
.domain(axisDefinitions.map((axis) => axis.id))
|
||||
.range([margin.left, width - margin.right])
|
||||
.padding(0.35);
|
||||
|
||||
const yByAxis = new Map<string, d3.ScalePoint<string>>();
|
||||
for (const axis of axisDefinitions) {
|
||||
yByAxis.set(
|
||||
axis.id,
|
||||
d3
|
||||
.scalePoint<string>()
|
||||
.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<string, Set<string>>();
|
||||
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 <Empty description="No packet path view available yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: `${svgHeight}px`, display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ForceTopology({ data }: { data: TopologyData }) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('force', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(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<string, number>();
|
||||
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<ForceNode>(nodes)
|
||||
.force(
|
||||
'link',
|
||||
d3
|
||||
.forceLink<ForceNode, 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<ForceNode>().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<ForceNode>()
|
||||
.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<ForceNode>()
|
||||
.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 <Empty description="No graph data available yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: `${svgHeight}px`, display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ProtocolHeatmap({ data }: { data: TopologyData }) {
|
||||
const scaleVisual = (value: number) => scaleVisualFor('heatmap', value);
|
||||
const svgRef = useRef<SVGSVGElement | null>(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<string>()
|
||||
.domain(data.protocols)
|
||||
.range([margin.left, width - margin.right])
|
||||
.paddingInner(0.08);
|
||||
const y = d3
|
||||
.scaleBand<string>()
|
||||
.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 <Empty description="No protocol heatmap data available yet" />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef} style={{ width: '100%', overflow: 'hidden' }}>
|
||||
<svg ref={svgRef} style={{ width: '100%', height: 'auto', display: 'block' }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DirectionalSankeyTopology({
|
||||
paths,
|
||||
includeEthernetLayer,
|
||||
includeIpLayer,
|
||||
}: {
|
||||
paths: InterfaceProtocolPathEvidence[];
|
||||
includeEthernetLayer: boolean;
|
||||
includeIpLayer: boolean;
|
||||
}) {
|
||||
const topologyData = useMemo(
|
||||
() => buildDirectionalSankeyData(paths, { includeEthernetLayer, includeIpLayer }),
|
||||
[paths, includeEthernetLayer, includeIpLayer],
|
||||
);
|
||||
|
||||
return <SankeyTopology data={topologyData} />;
|
||||
}
|
||||
Reference in New Issue
Block a user