add visualizations, better scaling
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s

This commit is contained in:
2026-04-01 23:10:59 +02:00
parent 8f6d795e87
commit 74b961e152
7 changed files with 3494 additions and 2844 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,104 @@
import { Card, Drawer, Space, Tag, Typography } from 'antd';
import type { HostIntelligenceEvidence } from '../../types/analysis';
import {
endpointText,
formatBytes,
formatTimestamp,
protocolColor,
renderLabelTags,
} from './shared.tsx';
const { Text } = Typography;
export function HostDetailDrawer({
host,
open,
onClose,
}: {
host: HostIntelligenceEvidence | null;
open: boolean;
onClose: () => void;
}) {
return (
<Drawer title={host ? endpointText(host.ip_address, host.mac_address) : 'Host details'} placement="right" width={520} open={open} onClose={onClose}>
{host == null ? null : (
<Space direction="vertical" size={16} style={{ width: '100%' }}>
<Card size="small" title="Identity">
<Space direction="vertical" size={8} style={{ width: '100%' }}>
<Text>IP: {host.ip_address ?? '—'}</Text>
<Text>MAC: {host.mac_address ?? '—'}</Text>
<Text>Interfaces: {host.interfaces.join(', ') || '—'}</Text>
<Text>First seen: {formatTimestamp(host.first_seen)} | Last seen: {formatTimestamp(host.last_seen)}</Text>
<div>{renderLabelTags(host.hostnames, 'geekblue')}</div>
</Space>
</Card>
<Card size="small" title="Protocol Profile">
<Space direction="vertical" size={10} style={{ width: '100%' }}>
{host.top_protocols.length === 0 ? (
<Text type="secondary">No protocol profile available yet.</Text>
) : (
host.top_protocols.map((protocol) => {
const maxCount = Math.max(...host.top_protocols.map((item) => item.packet_count), 1);
const widthPct = (protocol.packet_count / maxCount) * 100;
return (
<div key={protocol.label}>
<Space style={{ width: '100%', justifyContent: 'space-between' }}>
<Tag color={protocolColor(protocol.label)}>{protocol.label}</Tag>
<Text>{protocol.packet_count}</Text>
</Space>
<div style={{ height: 8, background: '#eef3f8', borderRadius: 999, overflow: 'hidden' }}>
<div style={{ width: `${widthPct}%`, height: '100%', background: protocolColor(protocol.label) }} />
</div>
</div>
);
})
)}
</Space>
</Card>
<Card size="small" title="Top Peers">
<Space direction="vertical" size={10} style={{ width: '100%' }}>
{host.peers.length === 0 ? (
<Text type="secondary">No peer details available yet.</Text>
) : (
host.peers.map((peer) => {
const maxCount = Math.max(...host.peers.map((item) => item.packet_count), 1);
const widthPct = (peer.packet_count / maxCount) * 100;
return (
<div key={`${peer.ip_address ?? 'no-ip'}|${peer.mac_address ?? 'no-mac'}`}>
<Space direction="vertical" size={4} style={{ width: '100%' }}>
<Text>{endpointText(peer.ip_address, peer.mac_address)}</Text>
<Text type="secondary">{peer.packet_count} packets · {formatBytes(peer.byte_count)} · {formatTimestamp(peer.last_seen)}</Text>
<div style={{ height: 8, background: '#eef3f8', borderRadius: 999, overflow: 'hidden' }}>
<div style={{ width: `${widthPct}%`, height: '100%', background: '#5b8ff9' }} />
</div>
<div>{renderLabelTags(peer.protocols, 'purple')}</div>
</Space>
</div>
);
})
)}
</Space>
</Card>
<Card size="small" title="Likely Services">
<Space direction="vertical" size={10} style={{ width: '100%' }}>
{host.services.length === 0 ? (
<Text type="secondary">No service evidence inferred yet.</Text>
) : (
host.services.map((service) => (
<div key={`${service.port ?? 'no-port'}|${service.protocol}`}>
<Text>Port {service.port ?? '—'} · {service.protocol} · {service.packet_count} packets · {formatBytes(service.byte_count)}</Text>
<div style={{ marginTop: 4 }}>{renderLabelTags(service.hostnames, 'geekblue')}</div>
</div>
))
)}
</Space>
</Card>
</Space>
)}
</Drawer>
);
}

View 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} />;
}

View File

@@ -0,0 +1,926 @@
import { Space, Tag } from 'antd';
import * as d3 from 'd3';
import { useEffect, useRef, useState, type ReactNode } from 'react';
import type {
ConversationEvidence,
InterfaceHostProtocolEvidence,
InterfaceProtocolAttachment,
InterfaceProtocolPathEvidence,
LabelCountEvidence,
} from '../../types/analysis';
import type { PacketRow } from '../../types/packets';
export type GraphNodeKind = 'interface' | 'host' | 'protocol';
export type TopologyLayerKind = GraphNodeKind | 'ethernet' | 'ip';
export type TopologyNode = {
id: string;
label: string;
kind: TopologyLayerKind;
packetCount: number;
interfaceName?: string;
ipAddress?: string | null;
macAddress?: string | null;
protocol?: string;
};
export type TopologyLink = {
source: string;
target: string;
value: number;
packetCount: number;
label: string;
};
export type HeatmapRow = {
hostId: string;
hostLabel: string;
interfaceName: string;
values: Record<string, number>;
};
export type ProtocolTableRow = {
key: string;
interface: string;
ip_address?: string | null;
mac_address?: string | null;
host_packet_count: number;
protocol: string;
protocol_packet_count: number;
accept_count: number;
drop_count: number;
reject_count: number;
unknown_count: number;
last_seen: string;
};
export type TopologyData = {
nodes: TopologyNode[];
links: TopologyLink[];
heatmapRows: HeatmapRow[];
protocols: string[];
tableRows: ProtocolTableRow[];
};
export type TopologyOptions = {
includeEthernetLayer: boolean;
includeIpLayer: boolean;
};
export function formatTimestamp(value?: string | null) {
if (!value) return '-';
try {
const date = new Date(value);
return (
date.toLocaleString('de-DE', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}) + `.${String(date.getMilliseconds()).padStart(3, '0')}`
);
} catch {
return value;
}
}
export function formatBytes(value?: number | null) {
const amount = Number(value ?? 0);
if (!Number.isFinite(amount) || amount <= 0) return '0 B';
if (amount < 1024) return `${amount} B`;
if (amount < 1024 ** 2) return `${(amount / 1024).toFixed(1)} KB`;
if (amount < 1024 ** 3) return `${(amount / 1024 ** 2).toFixed(1)} MB`;
return `${(amount / 1024 ** 3).toFixed(1)} GB`;
}
export function formatDurationMs(value?: number | null) {
const duration = Number(value ?? 0);
if (!Number.isFinite(duration) || duration <= 0) return '0 ms';
if (duration < 1000) return `${duration} ms`;
const seconds = duration / 1000;
if (seconds < 60) return `${seconds.toFixed(2)} s`;
const minutes = Math.floor(seconds / 60);
const remainingSeconds = seconds % 60;
if (minutes < 60) return `${minutes}m ${remainingSeconds.toFixed(1)}s`;
const hours = Math.floor(minutes / 60);
const remainingMinutes = minutes % 60;
return `${hours}h ${remainingMinutes}m`;
}
export function endpointText(ipAddress?: string | null, macAddress?: string | null) {
return ipAddress ?? macAddress ?? 'unknown endpoint';
}
export function normalizeIpAddress(value?: string | null) {
if (value == null) return null;
const trimmed = value.trim();
if (trimmed === '') return null;
const slashIndex = trimmed.indexOf('/');
return slashIndex >= 0 ? trimmed.slice(0, slashIndex) : trimmed;
}
export function conversationRowKey(row: ConversationEvidence) {
return [
row.src_ip_address,
row.src_mac_address,
row.src_port,
row.dst_ip_address,
row.dst_mac_address,
row.dst_port,
row.protocol,
].join('|');
}
export function asRecord(value: unknown): Record<string, unknown> | null {
if (value == null || typeof value !== 'object' || Array.isArray(value)) {
return null;
}
return value as Record<string, unknown>;
}
export function packetEventLabel(packet: PacketRow) {
const tcpLabel = packetTcpSummary(packet);
const activityLabel = packetActivityText(packet);
if (tcpLabel && activityLabel && tcpLabel !== activityLabel) {
return `${tcpLabel} · ${activityLabel}`;
}
if (activityLabel) {
return activityLabel;
}
if (tcpLabel) {
return tcpLabel;
}
return String(packet.app_protocol ?? packet.ip_proto ?? packet.eth_type ?? 'Packet');
}
export function packetTcpSummary(packet: PacketRow) {
const dpiMetadata = asRecord(packet.dpi_metadata);
const tcpMeta = asRecord(dpiMetadata?.tcp);
const tcpFlags = Array.isArray(tcpMeta?.flag_names)
? tcpMeta.flag_names.filter((flag): flag is string => typeof flag === 'string')
: [];
const tcpPacketType = typeof tcpMeta?.packet_type === 'string' ? tcpMeta.packet_type : null;
return tcpFlags.length > 0 ? tcpFlags.join('-') : tcpPacketType;
}
export function packetActivityText(packet: PacketRow) {
const dpiMetadata = asRecord(packet.dpi_metadata);
const httpMeta = asRecord(dpiMetadata?.http);
const dnsMeta = asRecord(dpiMetadata?.dns);
const tlsMeta = asRecord(dpiMetadata?.tls);
const arpMeta = asRecord(dpiMetadata?.arp);
const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null;
const uri =
typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null;
if (method) {
return `${method} ${uri ?? ''}`.trim();
}
const responseCode = httpMeta?.response_code;
const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : '';
if (typeof responseCode === 'number' || typeof responseCode === 'string') {
return `${responseCode} ${responsePhrase}`.trim();
}
const dnsName =
typeof dnsMeta?.query_name === 'string'
? dnsMeta.query_name
: typeof dnsMeta?.response_name === 'string'
? dnsMeta.response_name
: null;
if (dnsName) {
return dnsName;
}
const serverName =
typeof tlsMeta?.server_name === 'string'
? tlsMeta.server_name
: typeof tlsMeta?.sni === 'string'
? tlsMeta.sni
: null;
if (serverName) {
return serverName;
}
if (typeof arpMeta?.target_proto_ipv4 === 'string') {
return arpMeta.target_proto_ipv4;
}
const appProtocol = typeof packet.app_protocol === 'string' ? packet.app_protocol : null;
const ipProtocol = typeof packet.ip_proto === 'string' ? packet.ip_proto : null;
const ethernetProtocol = typeof packet.eth_type === 'string' ? packet.eth_type : null;
if (appProtocol && appProtocol !== ipProtocol && appProtocol !== ethernetProtocol) {
return appProtocol;
}
return '';
}
export function packetHttpDetailText(packet: PacketRow) {
const dpiMetadata = asRecord(packet.dpi_metadata);
const httpMeta = asRecord(dpiMetadata?.http);
const method = typeof httpMeta?.method === 'string' ? httpMeta.method : null;
const uri =
typeof httpMeta?.uri === 'string' ? httpMeta.uri : typeof httpMeta?.path === 'string' ? httpMeta.path : null;
if (method) {
return `${method} ${uri ?? ''}`.trim();
}
const responseCode = httpMeta?.response_code;
const responsePhrase = typeof httpMeta?.response_phrase === 'string' ? httpMeta.response_phrase : '';
if (typeof responseCode === 'number' || typeof responseCode === 'string') {
return `${responseCode} ${responsePhrase}`.trim();
}
return '';
}
export function packetDnsDetailText(packet: PacketRow) {
const dpiMetadata = asRecord(packet.dpi_metadata);
const dnsMeta = asRecord(dpiMetadata?.dns);
if (dnsMeta == null) return '';
const queryType = typeof dnsMeta.query_type === 'string' ? dnsMeta.query_type : null;
const queryName = typeof dnsMeta.query_name === 'string' ? dnsMeta.query_name : null;
const responseName = typeof dnsMeta.response_name === 'string' ? dnsMeta.response_name : null;
if (dnsMeta.is_response === false) {
return `Query${queryType ? ` ${queryType}` : ''}${queryName ? ` ${queryName}` : ''}`.trim();
}
if (dnsMeta.is_response === true) {
return `Response${responseName ? ` ${responseName}` : queryName ? ` ${queryName}` : ''}`.trim();
}
return queryName ?? responseName ?? '';
}
export function packetTlsDetailText(packet: PacketRow) {
const dpiMetadata = asRecord(packet.dpi_metadata);
const tlsMeta = asRecord(dpiMetadata?.tls);
if (tlsMeta == null) return '';
const version = typeof tlsMeta.handshake_version === 'string' ? tlsMeta.handshake_version : null;
const serverName =
typeof tlsMeta.server_name === 'string'
? tlsMeta.server_name
: typeof tlsMeta.sni === 'string'
? tlsMeta.sni
: null;
const alpn = typeof tlsMeta.alpn === 'string' ? tlsMeta.alpn : null;
return [version, serverName, alpn].filter((value): value is string => Boolean(value)).join(' · ');
}
export function packetArpDetailText(packet: PacketRow) {
const dpiMetadata = asRecord(packet.dpi_metadata);
const arpMeta = asRecord(dpiMetadata?.arp);
if (arpMeta == null) return '';
const opcode = typeof arpMeta.opcode === 'number' ? arpMeta.opcode : null;
const opcodeLabel =
opcode === 1
? 'Request'
: opcode === 2
? 'Reply'
: opcode === 10
? 'NAK'
: opcode === 16
? 'InARP'
: opcode === 24
? 'NAK Reply'
: opcode === 25
? 'Peer Request'
: opcode != null
? `Op ${opcode}`
: null;
const targetIp = typeof arpMeta.target_proto_ipv4 === 'string' ? arpMeta.target_proto_ipv4 : null;
return [opcodeLabel, targetIp].filter((value): value is string => Boolean(value)).join(' · ');
}
export function packetIcmpDetailText(packet: PacketRow) {
const dpiMetadata = asRecord(packet.dpi_metadata);
const icmpMeta = asRecord(dpiMetadata?.icmp);
if (icmpMeta == null) return '';
const icmpType =
typeof icmpMeta.type_label === 'string'
? icmpMeta.type_label
: typeof icmpMeta.type === 'string'
? icmpMeta.type
: typeof icmpMeta.icmp_type === 'string'
? icmpMeta.icmp_type
: null;
const code =
typeof icmpMeta.code === 'number'
? String(icmpMeta.code)
: typeof icmpMeta.icmp_code === 'string'
? icmpMeta.icmp_code
: null;
return [icmpType, code ? `code ${code}` : null].filter((value): value is string => Boolean(value)).join(' · ');
}
function stringList(value: unknown) {
if (Array.isArray(value)) {
return value.filter((entry): entry is string => typeof entry === 'string' && entry.trim() !== '');
}
if (typeof value === 'string' && value.trim() !== '') {
return [value];
}
return [];
}
function tcpFlagColor(flagName: string) {
const normalized = flagName.toUpperCase();
if (normalized === 'SYN') return 'blue';
if (normalized === 'ACK') return 'cyan';
if (normalized === 'PSH') return 'green';
if (normalized === 'FIN') return 'orange';
if (normalized === 'RST') return 'red';
if (normalized === 'URG') return 'volcano';
if (normalized === 'ECE' || normalized === 'CWR') return 'purple';
return 'default';
}
export function renderPacketBadges(packet: PacketRow): ReactNode[] {
const badges: ReactNode[] = [];
const dpiMetadata = asRecord(packet.dpi_metadata);
return badges.concat(renderPacketTcpBadges(packet));
}
export function renderPacketTcpBadges(packet: PacketRow): ReactNode[] {
const badges: ReactNode[] = [];
const dpiMetadata = asRecord(packet.dpi_metadata);
const tcpMeta = asRecord(dpiMetadata?.tcp);
const tsharkMeta = asRecord(dpiMetadata?.tshark);
const flagNames = Array.from(
new Set([...stringList(tcpMeta?.flag_names), ...stringList(tsharkMeta?.tcp_flag_names)]),
);
for (const flagName of flagNames) {
badges.push(
<Tag key={`protocol-flag-${flagName}`} color={tcpFlagColor(flagName)} style={{ marginInlineEnd: 0 }}>
{flagName}
</Tag>,
);
}
const tcpPacketType =
typeof tcpMeta?.packet_type === 'string'
? tcpMeta.packet_type
: typeof tsharkMeta?.tcp_packet_type === 'string'
? tsharkMeta.tcp_packet_type
: null;
if (tcpPacketType && flagNames.length === 0) {
badges.push(
<Tag key={`protocol-type-${tcpPacketType}`} color="default" style={{ marginInlineEnd: 0 }}>
{tcpPacketType}
</Tag>,
);
}
if (tcpMeta?.retransmission === true) {
badges.push(
<Tag key="protocol-retransmission" color="red" style={{ marginInlineEnd: 0 }}>
Retransmission
</Tag>,
);
}
if (tcpMeta?.duplicate_ack === true) {
badges.push(
<Tag key="protocol-dup-ack" color="volcano" style={{ marginInlineEnd: 0 }}>
Dup ACK
</Tag>,
);
}
if (tcpMeta?.keep_alive === true) {
badges.push(
<Tag key="protocol-keepalive" color="lime" style={{ marginInlineEnd: 0 }}>
Keep-Alive
</Tag>,
);
}
return badges;
}
export function endpointMatches(
packetIp: string | null | undefined,
packetMac: string | null | undefined,
targetIp: string | null | undefined,
targetMac: string | null | undefined,
) {
if (targetIp == null && targetMac == null) {
return false;
}
const ipMatches = targetIp == null || normalizeIpAddress(packetIp) === normalizeIpAddress(targetIp);
const macMatches = targetMac == null || packetMac === targetMac;
return ipMatches && macMatches;
}
export function packetDirection(packet: PacketRow, conversation: ConversationEvidence) {
const forward =
endpointMatches(packet.src_ip, packet.src_mac, conversation.src_ip_address, conversation.src_mac_address) &&
endpointMatches(packet.dst_ip, packet.dst_mac, conversation.dst_ip_address, conversation.dst_mac_address) &&
(conversation.src_port == null || packet.src_port === conversation.src_port) &&
(conversation.dst_port == null || packet.dst_port === conversation.dst_port);
if (forward) return 'forward';
const reverse =
endpointMatches(packet.src_ip, packet.src_mac, conversation.dst_ip_address, conversation.dst_mac_address) &&
endpointMatches(packet.dst_ip, packet.dst_mac, conversation.src_ip_address, conversation.src_mac_address) &&
(conversation.src_port == null || packet.dst_port === conversation.src_port) &&
(conversation.dst_port == null || packet.src_port === conversation.dst_port);
if (reverse) return 'reverse';
return 'unknown';
}
export function renderLabelTags(values: string[], color = 'default') {
if (values.length === 0) return '—';
return (
<Space wrap size={[4, 4]}>
{values.map((value) => (
<Tag key={value} color={color}>
{value}
</Tag>
))}
</Space>
);
}
export function renderLabelCountTags(values: LabelCountEvidence[]) {
if (values.length === 0) return '—';
return (
<Space wrap size={[4, 4]}>
{values.map((value) => (
<Tag key={value.label} color={protocolColor(value.label)}>
{value.label}: {value.packet_count}
</Tag>
))}
</Space>
);
}
export function hostIdentity(host: InterfaceHostProtocolEvidence) {
return `${host.ip_address ?? 'no-ip'}|${host.mac_address ?? 'no-mac'}`;
}
export function hostLabel(interfaceName: string, host: InterfaceHostProtocolEvidence) {
const ip = host.ip_address ?? 'unknown ip';
const mac = host.mac_address ?? 'unknown mac';
return `${interfaceName} • ${ip}\n${mac}`;
}
export function protocolColor(protocol: string) {
const palette = d3.schemeTableau10;
let hash = 0;
for (let index = 0; index < protocol.length; index += 1) {
hash = (hash * 31 + protocol.charCodeAt(index)) >>> 0;
}
return palette[hash % palette.length];
}
export function clamp(value: number, min: number, max: number) {
return Math.min(max, Math.max(min, value));
}
export type ChartKind = 'sankey' | 'parallel' | 'force' | 'heatmap' | 'matrix' | 'timeline' | 'sequence';
// Single place to tune overall Analysis visualization sizing.
export const ANALYSIS_VISUAL_SCALE = 1;
// Per-visualization tuning layered on top of the global Analysis scale.
export const ANALYSIS_VISUAL_SCALE_RATES: Record<ChartKind, number> = {
sankey: 1.5,
parallel: 1.5,
force: 1.5,
heatmap: 1,
matrix: 1.2,
timeline: 1.5,
sequence: 1.2,
};
export function scaleVisual(value: number) {
return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE));
}
export function scaleVisualFor(kind: ChartKind, value: number) {
return Math.max(1, Math.round(value * ANALYSIS_VISUAL_SCALE * ANALYSIS_VISUAL_SCALE_RATES[kind]));
}
export function useResponsiveChartWidth() {
const containerRef = useRef<HTMLDivElement | null>(null);
const [viewportWidth, setViewportWidth] = useState(0);
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const measureWidth = () => {
let width = container.getBoundingClientRect().width;
let ancestor = container.parentElement;
let depth = 0;
while (ancestor != null && depth < 3) {
width = Math.max(width, ancestor.getBoundingClientRect().width);
ancestor = ancestor.parentElement;
depth += 1;
}
return width;
};
const updateSize = () => {
const width = measureWidth();
setViewportWidth(width > 0 ? Math.floor(width) : 0);
};
updateSize();
const observer = new ResizeObserver(() => updateSize());
observer.observe(container);
return () => observer.disconnect();
}, []);
return { containerRef, viewportWidth };
}
type ChartPolicy = {
minWidth: number;
minHeight: number;
maxHeightPx: number;
maxHeightVh: number;
};
const chartPolicies: Record<ChartKind, ChartPolicy> = {
sankey: {
minWidth: scaleVisualFor('sankey', 560),
minHeight: scaleVisualFor('sankey', 230),
maxHeightPx: scaleVisualFor('sankey', 600),
maxHeightVh: 0.62,
},
parallel: {
minWidth: scaleVisualFor('parallel', 660),
minHeight: scaleVisualFor('parallel', 380),
maxHeightPx: scaleVisualFor('parallel', 740),
maxHeightVh: 0.72,
},
force: {
minWidth: scaleVisualFor('force', 660),
minHeight: scaleVisualFor('force', 340),
maxHeightPx: scaleVisualFor('force', 580),
maxHeightVh: 0.62,
},
heatmap: {
minWidth: scaleVisualFor('heatmap', 660),
minHeight: scaleVisualFor('heatmap', 320),
maxHeightPx: scaleVisualFor('heatmap', 600),
maxHeightVh: 0.62,
},
matrix: {
minWidth: scaleVisualFor('matrix', 660),
minHeight: scaleVisualFor('matrix', 320),
maxHeightPx: scaleVisualFor('matrix', 600),
maxHeightVh: 0.62,
},
timeline: {
minWidth: scaleVisualFor('timeline', 740),
minHeight: scaleVisualFor('timeline', 300),
maxHeightPx: scaleVisualFor('timeline', 620),
maxHeightVh: 0.66,
},
sequence: {
minWidth: scaleVisualFor('sequence', 740),
minHeight: scaleVisualFor('sequence', 340),
maxHeightPx: scaleVisualFor('sequence', 780),
maxHeightVh: 0.76,
},
};
type ChartSizeOverrides = Partial<ChartPolicy>;
function resolveChartMaxHeight(viewportHeight: number, minHeight: number, maxHeightPx: number, maxHeightVh: number) {
const viewportCap = viewportHeight > 0 ? Math.floor(viewportHeight * maxHeightVh) : maxHeightPx;
return Math.min(maxHeightPx, Math.max(minHeight, viewportCap));
}
export function useResponsiveChartSize(kind: ChartKind, overrides: ChartSizeOverrides = {}) {
const { containerRef, viewportWidth } = useResponsiveChartWidth();
const [viewportHeight, setViewportHeight] = useState(0);
useEffect(() => {
const updateViewportHeight = () => {
const height = typeof window !== 'undefined' ? window.innerHeight : 0;
setViewportHeight(height > 0 ? Math.floor(height) : 0);
};
updateViewportHeight();
if (typeof window === 'undefined') return;
window.addEventListener('resize', updateViewportHeight);
return () => window.removeEventListener('resize', updateViewportHeight);
}, []);
const policy = { ...chartPolicies[kind], ...overrides };
const chartWidth = viewportWidth > 0 ? Math.floor(viewportWidth) : policy.minWidth;
const maxChartHeight = resolveChartMaxHeight(
viewportHeight,
policy.minHeight,
policy.maxHeightPx,
policy.maxHeightVh,
);
const fitHeight = (desiredHeight: number, localOverrides: ChartSizeOverrides = {}) => {
const localMinHeight = localOverrides.minHeight ?? policy.minHeight;
const localMaxHeight = resolveChartMaxHeight(
viewportHeight,
localMinHeight,
localOverrides.maxHeightPx ?? policy.maxHeightPx,
localOverrides.maxHeightVh ?? policy.maxHeightVh,
);
return clamp(desiredHeight, localMinHeight, localMaxHeight);
};
return {
containerRef,
viewportWidth,
viewportHeight,
chartWidth,
minChartHeight: policy.minHeight,
maxChartHeight,
fitHeight,
};
}
export function sankeyVisualWeight(packetCount: number) {
return Math.max(1, Math.sqrt(Math.max(0, packetCount)));
}
function addOrUpdateLink(
links: Map<string, TopologyLink>,
source: string,
target: string,
packetCount: number,
label: string,
) {
const linkId = `${source}->${target}`;
const existing = links.get(linkId);
if (existing) {
existing.packetCount += packetCount;
existing.value = existing.packetCount;
existing.label = `${existing.label.split(' (')[0]} (${existing.packetCount})`;
return;
}
links.set(linkId, {
source,
target,
value: packetCount,
packetCount,
label: `${label} (${packetCount})`,
});
}
function ensureProtocolNode(nodes: Map<string, TopologyNode>, id: string, label: string, kind: TopologyLayerKind) {
if (!nodes.has(id)) {
nodes.set(id, {
id,
label,
kind,
packetCount: 0,
protocol: label,
});
}
return nodes.get(id)!;
}
export function buildTopologyData(interfaces: InterfaceProtocolAttachment[], options: TopologyOptions): TopologyData {
const nodes = new Map<string, TopologyNode>();
const links = new Map<string, TopologyLink>();
const heatmapByHost = new Map<string, HeatmapRow>();
const protocols = new Set<string>();
const tableRows: ProtocolTableRow[] = [];
for (const entry of interfaces) {
const interfaceNodeId = `iface:${entry.interface}`;
nodes.set(interfaceNodeId, {
id: interfaceNodeId,
label: entry.interface,
kind: 'interface',
packetCount: entry.hosts.reduce((sum, host) => sum + host.packet_count, 0),
interfaceName: entry.interface,
});
for (const host of entry.hosts) {
const hostId = `host:${entry.interface}:${hostIdentity(host)}`;
nodes.set(hostId, {
id: hostId,
label: hostLabel(entry.interface, host),
kind: 'host',
packetCount: host.packet_count,
interfaceName: entry.interface,
ipAddress: host.ip_address,
macAddress: host.mac_address,
});
const interfaceHostLinkId = `${interfaceNodeId}->${hostId}`;
links.set(interfaceHostLinkId, {
source: interfaceNodeId,
target: hostId,
value: host.packet_count,
packetCount: host.packet_count,
label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`,
});
const heatmapRow: HeatmapRow = {
hostId,
hostLabel: `${entry.interface} • ${host.ip_address ?? 'unknown ip'}`,
interfaceName: entry.interface,
values: {},
};
for (const protocol of host.protocols) {
protocols.add(protocol.protocol);
const layerPaths =
protocol.layer_paths.length > 0
? protocol.layer_paths
: [
{
ethernet_protocol: protocol.ethernet_protocol ?? null,
ip_protocol: protocol.ip_protocol ?? null,
packet_count: protocol.packet_count,
last_seen: protocol.last_seen,
accept_count: protocol.accept_count,
drop_count: protocol.drop_count,
reject_count: protocol.reject_count,
unknown_count: protocol.unknown_count,
},
];
for (const layerPath of layerPaths) {
let currentNodeId = hostId;
let currentLabel = host.ip_address ?? host.mac_address ?? 'host';
if (
options.includeEthernetLayer &&
layerPath.ethernet_protocol &&
layerPath.ethernet_protocol !== protocol.protocol
) {
const ethernetId = `ethernet:${layerPath.ethernet_protocol}`;
const ethernetNode = ensureProtocolNode(nodes, ethernetId, layerPath.ethernet_protocol, 'ethernet');
ethernetNode.packetCount += layerPath.packet_count;
addOrUpdateLink(
links,
currentNodeId,
ethernetId,
layerPath.packet_count,
`${currentLabel} -> ${layerPath.ethernet_protocol}`,
);
currentNodeId = ethernetId;
currentLabel = layerPath.ethernet_protocol;
}
if (
options.includeIpLayer &&
layerPath.ip_protocol &&
layerPath.ip_protocol !== currentLabel &&
layerPath.ip_protocol !== protocol.protocol
) {
const ipId = `ip:${layerPath.ip_protocol}`;
const ipNode = ensureProtocolNode(nodes, ipId, layerPath.ip_protocol, 'ip');
ipNode.packetCount += layerPath.packet_count;
addOrUpdateLink(
links,
currentNodeId,
ipId,
layerPath.packet_count,
`${currentLabel} -> ${layerPath.ip_protocol}`,
);
currentNodeId = ipId;
currentLabel = layerPath.ip_protocol;
}
if (currentLabel !== protocol.protocol || currentNodeId === hostId) {
const protocolId = `protocol:${protocol.protocol}`;
const protocolNode = ensureProtocolNode(nodes, protocolId, protocol.protocol, 'protocol');
protocolNode.packetCount += layerPath.packet_count;
addOrUpdateLink(
links,
currentNodeId,
protocolId,
layerPath.packet_count,
`${currentLabel} -> ${protocol.protocol}`,
);
}
}
heatmapRow.values[protocol.protocol] = protocol.packet_count;
tableRows.push({
key: `${entry.interface}-${hostIdentity(host)}-${protocol.protocol}`,
interface: entry.interface,
ip_address: host.ip_address,
mac_address: host.mac_address,
host_packet_count: host.packet_count,
protocol: protocol.protocol,
protocol_packet_count: protocol.packet_count,
accept_count: protocol.accept_count,
drop_count: protocol.drop_count,
reject_count: protocol.reject_count,
unknown_count: protocol.unknown_count,
last_seen: protocol.last_seen,
});
}
heatmapByHost.set(hostId, heatmapRow);
}
}
return {
nodes: Array.from(nodes.values()),
links: Array.from(links.values()),
heatmapRows: Array.from(heatmapByHost.values()).sort((left, right) =>
left.hostLabel.localeCompare(right.hostLabel),
),
protocols: Array.from(protocols).sort(),
tableRows: tableRows.sort(
(left, right) =>
right.protocol_packet_count - left.protocol_packet_count || left.interface.localeCompare(right.interface),
),
};
}
export function buildDirectionalSankeyData(
paths: InterfaceProtocolPathEvidence[],
options: TopologyOptions,
): TopologyData {
const nodes = new Map<string, TopologyNode>();
const links = new Map<string, TopologyLink>();
for (const path of paths) {
const packetCount = path.packet_count;
const ingressLabel = path.ingress_interface ? `${path.ingress_interface} (ingress)` : 'Unknown ingress';
const ingressId = `ingress:${path.ingress_interface ?? 'unknown'}`;
const sourceLabel = endpointText(path.src_ip_address, path.src_mac_address);
const sourceId = `source:${path.src_ip_address ?? 'no-ip'}|${path.src_mac_address ?? 'no-mac'}`;
const protocolLabel = path.protocol;
const protocolId = `protocol:${protocolLabel}`;
const destinationLabel = endpointText(path.dst_ip_address, path.dst_mac_address);
const destinationId = `destination:${path.dst_ip_address ?? 'no-ip'}|${path.dst_mac_address ?? 'no-mac'}`;
const egressLabel = path.egress_interface ? `${path.egress_interface} (egress)` : 'Unknown egress';
const egressId = `egress:${path.egress_interface ?? 'unknown'}`;
ensureProtocolNode(nodes, ingressId, ingressLabel, 'interface').packetCount += packetCount;
nodes.set(sourceId, {
...(nodes.get(sourceId) ?? {
id: sourceId,
label: sourceLabel,
kind: 'host' as const,
packetCount: 0,
ipAddress: path.src_ip_address,
macAddress: path.src_mac_address,
}),
packetCount: (nodes.get(sourceId)?.packetCount ?? 0) + packetCount,
});
ensureProtocolNode(nodes, protocolId, protocolLabel, 'protocol').packetCount += packetCount;
nodes.set(destinationId, {
...(nodes.get(destinationId) ?? {
id: destinationId,
label: destinationLabel,
kind: 'host' as const,
packetCount: 0,
ipAddress: path.dst_ip_address,
macAddress: path.dst_mac_address,
}),
packetCount: (nodes.get(destinationId)?.packetCount ?? 0) + packetCount,
});
ensureProtocolNode(nodes, egressId, egressLabel, 'interface').packetCount += packetCount;
addOrUpdateLink(links, sourceId, ingressId, packetCount, `${sourceLabel} -> ${ingressLabel}`);
let currentNodeId = ingressId;
let currentLabel = ingressLabel;
if (options.includeEthernetLayer && path.ethernet_protocol && path.ethernet_protocol !== protocolLabel) {
const ethernetId = `ethernet:${path.ethernet_protocol}`;
ensureProtocolNode(nodes, ethernetId, path.ethernet_protocol, 'ethernet').packetCount += packetCount;
addOrUpdateLink(links, currentNodeId, ethernetId, packetCount, `${currentLabel} -> ${path.ethernet_protocol}`);
currentNodeId = ethernetId;
currentLabel = path.ethernet_protocol;
}
if (
options.includeIpLayer &&
path.ip_protocol &&
path.ip_protocol !== currentLabel &&
path.ip_protocol !== protocolLabel
) {
const ipId = `ip:${path.ip_protocol}`;
ensureProtocolNode(nodes, ipId, path.ip_protocol, 'ip').packetCount += packetCount;
addOrUpdateLink(links, currentNodeId, ipId, packetCount, `${currentLabel} -> ${path.ip_protocol}`);
currentNodeId = ipId;
currentLabel = path.ip_protocol;
}
addOrUpdateLink(links, currentNodeId, protocolId, packetCount, `${currentLabel} -> ${protocolLabel}`);
addOrUpdateLink(links, protocolId, egressId, packetCount, `${protocolLabel} -> ${egressLabel}`);
addOrUpdateLink(links, egressId, destinationId, packetCount, `${egressLabel} -> ${destinationLabel}`);
}
return {
nodes: Array.from(nodes.values()),
links: Array.from(links.values()),
heatmapRows: [],
protocols: [],
tableRows: [],
};
}