Skip to content

Commit 1f4b3b6

Browse files
NotYuShengclaude
andauthored
refactor: centralise duplicate frontend helpers (#197)
* refactor: extract shared network filter and label utilities Move duplicated edge-filter logic into applyNetworkFilters() in networkService.ts and duplicated filter-label building into buildActiveFilterLabels() in constants.ts. Both AnalysisPage and NetworkDiagramPage now call the shared helpers, eliminating the divergence risk flagged in review. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * refactor: centralise duplicate helpers across frontend - parseDateTime: remove copy in timelineService, import from dateUtils - formatBytes: remove copies in NetworkDiagramPage + ComparePage, import from utils/formatters - toggleSet: remove copies in NetworkDiagramPage + ComparePage, export from network/constants - edgeMatchesLegendKey: remove copy in ComparePage, import from networkService - applyNetworkFilters: remove inline filter block in ComparePage, call shared helper (hiddenSources pre-filter kept inline as it is compare-specific) - confidenceLevel + buildDeviceSignals: remove copies in both classification popups, export from utils/deviceType - useClickOutside: new shared hook in utils/, replaces duplicate useEffect in both classification popups Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: address UI and LLM error handling issues - Fix LLM connection errors being swallowed by generic 500 handler by re-throwing LlmException and ContextLengthExceededException directly in StoryService - Show friendly LLM unreachable message on frontend for non-502 LLM errors - Hide Device Type filter section when no device-classified nodes are present - Hide Node Types section when no node/device types are present - Remove nav-tabs underline on Analysis page - Fix card header corners not rounding in dark mode with overflow: hidden Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * fix: use Set for IP filter node lookup and tighten LLM error check - Replace O(N×E) fe.some() loop with a Set-based lookup for IP filter node visibility in applyNetworkFilters - Remove overly broad substring matching for LLM errors in StoryPage; rely solely on HTTP 502 now that backend correctly propagates LlmException Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 67fc0ca commit 1f4b3b6

15 files changed

Lines changed: 307 additions & 350 deletions

File tree

backend/src/main/java/com/tracepcap/story/service/StoryService.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@
77
import com.tracepcap.analysis.repository.AnalysisResultRepository;
88
import com.tracepcap.analysis.repository.ConversationRepository;
99
import com.tracepcap.analysis.service.TimelineService;
10+
import com.tracepcap.common.exception.ContextLengthExceededException;
11+
import com.tracepcap.common.exception.LlmException;
1012
import com.tracepcap.common.exception.ResourceNotFoundException;
1113
import com.tracepcap.config.LlmConfig;
1214
import com.tracepcap.file.entity.FileEntity;
@@ -216,6 +218,8 @@ public StoryResponse generateStory(UUID fileId, String additionalContext, String
216218
.build();
217219

218220
storyRepository.save(failedStory);
221+
if (e instanceof LlmException) throw (LlmException) e;
222+
if (e instanceof ContextLengthExceededException) throw (ContextLengthExceededException) e;
219223
throw new RuntimeException("Failed to generate story: " + e.getMessage(), e);
220224
}
221225
}

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,7 @@
162162
--sgds-card-color: var(--tp-text);
163163

164164
color: var(--tp-text);
165+
overflow: hidden;
165166
}
166167

167168
/* List group */

frontend/src/components/common/DeviceClassificationPopup/DeviceClassificationPopup.tsx

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1-
import { useRef, useEffect } from 'react';
2-
import { deviceTypeLabel, deviceTypeColor } from '@/utils/deviceType';
1+
import { useRef } from 'react';
2+
import { deviceTypeLabel, deviceTypeColor, confidenceLevel, buildDeviceSignals } from '@/utils/deviceType';
33
import { portToServiceLabel } from '@/utils/portUtils';
4+
import { useClickOutside } from '@/utils/useClickOutside';
45

56
export interface DeviceClassificationInfo {
67
ip: string;
@@ -12,50 +13,18 @@ export interface DeviceClassificationInfo {
1213
conversationPort?: number;
1314
}
1415

15-
function confidenceLevel(pct: number): string {
16-
if (pct >= 75) return 'Strong';
17-
if (pct >= 50) return 'Moderate';
18-
if (pct >= 25) return 'Low';
19-
return 'Uncertain';
20-
}
21-
22-
function buildSignals(info: DeviceClassificationInfo): string[] {
23-
const signals: string[] = [];
24-
if (info.manufacturer) signals.push(`MAC OUI matched: ${info.manufacturer}`);
25-
if (info.ttl != null) {
26-
const os =
27-
info.ttl <= 64
28-
? 'Linux / Android / iOS'
29-
: info.ttl <= 128
30-
? 'Windows'
31-
: 'Network device (Cisco / BSD)';
32-
signals.push(`TTL ${info.ttl}${os}`);
33-
}
34-
if (info.confidence >= 60) signals.push('Application traffic profile analysed');
35-
if (info.confidence >= 25) signals.push('Network traffic patterns analysed');
36-
return signals;
37-
}
38-
3916
interface DeviceClassificationPopupProps {
4017
info: DeviceClassificationInfo;
4118
onClose: () => void;
4219
}
4320

4421
export function DeviceClassificationPopup({ info, onClose }: DeviceClassificationPopupProps) {
4522
const popupRef = useRef<HTMLDivElement>(null);
46-
const signals = buildSignals(info);
23+
const signals = buildDeviceSignals({ manufacturer: info.manufacturer, ttl: info.ttl, confidence: info.confidence });
4724
const level = confidenceLevel(info.confidence);
4825
const badgeBg = deviceTypeColor(info.deviceType);
4926

50-
useEffect(() => {
51-
const handleClick = (e: MouseEvent) => {
52-
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
53-
onClose();
54-
}
55-
};
56-
document.addEventListener('mousedown', handleClick);
57-
return () => document.removeEventListener('mousedown', handleClick);
58-
}, [onClose]);
27+
useClickOutside(popupRef, onClose);
5928

6029
return (
6130
<div

frontend/src/components/common/NodeClassificationPopup/NodeClassificationPopup.tsx

Lines changed: 5 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { useRef, useEffect } from 'react';
1+
import { useRef } from 'react';
22
import type { NodeType, NodeTypeEvidence } from '@/features/network/types';
33
import type { DeviceType } from '@/types';
4-
import { deviceTypeLabel, deviceTypeColor } from '@/utils/deviceType';
4+
import { deviceTypeLabel, deviceTypeColor, confidenceLevel, buildDeviceSignals } from '@/utils/deviceType';
5+
import { useClickOutside } from '@/utils/useClickOutside';
56

67
export interface NodeClassificationInfo {
78
ip: string;
@@ -21,13 +22,6 @@ export interface NodeClassificationInfo {
2122
ttl?: number;
2223
}
2324

24-
function confidenceLevel(pct: number): string {
25-
if (pct >= 75) return 'Strong';
26-
if (pct >= 50) return 'Moderate';
27-
if (pct >= 25) return 'Low';
28-
return 'Uncertain';
29-
}
30-
3125
function typeEvidence(nodeType: NodeType, ev: NodeTypeEvidence): string {
3226
switch (nodeType) {
3327
case 'router':
@@ -42,23 +36,6 @@ function typeEvidence(nodeType: NodeType, ev: NodeTypeEvidence): string {
4236
}
4337
}
4438

45-
function buildDeviceSignals(info: NodeClassificationInfo): string[] {
46-
const signals: string[] = [];
47-
if (info.manufacturer) signals.push(`MAC OUI matched: ${info.manufacturer}`);
48-
if (info.ttl != null) {
49-
const os =
50-
info.ttl <= 64
51-
? 'Linux / Android / iOS'
52-
: info.ttl <= 128
53-
? 'Windows'
54-
: 'Network device (Cisco / BSD)';
55-
signals.push(`TTL ${info.ttl}${os}`);
56-
}
57-
if ((info.deviceConfidence ?? 0) >= 60) signals.push('Application traffic profile analysed');
58-
if ((info.deviceConfidence ?? 0) >= 25) signals.push('Network traffic patterns analysed');
59-
return signals;
60-
}
61-
6239
function headerStyleFromBadgeClass(badgeClass: string): React.CSSProperties {
6340
if (badgeClass.includes('bg-warning')) return { backgroundColor: '#ffc107', color: '#000' };
6441
if (badgeClass.includes('bg-success')) return { backgroundColor: '#198754', color: '#fff' };
@@ -80,17 +57,9 @@ export function NodeClassificationPopup({ info, onClose }: Props) {
8057
const evText = typeEvidence(info.nodeType, info.typeEvidence);
8158
const deviceBg = info.deviceType ? deviceTypeColor(info.deviceType) : undefined;
8259
const confidence = info.deviceConfidence ?? 0;
83-
const deviceSignals = buildDeviceSignals(info);
60+
const deviceSignals = buildDeviceSignals({ manufacturer: info.manufacturer, ttl: info.ttl, confidence });
8461

85-
useEffect(() => {
86-
const handleClick = (e: MouseEvent) => {
87-
if (popupRef.current && !popupRef.current.contains(e.target as Node)) {
88-
onClose();
89-
}
90-
};
91-
document.addEventListener('mousedown', handleClick);
92-
return () => document.removeEventListener('mousedown', handleClick);
93-
}, [onClose]);
62+
useClickOutside(popupRef, onClose);
9463

9564
return (
9665
<div

frontend/src/components/conversation/ConversationFilterPanel/ConversationFilterPanel.tsx

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
RISK_BADGE,
1414
} from '@/utils/appColors';
1515
import { getProtocolColor } from '@/features/network/constants';
16-
import { DEVICE_TYPES, deviceTypeLabel, deviceTypeColor } from '@/utils/deviceType';
16+
import { deviceTypeLabel, deviceTypeColor } from '@/utils/deviceType';
1717
import './ConversationFilterPanel.css';
1818

1919
interface ProtocolStat {
@@ -40,6 +40,7 @@ interface ConversationFilterPanelProps {
4040
customSignatureOptions: string[];
4141
signatureSeverities?: Record<string, string>;
4242
countryOptions: string[];
43+
presentDeviceTypes?: string[];
4344
activeFilterCount: number;
4445
visibleColumns: Set<ColumnKey>;
4546
onToggleColumn: (key: ColumnKey) => void;
@@ -89,6 +90,7 @@ export function ConversationFilterPanel({
8990
customSignatureOptions,
9091
signatureSeverities = {},
9192
countryOptions,
93+
presentDeviceTypes,
9294
activeFilterCount,
9395
visibleColumns,
9496
onToggleColumn,
@@ -666,6 +668,7 @@ export function ConversationFilterPanel({
666668
)}
667669

668670
{/* Device type filter */}
671+
{presentDeviceTypes && presentDeviceTypes.length > 0 && (
669672
<div className="col-12">
670673
<PillSectionHeader
671674
label="Device Type"
@@ -676,11 +679,11 @@ export function ConversationFilterPanel({
676679
body="Filter by the classified device type of hosts in each conversation. Device types are inferred from traffic patterns and port usage. Click on a device type badge in the conversation details to see the confidence score and evidence."
677680
/>
678681
}
679-
onSelectAll={() => onFiltersChange({ deviceTypes: [...DEVICE_TYPES] })}
682+
onSelectAll={() => onFiltersChange({ deviceTypes: presentDeviceTypes })}
680683
onDeselectAll={() => onFiltersChange({ deviceTypes: [] })}
681684
/>
682685
<div className="d-flex flex-wrap gap-1 mt-1">
683-
{DEVICE_TYPES.map(dt => {
686+
{presentDeviceTypes.map(dt => {
684687
const selected = (filters.deviceTypes ?? []).includes(dt);
685688
const bg = deviceTypeColor(dt);
686689
return (
@@ -699,6 +702,7 @@ export function ConversationFilterPanel({
699702
})}
700703
</div>
701704
</div>
705+
)}
702706

703707
{/* Column visibility */}
704708
{!hideColumnToggle && (

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ export function NetworkControls({
304304
</div>
305305

306306
{/* Node Types */}
307-
<div className="col-12">
307+
{(presentNodeTypes.size > 0 || presentDeviceTypes.size > 0) && <div className="col-12">
308308
<PillSectionHeader
309309
label="Node Types"
310310
info={
@@ -365,7 +365,7 @@ export function NetworkControls({
365365
);
366366
})}
367367
</div>
368-
</div>
368+
</div>}
369369

370370
{/* Edge Protocols */}
371371
{presentEdgeLegendKeys.size > 0 && (

frontend/src/features/network/constants.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,59 @@ export function nodeFilterLabel(key: string): string {
8383
return key;
8484
}
8585

86+
/**
87+
* Returns a toggle callback that adds/removes a value from a string-array state.
88+
* Used in network diagram filter panels to toggle protocol/node/app filters.
89+
*/
90+
export function toggleSet(setter: React.Dispatch<React.SetStateAction<string[]>>) {
91+
return (val: string) =>
92+
setter(prev => (prev.includes(val) ? prev.filter(v => v !== val) : [...prev, val]));
93+
}
94+
95+
/**
96+
* Builds the list of human-readable active-filter labels from a filter state
97+
* snapshot. Used in both NetworkDiagramPage (ref sync) and AnalysisPage (PDF
98+
* report). Centralised here so the two sites stay in sync automatically.
99+
*/
100+
export function buildActiveFilterLabels(filters: {
101+
ipFilter: string;
102+
portFilter: string;
103+
hasRisksOnly: boolean;
104+
activeLegendProtocols: string[];
105+
activeNodeFilters: string[];
106+
activeAppFilters: string[];
107+
activeL7Protocols: string[];
108+
activeCategories: string[];
109+
activeRiskTypes: string[];
110+
activeCustomSigs: string[];
111+
activeFileTypes: string[];
112+
activeCountries: string[];
113+
}): string[] {
114+
const labels: string[] = [];
115+
if (filters.ipFilter) labels.push(`IP: ${filters.ipFilter}`);
116+
if (filters.portFilter) labels.push(`Port: ${filters.portFilter}`);
117+
if (filters.hasRisksOnly) labels.push('Has Risks: Yes');
118+
if (filters.activeLegendProtocols.length > 0)
119+
labels.push(`Protocol: ${filters.activeLegendProtocols.join(', ')}`);
120+
if (filters.activeNodeFilters.length > 0)
121+
labels.push(`Node type: ${filters.activeNodeFilters.map(nodeFilterLabel).join(', ')}`);
122+
if (filters.activeAppFilters.length > 0)
123+
labels.push(`App: ${filters.activeAppFilters.join(', ')}`);
124+
if (filters.activeL7Protocols.length > 0)
125+
labels.push(`L7: ${filters.activeL7Protocols.join(', ')}`);
126+
if (filters.activeCategories.length > 0)
127+
labels.push(`Category: ${filters.activeCategories.join(', ')}`);
128+
if (filters.activeRiskTypes.length > 0)
129+
labels.push(`Risk type: ${filters.activeRiskTypes.join(', ')}`);
130+
if (filters.activeCustomSigs.length > 0)
131+
labels.push(`Custom signature: ${filters.activeCustomSigs.join(', ')}`);
132+
if (filters.activeFileTypes.length > 0)
133+
labels.push(`File type: ${filters.activeFileTypes.join(', ')}`);
134+
if (filters.activeCountries.length > 0)
135+
labels.push(`Country: ${filters.activeCountries.join(', ')}`);
136+
return labels;
137+
}
138+
86139
/**
87140
* Single source of truth for node type colors used in
88141
* NetworkGraph (node fill) and NetworkControls (legend).

0 commit comments

Comments
 (0)