Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions frontend/src/assets/styles/sgds-overrides.css
Original file line number Diff line number Diff line change
Expand Up @@ -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%);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -140,7 +141,8 @@ export const SnapshotDetailModal = ({
const [showFilterModal, setShowFilterModal] = useState(false);
const [showLabelModal, setShowLabelModal] = useState(false);
const isDark = useResolvedDark();
const [edgeColorMode, setEdgeColorMode] = useState<EdgeColorMode>('protocol');
const [edgeColorMode, setEdgeColorMode] = useState<EdgeColorMode>('transport');
const [isDiagramFullscreen, setIsDiagramFullscreen] = useState(false);
const [showHeatmap, setShowHeatmap] = useState(false);
const [isHeatmapFullscreen, setIsHeatmapFullscreen] = useState(false);
const [selectedPair, setSelectedPair] = useState<VolumePair | null>(null);
Comment on lines 141 to 148

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Escape while diagram-fullscreen doesn't prioritize closing open modals/node-details first.

Unlike the analogous handler in NetworkDiagramPage.tsx (which checks showLabelModalshowFilterModalselectedNode before exiting fullscreen), this new isDiagramFullscreen branch exits fullscreen unconditionally and calls e.stopPropagation(). Since showFilterModal/showLabelModal remain openable while fullscreen (the Filters/tag buttons stay active), pressing Escape with one of those open will exit fullscreen and swallow the keypress that would otherwise close the modal — the modal is left open, requiring a second Escape press to dismiss it.

🐛 Proposed fix
       if (e.key === 'Escape' && isDiagramFullscreen) {
         e.preventDefault();
         e.stopPropagation();
+        if (showLabelModal) { setShowLabelModal(false); return; }
+        if (showFilterModal) { setShowFilterModal(false); return; }
+        if (selectedNode) { setSelectedNode(null); return; }
         setIsDiagramFullscreen(false);
         return;
       }

Also applies to: 269-301

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@frontend/src/components/monitor/SnapshotDetailModal/SnapshotDetailModal.tsx`
around lines 141 - 148, Update the Escape-key handler in SnapshotDetailModal to
prioritize closing showLabelModal, showFilterModal, and selectedPair before
handling isDiagramFullscreen. Only exit diagram fullscreen and stop propagation
when no modal or node-detail selection is active, matching the priority order
used by NetworkDiagramPage.

Expand Down Expand Up @@ -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<string>(); nodes.forEach(n => s.add(n.data.nodeType)); return s; }, [nodes]);
const presentDeviceTypes = useMemo(() => { const s = new Set<string>(); nodes.forEach(n => { if (n.data.deviceType) s.add(n.data.deviceType); }); return s; }, [nodes]);
const presentIdentities = useMemo(() => { const s = new Set<string>(); nodes.forEach(n => s.add(nodeIdentityKey(n.data))); return s; }, [nodes]);
const presentEdgeLegendKeys = useMemo(() => {
const s = new Set<string>();
edges.forEach(edge => {
Expand Down Expand Up @@ -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) {
Expand All @@ -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
Expand Down Expand Up @@ -514,7 +527,7 @@ export const SnapshotDetailModal = ({

{/* ── Network Diagram tab ── */}
{activeTab === 'diagram' && (
<div>
<div className={isDiagramFullscreen ? 'nd-css-fullscreen-over-modal' : ''}>
<div className="d-flex align-items-center gap-3 mb-3 flex-wrap">
<div className="d-flex align-items-center gap-2">
<Button size="sm" variant="outline-secondary"
Expand Down Expand Up @@ -548,11 +561,12 @@ export const SnapshotDetailModal = ({
<Form.Label className="mb-0 text-muted small">Color edges by</Form.Label>
<Form.Select
size="sm"
style={{ width: 110 }}
style={{ width: 130 }}
value={edgeColorMode}
onChange={e => setEdgeColorMode(e.target.value as EdgeColorMode)}
>
<option value="protocol">Protocol</option>
<option value="transport">Transport</option>
<option value="application">Application</option>
<option value="volume">Volume</option>
</Form.Select>
{edgeColorMode === 'volume' && (
Expand Down Expand Up @@ -593,8 +607,20 @@ export const SnapshotDetailModal = ({
>
<i className="bi bi-tags" />
</Button>
<Button
variant="link"
size="sm"
className="p-0 text-muted"
onClick={() => setIsDiagramFullscreen(f => !f)}
title={isDiagramFullscreen ? 'Exit fullscreen' : 'Fullscreen'}
>
<i className={`bi ${isDiagramFullscreen ? 'bi-fullscreen-exit' : 'bi-arrows-fullscreen'}`} />
</Button>
</div>
<div className="monitor-diagram-graph" style={{ height: 460 }}>
<div
className="monitor-diagram-graph"
style={isDiagramFullscreen ? undefined : { height: 460 }}
>
{graphLoading ? (
<div className="d-flex align-items-center justify-content-center h-100 text-muted">
<Spinner animation="border" size="sm" className="me-2" />Loading graph…
Expand Down Expand Up @@ -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}
Expand Down
67 changes: 22 additions & 45 deletions frontend/src/components/network/NetworkControls/NetworkControls.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -48,8 +47,8 @@ interface NetworkControlsProps {
activeNodeFilters: string[];
onNodeFilterClick: (key: string) => void;
onNodeFilterClear: () => void;
presentNodeTypes: Set<string>;
presentDeviceTypes: Set<string>;
/** Canonical node-identity keys present in the graph (DeviceType values + 'L2_DEVICE'). */
presentIdentities: Set<string>;
ipFilter: string;
onIpFilterChange: (value: string) => void;
portFilter: string;
Expand Down Expand Up @@ -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()
Expand All @@ -126,8 +119,7 @@ export function NetworkControls({
activeNodeFilters,
onNodeFilterClick,
onNodeFilterClear,
presentNodeTypes,
presentDeviceTypes,
presentIdentities,
ipFilter,
onIpFilterChange,
portFilter,
Expand Down Expand Up @@ -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' : ''}`;
Expand Down Expand Up @@ -385,15 +382,16 @@ export function NetworkControls({
</div>
)}

{/* Node Types */}
{(presentNodeTypes.size > 0 || presentDeviceTypes.size > 0) && <div className="col-12">
{/* 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 && <div className="col-12">
<PillSectionHeader
label="Node Types"
label="Node Identity"
info={
<InfoPopover
id="nc-info-nodetypes"
title="Node Types"
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."
id="nc-info-nodeidentity"
title="Node Identity"
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."
/>
}
onSelectAll={() =>
Expand All @@ -404,31 +402,10 @@ export function NetworkControls({
onDeselectAll={onNodeFilterClear}
/>
<div className="d-flex flex-wrap gap-1 mt-1">
{Array.from(presentNodeTypes).map(key => {
const fkey = `nt:${key}`;
const isActive = activeNodeFilters.includes(fkey);
const color = nodeLegendColor(key);
return (
<button
key={fkey}
type="button"
className={legendPillClass(isActive)}
style={
isActive
? { backgroundColor: color, color: getTextColor(color) }
: undefined
}
onClick={() => onNodeFilterClick(fkey)}
>
<span className="nc-dot" style={{ background: color }} />
{nodeLegendLabel(key)}
</button>
);
})}
{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);
Comment thread
NotYuSheng marked this conversation as resolved.
return (
<button
key={fkey}
Expand All @@ -442,7 +419,7 @@ export function NetworkControls({
onClick={() => onNodeFilterClick(fkey)}
>
<span className="nc-dot" style={{ background: color }} />
{deviceTypeLabel(dt)}
{deviceTypeLabel(key)}
Comment thread
NotYuSheng marked this conversation as resolved.
</button>
);
})}
Expand Down
20 changes: 20 additions & 0 deletions frontend/src/components/network/NetworkGraph/NetworkGraph.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
40 changes: 24 additions & 16 deletions frontend/src/components/network/NetworkGraph/NetworkGraph.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -27,7 +28,7 @@ export interface NodeHighlight {
description?: string;
}

export type EdgeColorMode = 'protocol' | 'volume';
export type EdgeColorMode = 'transport' | 'application' | 'volume';

interface NetworkGraphProps {
nodes: GraphNode[];
Expand Down Expand Up @@ -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' });
Expand Down Expand Up @@ -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',
Expand All @@ -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,
});
}

Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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<GraphNode[]>(() => {
if (!hoveredNode || crossEdges.length === 0) return [];
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading