diff --git a/frontend/src/assets/styles/sgds-overrides.css b/frontend/src/assets/styles/sgds-overrides.css index 82a7a529..a08c73c3 100644 --- a/frontend/src/assets/styles/sgds-overrides.css +++ b/frontend/src/assets/styles/sgds-overrides.css @@ -400,6 +400,22 @@ color: var(--tp-text); } +/* Outline-primary buttons that live inside a coloured alert — e.g. the "Contested" + candidate-confirm actions on a warning (amber) alert. SGDS keeps their purple + text/border in dark mode, which is hard to read on the dark amber/red alert + backgrounds. Within alerts, give them a light, legible foreground instead. */ +[data-theme='dark'] .alert .btn-outline-primary { + color: var(--tp-text); + border-color: var(--tp-border); +} + +[data-theme='dark'] .alert .btn-outline-primary:hover, +[data-theme='dark'] .alert .btn-outline-primary:focus { + background-color: var(--tp-surface-hover); + border-color: var(--tp-text); + color: var(--tp-text); +} + [data-theme='dark'] .btn-close { filter: invert(1) grayscale(100%) brightness(200%); } diff --git a/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx b/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx index a15de932..353a4894 100644 --- a/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx +++ b/frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx @@ -26,6 +26,7 @@ import type { VolumePair } from '@/components/network/VolumeHeatmap'; import { VolumeLegend } from '@/components/network/VolumeLegend'; import { useResolvedDark } from '@/utils/useResolvedDark'; import { parseDateTime } from '@/utils/dateUtils'; +import { nodeIdentityKey } from '@/utils/deviceType'; type Tab = 'diagram' | 'changes' | 'security' | 'context' | 'subnets' | 'insights'; @@ -140,7 +141,8 @@ export const SnapshotDetailModal = ({ const [showFilterModal, setShowFilterModal] = useState(false); const [showLabelModal, setShowLabelModal] = useState(false); const isDark = useResolvedDark(); - const [edgeColorMode, setEdgeColorMode] = useState('protocol'); + const [edgeColorMode, setEdgeColorMode] = useState('transport'); + const [isDiagramFullscreen, setIsDiagramFullscreen] = useState(false); const [showHeatmap, setShowHeatmap] = useState(false); const [isHeatmapFullscreen, setIsHeatmapFullscreen] = useState(false); const [selectedPair, setSelectedPair] = useState(null); @@ -203,8 +205,7 @@ export const SnapshotDetailModal = ({ const { nodes, edges, loading: graphLoading } = useNetworkData(diagramSnap.fileId); // "Present" sets for filter options - const presentNodeTypes = useMemo(() => { const s = new Set(); nodes.forEach(n => s.add(n.data.nodeType)); return s; }, [nodes]); - const presentDeviceTypes = useMemo(() => { const s = new Set(); nodes.forEach(n => { if (n.data.deviceType) s.add(n.data.deviceType); }); return s; }, [nodes]); + const presentIdentities = useMemo(() => { const s = new Set(); nodes.forEach(n => s.add(nodeIdentityKey(n.data))); return s; }, [nodes]); const presentEdgeLegendKeys = useMemo(() => { const s = new Set(); edges.forEach(edge => { @@ -278,6 +279,15 @@ export const SnapshotDetailModal = ({ setIsHeatmapFullscreen(false); return; } + // Diagram fullscreen owns Escape too — exit fullscreen instead of closing + // the whole dialog. Arrow stepping stays enabled so snapshots can be paged + // through while fullscreen. + if (e.key === 'Escape' && isDiagramFullscreen) { + e.preventDefault(); + e.stopPropagation(); + setIsDiagramFullscreen(false); + return; + } // Snapshot stepping would be disorienting while the matrix is fullscreen. if (isHeatmapFullscreen) return; if (e.key === 'ArrowLeft' && diagramIndex > 0) { @@ -288,12 +298,15 @@ export const SnapshotDetailModal = ({ }; window.addEventListener('keydown', handler, true); return () => window.removeEventListener('keydown', handler, true); - }, [activeTab, diagramIndex, sorted, isHeatmapFullscreen]); + }, [activeTab, diagramIndex, sorted, isHeatmapFullscreen, isDiagramFullscreen]); - // A fullscreen matrix is position:fixed outside the dialog's stacking context, - // so it must not outlive a tab change that hides the diagram beneath it. + // A fullscreen matrix/diagram is position:fixed outside the dialog's stacking + // context, so it must not outlive a tab change that hides the diagram beneath it. useEffect(() => { - if (activeTab !== 'diagram') setIsHeatmapFullscreen(false); + if (activeTab !== 'diagram') { + setIsHeatmapFullscreen(false); + setIsDiagramFullscreen(false); + } }, [activeTab]); // Context tab state @@ -514,7 +527,7 @@ export const SnapshotDetailModal = ({ {/* ── Network Diagram tab ── */} {activeTab === 'diagram' && ( -
+
+
-
+
{graphLoading ? (
Loading graph… @@ -948,8 +974,7 @@ export const SnapshotDetailModal = ({ activeNodeFilters={activeNodeFilters} onNodeFilterClick={toggleNodeFilter} onNodeFilterClear={() => setActiveNodeFilters([])} - presentNodeTypes={presentNodeTypes} - presentDeviceTypes={presentDeviceTypes} + presentIdentities={presentIdentities} ipFilter={ipFilter} onIpFilterChange={setIpFilter} portFilter={portFilter} diff --git a/frontend/src/components/network/NetworkControls/NetworkControls.tsx b/frontend/src/components/network/NetworkControls/NetworkControls.tsx index de9969ab..ed2d57cf 100644 --- a/frontend/src/components/network/NetworkControls/NetworkControls.tsx +++ b/frontend/src/components/network/NetworkControls/NetworkControls.tsx @@ -3,7 +3,6 @@ import { PROTOCOL_COLORS, PROTOCOL_LABELS, NODE_TYPE_COLORS, - NODE_TYPE_LABELS, DEFAULT_EDGE_COLOR, } from '@/features/network/constants'; import { DEVICE_TYPES, deviceTypeLabel, deviceTypeColor } from '@/utils/deviceType'; @@ -48,8 +47,8 @@ interface NetworkControlsProps { activeNodeFilters: string[]; onNodeFilterClick: (key: string) => void; onNodeFilterClear: () => void; - presentNodeTypes: Set; - presentDeviceTypes: Set; + /** Canonical node-identity keys present in the graph (DeviceType values + 'L2_DEVICE'). */ + presentIdentities: Set; ipFilter: string; onIpFilterChange: (value: string) => void; portFilter: string; @@ -104,12 +103,6 @@ function edgeLegendLabel(key: string): string { function edgeLegendColor(key: string): string { return PROTOCOL_COLORS[key] ?? DEFAULT_EDGE_COLOR; } -function nodeLegendLabel(key: string): string { - return NODE_TYPE_LABELS[key] ?? key.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); -} -function nodeLegendColor(key: string): string { - return NODE_TYPE_COLORS[key] ?? '#95a5a6'; -} function countryFlag(code: string): string { return code .toUpperCase() @@ -126,8 +119,7 @@ export function NetworkControls({ activeNodeFilters, onNodeFilterClick, onNodeFilterClear, - presentNodeTypes, - presentDeviceTypes, + presentIdentities, ipFilter, onIpFilterChange, portFilter, @@ -208,10 +200,15 @@ export function NetworkControls({ portDebounceRef.current = setTimeout(() => onPortFilterChange(value), 300); }; - const allNodeKeys = [ - ...Array.from(presentNodeTypes).map(k => `nt:${k}`), - ...DEVICE_TYPES.filter(dt => presentDeviceTypes.has(dt)).map(dt => `dt:${dt}`), + // One identity taxonomy: canonical keys in DEVICE_TYPES display order, then any custom + // YAML-override values not in that list (appended, sorted) so nothing is dropped. + const orderedIdentities = [ + ...DEVICE_TYPES.filter(dt => presentIdentities.has(dt)), + ...Array.from(presentIdentities) + .filter(k => !DEVICE_TYPES.includes(k)) + .sort(), ]; + const allNodeKeys = orderedIdentities.map(k => `id:${k}`); const legendPillClass = (isActive: boolean) => `badge rounded-pill border-0 nc-legend-pill filter-pill${isActive ? ' active' : ''}`; @@ -385,15 +382,16 @@ export function NetworkControls({
)} - {/* Node Types */} - {(presentNodeTypes.size > 0 || presentDeviceTypes.size > 0) &&
+ {/* Node Identity — one taxonomy (#499/#537): the adjudicated identity of each + node, no longer split into overlapping node-type + device-type lists. */} + {presentIdentities.size > 0 &&
} onSelectAll={() => @@ -404,31 +402,10 @@ export function NetworkControls({ onDeselectAll={onNodeFilterClear} />
- {Array.from(presentNodeTypes).map(key => { - const fkey = `nt:${key}`; - const isActive = activeNodeFilters.includes(fkey); - const color = nodeLegendColor(key); - return ( - - ); - })} - {DEVICE_TYPES.filter(dt => presentDeviceTypes.has(dt)).map(dt => { - const fkey = `dt:${dt}`; + {orderedIdentities.map(key => { + const fkey = `id:${key}`; const isActive = activeNodeFilters.includes(fkey); - const color = deviceTypeColor(dt); + const color = deviceTypeColor(key); return ( ); })} diff --git a/frontend/src/components/network/NetworkGraph/NetworkGraph.css b/frontend/src/components/network/NetworkGraph/NetworkGraph.css index 60bbfe7a..9d1690e3 100644 --- a/frontend/src/components/network/NetworkGraph/NetworkGraph.css +++ b/frontend/src/components/network/NetworkGraph/NetworkGraph.css @@ -310,3 +310,23 @@ .nd-css-fullscreen .network-diagram-graph-body .network-graph-wrapper { height: 100%; } + +/* Diagram fullscreen launched from inside the monitor dialog. Like the heatmap's + over-modal variant, it must stack ABOVE Bootstrap's modal (~1055); the shared + .nd-css-fullscreen sits at 1040 and would render behind the dialog. */ +.nd-css-fullscreen-over-modal { + position: fixed; + inset: 0; + z-index: 1080; + border-radius: 0; + display: flex; + flex-direction: column; + background: var(--tp-surface, #fff); + margin: 0; + padding: 1rem; +} + +.nd-css-fullscreen-over-modal .monitor-diagram-graph { + flex: 1; + min-height: 0; +} diff --git a/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx b/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx index 9a075e3a..cdeb7e0c 100644 --- a/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx +++ b/frontend/src/components/network/NetworkGraph/NetworkGraph.tsx @@ -9,7 +9,8 @@ import { EdgeCurvedArrowProgram, indexParallelEdgesIndex } from '@sigma/edge-cur import circular from 'graphology-layout/circular'; import noverlap from 'graphology-layout-noverlap'; import type { GraphNode, GraphEdge } from '@/features/network/types'; -import { getProtocolColor, NODE_TYPE_CONFIG, buildProtocolLegend, DEFAULT_EDGE_COLOR } from '@/features/network/constants'; +import { getProtocolColor, NODE_TYPE_CONFIG, buildProtocolLegend, buildAppLegend, DEFAULT_EDGE_COLOR } from '@/features/network/constants'; +import { getAppColor } from '@/utils/appColors'; import { makeVolumeEdgeColor } from '@/utils/volumeColor'; import { deviceTypeIcon, deviceTypeLabel, DEVICE_TYPES } from '@/utils/deviceType'; import { useStore } from '@/store'; @@ -27,7 +28,7 @@ export interface NodeHighlight { description?: string; } -export type EdgeColorMode = 'protocol' | 'volume'; +export type EdgeColorMode = 'transport' | 'application' | 'volume'; interface NetworkGraphProps { nodes: GraphNode[]; @@ -354,7 +355,7 @@ function buildGraph( nodes: GraphNode[], edges: GraphEdge[], primarySource?: string, - edgeColorMode: EdgeColorMode = 'protocol', + edgeColorMode: EdgeColorMode = 'transport', darkMode = false, ): Graph { const graph = new Graph({ multi: true, type: 'directed' }); @@ -402,11 +403,14 @@ function buildGraph( primarySource !== undefined && e.data.sources[0] !== primarySource; + const appName = e.data.appName ?? ''; graph.addEdgeWithKey(e.id, e.source, e.target, { color: edgeColorMode === 'volume' ? volumeColor(e.data.totalBytes ?? 0) - : getProtocolColor(e.data.protocol), + : edgeColorMode === 'application' + ? (appName ? getAppColor(appName) : DEFAULT_EDGE_COLOR) + : getProtocolColor(e.data.protocol), size: 1.2, label: e.label, type: 'arrow', @@ -416,6 +420,7 @@ function buildGraph( // place, without rebuilding the graph and throwing away the layout. totalBytes: e.data.totalBytes ?? 0, protocol: e.data.protocol, + appName, }); } @@ -480,7 +485,7 @@ export const NetworkGraph = memo(function NetworkGraph({ onFilterClick, activeFilterCount = 0, highlightedNodes, - edgeColorMode = 'protocol', + edgeColorMode = 'transport', forceLight = false, }: NetworkGraphProps) { const themeMode = useStore(s => s.themeMode); @@ -557,16 +562,15 @@ export const NetworkGraph = memo(function NetworkGraph({ [hiddenNodesList] ); - // Edge-colour legend entries for the protocols present in the current graph. - // Only meaningful when edges are coloured by protocol — in volume mode the - // strokes encode byte counts, not protocols, so the swatches would mislead. - const protocolLegend = useMemo( - () => - edgeColorMode === 'protocol' - ? buildProtocolLegend(edges.map(e => e.data.protocol)) - : { entries: [], hasUnmapped: false }, - [edges, edgeColorMode] - ); + // Edge-colour legend entries for the current colour mode. Volume mode encodes + // byte counts, not categories, so it has no swatch legend. Transport lists the + // base protocols (TCP/UDP/ICMP…); Application lists the nDPI applications + // (WhatsApp, YouTube…) the same way the strokes are coloured, via getAppColor. + const protocolLegend = useMemo(() => { + if (edgeColorMode === 'volume') return { entries: [], hasUnmapped: false }; + if (edgeColorMode === 'application') return buildAppLegend(edges.map(e => e.data.appName)); + return buildProtocolLegend(edges.map(e => e.data.protocol)); + }, [edges, edgeColorMode]); const hiddenNeighbors = useMemo(() => { if (!hoveredNode || crossEdges.length === 0) return []; @@ -948,7 +952,11 @@ export const NetworkGraph = memo(function NetworkGraph({ 'color', edgeColorMode === 'volume' ? volumeColor((attrs.totalBytes as number) ?? 0) - : getProtocolColor((attrs.protocol as string) ?? '') + : edgeColorMode === 'application' + ? ((attrs.appName as string) + ? getAppColor(attrs.appName as string) + : DEFAULT_EDGE_COLOR) + : getProtocolColor((attrs.protocol as string) ?? '') ); }); sigmaRef.current?.refresh(); diff --git a/frontend/src/features/network/__tests__/protocolLegend.test.ts b/frontend/src/features/network/__tests__/protocolLegend.test.ts index a648562d..bc9acecc 100644 --- a/frontend/src/features/network/__tests__/protocolLegend.test.ts +++ b/frontend/src/features/network/__tests__/protocolLegend.test.ts @@ -1,10 +1,34 @@ import { buildProtocolLegend, + buildAppLegend, getProtocolColor, normalizeProtocol, PROTOCOL_COLORS, DEFAULT_EDGE_COLOR, } from '../constants'; +import { getAppColor } from '@/utils/appColors'; + +describe('buildAppLegend', () => { + it('lists one swatch per distinct nDPI application, coloured via getAppColor', () => { + const { entries, hasUnmapped } = buildAppLegend(['WhatsApp', 'YouTube', 'WhatsApp']); + expect(entries).toEqual([ + { color: getAppColor('WhatsApp'), label: 'WhatsApp' }, + { color: getAppColor('YouTube'), label: 'YouTube' }, + ]); + expect(hasUnmapped).toBe(false); + }); + + it('flags edges with no identified application as unmapped (the grey "Other" bucket)', () => { + const { entries, hasUnmapped } = buildAppLegend(['WhatsApp', null, undefined, '']); + expect(entries).toEqual([{ color: getAppColor('WhatsApp'), label: 'WhatsApp' }]); + expect(hasUnmapped).toBe(true); + }); + + it('sorts application labels for a stable legend order', () => { + const { entries } = buildAppLegend(['Zoom', 'Discord', 'Netflix']); + expect(entries.map(e => e.label)).toEqual(['Discord', 'Netflix', 'Zoom']); + }); +}); describe('buildProtocolLegend', () => { it('lists only protocols present in the graph, in PROTOCOL_COLORS order', () => { diff --git a/frontend/src/features/network/constants.ts b/frontend/src/features/network/constants.ts index e31c0a07..23efd517 100644 --- a/frontend/src/features/network/constants.ts +++ b/frontend/src/features/network/constants.ts @@ -1,4 +1,6 @@ import type { NodeType } from './types'; +import { deviceTypeLabel } from '@/utils/deviceType'; +import { getAppColor } from '@/utils/appColors'; /** * Single source of truth for protocol edge colors and display labels used in @@ -110,6 +112,27 @@ export function getProtocolColor(protocol: string): string { return PROTOCOL_COLORS[normalizeProtocol(protocol)] ?? DEFAULT_EDGE_COLOR; } +/** + * Legend entries for the 'application' edge-colour mode: one swatch per distinct nDPI application + * present (WhatsApp, YouTube, …), coloured by getAppColor exactly as the strokes are. Edges with no + * identified application collapse into `hasUnmapped` (the grey "Other" bucket), mirroring + * buildProtocolLegend's shape so the legend renderer handles both modes identically. + */ +export function buildAppLegend( + appNames: Iterable, +): { entries: Array<{ color: string; label: string }>; hasUnmapped: boolean } { + const present = new Set(); + let hasUnmapped = false; + for (const a of appNames) { + if (a) present.add(a); + else hasUnmapped = true; + } + const entries = [...present] + .sort() + .map(label => ({ color: getAppColor(label), label })); + return { entries, hasUnmapped }; +} + /** * Display label overrides for edge protocol legend entries. * Keys not listed here use the key itself as the label. @@ -187,27 +210,12 @@ export const NODE_TYPE_LABELS: Record = Object.fromEntries( ); /** - * Converts a raw activeNodeFilters key (e.g. "nt:router", "dt:IOT") to a - * human-readable label using the existing display maps. + * Converts a raw activeNodeFilters key (e.g. "id:WEB_SERVER", "id:MOBILE") to a + * human-readable label. Filter keys use the single "id:" node-identity taxonomy + * (#499/#537); the value is a DeviceType-family key resolved via deviceTypeLabel. */ export function nodeFilterLabel(key: string): string { - if (key.startsWith('nt:')) { - const type = key.slice(3); - return NODE_TYPE_LABELS[type] ?? type.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase()); - } - if (key.startsWith('dt:')) { - // Inline the deviceTypeLabel logic to avoid a circular import. - const dt = key.slice(3); - switch (dt) { - case 'ROUTER': return 'Router'; - case 'MOBILE': return 'Mobile'; - case 'LAPTOP_DESKTOP': return 'Laptop / Desktop'; - case 'SERVER': return 'Server'; - case 'IOT': return 'IoT Device'; - case 'UNKNOWN': return 'Unknown'; - default: return dt; - } - } + if (key.startsWith('id:')) return deviceTypeLabel(key.slice(3)); return key; } @@ -254,7 +262,7 @@ export function buildActiveFilterLabels(filters: { if (filters.activeLegendProtocols.length > 0) labels.push(`Protocol: ${filters.activeLegendProtocols.join(', ')}`); if (filters.activeNodeFilters.length > 0) - labels.push(`Node type: ${filters.activeNodeFilters.map(nodeFilterLabel).join(', ')}`); + labels.push(`Node identity: ${filters.activeNodeFilters.map(nodeFilterLabel).join(', ')}`); if (filters.activeAppFilters.length > 0) labels.push(`App: ${filters.activeAppFilters.join(', ')}`); if (filters.activeL7Protocols.length > 0) diff --git a/frontend/src/features/network/services/networkService.ts b/frontend/src/features/network/services/networkService.ts index e17732d7..57f0112c 100644 --- a/frontend/src/features/network/services/networkService.ts +++ b/frontend/src/features/network/services/networkService.ts @@ -8,6 +8,7 @@ import type { NodeMap, NodeType, } from '../types'; +import { nodeIdentityKey } from '@/utils/deviceType'; /** MAC address regex — identifies nodes that have no IP and are addressed by MAC only */ const MAC_REGEX = /^([0-9a-f]{2}:){5}[0-9a-f]{2}$/i; @@ -626,8 +627,7 @@ export function applyNetworkFilters( allNodes .filter(n => activeNodeFilters.some(k => { - if (k.startsWith('nt:')) return n.data.nodeType === k.slice(3); - if (k.startsWith('dt:')) return n.data.deviceType === k.slice(3); + if (k.startsWith('id:')) return nodeIdentityKey(n.data) === k.slice(3); return false; }) ) diff --git a/frontend/src/pages/Compare/ComparePage.tsx b/frontend/src/pages/Compare/ComparePage.tsx index c3b8a0fe..17730a06 100644 --- a/frontend/src/pages/Compare/ComparePage.tsx +++ b/frontend/src/pages/Compare/ComparePage.tsx @@ -15,6 +15,7 @@ import { apiClient } from '@/services/api/client'; import { API_ENDPOINTS } from '@/services/api/endpoints'; import { toggleSet } from '@/features/network/constants'; import { edgeMatchesLegendKey, applyNetworkFilters } from '@/features/network/services/networkService'; +import { nodeIdentityKey } from '@/utils/deviceType'; import { formatBytes } from '@/utils/formatters'; import { captureNetworkDiagrams } from '@/features/report/captureNetworkDiagrams'; @@ -144,20 +145,10 @@ export const ComparePage = () => { // ── "Present" sets ─────────────────────────────────────────────────────── - const presentNodeTypes = useMemo(() => { - const types = new Set(); - mergedNodes.forEach(n => { - types.add(n.data.nodeType); - }); - return types; - }, [mergedNodes]); - - const presentDeviceTypes = useMemo(() => { - const types = new Set(); - mergedNodes.forEach(n => { - if (n.data.deviceType) types.add(n.data.deviceType); - }); - return types; + const presentIdentities = useMemo(() => { + const ids = new Set(); + mergedNodes.forEach(n => ids.add(nodeIdentityKey(n.data))); + return ids; }, [mergedNodes]); const presentEdgeLegendKeys = useMemo(() => { @@ -551,9 +542,8 @@ export const ComparePage = () => { activeNodeFilters={activeNodeFilters} onNodeFilterClick={toggleNodeFilter} onNodeFilterClear={() => setActiveNodeFilters([])} - presentNodeTypes={presentNodeTypes} + presentIdentities={presentIdentities} presentEdgeLegendKeys={presentEdgeLegendKeys} - presentDeviceTypes={presentDeviceTypes} ipFilter={ipFilter} onIpFilterChange={setIpFilter} portFilter={portFilter} diff --git a/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx b/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx index 36166c98..b92be20d 100644 --- a/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx +++ b/frontend/src/pages/NetworkDiagram/NetworkDiagramPage.tsx @@ -10,6 +10,7 @@ import { } from '@/features/network/hooks/useNetworkData'; import { buildActiveFilterLabels, toggleSet } from '@/features/network/constants'; import { edgeMatchesLegendKey } from '@/features/network/services/networkService'; +import { nodeIdentityKey } from '@/utils/deviceType'; import { formatBytes } from '@/utils/formatters'; import { NetworkGraph } from '@components/network/NetworkGraph'; import type { EdgeColorMode, NodeHighlight } from '@components/network/NetworkGraph/NetworkGraph'; @@ -89,7 +90,7 @@ export const NetworkDiagramPage = () => { ); const isDark = useResolvedDark(); - const [edgeColorMode, setEdgeColorMode] = useState('protocol'); + const [edgeColorMode, setEdgeColorMode] = useState('transport'); const [showHeatmap, setShowHeatmap] = useState(false); const [isHeatmapFullscreen, setIsHeatmapFullscreen] = useState(false); const heatmapCardRef = useRef(null); @@ -132,20 +133,10 @@ export const NetworkDiagramPage = () => { // ─── "Present" sets: only show options that exist in the data ─────────────── - const presentNodeTypes = useMemo(() => { - const types = new Set(); - nodes.forEach(n => { - types.add(n.data.nodeType); - }); - return types; - }, [nodes]); - - const presentDeviceTypes = useMemo(() => { - const types = new Set(); - nodes.forEach(n => { - if (n.data.deviceType) types.add(n.data.deviceType); - }); - return types; + const presentIdentities = useMemo(() => { + const ids = new Set(); + nodes.forEach(n => ids.add(nodeIdentityKey(n.data))); + return ids; }, [nodes]); const presentEdgeLegendKeys = useMemo(() => { @@ -280,11 +271,7 @@ export const NetworkDiagramPage = () => { nodes .filter(n => activeNodeFilters.some(key => { - if (key.startsWith('nt:')) { - const nt = key.slice(3); - return n.data.nodeType === nt; - } - if (key.startsWith('dt:')) return n.data.deviceType === key.slice(3); + if (key.startsWith('id:')) return nodeIdentityKey(n.data) === key.slice(3); return false; }) ) @@ -575,18 +562,20 @@ export const NetworkDiagramPage = () => { Topology Diagram
- {/* Colour is a single channel — protocol and volume are offered as - alternatives rather than layered, and the active one is always - accompanied by its legend. */} + {/* Colour is a single channel — transport, application, and volume are offered as + alternatives rather than layered, and the active one is always accompanied by + its legend. Transport = base protocol (TCP/UDP/ICMP); Application = deepest + identified protocol (HTTPS, DNS, QUIC…). */}
Color edges by setEdgeColorMode(e.target.value as EdgeColorMode)} > - + +
@@ -728,9 +717,8 @@ export const NetworkDiagramPage = () => { activeNodeFilters={activeNodeFilters} onNodeFilterClick={toggleNodeFilter} onNodeFilterClear={() => setActiveNodeFilters([])} - presentNodeTypes={presentNodeTypes} + presentIdentities={presentIdentities} presentEdgeLegendKeys={presentEdgeLegendKeys} - presentDeviceTypes={presentDeviceTypes} ipFilter={ipFilter} onIpFilterChange={setIpFilter} portFilter={portFilter} diff --git a/frontend/src/pages/NetworkIntelligence/NetworkIntelligencePage.tsx b/frontend/src/pages/NetworkIntelligence/NetworkIntelligencePage.tsx index da266d1d..f289c9c7 100644 --- a/frontend/src/pages/NetworkIntelligence/NetworkIntelligencePage.tsx +++ b/frontend/src/pages/NetworkIntelligence/NetworkIntelligencePage.tsx @@ -19,6 +19,7 @@ import { ClusterGraph } from '@components/intelligence/ClusterGraph/ClusterGraph import { TopHostsTable } from '@components/intelligence/TopHostsTable/TopHostsTable'; import { NetworkControls } from '@components/network/NetworkControls'; import { toggleSet } from '@/features/network/constants'; +import { nodeIdentityKey } from '@/utils/deviceType'; interface AnalysisOutletContext { data: AnalysisData; @@ -65,7 +66,7 @@ export const NetworkIntelligencePage = () => { const [presentFileTypes, setPresentFileTypes] = useState([]); const [presentCustomSigs, setPresentCustomSigs] = useState([]); const [presentCountries, setPresentCountries] = useState([]); - const [presentDeviceTypes, setPresentDeviceTypes] = useState>(new Set()); + const [presentIdentities, setPresentIdentities] = useState>(new Set()); const [presentNetLabels, setPresentNetLabels] = useState([]); useEffect(() => { @@ -78,7 +79,7 @@ export const NetworkIntelligencePage = () => { if (active) setPresentCountries(codes.map(c => c.split('|')[0]).filter(Boolean).sort()); }).catch(() => {}); conversationService.getHostClassifications(fileId).then(hosts => { - if (active) setPresentDeviceTypes(new Set(hosts.map(h => h.deviceType).filter(Boolean) as string[])); + if (active) setPresentIdentities(new Set(hosts.map(h => nodeIdentityKey(h)))); }).catch(() => {}); ipOrgRuleService.list().then(rules => { if (active) setPresentNetLabels([...new Set(rules.map(r => r.label))].sort()); @@ -132,9 +133,6 @@ export const NetworkIntelligencePage = () => { [data], ); - // node types not meaningful at cluster level — pass empty set - const presentNodeTypes = useMemo(() => new Set(), []); - // ── Filter toggles ──────────────────────────────────────────────────────── const toggleLegendProtocol = toggleSet(setActiveLegendProtocols); const toggleNodeFilter = toggleSet(setActiveNodeFilters); @@ -180,9 +178,10 @@ export const NetworkIntelligencePage = () => { // ── Build IntelClusterFilters from active filter state ──────────────────── const intelFilters = useMemo((): IntelClusterFilters => { - // Map activeNodeFilters (dt:MOBILE etc.) → deviceTypes param + // Map activeNodeFilters (id:MOBILE etc.) → deviceTypes param. The node-identity keys are + // DeviceType values, so they pass straight through to the cluster backend's deviceTypes filter. const deviceTypes = activeNodeFilters - .filter(k => k.startsWith('dt:')) + .filter(k => k.startsWith('id:')) .map(k => k.slice(3)); return { @@ -348,8 +347,7 @@ export const NetworkIntelligencePage = () => { activeNodeFilters={activeNodeFilters} onNodeFilterClick={toggleNodeFilter} onNodeFilterClear={() => setActiveNodeFilters([])} - presentNodeTypes={presentNodeTypes} - presentDeviceTypes={presentDeviceTypes} + presentIdentities={presentIdentities} ipFilter={ipFilter} onIpFilterChange={setIpFilter} portFilter={portFilter} diff --git a/frontend/src/utils/__tests__/deviceType.test.ts b/frontend/src/utils/__tests__/deviceType.test.ts index 7780f418..efdfccc0 100644 --- a/frontend/src/utils/__tests__/deviceType.test.ts +++ b/frontend/src/utils/__tests__/deviceType.test.ts @@ -1,4 +1,10 @@ -import { deviceTypeLabel, deviceTypeColor, confidenceLevel, deviceTypeIcon } from '../deviceType'; +import { + deviceTypeLabel, + deviceTypeColor, + confidenceLevel, + deviceTypeIcon, + nodeIdentityKey, +} from '../deviceType'; describe('deviceTypeLabel', () => { it('returns human-readable label for known types', () => { @@ -48,3 +54,57 @@ describe('deviceTypeIcon', () => { expect(deviceTypeIcon('CUSTOM' as never)).toBe('bi-question-circle'); }); }); + +describe('nodeIdentityKey', () => { + it('prefers the adjudicated identity label over everything else', () => { + expect( + nodeIdentityKey({ identityLabel: 'WEB_SERVER', deviceType: 'ROUTER', nodeType: 'router' }) + ).toBe('WEB_SERVER'); + }); + + it('falls back to the machine device type when there is no identity', () => { + expect(nodeIdentityKey({ deviceType: 'MOBILE', nodeType: 'client' })).toBe('MOBILE'); + }); + + it('ignores a non-committal UNKNOWN device type', () => { + expect(nodeIdentityKey({ deviceType: 'UNKNOWN', nodeType: 'client' })).toBe('UNKNOWN'); + expect(nodeIdentityKey({ deviceType: 'UNKNOWN', nodeType: 'l2-device' })).toBe('L2_DEVICE'); + }); + + it('maps pure L2 nodes to the L2_DEVICE identity', () => { + expect(nodeIdentityKey({ nodeType: 'l2-device' })).toBe('L2_DEVICE'); + }); + + it('returns UNKNOWN for a node with no signals', () => { + expect(nodeIdentityKey({ nodeType: 'unknown' })).toBe('UNKNOWN'); + expect(nodeIdentityKey({})).toBe('UNKNOWN'); + }); + + it('collapses the old two-taxonomy overlaps to a single key (the #499/#537 fix)', () => { + // Router: was "Router / Gateway" (nt:router) AND "Router" (dt:ROUTER) — now one key. + expect(nodeIdentityKey({ identityLabel: 'ROUTER', nodeType: 'router', deviceType: 'ROUTER' })) + .toBe('ROUTER'); + // Web server: was "Web Server" twice — now one key. + expect( + nodeIdentityKey({ identityLabel: 'WEB_SERVER', nodeType: 'web-server', deviceType: 'WEB_SERVER' }) + ).toBe('WEB_SERVER'); + // DNS server: was "DNS Server" twice — now one key. + expect( + nodeIdentityKey({ identityLabel: 'DNS_SERVER', nodeType: 'dns-server', deviceType: 'DNS_SERVER' }) + ).toBe('DNS_SERVER'); + }); + + it('keeps the Mobile/IoT/Laptop family distinct (finer than the rendered nodeType)', () => { + // All three render as the generic "client" nodeType but stay separate identities. + expect(nodeIdentityKey({ deviceType: 'MOBILE', nodeType: 'client' })).toBe('MOBILE'); + expect(nodeIdentityKey({ deviceType: 'IOT', nodeType: 'client' })).toBe('IOT'); + expect(nodeIdentityKey({ deviceType: 'LAPTOP_DESKTOP', nodeType: 'client' })).toBe('LAPTOP_DESKTOP'); + }); + + it('renders every resolved key through the display helpers with no missing config', () => { + for (const key of ['ROUTER', 'MOBILE', 'IOT', 'WEB_SERVER', 'DNS_SERVER', 'L2_DEVICE', 'UNKNOWN']) { + expect(deviceTypeLabel(key)).not.toBe(key === 'UNKNOWN' ? '' : key); // has a real label + expect(deviceTypeColor(key)).toMatch(/^#[0-9a-f]{6}$/i); + } + }); +}); diff --git a/frontend/src/utils/deviceType.ts b/frontend/src/utils/deviceType.ts index 7ff44e48..06f5a824 100644 --- a/frontend/src/utils/deviceType.ts +++ b/frontend/src/utils/deviceType.ts @@ -16,6 +16,9 @@ const DEVICE_TYPE_CONFIG: Partial> = { DNS_SERVER: { label: 'DNS Server', color: '#0ea5e9' }, // sky WEB_SERVER: { label: 'Web Server', color: '#6366f1' }, // indigo API_SERVER: { label: 'API Server', color: '#a855f7' }, // purple + // Pure L2 nodes (switches/bridges) have no adjudicated identity or device type of their own, + // but are still a distinct filterable identity. Teal matches the l2-device node colour. + L2_DEVICE: { label: 'L2 Device', color: '#1abc9c' }, // teal UNKNOWN: { label: 'Unknown', color: '#6b7280' }, // gray }; @@ -209,6 +212,7 @@ const DEVICE_TYPE_ICONS: Partial> = { DNS_SERVER: 'bi-hdd-network', WEB_SERVER: 'bi-globe', API_SERVER: 'bi-hdd-stack', + L2_DEVICE: 'bi-ethernet', }; /** @@ -218,7 +222,7 @@ export function deviceTypeIcon(deviceType: DeviceType): string { return DEVICE_TYPE_ICONS[deviceType] ?? 'bi-question-circle'; } -/** All canonical device type values shown in filter UIs. */ +/** All canonical device type values shown in filter UIs, in display order. */ export const DEVICE_TYPES: DeviceType[] = [ 'ROUTER', 'MOBILE', @@ -228,5 +232,33 @@ export const DEVICE_TYPES: DeviceType[] = [ 'DNS_SERVER', 'WEB_SERVER', 'API_SERVER', + 'L2_DEVICE', 'UNKNOWN', ]; + +/** + * The single canonical identity key for a graph node — the one taxonomy the Node Identity filter + * keys on (#499/#537: "Identity owns the verdict"). It replaces the old two parallel filter + * dimensions (`nodeType` + `deviceType`) that rendered the same role twice ("Router / Gateway" + * vs "Router", "Web Server" twice, …). + * + * Resolution order mirrors the display authority in networkService: + * 1. the adjudicated identity label (already a DeviceType value — WEB_SERVER, ROUTER, …) + * 2. else the machine device classification (skipping the non-committal UNKNOWN) + * 3. else the structural L2 fallback for pure switches/bridges + * 4. else UNKNOWN + * + * The result is always a DeviceType (plus 'L2_DEVICE'), so deviceTypeLabel/Color/Icon render it + * with no extra config. Note this is *finer* than the rendered `nodeType`, which collapses the + * Mobile/IoT/Laptop family into "client" — filtering here keeps them distinct. + */ +export function nodeIdentityKey(node: { + identityLabel?: string; + deviceType?: string; + nodeType?: string; +}): string { + if (node.identityLabel) return node.identityLabel; + if (node.deviceType && node.deviceType !== 'UNKNOWN') return node.deviceType; + if (node.nodeType === 'l2-device') return 'L2_DEVICE'; + return 'UNKNOWN'; +}