Skip to content

Commit 4f834db

Browse files
NotYuShengclaude
andauthored
feat: add cross-PCAP comparison with joint topology diagram (#187)
* feat: add cross-PCAP comparison with joint topology diagram Introduces a new Compare feature allowing analysts to select two or more PCAP files and visualise their network topologies in a single merged graph. Key changes: - New /compare?files=id1,id2,... route with ComparePage - mergeGraphs utility merges N graphs with MAC-address tiebreaker to prevent false node merges on DHCP lease reuse - useCompareData hook fetches all files in parallel and merges client-side - NetworkGraph extended with primarySource prop for source-aware rendering: dashed edges and borders for secondary-only nodes/edges, bi-layers badge for nodes present in multiple files - Per-file stats bar and file-source toggle pills in ComparePage - FileList updated: row click no longer navigates; added checkboxes for multi-select and Compare selected button; info popover explains both single-file and multi-file workflows - NetworkControls gains defaultCollapsed prop (used by ComparePage) - .network-diagram-page added to shared max-width/margin CSS rule Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: resolve NodeDetails fileId and fragile filename lookup in ComparePage - NodeDetails fileId now resolves to the file where the selected node actually exists (using sources[0] label → fileIds index) rather than always using fileIds[0], fixing broken conversation links for nodes exclusive to secondary PCAPs - Filename resolution now fetches each file's metadata individually via FILE_METADATA endpoint instead of a paginated list capped at 50, so labels are always correct regardless of upload history length Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 724df6c commit 4f834db

File tree

9 files changed

+1040
-34
lines changed

9 files changed

+1040
-34
lines changed

frontend/src/assets/styles/index.css

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,7 @@ body {
158158
.home-page,
159159
.upload-page,
160160
.analysis-page,
161+
.network-diagram-page,
161162
.not-found-page {
162163
max-width: 1200px;
163164
margin: 0 auto;

frontend/src/components/network/NetworkControls/NetworkControls.tsx

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,8 @@ interface NetworkControlsProps {
8585
onHasRisksOnlyChange: (val: boolean) => void;
8686
activeFilterCount: number;
8787
onClearAllFilters: () => void;
88+
/** Whether the panel starts collapsed. Defaults to false (expanded). */
89+
defaultCollapsed?: boolean;
8890
}
8991

9092
function edgeLegendLabel(key: string): string {
@@ -153,8 +155,9 @@ export function NetworkControls({
153155
onHasRisksOnlyChange,
154156
activeFilterCount,
155157
onClearAllFilters,
158+
defaultCollapsed = false,
156159
}: NetworkControlsProps) {
157-
const [isOpen, setIsOpen] = useState(true);
160+
const [isOpen, setIsOpen] = useState(!defaultCollapsed);
158161
const [showColorInfo, setShowColorInfo] = useState(false);
159162
const [ipInput, setIpInput] = useState(ipFilter);
160163
const [portInput, setPortInput] = useState(portFilter);

frontend/src/components/network/NetworkGraph/NetworkGraph.tsx

Lines changed: 57 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -39,17 +39,31 @@ interface NetworkGraphProps {
3939
onLayoutChange?: (layout: 'forceDirected2d' | 'hierarchicalTd') => void;
4040
/** Called once after ELK layout completes and ReactFlow has painted. */
4141
onLayoutComplete?: () => void;
42+
/**
43+
* In compare mode, the label of the "primary" (File A) source.
44+
* Nodes/edges exclusive to the secondary source (File B) are rendered with
45+
* a dashed style to visually distinguish them.
46+
*/
47+
primarySource?: string;
4248
}
4349

4450
interface FlowNodeData extends Record<string, unknown> {
4551
label: string;
4652
color: string;
4753
icon: string;
54+
/** Which file(s) this node appears in — set only in compare mode. */
55+
sources?: string[];
56+
/** Label of the primary (File A) source — used to determine dashed styling. */
57+
primarySource?: string;
4858
}
4959

5060
interface FlowEdgeData extends Record<string, unknown> {
5161
label: string;
5262
offset: number; // perpendicular pixel offset for parallel edges
63+
/** Which file(s) this edge appears in — set only in compare mode. */
64+
sources?: string[];
65+
/** Label of the primary (File A) source — used to determine dashed styling. */
66+
primarySource?: string;
5367
}
5468

5569
// ---------------------------------------------------------------------------
@@ -215,7 +229,8 @@ const ELK_OPTIONS: Record<string, Record<string, string>> = {
215229
async function computeLayout(
216230
nodes: GraphNode[],
217231
edges: GraphEdge[],
218-
layoutType: 'forceDirected2d' | 'hierarchicalTd'
232+
layoutType: 'forceDirected2d' | 'hierarchicalTd',
233+
primarySource?: string
219234
): Promise<{ nodes: Node[]; edges: Edge[] }> {
220235
const dedupedEdges = deduplicateEdges(edges);
221236
const offsetMap = assignEdgeOffsets(dedupedEdges);
@@ -237,22 +252,26 @@ async function computeLayout(
237252
label: n.label,
238253
color: getNodeColor(n.data),
239254
icon: getNodeIcon(n.data),
255+
sources: n.data.sources,
256+
primarySource,
240257
},
241258
width: NODE_WIDTH,
242259
height: NODE_HEIGHT,
243260
}));
244261

245262
const rfEdges: Edge[] = dedupedEdges.map(e => {
246263
const color = getProtocolColor(e.data.protocol);
264+
const sources = e.data.sources;
265+
const isShared = sources && sources.length >= 2;
247266
return {
248267
id: e.id,
249268
source: e.source,
250269
target: e.target,
251270
type: 'networkEdge',
252-
data: { label: e.label, offset: offsetMap.get(e.id) ?? 0 },
271+
data: { label: e.label, offset: offsetMap.get(e.id) ?? 0, sources, primarySource },
253272
style: {
254273
stroke: color,
255-
strokeWidth: 1.5,
274+
strokeWidth: isShared ? 2.5 : 1.5,
256275
},
257276
};
258277
});
@@ -265,9 +284,19 @@ async function computeLayout(
265284
// ---------------------------------------------------------------------------
266285

267286
function NetworkNode({ data }: NodeProps) {
268-
const { label, color, icon } = data as FlowNodeData;
287+
const { label, color, icon, sources, primarySource } = data as FlowNodeData;
288+
const isSecondaryOnly =
289+
sources?.length === 1 && primarySource !== undefined && sources[0] !== primarySource;
290+
const isShared = sources !== undefined && sources.length >= 2;
269291
return (
270-
<div className="network-flow-node" style={{ borderColor: color }}>
292+
<div
293+
className="network-flow-node"
294+
style={{
295+
borderColor: color,
296+
borderStyle: isSecondaryOnly ? 'dashed' : 'solid',
297+
opacity: isSecondaryOnly ? 0.8 : 1,
298+
}}
299+
>
271300
<Handle
272301
type="target"
273302
position={Position.Top}
@@ -284,6 +313,19 @@ function NetworkNode({ data }: NodeProps) {
284313
<i className={`bi ${icon}`} />
285314
</div>
286315
<span className="network-flow-label">{label}</span>
316+
{isShared && (
317+
<i
318+
className="bi bi-layers-fill"
319+
style={{
320+
position: 'absolute',
321+
bottom: 2,
322+
right: 2,
323+
fontSize: '0.6rem',
324+
color,
325+
opacity: 0.85,
326+
}}
327+
/>
328+
)}
287329
</div>
288330
);
289331
}
@@ -293,7 +335,12 @@ function NetworkNode({ data }: NodeProps) {
293335
// ---------------------------------------------------------------------------
294336

295337
function NetworkEdge({ id, sourceX, sourceY, targetX, targetY, data, style }: EdgeProps) {
296-
const { label, offset } = (data ?? { label: '', offset: 0 }) as FlowEdgeData;
338+
const { label, offset, sources, primarySource } = (data ?? { label: '', offset: 0 }) as FlowEdgeData;
339+
const isSecondaryOnly =
340+
sources?.length === 1 && primarySource !== undefined && sources[0] !== primarySource;
341+
const edgeStyle = isSecondaryOnly
342+
? { ...style, strokeDasharray: '6 3' }
343+
: style;
297344

298345
// Use a canonical direction for the perpendicular so that A→B and B→A
299346
// both receive the same perpendicular unit vector.
@@ -322,7 +369,7 @@ function NetworkEdge({ id, sourceX, sourceY, targetX, targetY, data, style }: Ed
322369

323370
return (
324371
<>
325-
<BaseEdge id={id} path={edgePath} style={style} />
372+
<BaseEdge id={id} path={edgePath} style={edgeStyle} />
326373
<polygon
327374
points="-6,-3.5 6,0 -6,3.5"
328375
transform={`translate(${arrowX},${arrowY}) rotate(${angle})`}
@@ -360,6 +407,7 @@ export const NetworkGraph = memo(function NetworkGraph({
360407
layoutType = 'forceDirected2d',
361408
onLayoutChange,
362409
onLayoutComplete,
410+
primarySource,
363411
}: NetworkGraphProps) {
364412
const themeMode = useStore(s => s.themeMode);
365413
const [sysDark, setSysDark] = useState(
@@ -403,7 +451,7 @@ export const NetworkGraph = memo(function NetworkGraph({
403451
}
404452

405453
setLayouting(true);
406-
computeLayout(visibleNodes, edges, layoutType)
454+
computeLayout(visibleNodes, edges, layoutType, primarySource)
407455
.then(({ nodes: n, edges: e }) => {
408456
if (!active) return;
409457
setRfNodes(n);
@@ -419,7 +467,7 @@ export const NetworkGraph = memo(function NetworkGraph({
419467
return () => {
420468
active = false;
421469
};
422-
}, [visibleNodes, edges, layoutType]);
470+
}, [visibleNodes, edges, layoutType, primarySource]);
423471

424472
// Signal the caller once the layout has been computed and painted.
425473
// Works for both the normal case (rfNodes set after ELK) and the empty-data

frontend/src/components/upload/FileList/FileList.tsx

Lines changed: 101 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { useNavigate } from 'react-router-dom';
2-
import { useEffect, useState } from 'react';
2+
import { useEffect, useState, useRef } from 'react';
33
import { isAxiosError } from 'axios';
44
import { Card, Modal } from '@govtechsg/sgds-react';
55
import { AlertCircle } from 'lucide-react';
@@ -22,6 +22,28 @@ export const FileList = () => {
2222
const [loading, setLoading] = useState(true);
2323
const [pendingDeleteFile, setPendingDeleteFile] = useState<FileMetadata | null>(null);
2424
const [confirmDeleteAll, setConfirmDeleteAll] = useState(false);
25+
const [selectedForCompare, setSelectedForCompare] = useState<Set<string>>(new Set());
26+
const [showInfo, setShowInfo] = useState(false);
27+
const infoRef = useRef<HTMLDivElement>(null);
28+
29+
// Close info popover when clicking outside
30+
useEffect(() => {
31+
if (!showInfo) return;
32+
const handler = (e: MouseEvent) => {
33+
if (infoRef.current && !infoRef.current.contains(e.target as Node)) {
34+
setShowInfo(false);
35+
}
36+
};
37+
document.addEventListener('mousedown', handler);
38+
return () => document.removeEventListener('mousedown', handler);
39+
}, [showInfo]);
40+
41+
const toggleCompareSelect = (fileId: string) =>
42+
setSelectedForCompare(prev => {
43+
const next = new Set(prev);
44+
next.has(fileId) ? next.delete(fileId) : next.add(fileId);
45+
return next;
46+
});
2547

2648
const fetchFiles = async () => {
2749
try {
@@ -80,19 +102,70 @@ export const FileList = () => {
80102
<>
81103
<Card className="file-list-card mt-4">
82104
<Card.Header className="d-flex justify-content-between align-items-center">
83-
<h5 className="mb-0">
84-
<i className="bi bi-folder2-open me-2"></i>
85-
All Uploads
86-
</h5>
105+
<div className="d-flex align-items-center gap-1">
106+
<h5 className="mb-0">
107+
<i className="bi bi-folder2-open me-2"></i>
108+
All Uploads
109+
</h5>
110+
<div ref={infoRef} style={{ position: 'relative' }}>
111+
<button
112+
type="button"
113+
className="btn btn-link p-0 text-muted"
114+
style={{ lineHeight: 1 }}
115+
onClick={() => setShowInfo(v => !v)}
116+
aria-label="About file actions"
117+
>
118+
<i className="bi bi-info-circle" style={{ fontSize: '0.85rem' }}></i>
119+
</button>
120+
{showInfo && (
121+
<div
122+
className="card shadow"
123+
style={{
124+
position: 'absolute',
125+
top: '1.6rem',
126+
left: 0,
127+
zIndex: 100,
128+
width: '260px',
129+
fontSize: '0.82rem',
130+
}}
131+
>
132+
<div className="card-body py-2 px-3">
133+
<p className="mb-1">
134+
<i className="bi bi-graph-up me-1 text-primary"></i>
135+
Click <strong>Analyze</strong> on any file to open its individual analysis.
136+
</p>
137+
<p className="mb-0">
138+
<i className="bi bi-diagram-3 me-1 text-primary"></i>
139+
Select <strong>two or more</strong> files using the checkboxes, then click <strong>Compare selected</strong> for cross-PCAP topology analysis.
140+
</p>
141+
</div>
142+
</div>
143+
)}
144+
</div>
145+
</div>
87146
{files.length > 0 && (
88-
<button
89-
type="button"
90-
className="btn btn-outline-danger btn-sm"
91-
onClick={() => setConfirmDeleteAll(true)}
92-
>
93-
<i className="bi bi-trash me-1"></i>
94-
Delete all
95-
</button>
147+
<div className="d-flex gap-2">
148+
{selectedForCompare.size >= 2 && (
149+
<button
150+
type="button"
151+
className="btn btn-outline-primary btn-sm"
152+
onClick={() =>
153+
navigate(`/compare?files=${[...selectedForCompare].join(',')}`)
154+
}
155+
>
156+
<i className="bi bi-diagram-3 me-1"></i>
157+
Compare selected ({selectedForCompare.size})
158+
</button>
159+
)}
160+
<button
161+
type="button"
162+
className="btn btn-outline-danger btn-sm"
163+
onClick={() => setConfirmDeleteAll(true)}
164+
>
165+
<i className="bi bi-trash me-1"></i>
166+
Delete all
167+
</button>
168+
</div>
96169
)}
97170
</Card.Header>
98171
<Card.Body className="p-0">
@@ -117,12 +190,22 @@ export const FileList = () => {
117190
{files.map(file => (
118191
<div
119192
key={file.fileId}
120-
className="list-group-item list-group-item-action d-flex justify-content-between align-items-center"
121-
style={{ cursor: 'pointer' }}
122-
onClick={() => navigate(`/analysis/${file.fileId}`)}
193+
className="list-group-item d-flex justify-content-between align-items-center"
123194
>
124195
<div className="flex-grow-1">
125196
<div className="d-flex align-items-center gap-2">
197+
<input
198+
type="checkbox"
199+
className="form-check-input mt-0 flex-shrink-0"
200+
checked={selectedForCompare.has(file.fileId)}
201+
disabled={file.status.toLowerCase() !== 'completed'}
202+
title={
203+
file.status.toLowerCase() !== 'completed'
204+
? 'File must be fully processed to compare'
205+
: 'Select for comparison'
206+
}
207+
onChange={() => toggleCompareSelect(file.fileId)}
208+
/>
126209
<i
127210
className="bi bi-file-earmark-binary text-primary"
128211
style={{ fontSize: '1.2rem' }}
@@ -147,20 +230,14 @@ export const FileList = () => {
147230
<div className="d-flex gap-2 align-items-center">
148231
<button
149232
className="btn btn-outline-primary btn-sm"
150-
onClick={e => {
151-
e.stopPropagation();
152-
navigate(`/analysis/${file.fileId}`);
153-
}}
233+
onClick={() => navigate(`/analysis/${file.fileId}`)}
154234
>
155235
<i className="bi bi-graph-up me-1"></i>
156236
Analyze
157237
</button>
158238
<button
159239
className="btn btn-link btn-sm p-0 text-danger"
160-
onClick={e => {
161-
e.stopPropagation();
162-
setPendingDeleteFile(file);
163-
}}
240+
onClick={() => setPendingDeleteFile(file)}
164241
title="Delete this file"
165242
>
166243
<i className="bi bi-trash"></i>

0 commit comments

Comments
 (0)