add more sankey options
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 10s

This commit is contained in:
2026-03-30 23:07:14 +02:00
parent 1a0d220f11
commit 5f6eedf859
4 changed files with 239 additions and 42 deletions

View File

@@ -3,6 +3,7 @@ import {
Alert,
Button,
Card,
Checkbox,
Col,
Empty,
InputNumber,
@@ -31,11 +32,12 @@ import type {
const { Title, Text, Paragraph } = Typography;
type GraphNodeKind = 'interface' | 'host' | 'protocol';
type TopologyLayerKind = GraphNodeKind | 'ethernet' | 'ip';
type TopologyNode = {
id: string;
label: string;
kind: GraphNodeKind;
kind: TopologyLayerKind;
packetCount: number;
interfaceName?: string;
ipAddress?: string | null;
@@ -81,6 +83,11 @@ type TopologyData = {
tableRows: ProtocolTableRow[];
};
type TopologyOptions = {
includeEthernetLayer: boolean;
includeIpLayer: boolean;
};
type ForceNode = d3.SimulationNodeDatum & TopologyNode;
type ForceLink = d3.SimulationLinkDatum<ForceNode> & TopologyLink;
@@ -133,7 +140,38 @@ function sankeyVisualWeight(packetCount: number) {
return Math.max(1, Math.sqrt(Math.max(0, packetCount)));
}
function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData {
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 = sankeyVisualWeight(existing.packetCount);
existing.label = `${existing.label.split(' (')[0]} (${existing.packetCount})`;
return;
}
links.set(linkId, {
source,
target,
value: sankeyVisualWeight(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)!;
}
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>();
@@ -179,28 +217,74 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD
};
for (const protocol of host.protocols) {
const protocolId = `protocol:${protocol.protocol}`;
protocols.add(protocol.protocol);
if (!nodes.has(protocolId)) {
nodes.set(protocolId, {
id: protocolId,
label: protocol.protocol,
kind: 'protocol',
packetCount: 0,
protocol: protocol.protocol,
});
}
const existingProtocolNode = nodes.get(protocolId)!;
existingProtocolNode.packetCount += protocol.packet_count;
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,
},
];
const hostProtocolLinkId = `${hostId}->${protocolId}`;
links.set(hostProtocolLinkId, {
source: hostId,
target: protocolId,
value: sankeyVisualWeight(protocol.packet_count),
packetCount: protocol.packet_count,
label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_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;
@@ -243,9 +327,11 @@ function SankeyTopology({ data }: { data: TopologyData }) {
const nodesPerLayer = {
interface: data.nodes.filter((node) => node.kind === 'interface').length,
host: data.nodes.filter((node) => node.kind === 'host').length,
ethernet: data.nodes.filter((node) => node.kind === 'ethernet').length,
ip: data.nodes.filter((node) => node.kind === 'ip').length,
protocol: data.nodes.filter((node) => node.kind === 'protocol').length,
};
const maxNodesInLayer = Math.max(nodesPerLayer.interface, nodesPerLayer.host, nodesPerLayer.protocol, 1);
const maxNodesInLayer = Math.max(nodesPerLayer.interface, nodesPerLayer.host, nodesPerLayer.ethernet, nodesPerLayer.ip, nodesPerLayer.protocol, 1);
const height = clamp(maxNodesInLayer * 56 + 120, 260, 760);
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
@@ -279,7 +365,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
.attr('d', sankeyLinkHorizontal())
.attr('stroke', (link) => {
const target = link.target as SankeyNodeDatum;
if (target.kind === 'protocol' && target.protocol) {
if ((target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol) {
return protocolColor(target.protocol);
}
return '#9aa7b5';
@@ -303,6 +389,8 @@ function SankeyTopology({ data }: { data: TopologyData }) {
.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'))
@@ -353,6 +441,8 @@ function ForceTopology({ data }: { data: TopologyData }) {
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)),
};
@@ -374,8 +464,11 @@ function ForceTopology({ data }: { data: TopologyData }) {
const interfaceY = distributedY(groupedNodes.interface, 120, height - 120);
const hostY = distributedY(groupedNodes.host, 90, height - 90);
const ethernetY = distributedY(groupedNodes.ethernet, 120, height - 120);
const ipY = distributedY(groupedNodes.ip, 120, height - 120);
const protocolY = distributedY(groupedNodes.protocol, 120, height - 120);
const targetY = (node: ForceNode) => interfaceY.get(node.id) ?? hostY.get(node.id) ?? protocolY.get(node.id) ?? height / 2;
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)
@@ -397,13 +490,17 @@ function ForceTopology({ data }: { data: TopologyData }) {
.force('collision', d3.forceCollide<ForceNode>().radius((node) => {
if (node.kind === 'interface') return 52;
if (node.kind === 'host') return 44;
if (node.kind === 'ethernet') return 36;
if (node.kind === 'ip') return 35;
return 34;
}))
.force(
'x',
d3.forceX<ForceNode>().x((node) => {
if (node.kind === 'interface') return 180;
if (node.kind === 'host') return width / 2;
if (node.kind === 'host') return 420;
if (node.kind === 'ethernet') return 690;
if (node.kind === 'ip') return 930;
return width - 200;
}).strength(0.42),
)
@@ -420,11 +517,13 @@ function ForceTopology({ data }: { data: TopologyData }) {
.join('line')
.attr('stroke', (d) => {
const target = d.target as ForceNode;
return target.kind === 'protocol' && target.protocol ? protocolColor(target.protocol) : '#92a1b2';
return (target.kind === 'protocol' || target.kind === 'ethernet' || target.kind === 'ip') && target.protocol
? protocolColor(target.protocol)
: '#92a1b2';
})
.attr('stroke-width', (d) => Math.max(1.5, Math.sqrt(d.value)));
link.append('title').text((d) => `${d.label}\nPackets: ${d.value}`);
link.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`);
const node = svg
.append('g')
@@ -437,11 +536,15 @@ function ForceTopology({ data }: { data: TopologyData }) {
.attr('r', (d) => {
if (d.kind === 'interface') return 26;
if (d.kind === 'host') return 22;
if (d.kind === 'ethernet') return 19;
if (d.kind === 'ip') return 18;
return 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'))
@@ -582,6 +685,8 @@ export default function Analysis(): ReactElement {
const [sinceMinutes, setSinceMinutes] = useState<number | null>(null);
const [limitPerInterface, setLimitPerInterface] = useState(50);
const [limitProtocolsPerHost, setLimitProtocolsPerHost] = useState(12);
const [includeEthernetLayer, setIncludeEthernetLayer] = useState(false);
const [includeIpLayer, setIncludeIpLayer] = useState(false);
const [data, setData] = useState<InterfaceHostProtocolAnalysisResponse | null>(null);
const [loading, setLoading] = useState(false);
@@ -601,7 +706,14 @@ export default function Analysis(): ReactElement {
loadData().catch(() => undefined);
}, [loadData]);
const topologyData = useMemo(() => buildTopologyData(data?.interfaces ?? []), [data]);
const topologyData = useMemo(
() =>
buildTopologyData(data?.interfaces ?? [], {
includeEthernetLayer,
includeIpLayer,
}),
[data, includeEthernetLayer, includeIpLayer],
);
const columns = useMemo<ColumnsType<ProtocolTableRow>>(
() => [
@@ -680,6 +792,12 @@ export default function Analysis(): ReactElement {
<Text>Max protocols per host</Text>
<InputNumber min={1} max={100} value={limitProtocolsPerHost} onChange={(value) => setLimitProtocolsPerHost(value ?? 12)} />
</Space>
<Checkbox checked={includeEthernetLayer} onChange={(event) => setIncludeEthernetLayer(event.target.checked)}>
Ethernet layer
</Checkbox>
<Checkbox checked={includeIpLayer} onChange={(event) => setIncludeIpLayer(event.target.checked)}>
IP layer
</Checkbox>
<Button icon={<ReloadOutlined />} onClick={() => loadData()} loading={loading} type="primary">
Refresh
</Button>