|
|
|
|
@@ -0,0 +1,725 @@
|
|
|
|
|
import { ReloadOutlined } from '@ant-design/icons';
|
|
|
|
|
import {
|
|
|
|
|
Alert,
|
|
|
|
|
Button,
|
|
|
|
|
Card,
|
|
|
|
|
Col,
|
|
|
|
|
Empty,
|
|
|
|
|
InputNumber,
|
|
|
|
|
Row,
|
|
|
|
|
Space,
|
|
|
|
|
Spin,
|
|
|
|
|
Table,
|
|
|
|
|
Tabs,
|
|
|
|
|
Tag,
|
|
|
|
|
Typography,
|
|
|
|
|
message,
|
|
|
|
|
} from 'antd';
|
|
|
|
|
import type { ColumnsType } from 'antd/es/table';
|
|
|
|
|
import * as d3 from 'd3';
|
|
|
|
|
import { sankey as d3Sankey, sankeyLinkHorizontal, type SankeyGraph, type SankeyLink, type SankeyNode } from 'd3-sankey';
|
|
|
|
|
import { ReactElement, useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
|
|
|
|
|
|
|
|
import { fetchInterfaceHostProtocolAnalysis } from '../api/apiClient';
|
|
|
|
|
import type {
|
|
|
|
|
InterfaceHostProtocolAnalysisResponse,
|
|
|
|
|
InterfaceHostProtocolEvidence,
|
|
|
|
|
InterfaceProtocolAttachment,
|
|
|
|
|
ProtocolEvidence,
|
|
|
|
|
} from '../types/analysis';
|
|
|
|
|
|
|
|
|
|
const { Title, Text, Paragraph } = Typography;
|
|
|
|
|
|
|
|
|
|
type GraphNodeKind = 'interface' | 'host' | 'protocol';
|
|
|
|
|
|
|
|
|
|
type TopologyNode = {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
kind: GraphNodeKind;
|
|
|
|
|
packetCount: number;
|
|
|
|
|
interfaceName?: string;
|
|
|
|
|
ipAddress?: string | null;
|
|
|
|
|
macAddress?: string | null;
|
|
|
|
|
protocol?: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type TopologyLink = {
|
|
|
|
|
source: string;
|
|
|
|
|
target: string;
|
|
|
|
|
value: number;
|
|
|
|
|
label: string;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type HeatmapRow = {
|
|
|
|
|
hostId: string;
|
|
|
|
|
hostLabel: string;
|
|
|
|
|
interfaceName: string;
|
|
|
|
|
values: Record<string, number>;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type TopologyData = {
|
|
|
|
|
nodes: TopologyNode[];
|
|
|
|
|
links: TopologyLink[];
|
|
|
|
|
heatmapRows: HeatmapRow[];
|
|
|
|
|
protocols: string[];
|
|
|
|
|
tableRows: ProtocolTableRow[];
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
type ForceNode = d3.SimulationNodeDatum & TopologyNode;
|
|
|
|
|
type ForceLink = d3.SimulationLinkDatum<ForceNode> & TopologyLink;
|
|
|
|
|
|
|
|
|
|
type SankeyNodeDatum = SankeyNode<TopologyNode, TopologyLink> & TopologyNode;
|
|
|
|
|
type SankeyLinkDatum = SankeyLink<TopologyNode, TopologyLink> & TopologyLink;
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function hostIdentity(host: InterfaceHostProtocolEvidence) {
|
|
|
|
|
return `${host.ip_address ?? 'no-ip'}|${host.mac_address ?? 'no-mac'}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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}`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): 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,
|
|
|
|
|
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) {
|
|
|
|
|
const protocolId = `protocol:${protocol.protocol}`;
|
|
|
|
|
protocols.add(protocol.protocol);
|
|
|
|
|
if (!nodes.has(protocolId)) {
|
|
|
|
|
nodes.set(protocolId, {
|
|
|
|
|
id: protocolId,
|
|
|
|
|
label: protocol.protocol,
|
|
|
|
|
kind: 'protocol',
|
|
|
|
|
packetCount: 0,
|
|
|
|
|
protocol: protocol.protocol,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
const existingProtocolNode = nodes.get(protocolId)!;
|
|
|
|
|
existingProtocolNode.packetCount += protocol.packet_count;
|
|
|
|
|
|
|
|
|
|
const hostProtocolLinkId = `${hostId}->${protocolId}`;
|
|
|
|
|
links.set(hostProtocolLinkId, {
|
|
|
|
|
source: hostId,
|
|
|
|
|
target: protocolId,
|
|
|
|
|
value: protocol.packet_count,
|
|
|
|
|
label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_count})`,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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)),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function SankeyTopology({ data }: { data: TopologyData }) {
|
|
|
|
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!svgRef.current) return;
|
|
|
|
|
|
|
|
|
|
const width = 1160;
|
|
|
|
|
const height = Math.max(420, data.nodes.length * 26);
|
|
|
|
|
const svg = d3.select(svgRef.current);
|
|
|
|
|
svg.selectAll('*').remove();
|
|
|
|
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
|
|
|
|
|
|
|
|
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const graph: SankeyGraph<TopologyNode, TopologyLink> = {
|
|
|
|
|
nodes: data.nodes.map((node) => ({ ...node })),
|
|
|
|
|
links: data.links.map((link) => ({ ...link })),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const sankeyLayout = d3Sankey<TopologyNode, TopologyLink>()
|
|
|
|
|
.nodeId((node) => node.id)
|
|
|
|
|
.nodeWidth(18)
|
|
|
|
|
.nodePadding(16)
|
|
|
|
|
.extent([
|
|
|
|
|
[24, 28],
|
|
|
|
|
[width - 24, height - 28],
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
const layout = sankeyLayout(graph);
|
|
|
|
|
|
|
|
|
|
const defs = svg.append('defs');
|
|
|
|
|
defs
|
|
|
|
|
.append('marker')
|
|
|
|
|
.attr('id', 'sankey-arrow')
|
|
|
|
|
.attr('markerWidth', 10)
|
|
|
|
|
.attr('markerHeight', 10)
|
|
|
|
|
.attr('refX', 8)
|
|
|
|
|
.attr('refY', 5)
|
|
|
|
|
.attr('orient', 'auto')
|
|
|
|
|
.append('path')
|
|
|
|
|
.attr('d', 'M0,0 L10,5 L0,10 z')
|
|
|
|
|
.attr('fill', '#8394a7');
|
|
|
|
|
|
|
|
|
|
svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#f8fbff');
|
|
|
|
|
|
|
|
|
|
const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.35);
|
|
|
|
|
linkLayer
|
|
|
|
|
.selectAll('path')
|
|
|
|
|
.data(layout.links as SankeyLinkDatum[])
|
|
|
|
|
.join('path')
|
|
|
|
|
.attr('d', sankeyLinkHorizontal())
|
|
|
|
|
.attr('stroke', (link) => {
|
|
|
|
|
const target = link.target as SankeyNodeDatum;
|
|
|
|
|
if (target.kind === 'protocol' && target.protocol) {
|
|
|
|
|
return protocolColor(target.protocol);
|
|
|
|
|
}
|
|
|
|
|
return '#7f8ea3';
|
|
|
|
|
})
|
|
|
|
|
.attr('stroke-width', (link) => Math.max(1, link.width || 1))
|
|
|
|
|
.attr('marker-end', 'url(#sankey-arrow)')
|
|
|
|
|
.append('title')
|
|
|
|
|
.text((link) => `${link.label}\nPackets: ${link.value}`);
|
|
|
|
|
|
|
|
|
|
const nodeLayer = svg.append('g');
|
|
|
|
|
const node = nodeLayer
|
|
|
|
|
.selectAll('g')
|
|
|
|
|
.data(layout.nodes as SankeyNodeDatum[])
|
|
|
|
|
.join('g');
|
|
|
|
|
|
|
|
|
|
node
|
|
|
|
|
.append('rect')
|
|
|
|
|
.attr('x', (d) => d.x0 ?? 0)
|
|
|
|
|
.attr('y', (d) => d.y0 ?? 0)
|
|
|
|
|
.attr('width', (d) => (d.x1 ?? 0) - (d.x0 ?? 0))
|
|
|
|
|
.attr('height', (d) => Math.max(8, (d.y1 ?? 0) - (d.y0 ?? 0)))
|
|
|
|
|
.attr('rx', 8)
|
|
|
|
|
.attr('fill', (d) => {
|
|
|
|
|
if (d.kind === 'interface') return '#20405d';
|
|
|
|
|
if (d.kind === 'host') return '#d7e7f5';
|
|
|
|
|
return d.protocol ? protocolColor(d.protocol) : '#d8d8d8';
|
|
|
|
|
})
|
|
|
|
|
.attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none'))
|
|
|
|
|
.append('title')
|
|
|
|
|
.text((d) => `${d.label}\nPackets: ${d.value ?? d.packetCount}`);
|
|
|
|
|
|
|
|
|
|
node
|
|
|
|
|
.append('text')
|
|
|
|
|
.attr('x', (d) => ((d.x0 ?? 0) < 360 ? (d.x1 ?? 0) + 8 : (d.x0 ?? 0) - 8))
|
|
|
|
|
.attr('y', (d) => ((d.y0 ?? 0) + (d.y1 ?? 0)) / 2)
|
|
|
|
|
.attr('dy', '0.35em')
|
|
|
|
|
.attr('text-anchor', (d) => ((d.x0 ?? 0) < 360 ? 'start' : 'end'))
|
|
|
|
|
.attr('font-size', 12)
|
|
|
|
|
.attr('font-weight', (d) => (d.kind === 'interface' ? 700 : 500))
|
|
|
|
|
.attr('fill', '#22374f')
|
|
|
|
|
.text((d) => {
|
|
|
|
|
if (d.kind === 'host') {
|
|
|
|
|
return d.ipAddress ?? d.macAddress ?? d.label;
|
|
|
|
|
}
|
|
|
|
|
return d.label;
|
|
|
|
|
});
|
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
|
|
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
|
|
|
|
return <Empty description="No interface, host, and protocol relationships found yet" />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <svg ref={svgRef} style={{ width: '100%', minWidth: 960 }} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ForceTopology({ data }: { data: TopologyData }) {
|
|
|
|
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!svgRef.current) return;
|
|
|
|
|
const width = 1160;
|
|
|
|
|
const height = 720;
|
|
|
|
|
const svg = d3.select(svgRef.current);
|
|
|
|
|
svg.selectAll('*').remove();
|
|
|
|
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
|
|
|
|
|
|
|
|
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node }));
|
|
|
|
|
const links: ForceLink[] = data.links.map((link) => ({ ...link }));
|
|
|
|
|
|
|
|
|
|
const simulation = d3
|
|
|
|
|
.forceSimulation<ForceNode>(nodes)
|
|
|
|
|
.force(
|
|
|
|
|
'link',
|
|
|
|
|
d3
|
|
|
|
|
.forceLink<ForceNode, ForceLink>(links)
|
|
|
|
|
.id((node) => node.id)
|
|
|
|
|
.distance((link) => {
|
|
|
|
|
const source = link.source as ForceNode;
|
|
|
|
|
return source.kind === 'interface' ? 140 : 120;
|
|
|
|
|
}),
|
|
|
|
|
)
|
|
|
|
|
.force('charge', d3.forceManyBody().strength(-280))
|
|
|
|
|
.force('collision', d3.forceCollide<ForceNode>().radius((node) => (node.kind === 'host' ? 38 : 28)))
|
|
|
|
|
.force(
|
|
|
|
|
'x',
|
|
|
|
|
d3.forceX<ForceNode>().x((node) => {
|
|
|
|
|
if (node.kind === 'interface') return 150;
|
|
|
|
|
if (node.kind === 'host') return width / 2;
|
|
|
|
|
return width - 180;
|
|
|
|
|
}).strength(0.32),
|
|
|
|
|
)
|
|
|
|
|
.force('y', d3.forceY<ForceNode>(height / 2).strength(0.08));
|
|
|
|
|
|
|
|
|
|
svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe');
|
|
|
|
|
|
|
|
|
|
const link = svg
|
|
|
|
|
.append('g')
|
|
|
|
|
.attr('stroke-opacity', 0.45)
|
|
|
|
|
.selectAll('line')
|
|
|
|
|
.data(links)
|
|
|
|
|
.join('line')
|
|
|
|
|
.attr('stroke', (d) => {
|
|
|
|
|
const target = d.target as ForceNode;
|
|
|
|
|
return target.kind === 'protocol' && target.protocol ? protocolColor(target.protocol) : '#92a1b2';
|
|
|
|
|
})
|
|
|
|
|
.attr('stroke-width', (d) => Math.max(1.5, Math.sqrt(d.value)));
|
|
|
|
|
|
|
|
|
|
link.append('title').text((d) => `${d.label}\nPackets: ${d.value}`);
|
|
|
|
|
|
|
|
|
|
const node = svg
|
|
|
|
|
.append('g')
|
|
|
|
|
.selectAll('g')
|
|
|
|
|
.data(nodes)
|
|
|
|
|
.join('g');
|
|
|
|
|
|
|
|
|
|
node
|
|
|
|
|
.append('circle')
|
|
|
|
|
.attr('r', (d) => {
|
|
|
|
|
if (d.kind === 'interface') return 26;
|
|
|
|
|
if (d.kind === 'host') return 22;
|
|
|
|
|
return 18;
|
|
|
|
|
})
|
|
|
|
|
.attr('fill', (d) => {
|
|
|
|
|
if (d.kind === 'interface') return '#20405d';
|
|
|
|
|
if (d.kind === 'host') return '#d7e7f5';
|
|
|
|
|
return d.protocol ? protocolColor(d.protocol) : '#cfd7df';
|
|
|
|
|
})
|
|
|
|
|
.attr('stroke', (d) => (d.kind === 'host' ? '#8aa8c6' : '#ffffff'))
|
|
|
|
|
.attr('stroke-width', 2);
|
|
|
|
|
|
|
|
|
|
node
|
|
|
|
|
.append('text')
|
|
|
|
|
.attr('text-anchor', 'middle')
|
|
|
|
|
.attr('dy', 40)
|
|
|
|
|
.attr('font-size', 11)
|
|
|
|
|
.attr('font-weight', 600)
|
|
|
|
|
.attr('fill', '#29445d')
|
|
|
|
|
.text((d) => {
|
|
|
|
|
if (d.kind === 'host') return d.ipAddress ?? d.macAddress ?? 'host';
|
|
|
|
|
return d.label;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
node.append('title').text((d) => `${d.label}\nPackets: ${d.packetCount}`);
|
|
|
|
|
|
|
|
|
|
simulation.on('tick', () => {
|
|
|
|
|
link
|
|
|
|
|
.attr('x1', (d) => (d.source as ForceNode).x ?? 0)
|
|
|
|
|
.attr('y1', (d) => (d.source as ForceNode).y ?? 0)
|
|
|
|
|
.attr('x2', (d) => (d.target as ForceNode).x ?? 0)
|
|
|
|
|
.attr('y2', (d) => (d.target as ForceNode).y ?? 0);
|
|
|
|
|
|
|
|
|
|
node.attr('transform', (d) => `translate(${d.x ?? 0},${d.y ?? 0})`);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
simulation.stop();
|
|
|
|
|
};
|
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
|
|
|
if (data.nodes.length === 0 || data.links.length === 0) {
|
|
|
|
|
return <Empty description="No graph data available yet" />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <svg ref={svgRef} style={{ width: '100%', minWidth: 960 }} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ProtocolHeatmap({ data }: { data: TopologyData }) {
|
|
|
|
|
const svgRef = useRef<SVGSVGElement | null>(null);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!svgRef.current) return;
|
|
|
|
|
|
|
|
|
|
const margin = { top: 140, right: 30, bottom: 30, left: 240 };
|
|
|
|
|
const cellWidth = 92;
|
|
|
|
|
const cellHeight = 34;
|
|
|
|
|
const width = Math.max(920, margin.left + margin.right + data.protocols.length * cellWidth);
|
|
|
|
|
const height = Math.max(320, margin.top + margin.bottom + data.heatmapRows.length * cellHeight);
|
|
|
|
|
|
|
|
|
|
const svg = d3.select(svgRef.current);
|
|
|
|
|
svg.selectAll('*').remove();
|
|
|
|
|
svg.attr('viewBox', `0 0 ${width} ${height}`);
|
|
|
|
|
|
|
|
|
|
if (data.protocols.length === 0 || data.heatmapRows.length === 0) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const x = d3.scaleBand<string>().domain(data.protocols).range([margin.left, width - margin.right]).paddingInner(0.08);
|
|
|
|
|
const y = d3.scaleBand<string>().domain(data.heatmapRows.map((row) => row.hostId)).range([margin.top, height - margin.bottom]).paddingInner(0.08);
|
|
|
|
|
const maxValue = d3.max(data.heatmapRows.flatMap((row) => data.protocols.map((protocol) => row.values[protocol] || 0))) ?? 1;
|
|
|
|
|
const color = d3.scaleSequential(d3.interpolateYlGnBu).domain([0, maxValue]);
|
|
|
|
|
|
|
|
|
|
svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe');
|
|
|
|
|
|
|
|
|
|
const cells = svg.append('g');
|
|
|
|
|
for (const row of data.heatmapRows) {
|
|
|
|
|
for (const protocol of data.protocols) {
|
|
|
|
|
const value = row.values[protocol] || 0;
|
|
|
|
|
const cell = cells
|
|
|
|
|
.append('g')
|
|
|
|
|
.attr('transform', `translate(${x(protocol) ?? 0},${y(row.hostId) ?? 0})`);
|
|
|
|
|
|
|
|
|
|
cell
|
|
|
|
|
.append('rect')
|
|
|
|
|
.attr('width', x.bandwidth())
|
|
|
|
|
.attr('height', y.bandwidth())
|
|
|
|
|
.attr('rx', 8)
|
|
|
|
|
.attr('fill', value > 0 ? color(value) : '#eef3f8')
|
|
|
|
|
.attr('stroke', '#dce5ef');
|
|
|
|
|
|
|
|
|
|
if (value > 0) {
|
|
|
|
|
cell
|
|
|
|
|
.append('text')
|
|
|
|
|
.attr('x', x.bandwidth() / 2)
|
|
|
|
|
.attr('y', y.bandwidth() / 2 + 4)
|
|
|
|
|
.attr('text-anchor', 'middle')
|
|
|
|
|
.attr('font-size', 11)
|
|
|
|
|
.attr('font-weight', 700)
|
|
|
|
|
.attr('fill', value > maxValue * 0.45 ? '#ffffff' : '#23415c')
|
|
|
|
|
.text(value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
cell.append('title').text(`${row.hostLabel}\n${protocol}: ${value} packets`);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
svg
|
|
|
|
|
.append('g')
|
|
|
|
|
.selectAll('text.protocol-label')
|
|
|
|
|
.data(data.protocols)
|
|
|
|
|
.join('text')
|
|
|
|
|
.attr('class', 'protocol-label')
|
|
|
|
|
.attr('x', (protocol) => (x(protocol) ?? 0) + x.bandwidth() / 2)
|
|
|
|
|
.attr('y', margin.top - 12)
|
|
|
|
|
.attr('transform', (protocol) => `rotate(-35, ${(x(protocol) ?? 0) + x.bandwidth() / 2}, ${margin.top - 12})`)
|
|
|
|
|
.attr('text-anchor', 'start')
|
|
|
|
|
.attr('font-size', 12)
|
|
|
|
|
.attr('font-weight', 600)
|
|
|
|
|
.attr('fill', '#29445d')
|
|
|
|
|
.text((protocol) => protocol);
|
|
|
|
|
|
|
|
|
|
svg
|
|
|
|
|
.append('g')
|
|
|
|
|
.selectAll('text.host-label')
|
|
|
|
|
.data(data.heatmapRows)
|
|
|
|
|
.join('text')
|
|
|
|
|
.attr('class', 'host-label')
|
|
|
|
|
.attr('x', margin.left - 12)
|
|
|
|
|
.attr('y', (row) => (y(row.hostId) ?? 0) + y.bandwidth() / 2 + 4)
|
|
|
|
|
.attr('text-anchor', 'end')
|
|
|
|
|
.attr('font-size', 12)
|
|
|
|
|
.attr('fill', '#29445d')
|
|
|
|
|
.text((row) => row.hostLabel);
|
|
|
|
|
}, [data]);
|
|
|
|
|
|
|
|
|
|
if (data.protocols.length === 0 || data.heatmapRows.length === 0) {
|
|
|
|
|
return <Empty description="No protocol heatmap data available yet" />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return <svg ref={svgRef} style={{ width: '100%', minWidth: 960 }} />;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
export default function Analysis(): ReactElement {
|
|
|
|
|
const [sinceMinutes, setSinceMinutes] = useState(60);
|
|
|
|
|
const [limitPerInterface, setLimitPerInterface] = useState(50);
|
|
|
|
|
const [limitProtocolsPerHost, setLimitProtocolsPerHost] = useState(12);
|
|
|
|
|
const [data, setData] = useState<InterfaceHostProtocolAnalysisResponse | null>(null);
|
|
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
|
|
|
|
|
|
|
const loadData = useCallback(async () => {
|
|
|
|
|
setLoading(true);
|
|
|
|
|
try {
|
|
|
|
|
const response = await fetchInterfaceHostProtocolAnalysis(sinceMinutes, limitPerInterface, limitProtocolsPerHost);
|
|
|
|
|
setData(response);
|
|
|
|
|
} catch (error: any) {
|
|
|
|
|
message.error(error?.message ?? 'Failed to load analysis data');
|
|
|
|
|
} finally {
|
|
|
|
|
setLoading(false);
|
|
|
|
|
}
|
|
|
|
|
}, [sinceMinutes, limitPerInterface, limitProtocolsPerHost]);
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
loadData().catch(() => undefined);
|
|
|
|
|
}, [loadData]);
|
|
|
|
|
|
|
|
|
|
const topologyData = useMemo(() => buildTopologyData(data?.interfaces ?? []), [data]);
|
|
|
|
|
|
|
|
|
|
const columns = useMemo<ColumnsType<ProtocolTableRow>>(
|
|
|
|
|
() => [
|
|
|
|
|
{
|
|
|
|
|
title: 'Interface',
|
|
|
|
|
dataIndex: 'interface',
|
|
|
|
|
key: 'interface',
|
|
|
|
|
width: 140,
|
|
|
|
|
render: (value: string) => <Tag color="blue">{value}</Tag>,
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'IP',
|
|
|
|
|
dataIndex: 'ip_address',
|
|
|
|
|
key: 'ip_address',
|
|
|
|
|
render: (value?: string | null) => value ?? '—',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'MAC',
|
|
|
|
|
dataIndex: 'mac_address',
|
|
|
|
|
key: 'mac_address',
|
|
|
|
|
render: (value?: string | null) => value ?? '—',
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
title: 'Protocol',
|
|
|
|
|
dataIndex: 'protocol',
|
|
|
|
|
key: 'protocol',
|
|
|
|
|
width: 140,
|
|
|
|
|
render: (value: string) => <Tag color={protocolColor(value)}>{value}</Tag>,
|
|
|
|
|
},
|
|
|
|
|
{ title: 'Host Packets', dataIndex: 'host_packet_count', key: 'host_packet_count', width: 110 },
|
|
|
|
|
{ title: 'Protocol Packets', dataIndex: 'protocol_packet_count', key: 'protocol_packet_count', width: 130 },
|
|
|
|
|
{ title: 'Accept', dataIndex: 'accept_count', key: 'accept_count', width: 90 },
|
|
|
|
|
{ title: 'Drop', dataIndex: 'drop_count', key: 'drop_count', width: 90 },
|
|
|
|
|
{ title: 'Reject', dataIndex: 'reject_count', key: 'reject_count', width: 90 },
|
|
|
|
|
{ title: 'Unknown', dataIndex: 'unknown_count', key: 'unknown_count', width: 90 },
|
|
|
|
|
{
|
|
|
|
|
title: 'Last Seen',
|
|
|
|
|
dataIndex: 'last_seen',
|
|
|
|
|
key: 'last_seen',
|
|
|
|
|
width: 220,
|
|
|
|
|
render: (value: string) => formatTimestamp(value),
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
[],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div style={{ padding: 16 }}>
|
|
|
|
|
<Row justify="space-between" align="middle" style={{ marginBottom: 12 }}>
|
|
|
|
|
<Col>
|
|
|
|
|
<Title level={2} style={{ margin: 0 }}>
|
|
|
|
|
Analysis
|
|
|
|
|
</Title>
|
|
|
|
|
<Text type="secondary">Explore inferred interface, host, and protocol relationships from captured traffic.</Text>
|
|
|
|
|
</Col>
|
|
|
|
|
</Row>
|
|
|
|
|
|
|
|
|
|
<Card style={{ marginBottom: 16 }}>
|
|
|
|
|
<Space wrap size={[12, 12]}>
|
|
|
|
|
<Space>
|
|
|
|
|
<Text>Look back</Text>
|
|
|
|
|
<InputNumber min={1} max={60 * 24 * 30} value={sinceMinutes} onChange={(value) => setSinceMinutes(value ?? 60)} />
|
|
|
|
|
<Text type="secondary">minutes</Text>
|
|
|
|
|
</Space>
|
|
|
|
|
<Space>
|
|
|
|
|
<Text>Max hosts per interface</Text>
|
|
|
|
|
<InputNumber min={1} max={1000} value={limitPerInterface} onChange={(value) => setLimitPerInterface(value ?? 50)} />
|
|
|
|
|
</Space>
|
|
|
|
|
<Space>
|
|
|
|
|
<Text>Max protocols per host</Text>
|
|
|
|
|
<InputNumber min={1} max={100} value={limitProtocolsPerHost} onChange={(value) => setLimitProtocolsPerHost(value ?? 12)} />
|
|
|
|
|
</Space>
|
|
|
|
|
<Button icon={<ReloadOutlined />} onClick={() => loadData()} loading={loading} type="primary">
|
|
|
|
|
Refresh
|
|
|
|
|
</Button>
|
|
|
|
|
</Space>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
{data?.notes?.length ? (
|
|
|
|
|
<Alert
|
|
|
|
|
type="info"
|
|
|
|
|
showIcon
|
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
|
message="How to read this"
|
|
|
|
|
description={
|
|
|
|
|
<ul style={{ margin: '8px 0 0 18px', padding: 0 }}>
|
|
|
|
|
{data.notes.map((note) => (
|
|
|
|
|
<li key={note}>{note}</li>
|
|
|
|
|
))}
|
|
|
|
|
</ul>
|
|
|
|
|
}
|
|
|
|
|
/>
|
|
|
|
|
) : null}
|
|
|
|
|
|
|
|
|
|
<Card
|
|
|
|
|
title="Topology Views"
|
|
|
|
|
extra={data?.since ? <Text type="secondary">Since {formatTimestamp(data.since)}</Text> : null}
|
|
|
|
|
style={{ marginBottom: 16 }}
|
|
|
|
|
>
|
|
|
|
|
<Spin spinning={loading}>
|
|
|
|
|
<Tabs
|
|
|
|
|
items={[
|
|
|
|
|
{
|
|
|
|
|
key: 'sankey',
|
|
|
|
|
label: 'Sankey',
|
|
|
|
|
children: (
|
|
|
|
|
<div>
|
|
|
|
|
<Paragraph type="secondary">
|
|
|
|
|
Best for understanding how traffic flows from MITM interfaces to inferred hosts and then into protocols.
|
|
|
|
|
</Paragraph>
|
|
|
|
|
<SankeyTopology data={topologyData} />
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'force',
|
|
|
|
|
label: 'Force Graph',
|
|
|
|
|
children: (
|
|
|
|
|
<div>
|
|
|
|
|
<Paragraph type="secondary">Useful for exploring clusters and protocol neighborhoods across interfaces and hosts.</Paragraph>
|
|
|
|
|
<ForceTopology data={topologyData} />
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
{
|
|
|
|
|
key: 'heatmap',
|
|
|
|
|
label: 'Heatmap',
|
|
|
|
|
children: (
|
|
|
|
|
<div>
|
|
|
|
|
<Paragraph type="secondary">Useful for comparing which hosts are most active in which protocols.</Paragraph>
|
|
|
|
|
<ProtocolHeatmap data={topologyData} />
|
|
|
|
|
</div>
|
|
|
|
|
),
|
|
|
|
|
},
|
|
|
|
|
]}
|
|
|
|
|
/>
|
|
|
|
|
</Spin>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
|
<Card title="Protocol Evidence Table">
|
|
|
|
|
<Paragraph type="secondary" style={{ marginTop: -4 }}>
|
|
|
|
|
This is the underlying aggregated evidence used by the visualizations, including verdict counts per interface, host, and protocol.
|
|
|
|
|
</Paragraph>
|
|
|
|
|
<Table
|
|
|
|
|
rowKey="key"
|
|
|
|
|
columns={columns}
|
|
|
|
|
dataSource={topologyData.tableRows}
|
|
|
|
|
size="small"
|
|
|
|
|
bordered
|
|
|
|
|
pagination={{ pageSize: 25 }}
|
|
|
|
|
locale={{ emptyText: loading ? 'Loading…' : 'No interface-host-protocol evidence available yet.' }}
|
|
|
|
|
/>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|