From 94e034f5d616363573662bcac9ed82636fc84419 Mon Sep 17 00:00:00 2001 From: Valerii Petrov Date: Fri, 9 Oct 2026 12:36:16 -0700 Subject: [PATCH 01/11] feat: add native NetFlow monitoring and Sankey visualization --- .changeset/native-netflow-monitoring.md | 27 ++ docs/images/netflow-sankey.png | Bin 0 -> 158615 bytes docs/netflow.md | 174 ++++++++++ knip.json | 2 +- package.json | 2 + packages/api/openapi.json | 137 +++++++- .../controllers/__tests__/sources.int.test.ts | 42 ++- packages/api/src/controllers/sources.ts | 3 + packages/api/src/models/source.ts | 24 ++ .../src/routers/external-api/v2/sources.ts | 108 +++++++ packages/app/pages/netflow.tsx | 2 + packages/app/src/DBSearchPage.tsx | 26 +- packages/app/src/HDXMultiSeriesTimeChart.tsx | 2 +- packages/app/src/NetflowPage.tsx | 299 ++++++++++++++++++ packages/app/src/Spotlights.tsx | 8 + packages/app/src/__tests__/netflow.test.ts | 151 +++++++++ .../app/src/__tests__/netflowSankey.test.ts | 200 ++++++++++++ packages/app/src/__tests__/source.test.ts | 64 ++++ packages/app/src/components/AppNav/AppNav.tsx | 7 + .../ChartEditorControls.tsx | 6 +- .../app/src/components/DBListBarChart.tsx | 15 +- .../app/src/components/DBRowSidePanel.tsx | 9 +- .../Dashboards/DashboardsListPage.tsx | 5 + packages/app/src/components/NetflowCharts.tsx | 92 ++++++ .../app/src/components/NetflowFilterMenu.tsx | 49 +++ .../app/src/components/NetflowFilterPills.tsx | 109 +++++++ .../app/src/components/NetflowRecords.tsx | 213 +++++++++++++ packages/app/src/components/NetflowSankey.tsx | 188 +++++++++++ .../app/src/components/NetflowSankeyChart.tsx | 134 ++++++++ .../app/src/components/NetflowSankeyNode.tsx | 134 ++++++++ .../app/src/components/NetflowSankeyTable.tsx | 88 ++++++ .../src/components/NetflowVisualization.tsx | 54 ++++ .../components/SearchInput/SearchInputV2.tsx | 3 + .../SearchInput/SearchWhereInput.tsx | 4 + .../__tests__/SearchWhereInput.test.tsx | 29 ++ .../src/components/SidePanelBreadcrumbs.tsx | 4 + .../SourceForm/NetflowTableModelForm.tsx | 60 ++++ .../Sources/SourceForm/SourceForm.tsx | 19 +- .../Sources/SourceForm/TableModelForm.tsx | 7 +- .../Sources/SourceForm/constants.ts | 1 + .../Sources/SourceForm/correlationFields.ts | 2 + .../__tests__/NetflowRecords.test.tsx | 82 +++++ .../app/src/components/sourceSelectUtils.tsx | 2 + packages/app/src/netflow.ts | 205 ++++++++++++ packages/app/src/netflowSankey.ts | 139 ++++++++ packages/app/src/netflowSankeyDimensions.ts | 61 ++++ packages/app/src/source.ts | 40 +++ .../common-utils/src/__tests__/iac.test.ts | 4 + .../src/__tests__/netflowSource.test.ts | 72 +++++ .../src/clickhouse/__tests__/browser.test.ts | 53 +++- .../common-utils/src/clickhouse/browser.ts | 4 +- .../src/core/searchChartConfig.ts | 3 +- packages/common-utils/src/iacEligibility.ts | 6 +- packages/common-utils/src/types.ts | 28 +- scripts/netflow-axis-browser-check.mjs | 44 +++ scripts/netflow-browser-check.mjs | 227 +++++++++++++ scripts/netflow-click-browser-check.mjs | 128 ++++++++ scripts/netflow-click-query-check.ts | 218 +++++++++++++ scripts/netflow-dev.py | 71 +++++ scripts/netflow-query-check.ts | 274 ++++++++++++++++ scripts/netflow-sankey-browser-check.mjs | 158 +++++++++ scripts/netflow-sankey-query-check.ts | 264 ++++++++++++++++ scripts/netflow-schema.sql | 34 ++ scripts/netflow-seed.py | 171 ++++++++++ yarn.lock | 2 + 65 files changed, 4762 insertions(+), 31 deletions(-) create mode 100644 .changeset/native-netflow-monitoring.md create mode 100644 docs/images/netflow-sankey.png create mode 100644 docs/netflow.md create mode 100644 packages/app/pages/netflow.tsx create mode 100644 packages/app/src/NetflowPage.tsx create mode 100644 packages/app/src/__tests__/netflow.test.ts create mode 100644 packages/app/src/__tests__/netflowSankey.test.ts create mode 100644 packages/app/src/components/NetflowCharts.tsx create mode 100644 packages/app/src/components/NetflowFilterMenu.tsx create mode 100644 packages/app/src/components/NetflowFilterPills.tsx create mode 100644 packages/app/src/components/NetflowRecords.tsx create mode 100644 packages/app/src/components/NetflowSankey.tsx create mode 100644 packages/app/src/components/NetflowSankeyChart.tsx create mode 100644 packages/app/src/components/NetflowSankeyNode.tsx create mode 100644 packages/app/src/components/NetflowSankeyTable.tsx create mode 100644 packages/app/src/components/NetflowVisualization.tsx create mode 100644 packages/app/src/components/Sources/SourceForm/NetflowTableModelForm.tsx create mode 100644 packages/app/src/components/__tests__/NetflowRecords.test.tsx create mode 100644 packages/app/src/netflow.ts create mode 100644 packages/app/src/netflowSankey.ts create mode 100644 packages/app/src/netflowSankeyDimensions.ts create mode 100644 packages/common-utils/src/__tests__/netflowSource.test.ts create mode 100644 scripts/netflow-axis-browser-check.mjs create mode 100644 scripts/netflow-browser-check.mjs create mode 100644 scripts/netflow-click-browser-check.mjs create mode 100644 scripts/netflow-click-query-check.ts create mode 100644 scripts/netflow-dev.py create mode 100644 scripts/netflow-query-check.ts create mode 100644 scripts/netflow-sankey-browser-check.mjs create mode 100644 scripts/netflow-sankey-query-check.ts create mode 100644 scripts/netflow-schema.sql create mode 100644 scripts/netflow-seed.py diff --git a/.changeset/native-netflow-monitoring.md b/.changeset/native-netflow-monitoring.md new file mode 100644 index 0000000000..91e004eb4a --- /dev/null +++ b/.changeset/native-netflow-monitoring.md @@ -0,0 +1,27 @@ +--- +'@hyperdx/app': minor +'@hyperdx/api': minor +'@hyperdx/common-utils': minor +--- + +Add native NetFlow sources and monitoring with configurable column mappings, +Akvorado schema detection, sampling-aware traffic charts, top talkers, exporter +and interface breakdowns, and flow details. NetFlow tables also support Search +and custom charts. + +Support Lucene search with field autocomplete on the NetFlow page, combining +search queries with quick filters across charts and flow records. + +Add clickable include/exclude filters to flow IPs, protocols, exporters, and +interfaces, with removable selections preserved in the URL. + +Show table column suggestions when focusing an empty NetFlow search input. + +Size time-chart Y axes to their formatted labels so rate units do not wrap or +get clipped. + +Avoid sending API proxy headers when querying ClickHouse directly in local mode. + +Add a Sankey visualization with ordered table dimensions, sampling-adjusted path +weights, average bit rates, configurable path limits, and clickable shared +filters. Include interface classifications in the local demo data. diff --git a/docs/images/netflow-sankey.png b/docs/images/netflow-sankey.png new file mode 100644 index 0000000000000000000000000000000000000000..a1bf1c27a40535fa59d4131eaf11b912df56aa18 GIT binary patch literal 158615 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

`` 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 literal 0 HcmV?d00001 diff --git a/docs/netflow.md b/docs/netflow.md new file mode 100644 index 0000000000..99d6f6994f --- /dev/null +++ b/docs/netflow.md @@ -0,0 +1,174 @@ +# NetFlow monitoring + +The NetFlow section queries a configured ClickHouse table through HyperDX's +existing connections and source mappings. An Akvorado `flows` table can be used +directly. HyperDX does not collect UDP NetFlow, IPFIX, or sFlow packets: keep +Akvorado or another collector responsible for decoding and ingestion. + +## Local demo + +Use Python 3, the repository's Node/Yarn dependencies, and a local ClickHouse +server with HTTP access and browser CORS enabled. The demo uses +`http://127.0.0.1:8123`, user `default`, and an empty password. Override these +with `CLICKHOUSE_URL`, `CLICKHOUSE_USER`, and `CLICKHOUSE_PASSWORD` as needed. +Local mode sends these credentials to the browser, so use a local development +account. + +From the repository root: + +```sh +yarn setup +yarn build:common-utils +python3 scripts/netflow-seed.py +python3 scripts/netflow-dev.py +``` + +The launcher prints the URL, normally `http://127.0.0.1:3000/netflow`, and uses +port 3001 if 3000 is occupied. It supplies the connection and native NetFlow +source through HyperDX's supported default configuration environment variables. +No MongoDB, API server, account registration, or browser storage edits are +needed for this local demo. A fresh browser session uses these defaults; +previously saved local connections and sources take precedence. + +Choose the last two hours to see the whole fixture, including its TCP traffic +burst. Re-run the seeder to move the data to the current time. The seeder +replaces only its own `netflow_demo.flows` table, checking its ownership comment +first. It refuses remote endpoints and does not modify other databases or +tables. Refreshes do not accumulate duplicate records. A failed insert can leave +this disposable fixture table empty; re-run the seeder to restore it. + +For a fixed, reproducible time range: + +```sh +python3 scripts/netflow-seed.py --end 2026-10-09T18:34:38Z +``` + +This produces the UTC interval `[2026-10-09 16:34:38, 2026-10-09 18:34:38)`. The +last stored timestamp is `18:34:37`; use that absolute range when inspecting +this fixed fixture after its timestamps leave the relative time window. + +## Data and sampling + +The data is deterministic synthetic traffic generated locally, not a packet +capture or customer traffic. The schema follows +[Akvorado's flow schema](https://github.com/akvorado/akvorado/blob/main/common/schema/definition.go), +using documentation IP ranges and private AS numbers. The fixture includes +14,400 records across three exporters, TCP, UDP, ICMP, ICMPv6, inbound and +outbound interfaces, IPv4-mapped IPv6 addresses, and native IPv6 addresses. +Interface classification columns (`InIfConnectivity`, `OutIfConnectivity`, +`InIfProvider`, `OutIfProvider`) contain deterministic transit, IX, and PNI +classifications with synthetic provider names tied to each exporter. As with +Akvorado's default classifier, internal interfaces leave these fields empty. The +seeder adds missing classification columns only after confirming ownership of +the demo table; existing counters and record counts stay unchanged. Interface +speeds are stored in Mbps, as in Akvorado. `ExporterAddress` uses plain `IPv6` +to work without ClickHouse's optional low-cardinality IPv6 setting. + +`Bytes` and `Packets` hold observed counters. `SamplingRate` holds the expansion +factor (1, 100, or 1,000). Traffic estimates are `sum(Bytes * SamplingRate)` and +`sum(Packets * SamplingRate)`, matching +[Akvorado's aggregation](https://github.com/akvorado/akvorado/blob/main/console/widgets.go). +Multiply estimated bytes by eight and divide by the requested duration in +seconds for bits per second. The record count is the number of stored records, +not an estimate of distinct connections or sampling-expanded flows. + +The seeder checks actual ClickHouse query results against generated totals: + +| Measurement | Expected value | +| ------------------- | ----------------: | +| Records | 14,400 | +| Exporters | 3 | +| Protocols | 4 | +| Native IPv6 records | 2,572 | +| Observed bytes | 7,572,836,640 | +| Observed packets | 9,162,160 | +| Estimated bytes | 2,776,744,315,920 | +| Estimated packets | 3,359,797,180 | + +## Source configuration + +The NetFlow search bar uses the same Lucene syntax, field autocomplete, query +history, and syntax reference as log Search. Focus the search bar to see table +columns; type a prefix to narrow them, then click a suggestion or use the arrow +keys and Enter/Tab. After a column and colon, matching values are suggested. +Press Enter or Run to apply the query to all charts and flow records. Queries +combine with the exporter, protocol, and address filters using AND; Clear +filters resets both the search and quick filters. The query and selected +language are saved in the URL. The shared language selector also supports SQL +WHERE expressions. + +Click an IP address, protocol, exporter, or interface in a breakdown chart, flow +row, or flow details to **Include** or **Exclude** it. These actions apply +immediately while preserving the search query and time range. Selected values +appear as removable filters and survive URL sharing and reloads. Multiple +included values for the same field use OR; different fields and exclusions use +AND. Clear filters also removes these selections. + +Use your table's actual field names, for example with the Akvorado schema: + +```text +Proto:6 AND DstPort:443 +ExporterName:edge* AND Bytes:[1000 TO 100000] +(DstPort:80 OR DstPort:443) AND NOT Proto:17 +``` + +In the source editor, select NetFlow, a ClickHouse connection, and the desired +database/table. Use these Akvorado mappings: + +| Source setting | Column/expression | +| ---------------------------- | ------------------------ | +| Timestamp | `TimeReceived` | +| Bytes / packets | `Bytes` / `Packets` | +| Sampling rate | `SamplingRate` | +| Source / destination address | `SrcAddr` / `DstAddr` | +| Source / destination port | `SrcPort` / `DstPort` | +| Protocol | `Proto` | +| Exporter | `ExporterName` | +| Input / output interface | `InIfName` / `OutIfName` | + +For already sampling-adjusted counters, omit the sampling-rate mapping to avoid +expanding the counters twice. Full deployments persist the source through the +existing authenticated source API and MongoDB model. + +## Sankey visualization + +Select **Sankey** in the NetFlow visualization selector to explore traffic paths. +Choose two to five dimensions in left-to-right order from the source mappings or +scalar table columns. Akvorado tables default to `SrcAS` → `InIfConnectivity` → +`InIfProvider` → exporter when those columns are available. + +Link widths represent sampling-adjusted bytes for the top 10, 20, or 50 paths. +The table and tooltips show transferred bytes and average bit rate over the +selected time range. Paths outside the limit are omitted from the diagram. +Click a node or table value to include or exclude it using the shared filters; +Lucene search, quick filters, and the time range also apply to this view. +The visualization, ordered dimensions, and path limit are saved in the URL. + +## Query verification + +After building common-utils and seeding, run the real chart configurations +against local ClickHouse: + +```sh +node node_modules/tsx/dist/cli.mjs scripts/netflow-query-check.ts +``` + +This checks overview values, time series, breakdowns, the flow table, IPv4/IPv6 +filters, protocol filtering, and safe handling of SQL syntax in filter values. +The seeder also verifies counts and sampled totals on every run. + +With the local app running and freshly seeded data, verify the browser workflow: + +```sh +yarn playwright install chromium +node scripts/netflow-browser-check.mjs +node scripts/netflow-click-browser-check.mjs +node node_modules/tsx/dist/cli.mjs scripts/netflow-click-query-check.ts +node node_modules/tsx/dist/cli.mjs scripts/netflow-sankey-query-check.ts +node scripts/netflow-sankey-browser-check.mjs +``` + +This checks charts, filters, sampled flow details, refresh, source creation and +persistence, and the shared Search page. Screenshots are saved under +`packages/app/test-results/netflow/`. Set `NETFLOW_APP_URL` if the app uses a +different local port. diff --git a/knip.json b/knip.json index 7d88c3d288..44157733c3 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/knip@6/schema.json", "workspaces": { ".": { - "entry": ["scripts/__tests__/*.test.mjs"] + "entry": ["scripts/__tests__/*.test.mjs", "scripts/netflow-*-check.{mjs,ts}"] }, "packages/app": { "entry": ["pages/**/*.{ts,tsx}", "scripts/*.js", "tests/e2e/**/*.ts"], diff --git a/package.json b/package.json index 6659ab07d9..4996ec4b76 100644 --- a/package.json +++ b/package.json @@ -8,8 +8,10 @@ ], "devDependencies": { "@changesets/cli": "^2.26.2", + "@clickhouse/client": "1.23.0-head.fae5998.1", "@dotenvx/dotenvx": "^1.51.1", "@eslint/js": "^9.39.1", + "@playwright/test": "^1.57.0", "@types/ungap__structured-clone": "^1.2.0", "@ungap/structured-clone": "^1.3.0", "babel-plugin-react-compiler": "^1.0.0", diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 1ac293d77f..4ac9e8ce01 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -5163,6 +5163,137 @@ } } }, + "NetflowSource": { + "type": "object", + "description": "Network flow table with configurable SQL expressions. Traffic totals multiply bytes and packets by the optional sampling multiplier (defaults to one).", + "required": [ + "name", + "kind", + "connection", + "from", + "timestampValueExpression", + "defaultTableSelectExpression", + "bytesExpression", + "packetsExpression", + "srcAddrExpression", + "dstAddrExpression", + "srcPortExpression", + "dstPortExpression", + "protocolExpression" + ], + "properties": { + "id": { + "type": "string", + "readOnly": true, + "description": "Unique source ID generated by the server.", + "example": "507f1f77bcf86cd799439011" + }, + "name": { + "type": "string", + "description": "Display name for the source.", + "example": "Network flows" + }, + "section": { + "type": "string", + "maxLength": 256, + "description": "Optional source grouping label.", + "example": "Network" + }, + "disabled": { + "type": "boolean", + "description": "Hide this source from source selectors." + }, + "kind": { + "type": "string", + "enum": [ + "netflow" + ], + "description": "Source kind discriminator for network flow tables.", + "example": "netflow" + }, + "connection": { + "type": "string", + "description": "ClickHouse connection ID.", + "example": "507f1f77bcf86cd799439012" + }, + "from": { + "$ref": "#/components/schemas/SourceFrom", + "description": "Database and table containing the flow records." + }, + "querySettings": { + "type": "array", + "description": "ClickHouse query settings applied to this source.", + "items": { + "$ref": "#/components/schemas/QuerySetting" + } + }, + "timestampValueExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "TimeReceived" + }, + "defaultTableSelectExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "TimeReceived, SrcAddr, DstAddr, Bytes" + }, + "bytesExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "Bytes" + }, + "packetsExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "Packets" + }, + "srcAddrExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "SrcAddr" + }, + "dstAddrExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "DstAddr" + }, + "srcPortExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "SrcPort" + }, + "dstPortExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "DstPort" + }, + "protocolExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "Proto" + }, + "samplingRateExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "SamplingRate" + }, + "exporterExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "ExporterName" + }, + "inIfExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "InIfName" + }, + "outIfExpression": { + "type": "string", + "description": "SQL expression for the mapped flow field.", + "example": "OutIfName" + } + } + }, "Source": { "oneOf": [ { @@ -5179,6 +5310,9 @@ }, { "$ref": "#/components/schemas/PromqlSource" + }, + { + "$ref": "#/components/schemas/NetflowSource" } ], "discriminator": { @@ -5188,7 +5322,8 @@ "trace": "#/components/schemas/TraceSource", "metric": "#/components/schemas/MetricSource", "session": "#/components/schemas/SessionSource", - "promql": "#/components/schemas/PromqlSource" + "promql": "#/components/schemas/PromqlSource", + "netflow": "#/components/schemas/NetflowSource" } } }, diff --git a/packages/api/src/controllers/__tests__/sources.int.test.ts b/packages/api/src/controllers/__tests__/sources.int.test.ts index 71a7d73466..296e850c55 100644 --- a/packages/api/src/controllers/__tests__/sources.int.test.ts +++ b/packages/api/src/controllers/__tests__/sources.int.test.ts @@ -1,6 +1,7 @@ +import { SourceKind } from '@hyperdx/common-utils/dist/types'; import mongoose from 'mongoose'; -import { getSource } from '@/controllers/sources'; +import { createSource, getSource, updateSource } from '@/controllers/sources'; import { clearDBCollections, closeDB, connectDB } from '@/fixtures'; describe('sources controller', () => { @@ -16,6 +17,45 @@ describe('sources controller', () => { await closeDB(); }); + it('creates and updates native NetFlow mappings without losing fields', async () => { + const team = new mongoose.Types.ObjectId().toString(); + const input = { + team, + kind: SourceKind.Netflow as const, + name: 'Flows', + connection: new mongoose.Types.ObjectId().toString(), + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: 'SrcAddr, DstAddr', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + samplingRateExpression: 'SamplingRate', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + exporterExpression: 'ExporterName', + inIfExpression: 'InIfName', + outIfExpression: 'OutIfName', + }; + const created = await createSource(team, input); + expect(created).toBeTruthy(); + const updated = await updateSource(team, String(created!._id), { + ...input, + bytesExpression: 'octets', + }); + expect(updated?.get('bytesExpression')).toBe('octets'); + expect(updated?.get('samplingRateExpression')).toBe('SamplingRate'); + expect(updated?.get('outIfExpression')).toBe('OutIfName'); + expect( + await getSource( + new mongoose.Types.ObjectId().toString(), + String(created!._id), + ), + ).toBeNull(); + }); + describe('getSource', () => { it('returns null when sourceId is not a valid ObjectId', async () => { // Non-ObjectId strings used to bubble a Mongoose CastError up diff --git a/packages/api/src/controllers/sources.ts b/packages/api/src/controllers/sources.ts index ad3670cd27..dd9ee0918f 100644 --- a/packages/api/src/controllers/sources.ts +++ b/packages/api/src/controllers/sources.ts @@ -5,6 +5,7 @@ import { ISourceInput, LogSource, MetricSource, + NetflowSource, PromqlSource, SessionSource, Source, @@ -16,6 +17,8 @@ import { // recognises kind-specific fields (e.g. metricTables on MetricSource). function getModelForKind(kind: SourceKind) { switch (kind) { + case SourceKind.Netflow: + return NetflowSource; case SourceKind.Log: return LogSource; case SourceKind.Trace: diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index 664874df5b..d880481b14 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -3,6 +3,7 @@ import { LogSourceSchema, MetricsDataType, MetricSourceSchema, + NetflowSourceSchema, PromqlSourceSchema, QuerySettings, SessionSourceSchema, @@ -22,6 +23,10 @@ import { objectIdSchema } from '@/utils/zod'; // runtime value is intentionally never parsed, so no-unused-vars is a false positive here. // eslint-disable-next-line @typescript-eslint/no-unused-vars const ISourceSchema = z.discriminatedUnion('kind', [ + NetflowSourceSchema.omit({ connection: true }).extend({ + team: objectIdSchema, + connection: objectIdSchema.or(z.string()), + }), LogSourceSchema.omit({ connection: true }).extend({ team: objectIdSchema, connection: objectIdSchema.or(z.string()), @@ -307,3 +312,22 @@ export const PromqlSource = Source.discriminator( minAutoGranularity: minAutoGranularityField, }), ); + +type INetflowSource = Extract; +export const NetflowSource = Source.discriminator( + SourceKind.Netflow, + new Schema({ + defaultTableSelectExpression: String, + bytesExpression: String, + packetsExpression: String, + samplingRateExpression: String, + srcAddrExpression: String, + dstAddrExpression: String, + srcPortExpression: String, + dstPortExpression: String, + protocolExpression: String, + exporterExpression: String, + inIfExpression: String, + outIfExpression: String, + }), +); diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index bf8a485e46..2eee8918eb 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -154,6 +154,8 @@ function formatExternalSource(source: SourceDocument) { const json = JSON.stringify( (() => { switch (source.kind) { + case SourceKind.Netflow: + return source.toJSON({ getters: true }); case SourceKind.Log: return source.toJSON({ getters: true }); case SourceKind.Trace: @@ -886,6 +888,110 @@ function formatExternalSource(source: SourceDocument) { * type: string * description: Required by the API for all source kinds; not used when querying a Prometheus endpoint. * example: timestamp + * NetflowSource: + * type: object + * description: Network flow table with configurable SQL expressions. Traffic totals multiply bytes and packets by the optional sampling multiplier (defaults to one). + * required: + * - name + * - kind + * - connection + * - from + * - timestampValueExpression + * - defaultTableSelectExpression + * - bytesExpression + * - packetsExpression + * - srcAddrExpression + * - dstAddrExpression + * - srcPortExpression + * - dstPortExpression + * - protocolExpression + * properties: + * id: + * type: string + * readOnly: true + * description: Unique source ID generated by the server. + * example: 507f1f77bcf86cd799439011 + * name: + * type: string + * description: Display name for the source. + * example: Network flows + * section: + * type: string + * maxLength: 256 + * description: Optional source grouping label. + * example: Network + * disabled: + * type: boolean + * description: Hide this source from source selectors. + * kind: + * type: string + * enum: [netflow] + * description: Source kind discriminator for network flow tables. + * example: netflow + * connection: + * type: string + * description: ClickHouse connection ID. + * example: 507f1f77bcf86cd799439012 + * from: + * $ref: '#/components/schemas/SourceFrom' + * description: Database and table containing the flow records. + * querySettings: + * type: array + * description: ClickHouse query settings applied to this source. + * items: + * $ref: '#/components/schemas/QuerySetting' + * timestampValueExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: TimeReceived + * defaultTableSelectExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: TimeReceived, SrcAddr, DstAddr, Bytes + * bytesExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: Bytes + * packetsExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: Packets + * srcAddrExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: SrcAddr + * dstAddrExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: DstAddr + * srcPortExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: SrcPort + * dstPortExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: DstPort + * protocolExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: Proto + * samplingRateExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: SamplingRate + * exporterExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: ExporterName + * inIfExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: InIfName + * outIfExpression: + * type: string + * description: SQL expression for the mapped flow field. + * example: OutIfName * Source: * oneOf: * - $ref: '#/components/schemas/LogSource' @@ -893,6 +999,7 @@ function formatExternalSource(source: SourceDocument) { * - $ref: '#/components/schemas/MetricSource' * - $ref: '#/components/schemas/SessionSource' * - $ref: '#/components/schemas/PromqlSource' + * - $ref: '#/components/schemas/NetflowSource' * discriminator: * propertyName: kind * mapping: @@ -901,6 +1008,7 @@ function formatExternalSource(source: SourceDocument) { * metric: '#/components/schemas/MetricSource' * session: '#/components/schemas/SessionSource' * promql: '#/components/schemas/PromqlSource' + * netflow: '#/components/schemas/NetflowSource' * SourcesListResponse: * type: object * properties: diff --git a/packages/app/pages/netflow.tsx b/packages/app/pages/netflow.tsx new file mode 100644 index 0000000000..d1604178db --- /dev/null +++ b/packages/app/pages/netflow.tsx @@ -0,0 +1,2 @@ +import NetflowPage from '@/NetflowPage'; +export default NetflowPage; diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index e7a257d397..416b9dd69d 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -184,7 +184,11 @@ const LIVE_TAIL_REFRESH_FREQUENCY_OPTIONS = [ ]; const DEFAULT_REFRESH_FREQUENCY = 10000; -const ALLOWED_SOURCE_KINDS = [SourceKind.Log, SourceKind.Trace]; +const ALLOWED_SOURCE_KINDS = [ + SourceKind.Log, + SourceKind.Trace, + SourceKind.Netflow, +]; const SearchConfigSchema = z.object({ select: z.string(), source: z.string(), @@ -505,7 +509,7 @@ function SaveSearchModalComponent({ const { data: sourceObj } = useSource({ id: searchedConfig.source, - kinds: [SourceKind.Log, SourceKind.Trace], + kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], }); const effectiveSelect = searchedConfig.select || sourceObj?.defaultTableSelectExpression || ''; @@ -796,7 +800,7 @@ function useSearchedConfigToChartConfig( ) { const { data: sourceObj, isLoading } = useSource({ id: source, - kinds: [SourceKind.Log, SourceKind.Trace], + kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], }); const defaultOrderBy = useDefaultOrderBy(source); @@ -872,7 +876,7 @@ function optimizeDefaultOrderBy( export function useDefaultOrderBy(sourceID: string | undefined | null) { const { data: source } = useSource({ id: sourceID, - kinds: [SourceKind.Log, SourceKind.Trace], + kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], }); const { data: tableMetadata } = useTableMetadata(tcFromSource(source)); @@ -880,11 +884,16 @@ export function useDefaultOrderBy(sourceID: string | undefined | null) { return useMemo(() => { // If no source, return undefined so that the orderBy is not set incorrectly if (!source) return undefined; - const trimmedOrderBy = source.orderByExpression?.trim(); + const trimmedOrderBy = + 'orderByExpression' in source + ? source.orderByExpression?.trim() + : undefined; if (trimmedOrderBy) return trimmedOrderBy; return optimizeDefaultOrderBy( source?.timestampValueExpression ?? '', - source.displayedTimestampValueExpression, + 'displayedTimestampValueExpression' in source + ? source.displayedTimestampValueExpression + : undefined, tableMetadata?.sorting_key, ); }, [source, tableMetadata]); @@ -1047,7 +1056,7 @@ function DBSearchPageContent() { const { source: searchedSource } = useResolvedSourceParam( rawSearchedConfig.source, { - kinds: [SourceKind.Log, SourceKind.Trace], + kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], }, ); @@ -1179,7 +1188,8 @@ function DBSearchPageContent() { select: _savedSearch?.select ?? (searchedSource?.kind === SourceKind.Log || - searchedSource?.kind === SourceKind.Trace + searchedSource?.kind === SourceKind.Trace || + searchedSource?.kind === SourceKind.Netflow ? searchedSource.defaultTableSelectExpression : undefined), where: _savedSearch?.where ?? '', diff --git a/packages/app/src/HDXMultiSeriesTimeChart.tsx b/packages/app/src/HDXMultiSeriesTimeChart.tsx index 5666a10b70..3aa8a7379c 100644 --- a/packages/app/src/HDXMultiSeriesTimeChart.tsx +++ b/packages/app/src/HDXMultiSeriesTimeChart.tsx @@ -2066,7 +2066,7 @@ export const MemoChart = memo(function MemoChart({ tick={{ fontSize: 11, fontFamily: 'IBM Plex Mono, monospace' }} /> (null); + const netflowSources = sources?.filter( + (s): s is TNetflowSource => s.kind === SourceKind.Netflow && !s.disabled, + ); + const source = params.source + ? netflowSources?.find( + s => s.id === params.source || s.name === params.source, + ) + : netflowSources?.[0]; + const { control, handleSubmit, reset, getValues } = useForm({ + values: { ...params, source: source?.id ?? '' }, + }); + const clickFilters = useNetflowFilterState({ + source, + filters: params.filters, + onChange: filters => { + const next = { ...getValues(), filters }; + reset(next); + void setParams(next); + }, + }); + const defaultTimeRange = useDefaultTimeRange(DEFAULT_INTERVAL); + const [displayedTimeInputValue, setDisplayedTimeInputValue] = + useState(DEFAULT_INTERVAL); + const { searchedTimeRange, onSearch, onTimeRangeSelect } = useNewTimeQuery({ + initialDisplayValue: DEFAULT_INTERVAL, + initialTimeRange: defaultTimeRange, + setDisplayedTimeInputValue, + }); + const { refresh, manualRefreshCooloff } = useDashboardRefresh({ + searchedTimeRange, + onTimeRangeSelect, + isLive: false, + }); + const configs = useMemo( + () => + source + ? buildNetflowQueryConfigs({ + source, + dateRange: searchedTimeRange, + filters: params, + where: params.where, + whereLanguage: params.whereLanguage, + extraFilters: params.filters, + }) + : undefined, + [source, searchedTimeRange, params], + ); + const run = handleSubmit(values => { + void setParams(values); + onSearch(displayedTimeInputValue); + }); + + return ( + <> + + {title} + + setSourceModal(null)} + title={ + sourceModal === 'edit' ? 'Edit NetFlow source' : 'Add NetFlow source' + } + size="xl" + > + { + void setParams({ source: created.id }); + setSourceModal(null); + }} + onSave={() => setSourceModal(null)} + onCancel={() => setSourceModal(null)} + /> + +
+ setSourceModal('new')} + onEdit={() => setSourceModal('edit')} + placeholder="NetFlow source" + aria-label="NetFlow source" + /> + } + actions={ + + + + + + + + } + content={ + + {source && ( + + )} + + + + + + + + + + Explore traffic volume, top talkers, and recent flow records. + Rates are averaged over the selected time range; counters + account for the source’s sampling rate. + + {error ? ( + + {error.message} + + ) : isLoading ? ( + + ) : configs ? ( + + ) : ( + } + title={ + params.source + ? 'NetFlow source unavailable' + : 'No NetFlow sources configured' + } + description={ + params.source + ? 'Select an available NetFlow source and run the query, or add a new source.' + : 'Connect your Akvorado or NetFlow table in ClickHouse to monitor network traffic.' + } + variant="card" + > + + + )} + + } + /> + + + ); +} + +const NetflowPageDynamic = dynamic(async () => NetflowPage, { ssr: false }); +// @ts-expect-error Next.js layout typing +NetflowPageDynamic.getLayout = withAppNavForSurface('dashboard', 'netflow'); +export default NetflowPageDynamic; diff --git a/packages/app/src/Spotlights.tsx b/packages/app/src/Spotlights.tsx index bbb593d651..78c7475df4 100644 --- a/packages/app/src/Spotlights.tsx +++ b/packages/app/src/Spotlights.tsx @@ -65,6 +65,14 @@ export const useSpotlightActions = () => { // Preset dashboards const presetDashboards = [ + { + id: 'preset-netflow', + label: 'NetFlow', + description: + 'Network traffic, top talkers, exporters, and flow records', + href: '/netflow', + keywords: ['netflow', 'network', 'akvorado', 'traffic', 'flows'], + }, { id: 'preset-services', label: 'Services', diff --git a/packages/app/src/__tests__/netflow.test.ts b/packages/app/src/__tests__/netflow.test.ts new file mode 100644 index 0000000000..b3ff26db2e --- /dev/null +++ b/packages/app/src/__tests__/netflow.test.ts @@ -0,0 +1,151 @@ +import { SourceKind, TNetflowSource } from '@hyperdx/common-utils/dist/types'; + +import { buildNetflowQueryConfigs, buildNetflowWhere } from '@/netflow'; + +const source: TNetflowSource = { + id: 'flows', + name: 'Network', + kind: SourceKind.Netflow, + connection: 'local', + from: { databaseName: 'netflow_demo', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: '*', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + samplingRateExpression: 'SamplingRate', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + exporterExpression: 'ExporterName', + inIfExpression: 'InIfName', + outIfExpression: 'OutIfName', +}; +const dateRange: [Date, Date] = [ + new Date('2026-10-09T12:00:00Z'), + new Date('2026-10-09T13:00:00Z'), +]; + +describe('NetFlow queries', () => { + it('estimates sampled traffic but counts stored flow records', () => { + const configs = buildNetflowQueryConfigs({ + source, + dateRange, + filters: {}, + }); + expect(configs.totalBytes.select).toEqual([ + { + valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate))', + alias: '__netflow_value', + }, + ]); + expect(configs.bitsPerSecond.select).toEqual([ + { + valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 / 3600', + alias: '__netflow_value', + }, + ]); + expect(configs.flowRecords.select).toEqual([ + { valueExpression: 'count()', alias: '__netflow_value' }, + ]); + }); + + it.each([undefined, ' '])( + 'supports empty optional mappings (%s)', + blank => { + const configs = buildNetflowQueryConfigs({ + source: { + ...source, + samplingRateExpression: blank, + exporterExpression: blank, + inIfExpression: blank, + outIfExpression: blank, + }, + dateRange, + filters: {}, + }); + expect(configs.totalBytes.select).toEqual([ + { + valueExpression: 'sum(toFloat64(Bytes) * (1))', + alias: '__netflow_value', + }, + ]); + expect(configs.exporters).toBeUndefined(); + expect(configs.inInterfaces).toBeUndefined(); + expect(configs.outInterfaces).toBeUndefined(); + }, + ); + + it('normalizes IPv4 and IPv6 filters and quotes user values', () => { + const where = buildNetflowWhere(source, { + srcAddr: '192.0.2.1', + dstAddr: '2001:db8::1', + exporter: "edge\\' OR 1=1 --", + protocol: '6', + }); + expect(where).toContain( + "toIPv6OrNull(toString(SrcAddr)) = toIPv6OrNull('192.0.2.1')", + ); + expect(where).toContain( + "toIPv6OrNull(toString(DstAddr)) = toIPv6OrNull('2001:db8::1')", + ); + expect(where).toContain("'edge\\\\'' OR 1=1 --'"); + expect(where).toContain("toString(Proto) = '6'"); + }); + + it('keeps the source, connection, time range and filters on every query', () => { + const configs = buildNetflowQueryConfigs({ + source, + dateRange, + filters: { protocol: '17' }, + }); + for (const config of Object.values(configs)) { + expect(config).toMatchObject({ + source: 'flows', + connection: 'local', + dateRange, + from: source.from, + whereLanguage: 'lucene', + where: '', + filters: [{ type: 'sql', condition: "(toString(Proto) = '17')" }], + }); + } + expect(configs.flows.limit).toEqual({ limit: 500 }); + expect(configs.topSourceAddresses.limit).toEqual({ limit: 10 }); + }); + + it.each(['lucene', 'sql'] as const)( + 'combines %s search with quick filters on every chart', + whereLanguage => { + const where = + whereLanguage === 'lucene' + ? 'DstPort:443 OR DstPort:80' + : 'DstPort = 443 OR DstPort = 80'; + const configs = buildNetflowQueryConfigs({ + source, + dateRange, + filters: { protocol: '6' }, + where, + whereLanguage, + }); + for (const config of Object.values(configs)) { + expect(config).toMatchObject({ + where, + whereLanguage, + filters: [{ type: 'sql', condition: "(toString(Proto) = '6')" }], + }); + } + }, + ); + + it('rejects empty or reversed time ranges', () => { + expect(() => + buildNetflowQueryConfigs({ + source, + dateRange: [dateRange[1], dateRange[0]], + filters: {}, + }), + ).toThrow('Time range'); + }); +}); diff --git a/packages/app/src/__tests__/netflowSankey.test.ts b/packages/app/src/__tests__/netflowSankey.test.ts new file mode 100644 index 0000000000..c5a9b5c30c --- /dev/null +++ b/packages/app/src/__tests__/netflowSankey.test.ts @@ -0,0 +1,200 @@ +import { + BuilderChartConfigWithDateRange, + DisplayType, +} from '@hyperdx/common-utils/dist/types'; + +import { + buildNetflowSankeyConfig, + buildNetflowSankeyData, + SankeyDimension, +} from '@/netflowSankey'; + +const dimensions: SankeyDimension[] = [ + { key: 'source', label: 'Source IP', expression: 'SrcAddr' }, + { + key: 'exporter', + label: 'Exporter', + expression: "coalesce(router, 'unknown')", + }, + { key: 'destination', label: 'Destination IP', expression: 'DstAddr' }, +]; +const baseConfig: BuilderChartConfigWithDateRange = { + source: 'flows', + connection: 'connection', + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + dateRange: [ + new Date('2026-10-09T10:00:00Z'), + new Date('2026-10-09T11:00:00Z'), + ], + where: 'Proto:6 OR Proto:17', + whereLanguage: 'lucene', + filters: [{ type: 'sql', condition: "ExporterName NOT IN ('offline')" }], + dateRangeEndInclusive: false, + displayType: DisplayType.Number, + select: [ + { + valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate))', + alias: '__netflow_value', + }, + ], +}; + +describe('NetFlow Sankey query', () => { + it('preserves source, filters, time range, and sampled byte aggregation', () => { + const config = buildNetflowSankeyConfig({ + baseConfig, + dimensions, + limit: 30, + }); + expect(config).toMatchObject({ + source: baseConfig.source, + connection: baseConfig.connection, + from: baseConfig.from, + dateRange: baseConfig.dateRange, + timestampValueExpression: 'TimeReceived', + where: baseConfig.where, + whereLanguage: 'lucene', + filters: baseConfig.filters, + dateRangeEndInclusive: false, + displayType: DisplayType.Table, + groupBy: + '__netflow_dimension_0, __netflow_dimension_1, __netflow_dimension_2', + orderBy: '__netflow_value DESC', + limit: { limit: 30 }, + }); + expect(config.select).toEqual([ + { + valueExpression: "ifNull(toString(SrcAddr), '')", + alias: '__netflow_dimension_0', + }, + { + valueExpression: "ifNull(toString(coalesce(router, 'unknown')), '')", + alias: '__netflow_dimension_1', + }, + { + valueExpression: "ifNull(toString(DstAddr), '')", + alias: '__netflow_dimension_2', + }, + { + valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate))', + alias: '__netflow_value', + }, + ]); + expect(baseConfig.select).toHaveLength(1); + expect(baseConfig).not.toHaveProperty('groupBy'); + }); + + it.each([ + [undefined, 20], + [NaN, 20], + [Infinity, 20], + [-5, 1], + [0, 1], + [101, 100], + [2.9, 2], + ])('bounds the requested path limit %s to %s', (limit, expected) => { + expect( + buildNetflowSankeyConfig({ baseConfig, dimensions, limit }).limit, + ).toEqual({ limit: expected }); + }); + + it('rejects queries without a path or a byte aggregate', () => { + expect(() => + buildNetflowSankeyConfig({ baseConfig, dimensions: [] }), + ).toThrow(); + expect(() => + buildNetflowSankeyConfig({ baseConfig, dimensions: [dimensions[0]] }), + ).toThrow(); + expect(() => + buildNetflowSankeyConfig({ + baseConfig, + dimensions: [dimensions[0], { ...dimensions[1], expression: ' ' }], + }), + ).toThrow(); + expect(() => + buildNetflowSankeyConfig({ + baseConfig: { ...baseConfig, select: '*' }, + dimensions, + }), + ).toThrow(); + }); +}); + +describe('NetFlow Sankey data', () => { + const row = (values: unknown[], value: unknown) => ({ + ...Object.fromEntries( + values.map((v, i) => [`__netflow_dimension_${i}`, v]), + ), + __netflow_value: value, + }); + + it('aggregates converging links and conserves sampled bytes at every stage', () => { + const data = buildNetflowSankeyData( + [ + row(['a', 'router', 'x'], '1000'), + row(['b', 'router', 'x'], 2000), + row(['a', 'router', 'y'], 4000), + ], + dimensions, + ); + expect(data.totalBytes).toBe(7000); + expect(data.paths.map(path => path.value)).toEqual([1000, 2000, 4000]); + expect(data.links).toHaveLength(4); + for (const stage of [0, 1]) { + expect( + data.links + .filter(link => data.nodes[link.source].stage === stage) + .reduce((total, link) => total + link.value, 0), + ).toBe(7000); + } + expect( + data.links.find(link => data.nodes[link.target].rawValue === 'x')?.value, + ).toBe(3000); + expect( + data.links.find(link => data.nodes[link.source].rawValue === 'a')?.value, + ).toBe(5000); + }); + + it('keeps repeated labels distinct across stages without cycles', () => { + const data = buildNetflowSankeyData( + [row(['same', 'same', 'same'], 5)], + dimensions, + ); + expect(new Set(data.nodes.map(node => node.id)).size).toBe(3); + expect(data.links).toEqual([ + { source: 0, target: 1, value: 5 }, + { source: 1, target: 2, value: 5 }, + ]); + expect(data.nodes.map(node => node.dimension)).toEqual(dimensions); + }); + + it('keeps empty raw values separate from the literal empty display label', () => { + const data = buildNetflowSankeyData( + [row(['', null, '(empty)'], 5)], + dimensions, + ); + expect(data.paths[0].values).toEqual(['', '', '(empty)']); + expect(data.nodes.map(node => node.name)).toEqual([ + '(empty)', + '(empty)', + '(empty)', + ]); + expect(data.nodes.map(node => node.rawValue)).toEqual(['', '', '(empty)']); + }); + + it('ignores invalid counters, nonpositive paths, and rows missing a dimension', () => { + const rows = [NaN, Infinity, -1, 0, '', null, true, 'bad'].map(value => + row(['a', 'b', 'c'], value), + ); + rows.push(row(['missing'], 5)); + expect(buildNetflowSankeyData(rows, dimensions)).toEqual({ + nodes: [], + links: [], + paths: [], + totalBytes: 0, + }); + expect(buildNetflowSankeyData([], dimensions).totalBytes).toBe(0); + expect(buildNetflowSankeyData([row(['a'], 5)], []).nodes).toEqual([]); + }); +}); diff --git a/packages/app/src/__tests__/source.test.ts b/packages/app/src/__tests__/source.test.ts index 7f6fd08cb2..4dd937773c 100644 --- a/packages/app/src/__tests__/source.test.ts +++ b/packages/app/src/__tests__/source.test.ts @@ -14,6 +14,7 @@ import { getEventBody, getSourceValidationNotificationId, getTraceDurationNumberFormat, + inferTableSourceConfig, pickTimeSeriesTable, useChartNumberFormats, useSingleSeriesNumberFormat, @@ -727,3 +728,66 @@ describe('getBuilderValueColumnCount', () => { expect(getBuilderValueColumnCount(makeRawSqlConfig({}))).toBe(0); }); }); + +describe('NetFlow source inference', () => { + const infer = async (names: string[]) => + inferTableSourceConfig({ + databaseName: 'default', + tableName: 'flows', + connectionId: 'connection', + kind: SourceKind.Netflow, + metadata: { + getColumns: jest.fn().mockResolvedValue( + names.map(name => ({ + name, + type: name === 'TimeReceived' ? 'DateTime' : 'UInt64', + })), + ), + getTableMetadata: jest + .fn() + .mockResolvedValue({ primary_key: 'TimeReceived' }), + } as any, + }); + it('maps Akvorado columns and preserves sampling semantics', async () => { + expect( + await infer([ + 'TimeReceived', + 'Bytes', + 'Packets', + 'SamplingRate', + 'SrcAddr', + 'DstAddr', + 'SrcPort', + 'DstPort', + 'Proto', + 'ExporterName', + 'InIfName', + 'OutIfName', + ]), + ).toMatchObject({ + kind: SourceKind.Netflow, + timestampValueExpression: 'TimeReceived', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + samplingRateExpression: 'SamplingRate', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + protocolExpression: 'Proto', + }); + }); + it('does not invent optional columns for unenriched or unsampled tables', async () => { + const config = await infer([ + 'TimeReceived', + 'Bytes', + 'Packets', + 'SrcAddr', + 'DstAddr', + 'SrcPort', + 'DstPort', + 'Proto', + ]); + expect(config).not.toHaveProperty('samplingRateExpression'); + expect(config).not.toHaveProperty('exporterExpression'); + expect(config).not.toHaveProperty('inIfExpression'); + }); +}); diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index 2d9bccd8a5..e7898a4201 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -23,6 +23,7 @@ import { IconDeviceFloppy, IconDeviceLaptop, IconLayoutGrid, + IconNetwork, IconSettings, IconSitemap, IconTable, @@ -80,6 +81,12 @@ type NavLinkConfig = { }; const NAV_LINKS: NavLinkConfig[] = [ + { + id: 'netflow', + label: 'NetFlow', + href: '/netflow', + icon: , + }, { id: 'chart', label: 'Chart Explorer', diff --git a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx index 6e597d06ae..73a8f4411c 100644 --- a/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx +++ b/packages/app/src/components/DBEditTimeChartForm/ChartEditorControls.tsx @@ -548,13 +548,15 @@ export function ChartEditorControls({ name="select" placeholder={ ((tableSource?.kind === SourceKind.Log || - tableSource?.kind === SourceKind.Trace) && + tableSource?.kind === SourceKind.Trace || + tableSource?.kind === SourceKind.Netflow) && tableSource.defaultTableSelectExpression) || 'SELECT Columns' } defaultValue={ tableSource?.kind === SourceKind.Log || - tableSource?.kind === SourceKind.Trace + tableSource?.kind === SourceKind.Trace || + tableSource?.kind === SourceKind.Netflow ? tableSource.defaultTableSelectExpression : undefined } diff --git a/packages/app/src/components/DBListBarChart.tsx b/packages/app/src/components/DBListBarChart.tsx index ef463ff1df..f77d39ec85 100644 --- a/packages/app/src/components/DBListBarChart.tsx +++ b/packages/app/src/components/DBListBarChart.tsx @@ -27,7 +27,7 @@ function ListItem({ hoverCardContent, hoverCardPosition = 'right', }: { - title: string; + title: React.ReactNode; value: string; color: string; percent: number; @@ -81,6 +81,7 @@ function ListBar({ getRowSearchLink, columns, hoverCardPosition, + renderGroupLabel, }: { data: any[]; valueColumn: string; @@ -93,6 +94,7 @@ function ListBar({ visible?: boolean; }[]; hoverCardPosition?: FloatingPosition; + renderGroupLabel?: (value: string) => React.ReactNode; }) { const values = (data ?? []).map(row => row[valueColumn]); const maxValue = Math.max(...values); @@ -148,7 +150,9 @@ function ListBar({ c="inherit" > void; @@ -201,6 +208,7 @@ export default function DBListBarChart({ toolbarItems?: React.ReactNode[]; showMVOptimizationIndicator?: boolean; errorVariant?: ChartErrorStateVariant; + renderGroupLabel?: (value: string) => React.ReactNode; }) { const queriedConfig = omit(config, ['granularity']); const { data, isLoading, isPlaceholderData, isError, error } = @@ -301,6 +309,7 @@ export default function DBListBarChart({ hoverCardPosition={hoverCardPosition} groupColumn={groupColumn} valueColumn={valueColumn} + renderGroupLabel={renderGroupLabel} /> )} diff --git a/packages/app/src/components/DBRowSidePanel.tsx b/packages/app/src/components/DBRowSidePanel.tsx index 17f3412748..27e69eb906 100644 --- a/packages/app/src/components/DBRowSidePanel.tsx +++ b/packages/app/src/components/DBRowSidePanel.tsx @@ -17,6 +17,7 @@ import { isTraceSource, SourceKind, TLogSource, + TNetflowSource, TSource, TTraceSource, } from '@hyperdx/common-utils/dist/types'; @@ -111,7 +112,7 @@ export type RowSidePanelContextProps = { dbSqlRowTableConfig?: BuilderChartConfigWithDateRange; isChildModalOpen?: boolean; setChildModalOpen?: (open: boolean) => void; - source?: TLogSource | TTraceSource; + source?: TLogSource | TTraceSource | TNetflowSource; onOpenLinkedTrace?: (link: SpanLinkData) => void; }; @@ -763,7 +764,11 @@ export const DBRowSidePanelInner = ({ const hasStack = crumbSourceStack.length > 0 || crumbNavStack.length > 0; const rootLabel = initialMainContent || - (rootSource.kind === SourceKind.Trace ? 'Trace' : 'Log'); + (rootSource.kind === SourceKind.Trace + ? 'Trace' + : rootSource.kind === SourceKind.Netflow + ? 'Flow' + : 'Log'); if (hasStack) { items.push({ diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index 10299cd622..4f44b98920 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -70,6 +70,11 @@ function emptyStateTitle(tab: DashboardTab, hasFilters: boolean): string { } const PRESET_DASHBOARDS = [ + { + name: 'NetFlow', + href: '/netflow', + description: 'Network traffic, top talkers, exporters, and flow records', + }, { name: 'Services', href: '/services', diff --git a/packages/app/src/components/NetflowCharts.tsx b/packages/app/src/components/NetflowCharts.tsx new file mode 100644 index 0000000000..35c04819fa --- /dev/null +++ b/packages/app/src/components/NetflowCharts.tsx @@ -0,0 +1,92 @@ +import { SimpleGrid, Stack } from '@mantine/core'; + +import { buildNetflowQueryConfigs } from '@/netflow'; +import { SankeyFilterHandler } from '@/netflowSankey'; + +import { ChartCard } from './charts/ChartCard'; +import DBListBarChart from './DBListBarChart'; +import DBNumberChart from './DBNumberChart'; +import NetflowFilterMenu, { NetflowFilterHandler } from './NetflowFilterMenu'; +import NetflowRecords from './NetflowRecords'; +import NetflowVisualization from './NetflowVisualization'; + +export default function NetflowCharts({ + configs, + onTimeRangeSelect, + onFilter, + onDimensionFilter, +}: { + configs: ReturnType; + onTimeRangeSelect: (start: Date, end: Date) => void; + onFilter?: NetflowFilterHandler; + onDimensionFilter?: SankeyFilterHandler; +}) { + return ( + + + {( + [ + ['Average bit rate', configs.bitsPerSecond], + ['Average packet rate', configs.packetsPerSecond], + ['Transferred bytes', configs.totalBytes], + ['Flow records', configs.flowRecords], + ] as const + ).map(([title, config]) => ( + + + + ))} + + + + {( + [ + ['Top source addresses', configs.topSourceAddresses, 'srcAddr'], + [ + 'Top destination addresses', + configs.topDestinationAddresses, + 'dstAddr', + ], + ['Protocols', configs.protocols, 'protocol'], + ['Exporters', configs.exporters, 'exporter'], + ['Input interfaces', configs.inInterfaces, 'inputInterface'], + ['Output interfaces', configs.outInterfaces, 'outputInterface'], + ] as const + ).map( + ([title, config, field]) => + config && ( + + ( + + )} + /> + + ), + )} + + + + ); +} diff --git a/packages/app/src/components/NetflowFilterMenu.tsx b/packages/app/src/components/NetflowFilterMenu.tsx new file mode 100644 index 0000000000..e965c173b6 --- /dev/null +++ b/packages/app/src/components/NetflowFilterMenu.tsx @@ -0,0 +1,49 @@ +import { Button, Menu } from '@mantine/core'; + +export type NetflowFilterField = + | 'srcAddr' + | 'dstAddr' + | 'protocol' + | 'exporter' + | 'inputInterface' + | 'outputInterface'; + +export type NetflowFilterHandler = ( + field: NetflowFilterField, + value: string, + excluded: boolean, +) => void; + +export default function NetflowFilterMenu({ + field, + value, + onFilter, +}: { + field: NetflowFilterField; + value: string; + onFilter?: NetflowFilterHandler; +}) { + if (!value.trim() || !onFilter) return <>{value || '—'}; + + return ( + + + + + + onFilter(field, value, false)}> + Include + + onFilter(field, value, true)}> + Exclude + + + + ); +} diff --git a/packages/app/src/components/NetflowFilterPills.tsx b/packages/app/src/components/NetflowFilterPills.tsx new file mode 100644 index 0000000000..b6e39b1e29 --- /dev/null +++ b/packages/app/src/components/NetflowFilterPills.tsx @@ -0,0 +1,109 @@ +import { Filter, TNetflowSource } from '@hyperdx/common-utils/dist/types'; +import { Group } from '@mantine/core'; + +import { getNetflowDimensions } from '@/netflow'; +import { + sankeyDimensionExpression, + SankeyFilterHandler, +} from '@/netflowSankey'; +import { useSearchPageFilterState } from '@/searchFilters'; + +import { cleanClickHouseExpression } from './DBSearchPageFilters/utils'; +import { FilterPill } from './FilterPill'; +import { NetflowFilterHandler } from './NetflowFilterMenu'; + +const labels = new Map([ + ['srcAddr', 'Source IP'], + ['dstAddr', 'Destination IP'], + ['protocol', 'Protocol'], + ['exporter', 'Exporter'], + ['inputInterface', 'Input interface'], + ['outputInterface', 'Output interface'], +]); +const knownColumns = new Set(); + +export function useNetflowFilterState({ + source, + filters, + onChange, +}: { + source?: TNetflowSource; + filters: Filter[]; + onChange: (filters: Filter[]) => void; +}) { + const state = useSearchPageFilterState({ + searchQuery: filters, + onFilterChange: onChange, + knownColumns, + }); + const dimensions = source ? getNetflowDimensions(source) : undefined; + const onFilter: NetflowFilterHandler = (field, value, excluded) => { + const expression = dimensions?.[field]; + if (expression) + state.setFilterValue(expression, value, excluded ? 'exclude' : 'include'); + }; + const onDimensionFilter: SankeyFilterHandler = ( + dimension, + value, + excluded, + ) => { + state.setFilterValue( + sankeyDimensionExpression(dimension), + value, + excluded ? 'exclude' : 'include', + ); + }; + const fieldLabels = Object.fromEntries( + Object.entries(dimensions ?? {}).flatMap(([field, expression]) => + expression + ? [ + [cleanClickHouseExpression(expression), labels.get(field) ?? field], + [ + cleanClickHouseExpression( + sankeyDimensionExpression({ + key: field, + label: field, + expression, + }), + ), + labels.get(field) ?? field, + ], + ] + : [], + ), + ); + return { state, onFilter, onDimensionFilter, fieldLabels }; +} + +export default function NetflowFilterPills({ + state, + fieldLabels, +}: Pick, 'state' | 'fieldLabels'>) { + return ( + + {Object.entries(state.filters).flatMap(([field, values]) => + (['included', 'excluded'] as const).flatMap(polarity => + Array.from(values[polarity]).map(value => ( + + state.setFilterValue( + field, + value, + polarity === 'excluded' ? 'exclude' : 'include', + ) + } + /> + )), + ), + )} + + ); +} diff --git a/packages/app/src/components/NetflowRecords.tsx b/packages/app/src/components/NetflowRecords.tsx new file mode 100644 index 0000000000..ac3b0f5dcc --- /dev/null +++ b/packages/app/src/components/NetflowRecords.tsx @@ -0,0 +1,213 @@ +import { useState } from 'react'; +import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; +import { + Button, + Drawer, + Loader, + ScrollArea, + Stack, + Table, + Text, +} from '@mantine/core'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { formatNumber } from '@/utils'; + +import { ChartCard } from './charts/ChartCard'; +import ChartContainer from './charts/ChartContainer'; +import ChartErrorState from './charts/ChartErrorState'; +import EmptyState from './EmptyState'; +import NetflowFilterMenu, { NetflowFilterHandler } from './NetflowFilterMenu'; + +const fields = [ + ['timestamp', 'Time'], + ['srcAddr', 'Source IP'], + ['srcPort', 'Source port'], + ['dstAddr', 'Destination IP'], + ['dstPort', 'Destination port'], + ['protocol', 'Protocol'], + ['exporter', 'Exporter'], + ['bytes', 'Bytes'], + ['packets', 'Packets'], + ['inputInterface', 'Input interface'], + ['outputInterface', 'Output interface'], + ['samplingRate', 'Sampling rate'], + ['rawBytes', 'Raw bytes'], + ['rawPackets', 'Raw packets'], +] as const; + +export default function NetflowRecords({ + config, + onFilter, +}: { + config: BuilderChartConfigWithDateRange; + onFilter?: NetflowFilterHandler; +}) { + const { data, isLoading, error } = useQueriedChartConfig(config); + const [selected, setSelected] = useState | null>( + null, + ); + const rows = (data?.data ?? []).map(row => + Object.fromEntries(fields.map(([key]) => [key, row[`__netflow_${key}`]])), + ); + + return ( + <> + + + Latest {rows.length} records · limit 500 + , + ]} + > + {error ? ( + + ) : isLoading ? ( + + ) : rows.length === 0 ? ( + + ) : ( + + + + + {[ + 'Time', + 'Source', + 'Destination', + 'Protocol', + 'Exporter', + 'Bytes', + 'Packets', + 'Details', + ].map(label => ( + {label} + ))} + + + + {rows.map((row, index) => ( + // Flow exports may contain duplicate records without an ID; the table remounts when its query changes. + // eslint-disable-next-line @eslint-react/no-array-index-key + + {String(row.timestamp)} + + {' '} + · {String(row.srcPort)} + + + {' '} + · {String(row.dstPort)} + + + + + + + + + {formatNumber(String(row.bytes), { + output: 'byte', + mantissa: 2, + })} + + + {formatNumber(String(row.packets), { + output: 'number', + thousandSeparated: true, + })} + + + + + + ))} + +
+
+ )} +
+
+ setSelected(null)} + position="right" + title="Flow details" + size="lg" + > + {selected && ( + + + Bytes and packets include sampling adjustment. Raw counters show + the values received from the exporter. + + + + {fields.map(([key, label]) => ( + + {label} + + {key === 'srcAddr' || + key === 'dstAddr' || + key === 'protocol' || + key === 'exporter' || + key === 'inputInterface' || + key === 'outputInterface' ? ( + + ) : ( + String(selected[key] ?? '—') + )} + + + ))} + +
+
+ )} +
+ + ); +} diff --git a/packages/app/src/components/NetflowSankey.tsx b/packages/app/src/components/NetflowSankey.tsx new file mode 100644 index 0000000000..86863ed86c --- /dev/null +++ b/packages/app/src/components/NetflowSankey.tsx @@ -0,0 +1,188 @@ +import { useMemo } from 'react'; +import { + parseAsArrayOf, + parseAsInteger, + parseAsString, + useQueryStates, +} from 'nuqs'; +import { + BuilderChartConfigWithDateRange, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; +import { Group, Loader, MultiSelect, Select, Stack, Text } from '@mantine/core'; + +import { useQueriedChartConfig } from '@/hooks/useChartConfig'; +import { useColumns } from '@/hooks/useMetadata'; +import { + buildNetflowSankeyConfig, + buildNetflowSankeyData, + SankeyDimension, + SankeyFilterHandler, +} from '@/netflowSankey'; +import { + getDefaultSankeyDimensions, + getSankeyDimensionOptions, +} from '@/netflowSankeyDimensions'; +import { useSource } from '@/source'; + +import { ChartCard } from './charts/ChartCard'; +import ChartContainer from './charts/ChartContainer'; +import ChartErrorState from './charts/ChartErrorState'; +import EmptyState from './EmptyState'; +import NetflowSankeyChart from './NetflowSankeyChart'; +import NetflowSankeyTable from './NetflowSankeyTable'; + +function SankeyResults({ + baseConfig, + dimensions, + limit, + onFilter, +}: { + baseConfig: BuilderChartConfigWithDateRange; + dimensions: SankeyDimension[]; + limit: number; + onFilter?: SankeyFilterHandler; +}) { + const config = useMemo( + () => buildNetflowSankeyConfig({ baseConfig, dimensions, limit }), + [baseConfig, dimensions, limit], + ); + const { data, isLoading, error } = useQueriedChartConfig(config); + const graph = useMemo( + () => buildNetflowSankeyData(data?.data ?? [], dimensions), + [data, dimensions], + ); + const rangeSeconds = + (baseConfig.dateRange[1].getTime() - baseConfig.dateRange[0].getTime()) / + 1000; + if (error) return ; + if (isLoading) return ; + if (!graph.paths.length) + return ( + + ); + return ( + <> + + + Showing {graph.paths.length} of the top {limit} paths by sampled bytes. + Link widths represent these displayed paths; rates are averaged over the + selected time range. + + + + ); +} + +export default function NetflowSankey({ + baseConfig, + onFilter, +}: { + baseConfig: BuilderChartConfigWithDateRange; + onFilter?: SankeyFilterHandler; +}) { + const [params, setParams] = useQueryStates({ + sankeyDimensions: parseAsArrayOf(parseAsString), + sankeyLimit: parseAsInteger.withDefault(20), + }); + const { data: source } = useSource({ + id: baseConfig.source, + kinds: [SourceKind.Netflow], + }); + const { + data: columns, + isLoading, + error, + } = useColumns({ + databaseName: baseConfig.from.databaseName, + tableName: baseConfig.from.tableName, + connectionId: baseConfig.connection ?? '', + }); + const options = useMemo( + () => (source ? getSankeyDimensionOptions(source, columns ?? []) : []), + [source, columns], + ); + const keys = params.sankeyDimensions ?? getDefaultSankeyDimensions(options); + const dimensions = keys.flatMap(key => + options.filter(option => option.key === key), + ); + const valid = + dimensions.length >= 2 && + dimensions.length <= 5 && + dimensions.length === keys.length && + new Set(keys).size === keys.length; + const limit = [10, 20, 50].includes(params.sankeyLimit) + ? params.sankeyLimit + : 20; + return ( + + + + + ({ + value: option.key, + label: option.label, + }))} + value={keys} + onChange={sankeyDimensions => + void setParams({ sankeyDimensions }) + } + /> + + )} + /> + ); +} +jest.mock('@/components/ConnectionSelect', () => ({ + ConnectionSelectControlled: MockInput, +})); +jest.mock('@/components/DatabaseSelect', () => ({ + DatabaseSelectControlled: MockInput, +})); +jest.mock('@/components/DBTableSelect', () => ({ + DBTableSelectControlled: MockInput, +})); +jest.mock('@/components/InputControlled', () => ({ + InputControlled: MockInput, + AutocompleteControlled: MockInput, +})); +jest.mock('@/components/ConfirmDeleteMenu', () => () => null); +jest.mock('@/components/Sources/SourceForm/TableModelForm', () => ({ + TableModelForm: ({ control }: { control: Control }) => ( + <> + + + + ), +})); + +const mockInfer = jest.mocked(inferTableSourceConfig); +const deferred = () => + Promise.withResolvers>>(); + +describe('Source form asynchronous inference', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('ignores a NetFlow response that arrives after the user selects Logs', async () => { + const oldRequest = deferred(); + const latestRequest = deferred(); + mockInfer + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(latestRequest.promise); + renderWithMantine( + , + ); + fireEvent.change(screen.getByLabelText('from.tableName'), { + target: { value: 'flows' }, + }); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(1)); + fireEvent.click(screen.getByRole('radio', { name: 'Logs' })); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(2)); + await act(async () => + latestRequest.resolve({ + kind: SourceKind.Log, + timestampValueExpression: 'LogTime', + }), + ); + await act(async () => + oldRequest.resolve({ + kind: SourceKind.Netflow, + timestampValueExpression: 'FlowTime', + }), + ); + expect(screen.getByRole('radio', { name: 'Logs' })).toBeChecked(); + expect(screen.getByLabelText('timestampValueExpression')).toHaveValue( + 'LogTime', + ); + expect(notifications.show).toHaveBeenCalledTimes(1); + }); + + it('keeps the latest table inference and a mapping edited while it loads', async () => { + const oldRequest = deferred(); + const latestRequest = deferred(); + mockInfer + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(latestRequest.promise); + renderWithMantine( + , + ); + fireEvent.change(screen.getByLabelText('from.tableName'), { + target: { value: 'old_flows' }, + }); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(1)); + fireEvent.change(screen.getByLabelText('from.tableName'), { + target: { value: 'new_flows' }, + }); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(2)); + fireEvent.change(screen.getByLabelText('bytesExpression'), { + target: { value: 'CustomBytes * 2' }, + }); + await act(async () => + latestRequest.resolve({ + kind: SourceKind.Netflow, + timestampValueExpression: 'NewTime', + bytesExpression: 'NewBytes', + }), + ); + await act(async () => + oldRequest.resolve({ + kind: SourceKind.Netflow, + timestampValueExpression: 'OldTime', + bytesExpression: 'OldBytes', + }), + ); + expect(screen.getByLabelText('timestampValueExpression')).toHaveValue( + 'NewTime', + ); + expect(screen.getByLabelText('bytesExpression')).toHaveValue( + 'CustomBytes * 2', + ); + expect(notifications.show).toHaveBeenCalledTimes(1); + }); + + it.each([ + ['from.databaseName', 'other_database', { databaseName: 'other_database' }], + ['connection', 'other_connection', { connectionId: 'other_connection' }], + ] as const)( + 'restarts discovery for the same table after %s changes', + async (field, value, expected) => { + const oldRequest = deferred(); + const latestRequest = deferred(); + mockInfer + .mockReturnValueOnce(oldRequest.promise) + .mockReturnValueOnce(latestRequest.promise); + renderWithMantine( + , + ); + fireEvent.change(screen.getByLabelText('from.tableName'), { + target: { value: 'flows' }, + }); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(1)); + fireEvent.change(screen.getByLabelText(field), { target: { value } }); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(2)); + expect(mockInfer).toHaveBeenLastCalledWith( + expect.objectContaining({ tableName: 'flows', ...expected }), + ); + await act(async () => + latestRequest.resolve({ + kind: SourceKind.Netflow, + timestampValueExpression: 'NewTime', + }), + ); + await act(async () => + oldRequest.resolve({ + kind: SourceKind.Netflow, + timestampValueExpression: 'OldTime', + }), + ); + expect(screen.getByLabelText('timestampValueExpression')).toHaveValue( + 'NewTime', + ); + expect(notifications.show).toHaveBeenCalledTimes(1); + }, + ); +}); diff --git a/packages/common-utils/src/clickhouse/__tests__/browser.test.ts b/packages/common-utils/src/clickhouse/__tests__/browser.test.ts index 7ffa233194..9eb4b2ad36 100644 --- a/packages/common-utils/src/clickhouse/__tests__/browser.test.ts +++ b/packages/common-utils/src/clickhouse/__tests__/browser.test.ts @@ -100,17 +100,23 @@ describe('browser connection routing', () => { expect.objectContaining({ http_headers: {} }), ); }); - it('retains connection routing for the authenticated API proxy', async () => { - const client = new ClickhouseClient({ host: '/api/clickhouse-proxy' }); - await client.query({ - query: 'SELECT 1', - connectionId: 'team-connection', - shouldSkipApplySettings: true, - }); - expect(query).toHaveBeenCalledWith( - expect.objectContaining({ - http_headers: { 'x-hyperdx-connection-id': 'team-connection' }, - }), - ); - }); + it.each([{}, { username: 'default' }, { password: '' }])( + 'retains proxy routing with incomplete credentials %j', + async credentials => { + const client = new ClickhouseClient({ + host: '/api/clickhouse-proxy', + ...credentials, + }); + await client.query({ + query: 'SELECT 1', + connectionId: 'team-connection', + shouldSkipApplySettings: true, + }); + expect(query).toHaveBeenCalledWith( + expect.objectContaining({ + http_headers: { 'x-hyperdx-connection-id': 'team-connection' }, + }), + ); + }, + ); }); diff --git a/scripts/netflow-browser-check.mjs b/scripts/netflow-browser-check.mjs index 5d1466caae..a15c69baa8 100644 --- a/scripts/netflow-browser-check.mjs +++ b/scripts/netflow-browser-check.mjs @@ -14,9 +14,22 @@ const errors = []; page.on('pageerror', error => errors.push(error.message)); try { + for (const [from, to] of [[1791565200000, 1791565200000], [1791565200000, 1791561600000]]) { + await page.goto(`${baseUrl}/netflow?from=${from}&to=${to}`); + await expect(page.getByText('Invalid time range', { exact: true })).toBeVisible(); + await expect(page.getByTestId('netflow-records')).toHaveCount(0); + await page.getByRole('button', { name: 'Use past hour', exact: true }).click(); + await expect(page.getByRole('button', { name: 'Inspect flow 1', exact: true })).toBeVisible({ timeout: 60000 }); + } await page.goto(`${baseUrl}/netflow`, { waitUntil: 'domcontentloaded' }); const records = page.getByTestId('netflow-records'); const firstFlow = () => records.locator('tbody tr').first(); + const pills = page.getByTestId('netflow-filter-pills'); + const addProtocolFilter = async () => { + await firstFlow().getByRole('button', { name: /^Filter protocol:/ }).click(); + await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); + await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(1); + }; await expect( page.getByRole('button', { name: 'Inspect flow 1', exact: true }), ).toBeVisible({ timeout: 60000 }); @@ -182,6 +195,7 @@ try { await page.keyboard.press('Escape'); await expect(page.getByRole('dialog')).toBeHidden(); + await addProtocolFilter(); await page .getByRole('button', { name: 'Source actions', exact: true }) .click(); @@ -203,6 +217,22 @@ try { .getByRole('button', { name: 'Save New Source', exact: true }) .click(); await expect(sourceForm).toBeHidden(); + await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(0); + await addProtocolFilter(); + const namedSourceUrl = new URL(page.url()); + namedSourceUrl.searchParams.set('source', 'NetFlow browser test'); + await page.goto(namedSourceUrl.href); + await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(1); + // Source changes apply before Run, including the edit target and URL. + await page.getByRole('combobox', { name: 'NetFlow source', exact: true }).click(); + await page.getByRole('option', { name: 'NetFlow demo', exact: true }).click(); + await expect.poll(() => new URL(page.url()).searchParams.get('source')).toBe('netflow-demo'); + await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(0); + await expect.poll(() => JSON.parse(decodeURIComponent(new URL(page.url()).searchParams.get('filters') || '[]'))).toEqual([]); + await page.getByRole('button', { name: 'Source actions', exact: true }).click(); + await page.getByRole('menuitem', { name: 'Edit source', exact: true }).click(); + await expect(page.getByRole('dialog').locator('input[name=name]')).toHaveValue('NetFlow demo'); + await page.keyboard.press('Escape'); await page.reload(); await expect( page.getByRole('button', { name: 'Inspect flow 1', exact: true }), @@ -219,7 +249,7 @@ try { await expect(page.getByText('NetFlow source unavailable')).toBeVisible(); assert.deepEqual(errors, [], 'No browser runtime errors'); console.log( - 'NetFlow browser checks passed: data, filters, sampling, details, refresh, source creation/reload, Search, unavailable source.', + 'NetFlow browser checks passed: data, filters, sampling, details, refresh, source creation/reload/switching, Search, unavailable source, invalid time range recovery.', ); console.log(`Screenshots: ${artifacts.pathname}`); } finally { diff --git a/scripts/netflow-click-browser-check.mjs b/scripts/netflow-click-browser-check.mjs index 484fe41f52..d7799b6103 100644 --- a/scripts/netflow-click-browser-check.mjs +++ b/scripts/netflow-click-browser-check.mjs @@ -1,7 +1,7 @@ import assert from 'node:assert/strict'; import { chromium, expect } from '@playwright/test'; -const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3001'; +const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3000'; const browser = await chromium.launch(); const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); page.setDefaultTimeout(15000); diff --git a/scripts/netflow-sankey-browser-check.mjs b/scripts/netflow-sankey-browser-check.mjs index de72b388fc..bcb7d5cab6 100644 --- a/scripts/netflow-sankey-browser-check.mjs +++ b/scripts/netflow-sankey-browser-check.mjs @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import { mkdir } from 'node:fs/promises'; import { chromium, expect } from '@playwright/test'; -const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3001'; +const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3000'; const artifacts = new URL( '../packages/app/test-results/netflow/', import.meta.url, From dee84147f7fdcc38ac5688bf1c5468f450728e9a Mon Sep 17 00:00:00 2001 From: Valerii Petrov Date: Fri, 9 Oct 2026 13:08:41 -0700 Subject: [PATCH 04/11] fix: complete NetFlow alerts and preserve search drafts --- .changeset/native-netflow-monitoring.md | 4 + .../__tests__/netflowAlerts.int.test.ts | 252 ++++++++++++++++++ packages/api/src/tasks/checkAlerts/index.ts | 4 +- .../api/src/tasks/checkAlerts/template.ts | 8 +- packages/app/src/NetflowPage.tsx | 29 +- .../src/__tests__/NetflowPage.drafts.test.tsx | 147 ++++++++++ .../app/src/components/AlertPreviewChart.tsx | 3 +- .../app/src/components/NetflowRecords.tsx | 3 +- .../__tests__/AlertPreviewChart.test.tsx | 97 +++++++ .../__tests__/NetflowRecords.test.tsx | 2 + scripts/netflow-click-browser-check.mjs | 35 ++- 11 files changed, 556 insertions(+), 28 deletions(-) create mode 100644 packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts create mode 100644 packages/app/src/__tests__/NetflowPage.drafts.test.tsx create mode 100644 packages/app/src/components/__tests__/AlertPreviewChart.test.tsx diff --git a/.changeset/native-netflow-monitoring.md b/.changeset/native-netflow-monitoring.md index 0c8b874f92..c7b575b57d 100644 --- a/.changeset/native-netflow-monitoring.md +++ b/.changeset/native-netflow-monitoring.md @@ -33,3 +33,7 @@ axis widths. Preserve NetFlow mappings through MCP source tools, ignore stale source inference, share filter keys across visualizations, retain quoted columns, and make explicit include/exclude actions idempotent. Format flow times using user preferences. + +Support NetFlow saved-search alerts with default aliases, previews, and flow +samples in notifications. Keep unfinished search fields pending when applying +click filters, and display the configured record limit. diff --git a/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts new file mode 100644 index 0000000000..2f19e571c7 --- /dev/null +++ b/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts @@ -0,0 +1,252 @@ +import { + ClickhouseClient, + createNativeClient, +} from '@hyperdx/common-utils/dist/clickhouse/node'; +import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; +import { + AlertState, + AlertThresholdType, + SourceKind, +} from '@hyperdx/common-utils/dist/types'; +import mongoose from 'mongoose'; + +import * as config from '@/config'; +import { createAlert } from '@/controllers/alerts'; +import { createTeam } from '@/controllers/team'; +import { getServer } from '@/fixtures'; +import Alert, { AlertSource } from '@/models/alert'; +import AlertHistory from '@/models/alertHistory'; +import Connection from '@/models/connection'; +import { SavedSearch } from '@/models/savedSearch'; +import { NetflowSource } from '@/models/source'; +import Webhook from '@/models/webhook'; +import { processAlert } from '@/tasks/checkAlerts'; +import { AlertTaskType, loadProvider } from '@/tasks/checkAlerts/providers'; +import { + fetchSampleLines, + renderAlertTemplate, +} from '@/tasks/checkAlerts/template'; +import * as slack from '@/utils/slack'; + +describe('NetFlow saved-search alerts', () => { + const server = getServer(); + const nativeClient = createNativeClient({ + url: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + const clickhouseClient = new ClickhouseClient({ + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + const table = 'default.netflow_alert_test'; + const startTime = new Date('2023-11-16T22:05:00Z'); + const endTime = new Date('2023-11-16T22:10:00Z'); + const select = 'SrcAddr, Bytes * SamplingRate AS traffic'; + + beforeAll(async () => { + await server.start(); + await nativeClient.command({ + query: `CREATE TABLE ${table} ( + TimeReceived DateTime('UTC'), SrcAddr IPv6, DstAddr IPv6, + SrcPort UInt16, DstPort UInt16, Proto UInt8, + Bytes UInt64, Packets UInt64, SamplingRate UInt32 + ) ENGINE = Memory`, + }); + }); + + afterEach(async () => { + await nativeClient.command({ query: `TRUNCATE TABLE ${table}` }); + await server.clearDBs(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + afterAll(async () => { + await nativeClient.command({ query: `DROP TABLE IF EXISTS ${table}` }); + await nativeClient.close(); + await server.stop(); + }); + + it.each([ + { + name: 'default SELECT and Lucene', + savedSelect: undefined, + where: 'traffic:[1000 TO *]', + whereLanguage: 'lucene' as const, + }, + { + name: 'empty SELECT and SQL', + savedSelect: '', + where: 'traffic >= 1000', + whereLanguage: 'sql' as const, + }, + { + name: 'explicit SELECT and Lucene', + savedSelect: select, + where: 'traffic:[1000 TO *]', + whereLanguage: 'lucene' as const, + }, + ])( + 'evaluates and delivers $name with aliased samples', + async ({ savedSelect, where, whereLanguage }) => { + const postMessage = jest + .spyOn(slack, 'postMessageToWebhook') + .mockResolvedValue({ text: 'ok' }); + const team = await createTeam({ name: 'NetFlow alerts' }); + const connection = await Connection.create({ + team: team._id, + name: 'Test ClickHouse', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + const source = await NetflowSource.create({ + kind: SourceKind.Netflow, + team: team._id, + connection: connection.id, + name: 'Flows', + from: { databaseName: 'default', tableName: 'netflow_alert_test' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: select, + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + samplingRateExpression: 'SamplingRate', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + }); + const savedSearch = await SavedSearch.create({ + team: team._id, + name: 'Large TCP flows', + source: source.id, + select: savedSelect, + where, + whereLanguage, + filters: [{ type: 'sql', condition: 'Proto = 6' }], + orderBy: 'SrcAddr', + tags: [], + }); + const webhook = await Webhook.create({ + team: team._id, + name: 'Test webhook', + service: 'slack', + url: 'https://hooks.slack.com/services/netflow-test', + }); + const alert = await createAlert( + team._id, + { + name: 'Large TCP flow alert', + source: AlertSource.SAVED_SEARCH, + savedSearchId: savedSearch.id, + channel: { type: 'webhook', webhookId: webhook.id }, + interval: '5m', + thresholdType: AlertThresholdType.ABOVE, + threshold: 2, + }, + new mongoose.Types.ObjectId(), + ); + await nativeClient.insert({ + table, + format: 'JSONEachRow', + values: [ + { SrcAddr: '2001:db8::1', Bytes: 20, SamplingRate: 100 }, + { SrcAddr: '2001:db8::2', Bytes: 30, SamplingRate: 100 }, + { SrcAddr: '2001:db8::3', Bytes: 30, SamplingRate: 100, Proto: 17 }, + { SrcAddr: '2001:db8::4', Bytes: 1, SamplingRate: 100 }, + { + SrcAddr: '2001:db8::5', + Bytes: 30, + SamplingRate: 100, + TimeReceived: '2023-11-16 22:10:00', + }, + ].map(row => ({ + TimeReceived: '2023-11-16 22:05:00', + DstAddr: '2001:db8::ffff', + SrcPort: 443, + DstPort: 50000, + Proto: 6, + Packets: 1, + ...row, + })), + }); + const alertProvider = await loadProvider(); + const metadata = getMetadata(clickhouseClient); + const teamWebhooksById = new Map([[webhook.id, webhook]]); + const expectedSamples = '"2001:db8::1",2000\n"2001:db8::2",3000\n'; + expect( + await fetchSampleLines({ + clickhouseClient, + metadata, + savedSearch, + source, + startTime, + endTime, + }), + ).toBe(expectedSamples); + + await processAlert( + new Date('2023-11-16T22:12:00Z'), + { + alert, + source, + savedSearch, + taskType: AlertTaskType.SAVED_SEARCH, + previousMap: new Map(), + }, + clickhouseClient, + connection.id, + alertProvider, + teamWebhooksById, + ); + + expect(await Alert.findById(alert.id).lean()).toMatchObject({ + state: AlertState.ALERT, + executionErrors: [], + }); + const histories = await AlertHistory.find({ alert: alert.id }).lean(); + expect(histories).toHaveLength(1); + expect(histories[0]).toMatchObject({ + state: AlertState.ALERT, + lastValues: [{ count: 2 }], + }); + expect(postMessage).toHaveBeenCalledTimes(1); + expect(JSON.stringify(postMessage.mock.calls[0][1])).toContain( + '2 lines found', + ); + expect(JSON.stringify(postMessage.mock.calls[0][1])).toContain( + '2001:db8::1', + ); + expect(JSON.stringify(postMessage.mock.calls[0][1])).not.toContain( + '2001:db8::3', + ); + + // Standalone rendering must also fetch samples without the evaluator's cache. + const rendered = await renderAlertTemplate({ + alertProvider, + clickhouseClient, + metadata, + state: AlertState.ALERT, + title: 'NetFlow alert', + teamId: team.id, + teamWebhooksById: new Map(), + view: { + alert: { ...alert.toObject(), channel: { type: null }, channels: [] }, + source, + savedSearch, + startTime, + endTime, + value: 2, + attributes: {}, + granularity: '5m', + isGroupedAlert: false, + }, + }); + expect(rendered.failures).toEqual([]); + expect(rendered.body).toContain(expectedSamples); + }, + ); +}); diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 1b481f789d..82738787b4 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -164,7 +164,9 @@ export async function computeAliasWithClauses( ): Promise { const resolvedSelect = savedSearch.select || - ((source.kind === SourceKind.Log || source.kind === SourceKind.Trace) && + ((source.kind === SourceKind.Log || + source.kind === SourceKind.Trace || + source.kind === SourceKind.Netflow) && source.defaultTableSelectExpression) || ''; const config: BuilderChartConfigWithOptDateRange = { diff --git a/packages/api/src/tasks/checkAlerts/template.ts b/packages/api/src/tasks/checkAlerts/template.ts index dad60cdeb4..90187b8877 100644 --- a/packages/api/src/tasks/checkAlerts/template.ts +++ b/packages/api/src/tasks/checkAlerts/template.ts @@ -15,8 +15,8 @@ import { ChartConfigWithOptDateRange, Filter, isRangeThresholdType, + isSearchableSource, SavedChartConfig, - SourceKind, zAlertChannelType, } from '@hyperdx/common-utils/dist/types'; import Handlebars, { HelperOptions } from 'handlebars'; @@ -897,10 +897,8 @@ ${targetTemplate}`; if (source == null) { throw new Error(`Source ID is ${alert.source} but source is null`); } - if (source.kind !== SourceKind.Log && source.kind !== SourceKind.Trace) { - throw new Error( - `Expecting SourceKind 'trace' or 'log', got ${source.kind}`, - ); + if (!isSearchableSource(source)) { + throw new Error(`Expecting a searchable source, got ${source.kind}`); } // TODO: show group + total count for group-by alerts // Pass query results through the view so Handlebars syntax in log lines diff --git a/packages/app/src/NetflowPage.tsx b/packages/app/src/NetflowPage.tsx index a34b9762f1..4edbdeea8a 100644 --- a/packages/app/src/NetflowPage.tsx +++ b/packages/app/src/NetflowPage.tsx @@ -60,12 +60,11 @@ function NetflowPage() { (s): s is TNetflowSource => s.kind === SourceKind.Netflow && !s.disabled, ); const source = params.source - ? netflowSources?.find( - s => s.id === params.source || s.name === params.source, - ) + ? netflowSources?.find(s => [s.id, s.name].includes(params.source)) : netflowSources?.[0]; - const { control, handleSubmit, reset, getValues } = useForm({ - values: { ...params, source: source?.id ?? '' }, + const { filters: appliedFilters, ...formParams } = params; + const { control, handleSubmit, reset } = useForm({ + values: { ...formParams, source: source?.id ?? '' }, }); const selectedSource = useWatch({ control, name: 'source' }); const syncSource = useEffectEvent((id: string) => { @@ -81,12 +80,8 @@ function NetflowPage() { }, [selectedSource]); const clickFilters = useNetflowFilterState({ source, - filters: params.filters, - onChange: filters => { - const next = { ...getValues(), filters }; - reset(next); - void setParams(next); - }, + filters: appliedFilters, + onChange: filters => void setParams({ filters }), }); const defaultTimeRange = useDefaultTimeRange(DEFAULT_INTERVAL); const [displayedTimeInputValue, setDisplayedTimeInputValue] = @@ -101,9 +96,7 @@ function NetflowPage() { onTimeRangeSelect, isLive: false, }); - const duration = - searchedTimeRange[1].getTime() - searchedTimeRange[0].getTime(); - const hasValidTimeRange = Number.isFinite(duration) && duration > 0; + const hasValidTimeRange = searchedTimeRange[1] > searchedTimeRange[0]; const configs = useMemo( () => source && hasValidTimeRange @@ -226,14 +219,13 @@ function NetflowPage() { source: source?.id ?? '', where: '', whereLanguage: params.whereLanguage, - filters: [], exporter: '', protocol: '', srcAddr: '', dstAddr: '', }; reset(cleared); - void setParams(cleared); + void setParams({ ...cleared, filters: [] }); }} > Clear filters @@ -241,9 +233,8 @@ function NetflowPage() { - Explore traffic volume, top talkers, and recent flow records. - Rates are averaged over the selected time range; counters - account for the source’s sampling rate. + Explore traffic, top talkers, and recent flows. Rates average + over the selected time range; counters account for sampling. {error ? ( diff --git a/packages/app/src/__tests__/NetflowPage.drafts.test.tsx b/packages/app/src/__tests__/NetflowPage.drafts.test.tsx new file mode 100644 index 0000000000..897e771157 --- /dev/null +++ b/packages/app/src/__tests__/NetflowPage.drafts.test.tsx @@ -0,0 +1,147 @@ +import { ComponentType, useEffect, useState } from 'react'; +import { + Filter, + SourceKind, + TNetflowSource, +} from '@hyperdx/common-utils/dist/types'; +import { act, fireEvent, screen, waitFor } from '@testing-library/react'; + +import { buildNetflowQueryConfigs } from '@/netflow'; +import NetflowPage from '@/NetflowPage'; + +const mockSource: TNetflowSource = { + id: 'flows', + name: 'Flows', + kind: SourceKind.Netflow, + connection: 'local', + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: '*', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + exporterExpression: 'ExporterName', +}; +const mockRange = [ + new Date('2026-10-09T12:00:00Z'), + new Date('2026-10-09T13:00:00Z'), +]; +const mockInitialParams = { + source: 'flows', + where: 'Proto:6', + whereLanguage: 'lucene', + filters: [], + exporter: '', + protocol: '', + srcAddr: '', + dstAddr: '', +}; +let mockFilterChange: (filters: Filter[]) => void; + +jest.mock( + 'next/dynamic', + () => (loader: () => Promise) => () => { + const [Component, setComponent] = useState(null); + useEffect(() => { + void loader().then(component => setComponent(() => component)); + }, []); + return Component ? : null; + }, +); +jest.mock('nuqs', () => ({ + ...jest.requireActual('nuqs'), + useQueryStates: () => { + const [params, setParams] = useState(mockInitialParams); + return [ + params, + (next: Partial) => + setParams(previous => ({ ...previous, ...next })), + ]; + }, +})); +jest.mock('@/source', () => ({ useSources: () => ({ data: [mockSource] }) })); +jest.mock('@/layout', () => ({ + withAppNavForSurface: () => (page: unknown) => page, +})); +jest.mock('@/theme/ThemeProvider', () => ({ + ...jest.requireActual('@/theme/ThemeProvider'), + usePageTitle: () => 'NetFlow', +})); +jest.mock('@/netflow', () => ({ + buildNetflowQueryConfigs: jest.fn(() => ({})), +})); +jest.mock('@/components/NetflowCharts', () => () => null); +jest.mock('@/components/NetflowSourceModal', () => () => null); +jest.mock('@/components/SourceSelect', () => ({ + SourceSelectControlled: () => null, +})); +jest.mock('@/components/TimePicker', () => ({ TimePicker: () => null })); +jest.mock('@/hooks/useDashboardRefresh', () => ({ + useDashboardRefresh: () => ({ refresh: jest.fn() }), +})); +jest.mock('@/timeQuery', () => ({ + useDefaultTimeRange: () => mockRange, + useNewTimeQuery: () => ({ + searchedTimeRange: mockRange, + onSearch: jest.fn(), + }), +})); +jest.mock('@/components/NetflowFilterPills', () => ({ + __esModule: true, + default: () => null, + useNetflowFilterState: ({ + onChange, + }: { + onChange: typeof mockFilterChange; + }) => { + mockFilterChange = onChange; + return {}; + }, +})); +jest.mock('@/components/SearchInput/SearchWhereInput', () => { + const Input = jest.requireActual( + '@/components/InputControlled', + ).TextInputControlled; + return ({ control }: { control: unknown }) => ( + + ); +}); + +describe('NetFlow draft search fields', () => { + it('preserves drafts until Run when clicks or migrations update filters', async () => { + renderWithMantine(); + const query = await screen.findByLabelText('Query'); + const protocol = screen.getByLabelText('Protocol'); + fireEvent.change(query, { target: { value: 'DstPort:443' } }); + fireEvent.change(protocol, { target: { value: '17' } }); + const filters: Filter[] = [ + { type: 'sql', condition: "ExporterName = 'edge-a'" }, + ]; + act(() => mockFilterChange(filters)); + await waitFor(() => + expect( + jest.mocked(buildNetflowQueryConfigs).mock.lastCall?.[0], + ).toMatchObject({ + where: 'Proto:6', + filters: { protocol: '' }, + extraFilters: filters, + }), + ); + expect(query).toHaveValue('DstPort:443'); + expect(protocol).toHaveValue('17'); + fireEvent.click(screen.getByRole('button', { name: 'Run' })); + await waitFor(() => + expect( + jest.mocked(buildNetflowQueryConfigs).mock.lastCall?.[0], + ).toMatchObject({ + where: 'DstPort:443', + filters: { protocol: '17' }, + extraFilters: filters, + }), + ); + }); +}); diff --git a/packages/app/src/components/AlertPreviewChart.tsx b/packages/app/src/components/AlertPreviewChart.tsx index 1040c6f295..8aa716f656 100644 --- a/packages/app/src/components/AlertPreviewChart.tsx +++ b/packages/app/src/components/AlertPreviewChart.tsx @@ -11,6 +11,7 @@ import { DisplayType, Filter, isLogSource, + isNetflowSource, isTraceSource, SearchCondition, SearchConditionLanguage, @@ -61,7 +62,7 @@ export const AlertPreviewChart = ({ const resolvedSelect = (select && select.trim().length > 0 ? select - : isLogSource(source) || isTraceSource(source) + : isLogSource(source) || isTraceSource(source) || isNetflowSource(source) ? source.defaultTableSelectExpression : undefined) ?? ''; diff --git a/packages/app/src/components/NetflowRecords.tsx b/packages/app/src/components/NetflowRecords.tsx index c2658749e3..415c14fe65 100644 --- a/packages/app/src/components/NetflowRecords.tsx +++ b/packages/app/src/components/NetflowRecords.tsx @@ -65,7 +65,8 @@ export default function NetflowRecords({ c="dimmed" style={{ whiteSpace: 'nowrap' }} > - Latest {rows.length} records · limit 500 + Latest {rows.length} records + {config.limit?.limit != null && ` · limit ${config.limit.limit}`} , ]} > diff --git a/packages/app/src/components/__tests__/AlertPreviewChart.test.tsx b/packages/app/src/components/__tests__/AlertPreviewChart.test.tsx new file mode 100644 index 0000000000..ea248e017f --- /dev/null +++ b/packages/app/src/components/__tests__/AlertPreviewChart.test.tsx @@ -0,0 +1,97 @@ +import { ALERT_COUNT_DEFAULT_SELECT } from '@hyperdx/common-utils/dist/core/searchChartConfig'; +import { + AlertThresholdType, + SourceKind, + TNetflowSource, +} from '@hyperdx/common-utils/dist/types'; + +import { AlertPreviewChart } from '@/components/AlertPreviewChart'; +import { DBTimeChart } from '@/components/DBTimeChart'; +import { useAliasMapFromChartConfig } from '@/hooks/useChartConfig'; + +jest.mock('@/components/DBTimeChart', () => ({ + DBTimeChart: jest.fn(() => null), +})); +jest.mock('@/hooks/useChartConfig', () => ({ + useAliasMapFromChartConfig: jest.fn(() => ({ + data: { exporter: 'toString(ExporterName)' }, + })), +})); + +const source: TNetflowSource = { + id: 'flows', + name: 'Flows', + kind: SourceKind.Netflow, + connection: 'local', + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: + 'TimeReceived, toString(ExporterName) AS exporter', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', +}; + +describe('NetFlow alert preview aliases', () => { + beforeEach(() => jest.clearAllMocks()); + + it.each([undefined, ' '])( + 'resolves source aliases when the saved search has no select (%s)', + select => { + renderWithMantine( + , + ); + expect(useAliasMapFromChartConfig).toHaveBeenCalledWith( + expect.objectContaining({ + select: source.defaultTableSelectExpression, + where: "exporter = 'edge-a'", + from: source.from, + }), + ); + expect(DBTimeChart).toHaveBeenCalledWith( + expect.objectContaining({ + config: expect.objectContaining({ + select: ALERT_COUNT_DEFAULT_SELECT, + where: "exporter = 'edge-a'", + timestampValueExpression: 'TimeReceived', + with: [ + { + name: 'exporter', + sql: { sql: 'toString(ExporterName)', params: {} }, + isSubquery: false, + }, + ], + }), + }), + undefined, + ); + }, + ); + + it('honors an explicit saved search select over source defaults', () => { + renderWithMantine( + , + ); + expect(useAliasMapFromChartConfig).toHaveBeenCalledWith( + expect.objectContaining({ select: 'SrcAddr AS address' }), + ); + }); +}); diff --git a/packages/app/src/components/__tests__/NetflowRecords.test.tsx b/packages/app/src/components/__tests__/NetflowRecords.test.tsx index 8d3fa84408..0a34ca1f15 100644 --- a/packages/app/src/components/__tests__/NetflowRecords.test.tsx +++ b/packages/app/src/components/__tests__/NetflowRecords.test.tsx @@ -24,6 +24,7 @@ const config: BuilderChartConfigWithDateRange = { ], select: '*', where: '', + limit: { limit: 25 }, }; describe('NetFlow records', () => { @@ -72,6 +73,7 @@ describe('NetFlow records', () => { }); renderWithMantine(); + expect(screen.getByText('Latest 1 records · limit 25')).toBeInTheDocument(); expect(screen.getByText('10.0.0.1 · 1234')).toBeInTheDocument(); expect(screen.getByText('2001:db8::1 · 443')).toBeInTheDocument(); expect(screen.getByText('2,000')).toBeInTheDocument(); diff --git a/scripts/netflow-click-browser-check.mjs b/scripts/netflow-click-browser-check.mjs index d7799b6103..dac76bcda9 100644 --- a/scripts/netflow-click-browser-check.mjs +++ b/scripts/netflow-click-browser-check.mjs @@ -32,6 +32,39 @@ try { exact: true, }); if (await devtools.isVisible()) await devtools.click(); + const draftSearch = page.getByTestId('netflow-search'); + const draftProtocol = page.getByLabel('Protocol', { exact: true }); + await draftSearch.fill('Proto:17'); + await draftProtocol.fill('17'); + await choose( + page.getByTestId('netflow-breakdown-protocol').getByRole('button', { + name: 'Filter protocol: TCP', + exact: true, + }), + 'Include', + ); + await expect(pills).toContainText('Protocol = TCP'); + await expect + .poll(() => new URL(page.url()).searchParams.get('where') || '') + .toBe(''); + await expect + .poll(() => new URL(page.url()).searchParams.get('protocol') || '') + .toBe(''); + await expect(draftSearch).toHaveValue('Proto:17'); + await expect(draftProtocol).toHaveValue('17'); + await expect( + records.getByRole('button', { name: 'Filter protocol: UDP', exact: true }), + ).toHaveCount(0); + await expect(records.locator('tbody tr').first()).toBeVisible(); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect + .poll(() => new URL(page.url()).searchParams.get('where')) + .toBe('Proto:17'); + await expect + .poll(() => new URL(page.url()).searchParams.get('protocol')) + .toBe('17'); + await expect(records.getByText('No flow records found')).toBeVisible(); + await clear(); for (const field of ['srcAddr', 'dstAddr', 'protocol', 'exporter']) { console.log(`Checking ${field} chart filters`); const chart = page.getByTestId(`netflow-breakdown-${field}`); @@ -121,7 +154,7 @@ try { await clear(); assert.deepEqual(errors, []); console.log( - 'Clickable NetFlow filters passed: chart include/exclude, removal, reload, Lucene composition, row/drawer actions, IPv6, clear.', + 'Clickable NetFlow filters passed: pending search/quick-filter drafts, chart include/exclude, removal, reload, Lucene composition, row/drawer actions, IPv6, clear.', ); } finally { await browser.close(); From a476fa965d151045e3b0132d47d6c6f6f864801f Mon Sep 17 00:00:00 2001 From: Valerii Petrov Date: Fri, 9 Oct 2026 14:12:15 -0700 Subject: [PATCH 05/11] fix: address remaining NetFlow review findings --- .changeset/native-netflow-monitoring.md | 5 + docs/netflow.md | 60 ++-- knip.json | 2 +- package.json | 2 - packages/api/openapi.json | 7 +- .../mcp/__tests__/netflow-search.int.test.ts | 144 +++++++++ .../api/src/mcp/__tests__/sources.int.test.ts | 17 +- packages/api/src/mcp/tools/query/helpers.ts | 17 +- .../tools/sources/__tests__/schemas.test.ts | 1 + .../src/mcp/tools/sources/describeSource.ts | 14 + .../api/src/mcp/tools/sources/saveSource.ts | 5 +- packages/api/src/mcp/tools/sources/schemas.ts | 7 +- packages/api/src/models/source.ts | 1 + .../__tests__/netflow-sources.int.test.ts | 113 ++++++++ .../api/src/routers/external-api/v2/charts.ts | 4 + .../src/routers/external-api/v2/sources.ts | 6 +- .../__tests__/netflowAlerts.int.test.ts | 16 +- packages/api/src/tasks/checkAlerts/index.ts | 22 +- packages/app/src/DBSearchPage.tsx | 12 +- packages/app/src/Spotlights.tsx | 16 +- .../HDXMultiSeriesTimeChart.geometry.test.tsx | 109 ++++--- packages/app/src/__tests__/netflow.test.ts | 28 +- .../app/src/__tests__/netflowSankey.test.ts | 31 +- .../__tests__/netflowSankeyDimensions.test.ts | 129 +++++++++ .../app/src/components/AlertPreviewChart.tsx | 4 + packages/app/src/components/AppNav/AppNav.tsx | 12 +- packages/app/src/components/DBNumberChart.tsx | 28 +- .../Dashboards/DashboardsListPage.tsx | 10 +- packages/app/src/components/NetflowCharts.tsx | 28 +- .../app/src/components/NetflowFilterMenu.tsx | 88 ++++-- .../app/src/components/NetflowFilterPills.tsx | 23 +- .../app/src/components/NetflowRecords.tsx | 25 +- packages/app/src/components/NetflowSankey.tsx | 11 +- .../app/src/components/NetflowSankeyNode.tsx | 126 ++++---- .../app/src/components/NetflowSankeyTable.tsx | 45 +-- .../SourceForm/NetflowTableModelForm.tsx | 70 ++++- .../Sources/SourceForm/SourceForm.tsx | 8 +- .../__tests__/DBNumberChart.test.tsx | 25 ++ .../__tests__/NetflowFilterMenu.test.tsx | 97 +++++++ .../__tests__/NetflowRecords.test.tsx | 17 ++ .../__tests__/NetflowSankey.test.tsx | 62 ++++ .../__tests__/NetflowTableModelForm.test.tsx | 62 ++++ packages/app/src/netflow.ts | 159 +++++++--- packages/app/src/netflowSankey.ts | 32 +- packages/app/src/netflowSankeyDimensions.ts | 20 +- packages/app/src/source.ts | 18 +- .../e2e/features/netflow/monitoring.spec.ts | 201 +++++++++++++ .../e2e/features/netflow/queries.spec.ts | 253 ++++++++++++++++ .../tests/e2e/features/netflow/sankey.spec.ts | 132 +++++++++ .../tests/e2e/features/netflow/source.spec.ts | 54 ++++ .../e2e/features/netflow/summary.spec.ts | 29 ++ packages/app/tests/e2e/fixtures/netflow.ts | 135 +++++++++ packages/app/tests/e2e/utils/base-test.ts | 15 +- .../src/__tests__/netflowSource.test.ts | 40 +++ .../common-utils/src/clickhouse/browser.ts | 10 +- .../src/core/searchChartConfig.ts | 30 +- packages/common-utils/src/types.ts | 15 + scripts/netflow-axis-browser-check.mjs | 44 --- scripts/netflow-browser-check.mjs | 257 ---------------- scripts/netflow-click-browser-check.mjs | 161 ---------- scripts/netflow-click-query-check.ts | 218 -------------- scripts/netflow-query-check.ts | 274 ------------------ scripts/netflow-sankey-browser-check.mjs | 158 ---------- scripts/netflow-sankey-query-check.ts | 264 ----------------- yarn.lock | 2 - 65 files changed, 2202 insertions(+), 1828 deletions(-) create mode 100644 packages/api/src/mcp/__tests__/netflow-search.int.test.ts create mode 100644 packages/api/src/routers/external-api/__tests__/netflow-sources.int.test.ts create mode 100644 packages/app/src/__tests__/netflowSankeyDimensions.test.ts create mode 100644 packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx create mode 100644 packages/app/src/components/__tests__/NetflowSankey.test.tsx create mode 100644 packages/app/src/components/__tests__/NetflowTableModelForm.test.tsx create mode 100644 packages/app/tests/e2e/features/netflow/monitoring.spec.ts create mode 100644 packages/app/tests/e2e/features/netflow/queries.spec.ts create mode 100644 packages/app/tests/e2e/features/netflow/sankey.spec.ts create mode 100644 packages/app/tests/e2e/features/netflow/source.spec.ts create mode 100644 packages/app/tests/e2e/features/netflow/summary.spec.ts create mode 100644 packages/app/tests/e2e/fixtures/netflow.ts delete mode 100644 scripts/netflow-axis-browser-check.mjs delete mode 100644 scripts/netflow-browser-check.mjs delete mode 100644 scripts/netflow-click-browser-check.mjs delete mode 100644 scripts/netflow-click-query-check.ts delete mode 100644 scripts/netflow-query-check.ts delete mode 100644 scripts/netflow-sankey-browser-check.mjs delete mode 100644 scripts/netflow-sankey-query-check.ts diff --git a/.changeset/native-netflow-monitoring.md b/.changeset/native-netflow-monitoring.md index c7b575b57d..fe3e77e861 100644 --- a/.changeset/native-netflow-monitoring.md +++ b/.changeset/native-netflow-monitoring.md @@ -37,3 +37,8 @@ include/exclude actions idempotent. Format flow times using user preferences. Support NetFlow saved-search alerts with default aliases, previews, and flow samples in notifications. Keep unfinished search fields pending when applying click filters, and display the configured record limit. + +Search mapped flow dimensions with bare Lucene terms or a custom full-text +expression. Share the summary aggregate query, reuse accessible filter menus, +restore valid Sankey dimensions across source changes, and document source +mappings consistently in REST and MCP. diff --git a/docs/netflow.md b/docs/netflow.md index 99d6f6994f..9fc581304c 100644 --- a/docs/netflow.md +++ b/docs/netflow.md @@ -95,7 +95,9 @@ Press Enter or Run to apply the query to all charts and flow records. Queries combine with the exporter, protocol, and address filters using AND; Clear filters resets both the search and quick filters. The query and selected language are saved in the URL. The shared language selector also supports SQL -WHERE expressions. +WHERE expressions. Bare terms search the mapped addresses, ports, protocol, +exporter, and interfaces. Set the optional **Full-text search expression** in +the source editor to search a different expression. Click an IP address, protocol, exporter, or interface in a breakdown chart, flow row, or flow details to **Include** or **Exclude** it. These actions apply @@ -132,43 +134,41 @@ existing authenticated source API and MongoDB model. ## Sankey visualization -Select **Sankey** in the NetFlow visualization selector to explore traffic paths. -Choose two to five dimensions in left-to-right order from the source mappings or -scalar table columns. Akvorado tables default to `SrcAS` → `InIfConnectivity` → -`InIfProvider` → exporter when those columns are available. +Select **Sankey** in the NetFlow visualization selector to explore traffic +paths. Choose two to five dimensions in left-to-right order from the source +mappings or scalar table columns. Akvorado tables default to `SrcAS` → +`InIfConnectivity` → `InIfProvider` → exporter when those columns are available. Link widths represent sampling-adjusted bytes for the top 10, 20, or 50 paths. The table and tooltips show transferred bytes and average bit rate over the -selected time range. Paths outside the limit are omitted from the diagram. -Click a node or table value to include or exclude it using the shared filters; -Lucene search, quick filters, and the time range also apply to this view. -The visualization, ordered dimensions, and path limit are saved in the URL. +selected time range. Paths outside the limit are omitted from the diagram. Click +a node or table value to include or exclude it using the shared filters; Lucene +search, quick filters, and the time range also apply to this view. The +visualization, ordered dimensions, and path limit are saved in the URL. -## Query verification +The path limit bounds returned paths, not the number of groups ClickHouse +aggregates. For high-cardinality dimensions or wide ranges, configure +`max_memory_usage` and `max_bytes_before_external_group_by` through the +connection's query settings. -After building common-utils and seeding, run the real chart configurations -against local ClickHouse: +A local validation with two million flow records, five dimensions, and a 30-day +range completed in 0.71 seconds while reading 106 MB, with a 512 MiB memory cap +and a 64 MiB external aggregation threshold. This checks that configuration on +the local fixture; it is not a production performance guarantee. -```sh -node node_modules/tsx/dist/cli.mjs scripts/netflow-query-check.ts -``` - -This checks overview values, time series, breakdowns, the flow table, IPv4/IPv6 -filters, protocol filtering, and safe handling of SQL syntax in filter values. -The seeder also verifies counts and sampled totals on every run. +## Automated verification -With the local app running and freshly seeded data, verify the browser workflow: +The NetFlow browser and ClickHouse query checks run in the existing Playwright +suite and are discovered by CI. Each test creates and tears down its own +ClickHouse database and source configuration; the demo seeder is not required. ```sh -yarn playwright install chromium -node scripts/netflow-browser-check.mjs -node scripts/netflow-click-browser-check.mjs -node node_modules/tsx/dist/cli.mjs scripts/netflow-click-query-check.ts -node node_modules/tsx/dist/cli.mjs scripts/netflow-sankey-query-check.ts -node scripts/netflow-sankey-browser-check.mjs +make dev-e2e FILE=netflow ``` -This checks charts, filters, sampled flow details, refresh, source creation and -persistence, and the shared Search page. Screenshots are saved under -`packages/app/test-results/netflow/`. Set `NETFLOW_APP_URL` if the app uses a -different local port. +The suite checks sampled overview totals and raw counters, every chart query, +IPv4/IPv6 filters, literal escaping, Lucene and SQL composition, Sankey traffic +conservation, empty classifications, source switching, pending query drafts, URL +reloads, invalid time ranges, flow details, and chart axes at wide and narrow +viewport sizes. Failures use the normal Playwright screenshots, traces, and +reports under `packages/app/test-results/`. diff --git a/knip.json b/knip.json index 44157733c3..7d88c3d288 100644 --- a/knip.json +++ b/knip.json @@ -2,7 +2,7 @@ "$schema": "https://unpkg.com/knip@6/schema.json", "workspaces": { ".": { - "entry": ["scripts/__tests__/*.test.mjs", "scripts/netflow-*-check.{mjs,ts}"] + "entry": ["scripts/__tests__/*.test.mjs"] }, "packages/app": { "entry": ["pages/**/*.{ts,tsx}", "scripts/*.js", "tests/e2e/**/*.ts"], diff --git a/package.json b/package.json index 4996ec4b76..6659ab07d9 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,8 @@ ], "devDependencies": { "@changesets/cli": "^2.26.2", - "@clickhouse/client": "1.23.0-head.fae5998.1", "@dotenvx/dotenvx": "^1.51.1", "@eslint/js": "^9.39.1", - "@playwright/test": "^1.57.0", "@types/ungap__structured-clone": "^1.2.0", "@ungap/structured-clone": "^1.3.0", "babel-plugin-react-compiler": "^1.0.0", diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 4ac9e8ce01..a7f7e145fc 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -5237,6 +5237,11 @@ "description": "SQL expression for the mapped flow field.", "example": "TimeReceived, SrcAddr, DstAddr, Bytes" }, + "implicitColumnExpression": { + "type": "string", + "description": "Optional SQL expression searched by bare Lucene terms. When omitted or blank, searches mapped IP addresses, ports, protocol, exporter, and interfaces.", + "example": "SearchText" + }, "bytesExpression": { "type": "string", "description": "SQL expression for the mapped flow field.", @@ -5274,7 +5279,7 @@ }, "samplingRateExpression": { "type": "string", - "description": "SQL expression for the mapped flow field.", + "description": "Sampling multiplier applied to observed byte and packet counters. Omit or leave empty for unsampled or already scaled counters; the multiplier then defaults to one.", "example": "SamplingRate" }, "exporterExpression": { diff --git a/packages/api/src/mcp/__tests__/netflow-search.int.test.ts b/packages/api/src/mcp/__tests__/netflow-search.int.test.ts new file mode 100644 index 0000000000..bf05de563e --- /dev/null +++ b/packages/api/src/mcp/__tests__/netflow-search.int.test.ts @@ -0,0 +1,144 @@ +import { createNativeClient } from '@hyperdx/common-utils/dist/clickhouse/node'; +import { SourceKind } from '@hyperdx/common-utils/dist/types'; + +import * as config from '@/config'; +import { createTeam } from '@/controllers/team'; +import { getServer } from '@/fixtures'; +import { buildTile, runConfigTile } from '@/mcp/tools/query/helpers'; +import Connection from '@/models/connection'; +import { NetflowSource } from '@/models/source'; +import { runSearchConfig } from '@/routers/external-api/v2/utils/search'; + +describe('NetFlow bare-term search through MCP and REST query paths', () => { + const server = getServer(); + const ch = createNativeClient({ + url: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + const startDate = new Date('2026-01-01T00:00:00Z'); + const endDate = new Date('2026-01-01T01:00:00Z'); + + beforeAll(async () => { + await server.start(); + await ch.command({ + query: `CREATE TABLE default.netflow_search_test ( + timestamp DateTime('UTC'), client IPv6, server IPv6, router Nullable(String), + src_port UInt16, dst_port UInt16, protocol UInt8, bytes UInt64, packets UInt64, + search_text String + ) ENGINE = Memory`, + }); + await ch.insert({ + table: 'default.netflow_search_test', + format: 'JSONEachRow', + values: [ + { router: 'edge-west', bytes: 2000, search_text: 'custom-west' }, + { router: 'edge-east', bytes: 100, search_text: 'custom-east' }, + { router: null, bytes: 3000, search_text: '' }, + ].map(row => ({ + timestamp: '2026-01-01 00:30:00', + client: '2001:db8::1', + server: '2001:db8::2', + src_port: 443, + dst_port: 50000, + protocol: 6, + packets: 2, + ...row, + })), + }); + }); + afterEach(async () => { + await server.clearDBs(); + }); + afterAll(async () => { + await ch.command({ + query: 'DROP TABLE IF EXISTS default.netflow_search_test', + }); + await ch.close(); + await server.stop(); + }); + + it.each([ + { + name: 'fallback exporter mapping', + implicit: undefined, + where: 'west AND bytes:[1000 TO *]', + expected: ['edge-west'], + }, + { + name: 'fallback with nullable exporter', + implicit: undefined, + where: 'db8 AND bytes:[1000 TO *]', + expected: ['edge-west', null], + }, + { + name: 'custom expression', + implicit: 'search_text', + where: 'custom-west AND bytes:[1000 TO *]', + expected: ['edge-west'], + }, + ])('supports $name', async ({ implicit, where, expected }) => { + const team = await createTeam({ name: 'NetFlow search' }); + const connection = await Connection.create({ + team: team._id, + name: 'Test ClickHouse', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + const source = await NetflowSource.create({ + kind: SourceKind.Netflow, + team: team._id, + connection: connection.id, + name: 'Custom flows', + from: { databaseName: 'default', tableName: 'netflow_search_test' }, + timestampValueExpression: 'timestamp', + defaultTableSelectExpression: 'router, bytes', + srcAddrExpression: 'client', + dstAddrExpression: 'server', + srcPortExpression: 'src_port', + dstPortExpression: 'dst_port', + protocolExpression: 'protocol', + bytesExpression: 'bytes', + packetsExpression: 'packets', + exporterExpression: 'router', + implicitColumnExpression: implicit, + }); + const query = { + displayType: 'search' as const, + select: '', + sourceId: source.id, + where, + whereLanguage: 'lucene' as const, + }; + const rest = await runSearchConfig({ + teamId: team.id, + config: query, + startDate, + endDate, + maxResults: 10, + offset: 0, + }); + expect(rest.isError).toBe(false); + if (rest.isError) throw new Error(rest.message); + expect(rest.data.map(row => row.router)).toEqual( + expect.arrayContaining(expected), + ); + expect(rest.data).toHaveLength(expected.length); + + const mcp = await runConfigTile( + team.id, + buildTile('Flows', 24, 6, query), + startDate, + endDate, + ); + expect('isError' in mcp && mcp.isError).toBeFalsy(); + const text = mcp.content.find(item => item.type === 'text'); + if (!text || text.type !== 'text') throw new Error('Missing MCP result'); + const rows = JSON.parse(text.text).result.data; + expect(rows).toHaveLength(expected.length); + expect(rows.map((row: { router: string | null }) => row.router)).toEqual( + expect.arrayContaining(expected), + ); + }); +}); diff --git a/packages/api/src/mcp/__tests__/sources.int.test.ts b/packages/api/src/mcp/__tests__/sources.int.test.ts index b5d0228c65..1e9ceda325 100644 --- a/packages/api/src/mcp/__tests__/sources.int.test.ts +++ b/packages/api/src/mcp/__tests__/sources.int.test.ts @@ -1302,6 +1302,7 @@ describe('MCP Source Tools', () => { describe('clickstack_save_source (create)', () => { it('round-trips every NetFlow mapping through create, describe, update, and clone', async () => { const mappings = { + implicitColumnExpression: 'SearchText', bytesExpression: 'Bytes', packetsExpression: 'Packets', samplingRateExpression: 'SamplingRate', @@ -1335,7 +1336,21 @@ describe('MCP Source Tools', () => { sourceId: saved.id, }); expect(described.isError).toBeFalsy(); - const sourceConfig = JSON.parse(getFirstText(described)).source.config; + const describedSource = JSON.parse(getFirstText(described)).source; + expect(describedSource.keyColumns).toMatchObject({ + bytes: 'Bytes', + packets: 'Packets', + samplingRate: 'SamplingRate', + srcAddr: 'SrcAddr', + dstAddr: 'DstAddr', + srcPort: 'SrcPort', + dstPort: 'DstPort', + protocol: 'Proto', + exporter: 'ExporterName', + inIf: 'InIfName', + outIf: 'OutIfName', + }); + const sourceConfig = describedSource.config; expect(sourceConfig).toMatchObject({ id: saved.id, ...mappings }); const updatedMappings = Object.fromEntries( Object.entries(mappings).map(([key, value]) => [key, `(${value})`]), diff --git a/packages/api/src/mcp/tools/query/helpers.ts b/packages/api/src/mcp/tools/query/helpers.ts index f1dcbe426a..70db0bb39d 100644 --- a/packages/api/src/mcp/tools/query/helpers.ts +++ b/packages/api/src/mcp/tools/query/helpers.ts @@ -4,6 +4,7 @@ import { } from '@clickhouse/client-common'; import { getHeatmapMode } from '@hyperdx/common-utils/dist/core/heatmap'; import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; +import { getNetflowImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { convertToCategoricalChartConfig, getFirstTimestampValueExpression, @@ -502,9 +503,11 @@ export async function runConfigTile( ? source.defaultTableSelectExpression : undefined; const implicitColumn = - 'implicitColumnExpression' in source - ? source.implicitColumnExpression - : undefined; + source.kind === SourceKind.Netflow + ? getNetflowImplicitColumnExpression(source) + : 'implicitColumnExpression' in source + ? source.implicitColumnExpression + : undefined; const useTextIndexForImplicitColumn = 'useTextIndexForImplicitColumn' in source ? source.useTextIndexForImplicitColumn @@ -626,9 +629,11 @@ export async function runConfigTile( sourceFields = { from: source.from, implicitColumnExpression: - 'implicitColumnExpression' in source - ? source.implicitColumnExpression - : undefined, + source.kind === SourceKind.Netflow + ? getNetflowImplicitColumnExpression(source) + : 'implicitColumnExpression' in source + ? source.implicitColumnExpression + : undefined, useTextIndexForImplicitColumn: 'useTextIndexForImplicitColumn' in source ? source.useTextIndexForImplicitColumn diff --git a/packages/api/src/mcp/tools/sources/__tests__/schemas.test.ts b/packages/api/src/mcp/tools/sources/__tests__/schemas.test.ts index 9df2d0ad17..8c652bd8fc 100644 --- a/packages/api/src/mcp/tools/sources/__tests__/schemas.test.ts +++ b/packages/api/src/mcp/tools/sources/__tests__/schemas.test.ts @@ -7,6 +7,7 @@ import { } from '@/mcp/tools/sources/schemas'; const mappings = { + implicitColumnExpression: 'SearchText', bytesExpression: 'Bytes', packetsExpression: 'Packets', srcAddrExpression: 'SrcAddr', diff --git a/packages/api/src/mcp/tools/sources/describeSource.ts b/packages/api/src/mcp/tools/sources/describeSource.ts index 5b22e00acc..2e7c0b0929 100644 --- a/packages/api/src/mcp/tools/sources/describeSource.ts +++ b/packages/api/src/mcp/tools/sources/describeSource.ts @@ -144,6 +144,20 @@ async function describeSourceSchema( severityText: source.severityTextExpression, traceId: source.traceIdExpression, }; + } else if (source.kind === SourceKind.Netflow) { + meta.keyColumns = { + bytes: source.bytesExpression, + packets: source.packetsExpression, + samplingRate: source.samplingRateExpression, + srcAddr: source.srcAddrExpression, + dstAddr: source.dstAddrExpression, + srcPort: source.srcPortExpression, + dstPort: source.dstPortExpression, + protocol: source.protocolExpression, + exporter: source.exporterExpression, + inIf: source.inIfExpression, + outIf: source.outIfExpression, + }; } else if (source.kind === SourceKind.Metric) { // Filter out implementation-detail keys (e.g. a stray Mongoose `_id` // on the metricTables subdoc) so the agent only sees valid metric diff --git a/packages/api/src/mcp/tools/sources/saveSource.ts b/packages/api/src/mcp/tools/sources/saveSource.ts index 74ba4c9df3..b1e2973aa6 100644 --- a/packages/api/src/mcp/tools/sources/saveSource.ts +++ b/packages/api/src/mcp/tools/sources/saveSource.ts @@ -35,7 +35,10 @@ export function registerSaveSource({ 'defaultTableSelectExpression; trace also needs durationExpression, ' + 'traceIdExpression, spanIdExpression, parentSpanIdExpression, ' + 'spanNameExpression, spanKindExpression; session needs traceSourceId; ' + - 'metric needs metricTables and resourceAttributesExpression. Get ' + + 'metric needs metricTables and resourceAttributesExpression; ' + + 'netflow needs defaultTableSelectExpression, bytesExpression, ' + + 'packetsExpression, srcAddrExpression, dstAddrExpression, ' + + 'srcPortExpression, dstPortExpression, protocolExpression. Get ' + 'connection and source IDs from clickstack_list_sources.', inputSchema: mcpSaveSourceSchema, }, diff --git a/packages/api/src/mcp/tools/sources/schemas.ts b/packages/api/src/mcp/tools/sources/schemas.ts index 080c2cf010..ed19be366d 100644 --- a/packages/api/src/mcp/tools/sources/schemas.ts +++ b/packages/api/src/mcp/tools/sources/schemas.ts @@ -113,7 +113,12 @@ export const mcpSaveSourceSchema = z.object({ .optional() .describe('Required for metric sources; optional for others.'), eventAttributesExpression: z.string().optional(), - implicitColumnExpression: z.string().optional(), + implicitColumnExpression: z + .string() + .optional() + .describe( + 'Expression searched by bare Lucene terms. NetFlow defaults to mapped flow dimensions when omitted.', + ), knownColumnsListExpression: z.string().optional(), orderByExpression: z.string().optional(), useTextIndexForImplicitColumn: z diff --git a/packages/api/src/models/source.ts b/packages/api/src/models/source.ts index d880481b14..3d876c1b7b 100644 --- a/packages/api/src/models/source.ts +++ b/packages/api/src/models/source.ts @@ -318,6 +318,7 @@ export const NetflowSource = Source.discriminator( SourceKind.Netflow, new Schema({ defaultTableSelectExpression: String, + implicitColumnExpression: String, bytesExpression: String, packetsExpression: String, samplingRateExpression: String, diff --git a/packages/api/src/routers/external-api/__tests__/netflow-sources.int.test.ts b/packages/api/src/routers/external-api/__tests__/netflow-sources.int.test.ts new file mode 100644 index 0000000000..f34049febb --- /dev/null +++ b/packages/api/src/routers/external-api/__tests__/netflow-sources.int.test.ts @@ -0,0 +1,113 @@ +import { SourceKind } from '@hyperdx/common-utils/dist/types'; +import { SuperAgentTest } from 'supertest'; + +import * as config from '@/config'; +import { getLoggedInAgent, getServer } from '@/fixtures'; +import Connection from '@/models/connection'; +import { Source } from '@/models/source'; + +describe('External API NetFlow sources', () => { + const server = getServer(); + let agent: SuperAgentTest; + let accessKey: string; + let connectionId: string; + const path = '/api/v2/sources'; + const mappings = { + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + samplingRateExpression: 'SamplingRate', + exporterExpression: 'ExporterName', + inIfExpression: 'InIfName', + outIfExpression: 'OutIfName', + implicitColumnExpression: 'SearchText', + }; + const body = () => ({ + kind: SourceKind.Netflow, + name: 'Network flows', + connection: connectionId, + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: 'TimeReceived, SrcAddr, DstAddr, Bytes', + ...mappings, + }); + const authRequest = (method: 'get' | 'post' | 'put', url: string) => + (method === 'get' + ? agent.get(url) + : method === 'post' + ? agent.post(url) + : agent.put(url) + ).set('Authorization', `Bearer ${accessKey}`); + + beforeAll(async () => { + await server.start(); + }); + beforeEach(async () => { + const session = await getLoggedInAgent(server); + agent = session.agent; + accessKey = session.user.accessKey; + const connection = await Connection.create({ + team: session.team._id, + name: 'ClickHouse', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + connectionId = connection.id; + }); + afterEach(async () => { + await server.clearDBs(); + }); + afterAll(async () => { + await server.stop(); + }); + + it('creates, reads, updates, and persists every mapping', async () => { + const created = await authRequest('post', path).send(body()).expect(200); + const id = created.body.data.id; + expect(created.body.data).toMatchObject(body()); + const read = await authRequest('get', `${path}/${id}`).expect(200); + expect(read.body.data).toMatchObject(body()); + const updated = { + ...body(), + name: 'Updated flows', + ...Object.fromEntries( + Object.entries(mappings).map(([key, value]) => [key, `(${value})`]), + ), + }; + await authRequest('put', `${path}/${id}`).send(updated).expect(200); + const readUpdate = await authRequest('get', `${path}/${id}`).expect(200); + expect(readUpdate.body.data).toMatchObject(updated); + expect(await Source.findById(id).lean()).toMatchObject({ + ...updated, + connection: expect.anything(), + }); + }); + + it.each([ + 'defaultTableSelectExpression', + 'bytesExpression', + 'packetsExpression', + 'srcAddrExpression', + 'dstAddrExpression', + 'srcPortExpression', + 'dstPortExpression', + 'protocolExpression', + ])( + 'rejects create and update missing %s without changing saved mappings', + async field => { + const invalid = { ...body(), [field]: undefined }; + await authRequest('post', path).send(invalid).expect(400); + expect(await Source.countDocuments({ kind: SourceKind.Netflow })).toBe(0); + const created = await authRequest('post', path).send(body()).expect(200); + const id = created.body.data.id; + await authRequest('put', `${path}/${id}`).send(invalid).expect(400); + const read = await authRequest('get', `${path}/${id}`).expect(200); + expect(read.body.data).toMatchObject(body()); + }, + ); +}); diff --git a/packages/api/src/routers/external-api/v2/charts.ts b/packages/api/src/routers/external-api/v2/charts.ts index 3dd5051cf3..dcb20a4282 100644 --- a/packages/api/src/routers/external-api/v2/charts.ts +++ b/packages/api/src/routers/external-api/v2/charts.ts @@ -1,4 +1,5 @@ import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; +import { getNetflowImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { Granularity } from '@hyperdx/common-utils/dist/core/utils'; import { ChartConfigWithOptDateRange, @@ -298,6 +299,9 @@ const buildChartConfigFromRequest = async ( ], where: '', timestampValueExpression: source.timestampValueExpression, + ...(source.kind === SourceKind.Netflow && { + implicitColumnExpression: getNetflowImplicitColumnExpression(source), + }), ...pickSampleWeightExpressionProps(source), dateRange: [new Date(params.startTime), new Date(params.endTime)] as [ Date, diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 2eee8918eb..8251d5e8a3 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -948,6 +948,10 @@ function formatExternalSource(source: SourceDocument) { * type: string * description: SQL expression for the mapped flow field. * example: TimeReceived, SrcAddr, DstAddr, Bytes + * implicitColumnExpression: + * type: string + * description: Optional SQL expression searched by bare Lucene terms. When omitted or blank, searches mapped IP addresses, ports, protocol, exporter, and interfaces. + * example: SearchText * bytesExpression: * type: string * description: SQL expression for the mapped flow field. @@ -978,7 +982,7 @@ function formatExternalSource(source: SourceDocument) { * example: Proto * samplingRateExpression: * type: string - * description: SQL expression for the mapped flow field. + * description: Sampling multiplier applied to observed byte and packet counters. Omit or leave empty for unsampled or already scaled counters; the multiplier then defaults to one. * example: SamplingRate * exporterExpression: * type: string diff --git a/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts index 2f19e571c7..d5f9365d14 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts @@ -88,9 +88,22 @@ describe('NetFlow saved-search alerts', () => { where: 'traffic:[1000 TO *]', whereLanguage: 'lucene' as const, }, + { + name: 'bare Lucene terms with default mappings', + savedSelect: undefined, + where: 'db8 AND traffic:[1000 TO *]', + whereLanguage: 'lucene' as const, + }, + { + name: 'bare Lucene terms with custom implicit expression', + savedSelect: undefined, + where: 'customflow AND traffic:[1000 TO *]', + whereLanguage: 'lucene' as const, + implicitColumnExpression: "concat('customflow ', toString(SrcAddr))", + }, ])( 'evaluates and delivers $name with aliased samples', - async ({ savedSelect, where, whereLanguage }) => { + async ({ savedSelect, where, whereLanguage, implicitColumnExpression }) => { const postMessage = jest .spyOn(slack, 'postMessageToWebhook') .mockResolvedValue({ text: 'ok' }); @@ -110,6 +123,7 @@ describe('NetFlow saved-search alerts', () => { from: { databaseName: 'default', tableName: 'netflow_alert_test' }, timestampValueExpression: 'TimeReceived', defaultTableSelectExpression: select, + implicitColumnExpression, bytesExpression: 'Bytes', packetsExpression: 'Packets', samplingRateExpression: 'SamplingRate', diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 82738787b4..619ffa5a31 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -21,6 +21,7 @@ import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartCo import { ALERT_COUNT_DEFAULT_SELECT, buildSearchChartConfig, + getNetflowImplicitColumnExpression, } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { aliasMapToWithClauses, @@ -177,9 +178,11 @@ export async function computeAliasWithClauses( where: savedSearch.where, whereLanguage: savedSearch.whereLanguage, implicitColumnExpression: - source.kind === SourceKind.Log || source.kind === SourceKind.Trace - ? source.implicitColumnExpression - : undefined, + source.kind === SourceKind.Netflow + ? getNetflowImplicitColumnExpression(source) + : source.kind === SourceKind.Log || source.kind === SourceKind.Trace + ? source.implicitColumnExpression + : undefined, useTextIndexForImplicitColumn: source.kind === SourceKind.Log || source.kind === SourceKind.Trace ? source.useTextIndexForImplicitColumn @@ -735,13 +738,14 @@ const buildAlertChartConfigFromSavedConfig = ({ } if (displayTypeSupportsBuilderAlerts(savedConfig.displayType)) { - // Alerts can use Log, Trace, or Metric sources. - // implicitColumnExpression+useTextIndexForImplicitColumn exist on Log and Trace sources; - // metricTables exists on Metric sources. + // NetFlow derives its implicit expression from mapped flow dimensions. + // Text-index options exist on Log and Trace; metricTables exists on Metric. const implicitColumnExpression = - source.kind === SourceKind.Log || source.kind === SourceKind.Trace - ? source.implicitColumnExpression - : undefined; + source.kind === SourceKind.Netflow + ? getNetflowImplicitColumnExpression(source) + : source.kind === SourceKind.Log || source.kind === SourceKind.Trace + ? source.implicitColumnExpression + : undefined; const useTextIndexForImplicitColumn = source.kind === SourceKind.Log || source.kind === SourceKind.Trace ? source.useTextIndexForImplicitColumn diff --git a/packages/app/src/DBSearchPage.tsx b/packages/app/src/DBSearchPage.tsx index 416b9dd69d..2fd6b6ac78 100644 --- a/packages/app/src/DBSearchPage.tsx +++ b/packages/app/src/DBSearchPage.tsx @@ -188,7 +188,7 @@ const ALLOWED_SOURCE_KINDS = [ SourceKind.Log, SourceKind.Trace, SourceKind.Netflow, -]; +] satisfies SourceKind[]; const SearchConfigSchema = z.object({ select: z.string(), source: z.string(), @@ -230,7 +230,7 @@ export function getDefaultSourceId( // Restrict to sources that this page can actually display. const searchableSources = sources.filter( - s => ALLOWED_SOURCE_KINDS.includes(s.kind) && !s.disabled, + s => ALLOWED_SOURCE_KINDS.some(kind => kind === s.kind) && !s.disabled, ); if (searchableSources.length === 0) return ''; @@ -509,7 +509,7 @@ function SaveSearchModalComponent({ const { data: sourceObj } = useSource({ id: searchedConfig.source, - kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], + kinds: ALLOWED_SOURCE_KINDS, }); const effectiveSelect = searchedConfig.select || sourceObj?.defaultTableSelectExpression || ''; @@ -800,7 +800,7 @@ function useSearchedConfigToChartConfig( ) { const { data: sourceObj, isLoading } = useSource({ id: source, - kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], + kinds: ALLOWED_SOURCE_KINDS, }); const defaultOrderBy = useDefaultOrderBy(source); @@ -876,7 +876,7 @@ function optimizeDefaultOrderBy( export function useDefaultOrderBy(sourceID: string | undefined | null) { const { data: source } = useSource({ id: sourceID, - kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], + kinds: ALLOWED_SOURCE_KINDS, }); const { data: tableMetadata } = useTableMetadata(tcFromSource(source)); @@ -1056,7 +1056,7 @@ function DBSearchPageContent() { const { source: searchedSource } = useResolvedSourceParam( rawSearchedConfig.source, { - kinds: [SourceKind.Log, SourceKind.Trace, SourceKind.Netflow], + kinds: ALLOWED_SOURCE_KINDS, }, ); diff --git a/packages/app/src/Spotlights.tsx b/packages/app/src/Spotlights.tsx index 78c7475df4..7a0994654a 100644 --- a/packages/app/src/Spotlights.tsx +++ b/packages/app/src/Spotlights.tsx @@ -65,14 +65,6 @@ export const useSpotlightActions = () => { // Preset dashboards const presetDashboards = [ - { - id: 'preset-netflow', - label: 'NetFlow', - description: - 'Network traffic, top talkers, exporters, and flow records', - href: '/netflow', - keywords: ['netflow', 'network', 'akvorado', 'traffic', 'flows'], - }, { id: 'preset-services', label: 'Services', @@ -98,6 +90,14 @@ export const useSpotlightActions = () => { }, ] : []), + { + id: 'preset-netflow', + label: 'NetFlow', + description: + 'Network traffic, top talkers, exporters, and flow records', + href: '/netflow', + keywords: ['netflow', 'network', 'akvorado', 'traffic', 'flows'], + }, ]; presetDashboards.forEach(preset => { diff --git a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.geometry.test.tsx b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.geometry.test.tsx index 7129342f92..eab3185563 100644 --- a/packages/app/src/__tests__/HDXMultiSeriesTimeChart.geometry.test.tsx +++ b/packages/app/src/__tests__/HDXMultiSeriesTimeChart.geometry.test.tsx @@ -1,6 +1,6 @@ -import { ComponentProps, ReactNode } from 'react'; +import { ComponentProps, ReactElement } from 'react'; import { DisplayType } from '@hyperdx/common-utils/dist/types'; -import { screen } from '@testing-library/react'; +import { waitFor } from '@testing-library/react'; import { getAnnotationElements, @@ -8,44 +8,22 @@ import { } from '@/components/charts/chartAnnotations'; import { MemoChart } from '@/HDXMultiSeriesTimeChart'; -let mockPlotWidth = 240; +let mockContainerWidth = 500; jest.mock('recharts', () => { const React = jest.requireActual('react'); - const Chart = ({ - children, - barSize, - }: { - children: ReactNode; - barSize?: number; - }) => ( - - {children} - - ); return { ...jest.requireActual('recharts'), - AreaChart: Chart, - BarChart: Chart, - Area: () => null, - Bar: () => null, - XAxis: () => null, - YAxis: () => null, - Tooltip: () => null, - ReferenceLine: () => null, - Customized: () => null, - usePlotArea: () => ({ x: 255, y: 5, width: mockPlotWidth, height: 250 }), + // jsdom has no layout engine; only supply the container's measured size. ResponsiveContainer: ({ children, - onResize, }: { - children: ReactNode; - onResize?: (width: number) => void; - }) => { - React.useEffect(() => { - onResize?.(500); - }, [onResize]); - return children; - }, + children: ReactElement<{ width: number; height: number }>; + }) => + React.createElement(children.type, { + ...children.props, + width: mockContainerWidth, + height: 300, + }), }; }); jest.mock('@/components/charts/chartAnnotations', () => ({ @@ -63,7 +41,16 @@ jest.mock('@/useFormatTime', () => ({ useFormatTime: () => String })); const props = { graphResults: [{ ts_bucket: 100, value: 1 }], - lineData: [], + lineData: [ + { + dataKey: 'value', + currentPeriodKey: 'value', + previousPeriodKey: 'previous', + displayName: 'Traffic', + valueColumnName: 'value', + color: 'var(--color-chart-blue)', + }, + ], dateRange: [new Date(0), new Date(200000)], granularity: '1 minute', displayType: DisplayType.StackedBar, @@ -76,35 +63,37 @@ const props = { describe('time chart plot geometry', () => { beforeEach(() => { - mockPlotWidth = 240; + mockContainerWidth = 500; jest.clearAllMocks(); }); - it('uses the measured plot width for bars and annotation layout', () => { - renderWithMantine(); - expect(screen.getByTestId('chart')).toHaveAttribute('data-bar-size', '192'); - expect(layoutAnnotations).toHaveBeenLastCalledWith( - expect.any(Array), - expect.objectContaining({ plotWidth: 240 }), - ); - expect(getAnnotationElements).toHaveBeenLastCalledWith( - expect.any(Array), - expect.objectContaining({ plotWidth: 240 }), - ); - }); - - it('updates geometry when the automatic axis grows without a container resize', () => { - const { rerender } = renderWithMantine(); - mockPlotWidth = 180; + it('uses the real Recharts plot area for single bars and annotations after resize', async () => { + const { container, rerender } = renderWithMantine(); + const assertGeometry = async () => { + await waitFor(() => { + const plot = container.querySelector('clipPath rect'); + expect(plot).not.toBeNull(); + const width = Number(plot?.getAttribute('width')); + expect(width).toBeGreaterThan(0); + expect(width).toBeLessThan(mockContainerWidth); + const bar = container.querySelector('.recharts-bar-rectangle rect'); + expect(bar).not.toBeNull(); + expect(Number(bar?.getAttribute('width'))).toBe( + Math.floor(width * 0.8), + ); + expect(layoutAnnotations).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ plotWidth: width }), + ); + expect(getAnnotationElements).toHaveBeenLastCalledWith( + expect.any(Array), + expect.objectContaining({ plotWidth: width }), + ); + }); + }; + await assertGeometry(); + mockContainerWidth = 350; rerender(); - expect(screen.getByTestId('chart')).toHaveAttribute('data-bar-size', '144'); - expect(layoutAnnotations).toHaveBeenLastCalledWith( - expect.any(Array), - expect.objectContaining({ plotWidth: 180 }), - ); - expect(getAnnotationElements).toHaveBeenLastCalledWith( - expect.any(Array), - expect.objectContaining({ plotWidth: 180 }), - ); + await assertGeometry(); }); }); diff --git a/packages/app/src/__tests__/netflow.test.ts b/packages/app/src/__tests__/netflow.test.ts index b3ff26db2e..b758ad8e53 100644 --- a/packages/app/src/__tests__/netflow.test.ts +++ b/packages/app/src/__tests__/netflow.test.ts @@ -34,21 +34,27 @@ describe('NetFlow queries', () => { dateRange, filters: {}, }); - expect(configs.totalBytes.select).toEqual([ + expect(configs.summary.select).toEqual([ + { + valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 / 3600', + alias: '__netflow_bitsPerSecond', + }, + { + valueExpression: 'sum(toFloat64(Packets) * (SamplingRate)) / 3600', + alias: '__netflow_packetsPerSecond', + }, { valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate))', - alias: '__netflow_value', + alias: '__netflow_bytes', }, + { valueExpression: 'count()', alias: '__netflow_flowRecords' }, ]); - expect(configs.bitsPerSecond.select).toEqual([ + expect(configs.traffic.select).toEqual([ { - valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 / 3600', - alias: '__netflow_value', + valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 / 60', + alias: 'Bits per second', }, ]); - expect(configs.flowRecords.select).toEqual([ - { valueExpression: 'count()', alias: '__netflow_value' }, - ]); }); it.each([undefined, ' '])( @@ -128,12 +134,16 @@ describe('NetFlow queries', () => { filters: { protocol: '6' }, where, whereLanguage, + extraFilters: [{ type: 'sql', condition: "ExporterName = 'edge-a'" }], }); for (const config of Object.values(configs)) { expect(config).toMatchObject({ where, whereLanguage, - filters: [{ type: 'sql', condition: "(toString(Proto) = '6')" }], + filters: [ + { type: 'sql', condition: "ExporterName = 'edge-a'" }, + { type: 'sql', condition: "(toString(Proto) = '6')" }, + ], }); } }, diff --git a/packages/app/src/__tests__/netflowSankey.test.ts b/packages/app/src/__tests__/netflowSankey.test.ts index c5a9b5c30c..bc4d93aac1 100644 --- a/packages/app/src/__tests__/netflowSankey.test.ts +++ b/packages/app/src/__tests__/netflowSankey.test.ts @@ -7,6 +7,8 @@ import { buildNetflowSankeyConfig, buildNetflowSankeyData, SankeyDimension, + sankeyDimensionExpression, + unwrapSankeyDimensionExpression, } from '@/netflowSankey'; const dimensions: SankeyDimension[] = [ @@ -45,7 +47,7 @@ describe('NetFlow Sankey query', () => { const config = buildNetflowSankeyConfig({ baseConfig, dimensions, - limit: 30, + limit: 50, }); expect(config).toMatchObject({ source: baseConfig.source, @@ -61,7 +63,7 @@ describe('NetFlow Sankey query', () => { groupBy: '__netflow_dimension_0, __netflow_dimension_1, __netflow_dimension_2', orderBy: '__netflow_value DESC', - limit: { limit: 30 }, + limit: { limit: 50 }, }); expect(config.select).toEqual([ { @@ -89,10 +91,12 @@ describe('NetFlow Sankey query', () => { [undefined, 20], [NaN, 20], [Infinity, 20], - [-5, 1], - [0, 1], - [101, 100], - [2.9, 2], + [-5, 20], + [0, 20], + [101, 20], + [2.9, 20], + [10, 10], + [50, 50], ])('bounds the requested path limit %s to %s', (limit, expected) => { expect( buildNetflowSankeyConfig({ baseConfig, dimensions, limit }).limit, @@ -138,7 +142,6 @@ describe('NetFlow Sankey data', () => { ], dimensions, ); - expect(data.totalBytes).toBe(7000); expect(data.paths.map(path => path.value)).toEqual([1000, 2000, 4000]); expect(data.links).toHaveLength(4); for (const stage of [0, 1]) { @@ -192,9 +195,19 @@ describe('NetFlow Sankey data', () => { nodes: [], links: [], paths: [], - totalBytes: 0, }); - expect(buildNetflowSankeyData([], dimensions).totalBytes).toBe(0); + expect(buildNetflowSankeyData([], dimensions).paths).toEqual([]); expect(buildNetflowSankeyData([row(['a'], 5)], []).nodes).toEqual([]); }); }); + +it('unwraps its own dimension expression while preserving custom SQL', () => { + for (const dimension of dimensions) { + expect( + unwrapSankeyDimensionExpression(sankeyDimensionExpression(dimension)), + ).toBe(dimension.expression); + expect(unwrapSankeyDimensionExpression(dimension.expression)).toBe( + dimension.expression, + ); + } +}); diff --git a/packages/app/src/__tests__/netflowSankeyDimensions.test.ts b/packages/app/src/__tests__/netflowSankeyDimensions.test.ts new file mode 100644 index 0000000000..b891aa6d6e --- /dev/null +++ b/packages/app/src/__tests__/netflowSankeyDimensions.test.ts @@ -0,0 +1,129 @@ +import { ColumnMeta } from '@hyperdx/common-utils/dist/clickhouse'; +import { SourceKind, TNetflowSource } from '@hyperdx/common-utils/dist/types'; + +import { + getDefaultSankeyDimensions, + getSankeyDimensionOptions, +} from '@/netflowSankeyDimensions'; + +const source: TNetflowSource = { + id: 'flows', + name: 'Flows', + kind: SourceKind.Netflow, + connection: 'local', + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: '*', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + exporterExpression: 'ExporterName', +}; + +const column = (name: string, type = 'String'): ColumnMeta => ({ + name, + type, + codec_expression: '', + comment: '', + default_expression: '', + default_type: '', + ttl_expression: '', +}); + +describe('NetFlow Sankey dimension options', () => { + it.each([ + 'String', + 'LowCardinality(Nullable(String))', + 'UInt32', + 'Float64', + 'Bool', + 'DateTime64(3)', + 'IPv6', + ])('offers scalar %s columns and quotes unusual identifiers', type => { + expect( + getSankeyDimensionOptions(source, [column('custom field', type)]), + ).toContainEqual({ + key: 'column:custom field', + label: 'custom field', + expression: '`custom field`', + }); + }); + + it.each([ + 'Array(String)', + 'Map(String, String)', + 'Tuple(String, UInt32)', + 'JSON', + 'Dynamic', + 'Unknown', + ])('excludes %s columns from scalar dimensions', type => { + expect( + getSankeyDimensionOptions(source, [column('nested', type)]), + ).not.toEqual( + expect.arrayContaining([ + expect.objectContaining({ key: 'column:nested' }), + ]), + ); + }); + + it('omits blank optional mappings and retains trimmed mapped expressions', () => { + const options = getSankeyDimensionOptions( + { + ...source, + exporterExpression: ' ', + inIfExpression: '', + outIfExpression: ' OutIfName ', + }, + [], + ); + expect(options.map(option => option.key)).toEqual([ + 'srcAddr', + 'dstAddr', + 'protocol', + 'outputInterface', + ]); + expect(options).toContainEqual({ + key: 'outputInterface', + label: 'Output interface', + expression: 'OutIfName', + }); + }); +}); + +describe('NetFlow Sankey default dimensions', () => { + const enrichment = [ + column('SrcAS', 'UInt32'), + column('InIfConnectivity'), + column('InIfProvider'), + ]; + + it('uses the Akvorado path when every enriched dimension is available', () => { + expect( + getDefaultSankeyDimensions(getSankeyDimensionOptions(source, enrichment)), + ).toEqual([ + 'column:SrcAS', + 'column:InIfConnectivity', + 'column:InIfProvider', + 'exporter', + ]); + }); + + it.each([ + [source, []], + [source, enrichment.slice(0, 2)], + [{ ...source, exporterExpression: '' }, enrichment], + ] as const)( + 'falls back when an Akvorado dimension is unavailable', + (selectedSource, columns) => { + expect( + getDefaultSankeyDimensions( + getSankeyDimensionOptions(selectedSource, [...columns]), + ), + ).toEqual(['srcAddr', 'protocol', 'dstAddr']); + }, + ); +}); diff --git a/packages/app/src/components/AlertPreviewChart.tsx b/packages/app/src/components/AlertPreviewChart.tsx index 8aa716f656..cde33b252b 100644 --- a/packages/app/src/components/AlertPreviewChart.tsx +++ b/packages/app/src/components/AlertPreviewChart.tsx @@ -2,6 +2,7 @@ import React, { useMemo } from 'react'; import { ALERT_COUNT_DEFAULT_SELECT, buildSearchChartConfig, + getNetflowImplicitColumnExpression, } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils'; import { @@ -68,6 +69,9 @@ export const AlertPreviewChart = ({ const { data: aliasMap } = useAliasMapFromChartConfig({ select: resolvedSelect, + implicitColumnExpression: isNetflowSource(source) + ? getNetflowImplicitColumnExpression(source) + : undefined, where: where || '', connection: source.connection, from: source.from, diff --git a/packages/app/src/components/AppNav/AppNav.tsx b/packages/app/src/components/AppNav/AppNav.tsx index e7898a4201..a031f2a093 100644 --- a/packages/app/src/components/AppNav/AppNav.tsx +++ b/packages/app/src/components/AppNav/AppNav.tsx @@ -81,12 +81,6 @@ type NavLinkConfig = { }; const NAV_LINKS: NavLinkConfig[] = [ - { - id: 'netflow', - label: 'NetFlow', - href: '/netflow', - icon: , - }, { id: 'chart', label: 'Chart Explorer', @@ -113,6 +107,12 @@ const NAV_LINKS: NavLinkConfig[] = [ icon: , isBeta: true, }, + { + id: 'netflow', + label: 'NetFlow', + href: '/netflow', + icon: , + }, ]; export default function AppNav({ fixed = false }: { fixed?: boolean }) { diff --git a/packages/app/src/components/DBNumberChart.tsx b/packages/app/src/components/DBNumberChart.tsx index 6bbf580e3c..c58e3f3e11 100644 --- a/packages/app/src/components/DBNumberChart.tsx +++ b/packages/app/src/components/DBNumberChart.tsx @@ -12,6 +12,7 @@ import { } from '@hyperdx/common-utils/dist/guards'; import { ChartConfigWithDateRange, + NumberFormat, resolveChartPaletteToken, } from '@hyperdx/common-utils/dist/types'; import { Flex, Text } from '@mantine/core'; @@ -216,6 +217,8 @@ export default function DBNumberChart({ toolbarSuffix, showMVOptimizationIndicator = true, errorVariant, + valueColumn: selectedValueColumn, + numberFormat, }: { config: ChartConfigWithDateRange; queryKeyPrefix?: string; @@ -225,6 +228,8 @@ export default function DBNumberChart({ toolbarSuffix?: React.ReactNode[]; showMVOptimizationIndicator?: boolean; errorVariant?: ChartErrorStateVariant; + valueColumn?: string; + numberFormat?: NumberFormat; }) { const { data: source } = useSource({ id: config.source, @@ -255,10 +260,12 @@ export default function DBNumberChart({ enabled, }); - // The value is the first numeric value in the first row of the result - const valueColumn = data?.meta - ? filterColumnMetaByType(data?.meta, [JSDataType.Number])?.[0] - : undefined; + // An explicit column lets several tiles share one aggregate query. + const valueColumn = selectedValueColumn + ? data?.meta?.find(column => column.name === selectedValueColumn) + : data?.meta + ? filterColumnMetaByType(data?.meta, [JSDataType.Number])?.[0] + : undefined; const resultError = data && !valueColumn && isRawSqlChartConfig(queriedConfig) ? new Error( @@ -279,10 +286,15 @@ export default function DBNumberChart({ const resolvedNumberFormat = useSingleSeriesNumberFormat(queriedConfig); - const value = valueColumn - ? data?.data?.[0]?.[valueColumn.name] - : (Object.values(data?.data?.[0] ?? {})?.[0] ?? Number.NaN); - const formattedValue = formatNumber(value as number, resolvedNumberFormat); + const value = selectedValueColumn + ? (data?.data?.[0]?.[selectedValueColumn] ?? Number.NaN) + : valueColumn + ? data?.data?.[0]?.[valueColumn.name] + : (Object.values(data?.data?.[0] ?? {})?.[0] ?? Number.NaN); + const formattedValue = formatNumber( + value as number, + numberFormat ?? resolvedNumberFormat, + ); // Resolve the display color in three layers: // 1. Conditional color rules evaluated against the raw value diff --git a/packages/app/src/components/Dashboards/DashboardsListPage.tsx b/packages/app/src/components/Dashboards/DashboardsListPage.tsx index 4f44b98920..4797d978e9 100644 --- a/packages/app/src/components/Dashboards/DashboardsListPage.tsx +++ b/packages/app/src/components/Dashboards/DashboardsListPage.tsx @@ -70,11 +70,6 @@ function emptyStateTitle(tab: DashboardTab, hasFilters: boolean): string { } const PRESET_DASHBOARDS = [ - { - name: 'NetFlow', - href: '/netflow', - description: 'Network traffic, top talkers, exporters, and flow records', - }, { name: 'Services', href: '/services', @@ -99,6 +94,11 @@ const PRESET_DASHBOARDS = [ href: '/llm', description: 'LLM calls, token usage, cost, and latency by model', }, + { + name: 'NetFlow', + href: '/netflow', + description: 'Network traffic, top talkers, exporters, and flow records', + }, ]; export default function DashboardsListPage() { diff --git a/packages/app/src/components/NetflowCharts.tsx b/packages/app/src/components/NetflowCharts.tsx index 35c04819fa..8c6b4a909f 100644 --- a/packages/app/src/components/NetflowCharts.tsx +++ b/packages/app/src/components/NetflowCharts.tsx @@ -1,6 +1,10 @@ import { SimpleGrid, Stack } from '@mantine/core'; -import { buildNetflowQueryConfigs } from '@/netflow'; +import { + buildNetflowQueryConfigs, + NETFLOW_ALIASES, + NETFLOW_SUMMARY_TILES, +} from '@/netflow'; import { SankeyFilterHandler } from '@/netflowSankey'; import { ChartCard } from './charts/ChartCard'; @@ -24,16 +28,14 @@ export default function NetflowCharts({ return ( - {( - [ - ['Average bit rate', configs.bitsPerSecond], - ['Average packet rate', configs.packetsPerSecond], - ['Transferred bytes', configs.totalBytes], - ['Flow records', configs.flowRecords], - ] as const - ).map(([title, config]) => ( + {NETFLOW_SUMMARY_TILES.map(({ title, column, numberFormat }) => ( - + ))} @@ -67,9 +69,9 @@ export default function NetflowCharts({ ( void; -export default function NetflowFilterMenu({ - field, - value, - onFilter, -}: { - field: NetflowFilterField; +type TargetProps = { + opened: boolean; + buttonProps: { + 'aria-label': string; + onKeyDown: KeyboardEventHandler; + role?: 'button'; + tabIndex?: number; + }; +}; +type Props = { value: string; - onFilter?: NetflowFilterHandler; -}) { - if (!value.trim() || !onFilter) return <>{value || '—'}; + allowEmpty?: boolean; + target?: (props: TargetProps) => ReactElement; +} & ( + | { field: NetflowFilterField; onFilter?: NetflowFilterHandler } + | { label: string; onSelect?: (excluded: boolean) => void } +); +export default function NetflowFilterMenu(props: Props) { + const [opened, setOpened] = useState(false); + const { value, allowEmpty = false, target } = props; + const label = + 'field' in props ? NETFLOW_DIMENSION_LABELS[props.field] : props.label; + const onSelect = + 'field' in props + ? props.onFilter && + ((excluded: boolean) => props.onFilter?.(props.field, value, excluded)) + : props.onSelect; + const interactive = !!onSelect && (allowEmpty || !!value.trim()); + const displayValue = value || (allowEmpty ? '(empty)' : '—'); + const buttonProps: TargetProps['buttonProps'] = { + 'aria-label': `Filter ${label}: ${displayValue}`, + role: interactive ? 'button' : undefined, + tabIndex: interactive ? 0 : undefined, + onKeyDown: event => { + if (interactive && (event.key === 'Enter' || event.key === ' ')) { + event.preventDefault(); + setOpened(true); + } + }, + }; + const renderedTarget = target?.({ opened, buttonProps }) ?? ( + + ); + if (!interactive) return target ? renderedTarget : <>{displayValue}; return ( - - - - + + {renderedTarget} - onFilter(field, value, false)}> - Include - - onFilter(field, value, true)}> - Exclude - + onSelect?.(false)}>Include + onSelect?.(true)}>Exclude ); diff --git a/packages/app/src/components/NetflowFilterPills.tsx b/packages/app/src/components/NetflowFilterPills.tsx index f2fc37d4f4..f685b60a28 100644 --- a/packages/app/src/components/NetflowFilterPills.tsx +++ b/packages/app/src/components/NetflowFilterPills.tsx @@ -8,10 +8,11 @@ import { import { Filter, TNetflowSource } from '@hyperdx/common-utils/dist/types'; import { Group } from '@mantine/core'; -import { getNetflowDimensions } from '@/netflow'; +import { getNetflowDimensions, NETFLOW_DIMENSION_LABELS } from '@/netflow'; import { sankeyDimensionExpression, SankeyFilterHandler, + unwrapSankeyDimensionExpression, } from '@/netflowSankey'; import { useSearchPageFilterState } from '@/searchFilters'; @@ -19,18 +20,10 @@ import { cleanClickHouseExpression } from './DBSearchPageFilters/utils'; import { FilterPill } from './FilterPill'; import { NetflowFilterHandler } from './NetflowFilterMenu'; -const labels = new Map([ - ['srcAddr', 'Source IP'], - ['dstAddr', 'Destination IP'], - ['protocol', 'Protocol'], - ['exporter', 'Exporter'], - ['inputInterface', 'Input interface'], - ['outputInterface', 'Output interface'], -]); // Filter keys are complete SQL expressions; restore their original quoting at emission. const knownColumns = new Set(); -// Older links used separate keys for table and Sankey clicks on the same field. +// Table and Sankey filters can wrap the same mapped field in different SQL expressions. function canonicalizeMappedFilters( filters: Filter[], expressions: Map, @@ -172,7 +165,10 @@ export function useNetflowFilterState({ Object.entries(dimensions ?? {}).flatMap(([field, expression]) => expression ? [ - [cleanClickHouseExpression(expression), labels.get(field) ?? field], + [ + cleanClickHouseExpression(expression), + NETFLOW_DIMENSION_LABELS[field], + ], [ cleanClickHouseExpression( sankeyDimensionExpression({ @@ -181,7 +177,7 @@ export function useNetflowFilterState({ expression, }), ), - labels.get(field) ?? field, + NETFLOW_DIMENSION_LABELS[field], ], ] : [], @@ -202,8 +198,7 @@ export default function NetflowFilterPills({ | null>( null, ); - const rows = (data?.data ?? []).map(row => - Object.fromEntries(fields.map(([key]) => [key, row[`__netflow_${key}`]])), + const rows = useMemo( + () => + (data?.data ?? []).map(row => + Object.fromEntries( + fields.map(([key]) => [key, row[netflowColumnAlias(key)]]), + ), + ), + [data], ); return ( diff --git a/packages/app/src/components/NetflowSankey.tsx b/packages/app/src/components/NetflowSankey.tsx index 86863ed86c..19380188f4 100644 --- a/packages/app/src/components/NetflowSankey.tsx +++ b/packages/app/src/components/NetflowSankey.tsx @@ -16,6 +16,7 @@ import { useColumns } from '@/hooks/useMetadata'; import { buildNetflowSankeyConfig, buildNetflowSankeyData, + normalizeNetflowSankeyLimit, SankeyDimension, SankeyFilterHandler, } from '@/netflowSankey'; @@ -115,7 +116,11 @@ export default function NetflowSankey({ () => (source ? getSankeyDimensionOptions(source, columns ?? []) : []), [source, columns], ); - const keys = params.sankeyDimensions ?? getDefaultSankeyDimensions(options); + const keys = params.sankeyDimensions?.every(key => + options.some(option => option.key === key), + ) + ? params.sankeyDimensions + : getDefaultSankeyDimensions(options); const dimensions = keys.flatMap(key => options.filter(option => option.key === key), ); @@ -124,9 +129,7 @@ export default function NetflowSankey({ dimensions.length <= 5 && dimensions.length === keys.length && new Set(keys).size === keys.length; - const limit = [10, 20, 50].includes(params.sankeyLimit) - ? params.sankeyLimit - : 20; + const limit = normalizeNetflowSankeyLimit(params.sankeyLimit); return ( diff --git a/packages/app/src/components/NetflowSankeyNode.tsx b/packages/app/src/components/NetflowSankeyNode.tsx index 612e3fcdd3..a5bf07d229 100644 --- a/packages/app/src/components/NetflowSankeyNode.tsx +++ b/packages/app/src/components/NetflowSankeyNode.tsx @@ -1,7 +1,6 @@ -import { useState } from 'react'; import { NodeProps } from 'recharts/types/chart/Sankey'; import { NumericUnit } from '@hyperdx/common-utils/dist/types'; -import { Menu, Stack, Text, Tooltip } from '@mantine/core'; +import { Stack, Text, Tooltip } from '@mantine/core'; import { NetflowSankeyNode as SankeyNode, @@ -9,6 +8,8 @@ import { } from '@/netflowSankey'; import { formatNumber, truncateMiddle } from '@/utils'; +import NetflowFilterMenu from './NetflowFilterMenu'; + export function NetflowSankeyTooltip({ label, value, @@ -54,81 +55,58 @@ export default function NetflowSankeyNode({ onFilter?: SankeyFilterHandler; color: string; }) { - const [opened, setOpened] = useState(false); const value = Number(payload.value); - const target = ( - + return ( + onFilter(node.dimension, node.rawValue, excluded)) } - > - { - if (onFilter && (event.key === 'Enter' || event.key === ' ')) { - event.preventDefault(); - setOpened(true); + target={({ opened, buttonProps }) => ( + } - }} - style={{ cursor: onFilter ? 'pointer' : 'default' }} - > - - - {truncateMiddle(node.name, 26)} - - - - ); - - if (!onFilter) return target; - - return ( - - {target} - - onFilter(node.dimension, node.rawValue, false)} - > - Include - - onFilter(node.dimension, node.rawValue, true)} > - Exclude - - - + + + + {truncateMiddle(node.name, 26)} + + + + )} + /> ); } diff --git a/packages/app/src/components/NetflowSankeyTable.tsx b/packages/app/src/components/NetflowSankeyTable.tsx index 5fbb532a0d..1fc442e44f 100644 --- a/packages/app/src/components/NetflowSankeyTable.tsx +++ b/packages/app/src/components/NetflowSankeyTable.tsx @@ -1,5 +1,5 @@ import { NumericUnit } from '@hyperdx/common-utils/dist/types'; -import { Button, Menu, ScrollArea, Table } from '@mantine/core'; +import { ScrollArea, Table } from '@mantine/core'; import { NetflowSankeyData, @@ -8,6 +8,8 @@ import { } from '@/netflowSankey'; import { formatNumber } from '@/utils'; +import NetflowFilterMenu from './NetflowFilterMenu'; + export default function NetflowSankeyTable({ data, dimensions, @@ -36,37 +38,16 @@ export default function NetflowSankeyTable({ {dimensions.map((dimension, index) => ( - {onFilter ? ( - - - - - - - onFilter(dimension, path.values[index], false) - } - > - Include - - - onFilter(dimension, path.values[index], true) - } - > - Exclude - - - - ) : ( - path.values[index] || '(empty)' - )} + + onFilter(dimension, path.values[index], excluded)) + } + /> ))} diff --git a/packages/app/src/components/Sources/SourceForm/NetflowTableModelForm.tsx b/packages/app/src/components/Sources/SourceForm/NetflowTableModelForm.tsx index 67cbf200ee..b8ffc5c5f9 100644 --- a/packages/app/src/components/Sources/SourceForm/NetflowTableModelForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/NetflowTableModelForm.tsx @@ -1,4 +1,5 @@ import { useWatch } from 'react-hook-form'; +import { NETFLOW_COLUMN_EXPRESSIONS } from '@hyperdx/common-utils/dist/types'; import { Stack, Text } from '@mantine/core'; import { SQLInlineEditorControlled } from '@/components/SQLEditor/SQLInlineEditor'; @@ -9,22 +10,67 @@ import { TableModelProps } from './types'; const fields = [ ['timestampValueExpression', 'Timestamp', 'TimeReceived'], + [ + 'implicitColumnExpression', + 'Full-text search expression (optional)', + 'Automatic from mapped flow fields', + ], [ 'defaultTableSelectExpression', 'Default columns', 'SrcAddr, DstAddr, Bytes', ], - ['bytesExpression', 'Bytes', 'Bytes'], - ['packetsExpression', 'Packets', 'Packets'], - ['srcAddrExpression', 'Source IP address', 'SrcAddr'], - ['dstAddrExpression', 'Destination IP address', 'DstAddr'], - ['srcPortExpression', 'Source port', 'SrcPort'], - ['dstPortExpression', 'Destination port', 'DstPort'], - ['protocolExpression', 'IP protocol number', 'Proto'], - ['samplingRateExpression', 'Sampling multiplier (optional)', 'SamplingRate'], - ['exporterExpression', 'Exporter (optional)', 'ExporterName'], - ['inIfExpression', 'Input interface (optional)', 'InIfName'], - ['outIfExpression', 'Output interface (optional)', 'OutIfName'], + ['bytesExpression', 'Bytes', NETFLOW_COLUMN_EXPRESSIONS.bytesExpression], + [ + 'packetsExpression', + 'Packets', + NETFLOW_COLUMN_EXPRESSIONS.packetsExpression, + ], + [ + 'srcAddrExpression', + 'Source IP address', + NETFLOW_COLUMN_EXPRESSIONS.srcAddrExpression, + ], + [ + 'dstAddrExpression', + 'Destination IP address', + NETFLOW_COLUMN_EXPRESSIONS.dstAddrExpression, + ], + [ + 'srcPortExpression', + 'Source port', + NETFLOW_COLUMN_EXPRESSIONS.srcPortExpression, + ], + [ + 'dstPortExpression', + 'Destination port', + NETFLOW_COLUMN_EXPRESSIONS.dstPortExpression, + ], + [ + 'protocolExpression', + 'IP protocol number', + NETFLOW_COLUMN_EXPRESSIONS.protocolExpression, + ], + [ + 'samplingRateExpression', + 'Sampling multiplier (optional)', + NETFLOW_COLUMN_EXPRESSIONS.samplingRateExpression, + ], + [ + 'exporterExpression', + 'Exporter (optional)', + NETFLOW_COLUMN_EXPRESSIONS.exporterExpression, + ], + [ + 'inIfExpression', + 'Input interface (optional)', + NETFLOW_COLUMN_EXPRESSIONS.inIfExpression, + ], + [ + 'outIfExpression', + 'Output interface (optional)', + NETFLOW_COLUMN_EXPRESSIONS.outIfExpression, + ], ] as const; export function NetflowTableModelForm({ control }: TableModelProps) { @@ -42,7 +88,7 @@ export function NetflowTableModelForm({ control }: TableModelProps) { Map the columns or SQL expressions in your flow table. Akvorado columns are detected automatically. Bytes and packets are multiplied by the sampling multiplier; leave it blank for unsampled or already scaled - data. + data. Full-text search uses the mapped flow fields unless overridden. {fields.map(([name, label, placeholder]) => ( {label}}> diff --git a/packages/app/src/components/Sources/SourceForm/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm/SourceForm.tsx index 66999601c8..cb035dfb6c 100644 --- a/packages/app/src/components/Sources/SourceForm/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/SourceForm.tsx @@ -668,10 +668,6 @@ export function TableSourceForm({ withAsterisk > - )} + )} diff --git a/packages/app/src/components/__tests__/DBNumberChart.test.tsx b/packages/app/src/components/__tests__/DBNumberChart.test.tsx index a53c75d8ce..bef18eaa0c 100644 --- a/packages/app/src/components/__tests__/DBNumberChart.test.tsx +++ b/packages/app/src/components/__tests__/DBNumberChart.test.tsx @@ -98,6 +98,31 @@ describe('DBNumberChart', () => { }); }); + it('renders a selected result column without changing the queried aggregate', () => { + mockUseQueriedChartConfig.mockReturnValue({ + data: { + data: [{ bytes: 1000, packets: 25 }], + meta: [ + { name: 'bytes', type: 'Float64' }, + { name: 'packets', type: 'Float64' }, + ], + }, + isLoading: false, + isError: false, + }); + renderWithMantine( + , + ); + expect(mockFormatNumber).toHaveBeenCalledWith(25, { output: 'number' }); + expect(mockUseQueriedChartConfig.mock.lastCall[0]).toMatchObject({ + select: baseTestConfig.select, + }); + }); + it('renders the number with default formatting when no numberFormat is provided', () => { renderWithMantine(); expect(mockFormatNumber).toHaveBeenCalledWith(1234, undefined); diff --git a/packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx b/packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx new file mode 100644 index 0000000000..5ab0028d40 --- /dev/null +++ b/packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx @@ -0,0 +1,97 @@ +import { fireEvent, screen } from '@testing-library/react'; + +import NetflowFilterMenu from '@/components/NetflowFilterMenu'; +import NetflowSankeyTable from '@/components/NetflowSankeyTable'; +import { buildNetflowSankeyData } from '@/netflowSankey'; + +describe('NetFlow filter menus', () => { + it.each([ + ['Include', false], + ['Exclude', true], + ] as const)( + 'announces the human field label and handles %s for the raw value', + async (action, excluded) => { + const onFilter = jest.fn(); + renderWithMantine( + , + ); + const target = screen.getByRole('button', { + name: 'Filter Source IP: 2001:db8::1', + }); + fireEvent.click(target); + fireEvent.click(await screen.findByRole('menuitem', { name: action })); + expect(onFilter).toHaveBeenLastCalledWith( + 'srcAddr', + '2001:db8::1', + excluded, + ); + }, + ); + + it('opens custom SVG targets with the keyboard', async () => { + const onSelect = jest.fn(); + renderWithMantine( + + ( + + + + )} + /> + , + ); + fireEvent.keyDown( + screen.getByRole('button', { name: 'Filter Provider: edge' }), + { key: 'Enter' }, + ); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Include' })); + expect(onSelect).toHaveBeenCalledWith(false); + }); + + it('does not offer filters for missing mapped values', () => { + renderWithMantine( + , + ); + expect(screen.queryByRole('button')).not.toBeInTheDocument(); + expect(screen.getByText('—')).toBeInTheDocument(); + }); + + it('keeps empty Sankey dimensions filterable using their raw empty value', async () => { + const onFilter = jest.fn(); + const dimensions = [ + { key: 'custom', label: 'Provider', expression: 'Provider' }, + { key: 'exporter', label: 'Exporter', expression: 'Exporter' }, + ]; + const data = buildNetflowSankeyData( + [ + { + __netflow_dimension_0: '', + __netflow_dimension_1: 'edge', + __netflow_value: 10, + }, + ], + dimensions, + ); + renderWithMantine( + , + ); + fireEvent.click( + screen.getByRole('button', { name: 'Filter Provider: (empty)' }), + ); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Exclude' })); + expect(onFilter).toHaveBeenCalledWith(dimensions[0], '', true); + }); +}); diff --git a/packages/app/src/components/__tests__/NetflowRecords.test.tsx b/packages/app/src/components/__tests__/NetflowRecords.test.tsx index 0a34ca1f15..66dda0b344 100644 --- a/packages/app/src/components/__tests__/NetflowRecords.test.tsx +++ b/packages/app/src/components/__tests__/NetflowRecords.test.tsx @@ -86,6 +86,23 @@ describe('NetFlow records', () => { expect(within(drawer).getByText('uplink')).toBeInTheDocument(); }); + it('does not remap the 500-row result when opening a drawer', async () => { + const timestamp = jest.fn(() => '2026-10-09 12:30:00'); + const rows = Array.from({ length: 500 }, () => ({ + get __netflow_timestamp() { + return timestamp(); + }, + })); + mockQuery.mockReturnValue({ data: { data: rows } }); + renderWithMantine( + , + ); + expect(timestamp).toHaveBeenCalledTimes(500); + fireEvent.click(screen.getByRole('button', { name: 'Inspect flow 1' })); + await screen.findByRole('dialog'); + expect(timestamp).toHaveBeenCalledTimes(500); + }); + it('shows an actionable empty state for a filter with no matching records', () => { mockQuery.mockReturnValue({ isLoading: false, data: { data: [] } }); renderWithMantine(); diff --git a/packages/app/src/components/__tests__/NetflowSankey.test.tsx b/packages/app/src/components/__tests__/NetflowSankey.test.tsx new file mode 100644 index 0000000000..6fcab6caac --- /dev/null +++ b/packages/app/src/components/__tests__/NetflowSankey.test.tsx @@ -0,0 +1,62 @@ +import { SourceKind, TNetflowSource } from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; +import { screen } from '@testing-library/react'; + +import NetflowSankey from '@/components/NetflowSankey'; +import { buildNetflowQueryConfigs } from '@/netflow'; + +let mockColumns = [{ name: 'SrcAS', type: 'UInt32' }]; +jest.mock('@/hooks/useMetadata', () => ({ + useColumns: () => ({ data: mockColumns }), +})); +jest.mock('@/hooks/useChartConfig', () => ({ + useQueriedChartConfig: () => ({ data: { data: [] } }), +})); +jest.mock('@/source', () => ({ useSource: () => ({ data: source }) })); +jest.mock('nuqs', () => ({ + ...jest.requireActual('nuqs'), + useQueryStates: () => [ + { sankeyDimensions: ['column:SrcAS', 'exporter'], sankeyLimit: 20 }, + jest.fn(), + ], +})); + +const source: TNetflowSource = { + id: 'flows', + name: 'Flows', + kind: SourceKind.Netflow, + connection: 'local', + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: '*', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + exporterExpression: 'Exporter', +}; + +it('falls back to available dimensions when the selected column disappears on a source switch', () => { + const baseConfig = buildNetflowQueryConfigs({ + source, + dateRange: [new Date(0), new Date(60000)], + filters: {}, + }).totalBytes; + const view = renderWithMantine(); + expect(screen.getByText('No traffic paths found')).toBeInTheDocument(); + mockColumns = []; + view.rerender( + + + , + ); + expect(screen.getByText('No traffic paths found')).toBeInTheDocument(); + expect( + screen.queryByText('Choose two to five dimensions'), + ).not.toBeInTheDocument(); + expect(screen.getAllByText('Source IP').length).toBeGreaterThan(0); + expect(screen.getAllByText('Destination IP').length).toBeGreaterThan(0); +}); diff --git a/packages/app/src/components/__tests__/NetflowTableModelForm.test.tsx b/packages/app/src/components/__tests__/NetflowTableModelForm.test.tsx new file mode 100644 index 0000000000..1cc81c6bf7 --- /dev/null +++ b/packages/app/src/components/__tests__/NetflowTableModelForm.test.tsx @@ -0,0 +1,62 @@ +import { useController, useForm } from 'react-hook-form'; +import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; + +import { NetflowTableModelForm } from '@/components/Sources/SourceForm/NetflowTableModelForm'; + +jest.mock('@/components/SQLEditor/SQLInlineEditor', () => ({ + SQLInlineEditorControlled: ( + props: Parameters>[0] & { + placeholder: string; + }, + ) => { + const { field } = useController(props); + return ( + + ); + }, +})); + +it('persists an optional full-text expression without changing mapped columns', async () => { + const onSubmit = jest.fn(); + function Form() { + const { control, setValue, handleSubmit } = useForm({ + defaultValues: { + kind: SourceKind.Netflow, + bytesExpression: 'Octets', + srcAddrExpression: 'ClientIP', + }, + }); + return ( +
+ + + + ); + } + renderWithMantine(
); + expect( + screen.getByText('Full-text search expression (optional)'), + ).toBeInTheDocument(); + const input = screen.getByLabelText('implicitColumnExpression'); + expect(input).toHaveValue(''); + fireEvent.change(input, { + target: { value: 'concat(ClientIP, RouterName)' }, + }); + fireEvent.click(screen.getByRole('button', { name: 'Save' })); + await waitFor(() => + expect(onSubmit).toHaveBeenCalledWith( + expect.objectContaining({ + implicitColumnExpression: 'concat(ClientIP, RouterName)', + bytesExpression: 'Octets', + srcAddrExpression: 'ClientIP', + }), + expect.anything(), + ), + ); +}); diff --git a/packages/app/src/netflow.ts b/packages/app/src/netflow.ts index d845e085cd..92aa4c6dc9 100644 --- a/packages/app/src/netflow.ts +++ b/packages/app/src/netflow.ts @@ -1,3 +1,4 @@ +import { getNetflowImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { convertDateRangeToGranularityString, convertGranularityToSeconds, @@ -13,6 +14,60 @@ import { TNetflowSource, } from '@hyperdx/common-utils/dist/types'; +export const NETFLOW_DIMENSION_LABELS: Record = { + srcAddr: 'Source IP', + dstAddr: 'Destination IP', + protocol: 'Protocol', + exporter: 'Exporter', + inputInterface: 'Input interface', + outputInterface: 'Output interface', +} satisfies Record; +export type NetflowFilterField = keyof ReturnType; +export const netflowColumnAlias = (field: string) => `__netflow_${field}`; +export const NETFLOW_ALIASES = { + name: netflowColumnAlias('name'), + bytes: netflowColumnAlias('bytes'), + value: netflowColumnAlias('value'), +}; +const PROTOCOL_NAMES = { + 1: 'ICMP', + 6: 'TCP', + 17: 'UDP', + 47: 'GRE', + 50: 'ESP', + 58: 'ICMPv6', +}; +export const NETFLOW_SUMMARY_TILES = [ + { + title: 'Average bit rate', + column: netflowColumnAlias('bitsPerSecond'), + numberFormat: { + output: 'data_rate', + numericUnit: NumericUnit.BitsSecSI, + mantissa: 2, + }, + }, + { + title: 'Average packet rate', + column: netflowColumnAlias('packetsPerSecond'), + numberFormat: { + output: 'throughput', + numericUnit: NumericUnit.PacketsSec, + mantissa: 2, + }, + }, + { + title: 'Transferred bytes', + column: NETFLOW_ALIASES.bytes, + numberFormat: { output: 'byte', mantissa: 2 }, + }, + { + title: 'Flow records', + column: netflowColumnAlias('flowRecords'), + numberFormat: { output: 'number', thousandSeparated: true }, + }, +] satisfies { title: string; column: string; numberFormat: NumberFormat }[]; + export type NetflowFilters = { exporter?: string; protocol?: string; @@ -26,7 +81,13 @@ export function getNetflowDimensions(source: TNetflowSource) { return { srcAddr: address(source.srcAddrExpression), dstAddr: address(source.dstAddrExpression), - protocol: `transform(toString(${source.protocolExpression}), ['1', '6', '17', '47', '50', '58'], ['ICMP', 'TCP', 'UDP', 'GRE', 'ESP', 'ICMPv6'], toString(${source.protocolExpression}))`, + protocol: `transform(toString(${source.protocolExpression}), [${Object.keys( + PROTOCOL_NAMES, + ) + .map(value => `'${value}'`) + .join(', ')}], [${Object.values(PROTOCOL_NAMES) + .map(value => `'${value}'`) + .join(', ')}], toString(${source.protocolExpression}))`, exporter: source.exporterExpression?.trim(), inputInterface: source.inIfExpression?.trim(), outputInterface: source.outIfExpression?.trim(), @@ -90,6 +151,7 @@ export function buildNetflowQueryConfigs({ connection: source.connection, source: source.id, timestampValueExpression: source.timestampValueExpression, + implicitColumnExpression: getNetflowImplicitColumnExpression(source), dateRange, dateRangeEndInclusive: false, alignDateRangeToGranularity: false, @@ -101,15 +163,6 @@ export function buildNetflowQueryConfigs({ ], }; // Keep result aliases separate from common mapped column names: ClickHouse substitutes aliases in WHERE and sibling expressions. - const number = ( - valueExpression: string, - numberFormat: NumberFormat, - ): BuilderChartConfigWithDateRange => ({ - ...base, - select: [{ valueExpression, alias: '__netflow_value' }], - displayType: DisplayType.Number, - numberFormat, - }); const byteFormat: NumberFormat = { output: 'byte', mantissa: 2 }; const bitRateFormat: NumberFormat = { output: 'data_rate', @@ -119,11 +172,11 @@ export function buildNetflowQueryConfigs({ const top = (expression: string): BuilderChartConfigWithDateRange => ({ ...base, select: [ - { valueExpression: expression, alias: '__netflow_name' }, - { valueExpression: `sum(${bytes})`, alias: '__netflow_bytes' }, + { valueExpression: expression, alias: netflowColumnAlias('name') }, + { valueExpression: `sum(${bytes})`, alias: netflowColumnAlias('bytes') }, ], - groupBy: '__netflow_name', - orderBy: '__netflow_bytes DESC', + groupBy: NETFLOW_ALIASES.name, + orderBy: `${NETFLOW_ALIASES.bytes} DESC`, limit: { limit: 10 }, displayType: DisplayType.Bar, numberFormat: byteFormat, @@ -145,35 +198,47 @@ export function buildNetflowQueryConfigs({ select: [ { valueExpression: source.timestampValueExpression, - alias: '__netflow_timestamp', + alias: netflowColumnAlias('timestamp'), }, { valueExpression: dimensions.srcAddr, - alias: '__netflow_srcAddr', + alias: netflowColumnAlias('srcAddr'), }, { valueExpression: dimensions.dstAddr, - alias: '__netflow_dstAddr', + alias: netflowColumnAlias('dstAddr'), + }, + { + valueExpression: source.srcPortExpression, + alias: netflowColumnAlias('srcPort'), + }, + { + valueExpression: source.dstPortExpression, + alias: netflowColumnAlias('dstPort'), + }, + { valueExpression: protocol, alias: netflowColumnAlias('protocol') }, + { + valueExpression: exporter || "''", + alias: netflowColumnAlias('exporter'), + }, + { valueExpression: bytes, alias: netflowColumnAlias('bytes') }, + { valueExpression: packets, alias: netflowColumnAlias('packets') }, + { + valueExpression: source.bytesExpression, + alias: netflowColumnAlias('rawBytes'), }, - { valueExpression: source.srcPortExpression, alias: '__netflow_srcPort' }, - { valueExpression: source.dstPortExpression, alias: '__netflow_dstPort' }, - { valueExpression: protocol, alias: '__netflow_protocol' }, - { valueExpression: exporter || "''", alias: '__netflow_exporter' }, - { valueExpression: bytes, alias: '__netflow_bytes' }, - { valueExpression: packets, alias: '__netflow_packets' }, - { valueExpression: source.bytesExpression, alias: '__netflow_rawBytes' }, { valueExpression: source.packetsExpression, - alias: '__netflow_rawPackets', + alias: netflowColumnAlias('rawPackets'), }, - { valueExpression: sample, alias: '__netflow_samplingRate' }, + { valueExpression: sample, alias: netflowColumnAlias('samplingRate') }, { valueExpression: inputInterface || "''", - alias: '__netflow_inputInterface', + alias: netflowColumnAlias('inputInterface'), }, { valueExpression: outputInterface || "''", - alias: '__netflow_outputInterface', + alias: netflowColumnAlias('outputInterface'), }, ], orderBy: `${source.timestampValueExpression} DESC`, @@ -182,17 +247,33 @@ export function buildNetflowQueryConfigs({ }; return { - bitsPerSecond: number(`sum(${bytes}) * 8 / ${seconds}`, bitRateFormat), - packetsPerSecond: number(`sum(${packets}) / ${seconds}`, { - output: 'throughput', - numericUnit: NumericUnit.PacketsSec, - mantissa: 2, - }), - totalBytes: number(`sum(${bytes})`, byteFormat), - flowRecords: number('count()', { - output: 'number', - thousandSeparated: true, - }), + summary: { + ...base, + select: [ + { + valueExpression: `sum(${bytes}) * 8 / ${seconds}`, + alias: netflowColumnAlias('bitsPerSecond'), + }, + { + valueExpression: `sum(${packets}) / ${seconds}`, + alias: netflowColumnAlias('packetsPerSecond'), + }, + { valueExpression: `sum(${bytes})`, alias: NETFLOW_ALIASES.bytes }, + { + valueExpression: 'count()', + alias: netflowColumnAlias('flowRecords'), + }, + ], + displayType: DisplayType.Number, + } satisfies BuilderChartConfigWithDateRange, + totalBytes: { + ...base, + select: [ + { valueExpression: `sum(${bytes})`, alias: NETFLOW_ALIASES.value }, + ], + displayType: DisplayType.Number, + numberFormat: byteFormat, + } satisfies BuilderChartConfigWithDateRange, traffic, topSourceAddresses: top(dimensions.srcAddr), topDestinationAddresses: top(dimensions.dstAddr), diff --git a/packages/app/src/netflowSankey.ts b/packages/app/src/netflowSankey.ts index 81f766efba..6a7ebeeca6 100644 --- a/packages/app/src/netflowSankey.ts +++ b/packages/app/src/netflowSankey.ts @@ -3,6 +3,8 @@ import { DisplayType, } from '@hyperdx/common-utils/dist/types'; +import { NETFLOW_ALIASES, netflowColumnAlias } from '@/netflow'; + export interface SankeyDimension { key: string; label: string; @@ -33,13 +35,20 @@ export interface NetflowSankeyData { nodes: NetflowSankeyNode[]; links: NetflowSankeyLink[]; paths: { values: string[]; value: number }[]; - totalBytes: number; } export function sankeyDimensionExpression(dimension: SankeyDimension): string { return `ifNull(toString(${dimension.expression}), '')`; } +export function unwrapSankeyDimensionExpression(expression: string): string { + return expression.replace(/^ifNull\(toString\((.*)\), ''\)$/s, '$1'); +} + +export function normalizeNetflowSankeyLimit(limit: number): number { + return [10, 20, 50].includes(limit) ? limit : 20; +} + export function buildNetflowSankeyConfig({ baseConfig, dimensions, @@ -58,22 +67,18 @@ export function buildNetflowSankeyConfig({ const dimensionSelect = dimensions.map((dimension, stage) => ({ valueExpression: sankeyDimensionExpression(dimension), - alias: `__netflow_dimension_${stage}`, + alias: netflowColumnAlias(`dimension_${stage}`), })); return { ...baseConfig, select: [ ...dimensionSelect, - { ...baseConfig.select[0], alias: '__netflow_value' }, + { ...baseConfig.select[0], alias: NETFLOW_ALIASES.value }, ], groupBy: dimensionSelect.map(dimension => dimension.alias).join(', '), - orderBy: '__netflow_value DESC', - limit: { - limit: Number.isFinite(limit) - ? Math.min(100, Math.max(1, Math.floor(limit))) - : 20, - }, + orderBy: `${NETFLOW_ALIASES.value} DESC`, + limit: { limit: normalizeNetflowSankeyLimit(limit) }, displayType: DisplayType.Table, }; } @@ -86,26 +91,26 @@ export function buildNetflowSankeyData( nodes: [], links: [], paths: [], - totalBytes: 0, }; if (dimensions.length < 2) return data; const nodeIndices = new Map(); const links = new Map(); for (const row of rows) { - const rawBytes = row.__netflow_value; + const rawBytes = row[NETFLOW_ALIASES.value]; if (typeof rawBytes !== 'number' && typeof rawBytes !== 'string') continue; const value = Number(rawBytes); if (!Number.isFinite(value) || value <= 0) continue; if ( dimensions.some( - (_, stage) => !Object.hasOwn(row, `__netflow_dimension_${stage}`), + (_, stage) => + !Object.hasOwn(row, netflowColumnAlias(`dimension_${stage}`)), ) ) continue; const values = dimensions.map((_, stage) => - String(row[`__netflow_dimension_${stage}`] ?? ''), + String(row[netflowColumnAlias(`dimension_${stage}`)] ?? ''), ); const pathNodes = values.map((rawValue, stage) => { // Stage-qualified IDs keep repeated values in different dimensions acyclic. @@ -132,7 +137,6 @@ export function buildNetflowSankeyData( else links.set(key, { source, target, value }); } data.paths.push({ values, value }); - data.totalBytes += value; } data.links = [...links.values()]; return data; diff --git a/packages/app/src/netflowSankeyDimensions.ts b/packages/app/src/netflowSankeyDimensions.ts index 7e1ece5635..8d3afcd940 100644 --- a/packages/app/src/netflowSankeyDimensions.ts +++ b/packages/app/src/netflowSankeyDimensions.ts @@ -6,24 +6,24 @@ import { import { quoteIdentifierIfNeeded } from '@hyperdx/common-utils/dist/core/metadata'; import { TNetflowSource } from '@hyperdx/common-utils/dist/types'; -import { getNetflowDimensions } from '@/netflow'; +import { getNetflowDimensions, NETFLOW_DIMENSION_LABELS } from '@/netflow'; import { SankeyDimension } from '@/netflowSankey'; export function getSankeyDimensionOptions( source: TNetflowSource, columns: ColumnMeta[], ): SankeyDimension[] { - const labels: Record = { - srcAddr: 'Source IP', - dstAddr: 'Destination IP', - protocol: 'Protocol', - exporter: 'Exporter', - inputInterface: 'Input interface', - outputInterface: 'Output interface', - }; const mapped = Object.entries(getNetflowDimensions(source)).flatMap( ([key, expression]) => - expression ? [{ key, label: labels[key], expression }] : [], + expression + ? [ + { + key, + label: NETFLOW_DIMENSION_LABELS[key], + expression, + }, + ] + : [], ); const scalarTypes = [ JSDataType.String, diff --git a/packages/app/src/source.ts b/packages/app/src/source.ts index 33c9876efa..4409309484 100644 --- a/packages/app/src/source.ts +++ b/packages/app/src/source.ts @@ -20,6 +20,7 @@ import { ChartConfigWithOptTimestamp, MetricFormula, MetricsDataType, + NETFLOW_COLUMN_EXPRESSIONS, NumberFormat, SourceKind, SourceSchema, @@ -352,19 +353,6 @@ export async function inferTableSourceConfig({ if (kind === SourceKind.Netflow) { const names = new Set(columns.map(column => column.name)); - const mapping = { - bytesExpression: 'Bytes', - packetsExpression: 'Packets', - samplingRateExpression: 'SamplingRate', - srcAddrExpression: 'SrcAddr', - dstAddrExpression: 'DstAddr', - srcPortExpression: 'SrcPort', - dstPortExpression: 'DstPort', - protocolExpression: 'Proto', - exporterExpression: 'ExporterName', - inIfExpression: 'InIfName', - outIfExpression: 'OutIfName', - }; const selected = [ 'TimeReceived', 'SrcAddr', @@ -383,7 +371,9 @@ export async function inferTableSourceConfig({ : {}), defaultTableSelectExpression: selected.join(', ') || '*', ...Object.fromEntries( - Object.entries(mapping).filter(([, column]) => names.has(column)), + Object.entries(NETFLOW_COLUMN_EXPRESSIONS).filter(([, column]) => + names.has(column), + ), ), }; } diff --git a/packages/app/tests/e2e/features/netflow/monitoring.spec.ts b/packages/app/tests/e2e/features/netflow/monitoring.spec.ts new file mode 100644 index 0000000000..f481ca288a --- /dev/null +++ b/packages/app/tests/e2e/features/netflow/monitoring.spec.ts @@ -0,0 +1,201 @@ +import { expect, test } from '../../fixtures/netflow'; + +test.use({ viewport: { width: 1600, height: 1050 } }); + +test('NetFlow searches, drafts, click filters and sampled details survive reload', async ({ + page, + netflow, +}) => { + await page.goto(`/netflow?source=${netflow.source.id}`); + const records = page.getByTestId('netflow-records'); + const pills = page.getByTestId('netflow-filter-pills'); + const search = page.getByTestId('netflow-search'); + const protocol = page.getByLabel('Protocol', { exact: true }); + await expect(records.locator('tbody tr')).toHaveCount(500); + await search.fill('Proto:17'); + await protocol.fill('17'); + await page + .getByTestId('netflow-breakdown-protocol') + .getByRole('button', { name: 'Filter Protocol: TCP', exact: true }) + .click(); + await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); + await expect(pills).toContainText('Protocol = TCP'); + await expect + .poll(() => new URL(page.url()).searchParams.get('where') || '') + .toBe(''); + await expect + .poll(() => new URL(page.url()).searchParams.get('protocol') || '') + .toBe(''); + await expect(search).toHaveValue('Proto:17'); + await expect(protocol).toHaveValue('17'); + await expect( + records.getByRole('button', { name: 'Filter Protocol: UDP', exact: true }), + ).toHaveCount(0); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect(records.getByText('No flow records found')).toBeVisible(); + await expect + .poll(() => new URL(page.url()).searchParams.get('where')) + .toBe('Proto:17'); + await page + .getByRole('button', { name: 'Clear filters', exact: true }) + .click(); + await search.fill('Proto:6 AND DstPort:443'); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect(records.locator('tbody tr')).toHaveCount(500); + await page.reload(); + await expect(search).toHaveValue('Proto:6 AND DstPort:443'); + await expect( + records.getByRole('button', { name: 'Filter Protocol: UDP', exact: true }), + ).toHaveCount(0); + await page.getByLabel('Source IP', { exact: true }).fill('192.0.2.2'); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect(records.locator('tbody tr')).toHaveCount(300); + await page + .getByRole('button', { name: 'Inspect flow 1', exact: true }) + .click(); + const drawer = page.getByRole('dialog', { name: 'Flow details' }); + const field = async (label: string) => + Number( + await drawer + .getByRole('row') + .filter({ + has: page.getByRole('rowheader', { name: label, exact: true }), + }) + .getByRole('cell') + .innerText(), + ); + expect(await field('Bytes')).toBe( + (await field('Raw bytes')) * (await field('Sampling rate')), + ); + expect(await field('Packets')).toBe( + (await field('Raw packets')) * (await field('Sampling rate')), + ); + await drawer + .getByRole('button', { name: 'Filter Exporter: edge-a', exact: true }) + .click(); + await page.getByRole('menuitem', { name: 'Exclude', exact: true }).click(); + await expect(drawer).toBeHidden(); + await expect(records.getByText('No flow records found')).toBeVisible(); + await page + .getByRole('button', { name: 'Clear filters', exact: true }) + .click(); + await expect(records.locator('tbody tr')).toHaveCount(500); +}); + +test('NetFlow source selection applies immediately and clears incompatible filters', async ({ + page, + netflow, +}) => { + await page.goto(`/netflow?source=${netflow.source.id}`); + const records = page.getByTestId('netflow-records'); + await expect(records.locator('tbody tr')).toHaveCount(500); + await records + .getByRole('button', { name: 'Filter Protocol: TCP', exact: true }) + .first() + .click(); + await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); + const pills = page.getByTestId('netflow-filter-pills'); + await expect(pills).toContainText('Protocol = TCP'); + await page + .getByRole('combobox', { name: 'NetFlow source', exact: true }) + .click(); + await page + .getByRole('option', { name: netflow.alternative.name, exact: true }) + .click(); + await expect + .poll(() => new URL(page.url()).searchParams.get('source')) + .toBe(netflow.alternative.id); + await expect( + pills.getByRole('button', { name: 'Remove filter' }), + ).toHaveCount(0); + await page + .getByRole('button', { name: 'Source actions', exact: true }) + .click(); + await page + .getByRole('menuitem', { name: 'Edit source', exact: true }) + .click(); + await expect( + page.getByRole('dialog').locator('input[name=name]'), + ).toHaveValue(netflow.alternative.name); + await page.keyboard.press('Escape'); + await page.reload(); + await expect( + page.getByRole('combobox', { name: 'NetFlow source', exact: true }), + ).toHaveValue(netflow.alternative.name); + await expect(records.locator('tbody tr')).toHaveCount(500); + await page.goto('/netflow?source=missing-source'); + await expect(page.getByText('NetFlow source unavailable')).toBeVisible(); +}); + +test('NetFlow invalid time ranges recover and historical ranges remain empty', async ({ + page, + netflow, +}) => { + for (const [from, to] of [ + [1000, 1000], + [2000, 1000], + ]) { + await page.goto( + `/netflow?source=${netflow.source.id}&from=${from}&to=${to}`, + ); + await expect( + page.getByText('Invalid time range', { exact: true }), + ).toBeVisible(); + await expect(page.getByTestId('netflow-records')).toHaveCount(0); + await page + .getByRole('button', { name: 'Use past hour', exact: true }) + .click(); + await expect( + page.getByRole('button', { name: 'Inspect flow 1', exact: true }), + ).toBeVisible(); + } + await page.goto(`/netflow?source=${netflow.source.id}&from=1000&to=3601000`); + await expect( + page.getByTestId('netflow-records').getByText('No flow records found'), + ).toBeVisible(); +}); + +for (const [key, label] of [ + ['srcAddr', 'Source IP'], + ['dstAddr', 'Destination IP'], + ['exporter', 'Exporter'], + ['protocol', 'Protocol'], +]) { + test(`NetFlow ${label} include/exclude filters persist and remove`, async ({ + page, + netflow, + }) => { + await page.goto(`/netflow?source=${netflow.source.id}`); + const records = page.getByTestId('netflow-records'); + await expect(records.locator('tbody tr')).toHaveCount(500); + const button = page + .getByTestId(`netflow-breakdown-${key}`) + .getByRole('button', { name: new RegExp(`^Filter ${label}:`) }) + .first(); + const value = await button.innerText(); + await button.click(); + await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); + const matching = records.getByRole('button', { + name: `Filter ${label}: ${value}`, + exact: true, + }); + await expect + .poll( + async () => + (await matching.count()) > 0 && + (await matching.count()) === + (await records.locator('tbody tr').count()), + ) + .toBe(true); + await page.reload(); + await expect(matching.first()).toBeVisible(); + await page + .getByTestId('netflow-filter-pills') + .getByRole('button', { name: 'Remove filter' }) + .click(); + await expect(records.locator('tbody tr')).toHaveCount(500); + await button.click(); + await page.getByRole('menuitem', { name: 'Exclude', exact: true }).click(); + await expect(matching).toHaveCount(0); + }); +} diff --git a/packages/app/tests/e2e/features/netflow/queries.spec.ts b/packages/app/tests/e2e/features/netflow/queries.spec.ts new file mode 100644 index 0000000000..db216f0b69 --- /dev/null +++ b/packages/app/tests/e2e/features/netflow/queries.spec.ts @@ -0,0 +1,253 @@ +import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; +import { filtersToQuery, parseQuery } from '@hyperdx/common-utils/dist/filters'; +import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; + +import { + buildNetflowQueryConfigs, + getNetflowDimensions, +} from '../../../../src/netflow'; +import { + buildNetflowSankeyConfig, + buildNetflowSankeyData, + sankeyDimensionExpression, +} from '../../../../src/netflowSankey'; +import { expect, test } from '../../fixtures/netflow'; + +test('NetFlow queries preserve sampled totals, raw records, aliases and Lucene semantics', async ({ + netflow, +}) => { + const { client, source, dateRange, database } = netflow; + const metadata = getMetadata(client); + const run = async (config: BuilderChartConfigWithDateRange) => + ( + await client.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }) + ).data; + const direct = async (where = '1') => + ( + await ( + await client.query({ + query: `SELECT count() AS records, sum(Bytes * SamplingRate) AS bytes, sum(Packets * SamplingRate) AS packets FROM ${database}.flows WHERE ${where}`, + format: 'JSONEachRow', + }) + ).json<{ records: string; bytes: string; packets: string }>() + )[0]; + const expected = await direct(); + const configs = buildNetflowQueryConfigs({ source, dateRange, filters: {} }); + for (const config of Object.values(configs).filter( + config => config != null, + )) { + expect((await run(config)).length).toBeGreaterThan(0); + } + const [summary] = await run(configs.summary); + expect(Number(summary.__netflow_flowRecords)).toBe(1200); + expect(Number(summary.__netflow_bytes)).toBe(Number(expected.bytes)); + expect(Number(summary.__netflow_bitsPerSecond)).toBeCloseTo( + (Number(expected.bytes) * 8) / 3600, + ); + expect(Number(summary.__netflow_packetsPerSecond)).toBeCloseTo( + Number(expected.packets) / 3600, + ); + const records = await run(configs.flows); + expect(records).toHaveLength(500); + for (const row of records) { + expect(Number(row.__netflow_bytes)).toBe( + Number(row.__netflow_rawBytes) * Number(row.__netflow_samplingRate), + ); + expect(Number(row.__netflow_packets)).toBe( + Number(row.__netflow_rawPackets) * Number(row.__netflow_samplingRate), + ); + } + const scaled = buildNetflowQueryConfigs({ + source: { + ...source, + bytesExpression: 'Bytes * SamplingRate', + samplingRateExpression: undefined, + }, + dateRange, + filters: {}, + }); + expect(Number((await run(scaled.totalBytes))[0].__netflow_value)).toBe( + Number(expected.bytes), + ); + for (const [where, sql] of [ + ['Proto:6', 'Proto = 6'], + ['192.0.2.2', "SrcAddr = toIPv6('192.0.2.2')"], + [ + '(Proto:6 OR Proto:17) AND NOT ExporterName:missing*', + "Proto IN (6,17) AND ExporterName NOT ILIKE 'missing%'", + ], + ['Bytes:[500000 TO 2000000]', 'Bytes BETWEEN 500000 AND 2000000'], + [ + 'ExporterName:edge* AND DstPort:443', + "ExporterName ILIKE 'edge%' AND DstPort = 443", + ], + ]) { + const wanted = await direct(sql); + const [actual] = await run( + buildNetflowQueryConfigs({ source, dateRange, filters: {}, where }) + .summary, + ); + expect(Number(actual.__netflow_flowRecords)).toBe(Number(wanted.records)); + expect(Number(actual.__netflow_bytes)).toBe(Number(wanted.bytes)); + } + for (const [filters, count] of [ + [{ protocol: '6' }, 600], + [{ srcAddr: '192.0.2.2' }, 300], + [{ srcAddr: '2001:0db8:0001::2' }, 300], + [{ exporter: "edge' OR 1=1 --" }, 0], + ] as const) { + const [actual] = await run( + buildNetflowQueryConfigs({ source, dateRange, filters }).summary, + ); + expect(Number(actual.__netflow_flowRecords)).toBe(count); + } + await expect( + run( + buildNetflowQueryConfigs({ + source, + dateRange, + filters: {}, + where: 'Proto:(', + }).summary, + ), + ).rejects.toThrow(); +}); + +test('NetFlow handles lowercase counter aliases and custom timestamp and exporter expressions', async ({ + netflow, +}) => { + const { client, source, dateRange, database } = netflow; + const view = await client.query({ + query: `CREATE VIEW ${database}.lowercase AS SELECT + TimeReceived AS timestamp, Bytes AS bytes, Packets AS packets, + SamplingRate AS sampling, Proto AS protocol, ExporterName AS "router-name", + SrcAddr AS srcAddr, DstAddr AS dstAddr, SrcPort AS srcPort, DstPort AS dstPort, + InIfName AS inputInterface, OutIfName AS outputInterface FROM ${database}.flows`, + }); + await view.text(); + const customSource = { + ...source, + from: { databaseName: database, tableName: 'lowercase' }, + timestampValueExpression: 'toDateTime(timestamp)', + bytesExpression: 'bytes', + packetsExpression: 'packets', + samplingRateExpression: 'sampling', + protocolExpression: 'protocol', + exporterExpression: '`router-name`', + srcAddrExpression: 'srcAddr', + dstAddrExpression: 'dstAddr', + srcPortExpression: 'srcPort', + dstPortExpression: 'dstPort', + inIfExpression: 'inputInterface', + outIfExpression: 'outputInterface', + }; + const configs = buildNetflowQueryConfigs({ + source: customSource, + dateRange, + filters: { protocol: '6', exporter: 'edge-a' }, + where: 'dstPort = 443', + whereLanguage: 'sql', + }); + for (const config of Object.values(configs).filter( + config => config != null, + )) { + const { data } = await client.queryChartConfig({ + config, + metadata: getMetadata(client), + querySettings: undefined, + }); + expect(data.length).toBeGreaterThan(0); + } + const result = await client.queryChartConfig({ + config: configs.flows, + metadata: getMetadata(client), + querySettings: undefined, + }); + expect(result.data).toHaveLength(500); + expect( + result.data.every( + row => + row.__netflow_protocol === 'TCP' && row.__netflow_exporter === 'edge-a', + ), + ).toBe(true); +}); + +test('NetFlow Sankey conserves path traffic and executes quoted, empty and composed filters', async ({ + netflow, +}) => { + const { client, source, dateRange } = netflow; + const metadata = getMetadata(client); + const run = async (config: BuilderChartConfigWithDateRange) => + ( + await client.queryChartConfig({ + config, + metadata, + querySettings: undefined, + }) + ).data; + const dimensions = [ + { + key: 'src', + label: 'Source', + expression: getNetflowDimensions(source).srcAddr, + }, + { key: 'provider', label: 'Provider', expression: '`provider-name`' }, + { key: 'exporter', label: 'Exporter', expression: 'ExporterName' }, + ]; + const configs = buildNetflowQueryConfigs({ source, dateRange, filters: {} }); + const config = buildNetflowSankeyConfig({ + baseConfig: configs.totalBytes, + dimensions, + limit: 100, + }); + const data = buildNetflowSankeyData(await run(config), dimensions); + expect(data.paths).toHaveLength(4); + const total = Number((await run(configs.totalBytes))[0].__netflow_value); + expect(data.paths.reduce((sum, path) => sum + path.value, 0)).toBe(total); + for (const stage of [0, 1]) { + expect( + data.links + .filter(link => data.nodes[link.source].stage === stage) + .reduce((sum, link) => sum + link.value, 0), + ).toBe(total); + } + const expression = sankeyDimensionExpression(dimensions[1]); + for (const [value, count] of [ + ['', 300], + ['provider', 900], + ["' OR 1=1 --", 0], + ] as const) { + const extraFilters = filtersToQuery({ + [expression]: { included: new Set([value]), excluded: new Set() }, + }); + const restored = parseQuery(extraFilters); + expect(Object.keys(restored.filters)).toHaveLength(1); + const filtered = buildNetflowQueryConfigs({ + source, + dateRange, + filters: {}, + extraFilters, + }); + const [summary] = await run(filtered.summary); + expect(Number(summary.__netflow_flowRecords)).toBe(count); + } + const filtered = buildNetflowQueryConfigs({ + source, + dateRange, + filters: { protocol: '6' }, + where: 'DstPort:443', + extraFilters: [{ type: 'sql', condition: "ExporterName IN ('edge-a')" }], + }); + const paths = await run( + buildNetflowSankeyConfig({ + baseConfig: filtered.totalBytes, + dimensions, + limit: 10, + }), + ); + expect(paths).toHaveLength(2); +}); diff --git a/packages/app/tests/e2e/features/netflow/sankey.spec.ts b/packages/app/tests/e2e/features/netflow/sankey.spec.ts new file mode 100644 index 0000000000..f12aa98b32 --- /dev/null +++ b/packages/app/tests/e2e/features/netflow/sankey.spec.ts @@ -0,0 +1,132 @@ +import { expect, test } from '../../fixtures/netflow'; + +test.use({ viewport: { width: 1600, height: 1050 } }); + +test('NetFlow Sankey dimensions, filters, empty classifications and URL state', async ({ + page, + netflow, +}) => { + await page.goto(`/netflow?source=${netflow.source.id}`); + await page.getByText('Sankey', { exact: true }).click(); + const graph = page.getByTestId('netflow-sankey-graph'); + const table = page.getByTestId('netflow-sankey-table'); + const rows = table.locator('tbody tr'); + await expect(graph.locator('svg')).toBeVisible(); + await expect(rows).toHaveCount(4); + await expect(table.getByRole('columnheader')).toHaveText([ + 'SrcAS', + 'InIfConnectivity', + 'InIfProvider', + 'Exporter', + 'Average bit rate', + 'Transferred bytes', + ]); + const provider = graph.getByRole('button', { + name: 'Filter InIfProvider: peer-east', + exact: true, + }); + await provider.hover(); + await expect( + page.getByRole('tooltip').filter({ hasText: 'Average bit rate:' }), + ).toBeVisible(); + await provider.click(); + await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); + await expect(rows).toHaveCount(1); + await expect(rows.first().locator('td').nth(2)).toHaveText('peer-east'); + await page.reload(); + await expect(rows).toHaveCount(1); + await provider.click(); + await page.getByRole('menuitem', { name: 'Exclude', exact: true }).click(); + await expect(rows).toHaveCount(3); + await expect(provider).toHaveCount(0); + await page + .getByRole('button', { name: 'Clear filters', exact: true }) + .click(); + await expect(rows).toHaveCount(4); + await graph + .getByRole('button', { name: 'Filter InIfProvider: (empty)', exact: true }) + .click(); + await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); + await expect(rows).toHaveCount(2); + await page + .getByRole('button', { name: 'Clear filters', exact: true }) + .click(); + await page + .getByRole('button', { name: 'Clear Sankey dimensions', exact: true }) + .click(); + await expect( + page.getByText('Choose two to five dimensions', { exact: true }), + ).toBeVisible(); + const picker = page.locator('input[aria-label="Sankey dimensions"]'); + for (const name of ['Source IP', 'Protocol', 'Destination IP']) { + await picker.fill(name); + await page.getByRole('option', { name, exact: true }).click(); + } + await picker.press('Escape'); + await page + .getByRole('combobox', { name: 'Sankey path limit', exact: true }) + .click(); + await page.getByRole('option', { name: '10', exact: true }).click(); + await expect(rows).toHaveCount(4); + await page.reload(); + await expect(table.getByRole('columnheader').first()).toHaveText('Source IP'); + await expect( + page.getByRole('combobox', { name: 'Sankey path limit', exact: true }), + ).toHaveValue('10'); + await page.setViewportSize({ width: 800, height: 1000 }); + await graph.scrollIntoViewIfNeeded(); + expect( + await page.evaluate( + () => document.documentElement.scrollWidth <= innerWidth, + ), + ).toBe(true); + await page.getByTestId('netflow-search').fill('ExporterName:missing'); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect( + page.getByText('No traffic paths found', { exact: true }), + ).toBeVisible(); + await page.getByText('Time series', { exact: true }).click(); + await expect(page.getByTestId('netflow-sankey')).toHaveCount(0); +}); + +test('real NetFlow chart axes keep Mbit/s and Gbit/s labels inside the SVG', async ({ + page, + netflow, +}) => { + for (const width of [1280, 800]) { + await page.setViewportSize({ width, height: 1000 }); + for (const [filter, unit] of [ + ['&srcAddr=192.0.2.2', 'Mbit/s'], + ['', 'Gbit/s'], + ]) { + await page.goto(`/netflow?source=${netflow.source.id}${filter}`); + await expect( + page.getByRole('button', { name: 'Inspect flow 1', exact: true }), + ).toBeVisible(); + await page + .getByText('Network traffic', { exact: true }) + .scrollIntoViewIfNeeded(); + await page.evaluate(() => document.fonts.ready); + const labels = page + .locator('text.recharts-cartesian-axis-tick-value') + .filter({ hasText: unit }); + await expect(labels.first()).toBeVisible(); + await expect + .poll(() => + labels.evaluateAll(nodes => + nodes.every(node => { + const bounds = node.getBoundingClientRect(); + const viewport = node.closest('svg')?.getBoundingClientRect(); + return ( + node.querySelectorAll('tspan').length === 1 && + viewport && + bounds.left >= viewport.left && + bounds.right <= viewport.right + ); + }), + ), + ) + .toBe(true); + } + } +}); diff --git a/packages/app/tests/e2e/features/netflow/source.spec.ts b/packages/app/tests/e2e/features/netflow/source.spec.ts new file mode 100644 index 0000000000..d730d289c3 --- /dev/null +++ b/packages/app/tests/e2e/features/netflow/source.spec.ts @@ -0,0 +1,54 @@ +import { expect, test } from '../../fixtures/netflow'; + +test('creates an inferred NetFlow source and opens its records in shared Search', async ({ + page, + netflow, +}) => { + await page.goto(`/netflow?source=${netflow.source.id}`); + await expect( + page.getByRole('button', { name: 'Inspect flow 1', exact: true }), + ).toBeVisible(); + const search = page.getByTestId('netflow-search'); + await search.fill('DstPo'); + await expect( + page + .getByTestId('autocomplete-suggestion') + .filter({ hasText: 'DstPort (number)' }), + ).toBeVisible(); + await search.press('Escape'); + await page + .getByRole('button', { name: 'Source actions', exact: true }) + .click(); + await page + .getByRole('menuitem', { name: 'Create new source', exact: true }) + .click(); + const form = page.getByRole('dialog', { name: 'Add NetFlow source' }); + const name = `${netflow.database} created`; + await form.locator('input[name=name]').fill(name); + await form + .getByPlaceholder('Database', { exact: true }) + .fill(netflow.database); + await page + .getByRole('option', { name: netflow.database, exact: true }) + .click(); + await form.getByPlaceholder('Table', { exact: true }).fill('flows'); + await page.getByRole('option', { name: 'flows', exact: true }).click(); + await expect(form.locator('.cm-content').first()).toHaveText('TimeReceived'); + await form + .getByRole('button', { name: 'Save New Source', exact: true }) + .click(); + await expect(form).toBeHidden(); + await expect( + page.getByRole('combobox', { name: 'NetFlow source', exact: true }), + ).toHaveValue(name); + await expect( + page.getByRole('button', { name: 'Inspect flow 1', exact: true }), + ).toBeVisible(); + await page.getByRole('link', { name: 'Search', exact: true }).click(); + await expect( + page + .getByRole('cell') + .filter({ hasText: /192\.0\.2/ }) + .first(), + ).toBeVisible(); +}); diff --git a/packages/app/tests/e2e/features/netflow/summary.spec.ts b/packages/app/tests/e2e/features/netflow/summary.spec.ts new file mode 100644 index 0000000000..47c6ac0e6c --- /dev/null +++ b/packages/app/tests/e2e/features/netflow/summary.spec.ts @@ -0,0 +1,29 @@ +import { NETFLOW_SUMMARY_TILES } from '../../../../src/netflow'; +import { formatNumber } from '../../../../src/utils'; +import { expect, test } from '../../fixtures/netflow'; + +test('four NetFlow overview tiles share one summary query and show distinct metrics', async ({ + page, + netflow, +}) => { + const requests: string[] = []; + page.on('request', request => { + const sql = request.postData() || ''; + if (request.method() === 'POST' && sql.includes('__netflow_bitsPerSecond')) + requests.push(sql); + }); + const [start, end] = netflow.dateRange; + await page.goto( + `/netflow?source=${netflow.source.id}&from=${start.getTime()}&to=${end.getTime()}`, + ); + const bytes = + 300 * (1000000 * 100 + 1000000000 + 1000000000 * 100 + 1000000000); + const packets = 600 * 1000 * 100 + 600 * 1000; + const expected = [(bytes * 8) / 3600, packets / 3600, bytes, 1200]; + await expect(page.getByTestId('number-chart-value')).toHaveText( + expected.map((value, index) => + formatNumber(value, NETFLOW_SUMMARY_TILES[index].numberFormat), + ), + ); + expect(requests).toHaveLength(1); +}); diff --git a/packages/app/tests/e2e/fixtures/netflow.ts b/packages/app/tests/e2e/fixtures/netflow.ts new file mode 100644 index 0000000000..dc0597d4d4 --- /dev/null +++ b/packages/app/tests/e2e/fixtures/netflow.ts @@ -0,0 +1,135 @@ +import { randomUUID } from 'crypto'; +import { + ClickhouseClient, + createNativeClient, +} from '@hyperdx/common-utils/dist/clickhouse/node'; +import { SourceKind, TNetflowSource } from '@hyperdx/common-utils/dist/types'; + +import { getApiUrl } from '../utils/api-helpers'; +import { expect, test as base } from '../utils/base-test'; + +const host = + process.env.CLICKHOUSE_HOST || + `http://localhost:${process.env.HDX_E2E_CH_PORT || '20500'}`; +const credentials = { + username: process.env.CLICKHOUSE_USER || 'default', + password: process.env.CLICKHOUSE_PASSWORD || '', +}; + +export const test = base.extend<{ + netflow: { + database: string; + source: TNetflowSource; + alternative: TNetflowSource; + dateRange: [Date, Date]; + client: ClickhouseClient; + }; +}>({ + netflow: async ({ request }, provide) => { + const database = `netflow_e2e_${randomUUID().replaceAll('-', '')}`; + const commands = createNativeClient({ url: host, ...credentials }); + const client = new ClickhouseClient({ host, ...credentials }); + const end = new Date(Math.floor(Date.now() / 60000) * 60000); + const start = new Date(end.getTime() - 60 * 60000); + let source: TNetflowSource = { + id: `${database}_flows`, + name: `${database} flows`, + kind: SourceKind.Netflow, + connection: 'netflow-test', + from: { databaseName: database, tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: '*', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + samplingRateExpression: 'SamplingRate', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + exporterExpression: 'ExporterName', + inIfExpression: 'InIfName', + outIfExpression: 'OutIfName', + }; + let alternative = { + ...source, + id: `${database}_other`, + name: `${database} other`, + }; + const createdSources: string[] = []; + try { + await commands.command({ query: `CREATE DATABASE ${database}` }); + await commands.command({ + query: `CREATE TABLE ${database}.flows ( + TimeReceived DateTime('UTC'), Bytes UInt64, Packets UInt64, SamplingRate UInt64, + SrcAddr IPv6, DstAddr IPv6, SrcPort UInt16, DstPort UInt16, Proto UInt8, + ExporterName String, InIfName String, OutIfName String, + SrcAS UInt32, InIfConnectivity String, InIfProvider String, + "provider-name" Nullable(String) + ) ENGINE = MergeTree ORDER BY TimeReceived`, + }); + // Four paths repeat across an hour, including sampled IPv4, IPv6 and an empty classification. + await commands.command({ + query: `INSERT INTO ${database}.flows SELECT + toDateTime(${end.getTime() / 1000}) - 3300 + number * 2, + if(number % 4 = 0, 1000000, 1000000000), 1000, if(number % 2 = 0, 100, 1), + toIPv6(['192.0.2.2','192.0.2.3','2001:db8:1::2','2001:db8:1::3'][number % 4 + 1]), + toIPv6('198.51.100.1'), 12345, if(number % 2 = 0, 443, 53), + if(number % 2 = 0, 6, 17), if(number % 2 = 0, 'edge-a', 'edge-b'), + 'eth0', 'eth1', 64500 + number % 4, 'transit', + ['peer-east','peer-west','',''][number % 4 + 1], + if(number % 4 = 2, NULL, 'provider') + FROM numbers(1200)`, + }); + if (process.env.E2E_FULLSTACK === 'true') { + const connections = await request.get(`${getApiUrl()}/connections`); + expect(connections.ok()).toBeTruthy(); + const [connection] = await connections.json(); + const create = async ( + input: TNetflowSource, + ): Promise => { + const response = await request.post(`${getApiUrl()}/sources`, { + data: { ...input, connection: connection.id }, + }); + expect(response.ok(), await response.text()).toBeTruthy(); + const saved = await response.json(); + const id = saved.id || saved._id; + createdSources.push(id); + return { ...input, id, connection: connection.id }; + }; + source = await create(source); + alternative = await create(alternative); + } + await provide({ + database, + source, + alternative, + dateRange: [start, end], + client, + }); + } finally { + if (process.env.E2E_FULLSTACK === 'true') { + const response = await request.get(`${getApiUrl()}/sources`); + const sources: TNetflowSource[] = await response.json(); + for (const saved of sources.filter( + item => item.from.databaseName === database, + )) { + if (!createdSources.includes(saved.id)) createdSources.push(saved.id); + } + } + for (const id of createdSources) + await request.delete(`${getApiUrl()}/sources/${id}`); + await commands.command({ query: `DROP DATABASE IF EXISTS ${database}` }); + await client.close(); + await commands.close(); + } + }, + localSources: async ({ netflow }, provide) => + provide([netflow.source, netflow.alternative]), + localConnections: async ({}, provide) => + provide([ + { id: 'netflow-test', name: 'NetFlow test', host, ...credentials }, + ]), +}); + +export { expect }; diff --git a/packages/app/tests/e2e/utils/base-test.ts b/packages/app/tests/e2e/utils/base-test.ts index 3537b976f8..8a199340df 100644 --- a/packages/app/tests/e2e/utils/base-test.ts +++ b/packages/app/tests/e2e/utils/base-test.ts @@ -23,8 +23,13 @@ function loadE2EFixtures(): { connections: unknown[]; sources: unknown[] } { const e2eFixtures = loadE2EFixtures(); // Extend the base test to automatically handle Tanstack devtools -export const test = base.extend({ - page: async ({ page }, fn) => { +export const test = base.extend<{ + localSources: unknown[]; + localConnections: unknown[]; +}>({ + localSources: [e2eFixtures.sources, { option: true }], + localConnections: [e2eFixtures.connections, { option: true }], + page: async ({ page, localSources, localConnections }, fn) => { // Note: page.addInitScript runs in the browser context, which cannot access Node.js // environment variables directly. We pass USE_FULLSTACK and connection/sources from // e2e-fixtures.json so local mode uses the same data as full-stack. @@ -48,11 +53,7 @@ export const test = base.extend({ JSON.stringify(true), ); }, - [ - e2eFixtures.connections, - e2eFixtures.sources, - VIEW_TRACE_CALLOUT_DISMISSED_KEY, - ], + [localConnections, localSources, VIEW_TRACE_CALLOUT_DISMISSED_KEY], ); await fn(page); }, diff --git a/packages/common-utils/src/__tests__/netflowSource.test.ts b/packages/common-utils/src/__tests__/netflowSource.test.ts index 45de7ad0e2..0a778d9c73 100644 --- a/packages/common-utils/src/__tests__/netflowSource.test.ts +++ b/packages/common-utils/src/__tests__/netflowSource.test.ts @@ -70,3 +70,43 @@ it('searches raw flows with mapped defaults without log-only fields or sampling expect(config).not.toHaveProperty('bodyExpression'); expect(config).not.toHaveProperty('sampleWeightExpression'); }); + +it('searches mapped NetFlow dimensions for bare Lucene terms by default', () => { + const config = buildSearchChartConfig(SourceSchema.parse(source), { + where: 'edge-router', + whereLanguage: 'lucene', + }); + expect(config.implicitColumnExpression).toContain('toString(SrcAddr)'); + expect(config.implicitColumnExpression).toContain('toString(ExporterName)'); + expect(config.implicitColumnExpression).toContain('toString(OutIfName)'); +}); + +it('preserves and uses a custom NetFlow implicit expression', () => { + const parsed = SourceSchema.parse({ + ...source, + implicitColumnExpression: 'SearchText', + }); + expect(parsed).toHaveProperty('implicitColumnExpression', 'SearchText'); + expect( + buildSearchChartConfig(parsed, { where: 'router' }) + .implicitColumnExpression, + ).toBe('SearchText'); +}); + +it('derives implicit search from custom mappings and ignores blank optional dimensions', () => { + const parsed = SourceSchema.parse({ + ...source, + srcAddrExpression: 'client_ip', + implicitColumnExpression: ' ', + exporterExpression: ' ', + inIfExpression: undefined, + outIfExpression: '', + }); + const implicit = buildSearchChartConfig(parsed, { + where: 'router', + }).implicitColumnExpression; + expect(implicit).toContain('toString(client_ip)'); + expect(implicit).not.toMatch( + /SrcAddr|ExporterName|InIfName|OutIfName|toString\(\s*\)/, + ); +}); diff --git a/packages/common-utils/src/clickhouse/browser.ts b/packages/common-utils/src/clickhouse/browser.ts index 77c69f5e9a..1a4fe60568 100644 --- a/packages/common-utils/src/clickhouse/browser.ts +++ b/packages/common-utils/src/clickhouse/browser.ts @@ -118,13 +118,16 @@ export class ClickhouseClient extends BaseClickhouseClient { return super.getClient() as WebClickHouseClient; } + private get isLocalMode() { + return this.username != null && this.password != null; + } + private buildClient() { let url = this.host!; let myFetch: typeof fetch; - const isLocalMode = this.username != null && this.password != null; const clickhouseSettings: ClickHouseSettings = {}; - if (isLocalMode) { + if (this.isLocalMode) { myFetch = localModeFetch; clickhouseSettings.add_http_cors_header = 1; } else { @@ -184,10 +187,9 @@ export class ClickhouseClient extends BaseClickhouseClient { clickhouseSettings = neutralSettings as ClickHouseSettings; } - const isLocalMode = this.username != null && this.password != null; const httpHeaders: { [header: string]: string } = { // Only the API proxy uses this header; direct requests must avoid a CORS preflight. - ...(!isLocalMode && connectionId && connectionId !== 'local' + ...(!this.isLocalMode && connectionId && connectionId !== 'local' ? { 'x-hyperdx-connection-id': connectionId } : {}), }; diff --git a/packages/common-utils/src/core/searchChartConfig.ts b/packages/common-utils/src/core/searchChartConfig.ts index 9078a39e63..fa79a56ee0 100644 --- a/packages/common-utils/src/core/searchChartConfig.ts +++ b/packages/common-utils/src/core/searchChartConfig.ts @@ -12,6 +12,7 @@ import { SelectList, SortSpecificationList, SQLInterval, + TNetflowSource, TSource, } from '@/types'; @@ -29,6 +30,28 @@ import { */ export type SearchChartConfig = BuilderChartConfig & Partial; +/** Bare terms search mapped dimensions even on sources saved before this option existed. */ +export function getNetflowImplicitColumnExpression( + source: TNetflowSource, +): string { + if (source.implicitColumnExpression?.trim()) { + return source.implicitColumnExpression.trim(); + } + const expressions = [ + source.srcAddrExpression, + source.dstAddrExpression, + source.srcPortExpression, + source.dstPortExpression, + source.protocolExpression, + source.exporterExpression, + source.inIfExpression, + source.outIfExpression, + ] + .map(expression => expression?.trim()) + .filter(Boolean); + return `concatWithSeparator(' ', ${expressions.map(expression => `ifNull(toString(${expression}), '')`).join(', ')})`; +} + /** * Default SELECT used by alert evaluators when no caller-supplied SELECT * is provided: a single `count()` aggregate. @@ -85,7 +108,7 @@ export type SearchChartConfigInput = { /** * Resolve the SELECT list, preferring caller-provided `select`, then the - * source's `defaultTableSelectExpression` (for Log / Trace sources), falling + * source's `defaultTableSelectExpression` (for searchable sources), falling * back to an empty string. * * Both `string` and `DerivedColumn[]` SELECT shapes have a `.length` property, @@ -139,8 +162,9 @@ export function buildSearchChartConfig( const userFilters: Filter[] = input.filters ?? []; const mergedFilters: Filter[] = [...tableFilter, ...userFilters]; - const implicitColumnExpression = - isLogSource(source) || isTraceSource(source) + const implicitColumnExpression = isNetflowSource(source) + ? getNetflowImplicitColumnExpression(source) + : isLogSource(source) || isTraceSource(source) ? source.implicitColumnExpression : undefined; const useTextIndexForImplicitColumn = diff --git a/packages/common-utils/src/types.ts b/packages/common-utils/src/types.ts index 0ee66ddaac..36cb9c78b4 100644 --- a/packages/common-utils/src/types.ts +++ b/packages/common-utils/src/types.ts @@ -2612,9 +2612,24 @@ export const PromqlSourceSchema = BaseSourceSchema.extend({ }); // NetFlow mappings support Akvorado and other flow tables without reingestion. +export const NETFLOW_COLUMN_EXPRESSIONS = { + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + samplingRateExpression: 'SamplingRate', + exporterExpression: 'ExporterName', + inIfExpression: 'InIfName', + outIfExpression: 'OutIfName', +} as const; + export const NetflowSourceSchema = BaseSourceSchema.extend({ kind: z.literal(SourceKind.Netflow), defaultTableSelectExpression: z.string().min(1), + implicitColumnExpression: z.string().optional(), bytesExpression: z.string().min(1), packetsExpression: z.string().min(1), srcAddrExpression: z.string().min(1), diff --git a/scripts/netflow-axis-browser-check.mjs b/scripts/netflow-axis-browser-check.mjs deleted file mode 100644 index bb17f3b086..0000000000 --- a/scripts/netflow-axis-browser-check.mjs +++ /dev/null @@ -1,44 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdir } from 'node:fs/promises'; -import { chromium, expect } from '@playwright/test'; - -const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3000'; -const artifacts = new URL('../packages/app/test-results/netflow/', import.meta.url); -await mkdir(artifacts, { recursive: true }); -const browser = await chromium.launch(); -const page = await browser.newPage(); -const errors = []; -page.on('pageerror', error => errors.push(error.message)); - -try { - for (const width of [1280, 800]) { - await page.setViewportSize({ width, height: 1000 }); - for (const [query, unit] of [['?srcAddr=192.0.2.2', 'Mbit/s'], ['', 'Gbit/s']]) { - await page.goto(`${baseUrl}/netflow${query}`); - await expect(page.getByRole('button', { name: 'Inspect flow 1', exact: true })).toBeVisible({ timeout: 60000 }); - if (width === 1280 && unit === 'Mbit/s') { - const devtools = page.getByRole('button', { name: 'Close tanstack query devtools', exact: true }); - const opened = await devtools.waitFor({ state: 'visible', timeout: 10000 }).then(() => true, () => false); - if (opened) { - await devtools.click(); - await expect(devtools).toBeHidden(); - } - } - await page.getByText('Network traffic', { exact: true }).evaluate(node => node.scrollIntoView({ block: 'center' })); - await page.evaluate(() => document.fonts.ready); - const labels = page.locator('text.recharts-cartesian-axis-tick-value').filter({ hasText: unit }); - await expect(labels.first()).toBeVisible(); - await expect.poll(async () => labels.evaluateAll(nodes => nodes.every(node => { - const bounds = node.getBoundingClientRect(); - const viewport = node.ownerSVGElement?.getBoundingClientRect(); - return node.querySelectorAll('tspan').length === 1 && viewport && - bounds.left >= viewport.left && bounds.right <= viewport.right; - }))).toBe(true); - await page.screenshot({ path: new URL(`axis-${unit.startsWith('M') ? 'mbit' : 'gbit'}-${width}.png`, artifacts).pathname }); - } - } - assert.deepEqual(errors, [], 'No browser runtime errors'); - console.log('NetFlow axis checks passed: Mbit/s and Gbit/s labels fit on one line at 1280px and 800px.'); -} finally { - await browser.close(); -} diff --git a/scripts/netflow-browser-check.mjs b/scripts/netflow-browser-check.mjs deleted file mode 100644 index a15c69baa8..0000000000 --- a/scripts/netflow-browser-check.mjs +++ /dev/null @@ -1,257 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdir } from 'node:fs/promises'; -import { chromium, expect } from '@playwright/test'; - -const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3000'; -const artifacts = new URL( - '../packages/app/test-results/netflow/', - import.meta.url, -); -await mkdir(artifacts, { recursive: true }); -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); -const errors = []; -page.on('pageerror', error => errors.push(error.message)); - -try { - for (const [from, to] of [[1791565200000, 1791565200000], [1791565200000, 1791561600000]]) { - await page.goto(`${baseUrl}/netflow?from=${from}&to=${to}`); - await expect(page.getByText('Invalid time range', { exact: true })).toBeVisible(); - await expect(page.getByTestId('netflow-records')).toHaveCount(0); - await page.getByRole('button', { name: 'Use past hour', exact: true }).click(); - await expect(page.getByRole('button', { name: 'Inspect flow 1', exact: true })).toBeVisible({ timeout: 60000 }); - } - await page.goto(`${baseUrl}/netflow`, { waitUntil: 'domcontentloaded' }); - const records = page.getByTestId('netflow-records'); - const firstFlow = () => records.locator('tbody tr').first(); - const pills = page.getByTestId('netflow-filter-pills'); - const addProtocolFilter = async () => { - await firstFlow().getByRole('button', { name: /^Filter protocol:/ }).click(); - await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); - await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(1); - }; - await expect( - page.getByRole('button', { name: 'Inspect flow 1', exact: true }), - ).toBeVisible({ timeout: 60000 }); - const devtools = page.getByRole('button', { - name: 'Close tanstack query devtools', - exact: true, - }); - await devtools.waitFor({ state: 'visible', timeout: 10000 }).catch(() => {}); - if (await devtools.isVisible()) { - await devtools.click(); - await expect(devtools).toBeHidden(); - } - await expect( - page.getByText( - 'Error loading chart, please check your query or try again later.', - ), - ).toHaveCount(0); - await expect(records.locator('tbody tr')).toHaveCount(500); - await page.screenshot({ path: new URL('overview.png', artifacts).pathname }); - - const search = page.getByTestId('netflow-search'); - await search.click(); - await expect( - page.getByTestId('autocomplete-suggestion').first(), - ).toBeVisible(); - await search.fill('Src'); - const sourceColumn = page - .getByTestId('autocomplete-suggestion') - .filter({ hasText: 'SrcAddr (string)' }); - await expect(sourceColumn).toBeVisible(); - await sourceColumn.click(); - await expect(search).toHaveValue('SrcAddr'); - await search.fill('DstPo'); - await expect( - page - .getByTestId('autocomplete-suggestion') - .filter({ hasText: 'DstPort (number)' }), - ).toBeVisible(); - await search.press('ArrowDown'); - await search.press('Tab'); - await expect(search).toHaveValue('DstPort'); - await search.fill('Proto:'); - const protocolValue = page - .getByTestId('autocomplete-suggestion') - .filter({ hasText: 'Proto:"6"' }); - await expect(protocolValue).toBeVisible(); - await protocolValue.click(); - await expect(search).toHaveValue('Proto:"6"'); - const lucene = 'Proto:6 AND DstPort:443'; - await expect( - page.getByRole('combobox', { name: 'Query language', exact: true }), - ).toHaveValue('Lucene'); - await search.fill(lucene); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect - .poll(() => new URL(page.url()).searchParams.get('where')) - .toBe(lucene); - await expect(firstFlow().locator('td').nth(3)).toHaveText('TCP'); - await expect(firstFlow().locator('td').nth(2)).toContainText('443'); - await expect(records.getByText('UDP', { exact: true })).toHaveCount(0); - await page.reload(); - await expect(search).toHaveValue(lucene); - await expect(firstFlow().locator('td').nth(2)).toContainText('443'); - await page.getByLabel('Protocol', { exact: true }).fill('17'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect(records.getByText('No flow records found')).toBeVisible(); - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect(search).toHaveValue(''); - await expect(records.locator('tbody tr')).toHaveCount(500); - await search.fill('Proto:17'); - await search.press('Escape'); - await search.press('Enter'); - await expect - .poll(() => new URL(page.url()).searchParams.get('where')) - .toBe('Proto:17'); - await expect(firstFlow().locator('td').nth(3)).toHaveText('UDP'); - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect(search).toHaveValue(''); - - await page.getByLabel('Protocol', { exact: true }).fill('6'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect(page).toHaveURL(/protocol=6/); - await expect(firstFlow().locator('td').nth(3)).toHaveText('TCP'); - await expect(records.getByText('UDP', { exact: true })).toHaveCount(0); - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect(page).not.toHaveURL(/(?:protocol|srcAddr|exporter)=[^&]+/); - await page.getByLabel('Source IP', { exact: true }).fill('192.0.2.2'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect(page).toHaveURL(/srcAddr=192.0.2.2/); - await expect(firstFlow().locator('td').nth(1)).toContainText('192.0.2.2'); - - await page - .getByRole('button', { name: 'Inspect flow 1', exact: true }) - .click(); - const drawer = page.getByRole('dialog', { name: 'Flow details' }); - await expect(drawer).toBeVisible(); - const field = async label => - Number( - await drawer - .getByRole('row') - .filter({ - has: page.getByRole('rowheader', { name: label, exact: true }), - }) - .getByRole('cell') - .innerText(), - ); - assert.equal( - await field('Bytes'), - (await field('Raw bytes')) * (await field('Sampling rate')), - ); - assert.equal( - await field('Packets'), - (await field('Raw packets')) * (await field('Sampling rate')), - ); - await page.screenshot({ - path: new URL('flow-details.png', artifacts).pathname, - animations: 'disabled', - }); - await page.keyboard.press('Escape'); - await expect(page.getByRole('dialog')).toBeHidden(); - - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect(page).not.toHaveURL(/(?:protocol|srcAddr|exporter)=[^&]+/); - await page.getByLabel('Exporter', { exact: true }).fill("missing' OR 1=1 --"); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect(records.getByText('No flow records found')).toBeVisible(); - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect(page).not.toHaveURL(/(?:protocol|srcAddr|exporter)=[^&]+/); - await expect( - page.getByRole('button', { name: 'Inspect flow 1', exact: true }), - ).toBeVisible(); - await page - .getByRole('button', { name: 'Refresh NetFlow', exact: true }) - .click(); - await expect( - page.getByRole('button', { name: 'Refresh NetFlow', exact: true }), - ).toBeEnabled(); - - await page - .getByRole('button', { name: 'Source actions', exact: true }) - .click(); - await page - .getByRole('menuitem', { name: 'Edit source', exact: true }) - .click(); - await expect( - page.getByRole('dialog', { name: 'Edit NetFlow source' }), - ).toBeVisible(); - await expect( - page - .getByRole('dialog') - .getByText('Sampling multiplier (optional)', { exact: true }), - ).toBeVisible(); - await page.keyboard.press('Escape'); - await expect(page.getByRole('dialog')).toBeHidden(); - - await addProtocolFilter(); - await page - .getByRole('button', { name: 'Source actions', exact: true }) - .click(); - await page - .getByRole('menuitem', { name: 'Create new source', exact: true }) - .click(); - const sourceForm = page.getByRole('dialog', { name: 'Add NetFlow source' }); - await sourceForm.locator('input[name=name]').fill('NetFlow browser test'); - await sourceForm - .getByPlaceholder('Database', { exact: true }) - .fill('netflow_demo'); - await page.getByRole('option', { name: 'netflow_demo', exact: true }).click(); - await sourceForm.getByPlaceholder('Table', { exact: true }).fill('flows'); - await page.getByRole('option', { name: 'flows', exact: true }).click(); - await expect(sourceForm.locator('.cm-content').first()).toHaveText( - 'TimeReceived', - ); - await sourceForm - .getByRole('button', { name: 'Save New Source', exact: true }) - .click(); - await expect(sourceForm).toBeHidden(); - await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(0); - await addProtocolFilter(); - const namedSourceUrl = new URL(page.url()); - namedSourceUrl.searchParams.set('source', 'NetFlow browser test'); - await page.goto(namedSourceUrl.href); - await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(1); - // Source changes apply before Run, including the edit target and URL. - await page.getByRole('combobox', { name: 'NetFlow source', exact: true }).click(); - await page.getByRole('option', { name: 'NetFlow demo', exact: true }).click(); - await expect.poll(() => new URL(page.url()).searchParams.get('source')).toBe('netflow-demo'); - await expect(pills.getByRole('button', { name: 'Remove filter' })).toHaveCount(0); - await expect.poll(() => JSON.parse(decodeURIComponent(new URL(page.url()).searchParams.get('filters') || '[]'))).toEqual([]); - await page.getByRole('button', { name: 'Source actions', exact: true }).click(); - await page.getByRole('menuitem', { name: 'Edit source', exact: true }).click(); - await expect(page.getByRole('dialog').locator('input[name=name]')).toHaveValue('NetFlow demo'); - await page.keyboard.press('Escape'); - await page.reload(); - await expect( - page.getByRole('button', { name: 'Inspect flow 1', exact: true }), - ).toBeVisible(); - await page.getByRole('link', { name: 'Search', exact: true }).click(); - await expect( - page - .getByRole('cell') - .filter({ hasText: /192\.0\.2/ }) - .first(), - ).toBeVisible({ timeout: 30000 }); - - await page.goto(`${baseUrl}/netflow?source=missing-source`); - await expect(page.getByText('NetFlow source unavailable')).toBeVisible(); - assert.deepEqual(errors, [], 'No browser runtime errors'); - console.log( - 'NetFlow browser checks passed: data, filters, sampling, details, refresh, source creation/reload/switching, Search, unavailable source, invalid time range recovery.', - ); - console.log(`Screenshots: ${artifacts.pathname}`); -} finally { - await browser.close(); -} diff --git a/scripts/netflow-click-browser-check.mjs b/scripts/netflow-click-browser-check.mjs deleted file mode 100644 index dac76bcda9..0000000000 --- a/scripts/netflow-click-browser-check.mjs +++ /dev/null @@ -1,161 +0,0 @@ -import assert from 'node:assert/strict'; -import { chromium, expect } from '@playwright/test'; - -const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3000'; -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1600, height: 1050 } }); -page.setDefaultTimeout(15000); -const errors = []; -page.on('pageerror', error => errors.push(error.message)); -const records = page.getByTestId('netflow-records'); -const pills = page.getByTestId('netflow-filter-pills'); -const clear = async () => { - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect( - pills.getByRole('button', { name: 'Remove filter' }), - ).toHaveCount(0); - await expect(records.locator('tbody tr')).toHaveCount(500); -}; -const choose = async (target, action) => { - await target.click(); - await page.getByRole('menuitem', { name: action, exact: true }).click(); -}; -try { - await page.goto(`${baseUrl}/netflow`); - await expect(records.locator('tbody tr')).toHaveCount(500, { - timeout: 60000, - }); - const devtools = page.getByRole('button', { - name: 'Close tanstack query devtools', - exact: true, - }); - if (await devtools.isVisible()) await devtools.click(); - const draftSearch = page.getByTestId('netflow-search'); - const draftProtocol = page.getByLabel('Protocol', { exact: true }); - await draftSearch.fill('Proto:17'); - await draftProtocol.fill('17'); - await choose( - page.getByTestId('netflow-breakdown-protocol').getByRole('button', { - name: 'Filter protocol: TCP', - exact: true, - }), - 'Include', - ); - await expect(pills).toContainText('Protocol = TCP'); - await expect - .poll(() => new URL(page.url()).searchParams.get('where') || '') - .toBe(''); - await expect - .poll(() => new URL(page.url()).searchParams.get('protocol') || '') - .toBe(''); - await expect(draftSearch).toHaveValue('Proto:17'); - await expect(draftProtocol).toHaveValue('17'); - await expect( - records.getByRole('button', { name: 'Filter protocol: UDP', exact: true }), - ).toHaveCount(0); - await expect(records.locator('tbody tr').first()).toBeVisible(); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect - .poll(() => new URL(page.url()).searchParams.get('where')) - .toBe('Proto:17'); - await expect - .poll(() => new URL(page.url()).searchParams.get('protocol')) - .toBe('17'); - await expect(records.getByText('No flow records found')).toBeVisible(); - await clear(); - for (const field of ['srcAddr', 'dstAddr', 'protocol', 'exporter']) { - console.log(`Checking ${field} chart filters`); - const chart = page.getByTestId(`netflow-breakdown-${field}`); - const first = chart - .getByRole('button', { name: new RegExp(`^Filter ${field}:`) }) - .first(); - const value = await first.innerText(); - const matchingRows = records.getByRole('button', { - name: `Filter ${field}: ${value}`, - exact: true, - }); - await choose(first, 'Include'); - await expect(pills).toContainText(`= ${value}`); - await expect - .poll(async () => { - const matches = await matchingRows.count(); - return ( - matches > 0 && matches === (await records.locator('tbody tr').count()) - ); - }) - .toBe(true); - await expect(matchingRows.first()).toBeVisible(); - assert.equal( - await matchingRows.count(), - await records.locator('tbody tr').count(), - ); - await page.reload(); - await expect(pills).toContainText(`= ${value}`); - await expect(matchingRows.first()).toBeVisible(); - await pills.getByRole('button', { name: 'Remove filter' }).click(); - await expect(records.locator('tbody tr')).toHaveCount(500); - await choose( - chart.getByRole('button', { - name: `Filter ${field}: ${value}`, - exact: true, - }), - 'Exclude', - ); - await expect(pills).toContainText(`!= ${value}`); - await expect(matchingRows).toHaveCount(0); - await expect(records.locator('tbody tr')).toHaveCount(500); - await clear(); - } - // Row actions preserve the Lucene query and apply immediately without Run. - const search = page.getByTestId('netflow-search'); - await search.fill('DstPort:443 OR DstPort:80'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await choose( - records - .getByRole('button', { name: 'Filter protocol: TCP', exact: true }) - .first(), - 'Include', - ); - await expect(search).toHaveValue('DstPort:443 OR DstPort:80'); - await expect(pills).toContainText('Protocol = TCP'); - await expect( - records.getByRole('button', { name: 'Filter protocol: UDP', exact: true }), - ).toHaveCount(0); - await clear(); - // Native IPv6 address and drawer actions. - const ipv6 = records - .getByRole('button', { name: /^Filter srcAddr: 2001:/ }) - .first(); - const ipv6Value = await ipv6.innerText(); - await choose(ipv6, 'Include'); - await expect(pills).toContainText(ipv6Value); - await expect( - records - .getByRole('button', { - name: `Filter srcAddr: ${ipv6Value}`, - exact: true, - }) - .first(), - ).toBeVisible(); - await page - .getByRole('button', { name: 'Inspect flow 1', exact: true }) - .click(); - const drawer = page.getByRole('dialog', { name: 'Flow details' }); - await choose( - drawer.getByRole('button', { name: /^Filter exporter:/ }), - 'Exclude', - ); - await expect( - pills.getByRole('button', { name: 'Remove filter' }), - ).toHaveCount(2); - await expect(drawer).toBeHidden(); - await clear(); - assert.deepEqual(errors, []); - console.log( - 'Clickable NetFlow filters passed: pending search/quick-filter drafts, chart include/exclude, removal, reload, Lucene composition, row/drawer actions, IPv6, clear.', - ); -} finally { - await browser.close(); -} diff --git a/scripts/netflow-click-query-check.ts b/scripts/netflow-click-query-check.ts deleted file mode 100644 index 7d35b5ec65..0000000000 --- a/scripts/netflow-click-query-check.ts +++ /dev/null @@ -1,218 +0,0 @@ -import assert from 'node:assert/strict'; -import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; -import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; -import { - FilterState, - filtersToQuery, - parseQuery, -} from '@hyperdx/common-utils/dist/filters'; -import { - SearchConditionLanguage, - SourceKind, - TNetflowSource, -} from '@hyperdx/common-utils/dist/types'; - -import { - buildNetflowQueryConfigs, - getNetflowDimensions, -} from '../packages/app/src/netflow'; - -async function main() { - const client = new ClickhouseClient({ - host: process.env.CLICKHOUSE_URL || 'http://127.0.0.1:8123', - username: process.env.CLICKHOUSE_USER || 'default', - password: process.env.CLICKHOUSE_PASSWORD || '', - attribution: { surface: 'dashboard', label: 'netflow-click-verification' }, - }); - const source: TNetflowSource = { - id: 'netflow-demo', - kind: SourceKind.Netflow, - name: 'NetFlow demo', - connection: 'netflow-local', - from: { databaseName: 'netflow_demo', tableName: 'flows' }, - timestampValueExpression: 'TimeReceived', - defaultTableSelectExpression: '*', - bytesExpression: 'Bytes', - packetsExpression: 'Packets', - samplingRateExpression: 'SamplingRate', - srcAddrExpression: 'SrcAddr', - dstAddrExpression: 'DstAddr', - srcPortExpression: 'SrcPort', - dstPortExpression: 'DstPort', - protocolExpression: 'Proto', - exporterExpression: 'ExporterName', - inIfExpression: 'InIfName', - outIfExpression: 'OutIfName', - }; - try { - const bounds = await ( - await client.query({ - query: - 'SELECT toUnixTimestamp(min(TimeReceived)) AS start, toUnixTimestamp(max(TimeReceived)) + 1 AS end, count() AS records FROM netflow_demo.flows', - format: 'JSONEachRow', - }) - ).json<{ start: number; end: number; records: string }>(); - assert.ok( - Number(bounds[0].records) > 0, - 'Seed NetFlow before verification', - ); - const dateRange: [Date, Date] = [ - new Date(bounds[0].start * 1000), - new Date(bounds[0].end * 1000), - ]; - const metadata = getMetadata(client); - const dimensions = getNetflowDimensions(source); - - async function verify({ - label, - state, - predicate, - mappedSource = source, - where = 'Bytes:[1000 TO *]', - whereLanguage = 'lucene', - expectedWhere = 'Bytes >= 1000', - expectMatches = true, - }: { - label: string; - state: FilterState; - predicate: string; - mappedSource?: TNetflowSource; - where?: string; - whereLanguage?: SearchConditionLanguage; - expectedWhere?: string; - expectMatches?: boolean; - }) { - const serialized = filtersToQuery(state); - const restored = parseQuery( - JSON.parse(JSON.stringify(serialized)), - ).filters; - assert.deepEqual(restored, state, `${label}: URL filter round trip`); - const configs = buildNetflowQueryConfigs({ - source: mappedSource, - dateRange, - filters: {}, - where, - whereLanguage, - extraFilters: filtersToQuery(restored), - }); - assert.equal( - configs.flowRecords.where, - where, - 'Search text must be retained', - ); - const expected = await ( - await client.query({ - query: `SELECT count() AS records, sum(Bytes * SamplingRate) AS bytes - FROM netflow_demo.flows WHERE (${expectedWhere}) AND (${predicate})`, - format: 'JSONEachRow', - }) - ).json<{ records: string; bytes: string }>(); - assert.equal(Number(expected[0].records) > 0, expectMatches, label); - for (const [config, value] of [ - [configs.flowRecords, expected[0].records], - [configs.totalBytes, expected[0].bytes], - ] as const) { - const result = await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }); - assert.equal( - Number(result.data[0].__netflow_value), - Number(value), - label, - ); - } - console.log( - `${label}: ${expected[0].records} records, sampled bytes match SQL`, - ); - } - - for (const [field, value, predicate] of [ - ['srcAddr', '192.0.2.2', "SrcAddr = toIPv6('192.0.2.2')"], - ['dstAddr', '203.0.113.2', "DstAddr = toIPv6('203.0.113.2')"], - ['protocol', 'TCP', 'Proto = 6'], - ['exporter', 'edge-fra-01', "ExporterName = 'edge-fra-01'"], - ['inputInterface', 'ae1', "InIfName = 'ae1'"], - ['outputInterface', 'ae1', "OutIfName = 'ae1'"], - ['srcAddr', '2001:db8:1::2', "SrcAddr = toIPv6('2001:db8:1::2')"], - ] as const) { - const expression = dimensions[field]; - assert.ok(expression); - for (const action of ['include', 'exclude'] as const) { - await verify({ - label: `${field} ${action} ${value}`, - state: { - [expression]: { - included: new Set(action === 'include' ? [value] : []), - excluded: new Set(action === 'exclude' ? [value] : []), - }, - }, - predicate: action === 'exclude' ? `NOT (${predicate})` : predicate, - }); - } - } - await verify({ - label: 'Multiple includes OR together, exclusion ANDs with them', - state: { - [dimensions.protocol]: { - included: new Set(['TCP', 'UDP']), - excluded: new Set(['UDP']), - }, - }, - predicate: 'Proto IN (6, 17) AND Proto != 17', - }); - await verify({ - label: 'Click filter retains SQL search', - state: { - [dimensions.protocol]: { - included: new Set(['TCP']), - excluded: new Set(), - }, - }, - predicate: 'Proto = 6', - where: 'Bytes >= 1000 AND DstPort = 443', - whereLanguage: 'sql', - expectedWhere: 'Bytes >= 1000 AND DstPort = 443', - }); - const escaped = "edge\\' OR 1=1 --"; - assert.ok(dimensions.exporter); - for (const action of ['include', 'exclude'] as const) { - await verify({ - label: `Quote/backslash ${action} remains a literal`, - state: { - [dimensions.exporter]: { - included: new Set(action === 'include' ? [escaped] : []), - excluded: new Set(action === 'exclude' ? [escaped] : []), - }, - }, - predicate: action === 'include' ? '0' : '1', - expectMatches: action === 'exclude', - }); - } - const mappedSource = { - ...source, - exporterExpression: "concat(ExporterSite, ':', ExporterName)", - }; - const mappedDimensions = getNetflowDimensions(mappedSource); - assert.ok(mappedDimensions.exporter); - await verify({ - label: 'Custom exporter expression uses displayed value', - mappedSource, - state: { - [mappedDimensions.exporter]: { - included: new Set(['sfo:edge-sfo-01']), - excluded: new Set(), - }, - }, - predicate: "ExporterSite = 'sfo' AND ExporterName = 'edge-sfo-01'", - }); - } finally { - await client.close(); - } -} - -main().catch(error => { - console.error(error); - process.exitCode = 1; -}); diff --git a/scripts/netflow-query-check.ts b/scripts/netflow-query-check.ts deleted file mode 100644 index 8cb3fb0e8e..0000000000 --- a/scripts/netflow-query-check.ts +++ /dev/null @@ -1,274 +0,0 @@ -import assert from 'node:assert/strict'; -import { createClient } from '@clickhouse/client'; -import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; -import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; -import { SourceKind, TNetflowSource } from '@hyperdx/common-utils/dist/types'; - -import { buildNetflowQueryConfigs } from '../packages/app/src/netflow'; - -async function main() { - const commands = createClient({ - url: process.env.CLICKHOUSE_URL || 'http://127.0.0.1:8123', - username: process.env.CLICKHOUSE_USER || 'default', - password: process.env.CLICKHOUSE_PASSWORD || '', - }); - const client = new ClickhouseClient({ - host: process.env.CLICKHOUSE_URL || 'http://127.0.0.1:8123', - username: process.env.CLICKHOUSE_USER || 'default', - password: process.env.CLICKHOUSE_PASSWORD || '', - attribution: { surface: 'dashboard', label: 'netflow-verification' }, - }); - const source: TNetflowSource = { - id: 'netflow-demo', - kind: SourceKind.Netflow, - name: 'NetFlow demo', - connection: 'netflow-local', - from: { databaseName: 'netflow_demo', tableName: 'flows' }, - timestampValueExpression: 'TimeReceived', - defaultTableSelectExpression: '*', - bytesExpression: 'Bytes', - packetsExpression: 'Packets', - samplingRateExpression: 'SamplingRate', - srcAddrExpression: 'SrcAddr', - dstAddrExpression: 'DstAddr', - srcPortExpression: 'SrcPort', - dstPortExpression: 'DstPort', - protocolExpression: 'Proto', - exporterExpression: 'ExporterName', - inIfExpression: 'InIfName', - outIfExpression: 'OutIfName', - }; - try { - const expected = await ( - await client.query({ - query: - 'SELECT toUnixTimestamp(min(TimeReceived)) AS start, toUnixTimestamp(max(TimeReceived)) + 1 AS end, count() AS records, sum(Bytes * SamplingRate) AS bytes FROM netflow_demo.flows', - format: 'JSONEachRow', - }) - ).json<{ start: number; end: number; records: string; bytes: string }>(); - assert.ok( - Number(expected[0].records) > 0, - 'Seed the NetFlow demo before verifying', - ); - const dateRange: [Date, Date] = [ - new Date(expected[0].start * 1000), - new Date(expected[0].end * 1000), - ]; - const metadata = getMetadata(client); - const configs = buildNetflowQueryConfigs({ - source, - dateRange, - filters: {}, - }); - for (const [name, config] of Object.entries(configs)) { - if (!config) continue; - const result = await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }); - assert.ok(result.data.length > 0, `${name} returned no rows`); - if (name === 'totalBytes') - assert.equal( - Number(result.data[0].__netflow_value), - Number(expected[0].bytes), - ); - if (name === 'flowRecords') - assert.equal( - Number(result.data[0].__netflow_value), - Number(expected[0].records), - ); - if (name === 'flows') assert.equal(result.data.length, 500); - console.log(`${name}: ${result.data.length} rows OK`); - } - const preScaled = buildNetflowQueryConfigs({ - source: { - ...source, - bytesExpression: 'Bytes * SamplingRate', - samplingRateExpression: undefined, - }, - dateRange, - filters: {}, - }); - const preScaledResult = await client.queryChartConfig({ - config: preScaled.totalBytes, - metadata, - querySettings: undefined, - }); - assert.equal( - Number(preScaledResult.data[0].__netflow_value), - Number(expected[0].bytes), - ); - console.log('Custom expressions with pre-scaled counters: OK'); - const viewName = `netflow_alias_regression_${process.pid}`; - await commands.command({ - query: `CREATE VIEW netflow_demo.${viewName} AS SELECT - TimeReceived AS timestamp, Bytes AS bytes, Packets AS packets, - SamplingRate AS sampling, Proto AS protocol, ExporterName AS name, - SrcAddr AS srcAddr, DstAddr AS dstAddr, SrcPort AS srcPort, - DstPort AS dstPort, InIfName AS inputInterface, - OutIfName AS outputInterface FROM netflow_demo.flows`, - }); - try { - const lowercase = buildNetflowQueryConfigs({ - source: { - ...source, - from: { ...source.from, tableName: viewName }, - timestampValueExpression: 'timestamp', - bytesExpression: 'bytes', - packetsExpression: 'packets', - samplingRateExpression: 'sampling', - protocolExpression: 'protocol', - exporterExpression: 'name', - srcAddrExpression: 'srcAddr', - dstAddrExpression: 'dstAddr', - srcPortExpression: 'srcPort', - dstPortExpression: 'dstPort', - inIfExpression: 'inputInterface', - outIfExpression: 'outputInterface', - }, - dateRange, - filters: { protocol: '6' }, - }); - for (const [name, config] of Object.entries(lowercase)) { - if (!config) continue; - const result = await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }); - assert.ok(result.data.length > 0, `Lowercase ${name} returned no rows`); - if (name === 'flows') { - assert.equal(result.data.length, 500); - for (const row of result.data) { - assert.equal(row.__netflow_protocol, 'TCP'); - assert.equal( - Number(row.__netflow_bytes), - Number(row.__netflow_rawBytes) * - Number(row.__netflow_samplingRate), - ); - assert.equal( - Number(row.__netflow_packets), - Number(row.__netflow_rawPackets) * - Number(row.__netflow_samplingRate), - ); - } - } - } - console.log( - 'Lowercase column aliases, raw counters, and protocol filters: OK', - ); - } finally { - await commands.command({ query: `DROP VIEW netflow_demo.${viewName}` }); - } - for (const filters of [ - { protocol: '6' }, - { srcAddr: '192.0.2.2' }, - { srcAddr: '2001:0db8:0001::2' }, - { exporter: "edge' OR 1=1 --" }, - ]) { - const config = buildNetflowQueryConfigs({ - source, - dateRange, - filters, - }).flowRecords; - const result = await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }); - const count = Number(result.data[0].__netflow_value); - assert.ok( - count >= 0 && count < Number(expected[0].records), - 'Filter must restrict the result', - ); - if ('exporter' in filters) - assert.equal(count, 0, 'SQL syntax in a filter must remain a literal'); - else - assert.ok( - count > 0, - 'Matching protocol/address filters must return records', - ); - console.log(`Filter ${JSON.stringify(filters)}: ${count} records OK`); - } - for (const test of [ - { where: 'Proto:6', sql: 'Proto = 6', filters: {} }, - { - where: '(Proto:6 OR Proto:17) AND NOT ExporterName:core*', - sql: "Proto IN (6, 17) AND ExporterName NOT ILIKE 'core%'", - filters: {}, - }, - { - where: 'Bytes:[50000 TO 500000]', - sql: 'Bytes BETWEEN 50000 AND 500000', - filters: {}, - }, - { - where: 'ExporterName:edge* AND DstPort:443', - sql: "ExporterName ILIKE 'edge%' AND DstPort = 443", - filters: {}, - }, - { - where: 'Bytes:[50000 TO *]', - sql: "Bytes >= 50000 AND Proto = 6 AND ExporterName = 'edge-sfo-01'", - filters: { protocol: '6', exporter: 'edge-sfo-01' }, - }, - ]) { - const searched = buildNetflowQueryConfigs({ - source, - dateRange, - where: test.where, - filters: test.filters, - }); - const direct = await ( - await client.query({ - query: `SELECT count() AS records, sum(Bytes * SamplingRate) AS bytes - FROM netflow_demo.flows WHERE ${test.sql}`, - format: 'JSONEachRow', - }) - ).json<{ records: string; bytes: string }>(); - assert.ok( - Number(direct[0].records) > 0, - 'Lucene fixture must match records', - ); - for (const [config, expectedValue] of [ - [searched.flowRecords, direct[0].records], - [searched.totalBytes, direct[0].bytes], - ] as const) { - const result = await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }); - assert.equal( - Number(result.data[0].__netflow_value), - Number(expectedValue), - test.where, - ); - } - console.log(`Lucene ${test.where}: count and sampled bytes match SQL`); - } - await assert.rejects(async () => { - const malformed = buildNetflowQueryConfigs({ - source, - dateRange, - filters: {}, - where: 'Proto:(', - }); - await client.queryChartConfig({ - config: malformed.flowRecords, - metadata, - querySettings: undefined, - }); - }); - console.log('Malformed Lucene query rejected: OK'); - } finally { - await client.close(); - await commands.close(); - } -} - -main().catch(error => { - console.error(error); - process.exitCode = 1; -}); diff --git a/scripts/netflow-sankey-browser-check.mjs b/scripts/netflow-sankey-browser-check.mjs deleted file mode 100644 index bcb7d5cab6..0000000000 --- a/scripts/netflow-sankey-browser-check.mjs +++ /dev/null @@ -1,158 +0,0 @@ -import assert from 'node:assert/strict'; -import { mkdir } from 'node:fs/promises'; -import { chromium, expect } from '@playwright/test'; - -const baseUrl = process.env.NETFLOW_APP_URL || 'http://127.0.0.1:3000'; -const artifacts = new URL( - '../packages/app/test-results/netflow/', - import.meta.url, -); -await mkdir(artifacts, { recursive: true }); -const browser = await chromium.launch(); -const page = await browser.newPage({ viewport: { width: 1600, height: 1100 } }); -page.setDefaultTimeout(15000); -const errors = []; -page.on('pageerror', error => errors.push(error.message)); -const graph = page.getByTestId('netflow-sankey-graph'); -const table = page.getByTestId('netflow-sankey-table'); -const pills = page.getByTestId('netflow-filter-pills'); -const rows = () => table.locator('tbody tr'); -const choose = async (node, action) => { - await node.click(); - await page.getByRole('menuitem', { name: action, exact: true }).click(); -}; -const closeDevtools = async () => { - const button = page.getByRole('button', { - name: 'Close tanstack query devtools', - exact: true, - }); - await button.waitFor({ state: 'visible', timeout: 5000 }).catch(() => {}); - if (await button.isVisible()) await button.click(); -}; -try { - await page.goto(`${baseUrl}/netflow`); - await closeDevtools(); - await page.getByText('Sankey', { exact: true }).click(); - await expect(page).toHaveURL(/netflowView=sankey/); - await expect(graph.locator('svg')).toBeVisible({ timeout: 60000 }); - await expect(rows()).toHaveCount(6); - await expect(table.getByRole('columnheader')).toHaveText([ - 'SrcAS', - 'InIfConnectivity', - 'InIfProvider', - 'Exporter', - 'Average bit rate', - 'Transferred bytes', - ]); - const provider = graph.getByRole('button', { - name: 'Filter InIfProvider: peer-east', - exact: true, - }); - await provider.hover(); - await expect( - page.getByRole('tooltip').filter({ hasText: 'Average bit rate:' }), - ).toBeVisible(); - await choose(provider, 'Include'); - await expect(pills).toContainText('InIfProvider = peer-east'); - await expect(rows()).toHaveCount(1); - await expect(rows().first().locator('td').nth(2)).toHaveText('peer-east'); - await page.reload(); - await expect(rows()).toHaveCount(1); - await expect(pills).toContainText('InIfProvider = peer-east'); - await choose(provider, 'Exclude'); - await expect(pills).toContainText('InIfProvider != peer-east'); - await expect(rows()).toHaveCount(5); - await expect( - graph.getByRole('button', { - name: 'Filter InIfProvider: peer-east', - exact: true, - }), - ).toHaveCount(0); - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect(rows()).toHaveCount(6); - // Empty classifications remain filterable, rather than becoming a literal '(empty)' search. - await choose( - graph.getByRole('button', { - name: 'Filter InIfProvider: (empty)', - exact: true, - }), - 'Include', - ); - await expect(rows()).toHaveCount(3); - await page - .getByRole('button', { name: 'Clear filters', exact: true }) - .click(); - await expect(rows()).toHaveCount(6); - // The existing search controls apply to this view too. - await page.getByTestId('netflow-search').fill('InIfBoundary:external'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect(rows()).toHaveCount(3); - await expect( - graph.getByRole('button', { - name: 'Filter InIfProvider: (empty)', - exact: true, - }), - ).toHaveCount(0); - await page.getByTestId('netflow-sankey').scrollIntoViewIfNeeded(); - await page.screenshot({ - path: new URL('sankey-desktop.png', artifacts).pathname, - animations: 'disabled', - }); - // Use the actual dimension picker to create a different chain. - const picker = page.locator('input[aria-label="Sankey dimensions"]'); - await page - .getByRole('button', { name: 'Clear Sankey dimensions', exact: true }) - .click(); - await expect( - page.getByText('Choose two to five dimensions', { exact: true }), - ).toBeVisible(); - for (const name of ['Source IP', 'Protocol', 'Destination IP']) { - await picker.fill(name); - await page.getByRole('option', { name, exact: true }).click(); - } - await picker.press('Escape'); - await page - .getByRole('combobox', { name: 'Sankey path limit', exact: true }) - .click(); - await page.getByRole('option', { name: '10', exact: true }).click(); - await expect(rows()).toHaveCount(10); - await expect(table.getByRole('columnheader')).toHaveText([ - 'Source IP', - 'Protocol', - 'Destination IP', - 'Average bit rate', - 'Transferred bytes', - ]); - await page.reload(); - await expect(rows()).toHaveCount(10); - await expect(table.getByRole('columnheader').first()).toHaveText('Source IP'); - await page.setViewportSize({ width: 800, height: 1000 }); - await graph.scrollIntoViewIfNeeded(); - assert.ok( - await page.evaluate( - () => document.documentElement.scrollWidth <= innerWidth, - ), - 'Graph must not overflow the page', - ); - await page.screenshot({ - path: new URL('sankey-narrow.png', artifacts).pathname, - animations: 'disabled', - }); - await page - .getByTestId('netflow-search') - .fill('ExporterName:missing-exporter'); - await page.getByRole('button', { name: 'Run', exact: true }).click(); - await expect( - page.getByTestId('netflow-sankey').getByText('No traffic paths found'), - ).toBeVisible(); - await page.getByText('Time series', { exact: true }).click(); - await expect(page.getByTestId('netflow-sankey')).toHaveCount(0); - assert.deepEqual(errors, []); - console.log( - 'Sankey browser checks passed: nodes, tooltips, include/exclude, empty values, Lucene, dimension picker, limit, reload, narrow layout, empty state, view switch.', - ); -} finally { - await browser.close(); -} diff --git a/scripts/netflow-sankey-query-check.ts b/scripts/netflow-sankey-query-check.ts deleted file mode 100644 index e60819285d..0000000000 --- a/scripts/netflow-sankey-query-check.ts +++ /dev/null @@ -1,264 +0,0 @@ -import assert from 'node:assert/strict'; -import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; -import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; -import { filtersToQuery, parseQuery } from '@hyperdx/common-utils/dist/filters'; -import { - Filter, - SourceKind, - TNetflowSource, -} from '@hyperdx/common-utils/dist/types'; - -import { - buildNetflowQueryConfigs, - NetflowFilters, -} from '../packages/app/src/netflow'; -import { - buildNetflowSankeyConfig, - buildNetflowSankeyData, - SankeyDimension, - sankeyDimensionExpression, -} from '../packages/app/src/netflowSankey'; - -async function main() { - const client = new ClickhouseClient({ - host: process.env.CLICKHOUSE_URL || 'http://127.0.0.1:8123', - username: process.env.CLICKHOUSE_USER || 'default', - password: process.env.CLICKHOUSE_PASSWORD || '', - attribution: { surface: 'dashboard', label: 'netflow-sankey-verification' }, - }); - const source: TNetflowSource = { - id: 'netflow-demo', - kind: SourceKind.Netflow, - name: 'NetFlow demo', - connection: 'netflow-local', - from: { databaseName: 'netflow_demo', tableName: 'flows' }, - timestampValueExpression: 'TimeReceived', - defaultTableSelectExpression: '*', - bytesExpression: 'Bytes', - packetsExpression: 'Packets', - samplingRateExpression: 'SamplingRate', - srcAddrExpression: 'SrcAddr', - dstAddrExpression: 'DstAddr', - srcPortExpression: 'SrcPort', - dstPortExpression: 'DstPort', - protocolExpression: 'Proto', - exporterExpression: 'ExporterName', - inIfExpression: 'InIfName', - outIfExpression: 'OutIfName', - }; - const canonical: SankeyDimension[] = [ - { key: 'srcAS', label: 'Source AS', expression: 'SrcAS' }, - { - key: 'connectivity', - label: 'Input connectivity', - expression: 'InIfConnectivity', - }, - { key: 'provider', label: 'Input provider', expression: 'InIfProvider' }, - { key: 'exporter', label: 'Exporter', expression: 'ExporterName' }, - ]; - const canonicalSql = [ - 'toString(SrcAS)', - 'InIfConnectivity', - 'InIfProvider', - 'ExporterName', - ]; - try { - const [bounds] = await ( - await client.query({ - query: - 'SELECT toUnixTimestamp(min(TimeReceived)) AS start, toUnixTimestamp(max(TimeReceived)) + 1 AS end, count() AS records FROM netflow_demo.flows', - format: 'JSONEachRow', - }) - ).json<{ start: number; end: number; records: string }>(); - assert.ok(Number(bounds.records) > 0, 'Seed NetFlow before verification'); - const dateRange: [Date, Date] = [ - new Date(bounds.start * 1000), - new Date(bounds.end * 1000), - ]; - const seconds = bounds.end - bounds.start; - const metadata = getMetadata(client); - - async function verify({ - label, - dimensions = canonical, - sqlDimensions = canonicalSql, - limit = 20, - mappedSource = source, - where = '', - filters = {}, - extraFilters = [], - predicate = '1', - }: { - label: string; - dimensions?: SankeyDimension[]; - sqlDimensions?: string[]; - limit?: number; - mappedSource?: TNetflowSource; - where?: string; - filters?: NetflowFilters; - extraFilters?: Filter[]; - predicate?: string; - }) { - const baseConfig = buildNetflowQueryConfigs({ - source: mappedSource, - dateRange, - where, - filters, - extraFilters, - }).totalBytes; - const config = buildNetflowSankeyConfig({ - baseConfig, - dimensions, - limit, - }); - assert.equal(config.where, where); - assert.deepEqual(config.filters, baseConfig.filters); - assert.deepEqual(config.dateRange, dateRange); - const result = await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }); - const graph = buildNetflowSankeyData(result.data, dimensions); - assert.ok(graph.paths.length > 0, label); - const aliases = sqlDimensions.map((_, index) => `d${index}`); - const sql = `SELECT ${sqlDimensions.map((expression, index) => `${expression} AS d${index}`).join(', ')}, - sum(Bytes * SamplingRate) AS bytes FROM netflow_demo.flows - WHERE TimeReceived >= toDateTime(${bounds.start}) AND TimeReceived < toDateTime(${bounds.end}) - AND (${predicate}) GROUP BY ${aliases.join(', ')} ORDER BY bytes DESC LIMIT ${limit}`; - const expected = await ( - await client.query({ query: sql, format: 'JSONEachRow' }) - ).json>(); - const expectedPaths = expected.map(row => ({ - values: aliases.map(alias => row[alias]), - value: Number(row.bytes), - })); - const byValues = (a: { values: string[] }, b: { values: string[] }) => - JSON.stringify(a.values).localeCompare(JSON.stringify(b.values)); - assert.deepEqual( - [...graph.paths].sort(byValues), - expectedPaths.sort(byValues), - label, - ); - const [rate] = await ( - await client.query({ - query: `SELECT sum(bytes) AS total_bytes, sum(bytes) * 8 / ${seconds} AS bps FROM (${sql})`, - format: 'JSONEachRow', - }) - ).json<{ total_bytes: string; bps: number }>(); - assert.equal(graph.totalBytes, Number(rate.total_bytes), label); - assert.equal( - (graph.totalBytes * 8) / seconds, - rate.bps, - `${label}: average bitrate`, - ); - for (let stage = 0; stage < dimensions.length - 1; stage++) { - const links = graph.links.filter( - link => graph.nodes[link.source].stage === stage, - ); - assert.equal( - links.reduce((total, link) => total + link.value, 0), - graph.totalBytes, - `${label}: stage ${stage}`, - ); - assert.ok( - links.every(link => graph.nodes[link.target].stage === stage + 1), - ); - } - console.log( - `${label}: ${graph.paths.length} paths, ${graph.totalBytes} bytes; bitrate and stage totals match SQL`, - ); - return graph; - } - - await verify({ label: 'Canonical four dimensions' }); - await verify({ - label: 'Reordered dimensions', - dimensions: [...canonical].reverse(), - sqlDimensions: [...canonicalSql].reverse(), - }); - await verify({ - label: 'Two dimensions', - dimensions: [canonical[0], canonical[3]], - sqlDimensions: [canonicalSql[0], canonicalSql[3]], - }); - await verify({ - label: 'Five dimensions', - dimensions: [ - ...canonical, - { - key: 'outputProvider', - label: 'Output provider', - expression: 'OutIfProvider', - }, - ], - sqlDimensions: [...canonicalSql, 'OutIfProvider'], - }); - const limited = await verify({ label: 'Top two paths', limit: 2 }); - assert.equal(limited.paths.length, 2); - - const clicked = filtersToQuery({ - [sankeyDimensionExpression(canonical[2])]: { - included: new Set(), - excluded: new Set(['']), - }, - }); - await verify({ - label: 'Lucene AND quick filters AND clicked provider exclusion', - where: 'Bytes:[50000 TO 500000]', - filters: { protocol: '6', exporter: 'core-iad-01' }, - extraFilters: filtersToQuery(parseQuery(clicked).filters), - predicate: - "Bytes BETWEEN 50000 AND 500000 AND Proto = 6 AND ExporterName = 'core-iad-01' AND InIfProvider != ''", - }); - for (const nullable of [false, true]) { - const provider = { - ...canonical[2], - expression: nullable ? "nullIf(InIfProvider, '')" : 'InIfProvider', - }; - for (const excluded of [false, true]) { - const clickFilters = filtersToQuery({ - [sankeyDimensionExpression(provider)]: { - included: new Set(excluded ? [] : ['']), - excluded: new Set(excluded ? [''] : []), - }, - }); - const graph = await verify({ - label: `${nullable ? 'Null' : 'Empty'} provider ${excluded ? 'exclude' : 'include'} click`, - dimensions: [canonical[0], canonical[1], provider, canonical[3]], - extraFilters: filtersToQuery(parseQuery(clickFilters).filters), - predicate: excluded ? "InIfProvider != ''" : "InIfProvider = ''", - }); - assert.equal( - graph.nodes.some(node => node.stage === 2 && node.rawValue === ''), - !excluded, - ); - } - } - const exporterExpression = "concat(ExporterSite, ':', ExporterName)"; - await verify({ - label: 'Custom dimension expression and pre-scaled byte mapping', - mappedSource: { - ...source, - bytesExpression: 'Bytes * SamplingRate', - samplingRateExpression: undefined, - exporterExpression, - }, - dimensions: [ - canonical[0], - { ...canonical[3], expression: exporterExpression }, - ], - sqlDimensions: [ - 'toString(SrcAS)', - "concat(ExporterSite, ':', ExporterName)", - ], - }); - } finally { - await client.close(); - } -} - -main().catch(error => { - console.error(error); - process.exitCode = 1; -}); diff --git a/yarn.lock b/yarn.lock index 8acbff36c1..b3c1157b52 100644 --- a/yarn.lock +++ b/yarn.lock @@ -19686,10 +19686,8 @@ __metadata: resolution: "hyperdx@workspace:." dependencies: "@changesets/cli": "npm:^2.26.2" - "@clickhouse/client": "npm:1.23.0-head.fae5998.1" "@dotenvx/dotenvx": "npm:^1.51.1" "@eslint/js": "npm:^9.39.1" - "@playwright/test": "npm:^1.57.0" "@types/ungap__structured-clone": "npm:^1.2.0" "@ungap/structured-clone": "npm:^1.3.0" babel-plugin-react-compiler: "npm:^1.0.0" From bbd3e3078696c5e1912438f05bf95906d8f0fb33 Mon Sep 17 00:00:00 2001 From: Valerii Petrov Date: Fri, 9 Oct 2026 14:54:38 -0700 Subject: [PATCH 06/11] fix: complete NetFlow search hydration and bucket rates --- .changeset/native-netflow-monitoring.md | 11 +- docs/netflow.md | 12 +- packages/api/openapi.json | 4 +- packages/api/src/mcp/tools/query/helpers.ts | 16 +-- .../api/src/routers/external-api/v2/charts.ts | 4 +- .../src/routers/external-api/v2/sources.ts | 4 +- packages/api/src/tasks/checkAlerts/index.ts | 16 +-- packages/app/src/DBDashboardPage.tsx | 8 +- .../app/src/ServicesDashboardPage/helpers.ts | 3 +- packages/app/src/__tests__/netflow.test.ts | 52 ++++++++- .../__tests__/netflowSourceHydration.test.ts | 110 ++++++++++++++++++ .../app/src/components/AlertPreviewChart.tsx | 6 +- .../app/src/components/ChartEditor/utils.ts | 13 +-- .../components/alerts/AlertDetailChart.tsx | 8 +- .../useQueriedDashboardFilterValues.test.tsx | 48 ++++++++ .../hooks/useQueriedDashboardFilterValues.tsx | 6 +- .../app/src/hooks/useReleaseAnnotations.tsx | 6 +- packages/app/src/netflow.ts | 18 ++- .../e2e/features/netflow/queries.spec.ts | 43 ++++++- .../src/__tests__/netflowSource.test.ts | 9 +- .../core/__tests__/searchChartConfig.test.ts | 38 ++++++ .../src/core/searchChartConfig.ts | 21 ++-- 22 files changed, 371 insertions(+), 85 deletions(-) create mode 100644 packages/app/src/__tests__/netflowSourceHydration.test.ts diff --git a/.changeset/native-netflow-monitoring.md b/.changeset/native-netflow-monitoring.md index fe3e77e861..322fe18036 100644 --- a/.changeset/native-netflow-monitoring.md +++ b/.changeset/native-netflow-monitoring.md @@ -30,9 +30,10 @@ Recover from invalid time ranges without crashing, clear source-specific click filters when switching sources, and keep chart geometry aligned with automatic axis widths. -Preserve NetFlow mappings through MCP source tools, ignore stale source inference, -share filter keys across visualizations, retain quoted columns, and make explicit -include/exclude actions idempotent. Format flow times using user preferences. +Preserve NetFlow mappings through MCP source tools, ignore stale source +inference, share filter keys across visualizations, retain quoted columns, and +make explicit include/exclude actions idempotent. Format flow times using user +preferences. Support NetFlow saved-search alerts with default aliases, previews, and flow samples in notifications. Keep unfinished search fields pending when applying @@ -42,3 +43,7 @@ Search mapped flow dimensions with bare Lucene terms or a custom full-text expression. Share the summary aggregate query, reuse accessible filter menus, restore valid Sankey dimensions across source changes, and document source mappings consistently in REST and MCP. + +Apply the same bare-term search defaults to dashboards, chart editing, alert +details, and dashboard filters. Support timestamp expression lists in flow +records and normalize partial traffic buckets over their selected duration. diff --git a/docs/netflow.md b/docs/netflow.md index 9fc581304c..cace79b92a 100644 --- a/docs/netflow.md +++ b/docs/netflow.md @@ -132,6 +132,11 @@ For already sampling-adjusted counters, omit the sampling-rate mapping to avoid expanding the counters twice. Full deployments persist the source through the existing authenticated source API and MongoDB model. +Traffic rates use the seconds covered by each bucket within the selected time +range, including partial first and last buckets. Flow records display and sort +by the first timestamp expression when multiple timestamp mappings are +configured. + ## Sankey visualization Select **Sankey** in the NetFlow visualization selector to explore traffic @@ -169,6 +174,7 @@ make dev-e2e FILE=netflow The suite checks sampled overview totals and raw counters, every chart query, IPv4/IPv6 filters, literal escaping, Lucene and SQL composition, Sankey traffic conservation, empty classifications, source switching, pending query drafts, URL -reloads, invalid time ranges, flow details, and chart axes at wide and narrow -viewport sizes. Failures use the normal Playwright screenshots, traces, and -reports under `packages/app/test-results/`. +reloads, invalid time ranges, partial-bucket rates, timestamp expression lists, +flow details, and chart axes at wide and narrow viewport sizes. Failures use the +normal Playwright screenshots, traces, and reports under +`packages/app/test-results/`. diff --git a/packages/api/openapi.json b/packages/api/openapi.json index a7f7e145fc..1bd61e6752 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -5229,12 +5229,12 @@ }, "timestampValueExpression": { "type": "string", - "description": "SQL expression for the mapped flow field.", + "description": "Timestamp column or SQL expression used to filter flow records by time and group them into time buckets. Must produce a ClickHouse date or timestamp value.", "example": "TimeReceived" }, "defaultTableSelectExpression": { "type": "string", - "description": "SQL expression for the mapped flow field.", + "description": "Comma-separated SQL select list used when displaying flow records in search results and alert samples. May include SQL expressions and aliases.", "example": "TimeReceived, SrcAddr, DstAddr, Bytes" }, "implicitColumnExpression": { diff --git a/packages/api/src/mcp/tools/query/helpers.ts b/packages/api/src/mcp/tools/query/helpers.ts index 70db0bb39d..1fac325072 100644 --- a/packages/api/src/mcp/tools/query/helpers.ts +++ b/packages/api/src/mcp/tools/query/helpers.ts @@ -4,7 +4,7 @@ import { } from '@clickhouse/client-common'; import { getHeatmapMode } from '@hyperdx/common-utils/dist/core/heatmap'; import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; -import { getNetflowImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { convertToCategoricalChartConfig, getFirstTimestampValueExpression, @@ -502,12 +502,7 @@ export async function runConfigTile( 'defaultTableSelectExpression' in source ? source.defaultTableSelectExpression : undefined; - const implicitColumn = - source.kind === SourceKind.Netflow - ? getNetflowImplicitColumnExpression(source) - : 'implicitColumnExpression' in source - ? source.implicitColumnExpression - : undefined; + const implicitColumn = getSourceImplicitColumnExpression(source); const useTextIndexForImplicitColumn = 'useTextIndexForImplicitColumn' in source ? source.useTextIndexForImplicitColumn @@ -628,12 +623,7 @@ export async function runConfigTile( if (source) { sourceFields = { from: source.from, - implicitColumnExpression: - source.kind === SourceKind.Netflow - ? getNetflowImplicitColumnExpression(source) - : 'implicitColumnExpression' in source - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), useTextIndexForImplicitColumn: 'useTextIndexForImplicitColumn' in source ? source.useTextIndexForImplicitColumn diff --git a/packages/api/src/routers/external-api/v2/charts.ts b/packages/api/src/routers/external-api/v2/charts.ts index dcb20a4282..d8510b2608 100644 --- a/packages/api/src/routers/external-api/v2/charts.ts +++ b/packages/api/src/routers/external-api/v2/charts.ts @@ -1,5 +1,5 @@ import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; -import { getNetflowImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { Granularity } from '@hyperdx/common-utils/dist/core/utils'; import { ChartConfigWithOptDateRange, @@ -300,7 +300,7 @@ const buildChartConfigFromRequest = async ( where: '', timestampValueExpression: source.timestampValueExpression, ...(source.kind === SourceKind.Netflow && { - implicitColumnExpression: getNetflowImplicitColumnExpression(source), + implicitColumnExpression: getSourceImplicitColumnExpression(source), }), ...pickSampleWeightExpressionProps(source), dateRange: [new Date(params.startTime), new Date(params.endTime)] as [ diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 8251d5e8a3..3b9c37efed 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -942,11 +942,11 @@ function formatExternalSource(source: SourceDocument) { * $ref: '#/components/schemas/QuerySetting' * timestampValueExpression: * type: string - * description: SQL expression for the mapped flow field. + * description: Timestamp column or SQL expression used to filter flow records by time and group them into time buckets. Must produce a ClickHouse date or timestamp value. * example: TimeReceived * defaultTableSelectExpression: * type: string - * description: SQL expression for the mapped flow field. + * description: Comma-separated SQL select list used when displaying flow records in search results and alert samples. May include SQL expressions and aliases. * example: TimeReceived, SrcAddr, DstAddr, Bytes * implicitColumnExpression: * type: string diff --git a/packages/api/src/tasks/checkAlerts/index.ts b/packages/api/src/tasks/checkAlerts/index.ts index 619ffa5a31..426cfca8a6 100644 --- a/packages/api/src/tasks/checkAlerts/index.ts +++ b/packages/api/src/tasks/checkAlerts/index.ts @@ -21,7 +21,7 @@ import { renderChartConfig } from '@hyperdx/common-utils/dist/core/renderChartCo import { ALERT_COUNT_DEFAULT_SELECT, buildSearchChartConfig, - getNetflowImplicitColumnExpression, + getSourceImplicitColumnExpression, } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { aliasMapToWithClauses, @@ -177,12 +177,7 @@ export async function computeAliasWithClauses( select: resolvedSelect, where: savedSearch.where, whereLanguage: savedSearch.whereLanguage, - implicitColumnExpression: - source.kind === SourceKind.Netflow - ? getNetflowImplicitColumnExpression(source) - : source.kind === SourceKind.Log || source.kind === SourceKind.Trace - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), useTextIndexForImplicitColumn: source.kind === SourceKind.Log || source.kind === SourceKind.Trace ? source.useTextIndexForImplicitColumn @@ -740,12 +735,7 @@ const buildAlertChartConfigFromSavedConfig = ({ if (displayTypeSupportsBuilderAlerts(savedConfig.displayType)) { // NetFlow derives its implicit expression from mapped flow dimensions. // Text-index options exist on Log and Trace; metricTables exists on Metric. - const implicitColumnExpression = - source.kind === SourceKind.Netflow - ? getNetflowImplicitColumnExpression(source) - : source.kind === SourceKind.Log || source.kind === SourceKind.Trace - ? source.implicitColumnExpression - : undefined; + const implicitColumnExpression = getSourceImplicitColumnExpression(source); const useTextIndexForImplicitColumn = source.kind === SourceKind.Log || source.kind === SourceKind.Trace ? source.useTextIndexForImplicitColumn diff --git a/packages/app/src/DBDashboardPage.tsx b/packages/app/src/DBDashboardPage.tsx index 40520a3e07..58f38992c6 100644 --- a/packages/app/src/DBDashboardPage.tsx +++ b/packages/app/src/DBDashboardPage.tsx @@ -26,6 +26,7 @@ import { TableConnection, tcFromSource, } from '@hyperdx/common-utils/dist/core/metadata'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { convertToDashboardTemplate, displayTypeSupportsBuilderAlerts, @@ -684,7 +685,6 @@ const Tile = ({ ...chart.config, // Populate these columns from the source to support Lucene-based filters and metric table macros ...pick(source, [ - 'implicitColumnExpression', 'useTextIndexForImplicitColumn', 'from', 'metricTables', @@ -692,6 +692,7 @@ const Tile = ({ ...(isLogSource(source) ? { bodyExpression: source.bodyExpression } : {}), + implicitColumnExpression: getSourceImplicitColumnExpression(source), sampleWeightExpression: getSampleWeightExpression(source), dateRange, granularity, @@ -724,10 +725,7 @@ const Tile = ({ databaseName: source.from?.databaseName || 'default', tableName: tableName || '', }, - implicitColumnExpression: - isLogSource(source) || isTraceSource(source) - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), useTextIndexForImplicitColumn: isLogSource(source) || isTraceSource(source) ? source.useTextIndexForImplicitColumn diff --git a/packages/app/src/ServicesDashboardPage/helpers.ts b/packages/app/src/ServicesDashboardPage/helpers.ts index fc1202089d..ccf437a32a 100644 --- a/packages/app/src/ServicesDashboardPage/helpers.ts +++ b/packages/app/src/ServicesDashboardPage/helpers.ts @@ -1,4 +1,5 @@ import SqlString from 'sqlstring'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import type { TSource } from '@hyperdx/common-utils/dist/types'; import { Filter, @@ -19,9 +20,9 @@ export function pickSourceConfigFields(source: TSource) { timestampValueExpression: source.timestampValueExpression, connection: source.connection, from: source.from, + implicitColumnExpression: getSourceImplicitColumnExpression(source), ...(isLogSource(source) || isTraceSource(source) ? { - implicitColumnExpression: source.implicitColumnExpression, useTextIndexForImplicitColumn: source.useTextIndexForImplicitColumn, } : {}), diff --git a/packages/app/src/__tests__/netflow.test.ts b/packages/app/src/__tests__/netflow.test.ts index b758ad8e53..74f652bbd3 100644 --- a/packages/app/src/__tests__/netflow.test.ts +++ b/packages/app/src/__tests__/netflow.test.ts @@ -28,6 +28,54 @@ const dateRange: [Date, Date] = [ ]; describe('NetFlow queries', () => { + it.each([ + ['TimeReceived, FlowDate', 'TimeReceived'], + [ + "toDateTime(TimeReceived, 'UTC'), toDate(TimeReceived)", + "toDateTime(TimeReceived, 'UTC')", + ], + ])( + 'uses the first timestamp expression for flow rows (%s)', + (mapping, timestamp) => { + const configs = buildNetflowQueryConfigs({ + source: { ...source, timestampValueExpression: mapping }, + dateRange, + filters: {}, + }); + expect(configs.flows.select[0]).toEqual({ + valueExpression: timestamp, + alias: '__netflow_timestamp', + }); + expect(configs.flows.orderBy).toBe(`${timestamp} DESC`); + // All mapped columns still participate in the shared timestamp optimizer/filter. + expect(configs.flows.timestampValueExpression).toBe(mapping); + expect(configs.traffic.timestampValueExpression).toBe(mapping); + }, + ); + + it('normalizes partial traffic buckets without expanding the selected range', () => { + const selectedRange: [Date, Date] = [ + new Date('2026-10-09T12:00:30Z'), + new Date('2026-10-09T13:00:20Z'), + ]; + const configs = buildNetflowQueryConfigs({ + source, + dateRange: selectedRange, + filters: {}, + }); + expect(configs.traffic).toMatchObject({ + dateRange: selectedRange, + alignDateRangeToGranularity: false, + }); + expect(configs.traffic.select).toEqual([ + { + alias: 'Bits per second', + valueExpression: expect.stringMatching(/least\(.*greatest\(/), + }, + ]); + expect(configs.summary.dateRange).toEqual(selectedRange); + }); + it('estimates sampled traffic but counts stored flow records', () => { const configs = buildNetflowQueryConfigs({ source, @@ -51,7 +99,9 @@ describe('NetFlow queries', () => { ]); expect(configs.traffic.select).toEqual([ { - valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 / 60', + valueExpression: expect.stringContaining( + 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 /', + ), alias: 'Bits per second', }, ]); diff --git a/packages/app/src/__tests__/netflowSourceHydration.test.ts b/packages/app/src/__tests__/netflowSourceHydration.test.ts new file mode 100644 index 0000000000..c57e7f886c --- /dev/null +++ b/packages/app/src/__tests__/netflowSourceHydration.test.ts @@ -0,0 +1,110 @@ +import { Granularity } from '@hyperdx/common-utils/dist/core/utils'; +import { + DisplayType, + SourceKind, + TNetflowSource, +} from '@hyperdx/common-utils/dist/types'; + +import { buildAlertChartConfig } from '@/components/alerts/AlertDetailChart'; +import { convertFormStateToChartConfig } from '@/components/ChartEditor/utils'; +import { buildReleaseChartConfig } from '@/hooks/useReleaseAnnotations'; +import { pickSourceConfigFields } from '@/ServicesDashboardPage/helpers'; + +const source: TNetflowSource = { + id: 'flows', + name: 'Flows', + kind: SourceKind.Netflow, + connection: 'local', + from: { databaseName: 'default', tableName: 'flows' }, + timestampValueExpression: 'TimeReceived', + defaultTableSelectExpression: '*', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'ClientIP', + dstAddrExpression: 'ServerIP', + srcPortExpression: 'ClientPort', + dstPortExpression: 'ServerPort', + protocolExpression: 'ProtocolNumber', + exporterExpression: 'RouterName', +}; +const dateRange: [Date, Date] = [new Date(0), new Date(60000)]; +const select = [{ valueExpression: 'sum(Bytes)' }]; + +describe.each([undefined, 'SearchText'])( + 'NetFlow full-text hydration with override %s', + implicitColumnExpression => { + const selectedSource = { ...source, implicitColumnExpression }; + const expected = + implicitColumnExpression ?? expect.stringContaining('toString(ClientIP)'); + + it('carries the search mapping through generic source field projection', () => { + expect(pickSourceConfigFields(selectedSource)).toMatchObject({ + implicitColumnExpression: expected, + }); + }); + + it.each(['sql', 'builder'] as const)( + 'carries the search mapping into %s chart editor previews', + configType => { + const config = convertFormStateToChartConfig( + { + configType, + displayType: DisplayType.Line, + source: source.id, + connection: source.connection, + where: 'edge', + whereLanguage: 'lucene', + series: select, + sqlTemplate: 'SELECT sum(Bytes) FROM flows WHERE $where', + }, + dateRange, + selectedSource, + ); + expect(config).toMatchObject({ implicitColumnExpression: expected }); + }, + ); + + it.each(['sql', 'builder'] as const)( + 'carries the search mapping into %s saved alert charts', + configType => { + const savedConfig = + configType === 'sql' + ? { + configType: 'sql' as const, + displayType: DisplayType.Line, + source: source.id, + connection: source.connection, + sqlTemplate: 'SELECT sum(Bytes) FROM flows WHERE $where', + } + : { + displayType: DisplayType.Line, + source: source.id, + select, + where: 'edge', + whereLanguage: 'lucene' as const, + }; + const config = buildAlertChartConfig({ + savedConfig, + source: selectedSource, + dateRange, + granularity: Granularity.OneMinute, + variables: [], + }); + expect(config).toMatchObject({ implicitColumnExpression: expected }); + }, + ); + + it('carries the search mapping into release queries scoped by bare Lucene terms', () => { + const config = buildReleaseChartConfig( + selectedSource, + 'Version', + dateRange, + { where: 'edge', whereLanguage: 'lucene' }, + ); + expect(config).toMatchObject({ + implicitColumnExpression: expected, + filters: [{ type: 'lucene', condition: 'edge' }], + }); + }); + }, +); diff --git a/packages/app/src/components/AlertPreviewChart.tsx b/packages/app/src/components/AlertPreviewChart.tsx index cde33b252b..e5a3beab19 100644 --- a/packages/app/src/components/AlertPreviewChart.tsx +++ b/packages/app/src/components/AlertPreviewChart.tsx @@ -2,7 +2,7 @@ import React, { useMemo } from 'react'; import { ALERT_COUNT_DEFAULT_SELECT, buildSearchChartConfig, - getNetflowImplicitColumnExpression, + getSourceImplicitColumnExpression, } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { aliasMapToWithClauses } from '@hyperdx/common-utils/dist/core/utils'; import { @@ -69,9 +69,7 @@ export const AlertPreviewChart = ({ const { data: aliasMap } = useAliasMapFromChartConfig({ select: resolvedSelect, - implicitColumnExpression: isNetflowSource(source) - ? getNetflowImplicitColumnExpression(source) - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), where: where || '', connection: source.connection, from: source.from, diff --git a/packages/app/src/components/ChartEditor/utils.ts b/packages/app/src/components/ChartEditor/utils.ts index 0709cf6974..8823edf3ab 100644 --- a/packages/app/src/components/ChartEditor/utils.ts +++ b/packages/app/src/components/ChartEditor/utils.ts @@ -7,6 +7,7 @@ import { displayTypeSupportsReducer, getPromqlSeries, } from '@hyperdx/common-utils/dist/core/promql'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { isFormulaDisplayType, isFormulaSourceKind, @@ -408,10 +409,9 @@ export function convertFormStateToChartConfig( connection: form.connection ?? '', source: form.source || undefined, from: source?.from, - implicitColumnExpression: - source && (isLogSource(source) || isTraceSource(source)) - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: source + ? getSourceImplicitColumnExpression(source) + : undefined, // Body expression is only populated for log sources; trace sources use // `spanNameExpression` for display, which has a different semantic for // bare-text search and should not auto-fall-back. @@ -448,10 +448,7 @@ export function convertFormStateToChartConfig( timestampValueExpression: source.timestampValueExpression, dateRange, connection: source.connection, - implicitColumnExpression: - isLogSource(source) || isTraceSource(source) - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), // Logs-only body fallback (see comment above for raw-sql config). bodyExpression: isLogSource(source) ? source.bodyExpression : undefined, useTextIndexForImplicitColumn: diff --git a/packages/app/src/components/alerts/AlertDetailChart.tsx b/packages/app/src/components/alerts/AlertDetailChart.tsx index 60608d154e..af215d0963 100644 --- a/packages/app/src/components/alerts/AlertDetailChart.tsx +++ b/packages/app/src/components/alerts/AlertDetailChart.tsx @@ -1,6 +1,7 @@ import * as React from 'react'; import Link from 'next/link'; import { pick } from 'lodash'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { Granularity, isTimeSeriesDisplayType, @@ -167,12 +168,12 @@ export function buildAlertChartConfig({ ...savedConfig, variables, ...pick(source, [ - 'implicitColumnExpression', 'useTextIndexForImplicitColumn', 'from', 'metricTables', ]), ...(isLogSource(source) ? { bodyExpression: source.bodyExpression } : {}), + implicitColumnExpression: getSourceImplicitColumnExpression(source), sampleWeightExpression: getSampleWeightExpression(source), dateRange, granularity, @@ -207,10 +208,7 @@ export function buildAlertChartConfig({ databaseName: source.from?.databaseName || 'default', tableName: tableName || '', }, - implicitColumnExpression: - isLogSource(source) || isTraceSource(source) - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), useTextIndexForImplicitColumn: isLogSource(source) || isTraceSource(source) ? source.useTextIndexForImplicitColumn diff --git a/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx b/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx index ba40f07b2e..402b7556bd 100644 --- a/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx +++ b/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx @@ -162,6 +162,54 @@ describe('useQueriedDashboardFilterValues', () => { jest.restoreAllMocks(); }); + it('uses mapped NetFlow fields for bare Lucene terms in filter-value queries', async () => { + jest.spyOn(sourceModule, 'useSources').mockReturnValue({ + data: [ + { + ...mockSources[0], + id: 'flows', + kind: SourceKind.Netflow, + srcAddrExpression: 'ClientIP', + dstAddrExpression: 'ServerIP', + srcPortExpression: 'ClientPort', + dstPortExpression: 'ServerPort', + protocolExpression: 'Protocol', + exporterExpression: 'RouterName', + }, + ], + isLoading: false, + } as any); + const { result } = renderHook( + () => + useQueriedDashboardFilterValues({ + filters: [ + { + id: 'router', + type: 'QUERY_EXPRESSION', + name: 'Router', + expression: 'RouterName', + source: 'flows', + where: 'edge', + whereLanguage: 'lucene', + }, + ], + dateRange: mockDateRange, + }), + { wrapper }, + ); + await waitFor(() => expect(result.current.isFetching).toBe(false)); + expect(mockMetadata.getKeyValues).toHaveBeenCalledWith( + expect.objectContaining({ + chartConfig: expect.objectContaining({ + where: 'edge', + whereLanguage: 'lucene', + implicitColumnExpression: + expect.stringContaining('toString(ClientIP)'), + }), + }), + ); + }); + it('should convert non-string key values to strings', async () => { // Arrange jest.spyOn(sourceModule, 'useSources').mockReturnValue({ diff --git a/packages/app/src/hooks/useQueriedDashboardFilterValues.tsx b/packages/app/src/hooks/useQueriedDashboardFilterValues.tsx index 9023b91ac7..1efe4f93f9 100644 --- a/packages/app/src/hooks/useQueriedDashboardFilterValues.tsx +++ b/packages/app/src/hooks/useQueriedDashboardFilterValues.tsx @@ -5,6 +5,7 @@ import { optimizeFacetedKeyValuesConfig, optimizeGetKeyValuesCalls, } from '@hyperdx/common-utils/dist/core/materializedViews'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { FilterSelection } from '@hyperdx/common-utils/dist/dashboardFilterValues'; import { FilterState, @@ -221,10 +222,7 @@ function useOptimizedKeyValuesCalls({ databaseName: source.from.databaseName, tableName, }, - implicitColumnExpression: - isTraceSource(source) || isLogSource(source) - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), // Logs-only body fallback for bare-text Lucene search. bodyExpression: isLogSource(source) ? source.bodyExpression diff --git a/packages/app/src/hooks/useReleaseAnnotations.tsx b/packages/app/src/hooks/useReleaseAnnotations.tsx index 839b3faeb7..d8a7f068ee 100644 --- a/packages/app/src/hooks/useReleaseAnnotations.tsx +++ b/packages/app/src/hooks/useReleaseAnnotations.tsx @@ -1,6 +1,7 @@ import { useEffect, useMemo, useRef } from 'react'; import Link from 'next/link'; import { isMissingColumnError } from '@hyperdx/common-utils/dist/clickhouse'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { BuilderChartConfigWithDateRange, Filter, @@ -227,10 +228,7 @@ export function buildReleaseChartConfig( from: source.from, timestampValueExpression: source.timestampValueExpression, // Needed for Lucene scope filters to resolve bare terms. - implicitColumnExpression: - 'implicitColumnExpression' in source - ? source.implicitColumnExpression - : undefined, + implicitColumnExpression: getSourceImplicitColumnExpression(source), useTextIndexForImplicitColumn: 'useTextIndexForImplicitColumn' in source ? source.useTextIndexForImplicitColumn diff --git a/packages/app/src/netflow.ts b/packages/app/src/netflow.ts index 92aa4c6dc9..fc6189421a 100644 --- a/packages/app/src/netflow.ts +++ b/packages/app/src/netflow.ts @@ -1,8 +1,10 @@ -import { getNetflowImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; +import { FIXED_TIME_BUCKET_EXPR_ALIAS } from '@hyperdx/common-utils/dist/core/renderChartConfig'; +import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { convertDateRangeToGranularityString, convertGranularityToSeconds, escapeSqlString, + getFirstTimestampValueExpression, } from '@hyperdx/common-utils/dist/core/utils'; import { BuilderChartConfigWithDateRange, @@ -144,6 +146,12 @@ export function buildNetflowQueryConfigs({ const packets = `toFloat64(${source.packetsExpression}) * (${sample})`; const granularity = convertDateRangeToGranularityString(dateRange); const bucketSeconds = convertGranularityToSeconds(granularity); + const timestamp = getFirstTimestampValueExpression( + source.timestampValueExpression, + ); + const bucketStart = `toUnixTimestamp64Milli(toDateTime64(\`${FIXED_TIME_BUCKET_EXPR_ALIAS}\`, 3)) / 1000`; + // Edge buckets cover only the intersection with the selected range. + const coveredSeconds = `least(${bucketStart} + ${bucketSeconds}, ${dateRange[1].getTime() / 1000}) - greatest(${bucketStart}, ${dateRange[0].getTime() / 1000})`; const dimensions = getNetflowDimensions(source); const protocol = dimensions.protocol; const base = { @@ -151,7 +159,7 @@ export function buildNetflowQueryConfigs({ connection: source.connection, source: source.id, timestampValueExpression: source.timestampValueExpression, - implicitColumnExpression: getNetflowImplicitColumnExpression(source), + implicitColumnExpression: getSourceImplicitColumnExpression(source), dateRange, dateRangeEndInclusive: false, alignDateRangeToGranularity: false, @@ -185,7 +193,7 @@ export function buildNetflowQueryConfigs({ ...base, select: [ { - valueExpression: `sum(${bytes}) * 8 / ${bucketSeconds}`, + valueExpression: `sum(${bytes}) * 8 / (${coveredSeconds})`, alias: 'Bits per second', }, ], @@ -197,7 +205,7 @@ export function buildNetflowQueryConfigs({ ...base, select: [ { - valueExpression: source.timestampValueExpression, + valueExpression: timestamp, alias: netflowColumnAlias('timestamp'), }, { @@ -241,7 +249,7 @@ export function buildNetflowQueryConfigs({ alias: netflowColumnAlias('outputInterface'), }, ], - orderBy: `${source.timestampValueExpression} DESC`, + orderBy: `${timestamp} DESC`, limit: { limit: 500 }, displayType: DisplayType.Table, }; diff --git a/packages/app/tests/e2e/features/netflow/queries.spec.ts b/packages/app/tests/e2e/features/netflow/queries.spec.ts index db216f0b69..f3b9321a2e 100644 --- a/packages/app/tests/e2e/features/netflow/queries.spec.ts +++ b/packages/app/tests/e2e/features/netflow/queries.spec.ts @@ -13,6 +13,44 @@ import { } from '../../../../src/netflowSankey'; import { expect, test } from '../../fixtures/netflow'; +test('steady traffic keeps its rate in partial first and last buckets', async ({ + netflow, +}) => { + const { client, database, source } = netflow; + const start = Date.parse('2026-10-09T12:00:00Z'); + const view = await client.query({ + query: `CREATE VIEW ${database}.steady AS + SELECT toDateTime(${start / 1000}) + number AS TimeReceived, + 125 AS Bytes, 1 AS Packets, 100 AS SamplingRate FROM numbers(3660)`, + }); + await view.text(); + const configs = buildNetflowQueryConfigs({ + source: { + ...source, + from: { databaseName: database, tableName: 'steady' }, + }, + dateRange: [new Date(start + 30000), new Date(start + 3620000)], + filters: {}, + }); + const result = await client.queryChartConfig({ + config: configs.traffic, + metadata: getMetadata(client), + querySettings: undefined, + }); + expect(result.data).toHaveLength(61); + // 125 raw bytes/second * sampling 100 * 8. Both edge buckets have less than a minute of data. + expect(result.data.map(row => Number(row['Bits per second']))).toEqual( + Array(61).fill(100000), + ); + const summary = await client.queryChartConfig({ + config: configs.summary, + metadata: getMetadata(client), + querySettings: undefined, + }); + expect(Number(summary.data[0].__netflow_flowRecords)).toBe(3590); + expect(Number(summary.data[0].__netflow_bytes)).toBe(3590 * 125 * 100); +}); + test('NetFlow queries preserve sampled totals, raw records, aliases and Lucene semantics', async ({ netflow, }) => { @@ -132,7 +170,7 @@ test('NetFlow handles lowercase counter aliases and custom timestamp and exporte const customSource = { ...source, from: { databaseName: database, tableName: 'lowercase' }, - timestampValueExpression: 'toDateTime(timestamp)', + timestampValueExpression: 'toDateTime(timestamp), toDate(timestamp)', bytesExpression: 'bytes', packetsExpression: 'packets', samplingRateExpression: 'sampling', @@ -168,6 +206,9 @@ test('NetFlow handles lowercase counter aliases and custom timestamp and exporte querySettings: undefined, }); expect(result.data).toHaveLength(500); + expect(new Date(String(result.data[0].__netflow_timestamp)).getTime()).toBe( + dateRange[1].getTime() - 904000, + ); expect( result.data.every( row => diff --git a/packages/common-utils/src/__tests__/netflowSource.test.ts b/packages/common-utils/src/__tests__/netflowSource.test.ts index 0a778d9c73..8e1cc62633 100644 --- a/packages/common-utils/src/__tests__/netflowSource.test.ts +++ b/packages/common-utils/src/__tests__/netflowSource.test.ts @@ -1,4 +1,7 @@ -import { buildSearchChartConfig } from '@/core/searchChartConfig'; +import { + buildSearchChartConfig, + getSourceImplicitColumnExpression, +} from '@/core/searchChartConfig'; import { isSearchableSource, SourceKind, @@ -79,6 +82,9 @@ it('searches mapped NetFlow dimensions for bare Lucene terms by default', () => expect(config.implicitColumnExpression).toContain('toString(SrcAddr)'); expect(config.implicitColumnExpression).toContain('toString(ExporterName)'); expect(config.implicitColumnExpression).toContain('toString(OutIfName)'); + expect(getSourceImplicitColumnExpression(SourceSchema.parse(source))).toBe( + config.implicitColumnExpression, + ); }); it('preserves and uses a custom NetFlow implicit expression', () => { @@ -87,6 +93,7 @@ it('preserves and uses a custom NetFlow implicit expression', () => { implicitColumnExpression: 'SearchText', }); expect(parsed).toHaveProperty('implicitColumnExpression', 'SearchText'); + expect(getSourceImplicitColumnExpression(parsed)).toBe('SearchText'); expect( buildSearchChartConfig(parsed, { where: 'router' }) .implicitColumnExpression, diff --git a/packages/common-utils/src/core/__tests__/searchChartConfig.test.ts b/packages/common-utils/src/core/__tests__/searchChartConfig.test.ts index 99d28a5c53..9fc080ee22 100644 --- a/packages/common-utils/src/core/__tests__/searchChartConfig.test.ts +++ b/packages/common-utils/src/core/__tests__/searchChartConfig.test.ts @@ -1,6 +1,7 @@ import { ALERT_COUNT_DEFAULT_SELECT, buildSearchChartConfig, + getSourceImplicitColumnExpression, } from '@/core/searchChartConfig'; import { DisplayType, Filter, SourceKind, TSource } from '@/types'; @@ -44,6 +45,43 @@ const makeMetricSource = (overrides: Record = {}): TSource => ...overrides, }) as unknown as TSource; +describe('getSourceImplicitColumnExpression', () => { + it.each([makeLogSource, makeTraceSource])( + 'preserves the configured expression on existing searchable kinds', + makeSource => { + const expression = " concatWithSeparator(';', Body, Message) "; + expect( + getSourceImplicitColumnExpression( + makeSource({ + implicitColumnExpression: expression, + }), + ), + ).toBe(expression); + expect( + getSourceImplicitColumnExpression( + makeSource({ + implicitColumnExpression: undefined, + }), + ), + ).toBeUndefined(); + }, + ); + + it.each([SourceKind.Metric, SourceKind.Session, SourceKind.Promql])( + 'does not add implicit search to %s sources', + kind => { + expect( + getSourceImplicitColumnExpression( + makeMetricSource({ + kind, + implicitColumnExpression: 'unexpected', + }), + ), + ).toBeUndefined(); + }, + ); +}); + describe('buildSearchChartConfig', () => { describe('tableFilterExpression', () => { it('prepends tableFilterExpression as a SQL filter on Log sources', () => { diff --git a/packages/common-utils/src/core/searchChartConfig.ts b/packages/common-utils/src/core/searchChartConfig.ts index fa79a56ee0..e2a22db175 100644 --- a/packages/common-utils/src/core/searchChartConfig.ts +++ b/packages/common-utils/src/core/searchChartConfig.ts @@ -31,9 +31,7 @@ import { export type SearchChartConfig = BuilderChartConfig & Partial; /** Bare terms search mapped dimensions even on sources saved before this option existed. */ -export function getNetflowImplicitColumnExpression( - source: TNetflowSource, -): string { +function getNetflowImplicitColumnExpression(source: TNetflowSource): string { if (source.implicitColumnExpression?.trim()) { return source.implicitColumnExpression.trim(); } @@ -52,6 +50,17 @@ export function getNetflowImplicitColumnExpression( return `concatWithSeparator(' ', ${expressions.map(expression => `ifNull(toString(${expression}), '')`).join(', ')})`; } +export function getSourceImplicitColumnExpression( + source: TSource, +): string | undefined { + if (isNetflowSource(source)) { + return getNetflowImplicitColumnExpression(source); + } + return isLogSource(source) || isTraceSource(source) + ? source.implicitColumnExpression + : undefined; +} + /** * Default SELECT used by alert evaluators when no caller-supplied SELECT * is provided: a single `count()` aggregate. @@ -162,11 +171,7 @@ export function buildSearchChartConfig( const userFilters: Filter[] = input.filters ?? []; const mergedFilters: Filter[] = [...tableFilter, ...userFilters]; - const implicitColumnExpression = isNetflowSource(source) - ? getNetflowImplicitColumnExpression(source) - : isLogSource(source) || isTraceSource(source) - ? source.implicitColumnExpression - : undefined; + const implicitColumnExpression = getSourceImplicitColumnExpression(source); const useTextIndexForImplicitColumn = isLogSource(source) || isTraceSource(source) ? source.useTextIndexForImplicitColumn From adc1e105c22521f074244101a6e83d860933c875 Mon Sep 17 00:00:00 2001 From: Valerii Petrov Date: Fri, 9 Oct 2026 14:58:17 -0700 Subject: [PATCH 07/11] test: consolidate NetFlow regression coverage --- packages/app/src/__tests__/netflow.test.ts | 92 ++++++++++++++- .../__tests__/netflowSourceHydration.test.ts | 110 ------------------ 2 files changed, 90 insertions(+), 112 deletions(-) delete mode 100644 packages/app/src/__tests__/netflowSourceHydration.test.ts diff --git a/packages/app/src/__tests__/netflow.test.ts b/packages/app/src/__tests__/netflow.test.ts index 74f652bbd3..cab209a1de 100644 --- a/packages/app/src/__tests__/netflow.test.ts +++ b/packages/app/src/__tests__/netflow.test.ts @@ -1,6 +1,15 @@ -import { SourceKind, TNetflowSource } from '@hyperdx/common-utils/dist/types'; +import { Granularity } from '@hyperdx/common-utils/dist/core/utils'; +import { + DisplayType, + SourceKind, + TNetflowSource, +} from '@hyperdx/common-utils/dist/types'; +import { buildAlertChartConfig } from '@/components/alerts/AlertDetailChart'; +import { convertFormStateToChartConfig } from '@/components/ChartEditor/utils'; +import { buildReleaseChartConfig } from '@/hooks/useReleaseAnnotations'; import { buildNetflowQueryConfigs, buildNetflowWhere } from '@/netflow'; +import { pickSourceConfigFields } from '@/ServicesDashboardPage/helpers'; const source: TNetflowSource = { id: 'flows', @@ -47,7 +56,6 @@ describe('NetFlow queries', () => { alias: '__netflow_timestamp', }); expect(configs.flows.orderBy).toBe(`${timestamp} DESC`); - // All mapped columns still participate in the shared timestamp optimizer/filter. expect(configs.flows.timestampValueExpression).toBe(mapping); expect(configs.traffic.timestampValueExpression).toBe(mapping); }, @@ -209,3 +217,83 @@ describe('NetFlow queries', () => { ).toThrow('Time range'); }); }); + +describe.each([undefined, 'SearchText'])( + 'NetFlow full-text hydration with override %s', + implicitColumnExpression => { + const selectedSource = { ...source, implicitColumnExpression }; + const select = [{ valueExpression: 'sum(Bytes)' }]; + const expected = + implicitColumnExpression ?? expect.stringContaining('toString(SrcAddr)'); + + it('carries the search mapping through generic source field projection', () => { + expect(pickSourceConfigFields(selectedSource)).toMatchObject({ + implicitColumnExpression: expected, + }); + }); + + it.each(['sql', 'builder'] as const)( + 'carries the search mapping into %s chart editor previews', + configType => { + const config = convertFormStateToChartConfig( + { + configType, + displayType: DisplayType.Line, + source: source.id, + connection: source.connection, + where: 'edge', + whereLanguage: 'lucene', + series: select, + sqlTemplate: 'SELECT sum(Bytes) FROM flows WHERE $where', + }, + dateRange, + selectedSource, + ); + expect(config).toMatchObject({ implicitColumnExpression: expected }); + }, + ); + + it.each(['sql', 'builder'] as const)( + 'carries the search mapping into %s saved alert charts', + configType => { + const savedConfig = + configType === 'sql' + ? { + configType: 'sql' as const, + displayType: DisplayType.Line, + source: source.id, + connection: source.connection, + sqlTemplate: 'SELECT sum(Bytes) FROM flows WHERE $where', + } + : { + displayType: DisplayType.Line, + source: source.id, + select, + where: 'edge', + whereLanguage: 'lucene' as const, + }; + const config = buildAlertChartConfig({ + savedConfig, + source: selectedSource, + dateRange, + granularity: Granularity.OneMinute, + variables: [], + }); + expect(config).toMatchObject({ implicitColumnExpression: expected }); + }, + ); + + it('carries the search mapping into release queries scoped by bare Lucene terms', () => { + const config = buildReleaseChartConfig( + selectedSource, + 'Version', + dateRange, + { where: 'edge', whereLanguage: 'lucene' }, + ); + expect(config).toMatchObject({ + implicitColumnExpression: expected, + filters: [{ type: 'lucene', condition: 'edge' }], + }); + }); + }, +); diff --git a/packages/app/src/__tests__/netflowSourceHydration.test.ts b/packages/app/src/__tests__/netflowSourceHydration.test.ts deleted file mode 100644 index c57e7f886c..0000000000 --- a/packages/app/src/__tests__/netflowSourceHydration.test.ts +++ /dev/null @@ -1,110 +0,0 @@ -import { Granularity } from '@hyperdx/common-utils/dist/core/utils'; -import { - DisplayType, - SourceKind, - TNetflowSource, -} from '@hyperdx/common-utils/dist/types'; - -import { buildAlertChartConfig } from '@/components/alerts/AlertDetailChart'; -import { convertFormStateToChartConfig } from '@/components/ChartEditor/utils'; -import { buildReleaseChartConfig } from '@/hooks/useReleaseAnnotations'; -import { pickSourceConfigFields } from '@/ServicesDashboardPage/helpers'; - -const source: TNetflowSource = { - id: 'flows', - name: 'Flows', - kind: SourceKind.Netflow, - connection: 'local', - from: { databaseName: 'default', tableName: 'flows' }, - timestampValueExpression: 'TimeReceived', - defaultTableSelectExpression: '*', - bytesExpression: 'Bytes', - packetsExpression: 'Packets', - srcAddrExpression: 'ClientIP', - dstAddrExpression: 'ServerIP', - srcPortExpression: 'ClientPort', - dstPortExpression: 'ServerPort', - protocolExpression: 'ProtocolNumber', - exporterExpression: 'RouterName', -}; -const dateRange: [Date, Date] = [new Date(0), new Date(60000)]; -const select = [{ valueExpression: 'sum(Bytes)' }]; - -describe.each([undefined, 'SearchText'])( - 'NetFlow full-text hydration with override %s', - implicitColumnExpression => { - const selectedSource = { ...source, implicitColumnExpression }; - const expected = - implicitColumnExpression ?? expect.stringContaining('toString(ClientIP)'); - - it('carries the search mapping through generic source field projection', () => { - expect(pickSourceConfigFields(selectedSource)).toMatchObject({ - implicitColumnExpression: expected, - }); - }); - - it.each(['sql', 'builder'] as const)( - 'carries the search mapping into %s chart editor previews', - configType => { - const config = convertFormStateToChartConfig( - { - configType, - displayType: DisplayType.Line, - source: source.id, - connection: source.connection, - where: 'edge', - whereLanguage: 'lucene', - series: select, - sqlTemplate: 'SELECT sum(Bytes) FROM flows WHERE $where', - }, - dateRange, - selectedSource, - ); - expect(config).toMatchObject({ implicitColumnExpression: expected }); - }, - ); - - it.each(['sql', 'builder'] as const)( - 'carries the search mapping into %s saved alert charts', - configType => { - const savedConfig = - configType === 'sql' - ? { - configType: 'sql' as const, - displayType: DisplayType.Line, - source: source.id, - connection: source.connection, - sqlTemplate: 'SELECT sum(Bytes) FROM flows WHERE $where', - } - : { - displayType: DisplayType.Line, - source: source.id, - select, - where: 'edge', - whereLanguage: 'lucene' as const, - }; - const config = buildAlertChartConfig({ - savedConfig, - source: selectedSource, - dateRange, - granularity: Granularity.OneMinute, - variables: [], - }); - expect(config).toMatchObject({ implicitColumnExpression: expected }); - }, - ); - - it('carries the search mapping into release queries scoped by bare Lucene terms', () => { - const config = buildReleaseChartConfig( - selectedSource, - 'Version', - dateRange, - { where: 'edge', whereLanguage: 'lucene' }, - ); - expect(config).toMatchObject({ - implicitColumnExpression: expected, - filters: [{ type: 'lucene', condition: 'edge' }], - }); - }); - }, -); From 7ebbb8d533eff05336bf4687232b8be3e7327647 Mon Sep 17 00:00:00 2001 From: Valerii Petrov Date: Fri, 9 Oct 2026 15:19:01 -0700 Subject: [PATCH 08/11] fix: preserve NetFlow source settings and filter behavior --- .changeset/native-netflow-monitoring.md | 9 ++ docs/netflow.md | 7 + packages/api/openapi.json | 2 +- .../mcp/__tests__/netflow-search.int.test.ts | 57 +++++++- packages/api/src/mcp/tools/sources/schemas.ts | 4 +- .../api/src/routers/external-api/v2/charts.ts | 4 +- .../src/routers/external-api/v2/sources.ts | 2 +- .../__tests__/netflowAlerts.int.test.ts | 48 +++++-- packages/app/src/NetflowPage.tsx | 83 ++++++------ .../app/src/ServicesDashboardPage/helpers.ts | 3 +- .../src/__tests__/NetflowPage.drafts.test.tsx | 105 ++++++++++++++- packages/app/src/__tests__/netflow.test.ts | 22 ++-- .../app/src/components/ActiveFilterPills.tsx | 15 ++- packages/app/src/components/AppNav/AppNav.tsx | 1 + .../app/src/components/NetflowFilterMenu.tsx | 21 ++- .../app/src/components/NetflowFilterPills.tsx | 54 ++++---- .../app/src/components/NetflowSankeyChart.tsx | 3 + .../app/src/components/NetflowSourceModal.tsx | 33 ++++- .../Sources/SourceForm/SourceForm.tsx | 28 ++-- .../__tests__/NetflowFilterMenu.test.tsx | 23 +++- .../__tests__/NetflowFilterPills.test.tsx | 93 ++++++++++++- .../__tests__/NetflowSankey.test.tsx | 54 ++++++++ .../__tests__/SourceForm.inference.test.tsx | 122 ++++++++++++++++-- .../useQueriedDashboardFilterValues.test.tsx | 49 ++++--- packages/app/src/netflow.ts | 5 +- .../e2e/features/netflow/monitoring.spec.ts | 42 ++++++ .../e2e/features/netflow/queries.spec.ts | 100 +++++++------- 27 files changed, 776 insertions(+), 213 deletions(-) diff --git a/.changeset/native-netflow-monitoring.md b/.changeset/native-netflow-monitoring.md index 322fe18036..a22631b79e 100644 --- a/.changeset/native-netflow-monitoring.md +++ b/.changeset/native-netflow-monitoring.md @@ -47,3 +47,12 @@ mappings consistently in REST and MCP. Apply the same bare-term search defaults to dashboards, chart editing, alert details, and dashboard filters. Support timestamp expression lists in flow records and normalize partial traffic buckets over their selected duration. + +Preserve saved source mappings when connections, databases, or metadata change. +Show removable range filters using the shared filter pills and apply configured +full-text mappings consistently in the external charts API. Mark NetFlow +navigation as beta. + +Clear applied and pending searches when switching NetFlow sources, treat invalid +sampling multipliers as one, guard stale Sankey render indices, and create cell +filter popovers only after interaction. diff --git a/docs/netflow.md b/docs/netflow.md index cace79b92a..43af8f746a 100644 --- a/docs/netflow.md +++ b/docs/netflow.md @@ -132,11 +132,18 @@ For already sampling-adjusted counters, omit the sampling-rate mapping to avoid expanding the counters twice. Full deployments persist the source through the existing authenticated source API and MongoDB model. +Missing, null, or non-positive sampling multipliers are treated as one. Positive +multipliers scale bytes and packets once across charts, records, and Sankey +paths. + Traffic rates use the seconds covered by each bucket within the selected time range, including partial first and last buckets. Flow records display and sort by the first timestamp expression when multiple timestamp mappings are configured. +Switching sources clears the search and quick filters, including pending edits, +so predicates for one table do not carry into another table. + ## Sankey visualization Select **Sankey** in the NetFlow visualization selector to explore traffic diff --git a/packages/api/openapi.json b/packages/api/openapi.json index 1bd61e6752..73e75e096f 100644 --- a/packages/api/openapi.json +++ b/packages/api/openapi.json @@ -5279,7 +5279,7 @@ }, "samplingRateExpression": { "type": "string", - "description": "Sampling multiplier applied to observed byte and packet counters. Omit or leave empty for unsampled or already scaled counters; the multiplier then defaults to one.", + "description": "Sampling multiplier applied to observed byte and packet counters. Omit or leave empty for unsampled or already scaled counters. Omitted mappings and null or non-positive row values use a multiplier of one; positive row values are preserved.", "example": "SamplingRate" }, "exporterExpression": { diff --git a/packages/api/src/mcp/__tests__/netflow-search.int.test.ts b/packages/api/src/mcp/__tests__/netflow-search.int.test.ts index bf05de563e..f6c57ac4bd 100644 --- a/packages/api/src/mcp/__tests__/netflow-search.int.test.ts +++ b/packages/api/src/mcp/__tests__/netflow-search.int.test.ts @@ -3,10 +3,10 @@ import { SourceKind } from '@hyperdx/common-utils/dist/types'; import * as config from '@/config'; import { createTeam } from '@/controllers/team'; -import { getServer } from '@/fixtures'; +import { getLoggedInAgent, getServer } from '@/fixtures'; import { buildTile, runConfigTile } from '@/mcp/tools/query/helpers'; import Connection from '@/models/connection'; -import { NetflowSource } from '@/models/source'; +import { NetflowSource, Source } from '@/models/source'; import { runSearchConfig } from '@/routers/external-api/v2/utils/search'; describe('NetFlow bare-term search through MCP and REST query paths', () => { @@ -58,6 +58,59 @@ describe('NetFlow bare-term search through MCP and REST query paths', () => { await server.stop(); }); + it.each([SourceKind.Log, SourceKind.Trace, SourceKind.Netflow])( + 'REST chart series resolves bare Lucene terms for %s', + async kind => { + const { agent, team, user } = await getLoggedInAgent(server); + const connection = await Connection.create({ + team: team._id, + name: 'Chart ClickHouse', + host: config.CLICKHOUSE_HOST, + username: config.CLICKHOUSE_USER, + password: config.CLICKHOUSE_PASSWORD, + }); + const source = await Source.create({ + kind, + team: team._id, + connection: connection.id, + name: 'Chart source', + from: { databaseName: 'default', tableName: 'netflow_search_test' }, + timestampValueExpression: 'timestamp', + defaultTableSelectExpression: 'router, bytes', + implicitColumnExpression: + kind === SourceKind.Netflow ? undefined : 'router', + srcAddrExpression: 'client', + dstAddrExpression: 'server', + srcPortExpression: 'src_port', + dstPortExpression: 'dst_port', + protocolExpression: 'protocol', + exporterExpression: 'router', + bytesExpression: 'bytes', + packetsExpression: 'packets', + }); + const response = await agent + .post('/api/v2/charts/series') + .set('Authorization', `Bearer ${user.accessKey}`) + .send({ + startTime: startDate.getTime(), + endTime: endDate.getTime(), + series: [ + { + sourceId: source.id, + dataSource: 'events', + aggFn: 'count', + where: 'west AND bytes:[1000 TO *]', + whereLanguage: 'lucene', + groupBy: [], + }, + ], + }) + .expect(200); + expect(response.body.data).toHaveLength(1); + expect(Number(response.body.data[0]['series_0.data'])).toBe(1); + }, + ); + it.each([ { name: 'fallback exporter mapping', diff --git a/packages/api/src/mcp/tools/sources/schemas.ts b/packages/api/src/mcp/tools/sources/schemas.ts index ed19be366d..6005fdd5f1 100644 --- a/packages/api/src/mcp/tools/sources/schemas.ts +++ b/packages/api/src/mcp/tools/sources/schemas.ts @@ -165,7 +165,9 @@ export const mcpSaveSourceSchema = z.object({ samplingRateExpression: z .string() .optional() - .describe('NetFlow sampling multiplier. Omit for already scaled counters.'), + .describe( + 'NetFlow sampling multiplier. Omit for unsampled or already scaled counters. Omitted mappings and null or non-positive row values use a multiplier of one.', + ), exporterExpression: z.string().optional(), inIfExpression: z.string().optional(), outIfExpression: z.string().optional(), diff --git a/packages/api/src/routers/external-api/v2/charts.ts b/packages/api/src/routers/external-api/v2/charts.ts index d8510b2608..17eb665278 100644 --- a/packages/api/src/routers/external-api/v2/charts.ts +++ b/packages/api/src/routers/external-api/v2/charts.ts @@ -299,9 +299,7 @@ const buildChartConfigFromRequest = async ( ], where: '', timestampValueExpression: source.timestampValueExpression, - ...(source.kind === SourceKind.Netflow && { - implicitColumnExpression: getSourceImplicitColumnExpression(source), - }), + implicitColumnExpression: getSourceImplicitColumnExpression(source), ...pickSampleWeightExpressionProps(source), dateRange: [new Date(params.startTime), new Date(params.endTime)] as [ Date, diff --git a/packages/api/src/routers/external-api/v2/sources.ts b/packages/api/src/routers/external-api/v2/sources.ts index 3b9c37efed..e8d6b2302a 100644 --- a/packages/api/src/routers/external-api/v2/sources.ts +++ b/packages/api/src/routers/external-api/v2/sources.ts @@ -982,7 +982,7 @@ function formatExternalSource(source: SourceDocument) { * example: Proto * samplingRateExpression: * type: string - * description: Sampling multiplier applied to observed byte and packet counters. Omit or leave empty for unsampled or already scaled counters; the multiplier then defaults to one. + * description: Sampling multiplier applied to observed byte and packet counters. Omit or leave empty for unsampled or already scaled counters. Omitted mappings and null or non-positive row values use a multiplier of one; positive row values are preserved. * example: SamplingRate * exporterExpression: * type: string diff --git a/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts b/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts index d5f9365d14..62465f6520 100644 --- a/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts +++ b/packages/api/src/tasks/checkAlerts/__tests__/netflowAlerts.int.test.ts @@ -101,9 +101,30 @@ describe('NetFlow saved-search alerts', () => { whereLanguage: 'lucene' as const, implicitColumnExpression: "concat('customflow ', toString(SrcAddr))", }, + { + name: 'no matching flows', + savedSelect: undefined, + where: 'traffic:[999999 TO *]', + whereLanguage: 'lucene' as const, + noMatch: true, + }, + { + name: 'grouped flow alerts', + savedSelect: undefined, + where: 'traffic:[1000 TO *]', + whereLanguage: 'lucene' as const, + groupBy: 'SrcAddr', + }, ])( 'evaluates and delivers $name with aliased samples', - async ({ savedSelect, where, whereLanguage, implicitColumnExpression }) => { + async ({ + savedSelect, + where, + whereLanguage, + implicitColumnExpression, + noMatch, + groupBy, + }) => { const postMessage = jest .spyOn(slack, 'postMessageToWebhook') .mockResolvedValue({ text: 'ok' }); @@ -159,7 +180,8 @@ describe('NetFlow saved-search alerts', () => { channel: { type: 'webhook', webhookId: webhook.id }, interval: '5m', thresholdType: AlertThresholdType.ABOVE, - threshold: 2, + threshold: groupBy ? 1 : 2, + groupBy, }, new mongoose.Types.ObjectId(), ); @@ -190,7 +212,9 @@ describe('NetFlow saved-search alerts', () => { const alertProvider = await loadProvider(); const metadata = getMetadata(clickhouseClient); const teamWebhooksById = new Map([[webhook.id, webhook]]); - const expectedSamples = '"2001:db8::1",2000\n"2001:db8::2",3000\n'; + const expectedSamples = noMatch + ? '' + : '"2001:db8::1",2000\n"2001:db8::2",3000\n'; expect( await fetchSampleLines({ clickhouseClient, @@ -218,18 +242,20 @@ describe('NetFlow saved-search alerts', () => { ); expect(await Alert.findById(alert.id).lean()).toMatchObject({ - state: AlertState.ALERT, + state: noMatch ? AlertState.OK : AlertState.ALERT, executionErrors: [], }); const histories = await AlertHistory.find({ alert: alert.id }).lean(); - expect(histories).toHaveLength(1); - expect(histories[0]).toMatchObject({ - state: AlertState.ALERT, - lastValues: [{ count: 2 }], - }); - expect(postMessage).toHaveBeenCalledTimes(1); + expect(histories).toHaveLength(groupBy ? 2 : 1); + for (const history of histories) + expect(history).toMatchObject({ + state: noMatch ? AlertState.OK : AlertState.ALERT, + lastValues: [{ count: noMatch ? 0 : groupBy ? 1 : 2 }], + }); + expect(postMessage).toHaveBeenCalledTimes(noMatch ? 0 : groupBy ? 2 : 1); + if (noMatch) return; expect(JSON.stringify(postMessage.mock.calls[0][1])).toContain( - '2 lines found', + `${groupBy ? 1 : 2} lines found`, ); expect(JSON.stringify(postMessage.mock.calls[0][1])).toContain( '2001:db8::1', diff --git a/packages/app/src/NetflowPage.tsx b/packages/app/src/NetflowPage.tsx index 4edbdeea8a..2b2763af29 100644 --- a/packages/app/src/NetflowPage.tsx +++ b/packages/app/src/NetflowPage.tsx @@ -18,7 +18,7 @@ import { Stack, Text, } from '@mantine/core'; -import { IconNetwork, IconPlayerPlay, IconRefresh } from '@tabler/icons-react'; +import { IconPlayerPlay, IconRefresh } from '@tabler/icons-react'; import EmptyState from '@/components/EmptyState'; import { TextInputControlled } from '@/components/InputControlled'; @@ -26,7 +26,9 @@ import NetflowCharts from '@/components/NetflowCharts'; import NetflowFilterPills, { useNetflowFilterState, } from '@/components/NetflowFilterPills'; -import NetflowSourceModal from '@/components/NetflowSourceModal'; +import NetflowSourceModal, { + NetflowSourceEmptyState, +} from '@/components/NetflowSourceModal'; import { PageLayout } from '@/components/PageLayout'; import SearchWhereInput from '@/components/SearchInput/SearchWhereInput'; import { SourceSelectControlled } from '@/components/SourceSelect'; @@ -66,13 +68,24 @@ function NetflowPage() { const { control, handleSubmit, reset } = useForm({ values: { ...formParams, source: source?.id ?? '' }, }); + const clearFilters = (id = source?.id ?? '') => { + const cleared = { + source: id, + where: '', + whereLanguage: params.whereLanguage, + exporter: '', + protocol: '', + srcAddr: '', + dstAddr: '', + }; + reset(cleared); + void setParams({ ...cleared, filters: [] }); + }; const selectedSource = useWatch({ control, name: 'source' }); const syncSource = useEffectEvent((id: string) => { if (id && id !== params.source) { - void setParams({ - source: id, - ...(id !== source?.id ? { filters: [] } : {}), - }); + if (id !== source?.id) clearFilters(id); + else void setParams({ source: id }); } }); useEffect(() => { @@ -103,7 +116,12 @@ function NetflowPage() { ? buildNetflowQueryConfigs({ source, dateRange: searchedTimeRange, - filters: params, + filters: { + exporter: params.exporter, + protocol: params.protocol, + srcAddr: params.srcAddr, + dstAddr: params.dstAddr, + }, where: params.where, whereLanguage: params.whereLanguage, extraFilters: params.filters, @@ -126,7 +144,7 @@ function NetflowPage() { sourceId={source?.id} onClose={() => setSourceModal(null)} onCreate={created => { - void setParams({ source: created.id, filters: [] }); + clearFilters(created.id); setSourceModal(null); }} /> @@ -212,26 +230,16 @@ function NetflowPage() { } /> ))} - - + {configs && ( + + )} Explore traffic, top talkers, and recent flows. Rates average over the selected time range; counters account for sampling. @@ -263,27 +271,10 @@ function NetflowPage() { onTimeRangeSelect={onTimeRangeSelect} /> ) : ( - } - title={ - params.source - ? 'NetFlow source unavailable' - : 'No NetFlow sources configured' - } - description={ - params.source - ? 'Select an available NetFlow source and run the query, or add a new source.' - : 'Connect your Akvorado or NetFlow table in ClickHouse to monitor network traffic.' - } - variant="card" - > - - + setSourceModal('new')} + /> )} } diff --git a/packages/app/src/ServicesDashboardPage/helpers.ts b/packages/app/src/ServicesDashboardPage/helpers.ts index ccf437a32a..fc1202089d 100644 --- a/packages/app/src/ServicesDashboardPage/helpers.ts +++ b/packages/app/src/ServicesDashboardPage/helpers.ts @@ -1,5 +1,4 @@ import SqlString from 'sqlstring'; -import { getSourceImplicitColumnExpression } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import type { TSource } from '@hyperdx/common-utils/dist/types'; import { Filter, @@ -20,9 +19,9 @@ export function pickSourceConfigFields(source: TSource) { timestampValueExpression: source.timestampValueExpression, connection: source.connection, from: source.from, - implicitColumnExpression: getSourceImplicitColumnExpression(source), ...(isLogSource(source) || isTraceSource(source) ? { + implicitColumnExpression: source.implicitColumnExpression, useTextIndexForImplicitColumn: source.useTextIndexForImplicitColumn, } : {}), diff --git a/packages/app/src/__tests__/NetflowPage.drafts.test.tsx b/packages/app/src/__tests__/NetflowPage.drafts.test.tsx index 897e771157..c71ad4f597 100644 --- a/packages/app/src/__tests__/NetflowPage.drafts.test.tsx +++ b/packages/app/src/__tests__/NetflowPage.drafts.test.tsx @@ -26,11 +26,17 @@ const mockSource: TNetflowSource = { protocolExpression: 'Proto', exporterExpression: 'ExporterName', }; +const mockOtherSource = { + ...mockSource, + id: 'other', + name: 'Other flows', + protocolExpression: 'IPProtocol', +}; const mockRange = [ new Date('2026-10-09T12:00:00Z'), new Date('2026-10-09T13:00:00Z'), ]; -const mockInitialParams = { +let mockInitialParams = { source: 'flows', where: 'Proto:6', whereLanguage: 'lucene', @@ -63,7 +69,9 @@ jest.mock('nuqs', () => ({ ]; }, })); -jest.mock('@/source', () => ({ useSources: () => ({ data: [mockSource] }) })); +jest.mock('@/source', () => ({ + useSources: () => ({ data: [mockSource, mockOtherSource] }), +})); jest.mock('@/layout', () => ({ withAppNavForSurface: () => (page: unknown) => page, })); @@ -76,9 +84,16 @@ jest.mock('@/netflow', () => ({ })); jest.mock('@/components/NetflowCharts', () => () => null); jest.mock('@/components/NetflowSourceModal', () => () => null); -jest.mock('@/components/SourceSelect', () => ({ - SourceSelectControlled: () => null, -})); +jest.mock('@/components/SourceSelect', () => { + const Input = jest.requireActual( + '@/components/InputControlled', + ).TextInputControlled; + return { + SourceSelectControlled: ({ control }: { control: unknown }) => ( + + ), + }; +}); jest.mock('@/components/TimePicker', () => ({ TimePicker: () => null })); jest.mock('@/hooks/useDashboardRefresh', () => ({ useDashboardRefresh: () => ({ refresh: jest.fn() }), @@ -112,6 +127,86 @@ jest.mock('@/components/SearchInput/SearchWhereInput', () => { }); describe('NetFlow draft search fields', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockInitialParams = { + source: 'flows', + where: 'Proto:6', + whereLanguage: 'lucene', + filters: [], + exporter: '', + protocol: '', + srcAddr: '', + dstAddr: '', + }; + }); + + it('clears applied search, quick filters and unsubmitted drafts when switching sources', async () => { + mockInitialParams = { + ...mockInitialParams, + exporter: 'edge', + protocol: '6', + srcAddr: '192.0.2.1', + dstAddr: '192.0.2.2', + }; + renderWithMantine(); + const query = await screen.findByLabelText('Query'); + act(() => + mockFilterChange([{ type: 'sql', condition: "ExporterName = 'edge'" }]), + ); + fireEvent.change(query, { target: { value: 'MissingOldColumn:443' } }); + fireEvent.change(screen.getByLabelText('Protocol'), { + target: { value: '17' }, + }); + fireEvent.change(screen.getByLabelText('Source'), { + target: { value: 'other' }, + }); + await waitFor(() => + expect( + jest.mocked(buildNetflowQueryConfigs).mock.lastCall?.[0], + ).toMatchObject({ + source: mockOtherSource, + where: '', + extraFilters: [], + filters: { exporter: '', protocol: '', srcAddr: '', dstAddr: '' }, + }), + ); + for (const label of [ + 'Query', + 'Exporter', + 'Protocol', + 'Source IP', + 'Destination IP', + ]) { + expect(screen.getByLabelText(label)).toHaveValue(''); + } + expect( + Object.keys( + jest.mocked(buildNetflowQueryConfigs).mock.lastCall?.[0].filters ?? {}, + ).sort(), + ).toEqual(['dstAddr', 'exporter', 'protocol', 'srcAddr']); + }); + + it('preserves applied filters while canonicalizing a source name to its ID', async () => { + mockInitialParams = { + ...mockInitialParams, + source: 'Flows', + protocol: '6', + }; + renderWithMantine(); + await screen.findByLabelText('Query'); + await waitFor(() => + expect(screen.getByLabelText('Source')).toHaveValue('flows'), + ); + expect( + jest.mocked(buildNetflowQueryConfigs).mock.lastCall?.[0], + ).toMatchObject({ + source: mockSource, + where: 'Proto:6', + filters: { protocol: '6' }, + }); + }); + it('preserves drafts until Run when clicks or migrations update filters', async () => { renderWithMantine(); const query = await screen.findByLabelText('Query'); diff --git a/packages/app/src/__tests__/netflow.test.ts b/packages/app/src/__tests__/netflow.test.ts index cab209a1de..24959cc776 100644 --- a/packages/app/src/__tests__/netflow.test.ts +++ b/packages/app/src/__tests__/netflow.test.ts @@ -9,7 +9,6 @@ import { buildAlertChartConfig } from '@/components/alerts/AlertDetailChart'; import { convertFormStateToChartConfig } from '@/components/ChartEditor/utils'; import { buildReleaseChartConfig } from '@/hooks/useReleaseAnnotations'; import { buildNetflowQueryConfigs, buildNetflowWhere } from '@/netflow'; -import { pickSourceConfigFields } from '@/ServicesDashboardPage/helpers'; const source: TNetflowSource = { id: 'flows', @@ -90,17 +89,24 @@ describe('NetFlow queries', () => { dateRange, filters: {}, }); + expect(configs.flows.select).toContainEqual({ + valueExpression: 'if((SamplingRate) > 0, (SamplingRate), 1)', + alias: '__netflow_samplingRate', + }); expect(configs.summary.select).toEqual([ { - valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 / 3600', + valueExpression: + 'sum(toFloat64(Bytes) * (if((SamplingRate) > 0, (SamplingRate), 1))) * 8 / 3600', alias: '__netflow_bitsPerSecond', }, { - valueExpression: 'sum(toFloat64(Packets) * (SamplingRate)) / 3600', + valueExpression: + 'sum(toFloat64(Packets) * (if((SamplingRate) > 0, (SamplingRate), 1))) / 3600', alias: '__netflow_packetsPerSecond', }, { - valueExpression: 'sum(toFloat64(Bytes) * (SamplingRate))', + valueExpression: + 'sum(toFloat64(Bytes) * (if((SamplingRate) > 0, (SamplingRate), 1)))', alias: '__netflow_bytes', }, { valueExpression: 'count()', alias: '__netflow_flowRecords' }, @@ -108,7 +114,7 @@ describe('NetFlow queries', () => { expect(configs.traffic.select).toEqual([ { valueExpression: expect.stringContaining( - 'sum(toFloat64(Bytes) * (SamplingRate)) * 8 /', + 'sum(toFloat64(Bytes) * (if((SamplingRate) > 0, (SamplingRate), 1))) * 8 /', ), alias: 'Bits per second', }, @@ -226,12 +232,6 @@ describe.each([undefined, 'SearchText'])( const expected = implicitColumnExpression ?? expect.stringContaining('toString(SrcAddr)'); - it('carries the search mapping through generic source field projection', () => { - expect(pickSourceConfigFields(selectedSource)).toMatchObject({ - implicitColumnExpression: expected, - }); - }); - it.each(['sql', 'builder'] as const)( 'carries the search mapping into %s chart editor previews', configType => { diff --git a/packages/app/src/components/ActiveFilterPills.tsx b/packages/app/src/components/ActiveFilterPills.tsx index 86d8234951..ad88da4c7f 100644 --- a/packages/app/src/components/ActiveFilterPills.tsx +++ b/packages/app/src/components/ActiveFilterPills.tsx @@ -74,6 +74,8 @@ function flattenFilters( function SearchFilterPill({ pill, + fieldLabel, + enableValueEditing, isInvalid, invalidReason, chartConfig, @@ -82,6 +84,8 @@ function SearchFilterPill({ onReplaceValue, }: { pill: PillItem; + fieldLabel?: string; + enableValueEditing: boolean; isInvalid?: boolean; invalidReason?: string; chartConfig: BuilderChartConfigWithDateRange; @@ -92,7 +96,7 @@ function SearchFilterPill({ const isExcluded = pill.type === 'excluded'; // A range pill has no single value to copy or flip, and an unapplied filter // (column missing on the active source) can only be removed. - const isEditable = pill.type !== 'range' && !isInvalid; + const isEditable = enableValueEditing && pill.type !== 'range' && !isInvalid; const [opened, setOpened] = useState(false); // The picker lists values to switch this pill to, so it must not be scoped @@ -118,7 +122,7 @@ function SearchFilterPill({ return ( , + isBeta: true, }, ]; diff --git a/packages/app/src/components/NetflowFilterMenu.tsx b/packages/app/src/components/NetflowFilterMenu.tsx index ef4232b14e..b9a2d4d636 100644 --- a/packages/app/src/components/NetflowFilterMenu.tsx +++ b/packages/app/src/components/NetflowFilterMenu.tsx @@ -1,4 +1,4 @@ -import { KeyboardEventHandler, ReactElement, useState } from 'react'; +import { KeyboardEventHandler, memo, ReactElement, useState } from 'react'; import { Button, Menu } from '@mantine/core'; import { NETFLOW_DIMENSION_LABELS, NetflowFilterField } from '@/netflow'; @@ -13,6 +13,9 @@ type TargetProps = { opened: boolean; buttonProps: { 'aria-label': string; + 'aria-haspopup'?: 'menu'; + 'aria-expanded'?: boolean; + onClick: () => void; onKeyDown: KeyboardEventHandler; role?: 'button'; tabIndex?: number; @@ -27,8 +30,9 @@ type Props = { | { label: string; onSelect?: (excluded: boolean) => void } ); -export default function NetflowFilterMenu(props: Props) { +export default memo(function NetflowFilterMenu(props: Props) { const [opened, setOpened] = useState(false); + const [activated, setActivated] = useState(false); const { value, allowEmpty = false, target } = props; const label = 'field' in props ? NETFLOW_DIMENSION_LABELS[props.field] : props.label; @@ -41,11 +45,20 @@ export default function NetflowFilterMenu(props: Props) { const displayValue = value || (allowEmpty ? '(empty)' : '—'); const buttonProps: TargetProps['buttonProps'] = { 'aria-label': `Filter ${label}: ${displayValue}`, + 'aria-haspopup': interactive ? 'menu' : undefined, + 'aria-expanded': interactive ? opened : undefined, + onClick: () => { + if (interactive && !activated) { + setActivated(true); + setOpened(true); + } + }, role: interactive ? 'button' : undefined, tabIndex: interactive ? 0 : undefined, onKeyDown: event => { if (interactive && (event.key === 'Enter' || event.key === ' ')) { event.preventDefault(); + setActivated(true); setOpened(true); } }, @@ -56,6 +69,8 @@ export default function NetflowFilterMenu(props: Props) { ); if (!interactive) return target ? renderedTarget : <>{displayValue}; + // Flow tables can have thousands of filter cells; create a popover only when used. + if (!activated) return renderedTarget; return ( ); -} +}); diff --git a/packages/app/src/components/NetflowFilterPills.tsx b/packages/app/src/components/NetflowFilterPills.tsx index f685b60a28..8d27f1726a 100644 --- a/packages/app/src/components/NetflowFilterPills.tsx +++ b/packages/app/src/components/NetflowFilterPills.tsx @@ -5,8 +5,11 @@ import { isRenderablePinnedFilter, parseQuery, } from '@hyperdx/common-utils/dist/filters'; -import { Filter, TNetflowSource } from '@hyperdx/common-utils/dist/types'; -import { Group } from '@mantine/core'; +import { + BuilderChartConfigWithDateRange, + Filter, + TNetflowSource, +} from '@hyperdx/common-utils/dist/types'; import { getNetflowDimensions, NETFLOW_DIMENSION_LABELS } from '@/netflow'; import { @@ -17,12 +20,9 @@ import { import { useSearchPageFilterState } from '@/searchFilters'; import { cleanClickHouseExpression } from './DBSearchPageFilters/utils'; -import { FilterPill } from './FilterPill'; +import { ActiveFilterPills } from './ActiveFilterPills'; import { NetflowFilterHandler } from './NetflowFilterMenu'; -// Filter keys are complete SQL expressions; restore their original quoting at emission. -const knownColumns = new Set(); - // Table and Sankey filters can wrap the same mapped field in different SQL expressions. function canonicalizeMappedFilters( filters: Filter[], @@ -65,6 +65,8 @@ export function useNetflowFilterState({ filters: Filter[]; onChange: (filters: Filter[]) => void; }) { + // Keys are SQL expressions, not physical columns; restore their quoting at emission. + const knownColumns = useMemo(() => new Set(), []); const dimensions = useMemo( () => (source ? getNetflowDimensions(source) : undefined), [source], @@ -189,31 +191,21 @@ export function useNetflowFilterState({ export default function NetflowFilterPills({ state, fieldLabels, -}: Pick, 'state' | 'fieldLabels'>) { + chartConfig, +}: Pick, 'state' | 'fieldLabels'> & { + chartConfig: BuilderChartConfigWithDateRange; +}) { return ( - - {Object.entries(state.filters).flatMap(([field, values]) => - (['included', 'excluded'] as const).flatMap(polarity => - Array.from(values[polarity]).map(value => ( - - state.setFilterValue( - field, - value, - polarity === 'excluded' ? 'exclude' : 'include', - ) - } - /> - )), - ), - )} - + + fieldLabels[field] ?? unwrapSankeyDimensionExpression(field) + } + px={0} + gap="xs" + data-testid="netflow-filter-pills" + /> ); } diff --git a/packages/app/src/components/NetflowSankeyChart.tsx b/packages/app/src/components/NetflowSankeyChart.tsx index cb50f10a9e..643589fadb 100644 --- a/packages/app/src/components/NetflowSankeyChart.tsx +++ b/packages/app/src/components/NetflowSankeyChart.tsx @@ -77,6 +77,7 @@ export default function NetflowSankeyChart({ }} node={props => { const node = data.nodes[props.index]; + if (!node) return ; return ( { const link = data.links[props.index]; + if (!link) return ; const source = data.nodes[link.source]; const target = data.nodes[link.target]; + if (!source || !target) return ; return ( void; +}) { + return ( + } + title={ + selected + ? 'NetFlow source unavailable' + : 'No NetFlow sources configured' + } + description={ + selected + ? 'Select an available NetFlow source and run the query, or add a new source.' + : 'Connect your Akvorado or NetFlow table in ClickHouse to monitor network traffic.' + } + variant="card" + > + + + ); +} + export default function NetflowSourceModal({ mode, sourceId, diff --git a/packages/app/src/components/Sources/SourceForm/SourceForm.tsx b/packages/app/src/components/Sources/SourceForm/SourceForm.tsx index cb035dfb6c..c70346cbb6 100644 --- a/packages/app/src/components/Sources/SourceForm/SourceForm.tsx +++ b/packages/app/src/components/Sources/SourceForm/SourceForm.tsx @@ -184,21 +184,22 @@ export function TableSourceForm({ useEffect(() => { let cancelled = false; + // Reconnecting a saved source to a replica must preserve its custom mappings. + const shouldInfer = + watchedTableName !== prevTableNameRef.current || + watchedKind !== prevKindRef.current || + (isNew && + (watchedConnection !== prevConnectionRef.current || + watchedDatabaseName !== prevDatabaseNameRef.current || + metadata !== prevMetadataRef.current)); + prevTableNameRef.current = watchedTableName; + prevKindRef.current = watchedKind; + prevConnectionRef.current = watchedConnection; + prevDatabaseNameRef.current = watchedDatabaseName; + prevMetadataRef.current = metadata; (async () => { try { - if ( - watchedTableName !== prevTableNameRef.current || - watchedKind !== prevKindRef.current || - watchedConnection !== prevConnectionRef.current || - watchedDatabaseName !== prevDatabaseNameRef.current || - metadata !== prevMetadataRef.current - ) { - prevTableNameRef.current = watchedTableName; - prevKindRef.current = watchedKind; - prevConnectionRef.current = watchedConnection; - prevDatabaseNameRef.current = watchedDatabaseName; - prevMetadataRef.current = metadata; - + if (shouldInfer) { if (isPrometheusOnlyConnection) { return; } @@ -256,6 +257,7 @@ export function TableSourceForm({ setValue, getFieldState, isPrometheusOnlyConnection, + isNew, ]); // Sets the default connection field to the first connection after the diff --git a/packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx b/packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx index 5ab0028d40..b715484a7b 100644 --- a/packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx +++ b/packages/app/src/components/__tests__/NetflowFilterMenu.test.tsx @@ -1,10 +1,31 @@ -import { fireEvent, screen } from '@testing-library/react'; +import { MantineProvider } from '@mantine/core'; +import { fireEvent, screen, waitFor } from '@testing-library/react'; import NetflowFilterMenu from '@/components/NetflowFilterMenu'; import NetflowSankeyTable from '@/components/NetflowSankeyTable'; import { buildNetflowSankeyData } from '@/netflowSankey'; describe('NetFlow filter menus', () => { + it('allocates a popover only after interaction and reopens after closing', async () => { + renderWithMantine( + + + , + ); + const target = () => + screen.getByRole('button', { name: 'Filter Protocol: TCP' }); + expect(target()).not.toHaveAttribute('id'); + fireEvent.click(target()); + fireEvent.click(await screen.findByRole('menuitem', { name: 'Include' })); + await waitFor(() => + expect(screen.queryByRole('menu')).not.toBeInTheDocument(), + ); + fireEvent.click(target()); + expect( + await screen.findByRole('menuitem', { name: 'Exclude' }), + ).toBeVisible(); + }); + it.each([ ['Include', false], ['Exclude', true], diff --git a/packages/app/src/components/__tests__/NetflowFilterPills.test.tsx b/packages/app/src/components/__tests__/NetflowFilterPills.test.tsx index 5b646c7bed..9e4350a0ff 100644 --- a/packages/app/src/components/__tests__/NetflowFilterPills.test.tsx +++ b/packages/app/src/components/__tests__/NetflowFilterPills.test.tsx @@ -1,3 +1,4 @@ +import { useState } from 'react'; import { enableMapSet } from 'immer'; import { parseQuery } from '@hyperdx/common-utils/dist/filters'; import { @@ -5,14 +6,28 @@ import { SourceKind, TNetflowSource, } from '@hyperdx/common-utils/dist/types'; -import { act, renderHook } from '@testing-library/react'; +import { + act, + fireEvent, + renderHook, + screen, + within, +} from '@testing-library/react'; -import { useNetflowFilterState } from '@/components/NetflowFilterPills'; -import { getNetflowDimensions } from '@/netflow'; +import NetflowFilterPills, { + useNetflowFilterState, +} from '@/components/NetflowFilterPills'; +import { useGetKeyValues } from '@/hooks/useMetadata'; +import { buildNetflowQueryConfigs, getNetflowDimensions } from '@/netflow'; import { sankeyDimensionExpression } from '@/netflowSankey'; enableMapSet(); +jest.mock('@/hooks/useMetadata', () => ({ + useGetKeyValues: jest.fn(() => ({ data: [], isFetching: false })), +})); +jest.mock('@/useFormatTime', () => ({ useFormatTime: () => String })); + const source: TNetflowSource = { id: 'flows', name: 'Flows', @@ -56,6 +71,78 @@ function renderFilters(filters: Filter[] = [], selectedSource = source) { } describe('NetFlow explicit filter actions', () => { + it('keeps custom SQL expression pills read-only without looking up unquoted columns', () => { + jest.mocked(useGetKeyValues).mockClear(); + const dimension = { + key: 'provider', + label: 'Provider', + expression: '`provider-name`', + }; + const { result } = renderFilters([ + { + type: 'sql', + condition: `${sankeyDimensionExpression(dimension)} IN ('transit')`, + }, + ]); + const configs = buildNetflowQueryConfigs({ + source, + dateRange: [new Date(0), new Date(60000)], + filters: {}, + }); + renderWithMantine( + , + ); + fireEvent.click(screen.getByText('provider-name')); + expect(useGetKeyValues).toHaveBeenCalled(); + expect(useGetKeyValues).not.toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ enabled: true }), + ); + expect(screen.getByText('transit')).toBeInTheDocument(); + }); + + it('shows a URL range filter and removes it without dropping other fields', () => { + const onChange = jest.fn(); + function Pills() { + const [filters, setFilters] = useState([ + { type: 'sql', condition: 'Bytes BETWEEN 100 AND 200' }, + { type: 'sql', condition: "ExporterName IN ('edge-a')" }, + ]); + const state = useNetflowFilterState({ + source, + filters, + onChange: next => { + onChange(next); + setFilters(next); + }, + }); + const configs = buildNetflowQueryConfigs({ + source, + dateRange: [new Date(0), new Date(60000)], + filters: {}, + }); + return ; + } + renderWithMantine(); + expect(screen.getByText('100 – 200')).toBeInTheDocument(); + expect(screen.getByText('Exporter')).toBeInTheDocument(); + const range = screen.getByTestId('active-filter-pill-Bytes'); + fireEvent.click( + within(range).getByRole('button', { name: 'Remove filter' }), + ); + expect(screen.queryByText('100 – 200')).not.toBeInTheDocument(); + expect(screen.getByText('edge-a')).toBeInTheDocument(); + expect(parseQuery(onChange.mock.lastCall?.[0]).filters).toEqual({ + [sankeyDimensionExpression(exporter)]: { + included: new Set(['edge-a']), + excluded: new Set(), + }, + }); + }); + it.each([ ['exporter', 'edge-a', 'edge-b'], ['protocol', 'TCP', 'UDP'], diff --git a/packages/app/src/components/__tests__/NetflowSankey.test.tsx b/packages/app/src/components/__tests__/NetflowSankey.test.tsx index 6fcab6caac..29016eb7c0 100644 --- a/packages/app/src/components/__tests__/NetflowSankey.test.tsx +++ b/packages/app/src/components/__tests__/NetflowSankey.test.tsx @@ -1,9 +1,29 @@ +import { ReactNode } from 'react'; import { SourceKind, TNetflowSource } from '@hyperdx/common-utils/dist/types'; import { MantineProvider } from '@mantine/core'; import { screen } from '@testing-library/react'; import NetflowSankey from '@/components/NetflowSankey'; +import NetflowSankeyChart from '@/components/NetflowSankeyChart'; import { buildNetflowQueryConfigs } from '@/netflow'; +import { buildNetflowSankeyData } from '@/netflowSankey'; + +let mockRender: 'node' | 'link' = 'node'; +let mockIndex = 99; +jest.mock('recharts', () => ({ + ResponsiveContainer: ({ children }: { children: ReactNode }) => children, + Sankey: ({ + node, + link, + }: { + node: (props: { index: number }) => ReactNode; + link: (props: { index: number }) => ReactNode; + }) => ( + + {(mockRender === 'node' ? node : link)({ index: mockIndex })} + + ), +})); let mockColumns = [{ name: 'SrcAS', type: 'UInt32' }]; jest.mock('@/hooks/useMetadata', () => ({ @@ -39,6 +59,40 @@ const source: TNetflowSource = { exporterExpression: 'Exporter', }; +it.each(['node', 'link', 'source', 'target'] as const)( + 'ignores an out-of-bounds Sankey %s during a data transition', + missing => { + const dimensions = [ + { key: 'src', label: 'Source', expression: 'SrcAddr' }, + { key: 'dst', label: 'Destination', expression: 'DstAddr' }, + ]; + const data = buildNetflowSankeyData( + [ + { + __netflow_dimension_0: 'a', + __netflow_dimension_1: 'b', + __netflow_value: 100, + }, + ], + dimensions, + ); + mockRender = missing === 'node' ? 'node' : 'link'; + mockIndex = missing === 'node' || missing === 'link' ? 99 : 0; + if (missing === 'source' || missing === 'target') + data.links[0][missing] = 99; + renderWithMantine( + , + ); + expect( + screen.getByTestId('mock-sankey').querySelector('rect, path'), + ).toBeNull(); + }, +); + it('falls back to available dimensions when the selected column disappears on a source switch', () => { const baseConfig = buildNetflowQueryConfigs({ source, diff --git a/packages/app/src/components/__tests__/SourceForm.inference.test.tsx b/packages/app/src/components/__tests__/SourceForm.inference.test.tsx index 5655de3f36..b1594bbcdf 100644 --- a/packages/app/src/components/__tests__/SourceForm.inference.test.tsx +++ b/packages/app/src/components/__tests__/SourceForm.inference.test.tsx @@ -1,13 +1,25 @@ import { Control, Controller, FieldPath } from 'react-hook-form'; -import { SourceKind, TSource } from '@hyperdx/common-utils/dist/types'; +import { + SourceKind, + SourceSchema, + TSource, +} from '@hyperdx/common-utils/dist/types'; +import { MantineProvider } from '@mantine/core'; import { notifications } from '@mantine/notifications'; -import { act, fireEvent, screen, waitFor } from '@testing-library/react'; +import { + act, + fireEvent, + render, + screen, + waitFor, +} from '@testing-library/react'; import { TableSourceForm } from '@/components/Sources/SourceForm/SourceForm'; import { inferTableSourceConfig } from '@/source'; const mockConnections = [{ id: 'local', name: 'Local ClickHouse' }]; -const mockMetadata = {}; +let mockMetadata = {}; +let mockSource: TSource | undefined; jest.mock('@/connection', () => ({ useConnections: () => ({ data: mockConnections }), })); @@ -16,7 +28,7 @@ jest.mock('@/hooks/useMetadata', () => ({ })); jest.mock('@/source', () => ({ inferTableSourceConfig: jest.fn(), - useSource: () => ({ data: undefined }), + useSource: () => ({ data: mockSource }), useSources: () => ({ data: [] }), useCreateSource: () => ({ mutate: jest.fn() }), useUpdateSource: () => ({ mutate: jest.fn(), mutateAsync: jest.fn() }), @@ -77,8 +89,76 @@ const deferred = () => describe('Source form asynchronous inference', () => { beforeEach(() => { jest.clearAllMocks(); + mockSource = undefined; + mockMetadata = {}; }); + it.each([SourceKind.Log, SourceKind.Trace, SourceKind.Netflow])( + 'preserves saved %s mappings on replica changes and infers a newly selected table or kind', + async kind => { + mockSource = SourceSchema.parse({ + id: 'saved', + name: 'Saved source', + kind, + connection: 'local', + from: { databaseName: 'default', tableName: 'events' }, + timestampValueExpression: 'CustomTime', + defaultTableSelectExpression: '*', + bytesExpression: 'CustomBytes', + packetsExpression: 'Packets', + srcAddrExpression: 'SrcAddr', + dstAddrExpression: 'DstAddr', + srcPortExpression: 'SrcPort', + dstPortExpression: 'DstPort', + protocolExpression: 'Proto', + durationExpression: 'Duration', + traceIdExpression: 'TraceId', + spanIdExpression: 'SpanId', + parentSpanIdExpression: 'ParentSpanId', + spanNameExpression: 'SpanName', + spanKindExpression: 'SpanKind', + }); + mockInfer.mockResolvedValue({ + kind, + timestampValueExpression: 'Detected', + }); + const view = render(, { + wrapper: MantineProvider, + }); + for (const field of ['connection', 'from.databaseName', 'metadata']) { + await act(async () => { + if (field === 'metadata') { + mockMetadata = {}; + view.rerender(); + } else { + fireEvent.change(screen.getByLabelText(field), { + target: { value: 'replica' }, + }); + } + }); + expect(mockInfer).not.toHaveBeenCalled(); + expect(screen.getByLabelText('timestampValueExpression')).toHaveValue( + 'CustomTime', + ); + } + fireEvent.change(screen.getByLabelText('from.tableName'), { + target: { value: 'new_events' }, + }); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(1)); + await waitFor(() => + expect(screen.getByLabelText('timestampValueExpression')).toHaveValue( + 'Detected', + ), + ); + fireEvent.click( + screen.getByRole('radio', { + name: kind === SourceKind.Netflow ? 'Logs' : 'NetFlow', + }), + ); + await waitFor(() => expect(mockInfer).toHaveBeenCalledTimes(2)); + }, + ); + it('ignores a NetFlow response that arrives after the user selects Logs', async () => { const oldRequest = deferred(); const latestRequest = deferred(); @@ -157,19 +237,39 @@ describe('Source form asynchronous inference', () => { }); it.each([ - ['from.databaseName', 'other_database', { databaseName: 'other_database' }], - ['connection', 'other_connection', { connectionId: 'other_connection' }], + [ + SourceKind.Netflow, + 'from.databaseName', + 'other_database', + { databaseName: 'other_database' }, + ], + [ + SourceKind.Netflow, + 'connection', + 'other_connection', + { connectionId: 'other_connection' }, + ], + [ + SourceKind.Log, + 'connection', + 'other_connection', + { connectionId: 'other_connection' }, + ], + [ + SourceKind.Trace, + 'from.databaseName', + 'other_database', + { databaseName: 'other_database' }, + ], ] as const)( - 'restarts discovery for the same table after %s changes', - async (field, value, expected) => { + 'restarts new %s discovery for the same table after %s changes', + async (kind, field, value, expected) => { const oldRequest = deferred(); const latestRequest = deferred(); mockInfer .mockReturnValueOnce(oldRequest.promise) .mockReturnValueOnce(latestRequest.promise); - renderWithMantine( - , - ); + renderWithMantine(); fireEvent.change(screen.getByLabelText('from.tableName'), { target: { value: 'flows' }, }); diff --git a/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx b/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx index 402b7556bd..d983997606 100644 --- a/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx +++ b/packages/app/src/hooks/__tests__/useQueriedDashboardFilterValues.test.tsx @@ -10,9 +10,14 @@ import { MetricsDataType, QueryExpressionDashboardFilter, SourceKind, + TNetflowSource, TSource, } from '@hyperdx/common-utils/dist/types'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { + QueryClient, + QueryClientProvider, + useQuery, +} from '@tanstack/react-query'; import { renderHook, waitFor } from '@testing-library/react'; import * as useMetadataModule from '@/hooks/useMetadata'; @@ -163,22 +168,32 @@ describe('useQueriedDashboardFilterValues', () => { }); it('uses mapped NetFlow fields for bare Lucene terms in filter-value queries', async () => { - jest.spyOn(sourceModule, 'useSources').mockReturnValue({ - data: [ - { - ...mockSources[0], - id: 'flows', - kind: SourceKind.Netflow, - srcAddrExpression: 'ClientIP', - dstAddrExpression: 'ServerIP', - srcPortExpression: 'ClientPort', - dstPortExpression: 'ServerPort', - protocolExpression: 'Protocol', - exporterExpression: 'RouterName', - }, - ], - isLoading: false, - } as any); + const source: TNetflowSource = { + id: 'flows', + name: 'Flows', + kind: SourceKind.Netflow, + connection: 'clickhouse-conn', + from: { databaseName: 'telemetry', tableName: 'flows' }, + timestampValueExpression: 'timestamp', + defaultTableSelectExpression: '*', + bytesExpression: 'Bytes', + packetsExpression: 'Packets', + srcAddrExpression: 'ClientIP', + dstAddrExpression: 'ServerIP', + srcPortExpression: 'ClientPort', + dstPortExpression: 'ServerPort', + protocolExpression: 'Protocol', + exporterExpression: 'RouterName', + }; + jest + .spyOn(sourceModule, 'useSources') + .mockImplementation(function useMockSources() { + return useQuery({ + queryKey: ['netflow-test-sources'], + queryFn: async () => [source], + initialData: [source], + }); + }); const { result } = renderHook( () => useQueriedDashboardFilterValues({ diff --git a/packages/app/src/netflow.ts b/packages/app/src/netflow.ts index fc6189421a..371904b674 100644 --- a/packages/app/src/netflow.ts +++ b/packages/app/src/netflow.ts @@ -137,7 +137,10 @@ export function buildNetflowQueryConfigs({ if (!Number.isFinite(seconds) || seconds <= 0) throw new Error('Time range must have a start before its end'); - const sample = source.samplingRateExpression?.trim() || '1'; + const samplingRate = source.samplingRateExpression?.trim(); + const sample = samplingRate + ? `if((${samplingRate}) > 0, (${samplingRate}), 1)` + : '1'; const exporter = source.exporterExpression?.trim(); const inputInterface = source.inIfExpression?.trim(); const outputInterface = source.outIfExpression?.trim(); diff --git a/packages/app/tests/e2e/features/netflow/monitoring.spec.ts b/packages/app/tests/e2e/features/netflow/monitoring.spec.ts index f481ca288a..fd132c2af3 100644 --- a/packages/app/tests/e2e/features/netflow/monitoring.spec.ts +++ b/packages/app/tests/e2e/features/netflow/monitoring.spec.ts @@ -1,7 +1,32 @@ import { expect, test } from '../../fixtures/netflow'; +import { expectFiltersParam } from '../../utils/filters-param'; test.use({ viewport: { width: 1600, height: 1050 } }); +test('NetFlow URL range filters remain visible and removable after reload', async ({ + page, + netflow, +}) => { + const params = new URLSearchParams({ + source: netflow.source.id, + filters: encodeURIComponent( + JSON.stringify([ + { type: 'sql', condition: 'Bytes BETWEEN 0 AND 2000000' }, + ]), + ), + }); + await page.goto(`/netflow?${params}`); + const rows = page.getByTestId('netflow-records').locator('tbody tr'); + await expect(rows).toHaveCount(300); + await page.reload(); + const range = page.getByTestId('active-filter-pill-Bytes'); + await expect(range).toContainText('0 – 2000000'); + await range.getByRole('button', { name: 'Remove filter' }).click(); + await expect(range).toHaveCount(0); + await expectFiltersParam(page, []); + await expect(rows).toHaveCount(500); +}); + test('NetFlow searches, drafts, click filters and sampled details survive reload', async ({ page, netflow, @@ -89,6 +114,14 @@ test('NetFlow source selection applies immediately and clears incompatible filte await page.goto(`/netflow?source=${netflow.source.id}`); const records = page.getByTestId('netflow-records'); await expect(records.locator('tbody tr')).toHaveCount(500); + const search = page.getByTestId('netflow-search'); + const protocol = page.getByLabel('Protocol', { exact: true }); + await search.fill('Proto:6'); + await protocol.fill('6'); + await page.getByRole('button', { name: 'Run', exact: true }).click(); + await expect + .poll(() => new URL(page.url()).searchParams.get('where')) + .toBe('Proto:6'); await records .getByRole('button', { name: 'Filter Protocol: TCP', exact: true }) .first() @@ -96,6 +129,8 @@ test('NetFlow source selection applies immediately and clears incompatible filte await page.getByRole('menuitem', { name: 'Include', exact: true }).click(); const pills = page.getByTestId('netflow-filter-pills'); await expect(pills).toContainText('Protocol = TCP'); + await search.fill('Proto:17'); + await protocol.fill('17'); await page .getByRole('combobox', { name: 'NetFlow source', exact: true }) .click(); @@ -108,6 +143,13 @@ test('NetFlow source selection applies immediately and clears incompatible filte await expect( pills.getByRole('button', { name: 'Remove filter' }), ).toHaveCount(0); + await expect(search).toHaveValue(''); + await expect(protocol).toHaveValue(''); + for (const name of ['where', 'exporter', 'protocol', 'srcAddr', 'dstAddr']) { + await expect + .poll(() => new URL(page.url()).searchParams.get(name) || '') + .toBe(''); + } await page .getByRole('button', { name: 'Source actions', exact: true }) .click(); diff --git a/packages/app/tests/e2e/features/netflow/queries.spec.ts b/packages/app/tests/e2e/features/netflow/queries.spec.ts index f3b9321a2e..9059968202 100644 --- a/packages/app/tests/e2e/features/netflow/queries.spec.ts +++ b/packages/app/tests/e2e/features/netflow/queries.spec.ts @@ -1,3 +1,4 @@ +import { ClickhouseClient } from '@hyperdx/common-utils/dist/clickhouse/node'; import { getMetadata } from '@hyperdx/common-utils/dist/core/metadata'; import { filtersToQuery, parseQuery } from '@hyperdx/common-utils/dist/filters'; import { BuilderChartConfigWithDateRange } from '@hyperdx/common-utils/dist/types'; @@ -13,10 +14,22 @@ import { } from '../../../../src/netflowSankey'; import { expect, test } from '../../fixtures/netflow'; +const queryRunner = + (client: ClickhouseClient) => + async (config: BuilderChartConfigWithDateRange) => + ( + await client.queryChartConfig({ + config, + metadata: getMetadata(client), + querySettings: undefined, + }) + ).data; + test('steady traffic keeps its rate in partial first and last buckets', async ({ netflow, }) => { const { client, database, source } = netflow; + const run = queryRunner(client); const start = Date.parse('2026-10-09T12:00:00Z'); const view = await client.query({ query: `CREATE VIEW ${database}.steady AS @@ -32,38 +45,22 @@ test('steady traffic keeps its rate in partial first and last buckets', async ({ dateRange: [new Date(start + 30000), new Date(start + 3620000)], filters: {}, }); - const result = await client.queryChartConfig({ - config: configs.traffic, - metadata: getMetadata(client), - querySettings: undefined, - }); - expect(result.data).toHaveLength(61); + const result = await run(configs.traffic); + expect(result).toHaveLength(61); // 125 raw bytes/second * sampling 100 * 8. Both edge buckets have less than a minute of data. - expect(result.data.map(row => Number(row['Bits per second']))).toEqual( + expect(result.map(row => Number(row['Bits per second']))).toEqual( Array(61).fill(100000), ); - const summary = await client.queryChartConfig({ - config: configs.summary, - metadata: getMetadata(client), - querySettings: undefined, - }); - expect(Number(summary.data[0].__netflow_flowRecords)).toBe(3590); - expect(Number(summary.data[0].__netflow_bytes)).toBe(3590 * 125 * 100); + const summary = await run(configs.summary); + expect(Number(summary[0].__netflow_flowRecords)).toBe(3590); + expect(Number(summary[0].__netflow_bytes)).toBe(3590 * 125 * 100); }); test('NetFlow queries preserve sampled totals, raw records, aliases and Lucene semantics', async ({ netflow, }) => { const { client, source, dateRange, database } = netflow; - const metadata = getMetadata(client); - const run = async (config: BuilderChartConfigWithDateRange) => - ( - await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }) - ).data; + const run = queryRunner(client); const direct = async (where = '1') => ( await ( @@ -159,10 +156,11 @@ test('NetFlow handles lowercase counter aliases and custom timestamp and exporte netflow, }) => { const { client, source, dateRange, database } = netflow; + const run = queryRunner(client); const view = await client.query({ query: `CREATE VIEW ${database}.lowercase AS SELECT TimeReceived AS timestamp, Bytes AS bytes, Packets AS packets, - SamplingRate AS sampling, Proto AS protocol, ExporterName AS "router-name", + [NULL, -1, 0, 1, 100][toUnixTimestamp(TimeReceived) % 5 + 1] AS sampling, Proto AS protocol, ExporterName AS "router-name", SrcAddr AS srcAddr, DstAddr AS dstAddr, SrcPort AS srcPort, DstPort AS dstPort, InIfName AS inputInterface, OutIfName AS outputInterface FROM ${database}.flows`, }); @@ -193,24 +191,40 @@ test('NetFlow handles lowercase counter aliases and custom timestamp and exporte for (const config of Object.values(configs).filter( config => config != null, )) { - const { data } = await client.queryChartConfig({ - config, - metadata: getMetadata(client), - querySettings: undefined, - }); - expect(data.length).toBeGreaterThan(0); + expect((await run(config)).length).toBeGreaterThan(0); } - const result = await client.queryChartConfig({ - config: configs.flows, - metadata: getMetadata(client), - querySettings: undefined, - }); - expect(result.data).toHaveLength(500); - expect(new Date(String(result.data[0].__netflow_timestamp)).getTime()).toBe( + const result = await run(configs.flows); + const summary = await run(configs.summary); + // Each of the five sampling values occurs 120 times after the TCP filter. + expect(Number(summary[0].__netflow_bytes)).toBe(6246240000000); + expect(Number(summary[0].__netflow_packetsPerSecond)).toBeCloseTo( + 12480000 / 3600, + ); + for (const row of result) { + expect([1, 100]).toContain(Number(row.__netflow_samplingRate)); + expect(Number(row.__netflow_bytes)).toBe( + Number(row.__netflow_rawBytes) * Number(row.__netflow_samplingRate), + ); + } + const sankey = await run( + buildNetflowSankeyConfig({ + baseConfig: configs.totalBytes, + dimensions: [ + { key: 'src', label: 'Source', expression: 'srcAddr' }, + { key: 'dst', label: 'Destination', expression: 'dstAddr' }, + ], + }), + ); + expect( + sankey.reduce((total, row) => total + Number(row.__netflow_value), 0), + ).toBe(6246240000000); + + expect(result).toHaveLength(500); + expect(new Date(String(result[0].__netflow_timestamp)).getTime()).toBe( dateRange[1].getTime() - 904000, ); expect( - result.data.every( + result.every( row => row.__netflow_protocol === 'TCP' && row.__netflow_exporter === 'edge-a', ), @@ -221,15 +235,7 @@ test('NetFlow Sankey conserves path traffic and executes quoted, empty and compo netflow, }) => { const { client, source, dateRange } = netflow; - const metadata = getMetadata(client); - const run = async (config: BuilderChartConfigWithDateRange) => - ( - await client.queryChartConfig({ - config, - metadata, - querySettings: undefined, - }) - ).data; + const run = queryRunner(client); const dimensions = [ { key: 'src', From d7b677cc7b45310527d4f07ffb46c61abbc8e24a Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 9 Oct 2026 20:41:27 -0700 Subject: [PATCH 09/11] 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 --- .changeset/native-netflow-monitoring.md | 6 ++-- packages/app/src/NetflowPage.tsx | 3 +- .../src/__tests__/NetflowPage.drafts.test.tsx | 23 ++++++++++++++- .../app/src/components/NetflowRecords.tsx | 28 ++++++++++--------- packages/app/src/netflow.ts | 21 ++++---------- .../src/__tests__/netflowSource.test.ts | 7 +++++ .../src/core/searchChartConfig.ts | 23 ++++++++++++++- 7 files changed, 76 insertions(+), 35 deletions(-) diff --git a/.changeset/native-netflow-monitoring.md b/.changeset/native-netflow-monitoring.md index a22631b79e..79a86f89e0 100644 --- a/.changeset/native-netflow-monitoring.md +++ b/.changeset/native-netflow-monitoring.md @@ -44,9 +44,9 @@ expression. Share the summary aggregate query, reuse accessible filter menus, restore valid Sankey dimensions across source changes, and document source mappings consistently in REST and MCP. -Apply the same bare-term search defaults to dashboards, chart editing, alert -details, and dashboard filters. Support timestamp expression lists in flow -records and normalize partial traffic buckets over their selected duration. +Apply the same bare-term search defaults to dashboards, chart editing, and alert +details. Support timestamp expression lists in flow records and normalize +partial traffic buckets over their selected duration. Preserve saved source mappings when connections, databases, or metadata change. Show removable range filters using the shared filter pills and apply configured diff --git a/packages/app/src/NetflowPage.tsx b/packages/app/src/NetflowPage.tsx index 2b2763af29..a0c369ef4f 100644 --- a/packages/app/src/NetflowPage.tsx +++ b/packages/app/src/NetflowPage.tsx @@ -144,7 +144,8 @@ function NetflowPage() { sourceId={source?.id} onClose={() => setSourceModal(null)} onCreate={created => { - clearFilters(created.id); + // The form can save another kind; keep the current flows then. + if (created.kind === SourceKind.Netflow) clearFilters(created.id); setSourceModal(null); }} /> diff --git a/packages/app/src/__tests__/NetflowPage.drafts.test.tsx b/packages/app/src/__tests__/NetflowPage.drafts.test.tsx index c71ad4f597..00b411a22a 100644 --- a/packages/app/src/__tests__/NetflowPage.drafts.test.tsx +++ b/packages/app/src/__tests__/NetflowPage.drafts.test.tsx @@ -83,7 +83,14 @@ jest.mock('@/netflow', () => ({ buildNetflowQueryConfigs: jest.fn(() => ({})), })); jest.mock('@/components/NetflowCharts', () => () => null); -jest.mock('@/components/NetflowSourceModal', () => () => null); +let mockCreateSource: (source: { id: string; kind: SourceKind }) => void; +jest.mock('@/components/NetflowSourceModal', () => ({ + __esModule: true, + default: ({ onCreate }: { onCreate: typeof mockCreateSource }) => { + mockCreateSource = onCreate; + return null; + }, +})); jest.mock('@/components/SourceSelect', () => { const Input = jest.requireActual( '@/components/InputControlled', @@ -207,6 +214,20 @@ describe('NetFlow draft search fields', () => { }); }); + it('switches only to NetFlow sources created from the source modal', async () => { + renderWithMantine(); + await screen.findByLabelText('Query'); + act(() => mockCreateSource({ id: 'logs', kind: SourceKind.Log })); + expect(screen.getByLabelText('Source')).toHaveValue('flows'); + expect(screen.getByLabelText('Query')).toHaveValue('Proto:6'); + act(() => mockCreateSource({ id: 'other', kind: SourceKind.Netflow })); + await waitFor(() => + expect( + jest.mocked(buildNetflowQueryConfigs).mock.lastCall?.[0], + ).toMatchObject({ source: mockOtherSource, where: '' }), + ); + }); + it('preserves drafts until Run when clicks or migrations update filters', async () => { renderWithMantine(); const query = await screen.findByLabelText('Query'); diff --git a/packages/app/src/components/NetflowRecords.tsx b/packages/app/src/components/NetflowRecords.tsx index 5cdc9194ff..8922231664 100644 --- a/packages/app/src/components/NetflowRecords.tsx +++ b/packages/app/src/components/NetflowRecords.tsx @@ -50,15 +50,19 @@ export default function NetflowRecords({ const [selected, setSelected] = useState | null>( null, ); - const rows = useMemo( - () => - (data?.data ?? []).map(row => - Object.fromEntries( - fields.map(([key]) => [key, row[netflowColumnAlias(key)]]), - ), - ), - [data], - ); + const rows = useMemo(() => { + const repeats = new Map(); + return (data?.data ?? []).map(row => { + const values = Object.fromEntries( + fields.map(([key]) => [key, row[netflowColumnAlias(key)]]), + ); + // Flow exports can repeat a record exactly and carry no ID. + const content = JSON.stringify(values); + const repeat = (repeats.get(content) ?? 0) + 1; + repeats.set(content, repeat); + return { values, key: `${content}#${repeat}` }; + }); + }, [data]); return ( <> @@ -112,10 +116,8 @@ export default function NetflowRecords({ - {rows.map((row, index) => ( - // Flow exports may contain duplicate records without an ID; the table remounts when its query changes. - // eslint-disable-next-line @eslint-react/no-array-index-key - + {rows.map(({ values: row, key }, index) => ( + {formatTime(String(row.timestamp))} `'${value}'`) - .join(', ')}], [${Object.values(PROTOCOL_NAMES) - .map(value => `'${value}'`) - .join(', ')}], toString(${source.protocolExpression}))`, + protocol: netflowProtocolNameExpression(source.protocolExpression), exporter: source.exporterExpression?.trim(), inputInterface: source.inIfExpression?.trim(), outputInterface: source.outIfExpression?.trim(), diff --git a/packages/common-utils/src/__tests__/netflowSource.test.ts b/packages/common-utils/src/__tests__/netflowSource.test.ts index 8e1cc62633..740e2975f0 100644 --- a/packages/common-utils/src/__tests__/netflowSource.test.ts +++ b/packages/common-utils/src/__tests__/netflowSource.test.ts @@ -1,6 +1,7 @@ import { buildSearchChartConfig, getSourceImplicitColumnExpression, + netflowProtocolNameExpression, } from '@/core/searchChartConfig'; import { isSearchableSource, @@ -82,6 +83,12 @@ it('searches mapped NetFlow dimensions for bare Lucene terms by default', () => expect(config.implicitColumnExpression).toContain('toString(SrcAddr)'); expect(config.implicitColumnExpression).toContain('toString(ExporterName)'); expect(config.implicitColumnExpression).toContain('toString(OutIfName)'); + expect(config.implicitColumnExpression).toContain( + netflowProtocolNameExpression('Proto'), + ); + expect(netflowProtocolNameExpression('Proto')).toBe( + "transform(toString(Proto), ['1', '6', '17', '47', '50', '58'], ['ICMP', 'TCP', 'UDP', 'GRE', 'ESP', 'ICMPv6'], toString(Proto))", + ); expect(getSourceImplicitColumnExpression(SourceSchema.parse(source))).toBe( config.implicitColumnExpression, ); diff --git a/packages/common-utils/src/core/searchChartConfig.ts b/packages/common-utils/src/core/searchChartConfig.ts index e2a22db175..7cbb6ec434 100644 --- a/packages/common-utils/src/core/searchChartConfig.ts +++ b/packages/common-utils/src/core/searchChartConfig.ts @@ -30,17 +30,38 @@ import { */ export type SearchChartConfig = BuilderChartConfig & Partial; +const NETFLOW_PROTOCOL_NAMES = { + 1: 'ICMP', + 6: 'TCP', + 17: 'UDP', + 47: 'GRE', + 50: 'ESP', + 58: 'ICMPv6', +}; + +/** Shows well-known IP protocol numbers by name, e.g. 6 as TCP. */ +export function netflowProtocolNameExpression(expression: string): string { + return `transform(toString(${expression}), [${Object.keys( + NETFLOW_PROTOCOL_NAMES, + ) + .map(value => `'${value}'`) + .join(', ')}], [${Object.values(NETFLOW_PROTOCOL_NAMES) + .map(value => `'${value}'`) + .join(', ')}], toString(${expression}))`; +} + /** Bare terms search mapped dimensions even on sources saved before this option existed. */ function getNetflowImplicitColumnExpression(source: TNetflowSource): string { if (source.implicitColumnExpression?.trim()) { return source.implicitColumnExpression.trim(); } + const protocol = source.protocolExpression?.trim(); const expressions = [ source.srcAddrExpression, source.dstAddrExpression, source.srcPortExpression, source.dstPortExpression, - source.protocolExpression, + protocol && netflowProtocolNameExpression(protocol), source.exporterExpression, source.inIfExpression, source.outIfExpression, From 3b86fafe2d9930dac94da434c4418d298c4544d3 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 9 Oct 2026 20:51:50 -0700 Subject: [PATCH 10/11] fix: keep raw protocol numbers in NetFlow bare-term search The previous commit replaced the protocol number with its name in the default full-text expression, so a bare "17" stopped matching UDP flows. Search both the number and the name. Co-Authored-By: Claude Opus 5.5 --- packages/common-utils/src/__tests__/netflowSource.test.ts | 3 +++ packages/common-utils/src/core/searchChartConfig.ts | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/common-utils/src/__tests__/netflowSource.test.ts b/packages/common-utils/src/__tests__/netflowSource.test.ts index 740e2975f0..da88b16523 100644 --- a/packages/common-utils/src/__tests__/netflowSource.test.ts +++ b/packages/common-utils/src/__tests__/netflowSource.test.ts @@ -83,6 +83,9 @@ it('searches mapped NetFlow dimensions for bare Lucene terms by default', () => expect(config.implicitColumnExpression).toContain('toString(SrcAddr)'); expect(config.implicitColumnExpression).toContain('toString(ExporterName)'); expect(config.implicitColumnExpression).toContain('toString(OutIfName)'); + expect(config.implicitColumnExpression).toContain( + "ifNull(toString(Proto), '')", + ); expect(config.implicitColumnExpression).toContain( netflowProtocolNameExpression('Proto'), ); diff --git a/packages/common-utils/src/core/searchChartConfig.ts b/packages/common-utils/src/core/searchChartConfig.ts index 7cbb6ec434..7a97004a81 100644 --- a/packages/common-utils/src/core/searchChartConfig.ts +++ b/packages/common-utils/src/core/searchChartConfig.ts @@ -61,6 +61,7 @@ function getNetflowImplicitColumnExpression(source: TNetflowSource): string { source.dstAddrExpression, source.srcPortExpression, source.dstPortExpression, + protocol, protocol && netflowProtocolNameExpression(protocol), source.exporterExpression, source.inIfExpression, From 219da28c1f29376f884fa303531870d382f6ab65 Mon Sep 17 00:00:00 2001 From: Valery Petrov Date: Fri, 9 Oct 2026 21:07:25 -0700 Subject: [PATCH 11/11] fix: accept protocol names in the NetFlow protocol filter Charts, records and the Sankey show protocols by name, but the Protocol filter only matched the raw number, so typing "TCP" returned no flows. Known names now map to their protocol number before filtering. Co-Authored-By: Claude Opus 5.5 --- packages/app/src/__tests__/netflow.test.ts | 11 +++++++++++ packages/app/src/netflow.ts | 9 ++++++++- packages/common-utils/src/core/searchChartConfig.ts | 2 +- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/packages/app/src/__tests__/netflow.test.ts b/packages/app/src/__tests__/netflow.test.ts index 24959cc776..86f7db2415 100644 --- a/packages/app/src/__tests__/netflow.test.ts +++ b/packages/app/src/__tests__/netflow.test.ts @@ -164,6 +164,17 @@ describe('NetFlow queries', () => { expect(where).toContain("toString(Proto) = '6'"); }); + it.each([ + ['tcp', '6'], + [' ICMPv6 ', '58'], + ['99', '99'], + ['SCTP', 'SCTP'], + ])('filters protocol %p as %p', (protocol, expected) => { + expect(buildNetflowWhere(source, { protocol })).toBe( + `(toString(Proto) = '${expected}')`, + ); + }); + it('keeps the source, connection, time range and filters on every query', () => { const configs = buildNetflowQueryConfigs({ source, diff --git a/packages/app/src/netflow.ts b/packages/app/src/netflow.ts index d53be10dbf..16150ba6c9 100644 --- a/packages/app/src/netflow.ts +++ b/packages/app/src/netflow.ts @@ -1,6 +1,7 @@ import { FIXED_TIME_BUCKET_EXPR_ALIAS } from '@hyperdx/common-utils/dist/core/renderChartConfig'; import { getSourceImplicitColumnExpression, + NETFLOW_PROTOCOL_NAMES, netflowProtocolNameExpression, } from '@hyperdx/common-utils/dist/core/searchChartConfig'; import { @@ -85,6 +86,12 @@ export function getNetflowDimensions(source: TNetflowSource) { }; } +// The protocol filter also accepts the names the charts show, e.g. TCP. +const protocolNumber = (value?: string) => + Object.entries(NETFLOW_PROTOCOL_NAMES).find( + ([, name]) => name.toLowerCase() === value?.trim().toLowerCase(), + )?.[0] ?? value; + export function buildNetflowWhere( source: TNetflowSource, filters: NetflowFilters, @@ -92,7 +99,7 @@ export function buildNetflowWhere( const predicates: string[] = []; for (const [value, expression, address] of [ [filters.exporter, source.exporterExpression, false], - [filters.protocol, source.protocolExpression, false], + [protocolNumber(filters.protocol), source.protocolExpression, false], [filters.srcAddr, source.srcAddrExpression, true], [filters.dstAddr, source.dstAddrExpression, true], ] as const) { diff --git a/packages/common-utils/src/core/searchChartConfig.ts b/packages/common-utils/src/core/searchChartConfig.ts index 7a97004a81..3efefbcfb1 100644 --- a/packages/common-utils/src/core/searchChartConfig.ts +++ b/packages/common-utils/src/core/searchChartConfig.ts @@ -30,7 +30,7 @@ import { */ export type SearchChartConfig = BuilderChartConfig & Partial; -const NETFLOW_PROTOCOL_NAMES = { +export const NETFLOW_PROTOCOL_NAMES = { 1: 'ICMP', 6: 'TCP', 17: 'UDP',