add visualizations, better scaling
This commit is contained in:
926
frontend/src/components/analysis/shared.tsx
Normal file
926
frontend/src/components/analysis/shared.tsx
Normal 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: [],
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user