927 lines
30 KiB
TypeScript
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: [],
|
|
};
|
|
}
|