Compare commits

..

11 Commits

Author SHA1 Message Date
Srikanth Chekuri
4b1b3092d5 fix(querier): skip the cache for logs and traces queries with a limited group by (#13472)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

- A logs or traces time series query with a group by and a limit ranks
its groups over the statement window. On a partial cache hit the gap
statement ranked over the gap, so the response was a union of the
winners of each piece, each with points for part of the window. A
narrower window served from a wider cached entry got the wider window's
winners.
- No piece of a window can stand in for the window, so such a query is
no longer served from the cache. Its fingerprint is empty, the same way
an AI builder query opts out today. Metrics are not affected, their
limit is applied after the full series set is read.
- Integration tests: a top-2 over a window whose first half is cached
must return the window's two winners with points for the whole window,
and a top-1 over the second half of a cached window must return that
half's winner. Both match the request with noCache.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#627

#### Additional Information

- The first commit adds the tests alone. CI ran them against main and
both failed. The second commit is the fix.
- This is the stop gap. Caching these queries exactly needs the ranking
summary of each piece, top-K totals with a threshold, so the merge can
rank with bounds and fall back when it cannot prove the top-N. That is a
design item, not a cache patch.
2026-11-09 20:33:56 +00:00
Srikanth Chekuri
d6017f88fd fix(querier): do not shift the gap of a timeShift query twice (#13470)
#### Description

- A timeShift query's window is moved into the shifted clock when the
query is built. On a partial cache hit the missing range comes from that
window, and the ranged gap query shifted it again, so the gap was read
one shift too far back, and the wrong points were served and written
back to the cache.
- The ranged query now takes the range as given. The spec copy already
carries the shift, so the gap statement reads the right data and the
postprocess shift moves it forward once.
- Integration test: a timeShift(3600) logs query with its first 30
minutes cached must return the same points for the next 20 minutes as
the request with noCache.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#625

#### Additional Information

- The first commit adds the test alone. CI ran it against main and it
failed with only that test failing in the suite. The second commit is
the fix.
2026-11-09 20:33:48 +00:00
Srikanth Chekuri
9d28014664 feat(querier): apply the having of a formula (#13452)
#### Description

The querier computes a formula in Go after its queries return, so a
formula having cannot run as SQL HAVING. It was accepted and ignored.

The having now filters the formula values in Go. It uses the having
grammar, validation, and messages of a builder query, with the formula
name and __result as the only references. A time series keeps the
points that match and drops a series with no points left. A scalar
result keeps the rows that match. The having runs before functions,
order, and limit, as HAVING does in SQL. A bad having fails the
request before any query runs.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#612

#### Additional Information

Assisted-by: Claude Opus 5.5
2026-11-09 20:33:22 +00:00
Srikanth Chekuri
1997901102 fix(querier): validate formula limits, raw order keys, and request types (#13426)
#### Description

- A negative formula `limit` now returns 400, as for builder queries.
Before, validation accepted it, and the series limit treated it as no
limit.
- An empty order key on a `raw`, `raw_stream`, or `trace` request now
returns 400. Before, validation accepted it. Aggregation requests keep
their own order key check, which lists the valid keys.
- A missing `requestType` returned `invalid request type: {}` and
suggested `timeseries`, which the decoder rejects. It now returns
`requestType is required`, with the valid request types as suggestions.
The decoder error for an unknown value lists the same set.
- Validation now accepts exactly what `RequestType.Enum` lists. The
spec, the decoder error, and validation read one list, so they cannot
drift apart.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#609

#### Additional Information

- Existing alerts should not be affected. Stored rules load without
validation, and the querier validates only in the `query_range` handler.
The UI does not send a negative formula limit or an empty order key.
- Part of https://github.com/SigNoz/nerve-pod/issues/428
2026-11-09 20:33:14 +00:00
Srikanth Chekuri
84268d00c8 docs(querier): add query_range examples for every builder function (#13475)
#### Description

- The query_range examples cover signals and request types but none of
the functions a builder query can carry, so a client has no place to
learn the argument shapes.
- Adds one example per function family: timeShift, fillZero, the four
threshold functions, runningDiff, ewma, median, cumulativeSum, log,
anomaly. Each description says what the function does to the series,
which arguments it takes, their units and their defaults.
- A test unmarshals every example into a `QueryRangeRequest` and
validates it, so an example cannot drift from the request type.
- The OpenAPI spec is regenerated in its own commit.
2026-11-09 20:32:41 +00:00
Ashwin Bhatkal
628e989bc0 fix(alerts): refresh the v3 rules list after rule changes (#13442)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Saving a rule, and cloning or renaming one from its details page, now
refreshes the v3 rules list instead of v2. The list page (#13322) and
`useListAllRules` (#13440) both read v3, so nothing reads the v2 list
anymore.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/718
2026-11-09 13:53:16 +00:00
Ashwin Bhatkal
74d272a945 fix(ai-assistant): read alert rules from the v3 list API (#13441)
#### Description

- The AI assistant's Alerts context picker now loads rules with one
`/api/v3/rules` request (`limit: 200`, the most the API returns per
call) instead of the deprecated `GET /api/v2/rules`. The Dashboards tab
next to it already loads one page (`limit: 1000`) the same way.
- Alert context chips read their names from the same cache entry.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/717
2026-11-09 13:20:39 +00:00
Ashwin Bhatkal
f8421e81e9 fix(planned-downtime): read alert rules from the v3 list API (#13440)
#### Description

- The planned downtime rule picker now reads `/api/v3/rules` through a
new `useListAllRules` hook instead of the deprecated `GET
/api/v2/rules`.
- The picker lists every rule, and v3 returns at most 200 per call, so
the hook fetches the remaining pages in parallel, like
`useChannelOptions` does for channels. Orgs with 200 rules or fewer make
one request.
- The planned downtime story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/713
2026-11-09 12:45:49 +00:00
Ashwin Bhatkal
e938a10ad5 fix(home): read alert rules from the v3 list API (#13439)
#### Description

- `GET /api/v2/rules` is deprecated in favor of the paginated
`/api/v3/rules`, and #13158 removes it. This moves the home page alerts
widget to v3 ahead of that.
- The widget still shows firing rules first, then the most recently
updated others, up to 5. It asks v3 for the firing rules first and
fetches the latest rules only when fewer than 5 are firing, instead of
loading every rule.
- Clicking a rule now opens the overview with only `ruleId`, like the
alert list, triggered alerts and Metrics Explorer. The overview loads
the rule and builds its query from it, so the `compositeQuery` the old
link carried is not needed.
- The home page story now mocks `/api/v3/rules`.

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/712
2026-11-09 11:52:40 +00:00
Ashwin Bhatkal
93cc1957d4 chore: make pulse-frontend the code owner of the list filter, views and alert rules folders (#13476)
#### Description

- `@SigNoz/pulse-frontend` now owns `components/ListFilter`,
`components/ListViews` and `hooks/listViews`. Only the dashboards list
and the alert rules list use them.
- It also owns `container/PlannedDowntime`, `hooks/alerts` and the home
page alert rules widget (`container/Home/AlertRules`).
- Until now these fell to the `/frontend/` catch-all, so changes to them
also needed `@SigNoz/frontend-maintainers`.
2026-11-09 11:26:04 +00:00
Swapnil Nakade
313ec2d657 chore: bump go version to 1.25 in github ci (#13480)
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description
- bump go version in ci to 1.25

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-11-09 10:08:41 +00:00
157 changed files with 3244 additions and 2307 deletions

9
.github/CODEOWNERS vendored
View File

@@ -209,6 +209,10 @@ go.mod @therealpandey
/frontend/src/types/api/alerts/ @SigNoz/pulse-frontend
/frontend/src/providers/Alert.tsx @SigNoz/pulse-frontend
/frontend/src/constants/alerts.ts @SigNoz/pulse-frontend
/frontend/src/container/PlannedDowntime/ @SigNoz/pulse-frontend
/frontend/src/hooks/alerts/ @SigNoz/pulse-frontend
/frontend/src/container/Home/AlertRules/ @SigNoz/pulse-frontend
/frontend/src/container/Home/__tests__/AlertRules.test.tsx @SigNoz/pulse-frontend
## Notification Channels
/frontend/src/pages/ChannelsEdit/ @SigNoz/pulse-frontend
@@ -217,6 +221,11 @@ go.mod @therealpandey
/frontend/src/container/FormAlertChannels/ @SigNoz/pulse-frontend
/frontend/src/hooks/notificationChannels/ @SigNoz/pulse-frontend
## List filter + views (dashboards and alerts lists)
/frontend/src/components/ListFilter/ @SigNoz/pulse-frontend
/frontend/src/components/ListViews/ @SigNoz/pulse-frontend
/frontend/src/hooks/listViews/ @SigNoz/pulse-frontend
## OpenAPI Schema - Generated
/frontend/src/api/generated/services/ @therealpandey @vikrantgupta25 @srikanthccv
/docs/api/openapi.yml @therealpandey @vikrantgupta25 @srikanthccv

View File

@@ -63,7 +63,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_NAME: signoz-community
GO_INPUT_ARTIFACT_CACHE_KEY: community-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build

View File

@@ -89,7 +89,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_INPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -95,7 +95,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
GO_INPUT_ARTIFACT_CACHE_KEY: staging-jsbuild-${{ github.sha }}
GO_INPUT_ARTIFACT_PATH: frontend/build
GO_BUILD_CONTEXT: ./cmd/enterprise

View File

@@ -22,7 +22,7 @@ jobs:
with:
PRIMUS_REF: main
GO_TEST_CONTEXT: ./...
GO_VERSION: 1.24
GO_VERSION: 1.25
fmt:
if: |
github.event_name == 'merge_group' ||
@@ -32,7 +32,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
lint:
if: |
github.event_name == 'merge_group' ||
@@ -42,7 +42,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
deps:
if: |
github.event_name == 'merge_group' ||
@@ -52,7 +52,7 @@ jobs:
secrets: inherit
with:
PRIMUS_REF: main
GO_VERSION: 1.24
GO_VERSION: 1.25
semconv-generated:
if: |
github.event_name == 'merge_group' ||
@@ -65,7 +65,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: check-semconv-generated-files
run: make semconv-check
build:
@@ -80,7 +80,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: qemu-install
uses: docker/setup-qemu-action@v3
- name: aarch64-install
@@ -116,7 +116,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-openapi
run: |
go run cmd/enterprise/*.go generate openapi
@@ -133,7 +133,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-authz
run: |
go run cmd/enterprise/*.go generate authz
@@ -150,7 +150,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-web-settings
run: |
go run cmd/enterprise/*.go generate config web-settings
@@ -167,7 +167,7 @@ jobs:
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: generate-transaction-groups
run: |
go run cmd/enterprise/*.go generate config transaction-groups

View File

@@ -66,7 +66,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -130,7 +130,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
# copy the caches from build
- name: get-sha

View File

@@ -75,7 +75,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
- name: cross-compilation-tools
if: matrix.os == 'ubuntu-latest'
run: |
@@ -138,7 +138,7 @@ jobs:
- name: setup-go
uses: actions/setup-go@v5
with:
go-version: "1.24"
go-version: "1.25"
# copy the caches from build
- name: get-sha

View File

@@ -10231,8 +10231,10 @@ components:
having:
allOf:
- $ref: '#/components/schemas/Querybuildertypesv5Having'
description: Accepted but currently ignored; the computed values are returned
unfiltered.
description: A filter on the formula values, referencing the formula by
its name or as __result, for example F1 > 0. A time series keeps only
the points that match and drops a series with none left; a scalar result
keeps only the rows that match.
legend:
description: The display name template for each series; {{field}} placeholders
are replaced by label values.
@@ -32729,6 +32731,305 @@ paths:
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_anomaly_request_rate:
description: 'anomaly is the one function with named arguments. seasonality
says which past periods predict the current value: hourly, daily
or weekly, daily when absent. z_score_threshold is how many standard
deviations from the prediction count as an anomaly. It has no default:
a request without it gets bounds of zero width, so send it. 3 is
the value the UI sends. The response carries, next to the values,
a predicted series, an upper and a lower bound series and an anomaly
score series. The scoring runs in the enterprise build; the community
build returns the series unchanged.'
summary: 'anomaly: request rate scored against its daily pattern'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- args:
- name: seasonality
value: daily
- name: z_score_threshold
value: 3
name: anomaly
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 300s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cumulativesum_errors_so_far:
description: cumulativeSum replaces each point with the sum of every
point up to and including it, so a per-step count becomes a running
total whose first point is the first count. It takes no arguments.
The total resets with the window, so a longer time range gives a
larger final value; it is a view of the window, not a counter.
summary: 'cumulativeSum: 5xx responses since the start of the window'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: server_errors
expression: count()
filter:
expression: http.response.status_code >= 500
functions:
- name: cumulativeSum
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_cutoffmin_hide_idle_hosts:
description: The four threshold functions take one number. cutOffMin
and cutOffMax remove the points below or above the threshold, so
an idle host disappears from the chart instead of drawing a line
along the bottom. clampMin and clampMax keep the points and replace
the value with the threshold, which caps spikes without hiding them.
system.cpu.utilization is a ratio, so 0.05 is 5%.
summary: 'cutOffMin: drop hosts below 5% CPU from the chart'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.cpu.utilization
spaceAggregation: avg
timeAggregation: avg
functions:
- args:
- value: 0.05
name: cutOffMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_ewma_smoothed_latency:
description: 'ewma3, ewma5 and ewma7 replace each point with an exponentially
weighted average of the point and the points before it. The number
in the name is the window the default weight is derived from: alpha
= 2 / (n + 1), so 0.5, 0.333 and 0.25. One optional argument overrides
alpha; a value close to 1 follows the raw series, a value close
to 0 smooths harder. 0.3 here smooths a little more than ewma5''s
default.'
summary: 'ewma5: smoothed p95 latency'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.bucket
spaceAggregation: p95
functions:
- args:
- value: 0.3
name: ewma5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_fillzero_sparse_errors:
description: A count over sparse events returns a point only for steps
that had a match, so a chart of failed spans has gaps where nothing
failed. fillZero adds a zero at every missing step. With no arguments
the server fills the request window at the query's step, which is
the common case. The three optional arguments are start, end and
step in milliseconds, for callers that want to fill a different
grid.
summary: 'fillZero: a zero at every step with no failed spans'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: failed_spans
expression: count()
filter:
expression: has_error = true
functions:
- name: fillZero
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: traces
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_log10_network_bytes:
description: log2 and log10 replace each value with its logarithm,
so a loopback device moving kilobytes and an uplink moving gigabytes
fit on one chart. They take no arguments. Zero and negative values
have no logarithm and are not drawn. absolute, also without arguments,
is the usual companion for a signed series before a logarithm.
summary: 'log10: network throughput across devices that differ by
orders of magnitude'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.network.io
spaceAggregation: sum
timeAggregation: rate
filter:
expression: direction = 'transmit'
functions:
- name: log10
groupBy:
- fieldContext: attribute
name: device
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_median_despiked_request_rate:
description: median3, median5 and median7 replace each point with
the median of the surrounding 3, 5 or 7 points. A spike that lasts
one step vanishes, a shift that lasts longer than half the window
stays. The first and last half window, 2 points each for median5,
have no full window and keep their values, so the newest spike stays
visible; a series shorter than the window is returned unchanged.
They take no arguments. Use them when the raw series is noisy and
the chart is for a trend, not for catching the spike.
summary: 'median5: request rate without one-step spikes'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: http.server.duration.count
spaceAggregation: sum
timeAggregation: rate
functions:
- name: median5
groupBy:
- fieldContext: resource
name: service.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_runningdiff_disk_growth:
description: 'runningDiff turns a gauge into the change between consecutive
points, so a filesystem usage gauge becomes bytes written per step.
It takes no arguments and drops the first point of the window, since
that point has nothing to difference against; the server reads one
step before the window to keep the first interval. Functions apply
in order: clampMin with 0 runs on the differences and replaces the
negative value a cleanup or a volume reset produces with zero.'
summary: 'runningDiff then clampMin: bytes written to disk per step'
value:
compositeQuery:
queries:
- spec:
aggregations:
- metricName: system.filesystem.usage
spaceAggregation: sum
timeAggregation: latest
filter:
expression: state = 'used'
functions:
- name: runningDiff
- args:
- value: 0
name: clampMin
groupBy:
- fieldContext: resource
name: host.name
name: A
signal: metrics
stepInterval: 60s
type: builder_query
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
function_timeshift_week_over_week:
description: timeShift moves every point of its query forward by the
argument, in seconds, so a series read from the past lands on the
current timestamps. B here reads the hour exactly seven days (604800
s) before the requested window and comes back on this week's timestamps,
so it overlays A on a chart and the formula can subtract it point
by point. The argument is positive; it says how far back to read.
summary: 'timeShift: this week''s error logs next to last week''s'
value:
compositeQuery:
queries:
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
name: A
signal: logs
stepInterval: 60s
type: builder_query
- spec:
aggregations:
- alias: errors
expression: count()
filter:
expression: severity_text = 'ERROR'
functions:
- args:
- value: 604800
name: timeShift
name: B
signal: logs
stepInterval: 60s
type: builder_query
- spec:
expression: A - B
name: F1
type: builder_formula
end: 1640998800000
requestType: time_series
schemaVersion: v1
start: 1640995200000
logs_raw:
summary: 'Raw: fetch raw log records'
value:

View File

@@ -7079,7 +7079,7 @@ export interface Querybuildertypesv5QueryBuilderFormulaDTO {
*/
functions?: Querybuildertypesv5FunctionDTO[] | null;
/**
* @description Accepted but currently ignored; the computed values are returned unfiltered.
* @description A filter on the formula values, referencing the formula by its name or as __result, for example F1 > 0. A time series keeps only the points that match and drops a series with none left; a scalar result keeps only the rows that match.
*/
having?: Querybuildertypesv5HavingDTO;
/**

View File

@@ -903,40 +903,6 @@ describe('prepareQueryRangePayloadV5', () => {
const logSpec = builderQuery.spec as LogBuilderQuery;
expect(logSpec.filter).toStrictEqual({ expression: '' });
});
it('sends the AI slot as builder_ai_query envelopes, ignoring builder', () => {
const props: GetQueryResultsProps = {
query: {
queryType: EQueryType.AI_QUERY_BUILDER,
id: 'q-ai',
unit: undefined,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [baseBuilderQuery({ queryName: 'M' })],
queryFormulas: [],
queryTraceOperator: [],
},
builder_ai_query: {
queryData: [
baseBuilderQuery({ queryName: 'A', dataSource: DataSource.TRACES }),
],
queryFormulas: [],
queryTraceOperator: [],
},
},
graphType: PANEL_TYPES.TIME_SERIES,
selectedTime: 'GLOBAL_TIME',
start,
end,
};
const { queries } =
prepareQueryRangePayloadV5(props).queryPayload.compositeQuery;
expect(
queries.map((q) => [q.type, (q.spec as { name: string }).name]),
).toStrictEqual([['builder_ai_query', 'A']]);
});
});
describe('mapPanelTypeToRequestType', () => {

View File

@@ -3,7 +3,6 @@
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { toWireQuery } from 'lib/query/builderSlot';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty } from 'lodash-es';
@@ -560,15 +559,9 @@ export const prepareQueryRangePayloadV5 = (
const requestType = mapPanelTypeToRequestType(graphType);
let queries: QueryEnvelope[] = [];
const wireQuery = toWireQuery(query);
switch (wireQuery.queryType) {
switch (query.queryType) {
case EQueryType.QUERY_BUILDER: {
const {
queryData: data,
queryFormulas,
queryTraceOperator,
} = wireQuery.builder;
const { queryData: data, queryFormulas, queryTraceOperator } = query.builder;
const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams);
const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName');
@@ -630,13 +623,13 @@ export const prepareQueryRangePayloadV5 = (
break;
}
case EQueryType.PROM: {
const promQueries = reduceQueriesToObject(wireQuery[wireQuery.queryType]);
const promQueries = reduceQueriesToObject(query[query.queryType]);
queries = convertPromQueriesToV5(promQueries.queries);
legendMap = promQueries.legends;
break;
}
case EQueryType.CLICKHOUSE: {
const chQueries = reduceQueriesToObject(wireQuery[wireQuery.queryType]);
const chQueries = reduceQueriesToObject(query[query.queryType]);
queries = convertClickHouseQueriesToV5(chQueries.queries);
legendMap = chQueries.legends;
break;

View File

@@ -1,5 +1,4 @@
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
@@ -50,7 +49,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
initialDataSource,
handleRunQuery,
} = useQueryBuilder();
const activeBuilder = getActiveBuilder(currentQuery);
const containerRef = useRef(null);
@@ -131,28 +129,28 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
const renderedQueries = useMemo(
() =>
isMultiQueryAllowed
? activeBuilder.queryData
: activeBuilder.queryData.slice(0, 1),
[isMultiQueryAllowed, activeBuilder.queryData],
? currentQuery.builder.queryData
: currentQuery.builder.queryData.slice(0, 1),
[isMultiQueryAllowed, currentQuery.builder.queryData],
);
const traceOperator = useMemo((): IBuilderTraceOperator | undefined => {
if (
activeBuilder.queryTraceOperator &&
activeBuilder.queryTraceOperator.length > 0
currentQuery.builder.queryTraceOperator &&
currentQuery.builder.queryTraceOperator.length > 0
) {
return activeBuilder.queryTraceOperator[0];
return currentQuery.builder.queryTraceOperator[0];
}
return undefined;
}, [activeBuilder.queryTraceOperator]);
}, [currentQuery.builder.queryTraceOperator]);
const hasAtLeastOneTraceQuery = useMemo(
() =>
activeBuilder.queryData.some(
currentQuery.builder.queryData.some(
(query) => query.dataSource === DataSource.TRACES,
),
[activeBuilder.queryData],
[currentQuery.builder.queryData],
);
const hasTraceOperator = useMemo(
@@ -227,9 +225,9 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
{!isMultiQueryAllowed ? (
<QueryV2
ref={containerRef}
key={activeBuilder.queryData[0].queryName}
key={currentQuery.builder.queryData[0].queryName}
index={0}
query={activeBuilder.queryData[0]}
query={currentQuery.builder.queryData[0]}
fieldsConfig={fieldsConfig}
allowedDataSources={queryDataSources}
isMultiQueryAllowed={isMultiQueryAllowed}
@@ -240,7 +238,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause}
isRawQuery={isRawQuery}
signalSource={activeBuilder.queryData[0].source as 'meter' | ''}
signalSource={currentQuery.builder.queryData[0].source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={1}
@@ -266,26 +264,28 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
signalSource={query.source as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={activeBuilder.queryData.length}
queriesCount={currentQuery.builder.queryData.length}
savePreviousQuery={savePreviousQuery}
/>
))
)}
{!showOnlyWhereClause && activeBuilder.queryFormulas?.length > 0 && (
<div className="qb-formulas-container">
{activeBuilder.queryFormulas.map((formula, index) => {
const query =
activeBuilder.queryData[index] || activeBuilder.queryData[0];
{!showOnlyWhereClause &&
currentQuery.builder.queryFormulas?.length > 0 && (
<div className="qb-formulas-container">
{currentQuery.builder.queryFormulas.map((formula, index) => {
const query =
currentQuery.builder.queryData[index] ||
currentQuery.builder.queryData[0];
return (
<div key={formula.queryName} className="qb-formula">
<Formula query={query} formula={formula} index={index} isQBV2 />
</div>
);
})}
</div>
)}
return (
<div key={formula.queryName} className="qb-formula">
<Formula query={query} formula={formula} index={index} isQBV2 />
</div>
);
})}
</div>
)}
{shouldShowFooter && (
<QueryFooter
@@ -319,7 +319,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
</div>
))}
{activeBuilder.queryFormulas?.map((formula) => (
{currentQuery.builder.queryFormulas?.map((formula) => (
<div key={formula.queryName} className="formula-name">
{formula.queryName}
</div>

View File

@@ -1,5 +1,4 @@
import { useMemo } from 'react';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { Button, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import WarningPopover from 'components/WarningPopover/WarningPopover';
@@ -16,25 +15,28 @@ function TraceOperatorSection({
addTraceOperator?: () => void;
}): JSX.Element {
const { currentQuery, panelType } = useQueryBuilder();
const activeBuilder = getActiveBuilder(currentQuery);
const showTraceOperatorWarning = useMemo(() => {
const isRawQueryPanel =
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
const hasMultipleQueries = activeBuilder.queryData.length > 1;
const hasMultipleQueries = currentQuery.builder.queryData.length > 1;
const hasTraceOperator =
activeBuilder.queryTraceOperator &&
activeBuilder.queryTraceOperator.length > 0;
currentQuery.builder.queryTraceOperator &&
currentQuery.builder.queryTraceOperator.length > 0;
return isRawQueryPanel && hasMultipleQueries && !hasTraceOperator;
}, [activeBuilder.queryData, activeBuilder.queryTraceOperator, panelType]);
}, [
currentQuery?.builder?.queryData,
currentQuery?.builder?.queryTraceOperator,
panelType,
]);
const traceOperatorWarning = useMemo(() => {
if (activeBuilder.queryData.length === 0) {
if (currentQuery.builder.queryData.length === 0) {
return '';
}
const firstQuery = activeBuilder.queryData[0];
const firstQuery = currentQuery.builder.queryData[0];
return `Currently, you are only seeing results from query ${firstQuery.queryName}. Add a trace operator to combine results of multiple queries.`;
}, [activeBuilder.queryData]);
}, [currentQuery]);
return (
<div className="qb-trace-operator-button-container">
<Tooltip

View File

@@ -26,7 +26,6 @@ import {
queryOperatorSuggestions,
} from 'constants/antlrQueryConstants';
import { useDynamicVariableSuggestions } from 'hooks/dashboard/useDynamicVariableSuggestions';
import { useBuilderQueryType } from 'hooks/queryBuilder/useBuilderQueryType';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useDebounce from 'hooks/useDebounce';
import { debounce, isNull } from 'lodash-es';
@@ -133,7 +132,6 @@ function QuerySearch({
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
const builderQueryType = useBuilderQueryType(queryData);
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>('');
@@ -332,7 +330,7 @@ function QuerySearch({
source: signalSource as TelemetrytypesSourceDTO,
metricNamespace,
},
builderQueryType,
queryData.builderQueryType,
);
if (response.data.keys) {
@@ -370,7 +368,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
builderQueryType,
queryData.builderQueryType,
],
);
@@ -512,7 +510,7 @@ function QuerySearch({
source: signalSource as TelemetrytypesSourceDTO,
metricName: debouncedMetricName ?? undefined,
},
builderQueryType,
queryData.builderQueryType,
).then((response) => {
const responseData = response.data;
const responseDataValues = responseData.values;
@@ -615,7 +613,7 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
builderQueryType,
queryData.builderQueryType,
],
);

View File

@@ -12,7 +12,6 @@ import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import QBEntityOptions from 'container/QueryBuilder/components/QBEntityOptions/QBEntityOptions';
import { QueryProps } from 'container/QueryBuilder/type';
import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearchV2/SpanScopeSelector';
import { useBuilderQueryType } from 'hooks/queryBuilder/useBuilderQueryType';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Copy, Ellipsis, Trash } from '@signozhq/icons';
@@ -63,8 +62,7 @@ export const QueryV2 = forwardRef(function QueryV2(
const { cloneQuery, panelType } = useQueryBuilder();
const hasQueryFunctions = query?.functions?.length > 0;
const { dataSource } = query;
const builderQueryType = useBuilderQueryType(query);
const { dataSource, builderQueryType } = query;
const [isCollapsed, setIsCollapsed] = useState(false);

View File

@@ -2,7 +2,6 @@
/* eslint-disable sonarjs/no-identical-functions */
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { getActiveBuilder } from 'lib/query/builderSlot';
import {
autocompletion,
closeCompletion,
@@ -88,18 +87,17 @@ function TraceOperatorEditor({
const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
const [lastExternalValue, setLastExternalValue] = useState<string>('');
const { currentQuery } = useQueryBuilder();
const activeBuilder = getActiveBuilder(currentQuery);
const queryOptions = useMemo(
() =>
activeBuilder.queryData
currentQuery.builder.queryData
.filter((query) => query.dataSource === DataSource.TRACES) // Only show trace queries
.map((query) => ({
label: query.queryName,
type: 'atom',
apply: query.queryName,
})),
[activeBuilder.queryData],
[currentQuery.builder.queryData],
);
const toggleSuggestions = useCallback(

View File

@@ -1,71 +0,0 @@
import {
initialQueriesMap,
initialQueryAIWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { render, screen } from 'tests/test-utils';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { QueryBuilderV2 } from '../QueryBuilderV2';
jest.mock('hooks/queryBuilder/useQueryBuilder');
jest.mock('../QueryV2/QueryV2', () => ({
QueryV2: ({ query }: { query: IBuilderQuery }): JSX.Element => (
<div data-testid="query-v2">{query.queryName}</div>
),
}));
jest.mock('../QueryV2/QueryFooter/QueryFooter', () => (): null => null);
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
function renderWith(currentQuery: Query): void {
mockedUseQueryBuilder.mockReturnValue({
currentQuery,
panelType: PANEL_TYPES.TIME_SERIES,
initialDataSource: null,
handleSetConfig: jest.fn(),
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
addTraceOperator: jest.fn(),
handleRunQuery: jest.fn(),
} as unknown as ReturnType<typeof useQueryBuilder>);
render(<QueryBuilderV2 panelType={PANEL_TYPES.TIME_SERIES} version="v3" />);
}
const builderWithQuery = (
builder: Query['builder'],
queryName: string,
): Query['builder'] => ({
...builder,
queryData: [{ ...builder.queryData[0], queryName }],
});
const query: Query = {
...initialQueriesMap.metrics,
builder: builderWithQuery(initialQueriesMap.metrics.builder, 'MAIN'),
builder_ai_query: builderWithQuery(
getActiveBuilder(initialQueryAIWithType),
'AI',
),
};
describe('QueryBuilderV2 builder slot', () => {
it('renders the AI slot when AI is the query type', () => {
renderWith({ ...query, queryType: EQueryType.AI_QUERY_BUILDER });
expect(screen.getByTestId('query-v2')).toHaveTextContent('AI');
});
it('renders builder when Query Builder is the query type', () => {
renderWith(query);
expect(screen.getByTestId('query-v2')).toHaveTextContent('MAIN');
});
});

View File

@@ -1,8 +1,8 @@
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { render } from 'tests/test-utils';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { QueryBuilderV2 } from '../QueryBuilderV2';
@@ -19,7 +19,7 @@ const aiQuery: Query = {
...initialQueriesMap.traces.builder,
queryData: initialQueriesMap.traces.builder.queryData.map((query) => ({
...query,
builderQueryType: EQueryType.AI_QUERY_BUILDER,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
})),
},
};

View File

@@ -1,16 +1,13 @@
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
adjustQueryForV5,
convertAggregationToExpression,
convertExpressionToFilters,
convertFiltersToExpression,
@@ -1687,31 +1684,3 @@ describe('formatValueForExpression', () => {
});
});
});
describe('adjustQueryForV5', () => {
it('adjusts the AI builder slot for AI query builder queries', () => {
const emptyBuilder = {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
};
const query: Query = {
id: 'q',
queryType: EQueryType.AI_QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: emptyBuilder,
builder_ai_query: {
...emptyBuilder,
queryData: [initialQueryBuilderFormValuesMap.logs],
},
};
const result = adjustQueryForV5(query);
expect(result.builder).toBe(emptyBuilder);
expect(result.builder_ai_query?.queryData[0]).not.toHaveProperty(
'aggregateOperator',
);
});
});

View File

@@ -1,5 +1,4 @@
import { useEffect, useRef } from 'react';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -21,8 +20,7 @@ export function useSyncQueryBuilderFields(
fields: SyncedQueryBuilderField[] = NO_FIELDS,
): void {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const activeBuilder = getActiveBuilder(currentQuery);
const queries = activeBuilder.queryData;
const queries = currentQuery.builder.queryData;
const previousRef = useRef<Map<string, IBuilderQuery>>();
const enabled = fields.length > 0;

View File

@@ -9,11 +9,6 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import {
getActiveBuilder,
getBuilderSlotKey,
isBuilderQueryType,
} from 'lib/query/builderSlot';
import FilterQueryLexer from 'parser/FilterQueryLexer';
import FilterQueryParser, {
AndExpressionContext,
@@ -38,6 +33,7 @@ import {
MetricAggregation,
TraceAggregation,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils';
@@ -1034,9 +1030,8 @@ export function getQueryLabelWithAggregation(
}
export const adjustQueryForV5 = (currentQuery: Query): Query => {
if (isBuilderQueryType(currentQuery.queryType)) {
const activeBuilder = getActiveBuilder(currentQuery);
const newQueryData = activeBuilder.queryData.map((query) => {
if (currentQuery.queryType === EQueryType.QUERY_BUILDER) {
const newQueryData = currentQuery.builder.queryData.map((query) => {
const aggregations = query.aggregations?.map((aggregation) => {
if (query.dataSource === DataSource.METRICS) {
const metricAggregation = aggregation as MetricAggregation;
@@ -1077,8 +1072,8 @@ export const adjustQueryForV5 = (currentQuery: Query): Query => {
return {
...currentQuery,
[getBuilderSlotKey(currentQuery.queryType)]: {
...activeBuilder,
builder: {
...currentQuery.builder,
queryData: newQueryData,
},
};

View File

@@ -4,8 +4,6 @@ function QueryTypeTag({ queryType }: IQueryTypeTagProps): JSX.Element {
switch (queryType) {
case EQueryType.QUERY_BUILDER:
return <span>Query Builder</span>;
case EQueryType.AI_QUERY_BUILDER:
return <span>AI Query Builder</span>;
case EQueryType.CLICKHOUSE:
return <span>ClickHouse Query</span>;

View File

@@ -3,7 +3,6 @@ import {
convertFiltersToExpressionWithExistingQuery,
} from 'components/QueryBuilderV2/utils';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { EQueryType } from 'types/common/dashboard';
import {
Query,
TagFilter,
@@ -557,49 +556,3 @@ describe('clearFilterFromQuery', () => {
expect(active.filter?.expression).toBe(`http.method = 'GET'`);
});
});
describe('quick filters on an AI query', () => {
const builderQuery = buildQuery([], '').builder;
const aiQuery = {
queryType: EQueryType.AI_QUERY_BUILDER,
builder: builderQuery,
builder_ai_query: buildQuery(
[{ key: KEY, op: 'in', value: ['a'] }],
`${KEY} IN ['a']`,
).builder,
} as unknown as Query;
it('a checkbox toggle writes into the AI slot and leaves builder alone', () => {
const result = applyCheckboxToggle({
currentQuery: aiQuery,
activeQueryIndex: 0,
filter: { attributeKey: { key: KEY, type: 'tag' } } as never,
quickFilterSource: QuickFiltersSource.AI_OBSERVABILITY,
attributeValues: ['a', 'b', 'c'],
value: 'b',
checked: true,
isOnlyOrAllClicked: false,
});
expect(result.builder).toBe(builderQuery);
expect(
result.builder_ai_query?.queryData[0].filters?.items.map(
(item) => item.value,
),
).toStrictEqual([['a', 'b']]);
});
it('clearing a filter clears it from the AI slot', () => {
const result = clearFilterFromQuery({
currentQuery: aiQuery,
filterKey: KEY,
activeQueryIndex: 0,
});
expect(result.builder).toBe(builderQuery);
expect(result.builder_ai_query?.queryData[0].filters?.items).toStrictEqual(
[],
);
expect(result.builder_ai_query?.queryData[0].filter?.expression).toBe('');
});
});

View File

@@ -10,11 +10,6 @@ import {
import { OPERATORS } from 'constants/antlrQueryConstants';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
import { cloneDeep, isArray } from 'lodash-es';
import {
getBuilderAt,
getBuilderSlotKey,
withBuilderAt,
} from 'lib/query/builderSlot';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { v4 as uuid } from 'uuid';
@@ -162,9 +157,8 @@ export function applyCheckboxToggle({
previousState?: CheckedState;
sectionType?: SectionType;
}): Query {
const slot = getBuilderSlotKey(currentQuery.queryType);
const builder = getBuilderAt(currentQuery, slot);
const activeItems = builder.queryData?.[activeQueryIndex]?.filters?.items;
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
@@ -179,7 +173,7 @@ export function applyCheckboxToggle({
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const query = cloneDeep(builder.queryData?.[activeQueryIndex]);
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
// if only or all are clicked we do not need to worry about anything just override whatever we have
// by either adding a new IN operator value clause in case of ONLY or remove everything we have for ALL.
@@ -436,15 +430,18 @@ export function applyCheckboxToggle({
query.filters = synced.filters;
}
return withBuilderAt(currentQuery, slot, {
...builder,
queryData: [
...builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
});
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
}

View File

@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { DEFAULT_VISIBLE_ITEMS_COUNT } from './constants';
import { isKeyMatch } from './utils';
@@ -38,13 +37,12 @@ function useCheckboxDisclosure({
DEFAULT_VISIBLE_ITEMS_COUNT,
);
const { queryData } = getActiveBuilder(currentQuery);
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
!!queryData?.[activeQueryIndex]?.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[queryData, activeQueryIndex, filter.attributeKey.key],
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
);
const isOpen = useMemo(() => {

View File

@@ -4,7 +4,6 @@ import {
QuickFiltersSource,
} from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { isEqual, isFunction } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -78,7 +77,7 @@ function useCheckboxFilterActions({
dispatch(updatedQuery);
if (onQuickFilterChange) {
const queryData = getActiveBuilder(updatedQuery).queryData[activeQueryIndex];
const queryData = updatedQuery.builder.queryData[activeQueryIndex];
const expression = queryData?.filter?.expression || '';
const filterItemKeys = (queryData?.filters?.items || [])
.map((item) => item.key?.key)

View File

@@ -1,7 +1,6 @@
import { useMemo } from 'react';
import { IQuickFiltersConfig } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { deriveCheckboxState } from './checkboxFilterQuery';
import { isKeyMatch } from './utils';
@@ -32,28 +31,33 @@ function useCheckboxFilterState({
activeQueryIndex,
}: UseCheckboxFilterStateProps): UseCheckboxFilterStateReturn {
const { currentQuery } = useQueryBuilder();
const activeQueryData = currentQuery
? getActiveBuilder(currentQuery).queryData
: undefined;
// derive the state of each filter key here and keep it in sync with current query
const currentFilterState = useMemo(
() =>
deriveCheckboxState({
attributeValues,
filterItems: activeQueryData?.[activeQueryIndex]?.filters?.items,
filterItems:
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
filterKey: filter.attributeKey.key,
}),
[attributeValues, activeQueryData, filter.attributeKey, activeQueryIndex],
[
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
],
);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(activeQueryData?.[activeQueryIndex]?.filters?.items?.filter((item) =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[activeQueryData, activeQueryIndex, filter.attributeKey],
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
);
// whether the current filter has multiple values to its name in the key op value section

View File

@@ -11,7 +11,6 @@ import {
} from 'components/QuickFilters/types';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -129,15 +128,18 @@ export default function CheckboxFilterV2(
onToggleOpen();
};
const activeQueryData = currentQuery
? getActiveBuilder(currentQuery).queryData
: undefined;
const currentFilterOp = useMemo(() => {
const filterSync = activeQueryData?.[activeQueryIndex]?.filters?.items.find(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
);
return filterSync?.op;
}, [activeQueryData, activeQueryIndex, filter.attributeKey.key]);
}, [
currentQuery?.builder.queryData,
activeQueryIndex,
filter.attributeKey.key,
]);
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { QuickFilterCheckboxUseFieldApis } from 'components/QuickFilters/types';
@@ -19,9 +18,6 @@ export function useExistingQuery({
activeQueryIndex,
}: UseExistingQueryParams): UseExistingQueryResult {
const { stagedQuery } = useQueryBuilder();
const stagedQueryData = stagedQuery
? getActiveBuilder(stagedQuery).queryData
: undefined;
const existingQuery = useMemo(() => {
if (useFieldApis.existingQuery === null) {
@@ -32,7 +28,7 @@ export function useExistingQuery({
return useFieldApis.existingQuery;
}
const queryData = stagedQueryData?.[activeQueryIndex];
const queryData = stagedQuery?.builder.queryData?.[activeQueryIndex];
// Prefer V5 filter.expression
if (queryData?.filter?.expression) {
@@ -45,17 +41,21 @@ export function useExistingQuery({
}
return undefined;
}, [useFieldApis.existingQuery, stagedQueryData, activeQueryIndex]);
}, [
useFieldApis.existingQuery,
stagedQuery?.builder.queryData,
activeQueryIndex,
]);
// Check if ANY filters exist in query (V3 items or V5 expression)
// This is separate from existingQuery because existingQuery can be explicitly
// disabled (null) while filters still exist in the query for UI purposes
const hasExistingQuery = useMemo(() => {
const queryData = stagedQueryData?.[activeQueryIndex];
const queryData = stagedQuery?.builder.queryData?.[activeQueryIndex];
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
const hasV5Expression = !!queryData?.filter?.expression;
return hasV3Items || hasV5Expression || !!existingQuery;
}, [stagedQueryData, activeQueryIndex, existingQuery]);
}, [stagedQuery?.builder.queryData, activeQueryIndex, existingQuery]);
return { existingQuery, hasExistingQuery };
}

View File

@@ -9,12 +9,6 @@ import {
import { getMs } from 'utils/timeUtils';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
getActiveBuilder,
getBuilderAt,
getBuilderSlotKey,
withBuilderAt,
} from 'lib/query/builderSlot';
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
import {
AllTraceFilterKeys,
@@ -63,8 +57,7 @@ function Duration({
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {
const filters =
compositeQuery &&
getActiveBuilder(compositeQuery).queryData?.[activeQueryIndex]?.filters;
compositeQuery?.builder.queryData?.[activeQueryIndex]?.filters;
if (!filters) {
return {} as FilterType;
}
@@ -190,51 +183,49 @@ function Duration({
};
const removeFilterItemIds = (query: Query): Query => {
const slot = getBuilderSlotKey(query.queryType);
const builder = getBuilderAt(query, slot);
return withBuilderAt(cloneDeep(query), slot, {
...builder,
queryData: builder.queryData.map((data) => ({
...data,
filters: {
...data.filters,
items:
data.filters?.items?.map((item) => ({
...item,
id: '',
})) || [],
op: data.filters?.op || 'AND',
},
})),
});
const clonedQuery = cloneDeep(query);
clonedQuery.builder.queryData = clonedQuery.builder.queryData.map((data) => ({
...data,
filters: {
...data.filters,
items:
data.filters?.items?.map((item) => ({
...item,
id: '',
})) || [],
op: data.filters?.op || 'AND',
},
}));
return clonedQuery;
};
const handleRun = useCallback(
(props?: HandleRunProps): void => {
const slot = getBuilderSlotKey(currentQuery.queryType);
const builder = getBuilderAt(currentQuery, slot);
const preparedQuery: Query = withBuilderAt(currentQuery, slot, {
...builder,
queryData: builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filters: {
...item.filters,
items: props?.resetAll
? []
: (unionTagFilterItems(item.filters?.items || [], preparePostData())
.map((item) =>
item.key?.key === props?.clearByType ? undefined : item,
)
.filter((i) => i) as TagFilterItem[]),
op: item.filters?.op || 'AND',
},
};
}),
});
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filters: {
...item.filters,
items: props?.resetAll
? []
: (unionTagFilterItems(item.filters?.items || [], preparePostData())
.map((item) =>
item.key?.key === props?.clearByType ? undefined : item,
)
.filter((i) => i) as TagFilterItem[]),
op: item.filters?.op || 'AND',
},
};
}),
},
};
const currentQueryWithoutIds = removeFilterItemIds(currentQuery);
const preparedQueryWithoutIds = removeFilterItemIds(preparedQuery);

View File

@@ -1,9 +1,4 @@
import { removeKeysFromExpression } from 'components/QueryBuilderV2/utils';
import {
getBuilderAt,
getBuilderSlotKey,
withBuilderAt,
} from 'lib/query/builderSlot';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getKeySpellings, isKeyMatch } from '../Checkbox/utils';
@@ -25,33 +20,34 @@ export function clearFilterFromQuery({
activeQueryIndex: number;
operators?: string[];
}): Query {
const slot = getBuilderSlotKey(currentQuery.queryType);
const builder = getBuilderAt(currentQuery, slot);
return withBuilderAt(currentQuery, slot, {
...builder,
queryData: builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filter: {
expression: removeKeysFromExpression(
item.filter?.expression ?? '',
getKeySpellings(filterKey),
false,
operators,
),
},
filters: {
...item.filters,
items:
item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filterKey),
) || [],
op: item.filters?.op || 'AND',
},
};
}),
});
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filter: {
expression: removeKeysFromExpression(
item.filter?.expression ?? '',
getKeySpellings(filterKey),
false,
operators,
),
},
filters: {
...item.filters,
items:
item.filters?.items?.filter(
(fil) => !isKeyMatch(fil.key?.key, filterKey),
) || [],
op: item.filters?.op || 'AND',
},
};
}),
},
};
}

View File

@@ -32,12 +32,6 @@ import {
QuickFilterReadPermission,
} from 'lib/authz/hooks/useAuthZ/permissions/quick-filter.permissions';
import { isFunction } from 'lodash-es';
import {
getActiveBuilder,
getBuilderAt,
getBuilderSlotKey,
withBuilderAt,
} from 'lib/query/builderSlot';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import useActiveQueryIndex from './hooks/useActiveQueryIndex';
@@ -93,15 +87,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
panelType,
} = useQueryBuilder();
const [open, setOpen] = useState(false);
const activeBuilder = currentQuery
? getActiveBuilder(currentQuery)
: undefined;
// Sync lastUsedQuery when queries change (e.g., after deletion)
const validQueryIndex = useMemo(
() =>
Math.min(lastUsedQuery || 0, (activeBuilder?.queryData?.length || 1) - 1),
[lastUsedQuery, activeBuilder?.queryData?.length],
Math.min(
lastUsedQuery || 0,
(currentQuery?.builder?.queryData?.length || 1) - 1,
),
[lastUsedQuery, currentQuery?.builder?.queryData?.length],
);
// Determine if we're in ListView mode
@@ -109,15 +103,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Generate query options based on available queries
const queryOptions = useMemo(() => {
if (!activeBuilder?.queryData) {
if (!currentQuery?.builder?.queryData) {
return [];
}
return activeBuilder.queryData.map((query, index) => ({
return currentQuery.builder.queryData.map((query, index) => ({
label: query.queryName || String.fromCharCode(65 + index),
value: index,
}));
}, [activeBuilder?.queryData]);
}, [currentQuery?.builder?.queryData]);
// Show dropdown in ListView only for TRACES_EXPLORER source
const shouldShowDropdownInListView =
@@ -133,35 +127,36 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// clear all the filters for the query which is in sync with filters
const handleReset = (): void => {
const activeQuery = activeBuilder?.queryData?.[activeQueryIndex];
const activeQuery = currentQuery?.builder.queryData?.[activeQueryIndex];
if (!activeQuery) {
return;
}
const slot = getBuilderSlotKey(currentQuery.queryType);
const builder = getBuilderAt(currentQuery, slot);
const preparedQuery: Query = withBuilderAt(currentQuery, slot, {
...builder,
queryData: builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
return {
...item,
filter: {
...item.filter,
expression: '',
},
filters: {
...item.filters,
items: [],
op: item.filters?.op || 'AND',
},
};
}),
});
return {
...item,
filter: {
...item.filter,
expression: '',
},
filters: {
...item.filters,
items: [],
op: item.filters?.op || 'AND',
},
};
}),
},
};
if (onFilterChange && isFunction(onFilterChange)) {
onFilterChange(preparedQuery);
@@ -171,12 +166,13 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
};
const lastQueryName =
showQueryName && activeBuilder?.queryData?.[lastUsedQuery || 0]?.queryName;
showQueryName &&
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
// In ListView, always show the 0th query's name; otherwise use the active query's name
const displayedQueryName =
isListView || isAIObservabilityRowView
? showQueryName && activeBuilder?.queryData?.[0]?.queryName
? showQueryName && currentQuery.builder.queryData?.[0]?.queryName
: lastQueryName;
const handleQueryChange = (value: number): void => {

View File

@@ -311,10 +311,14 @@ export const initialQueryMeterWithType: Query = {
export const initialQueryAIWithType: Query = {
...initialQueryWithType,
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: {
builder: {
...initialQueryWithType.builder,
queryData: [initialQueryBuilderFormValuesMap.traces],
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
builderQueryType: 'builder_ai_query',
},
],
},
};

View File

@@ -21,14 +21,15 @@ import {
useListDashboardsForUserV2,
} from 'api/generated/services/dashboard';
import {
getListRulesQueryKey,
useListRules,
getListRulesV3QueryKey,
useListRulesV3,
} from 'api/generated/services/rules';
import type {
DashboardtypesListedDashboardForUserV2DTO,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListRules200,
ListRulesV3200,
ListRulesV3Params,
} from 'api/generated/services/sigNoz.schemas';
import logEvent from 'api/common/logEvent';
import { useQueryService } from 'hooks/useQueryService';
@@ -179,6 +180,9 @@ const VOICE_UNAVAILABLE_KEY = 'ai-assistant-voice-unavailable';
*/
const DASHBOARD_LIST_PARAMS: ListDashboardsForUserV2Params = { limit: 1000 };
/** Same as dashboards. 200 is the most the rules list returns per call. */
const ALERT_RULE_LIST_PARAMS: ListRulesV3Params = { limit: 200 };
function dashboardTitle(
dashboard: DashboardtypesListedDashboardForUserV2DTO,
): string {
@@ -755,7 +759,7 @@ export default function ChatInput({
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRules({
} = useListRulesV3(ALERT_RULE_LIST_PARAMS, {
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
staleTime: Infinity,
@@ -804,10 +808,10 @@ export default function ChatInput({
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListRules200>(
getListRulesQueryKey(),
const cached = queryClient.getQueryData<ListRulesV3200>(
getListRulesV3QueryKey(ALERT_RULE_LIST_PARAMS),
);
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
const rule = cached?.data.rules.find((r) => r.id === ctx.resourceId);
if (rule?.alert) {
return rule.alert;
}
@@ -835,8 +839,8 @@ export default function ChatInput({
value: dashboardTitle(dashboard),
})) ?? [],
Alerts:
alertsResponse?.data
?.filter((alertRule) => Boolean(alertRule.alert))
alertsResponse?.data.rules
.filter((alertRule) => Boolean(alertRule.alert))
.map((alertRule) => ({
id: alertRule.id,
value: alertRule.alert,
@@ -1059,6 +1063,7 @@ export default function ChatInput({
color="secondary"
size="sm"
id={`ai-context-tab-${category}`}
testId={`ai-context-tab-${category}`}
// Single stable panel id shared by every tab: only the
// active category's tabpanel is rendered, so per-category
// `aria-controls` ids would point at nonexistent nodes

View File

@@ -0,0 +1,70 @@
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
jest.mock('api/generated/services/dashboard', () => ({
useListDashboardsForUserV2: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListDashboardsForUserV2QueryKey: (): string[] => ['dashboards'],
}));
jest.mock('hooks/useQueryService', () => ({
useQueryService: (): unknown => ({
data: [],
isLoading: false,
isFetching: false,
isError: false,
}),
}));
jest.mock('../../../hooks/useSpeechRecognition', () => ({
useSpeechRecognition: (): unknown => ({
isListening: false,
isSupported: false,
permission: 'prompt',
start: jest.fn(),
discard: jest.fn(),
}),
}));
jest.mock('../../../hooks/useAIAssistantAnalyticsContext', () => ({
useAIAssistantAnalyticsContext: (): unknown => ({
threadId: undefined,
page: '/',
mode: 'sidepane',
}),
}));
// eslint-disable-next-line import/first
import ChatInput from '../ChatInput';
describe('ChatInput — alerts context picker', () => {
it('lists the rules from one v3 rules request', async () => {
const [first, second] = alertRulesFixture;
const requests: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
requests.push(req.url.search);
return res(
ctx.status(200),
ctx.json(listRulesV3Response([first, second], req.url.searchParams)),
);
}),
);
render(<ChatInput onSend={jest.fn()} />);
await userEvent.click(screen.getByRole('button', { name: /add context/i }));
await userEvent.click(await screen.findByTestId('ai-context-tab-Alerts'));
await expect(screen.findByText(first.alert)).resolves.toBeInTheDocument();
expect(screen.getByText(second.alert)).toBeInTheDocument();
expect(requests).toStrictEqual(['?limit=200']);
});
});

View File

@@ -12,8 +12,12 @@ jest.mock('api/generated/services/dashboard', () => ({
}));
jest.mock('api/generated/services/rules', () => ({
useListRules: (): unknown => ({ data: [], isLoading: false, isError: false }),
getListRulesQueryKey: (): string[] => ['rules'],
useListRulesV3: (): unknown => ({
data: undefined,
isLoading: false,
isError: false,
}),
getListRulesV3QueryKey: (): string[] => ['rules'],
}));
jest.mock('hooks/useQueryService', () => ({

View File

@@ -13,11 +13,6 @@ import {
Query,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
getBuilderAt,
getBuilderSlotKey,
withBuilderAt,
} from 'lib/query/builderSlot';
import { v4 as uuidv4 } from 'uuid';
/** Param shape emitted by the AI for every filter-touching action. */
@@ -61,11 +56,12 @@ export function replaceFirstQueryData(
query: Query,
updated: IBuilderQuery,
): Query {
const slot = getBuilderSlotKey(query.queryType);
const builder = getBuilderAt(query, slot);
const queryData = [...builder.queryData];
const queryData = [...query.builder.queryData];
queryData[0] = updated;
return withBuilderAt(query, slot, { ...builder, queryData });
return {
...query,
builder: { ...query.builder, queryData },
};
}
/** Page-state deps required by the runQuery / addFilter actions. */

View File

@@ -40,7 +40,6 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
[EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql),
[EQueryType.CLICKHOUSE]: () =>
getSelectedQueryOptions(currentQuery.clickhouse_sql),
[EQueryType.AI_QUERY_BUILDER]: () => [],
};
return queryConfig[currentQuery.queryType]?.() || [];

View File

@@ -18,7 +18,7 @@ import { buildCreateAlertRulePayload, validateCreateAlertState } from './utils';
import './styles.scss';
import {
invalidateGetRuleByID,
invalidateListRules,
invalidateListRulesV3,
} from 'api/generated/services/rules';
import { useQueryClient } from 'react-query';
@@ -137,7 +137,7 @@ function Footer(): JSX.Element {
{
onSuccess: () => {
void invalidateGetRuleByID(queryClient, { id: ruleId });
void invalidateListRules(queryClient);
void invalidateListRulesV3(queryClient);
toast.success('Alert rule updated successfully');
safeNavigate('/alerts');

View File

@@ -218,7 +218,6 @@ function FormAlertRules({
[EQueryType.PROM]: () => getSelectedQueryOptions(currentQuery.promql),
[EQueryType.CLICKHOUSE]: () =>
getSelectedQueryOptions(currentQuery.clickhouse_sql),
[EQueryType.AI_QUERY_BUILDER]: () => [],
};
return queryConfig[currentQuery.queryType]?.() || [];

View File

@@ -1,24 +1,22 @@
import { useEffect, useState } from 'react';
import { useEffect } from 'react';
import { Button, Skeleton } from 'antd';
import { Badge } from '@signozhq/ui/badge';
import logEvent from 'api/common/logEvent';
import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { AppLink } from 'lib/router/AppLink';
import { navigate } from 'lib/router/navigation';
import { useAppLocation } from 'lib/router/useAppLocation';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { USER_ROLES } from 'types/roles';
import beaconUrl from '@/assets/Icons/beacon.svg';
import { getItemIcon } from '../constants';
import { useHomeAlertRules } from './useHomeAlertRules';
export default function AlertRules({
onUpdateChecklistDoneItem,
@@ -28,49 +26,19 @@ export default function AlertRules({
loadingUserPreferences: boolean;
}): JSX.Element {
const { user } = useAppContext();
const [rulesExist, setRulesExist] = useState(false);
const [sortedAlertRules, setSortedAlertRules] = useState<RuletypesRuleDTO[]>(
[],
);
const location = useAppLocation();
const params = new URLSearchParams(location.search);
// Fetch Alerts
const {
data: alerts,
isError,
isLoading,
} = useListRules({
query: { cacheTime: 0 },
});
const { rules: alertRules, isError, isLoading } = useHomeAlertRules();
const rulesExist = alertRules.length > 0;
useEffect(() => {
const rules = alerts?.data ?? [];
setRulesExist(rules.length > 0);
const sortedRules = [...rules].sort((a, b) => {
// First, prioritize firing alerts
if (a.state === 'firing' && b.state !== 'firing') {
return -1;
}
if (a.state !== 'firing' && b.state === 'firing') {
return 1;
}
// Then sort by updatedAt timestamp
return (
new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
);
});
if (sortedRules.length > 0 && !loadingUserPreferences) {
if (rulesExist && !loadingUserPreferences) {
onUpdateChecklistDoneItem('SETUP_ALERTS');
}
setSortedAlertRules(sortedRules.slice(0, 5));
}, [alerts, onUpdateChecklistDoneItem, loadingUserPreferences]);
}, [rulesExist, onUpdateChecklistDoneItem, loadingUserPreferences]);
const emptyStateCard = (): JSX.Element => (
<div className="empty-state-container">
@@ -95,7 +63,7 @@ export default function AlertRules({
className="periscope-btn secondary"
icon={<Plus size={16} />}
onClick={(): void => {
logEvent('Homepage: Create alert rule clicked', {});
void logEvent('Homepage: Create alert rule clicked', {});
}}
>
Create Alert Rule
@@ -106,7 +74,7 @@ export default function AlertRules({
type="link"
className="learn-more-link"
onClick={(): void => {
logEvent('Homepage: Learn more clicked', {
void logEvent('Homepage: Learn more clicked', {
source: 'Alert Rules',
});
@@ -125,26 +93,13 @@ export default function AlertRules({
</div>
);
const onEditHandler = (record: RuletypesRuleDTO) => (): void => {
logEvent('Homepage: Alert clicked', {
const onEditHandler = (record: RuletypesListableRuleDTO) => (): void => {
void logEvent('Homepage: Alert clicked', {
ruleId: record.id,
ruleName: record.alert,
ruleState: record.state,
});
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {
params.set(QueryParams.panelTypes, panelType);
}
params.set(QueryParams.ruleId, record.id);
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
@@ -153,11 +108,12 @@ export default function AlertRules({
const renderAlertRules = (): JSX.Element => (
<div className="alert-rules-container home-data-item-container">
<div className="alert-rules-list">
{sortedAlertRules.map((rule) => (
{alertRules.map((rule) => (
<div
role="button"
tabIndex={0}
className="alert-rule-item home-data-item"
data-testid={`home-alert-rule-${rule.id}`}
key={rule.id}
onClick={onEditHandler(rule)}
onKeyDown={(e): void => {
@@ -239,7 +195,7 @@ export default function AlertRules({
type="link"
className="periscope-btn link learn-more-link"
onClick={(): void => {
logEvent('Homepage: All alert rules clicked', {});
void logEvent('Homepage: All alert rules clicked', {});
}}
>
All Alert Rules <ArrowRight size={12} />

View File

@@ -0,0 +1,64 @@
import { useMemo } from 'react';
import { useListRulesV3 } from 'api/generated/services/rules';
import {
type ListRulesV3200,
type ListRulesV3Params,
RuletypesAlertStateDTO,
type RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { GeneratedAPIInstance } from 'api/generatedAPIInstance';
const HOME_ALERT_RULES_LIMIT = 5;
const FIRING_RULES_PARAMS: ListRulesV3Params = {
states: [RuletypesAlertStateDTO.firing],
limit: HOME_ALERT_RULES_LIMIT,
};
interface HomeAlertRules {
rules: RuletypesListableRuleDTO[];
isLoading: boolean;
isError: boolean;
}
export function useHomeAlertRules(): HomeAlertRules {
const firing = useListRulesV3(FIRING_RULES_PARAMS, {
query: {
cacheTime: 0,
// The backend binds repeated `states=` keys; axios' default sends `states[]=`.
queryFn: ({ signal }) =>
GeneratedAPIInstance<ListRulesV3200>({
url: '/api/v3/rules',
method: 'GET',
params: FIRING_RULES_PARAMS,
paramsSerializer: { indexes: null },
signal,
}),
},
});
const firingRules = firing.data?.data.rules;
const latest = useListRulesV3(
{ limit: HOME_ALERT_RULES_LIMIT },
{
query: {
cacheTime: 0,
enabled:
firingRules !== undefined && firingRules.length < HOME_ALERT_RULES_LIMIT,
},
},
);
const rules = useMemo(() => {
const firingIds = new Set(firingRules?.map(({ id }) => id));
const others = (latest.data?.data.rules ?? []).filter(
({ id }) => !firingIds.has(id),
);
return [...(firingRules ?? []), ...others].slice(0, HOME_ALERT_RULES_LIMIT);
}, [firingRules, latest.data]);
return {
rules,
isLoading: firing.isLoading || latest.isLoading,
isError: firing.isError || latest.isError,
};
}

View File

@@ -0,0 +1,119 @@
import {
RuletypesAlertStateDTO,
type RuletypesRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { navigate } from 'lib/router/navigation';
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent } from 'tests/test-utils';
import AlertRules from '../AlertRules/AlertRules';
jest.mock('lib/router/navigation', () => ({
...jest.requireActual('lib/router/navigation'),
navigate: jest.fn(),
}));
const rule = (id: string, state: RuletypesAlertStateDTO): RuletypesRuleDTO => ({
...alertRulesFixture[0],
id,
alert: `${id} rule`,
state,
});
const burning = rule('burning', RuletypesAlertStateDTO.firing);
const quiet = rule('quiet', RuletypesAlertStateDTO.inactive);
const waiting = rule('waiting', RuletypesAlertStateDTO.pending);
const serveRules = (
rules: RuletypesRuleDTO[],
rulesForLatest = rules,
): string[] => {
const requestedStates: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
const states = req.url.searchParams.getAll('states').join();
requestedStates.push(states);
return res(
ctx.status(200),
ctx.json(
listRulesV3Response(
states === 'firing' ? rules : rulesForLatest,
req.url.searchParams,
),
),
);
}),
);
return requestedStates;
};
const renderAlertRules = (): void => {
render(
<AlertRules
onUpdateChecklistDoneItem={jest.fn()}
loadingUserPreferences={false}
/>,
);
};
const renderedRuleIds = (): (string | undefined)[] =>
screen.getAllByTestId(/^home-alert-rule-/).map((row) => row.dataset.testid);
describe('Home alert rules', () => {
it('lists firing rules first, then the latest others, and opens the clicked rule', async () => {
const requestedStates = serveRules([quiet, burning, waiting]);
renderAlertRules();
await expect(
screen.findByTestId('home-alert-rule-waiting'),
).resolves.toBeInTheDocument();
expect(renderedRuleIds()).toStrictEqual([
'home-alert-rule-burning',
'home-alert-rule-quiet',
'home-alert-rule-waiting',
]);
expect(requestedStates).toStrictEqual(['firing', '']);
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByTestId('home-alert-rule-burning'));
expect(navigate).toHaveBeenCalledWith(
`${ROUTES.ALERT_OVERVIEW}?${QueryParams.ruleId}=burning`,
);
});
it('skips the latest rules request when 5 rules are firing', async () => {
const firing = Array.from({ length: 5 }, (_, index) =>
rule(`firing-${index}`, RuletypesAlertStateDTO.firing),
);
const requestedStates = serveRules([quiet, ...firing]);
renderAlertRules();
await expect(
screen.findByTestId('home-alert-rule-firing-4'),
).resolves.toBeInTheDocument();
expect(renderedRuleIds()).toStrictEqual(
firing.map(({ id }) => `home-alert-rule-${id}`),
);
expect(requestedStates).toStrictEqual(['firing']);
});
it('shows a rule once when it stops firing between the two requests', async () => {
const settled = { ...burning, state: RuletypesAlertStateDTO.inactive };
serveRules([burning], [settled, quiet]);
renderAlertRules();
await expect(
screen.findByTestId('home-alert-rule-quiet'),
).resolves.toBeInTheDocument();
expect(renderedRuleIds()).toStrictEqual([
'home-alert-rule-burning',
'home-alert-rule-quiet',
]);
});
});

View File

@@ -25,7 +25,6 @@ import {
useHandleExplorerTabChange,
} from 'hooks/useHandleExplorerTabChange';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { isEmpty } from 'lodash-es';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { ExplorerViews } from 'pages/LogsExplorer/utils';
@@ -187,9 +186,7 @@ function Explorer(): JSX.Element {
const isFilterApplied = useMemo(() => {
// if any of the non-disabled queries has filters applied, return true
const result = (
stagedQuery ? getActiveBuilder(stagedQuery) : undefined
)?.queryData?.filter(
const result = stagedQuery?.builder?.queryData?.filter(
(item) => !isEmpty(item.filters?.items) && !item.disabled,
);
return !!result?.length;

View File

@@ -15,7 +15,6 @@ import { convertDataValueToMs } from 'container/TimeSeriesView/utils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlYAxisUnit from 'hooks/useUrlYAxisUnit';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { AppState } from 'store/reducers';
import { Warning } from 'types/api';
import APIError from 'types/api/error';
@@ -36,7 +35,7 @@ function TimeSeriesViewContainer({
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
getActiveBuilder(currentQuery).queryData.forEach(
currentQuery.builder.queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||

View File

@@ -18,7 +18,6 @@ import {
FilterDeps,
replaceFirstQueryData,
} from 'container/AIAssistant/pageActions/builderQueryHelpers';
import { getActiveBuilder } from 'lib/query/builderSlot';
import {
ActionResult,
PageAction,
@@ -94,7 +93,7 @@ export function tracesRunQueryAction(
},
autoApply: true,
execute: async ({ filters }): Promise<ActionResult> => {
const baseQuery = getActiveBuilder(deps.currentQuery).queryData[0];
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}
@@ -118,7 +117,7 @@ export function tracesRunQueryAction(
},
getContext: (): Record<string, unknown> => ({
filters:
getActiveBuilder(deps.currentQuery).queryData[0]?.filters?.items?.map(
deps.currentQuery.builder.queryData[0]?.filters?.items?.map(
(f: TagFilterItem) => ({
key: f.key?.key,
op: f.op,
@@ -159,7 +158,7 @@ export function tracesAddFilterAction(
},
autoApply: true,
execute: async ({ key, op, value }): Promise<ActionResult> => {
const baseQuery = getActiveBuilder(deps.currentQuery).queryData[0];
const baseQuery = deps.currentQuery.builder.queryData[0];
if (!baseQuery) {
throw new Error('No active query found in Traces Explorer.');
}

View File

@@ -1,5 +1,4 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { cloneDeep } from 'lodash-es';
import { OrderByPayload, Query } from 'types/api/queryBuilder/queryBuilderData';
import { parseListOrderBy } from 'utils/explorerUtils';
@@ -16,9 +15,8 @@ export const getListViewQuery = (
? [parseListOrderBy(orderBy)]
: [];
const { queryData: builderQueries } = getActiveBuilder(query);
for (let i = 0; i < builderQueries.length; i++) {
const queryData = builderQueries[i];
for (let i = 0; i < query.builder.queryData.length; i++) {
const queryData = query.builder.queryData[i];
queryData.groupBy = [];
queryData.having = {
expression: '',

View File

@@ -16,11 +16,11 @@ import {
useDeleteDowntimeScheduleByID,
useListDowntimeSchedules,
} from 'api/generated/services/downtimeschedules';
import { useListRules } from 'api/generated/services/rules';
import type { AlertmanagertypesPlannedMaintenanceDTO } from 'api/generated/services/sigNoz.schemas';
import { ColumnSelector } from 'components/TanStackTableView';
import TextToolTip from 'components/TextToolTip';
import dayjs from 'dayjs';
import { useListAllRules } from 'hooks/alerts/useListAllRules';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
@@ -55,9 +55,7 @@ export function PlannedDowntime(): JSX.Element {
isError,
isLoading,
isFetching: isFetchingAlerts,
} = useListRules({
query: { cacheTime: 0 },
});
} = useListAllRules({ cacheTime: 0 });
const [isOpen, setIsOpen] = React.useState(false);
const [form] = Form.useForm();
const { user } = useAppContext();
@@ -73,7 +71,7 @@ export function PlannedDowntime(): JSX.Element {
const downtimeSchedules = useListDowntimeSchedules();
const alertOptions = React.useMemo(
() =>
alertsData?.data?.map((i) => ({
alertsData?.map((i) => ({
label: i.alert,
value: i.id,
})),
@@ -181,7 +179,7 @@ export function PlannedDowntime(): JSX.Element {
};
const refetchAllSchedules = (): void => {
downtimeSchedules.refetch();
void downtimeSchedules.refetch();
};
const {

View File

@@ -4,6 +4,7 @@ import { fireEvent, screen } from '@testing-library/react';
import type {
ListDowntimeSchedules200,
RenderErrorResponseDTO,
RuletypesListableRuleDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
AlertmanagertypesRepeatOnDTO,
@@ -126,19 +127,19 @@ jest.mock('api/generated/services/downtimeschedules', () => ({
}));
let mockRulesState: {
data?: { data: Array<{ id: number | string; alert: string }> };
data?: Pick<RuletypesListableRuleDTO, 'id' | 'alert'>[];
isError: boolean;
isLoading: boolean;
isFetching: boolean;
} = {
data: { data: [] },
data: [],
isError: false,
isLoading: false,
isFetching: false,
};
jest.mock('api/generated/services/rules', () => ({
useListRules: (): typeof mockRulesState => mockRulesState,
jest.mock('hooks/alerts/useListAllRules', () => ({
useListAllRules: (): typeof mockRulesState => mockRulesState,
}));
describe('PlannedDowntime Component', () => {
@@ -151,7 +152,7 @@ describe('PlannedDowntime Component', () => {
});
mockDowntimeQueryResult.data = MOCK_DOWNTIME_RESPONSE;
mockRulesState = {
data: { data: [] },
data: [],
isError: false,
isLoading: false,
isFetching: false,
@@ -362,7 +363,7 @@ describe('PlannedDowntime Component', () => {
it('shows "Unknown rule" when scoped rule IDs cannot be resolved (Bug 56)', async () => {
mockRulesState = {
data: { data: [] },
data: [],
isError: false,
isLoading: false,
isFetching: false,
@@ -387,14 +388,7 @@ describe('PlannedDowntime Component', () => {
it('shows resolved alert rule name when scoped rule ID exists', async () => {
mockRulesState = {
data: {
data: [
{
id: 'rule-42',
alert: 'Disk Space High',
} as any,
],
},
data: [{ id: 'rule-42', alert: 'Disk Space High' }],
isError: false,
isLoading: false,
isFetching: false,

View File

@@ -29,7 +29,6 @@ import { getMetricNameForQuery } from '../utils';
import OptionRenderer from '../OptionRenderer/OptionRenderer';
import { GroupByFilterProps } from './GroupByFilter.interfaces';
import { toAttributeKey } from './utils';
import { useBuilderQueryType } from 'hooks/queryBuilder/useBuilderQueryType';
export const GroupByFilter = memo(function GroupByFilter({
query,
@@ -46,7 +45,6 @@ export const GroupByFilter = memo(function GroupByFilter({
const debouncedValue = useDebounce(searchText, DEBOUNCE_DELAY);
const builderQueryType = useBuilderQueryType(query);
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
{
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
@@ -54,7 +52,7 @@ export const GroupByFilter = memo(function GroupByFilter({
metricName: getMetricNameForQuery(query),
searchText: debouncedValue,
},
builderQueryType,
query.builderQueryType,
{ enabled: !disabled && isFocused, keepPreviousData: true },
);

View File

@@ -14,7 +14,6 @@ import { selectStyle } from '../QueryBuilderSearchV2/config';
import { getMetricNameForQuery } from '../utils';
import { OrderByFilterProps } from './OrderByFilter.interfaces';
import { useOrderByFilter } from './useOrderByFilter';
import { useBuilderQueryType } from 'hooks/queryBuilder/useBuilderQueryType';
export function OrderByFilter({
query,
@@ -35,7 +34,6 @@ export function OrderByFilter({
handleSearchKeys,
} = useOrderByFilter({ query, onChange, entityVersion });
const builderQueryType = useBuilderQueryType(query);
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
{
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
@@ -43,7 +41,7 @@ export function OrderByFilter({
metricName: getMetricNameForQuery(query),
searchText: debouncedSearchText,
},
builderQueryType,
query.builderQueryType,
{
enabled: !!query.aggregateAttribute?.key || isRawQuery,
keepPreviousData: true,

View File

@@ -9,7 +9,6 @@ import {
useState,
} from 'react';
import { type RefSelectProps, Select, Spin } from 'antd';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { Pill } from '@signozhq/ui/pill';
import cx from 'classnames';
import {
@@ -857,10 +856,14 @@ function QueryBuilderSearchV2(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const isLastQuery = useMemo(() => {
const { queryData } = getActiveBuilder(currentQuery);
return isEqual(queryData[queryData.length - 1], query);
}, [currentQuery, query]);
const isLastQuery = useMemo(
() =>
isEqual(
currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1],
query,
),
[currentQuery, query],
);
useEffect(() => {
if (isLastQuery) {

View File

@@ -2,7 +2,6 @@ import { useMemo } from 'react';
import { UseQueryResult } from 'react-query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { isBuilderQueryType } from 'lib/query/builderSlot';
import { ContextLinksData } from 'types/api/widgets/widget';
import { MetricQueryRangeSuccessResponse } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -42,7 +41,7 @@ export function useGraphContextMenu({
} {
const drilldownQuery = useGetCompositeQueryParam() || query;
const isQueryTypeBuilder = isBuilderQueryType(drilldownQuery?.queryType);
const isQueryTypeBuilder = drilldownQuery?.queryType === 'builder';
const { aggregateDrilldownConfig } = useAggregateDrilldown({
query: drilldownQuery,

View File

@@ -3,7 +3,6 @@ import { useAppParams } from 'lib/router/useAppParams';
import cx from 'classnames';
import { ResizeTable } from 'components/ResizeTable';
import Download from 'container/Download/Download';
import { isBuilderQueryType } from 'lib/query/builderSlot';
import {
createTableColumnsFromQuery,
RowData,
@@ -34,7 +33,7 @@ export function QueryTable({
...props
}: QueryTableProps): JSX.Element {
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
const isQueryTypeBuilder = isBuilderQueryType(query.queryType);
const isQueryTypeBuilder = query.queryType === 'builder';
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');

View File

@@ -1,7 +1,6 @@
import WidgetHeader from 'container/WidgetCard/Header';
import { mockAppLocation } from 'tests/router-mocks';
import { fireEvent, render } from 'tests/test-utils';
import { EQueryType } from 'types/common/dashboard';
import { QueryTable } from '../QueryTable';
import { QueryTableProps, WidgetHeaderProps } from './mocks';
@@ -30,21 +29,6 @@ describe('QueryTable -', () => {
const tableRows = container.querySelectorAll('tr.ant-table-row');
expect(tableRows).toHaveLength(3);
});
it('should enable drilldown cells for AI query builder queries', () => {
const { container } = render(
<QueryTable
{...QueryTableProps}
query={{
...QueryTableProps.query,
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: QueryTableProps.query.builder,
}}
enableDrillDown
/>,
);
expect(container.querySelector('.clickable-cell')).toBeInTheDocument();
});
});
const setSearchTerm = jest.fn();

View File

@@ -14,12 +14,12 @@ import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import createQueryParams from 'lib/createQueryParams';
import { getActiveBuilder, isBuilderQueryType } from 'lib/query/builderSlot';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
getCustomTimeRangeWindowSweepInMS,
getStartAndEndTimesInMilliseconds,
} from 'pages/MessagingQueues/MessagingQueuesUtils';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { useGraphClickToShowButton } from 'container/WidgetCard/hooks/useGraphClickToShowButton';
@@ -132,13 +132,13 @@ function WidgetGraphComponent({
const [searchTerm, setSearchTerm] = useState<string>('');
const widgetQueryData = widget?.query
? getActiveBuilder(widget.query)?.queryData
: undefined;
const graphClick = useGraphClickToShowButton({
graphRef: currentGraphRef?.current ? currentGraphRef : graphRef,
isButtonEnabled: (Array.isArray(widgetQueryData) ? widgetQueryData : []).some(
isButtonEnabled: (widget?.query?.builder?.queryData &&
Array.isArray(widget.query.builder.queryData)
? widget.query.builder.queryData
: []
).some(
(q) =>
q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS,
),
@@ -219,7 +219,7 @@ function WidgetGraphComponent({
customOnDragSelect={customOnDragSelect}
setCurrentGraphRef={setCurrentGraphRef}
enableDrillDown={
enableDrillDown && isBuilderQueryType(widget?.query?.queryType)
enableDrillDown && widget?.query?.queryType === EQueryType.QUERY_BUILDER
}
/>
</Modal>

View File

@@ -1,5 +1,4 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
createColumnsAndDataSource,
@@ -48,18 +47,6 @@ describe('Table Panel utils', () => {
expect(getQueryLegend(query, 'B')).toBeUndefined();
});
it('getQueryLegend reads the AI builder slot', () => {
const builderQuery = widgetQueryWithLegend as Query;
const query: Query = {
...builderQuery,
queryType: EQueryType.AI_QUERY_BUILDER,
builder: { queryData: [], queryFormulas: [], queryTraceOperator: [] },
builder_ai_query: builderQuery.builder,
};
expect(getQueryLegend(query, 'A')).toBe('p99');
});
it('sorter function for table sorting', () => {
let rowA: {
A: string | number;

View File

@@ -4,7 +4,6 @@ import { ThresholdProps } from 'types/api/widgets/threshold';
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { isEmpty, isNaN } from 'lodash-es';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -120,15 +119,14 @@ export function getQueryLegend(
let legend: string | undefined;
switch (currentQuery.queryType) {
case EQueryType.QUERY_BUILDER:
case EQueryType.AI_QUERY_BUILDER:
// check if the value is present in the queries
legend = getActiveBuilder(currentQuery)?.queryData?.find(
legend = currentQuery?.builder?.queryData?.find(
(query) => query.queryName === queryName,
)?.legend;
if (!legend) {
// check if the value is present in the formula
legend = getActiveBuilder(currentQuery)?.queryFormulas?.find(
legend = currentQuery?.builder?.queryFormulas?.find(
(query) => query.queryName === queryName,
)?.legend;
}
@@ -201,7 +199,7 @@ export function createColumnsAndDataSource(
: undefined;
const isNewAggregation =
getActiveBuilder(currentQuery).queryData?.find(
currentQuery?.builder?.queryData?.find(
(query) => query.queryName === item.queryName,
)?.aggregations?.length || 0;

View File

@@ -6,7 +6,6 @@ import ValueGraph from 'components/ValueGraph';
import { generateGridTitle } from 'utils/generateGridTitle';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import ContextMenu, { useCoordinates } from 'periscope/components/ContextMenu';
import { isBuilderQueryType } from 'lib/query/builderSlot';
import { EQueryType } from 'types/common/dashboard';
import {
@@ -73,7 +72,8 @@ function GridValueComponent({
);
}
const isQueryTypeBuilder = isBuilderQueryType(widget?.query?.queryType);
const isQueryTypeBuilder =
widget?.query?.queryType === EQueryType.QUERY_BUILDER;
return (
<>

View File

@@ -58,27 +58,6 @@ describe('getLegend', () => {
expect(legendsData).toBe('alias_sum');
});
it('should read the AI builder slot for AI query builder queries', () => {
const payloadQuery = getMockQuery({
...mockQuery,
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: {
...mockQuery.builder,
queryData: [
{
...mockQuery.builder.queryData[0],
queryName: mockQueryData.queryName,
dataSource: DataSource.LOGS,
aggregations: [{ expression: "sum(bytes) as 'alias_sum'" }],
},
],
},
});
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
expect(legendsData).toBe('alias_sum');
});
it('should return legend when single builder query with no alias but legend set (builder)', () => {
const payloadQuery = getMockQuery({
...mockQuery,

View File

@@ -0,0 +1,40 @@
import {
alertRulesFixture,
listRulesV3Response,
} from 'mocks-server/__mockdata__/alert_rules';
import { rest, server } from 'mocks-server/server';
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
import { useListAllRules } from '../useListAllRules';
const TOTAL = 250;
const rules = Array.from({ length: TOTAL }, (_, index) => ({
...alertRulesFixture[0],
id: `rule-${index}`,
alert: `Rule ${index}`,
}));
describe('useListAllRules', () => {
it('fetches every page past the 200-rule cap', async () => {
const offsets: string[] = [];
server.use(
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
offsets.push(req.url.searchParams.get('offset') ?? '');
return res(
ctx.status(200),
ctx.json(listRulesV3Response(rules, req.url.searchParams)),
);
}),
);
const { result } = renderHook(() => useListAllRules(), {
wrapper: AllTheProviders,
});
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toHaveLength(TOTAL);
expect(result.current.data?.[TOTAL - 1]?.id).toBe(`rule-${TOTAL - 1}`);
expect(offsets.sort()).toStrictEqual(['0', '200']);
});
});

View File

@@ -0,0 +1,40 @@
import { useQuery, UseQueryOptions, UseQueryResult } from 'react-query';
import {
getListRulesV3QueryKey,
listRulesV3,
} from 'api/generated/services/rules';
import type { RuletypesListableRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { range } from 'lodash-es';
// The list API clamps any larger limit to 200.
const PAGE_SIZE = 200;
export const LIST_ALL_RULES_QUERY_KEY = [...getListRulesV3QueryKey(), 'all'];
function fetchPage(offset: number): ReturnType<typeof listRulesV3> {
return listRulesV3({ limit: PAGE_SIZE, offset });
}
// TODO(AshwinBhatkal): temporary. Replace with server-side search plus a lookup of saved rules by ID once
// /api/v3/rules can filter by IDs: https://github.com/SigNoz/pulse-pod/issues/715
async function fetchAllRules(): Promise<RuletypesListableRuleDTO[]> {
const first = await fetchPage(0);
const rest = await Promise.all(
range(PAGE_SIZE, first.data.total, PAGE_SIZE).map(fetchPage),
);
return [...first.data.rules, ...rest.flatMap((page) => page.data.rules)];
}
export function useListAllRules(
options?: Omit<
UseQueryOptions<RuletypesListableRuleDTO[], Error>,
'queryKey' | 'queryFn'
>,
): UseQueryResult<RuletypesListableRuleDTO[], Error> {
return useQuery<RuletypesListableRuleDTO[], Error>({
queryKey: LIST_ALL_RULES_QUERY_KEY,
queryFn: fetchAllRules,
...options,
});
}

View File

@@ -1,38 +0,0 @@
import { renderHook } from '@testing-library/react';
import { EQueryType } from 'types/common/dashboard';
import { useBuilderQueryType } from '../useBuilderQueryType';
import { useQueryBuilder } from '../useQueryBuilder';
jest.mock('../useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
const withQueryType = (queryType: EQueryType): void => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: { queryType },
});
};
describe('useBuilderQueryType', () => {
it('is AI for every query while AI is the query type', () => {
withQueryType(EQueryType.AI_QUERY_BUILDER);
expect(renderHook(() => useBuilderQueryType({})).result.current).toBe(
'builder_ai_query',
);
});
it("follows the query's own tag otherwise", () => {
withQueryType(EQueryType.QUERY_BUILDER);
expect(
renderHook(() =>
useBuilderQueryType({ builderQueryType: 'builder_ai_query' }),
).result.current,
).toBe('builder_ai_query');
expect(
renderHook(() => useBuilderQueryType({})).result.current,
).toBeUndefined();
});
});

View File

@@ -2,7 +2,6 @@ import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, initialQueryState } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { AllTheProviders, renderHook } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -106,25 +105,18 @@ describe('useGetCompositeQueryParam', () => {
},
});
const { QUERY_BUILDER, AI_QUERY_BUILDER } = EQueryType;
[
[ROUTES.LOGS_EXPLORER, QUERY_BUILDER, DataSource.LOGS, ''],
[ROUTES.AI_OBSERVABILITY_EXPLORER, AI_QUERY_BUILDER, DataSource.TRACES, ''],
[ROUTES.METRICS_EXPLORER_EXPLORER, QUERY_BUILDER, DataSource.METRICS, ''],
[
ROUTES.METER_EXPLORER,
QUERY_BUILDER,
DataSource.METRICS,
TelemetrytypesSourceDTO.meter,
],
].forEach(([route, queryType, dataSource, source]) => {
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
].forEach(([route, dataSource, source]) => {
const query = renderOn(clickhouseWithOperator, route);
const builder = query && getActiveBuilder(query);
expect(query?.queryType).toBe(queryType);
expect(builder?.queryData[0].dataSource).toBe(dataSource);
expect(builder?.queryData[0].source ?? '').toBe(source);
expect(builder?.queryTraceOperator).toStrictEqual([]);
expect(query?.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(query?.builder.queryData[0].dataSource).toBe(dataSource);
expect(query?.builder.queryData[0].source ?? '').toBe(source);
expect(query?.builder.queryTraceOperator).toStrictEqual([]);
});
});

View File

@@ -1,14 +1,16 @@
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
Querybuildertypesv5QueryTypeDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ROUTES from 'constants/routes';
import { EQueryType } from 'types/common/dashboard';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
export type ExplorerQueryRules = {
dataSource: DataSource;
/** `meter` on the meter explorer, which shares the metrics signal; '' elsewhere. */
source: BuilderQuerySource;
/** AI_QUERY_BUILDER on the AI explorer, whose queries live in the AI slot. */
queryType: EQueryType.QUERY_BUILDER | EQueryType.AI_QUERY_BUILDER;
builderQueryType: BuilderQueryType;
allowsTraceOperator: boolean;
};
@@ -16,31 +18,31 @@ const EXPLORER_QUERY_RULES: Partial<Record<string, ExplorerQueryRules>> = {
[ROUTES.LOGS_EXPLORER]: {
dataSource: DataSource.LOGS,
source: '',
queryType: EQueryType.QUERY_BUILDER,
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: false,
},
[ROUTES.TRACES_EXPLORER]: {
dataSource: DataSource.TRACES,
source: '',
queryType: EQueryType.QUERY_BUILDER,
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: true,
},
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
dataSource: DataSource.TRACES,
source: '',
queryType: EQueryType.AI_QUERY_BUILDER,
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_ai_query,
allowsTraceOperator: false,
},
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
dataSource: DataSource.METRICS,
source: '',
queryType: EQueryType.QUERY_BUILDER,
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: false,
},
[ROUTES.METER_EXPLORER]: {
dataSource: DataSource.METRICS,
source: TelemetrytypesSourceDTO.meter,
queryType: EQueryType.QUERY_BUILDER,
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
allowsTraceOperator: false,
},
};

View File

@@ -8,8 +8,11 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { getActiveBuilder } from 'lib/query/builderSlot';
import {
IBuilderQuery,
Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -41,9 +44,8 @@ function firstBuilderQuery(
queryData: unknown[],
pathname: string,
): IBuilderQuery {
return getActiveBuilder(
normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname).query,
).queryData[0];
return normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname)
.query.builder.queryData[0];
}
function without(builderQuery: object, key: keyof IBuilderQuery): object {
@@ -60,11 +62,9 @@ describe('normalizeCompositeQuery', () => {
queryType: EQueryType.CLICKHOUSE,
extra: 'kept',
};
const aiQuery: Query = {
...initialQueriesMap.traces,
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: initialQueriesMap.traces.builder,
};
const aiQuery = withBuilderQueries(initialQueriesMap.traces, [
{ ...tracesQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER },
]);
[NOT_AN_EXPLORER, ROUTES.LOGS_EXPLORER].forEach((pathname) =>
expect(
@@ -86,33 +86,6 @@ describe('normalizeCompositeQuery', () => {
).toBe(false);
});
it('moves the AI queries of an older link into the AI slot', () => {
const aiQueryData = [
{ ...tracesQuery, builderQueryType: EQueryType.AI_QUERY_BUILDER },
];
const { query, changed } = normalizeCompositeQuery(
withBuilderQueries(initialQueriesMap.traces, aiQueryData),
ROUTES.AI_OBSERVABILITY_EXPLORER,
);
expect(changed).toBe(true);
expect(query.queryType).toBe(EQueryType.AI_QUERY_BUILDER);
expect(query.builder_ai_query?.queryData).toStrictEqual([tracesQuery]);
expect(query.builder).toStrictEqual(initialQueryState.builder);
});
it('drops the AI slot outside the AI explorer', () => {
const { query } = normalizeCompositeQuery(
{
...initialQueriesMap.traces,
builder_ai_query: initialQueriesMap.traces.builder,
},
ROUTES.TRACES_EXPLORER,
);
expect(query.builder_ai_query).toBeUndefined();
});
it('adds missing blocks and a stable id without changing its inputs', () => {
const { builder: _, ...builderless } = {
...initialQueriesMap.logs,
@@ -236,7 +209,7 @@ describe('normalizeCompositeQuery', () => {
expect(
firstBuilderQuery(
logs,
[{ ...logsQuery, builderQueryType: EQueryType.AI_QUERY_BUILDER }],
[{ ...logsQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER }],
ROUTES.LOGS_EXPLORER,
).builderQueryType,
).toBeUndefined();
@@ -247,7 +220,7 @@ describe('normalizeCompositeQuery', () => {
{
...tracesQuery,
queryName: 'D',
builderQueryType: EQueryType.AI_QUERY_BUILDER,
builderQueryType: QueryMode.AI_QUERY_BUILDER,
...llmFilter,
},
],
@@ -270,7 +243,10 @@ describe('normalizeCompositeQuery', () => {
).toBe(TelemetrytypesSourceDTO.meter);
expect(
firstBuilderQuery(traces, [{ ...tracesQuery, ...llmFilter }], ai),
).toStrictEqual({ ...tracesQuery, ...llmFilter });
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
...llmFilter,
});
expect(
firstBuilderQuery(
traces,
@@ -284,13 +260,17 @@ describe('normalizeCompositeQuery', () => {
],
ai,
),
).toStrictEqual({ ...tracesQuery, queryName: 'B', ...llmFilter });
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
queryName: 'B',
filter: { expression: '' },
});
expect(
firstBuilderQuery(traces, [{ ...logsQuery, queryName: 'C' }], ai),
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
dataSource: DataSource.TRACES,
queryName: 'C',
filter: { expression: '' },
});
});

View File

@@ -7,10 +7,8 @@ import {
Querybuildertypesv5QueryTypeDTO,
TelemetrytypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import type { QueryBuilderData } from 'types/common/queryBuilder';
import { v5 as uuidv5 } from 'uuid';
import {
@@ -80,14 +78,6 @@ function addMissingBlocks(query: Query, urlQuery: object): void {
if (!query.builder.queryTraceOperator) {
query.builder.queryTraceOperator = [];
}
if (isPlainObject(query.builder_ai_query)) {
const aiBuilder = query.builder_ai_query as QueryBuilderData;
aiBuilder.queryFormulas = aiBuilder.queryFormulas ?? [];
aiBuilder.queryTraceOperator = aiBuilder.queryTraceOperator ?? [];
} else {
delete query.builder_ai_query;
}
}
/**
@@ -103,21 +93,8 @@ function isListOfObjects(value: unknown): boolean {
}
function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
// Explorers only have a query builder: no PromQL or ClickHouse editor.
query.queryType = rules.queryType;
if (rules.queryType === EQueryType.AI_QUERY_BUILDER) {
// Older links carry the AI queries inside `builder`.
if (!query.builder_ai_query) {
query.builder_ai_query = query.builder;
query.builder = cloneDeep(initialQueryState.builder);
}
} else {
delete query.builder_ai_query;
}
const builder = getActiveBuilder(query);
if (!isListOfObjects(builder.queryData) || builder.queryData.length === 0) {
builder.queryData = cloneDeep(initialQueryState.builder.queryData);
}
// Explorers only have the query builder: no PromQL or ClickHouse editor.
query.queryType = EQueryType.QUERY_BUILDER;
if (query.promql.some((promql) => promql.query !== '')) {
query.promql = cloneDeep(initialQueryState.promql);
@@ -128,10 +105,10 @@ function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
}
if (!rules.allowsTraceOperator) {
builder.queryTraceOperator = [];
query.builder.queryTraceOperator = [];
}
builder.queryData = builder.queryData.map((builderQuery) =>
query.builder.queryData = query.builder.queryData.map((builderQuery) =>
fitBuilderQueryToExplorer(builderQuery, rules),
);
}
@@ -171,9 +148,12 @@ function fillMissingSignal(
builderQuery.source = rules.source;
}
// The AI slot marks its queries as AI; a tag from an older link is redundant.
if (rules.queryType === EQueryType.AI_QUERY_BUILDER) {
delete builderQuery.builderQueryType;
// A missing `builderQueryType` already means builder_query, so only AI needs it written.
if (
builderQuery.builderQueryType === undefined &&
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
) {
builderQuery.builderQueryType = rules.builderQueryType;
}
}
@@ -189,11 +169,15 @@ function belongsToExplorer(
return false;
}
// Only the AI explorer runs AI queries.
return (
builderQuery.builderQueryType !==
Querybuildertypesv5QueryTypeDTO.builder_ai_query
);
const builderQueryType =
builderQuery.builderQueryType ??
Querybuildertypesv5QueryTypeDTO.builder_query;
if (builderQueryType !== rules.builderQueryType) {
return false;
}
return true;
}
function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
@@ -202,5 +186,11 @@ function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
);
builderQuery.source = rules.source;
if (
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
) {
builderQuery.builderQueryType = rules.builderQueryType;
}
return builderQuery;
}

View File

@@ -1,18 +0,0 @@
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { BuilderQueryType } from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
/**
* The query's builder type, for picking the field suggestion endpoints. Alert editors
* keep AI queries in `builder`, so a query's own tag still counts.
*/
export function useBuilderQueryType(
query: Pick<IBuilderQuery, 'builderQueryType'>,
): BuilderQueryType | undefined {
const { currentQuery } = useQueryBuilder();
return currentQuery?.queryType === EQueryType.AI_QUERY_BUILDER
? 'builder_ai_query'
: query.builderQueryType;
}

View File

@@ -7,7 +7,6 @@ import {
saveAsPreviousQuery,
} from 'components/QueryBuilderV2/QueryV2/previousQuery.utils';
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { LEGEND } from 'constants/global';
import {
ATTRIBUTE_TYPES,
@@ -489,15 +488,13 @@ export const useQueryOperations: UseQueryOperations = ({
],
);
const activeQueryCount = getActiveBuilder(currentQuery).queryData.length;
const handleDeleteQuery = useCallback(() => {
if (activeQueryCount > 1) {
if (currentQuery.builder.queryData.length > 1) {
removeQueryBuilderEntityByIndex('queryData', index);
}
setLastUsedQuery(0);
}, [
activeQueryCount,
currentQuery.builder.queryData.length,
setLastUsedQuery,
removeQueryBuilderEntityByIndex,
index,

View File

@@ -67,23 +67,6 @@ describe('useGetQueryLabels', () => {
});
});
describe('AI_QUERY_BUILDER type', () => {
it('returns formula labels from builder_ai_query', () => {
const query = buildQuery({
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: {
queryData: [],
queryFormulas: [{ queryName: 'F1' } as unknown as IBuilderFormula],
queryTraceOperator: [],
},
});
const { result } = renderHook(() => useGetQueryLabels(query));
expect(result.current).toStrictEqual([{ label: 'F1', value: 'F1' }]);
});
});
describe('CLICKHOUSE type', () => {
it('returns empty array when clickhouse_sql is undefined', () => {
const query = buildQuery({

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { getQueryLabelWithAggregation } from 'components/QueryBuilderV2/utils';
import { getActiveBuilder, isBuilderQueryType } from 'lib/query/builderSlot';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
@@ -8,12 +7,11 @@ export const useGetQueryLabels = (
currentQuery: Query,
): { label: string; value: string }[] =>
useMemo(() => {
if (currentQuery && isBuilderQueryType(currentQuery.queryType)) {
const activeBuilder = getActiveBuilder(currentQuery);
if (currentQuery?.queryType === EQueryType.QUERY_BUILDER) {
const queryLabels = getQueryLabelWithAggregation(
activeBuilder?.queryData || [],
currentQuery?.builder?.queryData || [],
);
const formulaLabels = (activeBuilder?.queryFormulas ?? []).map(
const formulaLabels = (currentQuery?.builder?.queryFormulas ?? []).map(
(formula) => ({
label: formula.queryName,
value: formula.queryName,

View File

@@ -27,7 +27,7 @@ import {
QueryRangeResponseV5,
} from 'types/api/v5/queryRange';
import { QueryData } from 'types/api/widgets/getQuery';
import { getActiveBuilder, isBuilderQueryType } from 'lib/query/builderSlot';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { prepareQueryRangePayload } from './prepareQueryRangePayload';
@@ -36,11 +36,11 @@ import { prepareQueryRangePayload } from './prepareQueryRangePayload';
* Validates if metric name is available for METRICS data source
*/
function validateMetricNameForMetricsDataSource(query: Query): boolean {
if (!isBuilderQueryType(query.queryType)) {
if (query.queryType !== 'builder') {
return true; // Non-builder queries don't need this validation
}
const { queryData } = getActiveBuilder(query);
const { queryData } = query.builder;
// Check if any METRICS data source queries exist
const metricsQueries = queryData.filter(
@@ -124,15 +124,14 @@ export const getLegend = (
labelName: string,
): string => {
// For non-query builder queries, return the label name directly
if (!isBuilderQueryType(payloadQuery.queryType)) {
if (payloadQuery.queryType !== EQueryType.QUERY_BUILDER) {
return labelName;
}
const activeBuilder = getActiveBuilder(payloadQuery);
// Combine queryData and queryTraceOperator
const allQueries = [
...(activeBuilder?.queryData || []),
...(activeBuilder?.queryTraceOperator || []),
...(payloadQuery?.builder?.queryData || []),
...(payloadQuery?.builder?.queryTraceOperator || []),
];
const aggregationPerQuery = allQueries.reduce((acc, query) => {

View File

@@ -161,35 +161,6 @@ describe('extractQueryTextStrings', () => {
expect(texts).toStrictEqual(['env = $deployment_environment']);
});
it('extracts from the AI slot, not builder, for an AI query', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.AI_QUERY_BUILDER,
builder: {
queryData: [
{
filter: { expression: 'env = $ignored' },
} as unknown as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
builder_ai_query: {
queryData: [
{
filter: { expression: 'service.name = $service' },
} as unknown as IBuilderQuery,
],
queryFormulas: [],
queryTraceOperator: [],
},
};
expect(extractQueryTextStrings(query)).toStrictEqual([
'service.name = $service',
]);
});
it('skips non-string filter values', () => {
const query: Query = {
...baseQuery,

View File

@@ -1,8 +1,6 @@
import { escapeRegExp, isArray } from 'lodash-es';
import { getActiveBuilder, isBuilderQueryType } from 'lib/query/builderSlot';
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import type { QueryBuilderData } from 'types/common/queryBuilder';
/**
* Builds a RegExp that matches any recognized variable reference syntax:
@@ -52,11 +50,9 @@ export function containsAnyVariableReference(text: string): boolean {
return !!text && ANY_VARIABLE_REFERENCE.test(text);
}
function extractQueryBuilderTexts(
builder: QueryBuilderData | undefined,
): string[] {
function extractQueryBuilderTexts(query: Query): string[] {
const texts: string[] = [];
const queryDataList = builder?.queryData;
const queryDataList = query.builder?.queryData;
if (isArray(queryDataList)) {
queryDataList.forEach((queryData) => {
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
@@ -106,13 +102,13 @@ function extractClickhouseSQLTexts(query: Query): string[] {
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
* - QUERY_BUILDER / AI_QUERY_BUILDER: filter items' string values + filter.expression
* - QUERY_BUILDER: filter items' string values + filter.expression
* - PROM: each promql[].query
* - CLICKHOUSE: each clickhouse_sql[].query
*/
export function extractQueryTextStrings(query: Query): string[] {
if (isBuilderQueryType(query.queryType)) {
return extractQueryBuilderTexts(getActiveBuilder(query));
if (query.queryType === EQueryType.QUERY_BUILDER) {
return extractQueryBuilderTexts(query);
}
if (query.queryType === EQueryType.PROM) {

View File

@@ -1,42 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { mapCompositeQueryFromQuery } from '../mapCompositeQueryFromQuery';
const emptyBuilder = {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
};
describe('mapCompositeQueryFromQuery', () => {
it('sends an AI query as builder_ai_query envelopes under the builder query type', () => {
const query: Query = {
id: 'q',
queryType: EQueryType.AI_QUERY_BUILDER,
promql: [],
clickhouse_sql: [],
builder: {
...emptyBuilder,
queryData: [
{ ...initialQueryBuilderFormValuesMap.metrics, queryName: 'M' },
],
},
builder_ai_query: {
...emptyBuilder,
queryData: [{ ...initialQueryBuilderFormValuesMap.traces, queryName: 'A' }],
},
};
const composite = mapCompositeQueryFromQuery(query, PANEL_TYPES.TIME_SERIES);
expect(composite.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(
composite.queries?.map((q) => [q.type, (q.spec as { name: string }).name]),
).toStrictEqual([['builder_ai_query', 'A']]);
});
});

View File

@@ -1,6 +1,5 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { toWireQuery } from 'lib/query/builderSlot';
import {
BuilderClickHouseResource,
BuilderPromQLResource,
@@ -92,13 +91,8 @@ const buildPromQuery = (
return compositeQuery;
};
const queryTypeMethodMapping: Record<
EQueryType,
(query: Query, panelType: PANEL_TYPES | null) => ICompositeMetricQuery
> = {
const queryTypeMethodMapping = {
[EQueryType.QUERY_BUILDER]: buildBuilderQuery,
[EQueryType.AI_QUERY_BUILDER]: (query, panelType) =>
buildBuilderQuery(toWireQuery(query), panelType),
[EQueryType.CLICKHOUSE]: buildClickHouseQuery,
[EQueryType.PROM]: buildPromQuery,
};

View File

@@ -15,7 +15,6 @@ import {
PromQuery,
QueryBuilderFormula,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import {
convertBuilderQueryToIBuilderQuery,
convertQueryBuilderFormulaToIBuilderFormula,
@@ -38,7 +37,6 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
> = {};
const promQueries: IPromQLQuery[] = [];
const clickhouseQueries: IClickHouseQuery[] = [];
const isAIQuery = compositeQuery.queryType === EQueryType.AI_QUERY_BUILDER;
compositeQuery.queries?.forEach((q) => {
const spec = q.spec as BuilderQuery | PromQuery | ClickHouseQuery;
@@ -51,13 +49,10 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
}
} else if (q.type === 'builder_ai_query') {
if (spec.name) {
const builderQuery = convertBuilderQueryToIBuilderQuery(
spec as BuilderQuery,
);
// Only AI queries kept in `builder` (alerts) need the tag; the AI slot marks its own.
builderQueries[spec.name] = isAIQuery
? builderQuery
: { ...builderQuery, builderQueryType: 'builder_ai_query' };
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: 'builder_ai_query',
};
builderQueryTypes[spec.name] = 'builder_ai_query';
}
} else if (q.type === 'builder_formula') {
@@ -92,14 +87,8 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
});
}
});
const builder = transformQueryBuilderDataModel(
builderQueries,
builderQueryTypes,
);
return {
builder: isAIQuery ? transformQueryBuilderDataModel({}) : builder,
...(isAIQuery && { builder_ai_query: builder }),
builder: transformQueryBuilderDataModel(builderQueries, builderQueryTypes),
promql: promQueries,
clickhouse_sql: clickhouseQueries,
queryType: compositeQuery.queryType,

View File

@@ -1,93 +0,0 @@
import {
initialQueriesMap,
initialQueryAIWithType,
} from 'constants/queryBuilder';
import {
getActiveBuilder,
getBuilderSlotKey,
isBuilderQueryType,
toWireQuery,
} from 'lib/query/builderSlot';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
const baseQuery = initialQueriesMap[DataSource.METRICS];
const aiBuilder = getActiveBuilder(initialQueryAIWithType);
describe('isBuilderQueryType', () => {
it.each([
[EQueryType.QUERY_BUILDER, true],
[EQueryType.AI_QUERY_BUILDER, true],
[EQueryType.CLICKHOUSE, false],
[EQueryType.PROM, false],
])('%s → %s', (queryType, expected) => {
expect(isBuilderQueryType(queryType)).toBe(expected);
});
});
describe('getBuilderSlotKey', () => {
it.each([
[EQueryType.QUERY_BUILDER, 'builder'],
[EQueryType.CLICKHOUSE, 'builder'],
[EQueryType.PROM, 'builder'],
[EQueryType.AI_QUERY_BUILDER, 'builder_ai_query'],
])('maps %s to %s', (queryType, slot) => {
expect(getBuilderSlotKey(queryType)).toBe(slot);
});
});
describe('getActiveBuilder', () => {
it('reads builder for the Query Builder type', () => {
const query: Query = { ...baseQuery, builder_ai_query: aiBuilder };
expect(getActiveBuilder(query)).toBe(baseQuery.builder);
});
it('reads the AI slot for the AI type', () => {
const query: Query = {
...baseQuery,
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: aiBuilder,
};
expect(getActiveBuilder(query)).toBe(aiBuilder);
});
it('reads an unused AI slot as holding no queries', () => {
const query: Query = { ...baseQuery, queryType: EQueryType.AI_QUERY_BUILDER };
expect(getActiveBuilder(query)).toStrictEqual({
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
});
});
it('keeps builder for ClickHouse and PromQL', () => {
const query: Query = { ...baseQuery, queryType: EQueryType.PROM };
expect(getActiveBuilder(query)).toBe(baseQuery.builder);
});
});
describe('toWireQuery', () => {
it('passes non-AI queries through untouched', () => {
expect(toWireQuery(baseQuery)).toBe(baseQuery);
});
it('sends AI as a builder query holding the AI slot, every query tagged', () => {
const untagged = {
...aiBuilder.queryData[0],
builderQueryType: undefined,
};
const query: Query = {
...baseQuery,
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: { ...aiBuilder, queryData: [untagged] },
};
const wire = toWireQuery(query);
expect(wire.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(wire.builder.queryData).toStrictEqual([
{ ...untagged, builderQueryType: 'builder_ai_query' },
]);
});
});

View File

@@ -1,73 +0,0 @@
import type {
Query,
QueryState,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import type { QueryBuilderData } from 'types/common/queryBuilder';
export type BuilderSlotKey = 'builder' | 'builder_ai_query';
const EMPTY_BUILDER: QueryBuilderData = {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
};
/** Query Builder and AI Query Builder; both author builder queries, each in its own slot. */
export function isBuilderQueryType(queryType: EQueryType | undefined): boolean {
return (
queryType === EQueryType.QUERY_BUILDER ||
queryType === EQueryType.AI_QUERY_BUILDER
);
}
/** The `Query` slot builder edits land in; non-builder types keep `builder`, as they always have. */
export function getBuilderSlotKey(queryType: EQueryType): BuilderSlotKey {
return queryType === EQueryType.AI_QUERY_BUILDER
? 'builder_ai_query'
: 'builder';
}
/**
* The query as the backend takes it. AI goes out as a `builder` query whose queries carry
* `builderQueryType: builder_ai_query`, the tag set from the slot so a stale one can't leak.
*/
export function toWireQuery(query: Query): Query {
if (query.queryType !== EQueryType.AI_QUERY_BUILDER) {
return query;
}
const aiBuilder = getActiveBuilder(query);
return {
...query,
queryType: EQueryType.QUERY_BUILDER,
builder: {
...aiBuilder,
queryData: aiBuilder.queryData.map((queryData) => ({
...queryData,
builderQueryType: 'builder_ai_query',
})),
},
};
}
/** The builder at `slot`; an unused AI slot holds none. */
export function getBuilderAt(
state: QueryState,
slot: BuilderSlotKey,
): QueryBuilderData {
return state[slot] ?? EMPTY_BUILDER;
}
/** `state` with the builder at `slot` replaced. */
export function withBuilderAt<T extends QueryState>(
state: T,
slot: BuilderSlotKey,
builder: QueryBuilderData,
): T {
return { ...state, [slot]: builder };
}
/** The builder queries `queryType` authors; an unused AI slot holds none. */
export function getActiveBuilder(query: Query): QueryBuilderData {
return getBuilderAt(query, getBuilderSlotKey(query.queryType));
}

View File

@@ -12,7 +12,6 @@ import {
import { FORMULA_REGEXP } from 'constants/regExp';
import { QUERY_TABLE_CONFIG } from 'container/QueryTable/config';
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
import { getActiveBuilder, isBuilderQueryType } from 'lib/query/builderSlot';
import { get, isEqual, isNaN, isObject } from 'lodash-es';
import {
IBuilderFormula,
@@ -94,8 +93,8 @@ const getQueryByName = <T extends keyof QueryBuilderData>(
queryArray.find((q) => q.name === currentQueryName) || defaultQueryValue
);
}
if (isBuilderQueryType(query.queryType)) {
const queryArray = (getActiveBuilder(query)[type] || []) as (
if (query.queryType === EQueryType.QUERY_BUILDER) {
const queryArray = (query.builder[type] || []) as (
| IBuilderQuery
| IBuilderFormula
)[];
@@ -174,7 +173,7 @@ const addOperatorFormulaColumns = (
return;
}
if (isBuilderQueryType(queryType)) {
if (queryType === EQueryType.QUERY_BUILDER) {
const currentQueryData = query as IBuilderQuery;
let operatorLabel = `${currentQueryData.aggregateOperator}`;
if (currentQueryData.aggregateAttribute?.key) {

View File

@@ -0,0 +1,51 @@
import {
BuilderVariant,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
/** Which variant the `builder` slot holds, whatever query type is active. */
export function getBuilderVariant(builder: Query['builder']): BuilderVariant {
return builder.queryData[0]?.builderQueryType === QueryMode.AI_QUERY_BUILDER
? BuilderVariant.AI
: BuilderVariant.MAIN;
}
/** The variant a tab authors; `undefined` for tabs that own their own slot. */
export function toBuilderVariant(mode: QueryMode): BuilderVariant | undefined {
if (mode === QueryMode.AI_QUERY_BUILDER) {
return BuilderVariant.AI;
}
return mode === QueryMode.QUERY_BUILDER ? BuilderVariant.MAIN : undefined;
}
/** The tab a query belongs to: its query type, or AI when a builder query carries the tag. */
export function getQueryMode(query: Query): QueryMode {
if (query.queryType !== QueryMode.QUERY_BUILDER) {
return query.queryType;
}
return getBuilderVariant(query.builder) === BuilderVariant.AI
? QueryMode.AI_QUERY_BUILDER
: QueryMode.QUERY_BUILDER;
}
/** Query Builder and AI both author `Query.builder`; the other modes own their own slot. */
export function isBuilderMode(mode: QueryMode): boolean {
return toBuilderVariant(mode) !== undefined;
}
/** Drops the AI tag, leaving the same query readable as a plain builder query. */
export function withoutAIQueryTag(query: Query): Query {
if (getBuilderVariant(query.builder) !== BuilderVariant.AI) {
return query;
}
return {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map(
({ builderQueryType: _tag, ...queryData }) => queryData,
),
},
};
}

View File

@@ -10,7 +10,7 @@ import {
getGetRuleByIDQueryKey,
getGetRuleHistoryTimelineQueryOptions,
invalidateGetRuleByID,
invalidateListRules,
invalidateListRulesV3,
updateRuleByID,
useGetRuleByID,
useGetRuleHistoryOverallStatus,
@@ -443,7 +443,7 @@ export const useAlertRuleClone = ({
message: `Success`,
});
await invalidateListRules(queryClient);
await invalidateListRulesV3(queryClient);
params.set(QueryParams.ruleId, response.data.id);
navigate(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
@@ -494,7 +494,7 @@ export const useAlertRuleUpdate = ({
data: { ...existingRule.data, alert: intermediateName },
});
}
void invalidateListRules(queryClient);
void invalidateListRulesV3(queryClient);
notifications.success({ message: 'Alert renamed successfully' });
},
onError: (error) => {

View File

@@ -17,10 +17,8 @@ import {
FIRST_DOWNTIME_NAME,
} from './__story_mockdata__/plannedDowntime';
import {
alertRulesResponse,
RULE_MAX,
} from '../../stories/__story_mockdata__/alerts';
import { alertRulesPage } from '../../AlertRules/stories/__story_mockdata__/ruleViews';
import { RULE_MAX } from '../../stories/__story_mockdata__/alerts';
import { AlertListSubTabs, AlertListTabs } from '../../types';
const LIST = 'Planned downtime · list';
@@ -86,18 +84,20 @@ export const plannedDowntimeMocks = defineStoryMocks({
res(ctx.status(200), ctx.json({ status: 'success', data: null })),
),
rest.get('http://localhost/api/v2/rules', (_req, res, ctx) =>
values.rulesState === 'error'
? res(ctx.status(500), ctx.json({ status: 'error' }))
: res(
ctx.json(
alertRulesResponse(values.silencedRules, {
severity: 'mixed',
state: 'mixed',
}),
),
rest.get('http://localhost/api/v3/rules', (req, res, ctx) => {
if (values.rulesState === 'error') {
return res(ctx.status(500), ctx.json({ status: 'error' }));
}
return res(
ctx.json(
alertRulesPage(
values.silencedRules,
{ severity: 'mixed', state: 'mixed' },
req.url.searchParams,
),
),
),
);
}),
],
config: () => ({
route: `/alerts?tab=${AlertListTabs.CONFIGURATION}&subTab=${AlertListSubTabs.PLANNED_DOWNTIME}`,

View File

@@ -1,9 +1,10 @@
import { EQueryType } from 'types/common/dashboard';
import { Flame } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getQueryMode } from 'lib/query/queryMode';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { useCreateAlertFromPanel } from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useCreateAlertFromPanel';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import ConfigActionRow from './ConfigActionRow';
import styles from './ConfigActions.module.scss';
@@ -32,7 +33,7 @@ function ConfigActions({
//TODO: AI query check should be removed after we support alerts on ai query @tewarig
if (
!actions.createAlert ||
currentQuery.queryType === EQueryType.AI_QUERY_BUILDER
getQueryMode(currentQuery) === QueryMode.AI_QUERY_BUILDER
) {
return null;
}

View File

@@ -81,7 +81,7 @@ describe('PanelTypeSwitcher', () => {
return {
...OPTIONS.find((option) => option.kind === kind),
mode: kind === 'signoz/TextPanel' ? 'static' : 'query',
supportedQueryTypesKindMap: Object.fromEntries(
supportedQueryModesKindMap: Object.fromEntries(
queryTypes.map((queryType) => [
queryType,
queryType === EQueryType.QUERY_BUILDER

View File

@@ -25,25 +25,6 @@ describe('getPanelTypeDisabledReason', () => {
).toBeUndefined();
});
it('offers the AI kinds to an AI query and explains the rest', () => {
expect(
getPanelTypeDisabledReason({
kind: 'signoz/TimeSeriesPanel',
queryType: EQueryType.AI_QUERY_BUILDER,
signal: TelemetrytypesSignalDTO.traces,
label: 'Time Series',
}),
).toBeUndefined();
expect(
getPanelTypeDisabledReason({
kind: 'signoz/HeatmapPanel',
queryType: EQueryType.AI_QUERY_BUILDER,
signal: TelemetrytypesSignalDTO.traces,
label: 'Heatmap',
}),
).toBe("Heatmap isn't available for AI Query Builder queries");
});
it('explains an unsupported query type', () => {
expect(
getPanelTypeDisabledReason({

View File

@@ -12,7 +12,6 @@ const QUERY_TYPE_LABEL: Record<EQueryType, string> = {
[EQueryType.QUERY_BUILDER]: 'Query Builder',
[EQueryType.CLICKHOUSE]: 'ClickHouse',
[EQueryType.PROM]: 'PromQL',
[EQueryType.AI_QUERY_BUILDER]: 'AI Query Builder',
};
const SIGNAL_LABEL: Record<TelemetrytypesSignalDTO, string> = {

View File

@@ -1,6 +1,5 @@
import { useMemo } from 'react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { useDashboardVariableNames } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardVariableNames';
import type { VariableItem } from './types';
@@ -21,16 +20,13 @@ const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
*/
export function useContextLinkVariables(): VariableItem[] {
const { currentQuery } = useQueryBuilder();
const activeQueryData = currentQuery
? getActiveBuilder(currentQuery).queryData
: undefined;
const dashboardVariableNames = useDashboardVariableNames();
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
const fieldVariableNames = useMemo(() => {
const names = new Set<string>();
(activeQueryData ?? []).forEach((query) => {
(currentQuery?.builder?.queryData ?? []).forEach((query) => {
(query.groupBy ?? []).forEach((field) => {
if (field.key) {
names.add(`_${field.key}`);
@@ -38,7 +34,7 @@ export function useContextLinkVariables(): VariableItem[] {
});
});
return Array.from(names);
}, [activeQueryData]);
}, [currentQuery?.builder?.queryData]);
return useMemo(
() => [

View File

@@ -14,7 +14,7 @@ jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
displayName: 'Time Series',
icon: (): null => null,
mode: 'query',
supportedQueryTypesKindMap: {
supportedQueryModesKindMap: {
builder: { kind: 'signal', signals: ['metrics', 'logs', 'traces'] },
clickhouse_sql: { kind: 'signal-less' },
promql: { kind: 'signal-less' },

View File

@@ -1,4 +1,3 @@
import { EQueryType } from 'types/common/dashboard';
import {
type KeyboardEvent,
type ReactNode,
@@ -24,20 +23,22 @@ import { useSyncQueryBuilderFields } from 'components/QueryBuilderV2/useSyncQuer
import { DataSource } from 'types/common/queryBuilder';
import {
getQueryTypeSignals,
getQueryModeSignals,
isRawRequest,
} from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import { isBuilderQueryType } from 'lib/query/builderSlot';
import { getQueryMode, isBuilderMode } from 'lib/query/queryMode';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { useQueryTypeChange } from './useQueryTypeChange';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types, the signals the
* builder may be pointed at, and how it narrows the builder's fields. */
panelDefinition: RenderableQueryPanelDefinition;
/** Switch authoring tab; owned by the host, which remembers the Query Builder query. */
onChangeQueryMode: (key: string) => void;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
isLoadingQueries: boolean;
/** Run the current query (Stage & Run button / ⌘↵). Always re-runs. */
@@ -57,6 +58,7 @@ interface PanelEditorQueryBuilderProps {
*/
function PanelEditorQueryBuilder({
panelDefinition,
onChangeQueryMode,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
@@ -66,10 +68,6 @@ function PanelEditorQueryBuilder({
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = toPanelType(panelDefinition.kind);
const onChangeQueryType = useQueryTypeChange({
panelType,
supportedQueryTypesKindMap: panelDefinition.supportedQueryTypesKindMap,
});
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isRawQuery = isRawRequest(panelDefinition.queryCapabilities);
@@ -101,10 +99,10 @@ function PanelEditorQueryBuilder({
const items = useMemo(() => {
const queryTypeComponents: Record<
EQueryType,
QueryMode,
{ icon: ReactNode; label: string; component: ReactNode }
> = {
[EQueryType.QUERY_BUILDER]: {
[QueryMode.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
@@ -112,9 +110,9 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
allowedDataSources={getQueryTypeSignals(
panelDefinition.supportedQueryTypesKindMap,
EQueryType.QUERY_BUILDER,
allowedDataSources={getQueryModeSignals(
panelDefinition.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)}
showTraceOperator={!isRawQuery}
version="v3"
@@ -125,12 +123,12 @@ function PanelEditorQueryBuilder({
</div>
),
},
[EQueryType.CLICKHOUSE]: {
[QueryMode.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[EQueryType.PROM]: {
[QueryMode.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
@@ -139,7 +137,7 @@ function PanelEditorQueryBuilder({
label: 'PromQL',
component: <PromQLQueryContainer />,
},
[EQueryType.AI_QUERY_BUILDER]: {
[QueryMode.AI_QUERY_BUILDER]: {
icon: <Brain size={14} />,
label: 'AI Query Builder',
component: (
@@ -162,8 +160,8 @@ function PanelEditorQueryBuilder({
};
const modes = Object.keys(
panelDefinition.supportedQueryTypesKindMap,
) as EQueryType[];
panelDefinition.supportedQueryModesKindMap,
) as QueryMode[];
return modes.map((queryType) => ({
key: queryType,
@@ -178,7 +176,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
// QB wants a null data source and AI pins traces: both mounted, they overwrite each other's provider config in a loop.
destroyInactiveTabPane: isBuilderQueryType(queryType),
destroyInactiveTabPane: isBuilderMode(queryType),
}));
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
@@ -195,8 +193,8 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={currentQuery.queryType}
onChange={onChangeQueryType}
activeKey={getQueryMode(currentQuery)}
onChange={onChangeQueryMode}
tabBarExtraContent={
<span className={styles.runQueryBtnContainer}>
<TextToolTip text="This will temporarily save the current query and graph state. This will persist across tab change" />

View File

@@ -8,6 +8,7 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
function QueryBuilderEditorPane({
panelDefinition,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -16,6 +17,7 @@ function QueryBuilderEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,11 +1,17 @@
import { render, screen, userEvent } from 'tests/test-utils';
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
toPanelType,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
import { useQueryModeChange } from '../../hooks/useQueryModeChange';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
const mockQueryBuilderV2 = jest.fn();
@@ -51,19 +57,25 @@ const BUILDER_QUERY = {
builder: { queryData: [{ dataSource: 'traces' }] },
};
const AI_QUERY = {
queryType: EQueryType.AI_QUERY_BUILDER,
builder: { queryData: [] },
builder_ai_query: {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
},
};
/** Stands in for the editor shell. */
/** Stands in for the editor shell: owns the tab switch and its parked Query Builder query. */
function BuilderHost({ panelKind }: { panelKind: PanelKind }): JSX.Element {
const panelDefinition = requireQueryPanelDefinition(panelKind);
const parkedBuilders = useRef<BuildersByVariant>({});
const onChangeQueryMode = useQueryModeChange({
panelType: toPanelType(panelKind),
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
parkedBuilders,
});
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}
onCancelQuery={jest.fn()}
@@ -160,12 +172,11 @@ describe('PanelEditorQueryBuilder AI tab', () => {
});
});
it('seeds an AI traces query when switching to the AI tab', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
it('seeds an AI traces query when switching to the AI tab', () => {
mockBuilder(BUILDER_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
await user.click(screen.getByText('AI Query Builder'));
fireEvent.click(screen.getByText('AI Query Builder'));
expect(updateAllQueriesOperators).toHaveBeenCalledWith(
expect.anything(),
@@ -173,16 +184,11 @@ describe('PanelEditorQueryBuilder AI tab', () => {
'traces',
);
const [seeded] = redirectWithQueryBuilderData.mock.calls[0];
expect(seeded.queryType).toBe(EQueryType.AI_QUERY_BUILDER);
expect(seeded.builder_ai_query.queryData[0]).toMatchObject({
dataSource: 'traces',
});
expect(seeded.builder_ai_query.queryData[0].builderQueryType).toBeUndefined();
expect(seeded.builder).toBe(BUILDER_QUERY.builder);
expect(seeded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(seeded.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
});
it('keeps the builder query when returning from ClickHouse', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
it('keeps the builder query when returning from ClickHouse', () => {
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs', legend: 'kept' }] },
@@ -190,19 +196,18 @@ describe('PanelEditorQueryBuilder AI tab', () => {
mockBuilder(logsQuery);
renderBuilder('signoz/TimeSeriesPanel');
await user.click(screen.getByText('ClickHouse Query'));
fireEvent.click(screen.getByText('ClickHouse Query'));
const [onClickHouse] = redirectWithQueryBuilderData.mock.calls[0];
expect(onClickHouse.queryType).toBe(EQueryType.CLICKHOUSE);
// The builder slot already holds the Query Builder query — nothing to swap.
expect(onClickHouse.builder).toBe(logsQuery.builder);
});
it('seeds a default query for the kind when leaving AI for an unused Query Builder', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
it('shows a fresh default query when leaving AI', () => {
mockBuilder(AI_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
await user.click(screen.getByText('Query Builder'));
fireEvent.click(screen.getByText('Query Builder'));
const [next] = redirectWithQueryBuilderData.mock.calls[0];
expect(next.queryType).toBe(EQueryType.QUERY_BUILDER);
@@ -210,22 +215,7 @@ describe('PanelEditorQueryBuilder AI tab', () => {
expect(next.builder.queryData[0].dataSource).toBe('metrics');
});
it("reseeds a Query Builder query this kind can't author when leaving AI", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockBuilder({
...AI_QUERY,
builder: { queryData: [{ dataSource: 'metrics' }] },
});
renderBuilder('signoz/ListPanel');
await user.click(screen.getByText('Query Builder'));
const [next] = redirectWithQueryBuilderData.mock.calls[0];
expect(next.builder.queryData[0].dataSource).toBe('logs');
});
it("keeps each tab's own query across Query Builder ↔ AI round trips", async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
it("keeps each tab's own query across Query Builder ↔ AI round trips", () => {
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs' }] },
@@ -236,26 +226,23 @@ describe('PanelEditorQueryBuilder AI tab', () => {
mockBuilder(logsQuery);
const { rerender } = render(host());
await user.click(screen.getByText('AI Query Builder'));
fireEvent.click(screen.getByText('AI Query Builder'));
const [aiQuery] = redirectWithQueryBuilderData.mock.calls[0];
// The user writes an AI query, then goes back to Query Builder.
const editedAIBuilder = {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
};
mockBuilder({ ...aiQuery, builder_ai_query: editedAIBuilder });
mockBuilder({ ...aiQuery, builder: editedAIBuilder });
rerender(host());
await user.click(screen.getByText('Query Builder'));
fireEvent.click(screen.getByText('Query Builder'));
const [backToQB] = redirectWithQueryBuilderData.mock.calls[1];
expect(backToQB.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(backToQB.builder).toBe(logsQuery.builder);
expect(backToQB.builder_ai_query).toBe(editedAIBuilder);
mockBuilder(backToQB);
rerender(host());
await user.click(screen.getByText('AI Query Builder'));
fireEvent.click(screen.getByText('AI Query Builder'));
const [backToAI] = redirectWithQueryBuilderData.mock.calls[2];
expect(backToAI.queryType).toBe(EQueryType.AI_QUERY_BUILDER);
expect(backToAI.builder_ai_query).toBe(editedAIBuilder);
expect(backToAI.builder).toBe(editedAIBuilder);
});
});

View File

@@ -1,72 +0,0 @@
import { useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
getQueryTypeSignals,
type SupportedQueryTypesKindMap,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
interface UseQueryTypeChangeArgs {
panelType: PANEL_TYPES;
supportedQueryTypesKindMap: SupportedQueryTypesKindMap;
}
/**
* Tab switching: each query type keeps its own slot, so a switch only changes `queryType`.
* A builder slot is seeded when it can't be used as-is: an AI slot never opened, or
* Query Builder queries on a signal this kind can't author (an AI panel's default).
*/
export function useQueryTypeChange({
panelType,
supportedQueryTypesKindMap,
}: UseQueryTypeChangeArgs): (key: string) => void {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
return useCallback(
(key: string): void => {
const queryType = key as EQueryType;
const builderSignals = getQueryTypeSignals(
supportedQueryTypesKindMap,
EQueryType.QUERY_BUILDER,
);
const seed = (): Query['builder'] =>
seedBuilderForMode({
mode: queryType,
defaultSignal: builderSignals[0],
panelType,
updateAllQueriesOperators,
});
const next: Query = { ...currentQuery, queryType };
if (queryType === EQueryType.AI_QUERY_BUILDER && !next.builder_ai_query) {
next.builder_ai_query = seed();
}
const authorableSignals = new Set<string>(builderSignals);
const builderFitsKind =
next.builder.queryData.length > 0 &&
next.builder.queryData.every((query) =>
authorableSignals.has(query.dataSource),
);
if (queryType === EQueryType.QUERY_BUILDER && !builderFitsKind) {
next.builder = seed();
}
redirectWithQueryBuilderData(next);
},
[
currentQuery,
panelType,
redirectWithQueryBuilderData,
supportedQueryTypesKindMap,
updateAllQueriesOperators,
],
);
}

View File

@@ -72,6 +72,8 @@ interface QueryEditorBodyProps {
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
originalPanelKind?: PanelKind;
/** Tab switch, owned by the shell alongside the kind switch it shares memory with. */
onChangeQueryMode: (key: string) => void;
}
/**
@@ -93,6 +95,7 @@ function QueryEditorBody({
panelDefinition,
onChangePanelKind,
originalPanelKind,
onChangeQueryMode,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
@@ -309,6 +312,7 @@ function QueryEditorBody({
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}

View File

@@ -60,8 +60,9 @@ jest.mock('../hooks/usePanelEditorQuerySync', () => ({
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelKindChange', () => ({
usePanelKindChange: (args: unknown): unknown => mockUseTypeSwitch(args),
jest.mock('../hooks/usePanelKindAndQueryModeSwitch', () => ({
usePanelKindAndQueryModeSwitch: (args: unknown): unknown =>
mockUseTypeSwitch(args),
}));
jest.mock('../hooks/usePanelEditorSave', () => ({

View File

@@ -1,6 +1,6 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { useState } from 'react';
import { useRef, useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
@@ -22,6 +22,8 @@ import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import { useQueryModeChange } from '../hooks/useQueryModeChange';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
jest.mock('hooks/useSafeNavigate', () => {
@@ -79,10 +81,17 @@ function EditorRoute({ panelId }: { panelId: string }): JSX.Element {
savedQueries: panel.spec.queries,
});
const panelDefinition = requireQueryPanelDefinition('signoz/TimeSeriesPanel');
const parkedBuilders = useRef<BuildersByVariant>({});
const onChangeQueryMode = useQueryModeChange({
panelType: PANEL_TYPES.TIME_SERIES,
supportedQueryModesKindMap: panelDefinition.supportedQueryModesKindMap,
parkedBuilders,
});
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
onChangeQueryMode={onChangeQueryMode}
isLoadingQueries={false}
onStageRunQuery={noop}
onCancelQuery={noop}

View File

@@ -5,11 +5,11 @@ import { handleQueryChange } from 'lib/query/panelQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { resolveQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelKindChange } from '../usePanelKindChange';
import { usePanelKindAndQueryModeSwitch } from '../usePanelKindAndQueryModeSwitch';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -18,8 +18,10 @@ jest.mock('lib/query/panelQuery', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
resolveQueryMode: jest.fn(),
getSupportedSignals: jest.fn(() => ['metrics']),
getQueryPanelDefinition: jest.requireActual('../../../Panels/capabilities')
.getQueryPanelDefinition,
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
@@ -37,7 +39,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType as unknown as jest.Mock;
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
const mockToPerses = toPerses as unknown as jest.Mock;
const mockGetSwitchedPluginSpec = getSwitchedPluginSpec as unknown as jest.Mock;
const mockGetBuilderQueries = getBuilderQueries as unknown as jest.Mock;
@@ -100,7 +102,7 @@ function builderState(currentQuery: Query): {
};
}
describe('usePanelKindChange', () => {
describe('usePanelKindAndQueryModeSwitch', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
@@ -109,7 +111,7 @@ describe('usePanelKindChange', () => {
mockGetBuilderQueries.mockReturnValue([{ signal: 'logs' }]);
// The guard owns coercion (tested in capabilities.test.ts); here it always
// resolves to Query Builder so the coerced type flows into handleQueryChange.
mockResolveQueryType.mockReturnValue('builder');
mockResolveQueryMode.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
@@ -118,7 +120,7 @@ describe('usePanelKindChange', () => {
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -137,7 +139,7 @@ describe('usePanelKindChange', () => {
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -169,7 +171,7 @@ describe('usePanelKindChange', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -200,7 +202,7 @@ describe('usePanelKindChange', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -236,7 +238,7 @@ describe('usePanelKindChange', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -277,7 +279,7 @@ describe('usePanelKindChange', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -318,7 +320,7 @@ describe('usePanelKindChange', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -336,7 +338,7 @@ describe('usePanelKindChange', () => {
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelKindChange({
usePanelKindAndQueryModeSwitch({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -345,7 +347,7 @@ describe('usePanelKindChange', () => {
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind…
expect(mockResolveQueryType).toHaveBeenCalledWith(
expect(mockResolveQueryMode).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
);
@@ -354,128 +356,211 @@ describe('usePanelKindChange', () => {
expect((queryArg as Query).queryType).toBe('builder');
});
describe('AI query slot', () => {
const QB_BUILDER = { queryData: [{ dataSource: 'logs' }] };
const AI_BUILDER = {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
};
const withBothSlots = (id: string, queryType: string): Query =>
({
id,
queryType,
builder: QB_BUILDER,
builder_ai_query: AI_BUILDER,
}) as unknown as Query;
const lastRedirect = (state: ReturnType<typeof builderState>): Query =>
state.redirectWithQueryBuilderData.mock.calls[
state.redirectWithQueryBuilderData.mock.calls.length - 1
][0] as Query;
const lastSpec = (setSpec: jest.Mock): DashboardtypesPanelSpecDTO =>
setSpec.mock.calls[setSpec.mock.calls.length - 1][0];
it('keeps the AI tag when the new kind supports AI', () => {
const setSpec = jest.fn();
const aiQuery = fakeQuery('ai', 'builder', [
{ builderQueryType: 'builder_ai_query' },
]);
mockUseQueryBuilder.mockReturnValue(builderState(aiQuery));
mockResolveQueryMode.mockReturnValue('builder_ai_query');
beforeEach(() => {
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const [, queryArg] = mockHandleQueryChange.mock.calls[0];
expect((queryArg as Query).builder).toBe(aiQuery.builder);
expect((queryArg as Query).builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
});
it('drops the AI tag when the new kind does not support AI', () => {
const setSpec = jest.fn();
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
const state = builderState(aiQuery);
mockUseQueryBuilder.mockReturnValue(state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
// The guard coerces to Query Builder; the tag must not survive it.
mockResolveQueryMode.mockReturnValue('builder');
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBeUndefined();
expect(redirected.builder.queryData[0].dataSource).toBe('traces');
});
it('stays on the AI tab when returning to a kind last left on Query Builder', () => {
const setSpec = jest.fn();
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
let state = builderState(qbQuery);
mockUseQueryBuilder.mockImplementation(() => state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table on Query Builder — its stash holds the QB query.
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// On List the user moves to the AI tab, then returns to Table.
state = builderState(aiQuery);
mockResolveQueryMode.mockReturnValue('builder_ai_query');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const redirected = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(redirected.builder.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
const restored = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
// The kind's own display spec still comes back; only the query follows the tab.
expect(restored.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restored.queries).toBe(CONVERTED);
// Table's Query Builder query waited behind the AI tab — the tab switch gives it back.
act(() => result.current.onChangeQueryMode('builder'));
const backToQB = state.redirectWithQueryBuilderData.mock.calls[1][0] as Query;
expect(backToQB.builder).toBe(qbQuery.builder);
});
describe('both builder tabs, per kind', () => {
const qbQuery = fakeQuery('qb', 'builder', [{ dataSource: 'logs' }]);
const aiQuery = fakeQuery('ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]);
// Table gets a Query Builder query and an AI one; the user leaves it on AI for List.
function leaveTableOnAI(): {
result: { current: ReturnType<typeof usePanelKindAndQueryModeSwitch> };
rerender: (props: {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
}) => void;
setSpec: jest.Mock;
setState: (query: Query) => ReturnType<typeof builderState>;
} {
const setSpec = jest.fn();
let state = builderState(qbQuery);
mockUseQueryBuilder.mockImplementation(() => state);
mockHandleQueryChange.mockImplementation((_type, query) => query);
});
it('rebuilds both builder slots for the new kind on a first visit', () => {
const setSpec = jest.fn();
const state = builderState(withBothSlots('q', 'builder_ai_query'));
mockUseQueryBuilder.mockReturnValue(state);
mockResolveQueryType.mockReturnValue('builder_ai_query');
const { result } = renderHook(() =>
usePanelKindChange({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
const rebuilt = mockHandleQueryChange.mock.calls.map(
([, query]) => (query as Query).builder,
);
expect(rebuilt).toStrictEqual([QB_BUILDER, AI_BUILDER]);
const redirected = lastRedirect(state);
expect(redirected.queryType).toBe('builder_ai_query');
expect(redirected.builder.queryData[0].dataSource).toBe('logs');
expect(redirected.builder_ai_query?.queryData[0].dataSource).toBe('traces');
});
it('falls back to Query Builder, keeping the AI query, when the new kind has no AI', () => {
const setSpec = jest.fn();
const state = builderState(withBothSlots('q', 'builder_ai_query'));
mockUseQueryBuilder.mockReturnValue(state);
mockResolveQueryType.mockReturnValue('builder');
const { result } = renderHook(() =>
usePanelKindChange({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
}),
);
act(() => result.current.onChangePanelKind('signoz/TimeSeriesPanel'));
const redirected = lastRedirect(state);
expect(redirected.queryType).toBe('builder');
expect(redirected.builder.queryData[0].dataSource).toBe('logs');
expect(redirected.builder_ai_query).toBeDefined();
});
it('restores a revisited kind on the tab in use, regenerating its preview queries', () => {
const setSpec = jest.fn();
let state = builderState(withBothSlots('table', 'builder_ai_query'));
mockUseQueryBuilder.mockImplementation(() => state);
mockResolveQueryType.mockReturnValue('builder_ai_query');
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindChange({ ...props, setSpec }),
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// Leave Table on AI; on List the user moves to Query Builder, then returns.
const setState = (query: Query): ReturnType<typeof builderState> => {
state = builderState(query);
return state;
};
act(() => result.current.onChangeQueryMode('builder_ai_query'));
setState(aiQuery);
rerender({ spec: tableSpec, panelType: PANEL_TYPES.TABLE });
mockResolveQueryMode.mockReturnValue('builder_ai_query');
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
state = builderState(withBothSlots('list', 'builder'));
mockResolveQueryType.mockReturnValue('builder');
return { result, rerender, setSpec, setState };
}
it('rebuilds the parked query for the new kind on a first visit', () => {
const { result } = leaveTableOnAI();
// Table's Query Builder query was rebuilt for List, and is one tab away there.
expect(mockHandleQueryChange).toHaveBeenCalledWith(
'list',
expect.objectContaining({ builder: qbQuery.builder }),
PANEL_TYPES.TABLE,
);
act(() => result.current.onChangeQueryMode('builder'));
const { redirectWithQueryBuilderData } =
mockUseQueryBuilder.mock.results[
mockUseQueryBuilder.mock.results.length - 1
].value;
const backToQB = redirectWithQueryBuilderData.mock.calls[
redirectWithQueryBuilderData.mock.calls.length - 1
][0] as Query;
expect(backToQB.builder.queryData[0].dataSource).toBe('logs');
expect(backToQB.builder.queryData[0].builderQueryType).toBeUndefined();
});
it("brings back both tabs' queries when revisiting a kind", () => {
const { result, rerender, setSpec, setState } = leaveTableOnAI();
// Back to Table from List, still on the AI tab.
const state = setState(
fakeQuery('list-ai', 'builder', [
{ dataSource: 'traces', builderQueryType: 'builder_ai_query' },
]),
);
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = lastRedirect(state);
expect(restored.id).toBe('table');
expect(restored.queryType).toBe('builder');
expect(restored.builder).toBe(QB_BUILDER);
expect(restored.builder_ai_query).toBe(AI_BUILDER);
expect(lastSpec(setSpec).plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(lastSpec(setSpec).queries).toBe(CONVERTED);
const restored = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(restored.builder).toBe(aiQuery.builder);
const restoredSpec = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restoredSpec.plugin.spec).toBe(TABLE_PLUGIN_SPEC);
expect(restoredSpec.queries).toBe(TABLE_QUERIES);
// Table's Query Builder query is one tab away.
act(() => result.current.onChangeQueryMode('builder'));
const backToQB = state.redirectWithQueryBuilderData.mock
.calls[1][0] as Query;
expect(backToQB.builder).toBe(qbQuery.builder);
});
it('carries the AI query into a revisited kind that never had one', () => {
const setSpec = jest.fn();
let state = builderState(
fakeQuery('table', 'builder', [{ dataSource: 'logs' }]),
);
mockUseQueryBuilder.mockImplementation(() => state);
it('swaps in the Query Builder query when revisiting a kind left on AI', () => {
const { result, rerender, setSpec, setState } = leaveTableOnAI();
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindChange({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
// On List the user is back on Query Builder, then returns to Table.
const state = setState(
fakeQuery('list-qb', 'builder', [{ dataSource: 'logs' }]),
);
// Leave Table on Query Builder; on List the user writes an AI query, then returns.
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
state = builderState(withBothSlots('list', 'builder_ai_query'));
mockResolveQueryType.mockReturnValue('builder_ai_query');
mockResolveQueryMode.mockReturnValue('builder');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
const restored = lastRedirect(state);
expect(restored.id).toBe('table');
expect(restored.queryType).toBe('builder_ai_query');
expect(restored.builder_ai_query?.queryData[0].builderQueryType).toBe(
'builder_ai_query',
);
expect(lastSpec(setSpec).queries).toBe(CONVERTED);
const restored = state.redirectWithQueryBuilderData.mock
.calls[0][0] as Query;
expect(restored.builder).toBe(qbQuery.builder);
// The stashed preview queries were the AI query's; they're rebuilt for the swap.
const restoredSpec = setSpec.mock.calls[
setSpec.mock.calls.length - 1
][0] as DashboardtypesPanelSpecDTO;
expect(restoredSpec.queries).toBe(CONVERTED);
// Table's AI query is one tab away.
act(() => result.current.onChangeQueryMode('builder_ai_query'));
const backToAI = state.redirectWithQueryBuilderData.mock
.calls[1][0] as Query;
expect(backToAI.builder).toBe(aiQuery.builder);
});
});
@@ -488,7 +573,7 @@ describe('usePanelKindChange', () => {
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindChange({ ...props, setSpec }),
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);

View File

@@ -9,7 +9,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { getIsQueryModified } from 'lib/query/panelQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { isEqual } from 'lodash-es';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -112,10 +111,9 @@ export function usePanelEditorQuerySync({
// Re-commit on a query-type/datasource switch so the preview refetches. Skip
// mount: the initial query is synced into the draft by the staged-query effect below.
const activeQueryData = getActiveBuilder(currentQuery).queryData;
const dataSources = useMemo(
() => (activeQueryData ?? []).map((q) => q.dataSource),
[activeQueryData],
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
[currentQuery.builder],
);
const dataSourceSignature = dataSources.join(',');
const prevDataSourcesRef = useRef(dataSources);

View File

@@ -11,16 +11,27 @@ import { handleQueryChange } from 'lib/query/panelQuery';
import type { PartialPanelTypes } from 'lib/query/panelTypeDataSourceFormValuesMap';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
import type {
OrderByPayload,
Query,
import {
type BuilderVariant,
type BuildersByVariant,
type OrderByPayload,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
getQueryPanelDefinition,
isStaticPanelKind,
resolveQueryType,
resolveQueryMode,
} from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getQueryModeSignals } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
getBuilderVariant,
getQueryMode,
toBuilderVariant,
withoutAIQueryTag,
} from 'lib/query/queryMode';
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
@@ -32,6 +43,7 @@ import {
withPercentileHistogramAggregation,
withSingleHeatmapQuery,
} from '../utils/heatmapQuery';
import { useQueryModeChange } from './useQueryModeChange';
// V1's handleQueryChange clears orderBy for lists; re-seed the fresh-list default (timestamp desc).
const DEFAULT_LIST_ORDER_BY: OrderByPayload[] = [
@@ -57,17 +69,21 @@ interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[];
query: Query;
/** Both builder variants' queries as the kind was left. */
builders: BuildersByVariant;
}
interface UsePanelKindChangeArgs {
interface UsePanelKindAndQueryModeSwitchArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelKindChangeApi {
interface UsePanelKindAndQueryModeSwitchApi {
/** Switch the panel to `newKind`, transforming/restoring its query + spec. */
onChangePanelKind: (newKind: PanelKind) => void;
/** Switch authoring tab; shares the off-screen builder stash with the kind switch. */
onChangeQueryMode: (key: string) => void;
}
/**
@@ -76,15 +92,22 @@ interface UsePanelKindChangeApi {
* the two things that don't: a per-kind session cache that makes switching reversible
* (`Table → List → Table` restores the original query + spec), and, on first visit to a
* kind, a query rebuild (`handleQueryChange`) + spec reset (`getSwitchedPluginSpec`).
* It also owns the tab switch, stashing the off-screen builder variant per kind.
*/
export function usePanelKindChange({
export function usePanelKindAndQueryModeSwitch({
spec,
panelType,
setSpec,
}: UsePanelKindChangeArgs): UsePanelKindChangeApi {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
}: UsePanelKindAndQueryModeSwitchArgs): UsePanelKindAndQueryModeSwitchApi {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
const cacheRef = useRef<Map<PanelKind, KindState>>(new Map());
// Builder queries for the tabs not on screen; the live one is `currentQuery.builder`.
const parkedBuilders = useRef<BuildersByVariant>({});
// Latest spec/query/type, read inside the stable callback without re-subscribing.
const specRef = useRef(spec);
@@ -94,6 +117,14 @@ export function usePanelKindChange({
const panelTypeRef = useRef(panelType);
panelTypeRef.current = panelType;
const onChangeQueryMode = useQueryModeChange({
panelType,
supportedQueryModesKindMap:
getQueryPanelDefinition(spec.plugin.kind as PanelKind)
?.supportedQueryModesKindMap ?? {},
parkedBuilders,
});
const onChangePanelKind = useCallback(
(newKind: PanelKind): void => {
const currentSpec = specRef.current;
@@ -111,9 +142,20 @@ export function usePanelKindChange({
pluginSpec: currentSpec.plugin.spec,
queries: currentSpec.queries,
query,
builders: {
...parkedBuilders.current,
[getBuilderVariant(query.builder)]: query.builder,
},
});
const newPanelType = toPanelType(newKind);
const targetMode = resolveQueryMode(newKind, getQueryMode(query));
const targetVariant = toBuilderVariant(targetMode);
// An AI query is a builder query carrying the tag, so it rides on `builder`.
const targetQueryType =
targetMode === QueryMode.AI_QUERY_BUILDER
? QueryMode.QUERY_BUILDER
: targetMode;
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in
@@ -130,12 +172,10 @@ export function usePanelKindChange({
} as DashboardtypesPanelPluginDTO,
queries,
});
const queryType = resolveQueryType(newKind, query.queryType);
const rebuildForNewKind = (builder: Query['builder']): Query['builder'] => {
const rebuildForNewKind = (source: Query): Query => {
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...query, queryType: EQueryType.QUERY_BUILDER, builder },
{ ...source, queryType: targetQueryType },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
@@ -143,41 +183,62 @@ export function usePanelKindChange({
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
return (
newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(ordered)
: withPercentileHistogramAggregation(ordered)
).builder;
return newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(ordered)
: withPercentileHistogramAggregation(ordered);
};
// Tab unused on this kind: AI gets a fresh seed, Query Builder reuses `source` untagged.
const seedTargetTab = (source: Query): Query =>
targetMode === QueryMode.AI_QUERY_BUILDER
? {
...source,
builder: seedBuilderForMode({
mode: targetMode,
defaultSignal: getQueryModeSignals(
getQueryPanelDefinition(newKind)?.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)[0],
panelType: newPanelType,
updateAllQueriesOperators,
}),
}
: withoutAIQueryTag(source);
// Target tab's query comes from the stash; the rest stay parked.
const unparkTargetTab = (
source: Query,
builders: BuildersByVariant,
): { query: Query; parked: BuildersByVariant } => {
const stored = targetVariant ? builders[targetVariant] : source.builder;
const next = stored
? { ...source, builder: stored }
: seedTargetTab(source);
const parked: BuildersByVariant = { ...builders };
delete parked[getBuilderVariant(next.builder)];
return { query: next, parked };
};
// Revisit → restore the stash (the reversibility path) on the tab in use, an AI
// query the kind never had riding along. A static kind's stash carries
// `queries: []` and its builder query is untouched — there is no builder to
// re-seed for it.
// Revisit → restore the stash in the active mode; a static kind has no builder to re-seed.
const cached = cacheRef.current.get(newKind);
if (cached) {
if (isStaticPanelKind(newKind)) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
return;
}
const restored: Query = {
...cached.query,
queryType,
...(!cached.query.builder_ai_query &&
query.builder_ai_query && {
builder_ai_query: rebuildForNewKind(query.builder_ai_query),
}),
};
// The stashed preview queries belong to the stashed query's type.
const restored = unparkTargetTab(cached.query, cached.builders);
parkedBuilders.current = restored.parked;
// The stashed preview queries belong to the stashed query, not a swapped-in one.
setSpec(
buildSpec(
cached.pluginSpec,
queryType === cached.query.queryType
restored.query.builder === cached.query.builder
? cached.queries
: toPerses(restored, newPanelType),
: toPerses(restored.query, newPanelType),
),
);
redirectWithQueryBuilderData(restored);
redirectWithQueryBuilderData({
...restored.query,
queryType: targetQueryType,
});
return;
}
@@ -191,15 +252,25 @@ export function usePanelKindChange({
return;
}
// First visit → rebuild every builder slot for the new kind.
const nextQuery: Query = {
...query,
queryType,
builder: rebuildForNewKind(query.builder),
...(query.builder_ai_query && {
builder_ai_query: rebuildForNewKind(query.builder_ai_query),
}),
};
// First visit → rebuild both builder tabs' queries for the new panel type.
const transformed = rebuildForNewKind(query);
const rebuiltBuilders: BuildersByVariant = {};
(Object.keys(parkedBuilders.current) as BuilderVariant[]).forEach(
(variant) => {
const parked = parkedBuilders.current[variant];
if (parked) {
rebuiltBuilders[variant] = rebuildForNewKind({
...query,
builder: parked,
}).builder;
}
},
);
rebuiltBuilders[getBuilderVariant(transformed.builder)] =
transformed.builder;
const picked = unparkTargetTab(transformed, rebuiltBuilders);
parkedBuilders.current = picked.parked;
const nextQuery = picked.query;
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
@@ -211,8 +282,8 @@ export function usePanelKindChange({
);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData],
[setSpec, redirectWithQueryBuilderData, updateAllQueriesOperators],
);
return { onChangePanelKind };
return { onChangePanelKind, onChangeQueryMode };
}

View File

@@ -0,0 +1,83 @@
import { type MutableRefObject, useCallback } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
getQueryModeSignals,
type SupportedQueryModesKindMap,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelCapabilities';
import {
type BuildersByVariant,
type Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { getBuilderVariant, toBuilderVariant } from 'lib/query/queryMode';
import { seedBuilderForMode } from 'pages/DashboardPage/DashboardContainer/Panels/utils/seedBuilderForMode';
interface UseQueryModeChangeArgs {
panelType: PANEL_TYPES;
supportedQueryModesKindMap: SupportedQueryModesKindMap;
/** Builder queries for the variant not on screen. */
parkedBuilders: MutableRefObject<BuildersByVariant>;
}
/**
* Tab switching. Query Builder and AI share `currentQuery.builder`, so crossing between
* them swaps in the parked query, seeding a first visit; ClickHouse and PromQL pass through.
*/
export function useQueryModeChange({
panelType,
supportedQueryModesKindMap,
parkedBuilders,
}: UseQueryModeChangeArgs): (key: string) => void {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
return useCallback(
(key: string): void => {
const targetMode = key as QueryMode;
const targetVariant = toBuilderVariant(targetMode);
const heldVariant = getBuilderVariant(currentQuery.builder);
const seedFor = (mode: QueryMode): Query['builder'] =>
seedBuilderForMode({
mode,
defaultSignal: getQueryModeSignals(
supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
)[0],
panelType,
updateAllQueriesOperators,
});
// Keyed on what `builder` holds, not the active tab: ClickHouse → QB must not reseed.
let { builder } = currentQuery;
if (targetVariant && targetVariant !== heldVariant) {
parkedBuilders.current[heldVariant] = currentQuery.builder;
builder = parkedBuilders.current[targetVariant] ?? seedFor(targetMode);
delete parkedBuilders.current[targetVariant];
}
redirectWithQueryBuilderData({
...currentQuery,
// An AI query is a builder query carrying the AI tag; its query type stays `builder`.
queryType:
targetMode === QueryMode.AI_QUERY_BUILDER
? QueryMode.QUERY_BUILDER
: targetMode,
builder,
});
},
[
currentQuery,
panelType,
parkedBuilders,
redirectWithQueryBuilderData,
supportedQueryModesKindMap,
updateAllQueriesOperators,
],
);
}

Some files were not shown because too many files have changed in this diff Show More