Skip to content

Commit d7b677c

Browse files
Valery Petrovclaude
andcommitted
fix: match NetFlow protocol names and drop the records eslint-disable
Bare Lucene terms searched the raw protocol number, so "TCP" matched no flows while charts, pills and records all show protocol names. The default full-text expression now uses the protocol-name transform from the NetFlow dimensions, moved to common-utils so both share one list. The source modal can save a non-NetFlow source. The page now switches only to a created NetFlow source instead of showing "NetFlow source unavailable". Flow records keyed rows by index behind an eslint-disable, which fails the escape-hatch ratchet. Rows are now keyed by content plus a repeat count. The changeset no longer claims dashboard-filter support, because dashboard filters cannot select NetFlow sources. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 7ebbb8d commit d7b677c

7 files changed

Lines changed: 76 additions & 35 deletions

File tree

‎.changeset/native-netflow-monitoring.md‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,9 +44,9 @@ expression. Share the summary aggregate query, reuse accessible filter menus,
4444
restore valid Sankey dimensions across source changes, and document source
4545
mappings consistently in REST and MCP.
4646

47-
Apply the same bare-term search defaults to dashboards, chart editing, alert
48-
details, and dashboard filters. Support timestamp expression lists in flow
49-
records and normalize partial traffic buckets over their selected duration.
47+
Apply the same bare-term search defaults to dashboards, chart editing, and alert
48+
details. Support timestamp expression lists in flow records and normalize
49+
partial traffic buckets over their selected duration.
5050

5151
Preserve saved source mappings when connections, databases, or metadata change.
5252
Show removable range filters using the shared filter pills and apply configured

‎packages/app/src/NetflowPage.tsx‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,8 @@ function NetflowPage() {
144144
sourceId={source?.id}
145145
onClose={() => setSourceModal(null)}
146146
onCreate={created => {
147-
clearFilters(created.id);
147+
// The form can save another kind; keep the current flows then.
148+
if (created.kind === SourceKind.Netflow) clearFilters(created.id);
148149
setSourceModal(null);
149150
}}
150151
/>

‎packages/app/src/__tests__/NetflowPage.drafts.test.tsx‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,14 @@ jest.mock('@/netflow', () => ({
8383
buildNetflowQueryConfigs: jest.fn(() => ({})),
8484
}));
8585
jest.mock('@/components/NetflowCharts', () => () => null);
86-
jest.mock('@/components/NetflowSourceModal', () => () => null);
86+
let mockCreateSource: (source: { id: string; kind: SourceKind }) => void;
87+
jest.mock('@/components/NetflowSourceModal', () => ({
88+
__esModule: true,
89+
default: ({ onCreate }: { onCreate: typeof mockCreateSource }) => {
90+
mockCreateSource = onCreate;
91+
return null;
92+
},
93+
}));
8794
jest.mock('@/components/SourceSelect', () => {
8895
const Input = jest.requireActual(
8996
'@/components/InputControlled',
@@ -207,6 +214,20 @@ describe('NetFlow draft search fields', () => {
207214
});
208215
});
209216

217+
it('switches only to NetFlow sources created from the source modal', async () => {
218+
renderWithMantine(<NetflowPage />);
219+
await screen.findByLabelText('Query');
220+
act(() => mockCreateSource({ id: 'logs', kind: SourceKind.Log }));
221+
expect(screen.getByLabelText('Source')).toHaveValue('flows');
222+
expect(screen.getByLabelText('Query')).toHaveValue('Proto:6');
223+
act(() => mockCreateSource({ id: 'other', kind: SourceKind.Netflow }));
224+
await waitFor(() =>
225+
expect(
226+
jest.mocked(buildNetflowQueryConfigs).mock.lastCall?.[0],
227+
).toMatchObject({ source: mockOtherSource, where: '' }),
228+
);
229+
});
230+
210231
it('preserves drafts until Run when clicks or migrations update filters', async () => {
211232
renderWithMantine(<NetflowPage />);
212233
const query = await screen.findByLabelText('Query');

‎packages/app/src/components/NetflowRecords.tsx‎

Lines changed: 15 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -50,15 +50,19 @@ export default function NetflowRecords({
5050
const [selected, setSelected] = useState<Record<string, unknown> | null>(
5151
null,
5252
);
53-
const rows = useMemo(
54-
() =>
55-
(data?.data ?? []).map(row =>
56-
Object.fromEntries(
57-
fields.map(([key]) => [key, row[netflowColumnAlias(key)]]),
58-
),
59-
),
60-
[data],
61-
);
53+
const rows = useMemo(() => {
54+
const repeats = new Map<string, number>();
55+
return (data?.data ?? []).map(row => {
56+
const values = Object.fromEntries(
57+
fields.map(([key]) => [key, row[netflowColumnAlias(key)]]),
58+
);
59+
// Flow exports can repeat a record exactly and carry no ID.
60+
const content = JSON.stringify(values);
61+
const repeat = (repeats.get(content) ?? 0) + 1;
62+
repeats.set(content, repeat);
63+
return { values, key: `${content}#${repeat}` };
64+
});
65+
}, [data]);
6266

6367
return (
6468
<>
@@ -112,10 +116,8 @@ export default function NetflowRecords({
112116
</Table.Tr>
113117
</Table.Thead>
114118
<Table.Tbody>
115-
{rows.map((row, index) => (
116-
// Flow exports may contain duplicate records without an ID; the table remounts when its query changes.
117-
// eslint-disable-next-line @eslint-react/no-array-index-key
118-
<Table.Tr key={index}>
119+
{rows.map(({ values: row, key }, index) => (
120+
<Table.Tr key={key}>
119121
<Table.Td>{formatTime(String(row.timestamp))}</Table.Td>
120122
<Table.Td>
121123
<NetflowFilterMenu

‎packages/app/src/netflow.ts‎

Lines changed: 5 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,8 @@
11
import { FIXED_TIME_BUCKET_EXPR_ALIAS } from '@hyperdx/common-utils/dist/core/renderChartConfig';
2-
import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig';
2+
import {
3+
getSourceImplicitColumnExpression,
4+
netflowProtocolNameExpression,
5+
} from '@hyperdx/common-utils/dist/core/searchChartConfig';
36
import {
47
convertDateRangeToGranularityString,
58
convertGranularityToSeconds,
@@ -31,14 +34,6 @@ export const NETFLOW_ALIASES = {
3134
bytes: netflowColumnAlias('bytes'),
3235
value: netflowColumnAlias('value'),
3336
};
34-
const PROTOCOL_NAMES = {
35-
1: 'ICMP',
36-
6: 'TCP',
37-
17: 'UDP',
38-
47: 'GRE',
39-
50: 'ESP',
40-
58: 'ICMPv6',
41-
};
4237
export const NETFLOW_SUMMARY_TILES = [
4338
{
4439
title: 'Average bit rate',
@@ -83,13 +78,7 @@ export function getNetflowDimensions(source: TNetflowSource) {
8378
return {
8479
srcAddr: address(source.srcAddrExpression),
8580
dstAddr: address(source.dstAddrExpression),
86-
protocol: `transform(toString(${source.protocolExpression}), [${Object.keys(
87-
PROTOCOL_NAMES,
88-
)
89-
.map(value => `'${value}'`)
90-
.join(', ')}], [${Object.values(PROTOCOL_NAMES)
91-
.map(value => `'${value}'`)
92-
.join(', ')}], toString(${source.protocolExpression}))`,
81+
protocol: netflowProtocolNameExpression(source.protocolExpression),
9382
exporter: source.exporterExpression?.trim(),
9483
inputInterface: source.inIfExpression?.trim(),
9584
outputInterface: source.outIfExpression?.trim(),

‎packages/common-utils/src/__tests__/netflowSource.test.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {
22
buildSearchChartConfig,
33
getSourceImplicitColumnExpression,
4+
netflowProtocolNameExpression,
45
} from '@/core/searchChartConfig';
56
import {
67
isSearchableSource,
@@ -82,6 +83,12 @@ it('searches mapped NetFlow dimensions for bare Lucene terms by default', () =>
8283
expect(config.implicitColumnExpression).toContain('toString(SrcAddr)');
8384
expect(config.implicitColumnExpression).toContain('toString(ExporterName)');
8485
expect(config.implicitColumnExpression).toContain('toString(OutIfName)');
86+
expect(config.implicitColumnExpression).toContain(
87+
netflowProtocolNameExpression('Proto'),
88+
);
89+
expect(netflowProtocolNameExpression('Proto')).toBe(
90+
"transform(toString(Proto), ['1', '6', '17', '47', '50', '58'], ['ICMP', 'TCP', 'UDP', 'GRE', 'ESP', 'ICMPv6'], toString(Proto))",
91+
);
8592
expect(getSourceImplicitColumnExpression(SourceSchema.parse(source))).toBe(
8693
config.implicitColumnExpression,
8794
);

‎packages/common-utils/src/core/searchChartConfig.ts‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,17 +30,38 @@ import {
3030
*/
3131
export type SearchChartConfig = BuilderChartConfig & Partial<DateRange>;
3232

33+
const NETFLOW_PROTOCOL_NAMES = {
34+
1: 'ICMP',
35+
6: 'TCP',
36+
17: 'UDP',
37+
47: 'GRE',
38+
50: 'ESP',
39+
58: 'ICMPv6',
40+
};
41+
42+
/** Shows well-known IP protocol numbers by name, e.g. 6 as TCP. */
43+
export function netflowProtocolNameExpression(expression: string): string {
44+
return `transform(toString(${expression}), [${Object.keys(
45+
NETFLOW_PROTOCOL_NAMES,
46+
)
47+
.map(value => `'${value}'`)
48+
.join(', ')}], [${Object.values(NETFLOW_PROTOCOL_NAMES)
49+
.map(value => `'${value}'`)
50+
.join(', ')}], toString(${expression}))`;
51+
}
52+
3353
/** Bare terms search mapped dimensions even on sources saved before this option existed. */
3454
function getNetflowImplicitColumnExpression(source: TNetflowSource): string {
3555
if (source.implicitColumnExpression?.trim()) {
3656
return source.implicitColumnExpression.trim();
3757
}
58+
const protocol = source.protocolExpression?.trim();
3859
const expressions = [
3960
source.srcAddrExpression,
4061
source.dstAddrExpression,
4162
source.srcPortExpression,
4263
source.dstPortExpression,
43-
source.protocolExpression,
64+
protocol && netflowProtocolNameExpression(protocol),
4465
source.exporterExpression,
4566
source.inIfExpression,
4667
source.outIfExpression,

0 commit comments

Comments
 (0)