Skip to content

Commit 999f4ac

Browse files
NotYuShengclaude
andauthored
feat(network): single Node Identity filter + selectable edge-colour modes (#556)
Node Types filter (#499/#537 follow-up): the panel rendered two overlapping taxonomies (nodeType + deviceType) side by side, so Router, Web Server, DNS Server and Unknown each appeared twice. Collapse them into one taxonomy keyed on the adjudicated identity via a new nodeIdentityKey() (identityLabel → deviceType → L2_DEVICE → UNKNOWN), rename the section to "Node Identity", and switch every filter site to a single id: key namespace. Mobile/IoT/Laptop now stay distinct (finer than the rendered nodeType, which collapses them to "client"). Edge colour: the old "Protocol"/"Volume" toggle becomes Transport / Application / Volume in the same "Color edges by" dropdown. - Transport: base protocol (TCP/UDP/ICMP…), the previous behaviour. - Application: the nDPI application (WhatsApp, YouTube…) via getAppColor, with a matching legend (buildAppLegend); flows with no identified app fall to the grey "Other" bucket. This fixes edges reading as grey TCP when the identity lived only in appName. UI fixes: - Dark mode: contested "confirm" buttons (outline-primary on a warning alert) kept their unreadable purple; give outline-primary buttons inside alerts a legible light foreground in dark mode. - Monitor: the snapshot-detail network diagram gains a fullscreen button like the analysis diagram, using an over-modal z-index variant so it stacks above the dialog, with Escape-to-exit and tab-change cleanup. Tests: nodeIdentityKey resolution/dedup and buildAppLegend. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 9dc3573 commit 999f4ac

13 files changed

Lines changed: 296 additions & 150 deletions

File tree

frontend/src/assets/styles/sgds-overrides.css

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,22 @@
400400
color: var(--tp-text);
401401
}
402402

403+
/* Outline-primary buttons that live inside a coloured alert — e.g. the "Contested"
404+
candidate-confirm actions on a warning (amber) alert. SGDS keeps their purple
405+
text/border in dark mode, which is hard to read on the dark amber/red alert
406+
backgrounds. Within alerts, give them a light, legible foreground instead. */
407+
[data-theme='dark'] .alert .btn-outline-primary {
408+
color: var(--tp-text);
409+
border-color: var(--tp-border);
410+
}
411+
412+
[data-theme='dark'] .alert .btn-outline-primary:hover,
413+
[data-theme='dark'] .alert .btn-outline-primary:focus {
414+
background-color: var(--tp-surface-hover);
415+
border-color: var(--tp-text);
416+
color: var(--tp-text);
417+
}
418+
403419
[data-theme='dark'] .btn-close {
404420
filter: invert(1) grayscale(100%) brightness(200%);
405421
}

frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx

Lines changed: 38 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import type { VolumePair } from '@/components/network/VolumeHeatmap';
2626
import { VolumeLegend } from '@/components/network/VolumeLegend';
2727
import { useResolvedDark } from '@/utils/useResolvedDark';
2828
import { parseDateTime } from '@/utils/dateUtils';
29+
import { nodeIdentityKey } from '@/utils/deviceType';
2930

3031
type Tab = 'diagram' | 'changes' | 'security' | 'context' | 'subnets' | 'insights';
3132

@@ -140,7 +141,8 @@ export const SnapshotDetailModal = ({
140141
const [showFilterModal, setShowFilterModal] = useState(false);
141142
const [showLabelModal, setShowLabelModal] = useState(false);
142143
const isDark = useResolvedDark();
143-
const [edgeColorMode, setEdgeColorMode] = useState<EdgeColorMode>('protocol');
144+
const [edgeColorMode, setEdgeColorMode] = useState<EdgeColorMode>('transport');
145+
const [isDiagramFullscreen, setIsDiagramFullscreen] = useState(false);
144146
const [showHeatmap, setShowHeatmap] = useState(false);
145147
const [isHeatmapFullscreen, setIsHeatmapFullscreen] = useState(false);
146148
const [selectedPair, setSelectedPair] = useState<VolumePair | null>(null);
@@ -203,8 +205,7 @@ export const SnapshotDetailModal = ({
203205
const { nodes, edges, loading: graphLoading } = useNetworkData(diagramSnap.fileId);
204206

205207
// "Present" sets for filter options
206-
const presentNodeTypes = useMemo(() => { const s = new Set<string>(); nodes.forEach(n => s.add(n.data.nodeType)); return s; }, [nodes]);
207-
const presentDeviceTypes = useMemo(() => { const s = new Set<string>(); nodes.forEach(n => { if (n.data.deviceType) s.add(n.data.deviceType); }); return s; }, [nodes]);
208+
const presentIdentities = useMemo(() => { const s = new Set<string>(); nodes.forEach(n => s.add(nodeIdentityKey(n.data))); return s; }, [nodes]);
208209
const presentEdgeLegendKeys = useMemo(() => {
209210
const s = new Set<string>();
210211
edges.forEach(edge => {
@@ -278,6 +279,15 @@ export const SnapshotDetailModal = ({
278279
setIsHeatmapFullscreen(false);
279280
return;
280281
}
282+
// Diagram fullscreen owns Escape too — exit fullscreen instead of closing
283+
// the whole dialog. Arrow stepping stays enabled so snapshots can be paged
284+
// through while fullscreen.
285+
if (e.key === 'Escape' && isDiagramFullscreen) {
286+
e.preventDefault();
287+
e.stopPropagation();
288+
setIsDiagramFullscreen(false);
289+
return;
290+
}
281291
// Snapshot stepping would be disorienting while the matrix is fullscreen.
282292
if (isHeatmapFullscreen) return;
283293
if (e.key === 'ArrowLeft' && diagramIndex > 0) {
@@ -288,12 +298,15 @@ export const SnapshotDetailModal = ({
288298
};
289299
window.addEventListener('keydown', handler, true);
290300
return () => window.removeEventListener('keydown', handler, true);
291-
}, [activeTab, diagramIndex, sorted, isHeatmapFullscreen]);
301+
}, [activeTab, diagramIndex, sorted, isHeatmapFullscreen, isDiagramFullscreen]);
292302

293-
// A fullscreen matrix is position:fixed outside the dialog's stacking context,
294-
// so it must not outlive a tab change that hides the diagram beneath it.
303+
// A fullscreen matrix/diagram is position:fixed outside the dialog's stacking
304+
// context, so it must not outlive a tab change that hides the diagram beneath it.
295305
useEffect(() => {
296-
if (activeTab !== 'diagram') setIsHeatmapFullscreen(false);
306+
if (activeTab !== 'diagram') {
307+
setIsHeatmapFullscreen(false);
308+
setIsDiagramFullscreen(false);
309+
}
297310
}, [activeTab]);
298311

299312
// Context tab state
@@ -514,7 +527,7 @@ export const SnapshotDetailModal = ({
514527

515528
{/* ── Network Diagram tab ── */}
516529
{activeTab === 'diagram' && (
517-
<div>
530+
<div className={isDiagramFullscreen ? 'nd-css-fullscreen-over-modal' : ''}>
518531
<div className="d-flex align-items-center gap-3 mb-3 flex-wrap">
519532
<div className="d-flex align-items-center gap-2">
520533
<Button size="sm" variant="outline-secondary"
@@ -548,11 +561,12 @@ export const SnapshotDetailModal = ({
548561
<Form.Label className="mb-0 text-muted small">Color edges by</Form.Label>
549562
<Form.Select
550563
size="sm"
551-
style={{ width: 110 }}
564+
style={{ width: 130 }}
552565
value={edgeColorMode}
553566
onChange={e => setEdgeColorMode(e.target.value as EdgeColorMode)}
554567
>
555-
<option value="protocol">Protocol</option>
568+
<option value="transport">Transport</option>
569+
<option value="application">Application</option>
556570
<option value="volume">Volume</option>
557571
</Form.Select>
558572
{edgeColorMode === 'volume' && (
@@ -593,8 +607,20 @@ export const SnapshotDetailModal = ({
593607
>
594608
<i className="bi bi-tags" />
595609
</Button>
610+
<Button
611+
variant="link"
612+
size="sm"
613+
className="p-0 text-muted"
614+
onClick={() => setIsDiagramFullscreen(f => !f)}
615+
title={isDiagramFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
616+
>
617+
<i className={`bi ${isDiagramFullscreen ? 'bi-fullscreen-exit' : 'bi-arrows-fullscreen'}`} />
618+
</Button>
596619
</div>
597-
<div className="monitor-diagram-graph" style={{ height: 460 }}>
620+
<div
621+
className="monitor-diagram-graph"
622+
style={isDiagramFullscreen ? undefined : { height: 460 }}
623+
>
598624
{graphLoading ? (
599625
<div className="d-flex align-items-center justify-content-center h-100 text-muted">
600626
<Spinner animation="border" size="sm" className="me-2" />Loading graph…
@@ -948,8 +974,7 @@ export const SnapshotDetailModal = ({
948974
activeNodeFilters={activeNodeFilters}
949975
onNodeFilterClick={toggleNodeFilter}
950976
onNodeFilterClear={() => setActiveNodeFilters([])}
951-
presentNodeTypes={presentNodeTypes}
952-
presentDeviceTypes={presentDeviceTypes}
977+
presentIdentities={presentIdentities}
953978
ipFilter={ipFilter}
954979
onIpFilterChange={setIpFilter}
955980
portFilter={portFilter}

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

Lines changed: 22 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import {
33
PROTOCOL_COLORS,
44
PROTOCOL_LABELS,
55
NODE_TYPE_COLORS,
6-
NODE_TYPE_LABELS,
76
DEFAULT_EDGE_COLOR,
87
} from '@/features/network/constants';
98
import { DEVICE_TYPES, deviceTypeLabel, deviceTypeColor } from '@/utils/deviceType';
@@ -48,8 +47,8 @@ interface NetworkControlsProps {
4847
activeNodeFilters: string[];
4948
onNodeFilterClick: (key: string) => void;
5049
onNodeFilterClear: () => void;
51-
presentNodeTypes: Set<string>;
52-
presentDeviceTypes: Set<string>;
50+
/** Canonical node-identity keys present in the graph (DeviceType values + 'L2_DEVICE'). */
51+
presentIdentities: Set<string>;
5352
ipFilter: string;
5453
onIpFilterChange: (value: string) => void;
5554
portFilter: string;
@@ -104,12 +103,6 @@ function edgeLegendLabel(key: string): string {
104103
function edgeLegendColor(key: string): string {
105104
return PROTOCOL_COLORS[key] ?? DEFAULT_EDGE_COLOR;
106105
}
107-
function nodeLegendLabel(key: string): string {
108-
return NODE_TYPE_LABELS[key] ?? key.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
109-
}
110-
function nodeLegendColor(key: string): string {
111-
return NODE_TYPE_COLORS[key] ?? '#95a5a6';
112-
}
113106
function countryFlag(code: string): string {
114107
return code
115108
.toUpperCase()
@@ -126,8 +119,7 @@ export function NetworkControls({
126119
activeNodeFilters,
127120
onNodeFilterClick,
128121
onNodeFilterClear,
129-
presentNodeTypes,
130-
presentDeviceTypes,
122+
presentIdentities,
131123
ipFilter,
132124
onIpFilterChange,
133125
portFilter,
@@ -208,10 +200,15 @@ export function NetworkControls({
208200
portDebounceRef.current = setTimeout(() => onPortFilterChange(value), 300);
209201
};
210202

211-
const allNodeKeys = [
212-
...Array.from(presentNodeTypes).map(k => `nt:${k}`),
213-
...DEVICE_TYPES.filter(dt => presentDeviceTypes.has(dt)).map(dt => `dt:${dt}`),
203+
// One identity taxonomy: canonical keys in DEVICE_TYPES display order, then any custom
204+
// YAML-override values not in that list (appended, sorted) so nothing is dropped.
205+
const orderedIdentities = [
206+
...DEVICE_TYPES.filter(dt => presentIdentities.has(dt)),
207+
...Array.from(presentIdentities)
208+
.filter(k => !DEVICE_TYPES.includes(k))
209+
.sort(),
214210
];
211+
const allNodeKeys = orderedIdentities.map(k => `id:${k}`);
215212

216213
const legendPillClass = (isActive: boolean) =>
217214
`badge rounded-pill border-0 nc-legend-pill filter-pill${isActive ? ' active' : ''}`;
@@ -385,15 +382,16 @@ export function NetworkControls({
385382
</div>
386383
)}
387384

388-
{/* Node Types */}
389-
{(presentNodeTypes.size > 0 || presentDeviceTypes.size > 0) && <div className="col-12">
385+
{/* Node Identity — one taxonomy (#499/#537): the adjudicated identity of each
386+
node, no longer split into overlapping node-type + device-type lists. */}
387+
{presentIdentities.size > 0 && <div className="col-12">
390388
<PillSectionHeader
391-
label="Node Types"
389+
label="Node Identity"
392390
info={
393391
<InfoPopover
394-
id="nc-info-nodetypes"
395-
title="Node Types"
396-
body="Filter by the inferred role of each node. Determined from graph data: a node's dominant inbound port identifies it as a specific server type (e.g. DNS, HTTP), a high distinct peer count marks it as a router, and nodes that only initiate connections are classified as clients."
392+
id="nc-info-nodeidentity"
393+
title="Node Identity"
394+
body="Filter by each node's adjudicated identity — the single verdict on what a host is, combining measured service roles, hardware/OUI and behaviour. A human-confirmed identity wins; otherwise it is the machine's best classification."
397395
/>
398396
}
399397
onSelectAll={() =>
@@ -404,31 +402,10 @@ export function NetworkControls({
404402
onDeselectAll={onNodeFilterClear}
405403
/>
406404
<div className="d-flex flex-wrap gap-1 mt-1">
407-
{Array.from(presentNodeTypes).map(key => {
408-
const fkey = `nt:${key}`;
409-
const isActive = activeNodeFilters.includes(fkey);
410-
const color = nodeLegendColor(key);
411-
return (
412-
<button
413-
key={fkey}
414-
type="button"
415-
className={legendPillClass(isActive)}
416-
style={
417-
isActive
418-
? { backgroundColor: color, color: getTextColor(color) }
419-
: undefined
420-
}
421-
onClick={() => onNodeFilterClick(fkey)}
422-
>
423-
<span className="nc-dot" style={{ background: color }} />
424-
{nodeLegendLabel(key)}
425-
</button>
426-
);
427-
})}
428-
{DEVICE_TYPES.filter(dt => presentDeviceTypes.has(dt)).map(dt => {
429-
const fkey = `dt:${dt}`;
405+
{orderedIdentities.map(key => {
406+
const fkey = `id:${key}`;
430407
const isActive = activeNodeFilters.includes(fkey);
431-
const color = deviceTypeColor(dt);
408+
const color = deviceTypeColor(key);
432409
return (
433410
<button
434411
key={fkey}
@@ -442,7 +419,7 @@ export function NetworkControls({
442419
onClick={() => onNodeFilterClick(fkey)}
443420
>
444421
<span className="nc-dot" style={{ background: color }} />
445-
{deviceTypeLabel(dt)}
422+
{deviceTypeLabel(key)}
446423
</button>
447424
);
448425
})}

frontend/src/components/network/NetworkGraph/NetworkGraph.css

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -310,3 +310,23 @@
310310
.nd-css-fullscreen .network-diagram-graph-body .network-graph-wrapper {
311311
height: 100%;
312312
}
313+
314+
/* Diagram fullscreen launched from inside the monitor dialog. Like the heatmap's
315+
over-modal variant, it must stack ABOVE Bootstrap's modal (~1055); the shared
316+
.nd-css-fullscreen sits at 1040 and would render behind the dialog. */
317+
.nd-css-fullscreen-over-modal {
318+
position: fixed;
319+
inset: 0;
320+
z-index: 1080;
321+
border-radius: 0;
322+
display: flex;
323+
flex-direction: column;
324+
background: var(--tp-surface, #fff);
325+
margin: 0;
326+
padding: 1rem;
327+
}
328+
329+
.nd-css-fullscreen-over-modal .monitor-diagram-graph {
330+
flex: 1;
331+
min-height: 0;
332+
}

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

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { EdgeCurvedArrowProgram, indexParallelEdgesIndex } from '@sigma/edge-cur
99
import circular from 'graphology-layout/circular';
1010
import noverlap from 'graphology-layout-noverlap';
1111
import type { GraphNode, GraphEdge } from '@/features/network/types';
12-
import { getProtocolColor, NODE_TYPE_CONFIG, buildProtocolLegend, DEFAULT_EDGE_COLOR } from '@/features/network/constants';
12+
import { getProtocolColor, NODE_TYPE_CONFIG, buildProtocolLegend, buildAppLegend, DEFAULT_EDGE_COLOR } from '@/features/network/constants';
13+
import { getAppColor } from '@/utils/appColors';
1314
import { makeVolumeEdgeColor } from '@/utils/volumeColor';
1415
import { deviceTypeIcon, deviceTypeLabel, DEVICE_TYPES } from '@/utils/deviceType';
1516
import { useStore } from '@/store';
@@ -27,7 +28,7 @@ export interface NodeHighlight {
2728
description?: string;
2829
}
2930

30-
export type EdgeColorMode = 'protocol' | 'volume';
31+
export type EdgeColorMode = 'transport' | 'application' | 'volume';
3132

3233
interface NetworkGraphProps {
3334
nodes: GraphNode[];
@@ -354,7 +355,7 @@ function buildGraph(
354355
nodes: GraphNode[],
355356
edges: GraphEdge[],
356357
primarySource?: string,
357-
edgeColorMode: EdgeColorMode = 'protocol',
358+
edgeColorMode: EdgeColorMode = 'transport',
358359
darkMode = false,
359360
): Graph {
360361
const graph = new Graph({ multi: true, type: 'directed' });
@@ -402,11 +403,14 @@ function buildGraph(
402403
primarySource !== undefined &&
403404
e.data.sources[0] !== primarySource;
404405

406+
const appName = e.data.appName ?? '';
405407
graph.addEdgeWithKey(e.id, e.source, e.target, {
406408
color:
407409
edgeColorMode === 'volume'
408410
? volumeColor(e.data.totalBytes ?? 0)
409-
: getProtocolColor(e.data.protocol),
411+
: edgeColorMode === 'application'
412+
? (appName ? getAppColor(appName) : DEFAULT_EDGE_COLOR)
413+
: getProtocolColor(e.data.protocol),
410414
size: 1.2,
411415
label: e.label,
412416
type: 'arrow',
@@ -416,6 +420,7 @@ function buildGraph(
416420
// place, without rebuilding the graph and throwing away the layout.
417421
totalBytes: e.data.totalBytes ?? 0,
418422
protocol: e.data.protocol,
423+
appName,
419424
});
420425
}
421426

@@ -480,7 +485,7 @@ export const NetworkGraph = memo(function NetworkGraph({
480485
onFilterClick,
481486
activeFilterCount = 0,
482487
highlightedNodes,
483-
edgeColorMode = 'protocol',
488+
edgeColorMode = 'transport',
484489
forceLight = false,
485490
}: NetworkGraphProps) {
486491
const themeMode = useStore(s => s.themeMode);
@@ -557,16 +562,15 @@ export const NetworkGraph = memo(function NetworkGraph({
557562
[hiddenNodesList]
558563
);
559564

560-
// Edge-colour legend entries for the protocols present in the current graph.
561-
// Only meaningful when edges are coloured by protocol — in volume mode the
562-
// strokes encode byte counts, not protocols, so the swatches would mislead.
563-
const protocolLegend = useMemo(
564-
() =>
565-
edgeColorMode === 'protocol'
566-
? buildProtocolLegend(edges.map(e => e.data.protocol))
567-
: { entries: [], hasUnmapped: false },
568-
[edges, edgeColorMode]
569-
);
565+
// Edge-colour legend entries for the current colour mode. Volume mode encodes
566+
// byte counts, not categories, so it has no swatch legend. Transport lists the
567+
// base protocols (TCP/UDP/ICMP…); Application lists the nDPI applications
568+
// (WhatsApp, YouTube…) the same way the strokes are coloured, via getAppColor.
569+
const protocolLegend = useMemo(() => {
570+
if (edgeColorMode === 'volume') return { entries: [], hasUnmapped: false };
571+
if (edgeColorMode === 'application') return buildAppLegend(edges.map(e => e.data.appName));
572+
return buildProtocolLegend(edges.map(e => e.data.protocol));
573+
}, [edges, edgeColorMode]);
570574

571575
const hiddenNeighbors = useMemo<GraphNode[]>(() => {
572576
if (!hoveredNode || crossEdges.length === 0) return [];
@@ -948,7 +952,11 @@ export const NetworkGraph = memo(function NetworkGraph({
948952
'color',
949953
edgeColorMode === 'volume'
950954
? volumeColor((attrs.totalBytes as number) ?? 0)
951-
: getProtocolColor((attrs.protocol as string) ?? '')
955+
: edgeColorMode === 'application'
956+
? ((attrs.appName as string)
957+
? getAppColor(attrs.appName as string)
958+
: DEFAULT_EDGE_COLOR)
959+
: getProtocolColor((attrs.protocol as string) ?? '')
952960
);
953961
});
954962
sigmaRef.current?.refresh();

0 commit comments

Comments
 (0)