Files
mitm-webserver/frontend/src/components/analysis/shared.tsx
malmert 74b961e152
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
add visualizations, better scaling
2026-04-01 23:10:59 +02:00

927 lines
30 KiB
TypeScript

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: [],
};
}