diff --git a/backend/src/utilities/database.py b/backend/src/utilities/database.py
index 7fa1fa8..ae4b63c 100644
--- a/backend/src/utilities/database.py
+++ b/backend/src/utilities/database.py
@@ -496,6 +496,10 @@ class DatabasePool:
stream_kind: str,
stream_id: int,
observed_at_ms: int,
+ src_ip: str,
+ dst_ip: str,
+ src_port: int,
+ dst_port: int,
enrichment: Dict[str, Any],
window_ms: int,
) -> int:
@@ -522,12 +526,26 @@ class DatabasePool:
AND (capture_iface = $2 OR ingress_if = $2 OR egress_if = $2)
AND timestamp BETWEEN $5 AND $6
AND (
- CASE
- WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '')
- WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '')
- ELSE ''
- END
- ) = $4
+ (
+ CASE
+ WHEN $3 = 'tcp' THEN COALESCE(dpi_metadata -> 'tcp' ->> 'stream', '')
+ WHEN $3 = 'udp' THEN COALESCE(dpi_metadata -> 'udp' ->> 'stream', '')
+ ELSE ''
+ END
+ ) = $4
+ OR (
+ src_ip = $7::inet
+ AND dst_ip = $8::inet
+ AND COALESCE(src_port, 0) = $9
+ AND COALESCE(dst_port, 0) = $10
+ )
+ OR (
+ src_ip = $8::inet
+ AND dst_ip = $7::inet
+ AND COALESCE(src_port, 0) = $10
+ AND COALESCE(dst_port, 0) = $9
+ )
+ )
AND (
app_protocol IS NULL
OR app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
@@ -546,21 +564,21 @@ class DatabasePool:
updated_at = NOW(),
app_protocol = CASE
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
- THEN COALESCE($7, packets.app_protocol)
+ THEN COALESCE($11, packets.app_protocol)
ELSE packets.app_protocol
END,
app_category = CASE
WHEN packets.app_category IS NULL OR packets.app_category IN ('Transport', 'Network', 'Protocol')
- THEN COALESCE($8, packets.app_category)
+ THEN COALESCE($12, packets.app_category)
ELSE packets.app_category
END,
app_confidence = CASE
WHEN packets.app_protocol IS NULL OR packets.app_protocol IN ('TCP', 'UDP', 'IP', 'IPv6', 'ETH')
- THEN COALESCE($9, packets.app_confidence)
+ THEN COALESCE($13, packets.app_confidence)
ELSE packets.app_confidence
END,
- app_hostname = COALESCE(packets.app_hostname, $10),
- app_is_encrypted = COALESCE(packets.app_is_encrypted, $11),
+ app_hostname = COALESCE(packets.app_hostname, $14),
+ app_is_encrypted = COALESCE(packets.app_is_encrypted, $15),
flow_id = COALESCE(
packets.flow_id,
COALESCE(NULLIF(packets.capture_session_id, '') || ':', '') || $3 || ':' || $4
@@ -570,7 +588,7 @@ class DatabasePool:
SELECT DISTINCT source
FROM unnest(
COALESCE(packets.capture_sources, ARRAY[]::text[]) ||
- COALESCE($12::text[], ARRAY[]::text[])
+ COALESCE($16::text[], ARRAY[]::text[])
) AS source
)
)
@@ -584,6 +602,10 @@ class DatabasePool:
str(stream_id),
lower_bound,
upper_bound,
+ src_ip,
+ dst_ip,
+ src_port,
+ dst_port,
enrichment.get("app_protocol"),
enrichment.get("app_category"),
enrichment.get("app_confidence"),
diff --git a/backend/src/utilities/tshark_manager.py b/backend/src/utilities/tshark_manager.py
index 1316229..d977e75 100644
--- a/backend/src/utilities/tshark_manager.py
+++ b/backend/src/utilities/tshark_manager.py
@@ -956,6 +956,10 @@ class TsharkManager:
stream_kind=stream_kind,
stream_id=stream_id,
observed_at_ms=int(event["observed_at_ms"]),
+ src_ip=str(event["src_ip"]),
+ dst_ip=str(event["dst_ip"]),
+ src_port=int(event["src_port"]),
+ dst_port=int(event["dst_port"]),
enrichment=stream_enrichment,
window_ms=max(settings.tshark_match_window_ms, 10_000),
)
diff --git a/frontend/src/Layout/Sidebar.tsx b/frontend/src/Layout/Sidebar.tsx
index 17949c1..5daa036 100644
--- a/frontend/src/Layout/Sidebar.tsx
+++ b/frontend/src/Layout/Sidebar.tsx
@@ -1,7 +1,7 @@
// src/components/Sidebar.tsx
import {
ApartmentOutlined,
- ClusterOutlined,
+ AreaChartOutlined,
HomeOutlined,
InfoCircleOutlined,
MonitorOutlined,
@@ -20,14 +20,14 @@ const { Sider } = Layout;
const menuItems = [
{ key: PATHS.HOME, icon: , label: 'Home' },
{ key: PATHS.NETWORK, icon: , label: 'Network' },
- { key: PATHS.ANALYSIS, icon: , label: 'Analysis' },
+ { key: PATHS.FIREWALL, icon: , label: 'Firewall' },
{ key: PATHS.SNIFFING, icon: , label: 'Sniffing' },
{
key: PATHS.SCRIPTING,
icon: ,
label: 'Scripting',
},
- { key: PATHS.FIREWALL, icon: , label: 'Firewall' },
+ { key: PATHS.ANALYSIS, icon: , label: 'Analysis' },
{ key: '/about', icon: , label: 'About' },
{ key: '/settings', icon: , label: 'Settings' },
];
diff --git a/frontend/src/pages/Analysis.tsx b/frontend/src/pages/Analysis.tsx
index fcf98fb..a5917ca 100644
--- a/frontend/src/pages/Analysis.tsx
+++ b/frontend/src/pages/Analysis.tsx
@@ -47,6 +47,7 @@ type TopologyLink = {
source: string;
target: string;
value: number;
+ packetCount: number;
label: string;
};
@@ -124,6 +125,14 @@ function protocolColor(protocol: string) {
return palette[hash % palette.length];
}
+function clamp(value: number, min: number, max: number) {
+ return Math.min(max, Math.max(min, value));
+}
+
+function sankeyVisualWeight(packetCount: number) {
+ return Math.max(1, Math.sqrt(Math.max(0, packetCount)));
+}
+
function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyData {
const nodes = new Map();
const links = new Map();
@@ -157,7 +166,8 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD
links.set(interfaceHostLinkId, {
source: interfaceNodeId,
target: hostId,
- value: host.packet_count,
+ value: sankeyVisualWeight(host.packet_count),
+ packetCount: host.packet_count,
label: `${entry.interface} -> ${host.ip_address ?? host.mac_address ?? 'host'} (${host.packet_count})`,
});
@@ -187,7 +197,8 @@ function buildTopologyData(interfaces: InterfaceProtocolAttachment[]): TopologyD
links.set(hostProtocolLinkId, {
source: hostId,
target: protocolId,
- value: protocol.packet_count,
+ value: sankeyVisualWeight(protocol.packet_count),
+ packetCount: protocol.packet_count,
label: `${host.ip_address ?? host.mac_address ?? 'host'} -> ${protocol.protocol} (${protocol.packet_count})`,
});
@@ -229,7 +240,13 @@ function SankeyTopology({ data }: { data: TopologyData }) {
if (!svgRef.current) return;
const width = 1160;
- const height = Math.max(420, data.nodes.length * 26);
+ const nodesPerLayer = {
+ interface: data.nodes.filter((node) => node.kind === 'interface').length,
+ host: data.nodes.filter((node) => node.kind === 'host').length,
+ protocol: data.nodes.filter((node) => node.kind === 'protocol').length,
+ };
+ const maxNodesInLayer = Math.max(nodesPerLayer.interface, nodesPerLayer.host, nodesPerLayer.protocol, 1);
+ const height = clamp(maxNodesInLayer * 56 + 120, 260, 760);
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
svg.attr('viewBox', `0 0 ${width} ${height}`);
@@ -246,7 +263,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
const sankeyLayout = d3Sankey()
.nodeId((node) => node.id)
.nodeWidth(18)
- .nodePadding(16)
+ .nodePadding(maxNodesInLayer <= 4 ? 28 : maxNodesInLayer <= 8 ? 22 : 16)
.extent([
[24, 28],
[width - 24, height - 28],
@@ -254,22 +271,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
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);
+ const linkLayer = svg.append('g').attr('fill', 'none').attr('stroke-opacity', 0.4);
linkLayer
.selectAll('path')
.data(layout.links as SankeyLinkDatum[])
@@ -280,12 +282,11 @@ function SankeyTopology({ data }: { data: TopologyData }) {
if (target.kind === 'protocol' && target.protocol) {
return protocolColor(target.protocol);
}
- return '#7f8ea3';
+ return '#9aa7b5';
})
.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}`);
+ .text((link) => `${link.label}\nPackets: ${link.packetCount}`);
const nodeLayer = svg.append('g');
const node = nodeLayer
@@ -299,7 +300,6 @@ function SankeyTopology({ data }: { data: TopologyData }) {
.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';
@@ -307,7 +307,7 @@ function SankeyTopology({ data }: { data: TopologyData }) {
})
.attr('stroke', (d) => (d.kind === 'host' ? '#9bb8d6' : 'none'))
.append('title')
- .text((d) => `${d.label}\nPackets: ${d.value ?? d.packetCount}`);
+ .text((d) => `${d.label}\nPackets: ${d.packetCount}`);
node
.append('text')
@@ -338,8 +338,8 @@ function ForceTopology({ data }: { data: TopologyData }) {
useEffect(() => {
if (!svgRef.current) return;
- const width = 1160;
- const height = 720;
+ const width = 1320;
+ const height = 860;
const svg = d3.select(svgRef.current);
svg.selectAll('*').remove();
svg.attr('viewBox', `0 0 ${width} ${height}`);
@@ -350,6 +350,32 @@ function ForceTopology({ data }: { data: TopologyData }) {
const nodes: ForceNode[] = data.nodes.map((node) => ({ ...node }));
const links: ForceLink[] = data.links.map((link) => ({ ...link }));
+ const groupedNodes = {
+ interface: nodes.filter((node) => node.kind === 'interface').sort((left, right) => left.label.localeCompare(right.label)),
+ host: nodes.filter((node) => node.kind === 'host').sort((left, right) => (left.ipAddress ?? left.macAddress ?? left.label).localeCompare(right.ipAddress ?? right.macAddress ?? right.label)),
+ protocol: nodes.filter((node) => node.kind === 'protocol').sort((left, right) => left.label.localeCompare(right.label)),
+ };
+
+ const distributedY = (group: ForceNode[], top: number, bottom: number) => {
+ const targets = new Map();
+ if (group.length === 0) {
+ return targets;
+ }
+ if (group.length === 1) {
+ targets.set(group[0].id, (top + bottom) / 2);
+ return targets;
+ }
+ const step = (bottom - top) / (group.length - 1);
+ group.forEach((node, index) => {
+ targets.set(node.id, top + step * index);
+ });
+ return targets;
+ };
+
+ const interfaceY = distributedY(groupedNodes.interface, 120, height - 120);
+ const hostY = distributedY(groupedNodes.host, 90, height - 90);
+ const protocolY = distributedY(groupedNodes.protocol, 120, height - 120);
+ const targetY = (node: ForceNode) => interfaceY.get(node.id) ?? hostY.get(node.id) ?? protocolY.get(node.id) ?? height / 2;
const simulation = d3
.forceSimulation(nodes)
@@ -360,20 +386,29 @@ function ForceTopology({ data }: { data: TopologyData }) {
.id((node) => node.id)
.distance((link) => {
const source = link.source as ForceNode;
- return source.kind === 'interface' ? 140 : 120;
+ return source.kind === 'interface' ? 250 : 200;
+ })
+ .strength((link) => {
+ const source = link.source as ForceNode;
+ return source.kind === 'interface' ? 0.45 : 0.35;
}),
)
- .force('charge', d3.forceManyBody().strength(-280))
- .force('collision', d3.forceCollide().radius((node) => (node.kind === 'host' ? 38 : 28)))
+ .force('charge', d3.forceManyBody().strength(-720))
+ .force('collision', d3.forceCollide().radius((node) => {
+ if (node.kind === 'interface') return 52;
+ if (node.kind === 'host') return 44;
+ return 34;
+ }))
.force(
'x',
d3.forceX().x((node) => {
- if (node.kind === 'interface') return 150;
+ if (node.kind === 'interface') return 180;
if (node.kind === 'host') return width / 2;
- return width - 180;
- }).strength(0.32),
+ return width - 200;
+ }).strength(0.42),
)
- .force('y', d3.forceY(height / 2).strength(0.08));
+ .force('y', d3.forceY().y((node) => targetY(node)).strength(0.22))
+ .force('center', d3.forceCenter(width / 2, height / 2).strength(0.06));
svg.append('rect').attr('x', 0).attr('y', 0).attr('width', width).attr('height', height).attr('rx', 18).attr('fill', '#fbfcfe');
@@ -445,7 +480,7 @@ function ForceTopology({ data }: { data: TopologyData }) {
return ;
}
- return ;
+ return ;
}
function ProtocolHeatmap({ data }: { data: TopologyData }) {