test visualization with d3js
All checks were successful
Build and Deploy MITM Webserver / traffic_target (push) Successful in 1s
Build and Deploy MITM Webserver / build (push) Successful in 11s

This commit is contained in:
2026-03-30 21:34:40 +02:00
parent f758901f83
commit 0463075782
10 changed files with 1842 additions and 6 deletions

View File

@@ -1,6 +1,7 @@
// src/components/Sidebar.tsx
import {
ApartmentOutlined,
ClusterOutlined,
HomeOutlined,
InfoCircleOutlined,
MonitorOutlined,
@@ -19,6 +20,7 @@ const { Sider } = Layout;
const menuItems = [
{ key: PATHS.HOME, icon: <HomeOutlined style={{ fontSize: '18px' }} />, label: 'Home' },
{ key: PATHS.NETWORK, icon: <ApartmentOutlined style={{ fontSize: '18px' }} />, label: 'Network' },
{ key: PATHS.ANALYSIS, icon: <ClusterOutlined style={{ fontSize: '18px' }} />, label: 'Analysis' },
{ key: PATHS.SNIFFING, icon: <MonitorOutlined style={{ fontSize: '18px' }} />, label: 'Sniffing' },
{
key: PATHS.SCRIPTING,

View File

@@ -1,5 +1,6 @@
import axios from 'axios';
import { InterfaceHostAnalysisResponse, InterfaceHostProtocolAnalysisResponse } from '../types/analysis';
import { CreateRuleRequest, ExecResult, RulesetModel } from '../types/firewall';
import {
BridgeCreateRequest,
@@ -172,6 +173,34 @@ export const clearPackets = async (): Promise<any> => {
return res.data;
};
export const fetchInterfaceHostAnalysis = async (
sinceMinutes = 60,
limitPerInterface = 100,
): Promise<InterfaceHostAnalysisResponse> => {
const res = await api.get<InterfaceHostAnalysisResponse>('/analysis/interface-hosts', {
params: {
since_minutes: sinceMinutes,
limit_per_interface: limitPerInterface,
},
});
return res.data;
};
export const fetchInterfaceHostProtocolAnalysis = async (
sinceMinutes = 60,
limitPerInterface = 50,
limitProtocolsPerHost = 12,
): Promise<InterfaceHostProtocolAnalysisResponse> => {
const res = await api.get<InterfaceHostProtocolAnalysisResponse>('/analysis/interface-host-protocols', {
params: {
since_minutes: sinceMinutes,
limit_per_interface: limitPerInterface,
limit_protocols_per_host: limitProtocolsPerHost,
},
});
return res.data;
};
export const fetchRuleset = async (): Promise<{ ruleset: RulesetModel }> => {
const res = await api.get<{ ruleset: RulesetModel }>('/firewall/rules');
return res.data;

View File

@@ -1,6 +1,7 @@
import { Navigate, Route, Routes } from 'react-router-dom';
import App from './App';
import Analysis from './pages/Analysis';
import { Firewall } from './pages/Firewall';
import Home from './pages/Home';
import Network from './pages/Network';
@@ -24,6 +25,7 @@ export default function AppRouter() {
<Route index element={<Navigate to={PATHS.HOME} replace />} />
<Route path={PATHS.HOME.slice(1)} element={<Home />} />
<Route path={PATHS.NETWORK.slice(1)} element={<Network />} />
<Route path={PATHS.ANALYSIS.slice(1)} element={<Analysis />} />
<Route path={PATHS.SNIFFING.slice(1)} element={<Sniffing />} />
<Route path={PATHS.SCRIPTING.slice(1)} element={<Scripting />} />
<Route path={PATHS.FIREWALL.slice(1)} element={<Firewall />} />

View File

@@ -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>
);
}

View File

@@ -2,7 +2,8 @@ export const PATHS = {
ROOT: '/',
HOME: '/home',
NETWORK: '/network',
ANALYSIS: '/analysis',
SNIFFING: '/sniffing',
SCRIPTING: '/scripting',
FIREWALL: '/firewall',
};
};

View File

@@ -0,0 +1,44 @@
export interface InterfaceHostEvidence {
ip_address?: string | null;
mac_address?: string | null;
packet_count: number;
last_seen: string;
source_on_ingress_count: number;
destination_on_egress_count: number;
}
export interface InterfaceAttachment {
interface: string;
hosts: InterfaceHostEvidence[];
}
export interface InterfaceHostAnalysisResponse {
since?: string | null;
interfaces: InterfaceAttachment[];
notes: string[];
}
export interface ProtocolEvidence {
protocol: string;
packet_count: number;
last_seen: string;
accept_count: number;
drop_count: number;
reject_count: number;
unknown_count: number;
}
export interface InterfaceHostProtocolEvidence extends InterfaceHostEvidence {
protocols: ProtocolEvidence[];
}
export interface InterfaceProtocolAttachment {
interface: string;
hosts: InterfaceHostProtocolEvidence[];
}
export interface InterfaceHostProtocolAnalysisResponse {
since?: string | null;
interfaces: InterfaceProtocolAttachment[];
notes: string[];
}