Compare commits

...

23 Commits

Author SHA1 Message Date
Abhi Kumar
520d0c3853 Merge branch 'feat/dashboard-ai-panel-slot' into refactor/ai-explorer-query-type 2026-11-09 15:20:01 +05:30
Abhi Kumar
04c7fadd45 test(dashboard): click query tabs with userEvent
Assisted-by: Claude Opus 5.5
2026-11-09 15:19:53 +05:30
Abhi Kumar
d89d001532 fix(dashboard): drop explorer paging from the AI slot of List panels
Assisted-by: Claude Opus 5.5
2026-11-09 15:19:50 +05:30
Abhi Kumar
52fcd31cdb test(query-builder): cover AI-slot quick filters and AI explorer links 2026-11-09 14:59:10 +05:30
Abhi Kumar
8379e5e42f fix(dashboard): follow the AI slot in panel editor query sync and context-link variables 2026-11-09 14:59:10 +05:30
Abhi Kumar
7ff90cc084 refactor(query-builder): pick AI field suggestions from the query type
Alert editors keep AI queries in `builder`, so the per-query tag still counts.
2026-11-09 14:59:10 +05:30
Abhi Kumar
da4cf2ab2b refactor(dashboard): remove the query-mode leftovers
The tab switch now lives in PanelEditorQueryBuilder, so the editor panes stop passing it down.
2026-11-09 14:59:10 +05:30
Abhi Kumar
612a515bd7 refactor(llm-observability): run the AI explorer on its own query type and slot
Older links with AI queries inside `builder` are moved into the slot. The
per-query tag is now only kept on AI queries that stay in `builder` (alerts);
`toWireQuery` still sets it on the wire.
2026-11-09 14:59:10 +05:30
Abhi Kumar
51012284af refactor(query-builder): read the active builder slot in quick filters and AI explorer helpers 2026-11-09 14:59:10 +05:30
Abhi Kumar
bfe5d3c531 feat(dashboard): edit AI panels in their own builder slot
Saved AI panels open on the AI query type. A tab switch only changes queryType, so
the parked-builder stash goes; the kind switch rebuilds both slots. Drilldown and
create-alert keep reading the wire shape.
2026-11-09 14:59:09 +05:30
Abhi Kumar
2119418c3a feat(query-builder): render the active builder slot 2026-11-09 14:59:09 +05:30
Abhi Kumar
0bce326175 feat(query-builder): edit the active builder slot in the provider
Builder edits follow queryType. The AI slot is filled on first use and only written
to the URL when present, so links without it load unchanged.
2026-11-09 14:59:09 +05:30
Abhi Kumar
0d8a471757 refactor(query-builder): let AI queries through builder-only checks
An AI query is a builder query under its own type, so legends, labels, table columns,
variable references and drilldown treat it as one and read its slot.
2026-11-09 14:59:09 +05:30
Abhi Kumar
84a87bf8bb feat(query-builder): map AI composites into the AI slot 2026-11-09 14:59:09 +05:30
Abhi Kumar
277db9e2f4 feat(query-builder): add a builder slot for AI queries
AI still goes out as a builder query with builder_ai_query envelopes;
the backend has no AI query type.
2026-11-09 14:59:09 +05:30
Abhi Kumar
baf5bcd687 refactor(dashboard): move scatter panel and url normalizer tests onto EQueryType 2026-11-09 14:59:09 +05:30
Abhi Kumar
52e4c5a28f refactor(dashboard): fold QueryMode into EQueryType
AI joins EQueryType so tabs and query data share one enum; capability names follow it.
2026-11-09 14:59:09 +05:30
Vinicius Lourenço
87bcd1b702 test(saved-views): cover behaviour the current tests miss (#13301)
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

Stacked on #13287, so that one merges first.

The saved views tests touch about 98% of the lines, yet a lot of
behaviour could break without any of them failing. This adds tests for
that behaviour:

- The row link and Copy link follow the selected time range.
- Saving from the header: a failed update, blank names, pressing Enter
twice, keeping the view color, and saving the query that was run.
- The hover card while it loads, when it fails, and when it closes.
- Restoring the last used view, per source.
- The unsaved changes check and spec conversion against what the server
stores.

It also adds the first saved views e2e specs, run against a real
backend:

- Open, create, update, discard, delete and rename a view on the logs
explorer, and check the header is still clean after a reload.
- Row links and Copy link carry the time range, and a Ctrl/Cmd or middle
click opens the view in a new tab.

While adding the tests I found that renaming a view from the row menu
sent the list's copy of the view back as a full replacement, so a stale
list could undo someone else's edit. Rename now reads the stored view
first, and falls back to the list's copy when the user cannot read the
view.

#### Issues closed by this PR

Closes https://github.com/SigNoz/uforge-pod/issues/6397

#### Additional Information

- Each new test fails when the code it covers is broken on purpose.
- Jest: `pnpm jest src/container/SavedViews
src/utils/__tests__/buildExplorerNavigationUrl.test.ts`
- E2E, with the stack up (`pnpm env:start` in `tests/e2e`): `pnpm
playwright test tests/saved-views --project=chromium`
- Opening a view the user cannot read still sends the GET, from
`usePreferenceSync`, which has no read check. The server answers 403 and
the header shows "No view access".
2026-11-08 17:53:07 +00:00
Srikanth Chekuri
43cde62108 test(alertmanager): fix flaky TestAggrGroup timing and resolve checks (#12131)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (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

- TestAggrGroup failed four times this week in CI with "received batch
too early after 299.5ms" and "Expected aggregation group to be empty
after resolving alerts".
- The test timed batches with its own clock. The group resets its timer
before it calls notify, so two ticks can be under one group interval on
the test's clock by a millisecond. The test now reads the tick time the
group puts in the context, which is the clock the group itself uses.
- The empty check ran as soon as the batch arrived, but the group
deletes resolved alerts after notify returns. The test now waits for the
delete.
- Checks inside the notify callback use assert, not require, because the
callback runs on the group's goroutine.

## Issues closed by this PR

Closes https://github.com/SigNoz/engineering-pod/issues/5121
Closes https://github.com/SigNoz/engineering-pod/issues/5636

🤖 Generated with [Claude Code](https://claude.com/claude-code)
2026-11-07 19:15:26 +00:00
Srikanth Chekuri
94367f4575 fix(querybuilder): warn on unknown keys outside the filter (#13421)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (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

`ResolveColumn` dropped the not-found warning from `Resolve`, so a group
by, select field, order by, or aggregation on an unknown key returned
data with no warning. The le bucket key the histogram rewrite adds does
not warn, and meter queries still report no warnings.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#600

#### Additional Information

Assisted-by: Claude Opus 5.5
2026-11-07 10:51:41 +00:00
Srikanth Chekuri
108b2d8694 fix(querybuilder): parse aggregation predicates and field names like filters (#13418)
#### Description

- Gracefully handle aggregation keys names
  - otherwise `sum(http.response.body-size)` became a subtraction
- `count_distinct(k8s.pod.labels.app.kubernetes.io/name)` a division,
and both returned null or 0 with no warning to agent.
- Parsing failure, a wrong argument count, or an argument that is not a
field name now returns 400, earlier 500.
- Each argument of an aggregation call now comes from the source text
before the SQL parse. Predicates go to the filter grammar. A value
argument must be one filter-grammar key, so keys with `-`, `/` and
`:type` resolve.
- rewriter parsed the whole expression with the chsql parser before the
filter grammar saw the predicate. Filter-only syntax in an `*If`
predicate (`EXISTS`, `NOT EXISTS`, `CONTAINS`, `NOT CONTAINS`) failed in
that parser and returned 500.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#599
2026-11-07 09:41:13 +00:00
Srikanth Chekuri
fa6fda338f fix(querybuilder): read an unknown body path in a column as a string (#13429)
#### Description

- A `body.` key that metadata does not report failed with 404 "field not
found" in a select, group by, order by, or aggregation. The logs
fallback kept it with no data type, and the JSON body read needs one. A
filter on the same key worked, because the operand types it.
- The fallback now types it as `bodyPath` does for a bare key: a string
path when no operand types it. Filter SQL does not change.
- `TestResolveColumn_UnknownKey` resolves an unknown key in a column for
every storage and field context and allows only invalid input as an
error. On main, only the logs JSON body `body` context failed.
- The body path parity integration test now covers the `body.` spelling,
and group by, `count_distinct`, and select.

#### Issues closed by this PR

Closes SigNoz/nerve-pod#610

#### Additional Information

- The not-found warning for group by and select reaches the response
with #13421. #13421 also changes `ResolveColumn` to return warnings; the
second PR to merge updates the call in `resolve_test.go`.
2026-11-07 09:39:16 +00:00
Abhi kumar
d1f4503b40 test(query-builder): add e2e suite for the query builder (#13313)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (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

- Adds a host-agnostic `qb` driver
(`tests/e2e/drivers/query-builder.ts`) and ~110 query builder tests
across the logs, traces and metrics explorers, the V2 panel editor and
alerts. Assertions read the outgoing `query_range` body and the URL
query, never exact counts.
- Covers URL handling (malformed, partial, legacy shapes,
back/forward/reload), the filter editor (suggestions, quoting, IN lists,
nesting, validation, keyboard, recents, quick filters), metric-type
defaults, aggregations, add-ons per panel kind, multi-query and
formulas, trace operator, signal and query-type switching, cross-host
round trips (explorer → dashboard, alert save → reopen) and
run/cancel/error states.
- Each regression-driven case names the fix it guards (#13257, #12705,
#12496, #10048, #9947, #9732, #11208, #9637, #11035, #10100, #12755,
#9066, #8250).
- Seeds a gauge and a histogram per worker (golden metrics are all
sums); exports the seeder's retrying POST for that.
- Shared helper fixes found under load: `pickChannelByName` re-clicks
while the select is still closed; `holdQueryRange` tolerates a request
cancelled while held.

#### Issues closed by this PR

Closes SigNoz/pulse-pod#662

#### Additional Information

- Stacked: #13315 (legacy having and limit fixes) → #13323 (added query
keeps its signal) → this PR. Merge in that order.
- Regression tests for both fixes (legacy having `COUNT() > 5`, negative
limit, added query's signal) fail against the unfixed app.
- Stable across 2 consecutive CI-mode runs on chromium (198 passed, 0
flaky). Meter source switching is not covered yet: the stack has no
meter metrics.
2026-11-06 20:25:12 +00:00
177 changed files with 7307 additions and 1680 deletions

View File

@@ -903,6 +903,40 @@ 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,6 +3,7 @@
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';
@@ -559,9 +560,15 @@ export const prepareQueryRangePayloadV5 = (
const requestType = mapPanelTypeToRequestType(graphType);
let queries: QueryEnvelope[] = [];
switch (query.queryType) {
const wireQuery = toWireQuery(query);
switch (wireQuery.queryType) {
case EQueryType.QUERY_BUILDER: {
const { queryData: data, queryFormulas, queryTraceOperator } = query.builder;
const {
queryData: data,
queryFormulas,
queryTraceOperator,
} = wireQuery.builder;
const currentQueryData = mapQueryDataToApi(data, 'queryName', tableParams);
const currentFormulas = mapQueryDataToApi(queryFormulas, 'queryName');
@@ -623,13 +630,13 @@ export const prepareQueryRangePayloadV5 = (
break;
}
case EQueryType.PROM: {
const promQueries = reduceQueriesToObject(query[query.queryType]);
const promQueries = reduceQueriesToObject(wireQuery[wireQuery.queryType]);
queries = convertPromQueriesToV5(promQueries.queries);
legendMap = promQueries.legends;
break;
}
case EQueryType.CLICKHOUSE: {
const chQueries = reduceQueriesToObject(query[query.queryType]);
const chQueries = reduceQueriesToObject(wireQuery[wireQuery.queryType]);
queries = convertClickHouseQueriesToV5(chQueries.queries);
legendMap = chQueries.legends;
break;

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -15,28 +16,25 @@ 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 = currentQuery.builder.queryData.length > 1;
const hasMultipleQueries = activeBuilder.queryData.length > 1;
const hasTraceOperator =
currentQuery.builder.queryTraceOperator &&
currentQuery.builder.queryTraceOperator.length > 0;
activeBuilder.queryTraceOperator &&
activeBuilder.queryTraceOperator.length > 0;
return isRawQueryPanel && hasMultipleQueries && !hasTraceOperator;
}, [
currentQuery?.builder?.queryData,
currentQuery?.builder?.queryTraceOperator,
panelType,
]);
}, [activeBuilder.queryData, activeBuilder.queryTraceOperator, panelType]);
const traceOperatorWarning = useMemo(() => {
if (currentQuery.builder.queryData.length === 0) {
if (activeBuilder.queryData.length === 0) {
return '';
}
const firstQuery = currentQuery.builder.queryData[0];
const firstQuery = activeBuilder.queryData[0];
return `Currently, you are only seeing results from query ${firstQuery.queryName}. Add a trace operator to combine results of multiple queries.`;
}, [currentQuery]);
}, [activeBuilder.queryData]);
return (
<div className="qb-trace-operator-button-container">
<Tooltip

View File

@@ -26,6 +26,7 @@ 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';
@@ -132,6 +133,7 @@ function QuerySearch({
metricNamespace,
valueSuggestionsOverride,
}: QuerySearchProps): JSX.Element {
const builderQueryType = useBuilderQueryType(queryData);
const isDarkMode = useIsDarkMode();
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>('');
@@ -330,7 +332,7 @@ function QuerySearch({
source: signalSource as TelemetrytypesSourceDTO,
metricNamespace,
},
queryData.builderQueryType,
builderQueryType,
);
if (response.data.keys) {
@@ -368,7 +370,7 @@ function QuerySearch({
hardcodedAttributeKeys,
showFilterSuggestionsWithoutMetric,
metricNamespace,
queryData.builderQueryType,
builderQueryType,
],
);
@@ -510,7 +512,7 @@ function QuerySearch({
source: signalSource as TelemetrytypesSourceDTO,
metricName: debouncedMetricName ?? undefined,
},
queryData.builderQueryType,
builderQueryType,
).then((response) => {
const responseData = response.data;
const responseDataValues = responseData.values;
@@ -613,7 +615,7 @@ function QuerySearch({
signalSource,
toggleSuggestions,
valueSuggestionsOverride,
queryData.builderQueryType,
builderQueryType,
],
);

View File

@@ -12,6 +12,7 @@ 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';
@@ -62,7 +63,8 @@ export const QueryV2 = forwardRef(function QueryV2(
const { cloneQuery, panelType } = useQueryBuilder();
const hasQueryFunctions = query?.functions?.length > 0;
const { dataSource, builderQueryType } = query;
const { dataSource } = query;
const builderQueryType = useBuilderQueryType(query);
const [isCollapsed, setIsCollapsed] = useState(false);

View File

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

View File

@@ -0,0 +1,71 @@
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: QueryMode.AI_QUERY_BUILDER,
builderQueryType: EQueryType.AI_QUERY_BUILDER,
})),
},
};

View File

@@ -1,13 +1,16 @@
import { negateOperator, OPERATORS } from 'constants/antlrQueryConstants';
import { initialQueryBuilderFormValuesMap } from 'constants/queryBuilder';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils';
import {
adjustQueryForV5,
convertAggregationToExpression,
convertExpressionToFilters,
convertFiltersToExpression,
@@ -1684,3 +1687,31 @@ 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,4 +1,5 @@
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';
@@ -20,7 +21,8 @@ export function useSyncQueryBuilderFields(
fields: SyncedQueryBuilderField[] = NO_FIELDS,
): void {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
const queries = currentQuery.builder.queryData;
const activeBuilder = getActiveBuilder(currentQuery);
const queries = activeBuilder.queryData;
const previousRef = useRef<Map<string, IBuilderQuery>>();
const enabled = fields.length > 0;

View File

@@ -9,6 +9,11 @@ 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,
@@ -33,7 +38,6 @@ 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';
@@ -1030,8 +1034,9 @@ export function getQueryLabelWithAggregation(
}
export const adjustQueryForV5 = (currentQuery: Query): Query => {
if (currentQuery.queryType === EQueryType.QUERY_BUILDER) {
const newQueryData = currentQuery.builder.queryData.map((query) => {
if (isBuilderQueryType(currentQuery.queryType)) {
const activeBuilder = getActiveBuilder(currentQuery);
const newQueryData = activeBuilder.queryData.map((query) => {
const aggregations = query.aggregations?.map((aggregation) => {
if (query.dataSource === DataSource.METRICS) {
const metricAggregation = aggregation as MetricAggregation;
@@ -1072,8 +1077,8 @@ export const adjustQueryForV5 = (currentQuery: Query): Query => {
return {
...currentQuery,
builder: {
...currentQuery.builder,
[getBuilderSlotKey(currentQuery.queryType)]: {
...activeBuilder,
queryData: newQueryData,
},
};

View File

@@ -4,6 +4,8 @@ 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,6 +3,7 @@ import {
convertFiltersToExpressionWithExistingQuery,
} from 'components/QueryBuilderV2/utils';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { EQueryType } from 'types/common/dashboard';
import {
Query,
TagFilter,
@@ -556,3 +557,49 @@ 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,6 +10,11 @@ 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';
@@ -157,8 +162,9 @@ export function applyCheckboxToggle({
previousState?: CheckedState;
sectionType?: SectionType;
}): Query {
const activeItems =
currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items;
const slot = getBuilderSlotKey(currentQuery.queryType);
const builder = getBuilderAt(currentQuery, slot);
const activeItems = builder.queryData?.[activeQueryIndex]?.filters?.items;
const isSomeFilterPresentForCurrentAttribute = !!activeItems?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
@@ -173,7 +179,7 @@ export function applyCheckboxToggle({
const isMultipleValuesTrueForTheKey =
Object.values(currentFilterState).filter((val) => val).length > 1;
const query = cloneDeep(currentQuery.builder.queryData?.[activeQueryIndex]);
const query = cloneDeep(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.
@@ -430,18 +436,15 @@ export function applyCheckboxToggle({
query.filters = synced.filters;
}
return {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
...currentQuery.builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
},
};
return withBuilderAt(currentQuery, slot, {
...builder,
queryData: [
...builder.queryData.map((q, idx) => {
if (idx === activeQueryIndex) {
return query;
}
return q;
}),
],
});
}

View File

@@ -1,6 +1,7 @@
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';
@@ -37,12 +38,13 @@ function useCheckboxDisclosure({
DEFAULT_VISIBLE_ITEMS_COUNT,
);
const { queryData } = getActiveBuilder(currentQuery);
const isSomeFilterPresentForCurrentAttribute = useMemo(
() =>
!!currentQuery.builder.queryData?.[activeQueryIndex]?.filters?.items?.some(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
!!queryData?.[activeQueryIndex]?.filters?.items?.some((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
),
[currentQuery.builder.queryData, activeQueryIndex, filter.attributeKey.key],
[queryData, activeQueryIndex, filter.attributeKey.key],
);
const isOpen = useMemo(() => {

View File

@@ -4,6 +4,7 @@ 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';
@@ -77,7 +78,7 @@ function useCheckboxFilterActions({
dispatch(updatedQuery);
if (onQuickFilterChange) {
const queryData = updatedQuery.builder.queryData[activeQueryIndex];
const queryData = getActiveBuilder(updatedQuery).queryData[activeQueryIndex];
const expression = queryData?.filter?.expression || '';
const filterItemKeys = (queryData?.filters?.items || [])
.map((item) => item.key?.key)

View File

@@ -1,6 +1,7 @@
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';
@@ -31,33 +32,28 @@ 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:
currentQuery?.builder.queryData?.[activeQueryIndex]?.filters?.items,
filterItems: activeQueryData?.[activeQueryIndex]?.filters?.items,
filterKey: filter.attributeKey.key,
}),
[
attributeValues,
currentQuery?.builder.queryData,
filter.attributeKey,
activeQueryIndex,
],
[attributeValues, activeQueryData, filter.attributeKey, activeQueryIndex],
);
// disable the filter when there are multiple entries of the same attribute key present in the filter bar
const isFilterDisabled = useMemo(
() =>
(currentQuery?.builder?.queryData?.[
activeQueryIndex
]?.filters?.items?.filter((item) =>
(activeQueryData?.[activeQueryIndex]?.filters?.items?.filter((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
)?.length || 0) > 1,
[currentQuery?.builder?.queryData, activeQueryIndex, filter.attributeKey],
[activeQueryData, activeQueryIndex, filter.attributeKey],
);
// whether the current filter has multiple values to its name in the key op value section

View File

@@ -11,6 +11,7 @@ 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';
@@ -128,18 +129,15 @@ export default function CheckboxFilterV2(
onToggleOpen();
};
const activeQueryData = currentQuery
? getActiveBuilder(currentQuery).queryData
: undefined;
const currentFilterOp = useMemo(() => {
const filterSync = currentQuery?.builder.queryData?.[
activeQueryIndex
]?.filters?.items.find((item) =>
isKeyMatch(item.key?.key, filter.attributeKey.key),
const filterSync = activeQueryData?.[activeQueryIndex]?.filters?.items.find(
(item) => isKeyMatch(item.key?.key, filter.attributeKey.key),
);
return filterSync?.op;
}, [
currentQuery?.builder.queryData,
activeQueryIndex,
filter.attributeKey.key,
]);
}, [activeQueryData, activeQueryIndex, filter.attributeKey.key]);
const isNotInOperator = NON_SELECTED_OPERATORS.includes(currentFilterOp || '');

View File

@@ -1,5 +1,6 @@
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';
@@ -18,6 +19,9 @@ export function useExistingQuery({
activeQueryIndex,
}: UseExistingQueryParams): UseExistingQueryResult {
const { stagedQuery } = useQueryBuilder();
const stagedQueryData = stagedQuery
? getActiveBuilder(stagedQuery).queryData
: undefined;
const existingQuery = useMemo(() => {
if (useFieldApis.existingQuery === null) {
@@ -28,7 +32,7 @@ export function useExistingQuery({
return useFieldApis.existingQuery;
}
const queryData = stagedQuery?.builder.queryData?.[activeQueryIndex];
const queryData = stagedQueryData?.[activeQueryIndex];
// Prefer V5 filter.expression
if (queryData?.filter?.expression) {
@@ -41,21 +45,17 @@ export function useExistingQuery({
}
return undefined;
}, [
useFieldApis.existingQuery,
stagedQuery?.builder.queryData,
activeQueryIndex,
]);
}, [useFieldApis.existingQuery, stagedQueryData, 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 = stagedQuery?.builder.queryData?.[activeQueryIndex];
const queryData = stagedQueryData?.[activeQueryIndex];
const hasV3Items = (queryData?.filters?.items?.length ?? 0) > 0;
const hasV5Expression = !!queryData?.filter?.expression;
return hasV3Items || hasV5Expression || !!existingQuery;
}, [stagedQuery?.builder.queryData, activeQueryIndex, existingQuery]);
}, [stagedQueryData, activeQueryIndex, existingQuery]);
return { existingQuery, hasExistingQuery };
}

View File

@@ -9,6 +9,12 @@ 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,
@@ -57,7 +63,8 @@ function Duration({
// eslint-disable-next-line sonarjs/cognitive-complexity
const syncSelectedFilters = useMemo((): FilterType => {
const filters =
compositeQuery?.builder.queryData?.[activeQueryIndex]?.filters;
compositeQuery &&
getActiveBuilder(compositeQuery).queryData?.[activeQueryIndex]?.filters;
if (!filters) {
return {} as FilterType;
}
@@ -183,49 +190,51 @@ function Duration({
};
const removeFilterItemIds = (query: Query): Query => {
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 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 handleRun = useCallback(
(props?: HandleRunProps): void => {
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 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 currentQueryWithoutIds = removeFilterItemIds(currentQuery);
const preparedQueryWithoutIds = removeFilterItemIds(preparedQuery);

View File

@@ -1,4 +1,9 @@
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';
@@ -20,34 +25,33 @@ export function clearFilterFromQuery({
activeQueryIndex: number;
operators?: string[];
}): Query {
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',
},
};
}),
},
};
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',
},
};
}),
});
}

View File

@@ -32,6 +32,12 @@ 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';
@@ -87,15 +93,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,
(currentQuery?.builder?.queryData?.length || 1) - 1,
),
[lastUsedQuery, currentQuery?.builder?.queryData?.length],
Math.min(lastUsedQuery || 0, (activeBuilder?.queryData?.length || 1) - 1),
[lastUsedQuery, activeBuilder?.queryData?.length],
);
// Determine if we're in ListView mode
@@ -103,15 +109,15 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
// Generate query options based on available queries
const queryOptions = useMemo(() => {
if (!currentQuery?.builder?.queryData) {
if (!activeBuilder?.queryData) {
return [];
}
return currentQuery.builder.queryData.map((query, index) => ({
return activeBuilder.queryData.map((query, index) => ({
label: query.queryName || String.fromCharCode(65 + index),
value: index,
}));
}, [currentQuery?.builder?.queryData]);
}, [activeBuilder?.queryData]);
// Show dropdown in ListView only for TRACES_EXPLORER source
const shouldShowDropdownInListView =
@@ -127,36 +133,35 @@ 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 = currentQuery?.builder.queryData?.[activeQueryIndex];
const activeQuery = activeBuilder?.queryData?.[activeQueryIndex];
if (!activeQuery) {
return;
}
const preparedQuery: Query = {
...currentQuery,
builder: {
...currentQuery.builder,
queryData: currentQuery.builder.queryData.map((item, idx) => {
if (idx !== activeQueryIndex) {
return item;
}
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,
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);
@@ -166,13 +171,12 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
};
const lastQueryName =
showQueryName &&
currentQuery.builder.queryData?.[lastUsedQuery || 0]?.queryName;
showQueryName && activeBuilder?.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 && currentQuery.builder.queryData?.[0]?.queryName
? showQueryName && activeBuilder?.queryData?.[0]?.queryName
: lastQueryName;
const handleQueryChange = (value: number): void => {

View File

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

View File

@@ -13,6 +13,11 @@ 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. */
@@ -56,12 +61,11 @@ export function replaceFirstQueryData(
query: Query,
updated: IBuilderQuery,
): Query {
const queryData = [...query.builder.queryData];
const slot = getBuilderSlotKey(query.queryType);
const builder = getBuilderAt(query, slot);
const queryData = [...builder.queryData];
queryData[0] = updated;
return {
...query,
builder: { ...query.builder, queryData },
};
return withBuilderAt(query, slot, { ...builder, queryData });
}
/** Page-state deps required by the runQuery / addFilter actions. */

View File

@@ -40,6 +40,7 @@ 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

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

View File

@@ -25,6 +25,7 @@ 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';
@@ -186,7 +187,9 @@ function Explorer(): JSX.Element {
const isFilterApplied = useMemo(() => {
// if any of the non-disabled queries has filters applied, return true
const result = stagedQuery?.builder?.queryData?.filter(
const result = (
stagedQuery ? getActiveBuilder(stagedQuery) : undefined
)?.queryData?.filter(
(item) => !isEmpty(item.filters?.items) && !item.disabled,
);
return !!result?.length;

View File

@@ -15,6 +15,7 @@ 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';
@@ -35,7 +36,7 @@ function TimeSeriesViewContainer({
const isValidToConvertToMs = useMemo(() => {
const isValid: boolean[] = [];
currentQuery.builder.queryData.forEach(
getActiveBuilder(currentQuery).queryData.forEach(
({ aggregateAttribute, aggregateOperator }) => {
const isExistDurationNanoAttribute =
aggregateAttribute?.key === 'durationNano' ||

View File

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

View File

@@ -1,4 +1,5 @@
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';
@@ -15,8 +16,9 @@ export const getListViewQuery = (
? [parseListOrderBy(orderBy)]
: [];
for (let i = 0; i < query.builder.queryData.length; i++) {
const queryData = query.builder.queryData[i];
const { queryData: builderQueries } = getActiveBuilder(query);
for (let i = 0; i < builderQueries.length; i++) {
const queryData = builderQueries[i];
queryData.groupBy = [];
queryData.having = {
expression: '',

View File

@@ -29,6 +29,7 @@ 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,
@@ -45,6 +46,7 @@ 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],
@@ -52,7 +54,7 @@ export const GroupByFilter = memo(function GroupByFilter({
metricName: getMetricNameForQuery(query),
searchText: debouncedValue,
},
query.builderQueryType,
builderQueryType,
{ enabled: !disabled && isFocused, keepPreviousData: true },
);

View File

@@ -14,6 +14,7 @@ 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,
@@ -34,6 +35,7 @@ export function OrderByFilter({
handleSearchKeys,
} = useOrderByFilter({ query, onChange, entityVersion });
const builderQueryType = useBuilderQueryType(query);
const { data: fieldKeys, isFetching } = useFieldKeysSuggestion(
{
signal: DATA_SOURCE_TO_SIGNAL[query.dataSource],
@@ -41,7 +43,7 @@ export function OrderByFilter({
metricName: getMetricNameForQuery(query),
searchText: debouncedSearchText,
},
query.builderQueryType,
builderQueryType,
{
enabled: !!query.aggregateAttribute?.key || isRawQuery,
keepPreviousData: true,

View File

@@ -9,6 +9,7 @@ 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 {
@@ -856,14 +857,10 @@ function QueryBuilderSearchV2(
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [query]);
const isLastQuery = useMemo(
() =>
isEqual(
currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1],
query,
),
[currentQuery, query],
);
const isLastQuery = useMemo(() => {
const { queryData } = getActiveBuilder(currentQuery);
return isEqual(queryData[queryData.length - 1], query);
}, [currentQuery, query]);
useEffect(() => {
if (isLastQuery) {

View File

@@ -2,6 +2,7 @@ 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';
@@ -41,7 +42,7 @@ export function useGraphContextMenu({
} {
const drilldownQuery = useGetCompositeQueryParam() || query;
const isQueryTypeBuilder = drilldownQuery?.queryType === 'builder';
const isQueryTypeBuilder = isBuilderQueryType(drilldownQuery?.queryType);
const { aggregateDrilldownConfig } = useAggregateDrilldown({
query: drilldownQuery,

View File

@@ -3,6 +3,7 @@ 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,
@@ -33,7 +34,7 @@ export function QueryTable({
...props
}: QueryTableProps): JSX.Element {
const { isDownloadEnabled = false, fileName = '' } = downloadOption || {};
const isQueryTypeBuilder = query.queryType === 'builder';
const isQueryTypeBuilder = isBuilderQueryType(query.queryType);
const { servicename: encodedServiceName } = useAppParams<'servicename'>();
const servicename = decodeURIComponent(encodedServiceName || '');

View File

@@ -1,6 +1,7 @@
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';
@@ -29,6 +30,21 @@ 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

@@ -0,0 +1,56 @@
import { useState } from 'react';
import { tags } from '@lezer/highlight';
import { copilotDarkStyle } from '@uiw/codemirror-theme-copilot';
import { githubLightStyle } from '@uiw/codemirror-theme-github';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { act, render, screen } from 'tests/test-utils';
import SavedViewHighlightedExpression from '../SavedViewHighlightedExpression';
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: jest.fn(),
}));
const mockUseIsDarkMode = useIsDarkMode as jest.MockedFunction<
typeof useIsDarkMode
>;
const EXPRESSION = "service.name = 'frontend'";
function stringColor(styles: typeof copilotDarkStyle): string {
return (styles ?? []).find(({ tag }) =>
(Array.isArray(tag) ? tag : [tag]).includes(tags.string),
)?.color as string;
}
describe('SavedViewHighlightedExpression', () => {
it.each([
['dark', true, copilotDarkStyle],
['light', false, githubLightStyle],
])('colours a string value with the %s theme', (_, isDarkMode, styles) => {
mockUseIsDarkMode.mockReturnValue(isDarkMode);
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
expect(screen.getByText("'frontend'")).toHaveStyle({
color: stringColor(styles),
});
});
it('takes the new theme colours when the theme changes', () => {
let setIsDarkMode: (isDarkMode: boolean) => void = () => {};
function useSwitchableDarkMode(): boolean {
const [isDarkMode, setter] = useState(true);
setIsDarkMode = setter;
return isDarkMode;
}
mockUseIsDarkMode.mockImplementation(useSwitchableDarkMode);
render(<SavedViewHighlightedExpression expression={EXPRESSION} />);
act(() => setIsDarkMode(false));
expect(screen.getByText("'frontend'")).toHaveStyle({
color: stringColor(githubLightStyle),
});
});
});

View File

@@ -5,11 +5,19 @@ import {
waitFor,
within,
} from '@testing-library/react';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import {
AuthtypesTransactionDTO,
SavedviewtypesSourceDTO,
} from 'api/generated/services/sigNoz.schemas';
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
AUTHZ_CHECK_URL,
authzMockResponse,
} from 'lib/authz/utils/authz-test-utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { userEvent } from 'tests/test-utils';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -222,6 +230,31 @@ describe('SavedViewHoverCard', () => {
expect(card()).toBeNull();
});
it('does not open when the row menu is pressed before the delay', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS / 2);
await user.click(within(row('Errors')).getByTestId('saved-views-row-menu'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
expect(card()).toBeNull();
});
it('closes on escape, leaving nothing of the popover behind', async () => {
const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime });
await renderPanel();
await openCard('Errors');
await user.keyboard('{Escape}');
expect(card()).toBeNull();
expect(
document.querySelector('[data-radix-popper-content-wrapper]'),
).toBeNull();
});
});
describe('content', () => {
@@ -312,6 +345,78 @@ describe('SavedViewHoverCard', () => {
await waitFor(() => expect(fetches).toBe(2));
});
it('shows a skeleton, not the failure, while the view loads', async () => {
let requested = false;
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
server.use(
rest.get(`${API}/:id`, async (_req, res, ctx) => {
requested = true;
await gate;
return res(ctx.json({ status: 'success', data: errors }));
}),
);
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await waitFor(() => expect(requested).toBe(true));
expect(
screen.getByTestId('saved-view-hover-card-loading'),
).toBeInTheDocument();
expect(screen.queryByTestId('saved-view-hover-card-error')).toBeNull();
release();
await screen.findByTestId('saved-view-hover-card-query');
});
it('shows a skeleton while the read check is pending', async () => {
let release: () => void = () => undefined;
const gate = new Promise<void>((resolve) => {
release = resolve;
});
const isRead = (transaction: AuthtypesTransactionDTO): boolean =>
transaction.relation === 'read';
const isReadAll = (transaction: AuthtypesTransactionDTO): boolean =>
isRead(transaction) && transaction.object.selector === '*';
// Read on every view would skip the per-view check, so it is denied.
server.use(
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
const payload = (await req.json()) as AuthtypesTransactionDTO[];
if (
payload.some(
(transaction) => isRead(transaction) && !isReadAll(transaction),
)
) {
await gate;
}
return res(
ctx.json(
authzMockResponse(
payload,
payload.map((transaction) => !isReadAll(transaction)),
),
),
);
}),
);
await renderPanel();
fireEvent.mouseEnter(row('Errors'));
advance(SAVED_VIEW_HOVER_CARD_OPEN_DELAY_MS);
await expect(
screen.findByTestId('saved-view-hover-card-loading'),
).resolves.toBeInTheDocument();
expect(screen.queryByTestId('saved-view-hover-card-query')).toBeNull();
release();
await screen.findByTestId('saved-view-hover-card-query');
});
it('says the view failed to load when its fetch fails', async () => {
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>

View File

@@ -339,6 +339,45 @@ describe('SavedViews - AuthZ', () => {
).resolves.not.toHaveAttribute('data-disabled');
});
it('disables save as new without create, keeping update', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(SavedViewCreatePermission));
const { history } = renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent('Errors'),
);
history.push(
explorerUrl(PATH, {
query: queryWith(DataSource.TRACES, 'has_error = false'),
panelType: PANEL_TYPES.LIST,
viewKey: errors.id,
}),
);
await userEvent.click(await screen.findByTestId('saved-views-save-changes'));
await expect(
settledRow('saved-views-save-changes-item-save-as-new'),
).resolves.toHaveAttribute('data-disabled');
await expect(
settledRow('saved-views-save-changes-item-update'),
).resolves.not.toHaveAttribute('data-disabled');
});
it('offers clear on a view it cannot read', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));
renderHeader(viewUrl(PATH, errors));
await waitFor(() =>
expect(screen.getByTestId('saved-views-name')).toHaveTextContent(
'No view access',
),
);
expect(screen.getByTestId('saved-views-clear')).toBeInTheDocument();
});
it('says no access when the check denies read, even if the server returns the view', async () => {
mockSavedViewsApi([errors]);
server.use(setupAuthzDeny(buildSavedViewReadPermission(errors.id)));

View File

@@ -3,6 +3,7 @@ import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { userEvent } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
@@ -75,4 +76,39 @@ describe('SavedViewsHeader last used view', () => {
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
it('does not bring the view back once it is cleared', async () => {
mockSavedViewsApi([errors]);
const { history } = renderHeader(explorerUrl(PATH, {}));
await waitForView('Errors');
await userEvent.click(screen.getByTestId('saved-views-clear'));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
expect(urlParam(history, QueryParams.viewKey)).toBeNull();
});
it('forgets a last used view that no longer loads', async () => {
mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await waitFor(() =>
expect(
JSON.parse(
localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}',
),
).toStrictEqual({}),
);
expect(chip()).toHaveTextContent('My view');
});
it('starts on a bare explorer when the stored last used views are unreadable', async () => {
localStorage.setItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS, '{not json');
mockSavedViewsApi([errors]);
renderHeader(explorerUrl(PATH, {}));
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(chip()).toHaveTextContent('My view');
});
});

View File

@@ -1,10 +1,14 @@
import { act, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { Button } from '@signozhq/ui/button';
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsHeader from '../SavedViewsHeader';
@@ -19,7 +23,7 @@ import {
urlParam,
viewUrl,
} from './savedViewsTestUtils';
import { mockSavedViewsApi } from './savedViewsApiMock';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
@@ -43,6 +47,27 @@ function renderHeader(
);
}
// Edits the current query without running it, so it is not the staged one.
function EditCurrentQuery(): JSX.Element {
const { currentQuery, handleSetQueryData } = useQueryBuilder();
return (
<Button
size="md"
variant="solid"
color="primary"
testId="edit-current-query"
onClick={(): void =>
handleSetQueryData(0, {
...currentQuery.builder.queryData[0],
filter: { expression: 'unstaged = true' },
})
}
>
Edit query
</Button>
);
}
const modal = (): HTMLElement => screen.getByTestId('save-view-modal');
const isModalOpen = (): boolean => !!screen.queryByTestId('save-view-modal');
@@ -160,6 +185,76 @@ describe('SavedViewsHeader saving', () => {
expect(toast.success).not.toHaveBeenCalled();
});
it('does not create a view from a blank name submitted with Enter', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
await clickCreate();
await userEvent.type(screen.getByTestId('save-view-name'), ' {enter}');
await act(() => new Promise((resolve) => setTimeout(resolve, 200)));
expect(requests.created).toHaveLength(0);
expect(isModalOpen()).toBe(true);
});
it('ignores Enter while the view is being saved', async () => {
let creates = 0;
mockSavedViewsApi([]);
server.use(
rest.post(API, (_req, res, ctx) => {
creates += 1;
return res(
ctx.delay(400),
ctx.status(201),
ctx.json({ status: 'success', data: { id: 'view-new' } }),
);
}),
);
renderHeader(explorerUrl(PATH, {}));
await clickCreate();
await submitName('Slow checkout');
await waitFor(() => expect(creates).toBe(1));
await userEvent.type(screen.getByTestId('save-view-name'), '{enter}');
await waitFor(() => expect(isModalOpen()).toBe(false));
expect(creates).toBe(1);
});
it('saves the query that was run, not edits made since', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderWithExplorerProviders(
<>
<SavedViewsHeader
source={SavedviewtypesSourceDTO.traces}
onOpenViews={jest.fn()}
/>
<EditCurrentQuery />
</>,
explorerUrl(PATH, {}),
);
await userEvent.click(screen.getByTestId('edit-current-query'));
await clickCreate();
await submitName('Slow checkout');
await waitFor(() => expect(requests.created).toHaveLength(1));
const body = requests.created[0] as {
spec: { queries: { spec: { filter?: { expression?: string } } }[] };
};
expect(body.spec.queries[0].spec.filter?.expression ?? '').not.toContain(
'unstaged',
);
await waitFor(() =>
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify('view-new'),
),
);
expect(
decodeURIComponent(urlParam(history, QueryParams.compositeQuery) ?? ''),
).not.toContain('unstaged');
});
it('cancel closes the modal without saving', async () => {
const requests = mockSavedViewsApi([]);
renderHeader(explorerUrl(PATH, {}));
@@ -231,6 +326,37 @@ describe('SavedViewsHeader saving', () => {
expect(requests.created).toHaveLength(0);
});
it('update that fails reports the error and never says the view was updated', async () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });
const requests = mockSavedViewsApi([errors], { updateStatus: 500 });
await openDirtyView(errors);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(toast.error).toHaveBeenCalled());
await act(() => new Promise((resolve) => setTimeout(resolve, 100)));
expect(requests.updated).toHaveLength(1);
expect(toast.success).not.toHaveBeenCalled();
});
it('update keeps the color of the open view', async () => {
const base = makeView({ id: 'view-1', displayName: 'Errors' });
const colored = {
...base,
spec: { ...base.spec, display: { color: '#e5484d' } },
};
const requests = mockSavedViewsApi([colored]);
await openDirtyView(colored);
await chooseFromSaveMenu('Update selected view');
await waitFor(() => expect(requests.updated).toHaveLength(1));
const { body } = requests.updated[0] as {
body: { spec: { display?: { color?: string } } };
};
expect(body.spec.display?.color).toBe('#e5484d');
});
describe('view name toggle with unsaved changes', () => {
const errors = makeView({ id: 'view-1', displayName: 'Errors' });

View File

@@ -1,5 +1,6 @@
import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
@@ -9,6 +10,7 @@ import {
waitFor,
within,
} from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
@@ -219,6 +221,75 @@ describe('SavedViewsPanel list', () => {
});
});
it('lists only the views of its own source', async () => {
mockSavedViewsApi([
mine,
makeView({
id: 'view-9',
displayName: 'Noisy logs',
source: SavedviewtypesSourceDTO.logs,
}),
]);
renderPanel();
await screen.findByTestId('saved-views-list');
expect(rowNames('saved-views-created-by-me')).toStrictEqual(['Errors']);
});
describe('last used view', () => {
const lastUsedViews = (): unknown =>
JSON.parse(localStorage.getItem(LOCALSTORAGE.LAST_USED_SAVED_VIEWS) ?? '{}');
const logsEntry = { logs: { key: 'logs-view', value: 'Noisy logs' } };
it('is remembered under the panel source, keeping the other sources', async () => {
localStorage.setItem(
LOCALSTORAGE.LAST_USED_SAVED_VIEWS,
JSON.stringify(logsEntry),
);
mockSavedViewsApi([mine, alsoMine]);
renderPanel();
await screen.findByTestId('saved-views-list');
await waitFor(() => expectUsable(rowName('Slow spans')));
await userEvent.click(rowName('Slow spans'));
await waitFor(() =>
expect(lastUsedViews()).toStrictEqual({
...logsEntry,
traces: { key: alsoMine.id, value: 'Slow spans' },
}),
);
});
it('is filed under the source of the panel it was opened from', async () => {
const logsView = makeView({
id: 'view-9',
displayName: 'Noisy logs',
source: SavedviewtypesSourceDTO.logs,
dataSource: DataSource.LOGS,
});
mockSavedViewsApi([logsView]);
renderWithExplorerProviders(
<SavedViewsPanel
source={SavedviewtypesSourceDTO.logs}
onClose={jest.fn()}
/>,
explorerUrl(ROUTES.LOGS_EXPLORER, {}),
DataSource.LOGS,
);
await screen.findByTestId('saved-views-list');
await waitFor(() => expectUsable(rowName('Noisy logs')));
await userEvent.click(rowName('Noisy logs'));
await waitFor(() =>
expect(lastUsedViews()).toStrictEqual({
logs: { key: logsView.id, value: 'Noisy logs' },
}),
);
});
});
it('offers to create a view when there are none, and opens it', async () => {
const requests = mockSavedViewsApi([], { createdId: 'view-new' });
const { history } = renderPanel();

View File

@@ -4,11 +4,13 @@ import { toast } from '@signozhq/ui/sonner';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime';
import SavedViewsHeader from '../SavedViewsHeader';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import { API, mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
explorerUrl,
@@ -181,6 +183,87 @@ describe('SavedViewsPanel row menu', () => {
await waitFor(() => expect(row('Slow checkout spans')).toBeInTheDocument());
});
it('renames from the stored view, not a stale list row', async () => {
const views = [errors, slow];
const requests = mockSavedViewsApi(views);
renderPanelWithHeader();
await screen.findByTestId('saved-views-list');
const stored = makeView({
id: slow.id,
displayName: 'Slow spans',
expression: 'duration_nano > 5000',
});
views[1] = stored;
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.clear(name);
await userEvent.type(name, 'Slow checkout spans');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(requests.updated).toHaveLength(1));
expect(requests.updated[0].body).toMatchObject({
spec: { ...stored.spec, displayName: 'Slow checkout spans' },
});
});
it('renames from the list row when the view cannot be read', async () => {
const requests = mockSavedViewsApi([errors, slow]);
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(ctx.status(403), ctx.json({ status: 'error' })),
),
);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.clear(name);
await userEvent.type(name, 'Slow checkout spans');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(requests.updated).toHaveLength(1));
expect(requests.updated[0].body).toMatchObject({
spec: { ...slow.spec, displayName: 'Slow checkout spans' },
});
});
it('does not rename when the stored view fails to load', async () => {
const requests = mockSavedViewsApi([errors, slow]);
server.use(
rest.get(`${API}/:id`, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({ status: 'error' })),
),
);
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.type(name, ' 2');
await userEvent.click(screen.getByTestId('save-view-submit'));
await waitFor(() => expect(toast.error).toHaveBeenCalled());
expect(requests.updated).toHaveLength(0);
});
it('ignores Enter while the stored view loads', async () => {
const requests = mockSavedViewsApi([errors, slow], { getDelay: 300 });
renderPanelWithHeader();
await chooseFromRowMenu('Slow spans', 'Edit details');
const name = screen.getByTestId('save-view-name');
await userEvent.type(name, ' 2{enter}');
await userEvent.type(name, '{enter}');
await waitFor(() =>
expect(toast.success).toHaveBeenCalledWith(
'View updated',
expect.anything(),
),
);
expect(requests.updated).toHaveLength(1);
});
it('shows the new name in the header when the open view is renamed', async () => {
mockSavedViewsApi([errors]);
renderPanelWithHeader(viewUrl(PATH, errors));
@@ -236,4 +319,19 @@ describe('SavedViewsPanel row menu', () => {
);
await waitFor(() => expect(chip()).toHaveTextContent('My view'));
});
it('keeps the open view when another view is deleted', async () => {
mockSavedViewsApi([errors, slow]);
const { history } = renderPanelWithHeader(viewUrl(PATH, errors));
await waitFor(() => expect(chip()).toHaveTextContent('Errors'));
await chooseFromRowMenu('Slow spans', 'Delete');
await userEvent.click(screen.getByTestId('delete-saved-view-confirm'));
await waitFor(() => expect(row('Slow spans')).toBeUndefined());
expect(urlParam(history, QueryParams.viewKey)).toBe(
JSON.stringify(errors.id),
);
expect(chip()).toHaveTextContent('Errors');
});
});

View File

@@ -0,0 +1,111 @@
// eslint-disable-next-line no-restricted-imports
import { Provider } from 'react-redux';
import { SavedviewtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import store from 'store';
import { act, screen, userEvent, waitFor, within } from 'tests/test-utils';
import { GlobalReducer } from 'types/reducer/globalTime';
import SavedViewsPanel from '../SavedViewsPanel';
import { mockSavedViewsApi } from './savedViewsApiMock';
import {
expectRowUsable,
explorerUrl,
makeView,
MENU_CHECK_TIMEOUT,
renderWithExplorerProviders,
} from './savedViewsTestUtils';
const mockCopy: jest.MockedFunction<(text: string) => void> = jest.fn();
// copy-to-clipboard falls back to window.prompt in jsdom.
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: (): [unknown, typeof mockCopy] => [null, mockCopy],
}));
const PATH = ROUTES.TRACES_EXPLORER;
const slow = makeView({ id: 'view-2', displayName: 'Slow spans' });
// A store whose global time can change while the panel stays mounted.
function renderPanelWithTime(selectedTime: GlobalReducer['selectedTime']): {
setTime: (time: Partial<GlobalReducer>) => void;
} {
let globalTime: GlobalReducer = {
...store.getState().globalTime,
selectedTime,
};
const timeStore = configureStore([thunk])(() => ({
...store.getState(),
globalTime,
}));
renderWithExplorerProviders(
<Provider store={timeStore}>
<SavedViewsPanel
source={SavedviewtypesSourceDTO.traces}
onClose={jest.fn()}
/>
</Provider>,
explorerUrl(PATH, {}),
);
return {
setTime: (time): void => {
globalTime = { ...globalTime, ...time };
act(() => {
timeStore.dispatch({ type: 'TEST_TIME_CHANGED' });
});
},
};
}
const row = (name: string): HTMLElement =>
screen
.getAllByTestId('saved-views-row')
.find((element) => element.textContent?.includes(name)) as HTMLElement;
const relativeTimeOf = (link: string): string | null =>
new URL(link, window.location.origin).searchParams.get(
QueryParams.relativeTime,
);
describe('SavedViewsPanel time range', () => {
beforeEach(() => {
localStorage.clear();
mockCopy.mockClear();
mockSavedViewsApi([slow]);
});
it('moves the row link to the new time range when it changes', async () => {
const { setTime } = renderPanelWithTime('1h');
await screen.findByTestId('saved-views-list');
const name = within(row('Slow spans')).getByTestId('saved-views-row-name');
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('1h');
setTime({ selectedTime: '6h' });
expect(relativeTimeOf(name.getAttribute('href') as string)).toBe('6h');
});
it('copies a link in the time range that is selected when it is copied', async () => {
const { setTime } = renderPanelWithTime('1h');
await screen.findByTestId('saved-views-list');
setTime({ selectedTime: '6h' });
await userEvent.click(
within(row('Slow spans')).getByTestId('saved-views-row-menu'),
);
const copyLink = await screen.findByTestId(
'saved-views-row-menu-item-copyLink',
);
await waitFor(() => expectRowUsable(copyLink), MENU_CHECK_TIMEOUT);
await userEvent.click(copyLink);
expect(mockCopy).toHaveBeenCalledTimes(1);
expect(relativeTimeOf(mockCopy.mock.calls[0][0])).toBe('6h');
});
});

View File

@@ -1,10 +1,11 @@
import { useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { useMutation, useQueryClient } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { toast } from '@signozhq/ui/sonner';
import {
getGetSavedViewQueryOptions,
updateSavedView,
useDeleteSavedView,
useUpdateSavedView,
} from 'api/generated/services/saved-view';
import {
RenderErrorResponseDTO,
@@ -45,8 +46,39 @@ export function useSavedViewRowActions(
const timeSearch = useTimeSearchParams();
const [, copyToClipboard] = useCopyToClipboard();
const logSavedViewEvent = useSavedViewLogEvent(source);
const { mutateAsync: updateSavedView, isLoading: isRenaming } =
useUpdateSavedView();
// The update replaces the whole spec, so it starts from the stored view, not
// the list row, which can be stale. Without read on the view, the list row is
// the only copy there is.
const { mutateAsync: renameSavedView, isLoading: isRenaming } = useMutation(
async ({
view,
displayName,
}: {
view: SavedviewtypesSavedViewDTO;
displayName: string;
}) => {
let { spec } = view;
try {
({
data: { spec },
} = await queryClient.fetchQuery(
getGetSavedViewQueryOptions({ id: view.id }),
));
} catch (error) {
if ((error as ErrorType<unknown>).response?.status !== 403) {
throw error;
}
}
await updateSavedView(
{ id: view.id },
{
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: { ...spec, displayName },
},
);
},
);
const { mutateAsync: deleteSavedView, isLoading: isDeleting } =
useDeleteSavedView();
@@ -77,14 +109,7 @@ export function useSavedViewRowActions(
displayName: string,
): Promise<boolean> => {
try {
await updateSavedView({
pathParams: { id: view.id },
data: {
source,
schemaVersion: SAVED_VIEW_SCHEMA_VERSION,
spec: { ...view.spec, displayName },
},
});
await renameSavedView({ view, displayName });
await invalidateSavedViews(queryClient, source, view.id);
toast.success('View updated', { position: SAVED_VIEW_TOAST_POSITION });
logSavedViewEvent(SavedViewEvents.ViewDetailsEdited, {
@@ -96,7 +121,7 @@ export function useSavedViewRowActions(
return false;
}
},
[updateSavedView, source, queryClient, logSavedViewEvent],
[renameSavedView, source, queryClient, logSavedViewEvent],
);
// The deleted view's own query is left alone: refetching it would fail

View File

@@ -127,6 +127,48 @@ describe('hasUnsavedViewChanges', () => {
).toBe(true);
});
it('is clean while no query is staged', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: null,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options,
}),
).toBe(false);
});
it('is dirty when the columns are reordered', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: { ...options, selectColumns: [columns[1], columns[0]] },
}),
).toBe(true);
});
it('is dirty when a column is swapped for another', () => {
expect(
hasUnsavedViewChanges({
view,
stagedQuery: staged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.traces,
options: {
...options,
selectColumns: [
columns[0],
{ name: 'duration_nano', key: 'duration_nano' } as TelemetryFieldKey,
],
},
}),
).toBe(true);
});
it('reads a traces view without columns as the default columns', () => {
const bare = viewFrom(
toSavedViewSpec({
@@ -184,6 +226,21 @@ describe('hasUnsavedViewChanges', () => {
).toBe(true);
});
it.each([
['format', { format: 'table' as LogViewMode }],
['font size', { fontSize: FontSize.LARGE }],
])('is dirty when only the %s changes', (_, change) => {
expect(
hasUnsavedViewChanges({
view: logsView,
stagedQuery: logsStaged,
panelType: PANEL_TYPES.LIST,
sourcePage: SavedviewtypesSourceDTO.logs,
options: { selectColumns: shownColumns, ...formatting, ...change },
}),
).toBe(true);
});
it('reads a view without formatting as the default formatting', () => {
const bare = viewFrom(
toSavedViewSpec({
@@ -295,6 +352,137 @@ describe('hasUnsavedViewChanges', () => {
).toBe(false);
});
it.each([
['point', 'attribute'],
['spanfield', 'span'],
['logfield', 'log'],
['tracefield', 'trace'],
])(
'reads a group-by staged as %s and stored as %s as the same field',
(legacyContext, storedContext) => {
const base = viewFrom(
toSavedViewSpec({
query: query(DataSource.METRICS, ''),
panelType: PANEL_TYPES.TIME_SERIES,
displayName: 'By os',
}),
);
const [storedQuery] = base.spec.queries;
const storedView = {
...base,
spec: {
...base.spec,
queries: [
{
...storedQuery,
spec: {
...storedQuery.spec,
groupBy: [
{
name: 'os.type',
fieldContext: storedContext,
fieldDataType: 'string',
},
],
},
},
],
},
} as SavedviewtypesSavedViewDTO;
const opened = stagedFrom(storedView, DataSource.METRICS);
const stagedLegacy = {
...opened,
builder: {
...opened.builder,
queryData: opened.builder.queryData.map((queryData) => ({
...queryData,
groupBy: [{ key: 'os.type', dataType: 'string', type: legacyContext }],
})),
},
} as Query;
expect(
hasUnsavedViewChanges({
view: storedView,
stagedQuery: stagedLegacy,
panelType: PANEL_TYPES.TIME_SERIES,
sourcePage: SavedviewtypesSourceDTO.metrics,
}),
).toBe(false);
},
);
// The query still staged after a save keeps the builder's values, while
// the server returns the view without them.
describe('right after saving a query that carries builder defaults', () => {
const withPatch = (
dataSource: DataSource,
patch: Partial<Query['builder']['queryData'][number]>,
): Query => {
const base = query(dataSource, '');
return {
...base,
builder: {
...base.builder,
queryData: base.builder.queryData.map((queryData) => ({
...queryData,
...patch,
})),
},
} as Query;
};
it.each([
[
'a zero offset on a list',
DataSource.LOGS,
SavedviewtypesSourceDTO.logs,
PANEL_TYPES.LIST,
{ offset: 0 },
],
[
'an empty group-by description',
DataSource.TRACES,
SavedviewtypesSourceDTO.traces,
PANEL_TYPES.TIME_SERIES,
{
groupBy: [
{
key: 'service.name',
dataType: 'string',
type: 'resource',
description: '',
},
],
},
],
[
'a function without arguments',
DataSource.METRICS,
SavedviewtypesSourceDTO.metrics,
PANEL_TYPES.TIME_SERIES,
{ functions: [{ name: 'absolute', args: [], namedArgs: {} }] },
],
])('is clean with %s', (_, dataSource, sourcePage, panelType, patch) => {
const saved = withPatch(
dataSource,
patch as Partial<Query['builder']['queryData'][number]>,
);
const savedView = viewFrom(
toSavedViewSpec({ query: saved, panelType, displayName: 'Saved' }),
);
expect(
hasUnsavedViewChanges({
view: savedView,
stagedQuery: saved,
panelType,
sourcePage,
}),
).toBe(false);
});
});
describe('list order', () => {
const withOrderBy = (
staged: Query,

View File

@@ -80,6 +80,66 @@ describe('toSavedViewSpec', () => {
});
});
it('keeps the description and unit of a column, naming it by name before key', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType: PANEL_TYPES.LIST,
displayName: 'Errors',
options: {
selectColumns: [
{
name: 'duration_nano',
key: 'durationNano',
description: 'Span duration',
unit: 'ns',
},
] as unknown as TelemetryFieldKey[],
format: 'raw' as LogViewMode,
maxLines: 2,
fontSize: FontSize.SMALL,
},
});
expect(spec.selectedFields).toStrictEqual([
{ name: 'duration_nano', description: 'Span duration', unit: 'ns' },
]);
});
it.each([
[
PANEL_TYPES.LIST,
SavedviewtypesPanelTypeDTO.list,
Querybuildertypesv5RequestTypeDTO.raw,
],
[
PANEL_TYPES.TABLE,
SavedviewtypesPanelTypeDTO.table,
Querybuildertypesv5RequestTypeDTO.scalar,
],
[
PANEL_TYPES.TRACE,
SavedviewtypesPanelTypeDTO.trace,
Querybuildertypesv5RequestTypeDTO.trace,
],
[
PANEL_TYPES.PIE,
SavedviewtypesPanelTypeDTO.graph,
Querybuildertypesv5RequestTypeDTO.scalar,
],
])(
'stores the %s panel as panel type %s with request type %s',
(panelType, storedPanelType, requestType) => {
const spec = toSavedViewSpec({
query: tracesQuery(),
panelType,
displayName: 'Errors',
});
expect(spec.panelType).toBe(storedPanelType);
expect(spec.requestType).toBe(requestType);
},
);
it('keeps the colour in display', () => {
const spec = toSavedViewSpec({
query: tracesQuery(),

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: (widget?.query?.builder?.queryData &&
Array.isArray(widget.query.builder.queryData)
? widget.query.builder.queryData
: []
).some(
isButtonEnabled: (Array.isArray(widgetQueryData) ? widgetQueryData : []).some(
(q) =>
q.dataSource === DataSource.TRACES || q.dataSource === DataSource.LOGS,
),
@@ -219,7 +219,7 @@ function WidgetGraphComponent({
customOnDragSelect={customOnDragSelect}
setCurrentGraphRef={setCurrentGraphRef}
enableDrillDown={
enableDrillDown && widget?.query?.queryType === EQueryType.QUERY_BUILDER
enableDrillDown && isBuilderQueryType(widget?.query?.queryType)
}
/>
</Modal>

View File

@@ -1,4 +1,5 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
createColumnsAndDataSource,
@@ -47,6 +48,18 @@ 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,6 +4,7 @@ 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';
@@ -119,14 +120,15 @@ 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 = currentQuery?.builder?.queryData?.find(
legend = getActiveBuilder(currentQuery)?.queryData?.find(
(query) => query.queryName === queryName,
)?.legend;
if (!legend) {
// check if the value is present in the formula
legend = currentQuery?.builder?.queryFormulas?.find(
legend = getActiveBuilder(currentQuery)?.queryFormulas?.find(
(query) => query.queryName === queryName,
)?.legend;
}
@@ -199,7 +201,7 @@ export function createColumnsAndDataSource(
: undefined;
const isNewAggregation =
currentQuery?.builder?.queryData?.find(
getActiveBuilder(currentQuery).queryData?.find(
(query) => query.queryName === item.queryName,
)?.aggregations?.length || 0;

View File

@@ -6,6 +6,7 @@ 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 {
@@ -72,8 +73,7 @@ function GridValueComponent({
);
}
const isQueryTypeBuilder =
widget?.query?.queryType === EQueryType.QUERY_BUILDER;
const isQueryTypeBuilder = isBuilderQueryType(widget?.query?.queryType);
return (
<>

View File

@@ -58,6 +58,27 @@ 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,38 @@
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,6 +2,7 @@ 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';
@@ -105,18 +106,25 @@ describe('useGetCompositeQueryParam', () => {
},
});
const { QUERY_BUILDER, AI_QUERY_BUILDER } = EQueryType;
[
[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]) => {
[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]) => {
const query = renderOn(clickhouseWithOperator, route);
const builder = query && getActiveBuilder(query);
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([]);
expect(query?.queryType).toBe(queryType);
expect(builder?.queryData[0].dataSource).toBe(dataSource);
expect(builder?.queryData[0].source ?? '').toBe(source);
expect(builder?.queryTraceOperator).toStrictEqual([]);
});
});

View File

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

View File

@@ -8,11 +8,8 @@ import {
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import {
IBuilderQuery,
Query,
QueryMode,
} from 'types/api/queryBuilder/queryBuilderData';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
import { getActiveBuilder } from 'lib/query/builderSlot';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
@@ -44,8 +41,9 @@ function firstBuilderQuery(
queryData: unknown[],
pathname: string,
): IBuilderQuery {
return normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname)
.query.builder.queryData[0];
return getActiveBuilder(
normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname).query,
).queryData[0];
}
function without(builderQuery: object, key: keyof IBuilderQuery): object {
@@ -62,9 +60,11 @@ describe('normalizeCompositeQuery', () => {
queryType: EQueryType.CLICKHOUSE,
extra: 'kept',
};
const aiQuery = withBuilderQueries(initialQueriesMap.traces, [
{ ...tracesQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER },
]);
const aiQuery: Query = {
...initialQueriesMap.traces,
queryType: EQueryType.AI_QUERY_BUILDER,
builder_ai_query: initialQueriesMap.traces.builder,
};
[NOT_AN_EXPLORER, ROUTES.LOGS_EXPLORER].forEach((pathname) =>
expect(
@@ -86,6 +86,33 @@ 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,
@@ -209,7 +236,7 @@ describe('normalizeCompositeQuery', () => {
expect(
firstBuilderQuery(
logs,
[{ ...logsQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER }],
[{ ...logsQuery, builderQueryType: EQueryType.AI_QUERY_BUILDER }],
ROUTES.LOGS_EXPLORER,
).builderQueryType,
).toBeUndefined();
@@ -220,7 +247,7 @@ describe('normalizeCompositeQuery', () => {
{
...tracesQuery,
queryName: 'D',
builderQueryType: QueryMode.AI_QUERY_BUILDER,
builderQueryType: EQueryType.AI_QUERY_BUILDER,
...llmFilter,
},
],
@@ -243,10 +270,7 @@ describe('normalizeCompositeQuery', () => {
).toBe(TelemetrytypesSourceDTO.meter);
expect(
firstBuilderQuery(traces, [{ ...tracesQuery, ...llmFilter }], ai),
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
...llmFilter,
});
).toStrictEqual({ ...tracesQuery, ...llmFilter });
expect(
firstBuilderQuery(
traces,
@@ -260,17 +284,13 @@ describe('normalizeCompositeQuery', () => {
],
ai,
),
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
queryName: 'B',
filter: { expression: '' },
});
).toStrictEqual({ ...tracesQuery, queryName: 'B', ...llmFilter });
expect(
firstBuilderQuery(traces, [{ ...logsQuery, queryName: 'C' }], ai),
).toMatchObject({
builderQueryType: QueryMode.AI_QUERY_BUILDER,
dataSource: DataSource.TRACES,
queryName: 'C',
filter: { expression: '' },
});
});

View File

@@ -7,8 +7,10 @@ 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 {
@@ -78,6 +80,14 @@ 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;
}
}
/**
@@ -93,8 +103,21 @@ function isListOfObjects(value: unknown): boolean {
}
function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
// Explorers only have the query builder: no PromQL or ClickHouse editor.
query.queryType = EQueryType.QUERY_BUILDER;
// 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);
}
if (query.promql.some((promql) => promql.query !== '')) {
query.promql = cloneDeep(initialQueryState.promql);
@@ -105,10 +128,10 @@ function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
}
if (!rules.allowsTraceOperator) {
query.builder.queryTraceOperator = [];
builder.queryTraceOperator = [];
}
query.builder.queryData = query.builder.queryData.map((builderQuery) =>
builder.queryData = builder.queryData.map((builderQuery) =>
fitBuilderQueryToExplorer(builderQuery, rules),
);
}
@@ -148,12 +171,9 @@ function fillMissingSignal(
builderQuery.source = rules.source;
}
// 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;
// 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;
}
}
@@ -169,15 +189,11 @@ function belongsToExplorer(
return false;
}
const builderQueryType =
builderQuery.builderQueryType ??
Querybuildertypesv5QueryTypeDTO.builder_query;
if (builderQueryType !== rules.builderQueryType) {
return false;
}
return true;
// Only the AI explorer runs AI queries.
return (
builderQuery.builderQueryType !==
Querybuildertypesv5QueryTypeDTO.builder_ai_query
);
}
function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
@@ -186,11 +202,5 @@ function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
);
builderQuery.source = rules.source;
if (
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
) {
builderQuery.builderQueryType = rules.builderQueryType;
}
return builderQuery;
}

View File

@@ -0,0 +1,18 @@
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,6 +7,7 @@ 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,
@@ -488,13 +489,15 @@ export const useQueryOperations: UseQueryOperations = ({
],
);
const activeQueryCount = getActiveBuilder(currentQuery).queryData.length;
const handleDeleteQuery = useCallback(() => {
if (currentQuery.builder.queryData.length > 1) {
if (activeQueryCount > 1) {
removeQueryBuilderEntityByIndex('queryData', index);
}
setLastUsedQuery(0);
}, [
currentQuery.builder.queryData.length,
activeQueryCount,
setLastUsedQuery,
removeQueryBuilderEntityByIndex,
index,

View File

@@ -67,6 +67,23 @@ 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,5 +1,6 @@
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';
@@ -7,11 +8,12 @@ export const useGetQueryLabels = (
currentQuery: Query,
): { label: string; value: string }[] =>
useMemo(() => {
if (currentQuery?.queryType === EQueryType.QUERY_BUILDER) {
if (currentQuery && isBuilderQueryType(currentQuery.queryType)) {
const activeBuilder = getActiveBuilder(currentQuery);
const queryLabels = getQueryLabelWithAggregation(
currentQuery?.builder?.queryData || [],
activeBuilder?.queryData || [],
);
const formulaLabels = (currentQuery?.builder?.queryFormulas ?? []).map(
const formulaLabels = (activeBuilder?.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 { EQueryType } from 'types/common/dashboard';
import { getActiveBuilder, isBuilderQueryType } from 'lib/query/builderSlot';
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 (query.queryType !== 'builder') {
if (!isBuilderQueryType(query.queryType)) {
return true; // Non-builder queries don't need this validation
}
const { queryData } = query.builder;
const { queryData } = getActiveBuilder(query);
// Check if any METRICS data source queries exist
const metricsQueries = queryData.filter(
@@ -124,14 +124,15 @@ export const getLegend = (
labelName: string,
): string => {
// For non-query builder queries, return the label name directly
if (payloadQuery.queryType !== EQueryType.QUERY_BUILDER) {
if (!isBuilderQueryType(payloadQuery.queryType)) {
return labelName;
}
const activeBuilder = getActiveBuilder(payloadQuery);
// Combine queryData and queryTraceOperator
const allQueries = [
...(payloadQuery?.builder?.queryData || []),
...(payloadQuery?.builder?.queryTraceOperator || []),
...(activeBuilder?.queryData || []),
...(activeBuilder?.queryTraceOperator || []),
];
const aggregationPerQuery = allQueries.reduce((acc, query) => {

View File

@@ -161,6 +161,35 @@ 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,6 +1,8 @@
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:
@@ -50,9 +52,11 @@ export function containsAnyVariableReference(text: string): boolean {
return !!text && ANY_VARIABLE_REFERENCE.test(text);
}
function extractQueryBuilderTexts(query: Query): string[] {
function extractQueryBuilderTexts(
builder: QueryBuilderData | undefined,
): string[] {
const texts: string[] = [];
const queryDataList = query.builder?.queryData;
const queryDataList = builder?.queryData;
if (isArray(queryDataList)) {
queryDataList.forEach((queryData) => {
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
@@ -102,13 +106,13 @@ function extractClickhouseSQLTexts(query: Query): string[] {
/**
* Extracts all text strings from a widget Query that could contain variable
* references. Covers:
* - QUERY_BUILDER: filter items' string values + filter.expression
* - QUERY_BUILDER / AI_QUERY_BUILDER: filter items' string values + filter.expression
* - PROM: each promql[].query
* - CLICKHOUSE: each clickhouse_sql[].query
*/
export function extractQueryTextStrings(query: Query): string[] {
if (query.queryType === EQueryType.QUERY_BUILDER) {
return extractQueryBuilderTexts(query);
if (isBuilderQueryType(query.queryType)) {
return extractQueryBuilderTexts(getActiveBuilder(query));
}
if (query.queryType === EQueryType.PROM) {

View File

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

View File

@@ -15,6 +15,7 @@ import {
PromQuery,
QueryBuilderFormula,
} from 'types/api/v5/queryRange';
import { EQueryType } from 'types/common/dashboard';
import {
convertBuilderQueryToIBuilderQuery,
convertQueryBuilderFormulaToIBuilderFormula,
@@ -37,6 +38,7 @@ 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;
@@ -49,10 +51,13 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
}
} else if (q.type === 'builder_ai_query') {
if (spec.name) {
builderQueries[spec.name] = {
...convertBuilderQueryToIBuilderQuery(spec as BuilderQuery),
builderQueryType: 'builder_ai_query',
};
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' };
builderQueryTypes[spec.name] = 'builder_ai_query';
}
} else if (q.type === 'builder_formula') {
@@ -87,8 +92,14 @@ const mapQueryFromV5 = (compositeQuery: ICompositeMetricQuery): Query => {
});
}
});
const builder = transformQueryBuilderDataModel(
builderQueries,
builderQueryTypes,
);
return {
builder: transformQueryBuilderDataModel(builderQueries, builderQueryTypes),
builder: isAIQuery ? transformQueryBuilderDataModel({}) : builder,
...(isAIQuery && { builder_ai_query: builder }),
promql: promQueries,
clickhouse_sql: clickhouseQueries,
queryType: compositeQuery.queryType,

View File

@@ -0,0 +1,93 @@
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

@@ -0,0 +1,73 @@
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,6 +12,7 @@ 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,
@@ -93,8 +94,8 @@ const getQueryByName = <T extends keyof QueryBuilderData>(
queryArray.find((q) => q.name === currentQueryName) || defaultQueryValue
);
}
if (query.queryType === EQueryType.QUERY_BUILDER) {
const queryArray = (query.builder[type] || []) as (
if (isBuilderQueryType(query.queryType)) {
const queryArray = (getActiveBuilder(query)[type] || []) as (
| IBuilderQuery
| IBuilderFormula
)[];
@@ -173,7 +174,7 @@ const addOperatorFormulaColumns = (
return;
}
if (queryType === EQueryType.QUERY_BUILDER) {
if (isBuilderQueryType(queryType)) {
const currentQueryData = query as IBuilderQuery;
let operatorLabel = `${currentQueryData.aggregateOperator}`;
if (currentQueryData.aggregateAttribute?.key) {

View File

@@ -1,51 +0,0 @@
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

@@ -1,10 +1,9 @@
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';
@@ -33,7 +32,7 @@ function ConfigActions({
//TODO: AI query check should be removed after we support alerts on ai query @tewarig
if (
!actions.createAlert ||
getQueryMode(currentQuery) === QueryMode.AI_QUERY_BUILDER
currentQuery.queryType === EQueryType.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',
supportedQueryModesKindMap: Object.fromEntries(
supportedQueryTypesKindMap: Object.fromEntries(
queryTypes.map((queryType) => [
queryType,
queryType === EQueryType.QUERY_BUILDER

View File

@@ -25,6 +25,25 @@ 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,6 +12,7 @@ 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,5 +1,6 @@
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';
@@ -20,13 +21,16 @@ 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>();
(currentQuery?.builder?.queryData ?? []).forEach((query) => {
(activeQueryData ?? []).forEach((query) => {
(query.groupBy ?? []).forEach((field) => {
if (field.key) {
names.add(`_${field.key}`);
@@ -34,7 +38,7 @@ export function useContextLinkVariables(): VariableItem[] {
});
});
return Array.from(names);
}, [currentQuery?.builder?.queryData]);
}, [activeQueryData]);
return useMemo(
() => [

View File

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

View File

@@ -1,3 +1,4 @@
import { EQueryType } from 'types/common/dashboard';
import {
type KeyboardEvent,
type ReactNode,
@@ -23,22 +24,20 @@ import { useSyncQueryBuilderFields } from 'components/QueryBuilderV2/useSyncQuer
import { DataSource } from 'types/common/queryBuilder';
import {
getQueryModeSignals,
getQueryTypeSignals,
isRawRequest,
} from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { toPanelType } from '../../Panels/types/panelKind';
import { getQueryMode, isBuilderMode } from 'lib/query/queryMode';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { isBuilderQueryType } from 'lib/query/builderSlot';
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. */
@@ -58,7 +57,6 @@ interface PanelEditorQueryBuilderProps {
*/
function PanelEditorQueryBuilder({
panelDefinition,
onChangeQueryMode,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
@@ -68,6 +66,10 @@ 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);
@@ -99,10 +101,10 @@ function PanelEditorQueryBuilder({
const items = useMemo(() => {
const queryTypeComponents: Record<
QueryMode,
EQueryType,
{ icon: ReactNode; label: string; component: ReactNode }
> = {
[QueryMode.QUERY_BUILDER]: {
[EQueryType.QUERY_BUILDER]: {
icon: <Atom size={14} />,
label: 'Query Builder',
component: (
@@ -110,9 +112,9 @@ function PanelEditorQueryBuilder({
<QueryBuilderV2
panelType={panelType}
fieldsConfig={panelDefinition.queryBuilderFields}
allowedDataSources={getQueryModeSignals(
panelDefinition.supportedQueryModesKindMap,
QueryMode.QUERY_BUILDER,
allowedDataSources={getQueryTypeSignals(
panelDefinition.supportedQueryTypesKindMap,
EQueryType.QUERY_BUILDER,
)}
showTraceOperator={!isRawQuery}
version="v3"
@@ -123,12 +125,12 @@ function PanelEditorQueryBuilder({
</div>
),
},
[QueryMode.CLICKHOUSE]: {
[EQueryType.CLICKHOUSE]: {
icon: <Terminal size={14} />,
label: 'ClickHouse Query',
component: <ClickHouseQueryContainer />,
},
[QueryMode.PROM]: {
[EQueryType.PROM]: {
icon: (
<PromQLIcon
fillColor={isDarkMode ? Color.BG_VANILLA_200 : Color.BG_INK_300}
@@ -137,7 +139,7 @@ function PanelEditorQueryBuilder({
label: 'PromQL',
component: <PromQLQueryContainer />,
},
[QueryMode.AI_QUERY_BUILDER]: {
[EQueryType.AI_QUERY_BUILDER]: {
icon: <Brain size={14} />,
label: 'AI Query Builder',
component: (
@@ -160,8 +162,8 @@ function PanelEditorQueryBuilder({
};
const modes = Object.keys(
panelDefinition.supportedQueryModesKindMap,
) as QueryMode[];
panelDefinition.supportedQueryTypesKindMap,
) as EQueryType[];
return modes.map((queryType) => ({
key: queryType,
@@ -176,7 +178,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: isBuilderMode(queryType),
destroyInactiveTabPane: isBuilderQueryType(queryType),
}));
}, [panelDefinition, panelType, isDarkMode, isRawQuery]);
@@ -193,8 +195,8 @@ function PanelEditorQueryBuilder({
className={cx(styles.tabsContainer, {
[styles.stickyNav]: stickyHeader,
})}
activeKey={getQueryMode(currentQuery)}
onChange={onChangeQueryMode}
activeKey={currentQuery.queryType}
onChange={onChangeQueryType}
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,7 +8,6 @@ import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
function QueryBuilderEditorPane({
panelDefinition,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -17,7 +16,6 @@ function QueryBuilderEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,17 +1,11 @@
import { useRef } from 'react';
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen, userEvent } from 'tests/test-utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import {
toPanelType,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { BuildersByVariant } from 'types/api/queryBuilder/queryBuilderData';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
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();
@@ -57,25 +51,19 @@ const BUILDER_QUERY = {
builder: { queryData: [{ dataSource: 'traces' }] },
};
const AI_QUERY = {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryType: EQueryType.AI_QUERY_BUILDER,
builder: { queryData: [] },
builder_ai_query: {
queryData: [{ dataSource: 'traces', builderQueryType: 'builder_ai_query' }],
},
};
/** Stands in for the editor shell: owns the tab switch and its parked Query Builder query. */
/** Stands in for the editor shell. */
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()}
@@ -172,11 +160,12 @@ describe('PanelEditorQueryBuilder AI tab', () => {
});
});
it('seeds an AI traces query when switching to the AI tab', () => {
it('seeds an AI traces query when switching to the AI tab', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockBuilder(BUILDER_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('AI Query Builder'));
await user.click(screen.getByText('AI Query Builder'));
expect(updateAllQueriesOperators).toHaveBeenCalledWith(
expect.anything(),
@@ -184,11 +173,16 @@ describe('PanelEditorQueryBuilder AI tab', () => {
'traces',
);
const [seeded] = redirectWithQueryBuilderData.mock.calls[0];
expect(seeded.queryType).toBe(EQueryType.QUERY_BUILDER);
expect(seeded.builder.queryData[0].builderQueryType).toBe('builder_ai_query');
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);
});
it('keeps the builder query when returning from ClickHouse', () => {
it('keeps the builder query when returning from ClickHouse', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs', legend: 'kept' }] },
@@ -196,18 +190,19 @@ describe('PanelEditorQueryBuilder AI tab', () => {
mockBuilder(logsQuery);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('ClickHouse Query'));
await user.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('shows a fresh default query when leaving AI', () => {
it('seeds a default query for the kind when leaving AI for an unused Query Builder', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
mockBuilder(AI_QUERY);
renderBuilder('signoz/TimeSeriesPanel');
fireEvent.click(screen.getByText('Query Builder'));
await user.click(screen.getByText('Query Builder'));
const [next] = redirectWithQueryBuilderData.mock.calls[0];
expect(next.queryType).toBe(EQueryType.QUERY_BUILDER);
@@ -215,7 +210,22 @@ describe('PanelEditorQueryBuilder AI tab', () => {
expect(next.builder.queryData[0].dataSource).toBe('metrics');
});
it("keeps each tab's own query across Query Builder ↔ AI round trips", () => {
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 });
const logsQuery = {
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [{ dataSource: 'logs' }] },
@@ -226,23 +236,26 @@ describe('PanelEditorQueryBuilder AI tab', () => {
mockBuilder(logsQuery);
const { rerender } = render(host());
fireEvent.click(screen.getByText('AI Query Builder'));
await user.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: editedAIBuilder });
mockBuilder({ ...aiQuery, builder_ai_query: editedAIBuilder });
rerender(host());
fireEvent.click(screen.getByText('Query Builder'));
await user.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());
fireEvent.click(screen.getByText('AI Query Builder'));
await user.click(screen.getByText('AI Query Builder'));
const [backToAI] = redirectWithQueryBuilderData.mock.calls[2];
expect(backToAI.builder).toBe(editedAIBuilder);
expect(backToAI.queryType).toBe(EQueryType.AI_QUERY_BUILDER);
expect(backToAI.builder_ai_query).toBe(editedAIBuilder);
});
});

View File

@@ -0,0 +1,72 @@
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,8 +72,6 @@ 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;
}
/**
@@ -95,7 +93,6 @@ 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.
@@ -312,7 +309,6 @@ function QueryEditorBody({
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}

View File

@@ -60,9 +60,8 @@ jest.mock('../hooks/usePanelEditorQuerySync', () => ({
}));
const mockUseTypeSwitch = jest.fn();
jest.mock('../hooks/usePanelKindAndQueryModeSwitch', () => ({
usePanelKindAndQueryModeSwitch: (args: unknown): unknown =>
mockUseTypeSwitch(args),
jest.mock('../hooks/usePanelKindChange', () => ({
usePanelKindChange: (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 { useRef, useState } from 'react';
import { useState } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { Provider as ReduxProvider } from 'react-redux';
@@ -22,8 +22,6 @@ 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', () => {
@@ -81,17 +79,10 @@ 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 { resolveQueryMode } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getBuilderQueries } from '../../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../../queryV5/persesQueryAdapters';
import { getSwitchedPluginSpec } from '../../getSwitchedPluginSpec';
import { usePanelKindAndQueryModeSwitch } from '../usePanelKindAndQueryModeSwitch';
import { usePanelKindChange } from '../usePanelKindChange';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
@@ -18,10 +18,8 @@ jest.mock('lib/query/panelQuery', () => ({
handleQueryChange: jest.fn(),
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryMode: jest.fn(),
resolveQueryType: 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')
@@ -39,7 +37,7 @@ jest.mock('../../../Panels/utils/getBuilderQueries', () => ({
const mockUseQueryBuilder = useQueryBuilder as unknown as jest.Mock;
const mockHandleQueryChange = handleQueryChange as unknown as jest.Mock;
const mockResolveQueryMode = resolveQueryMode as unknown as jest.Mock;
const mockResolveQueryType = resolveQueryType 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;
@@ -102,7 +100,7 @@ function builderState(currentQuery: Query): {
};
}
describe('usePanelKindAndQueryModeSwitch', () => {
describe('usePanelKindChange', () => {
beforeEach(() => {
jest.clearAllMocks();
mockHandleQueryChange.mockReturnValue(TRANSFORMED);
@@ -111,7 +109,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
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.
mockResolveQueryMode.mockReturnValue('builder');
mockResolveQueryType.mockReturnValue('builder');
});
it('does nothing when switching to the current kind', () => {
@@ -120,7 +118,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -139,7 +137,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
mockUseQueryBuilder.mockReturnValue(state);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: tableSpec,
panelType: PANEL_TYPES.TABLE,
setSpec,
@@ -171,7 +169,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -202,7 +200,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -238,7 +236,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -279,7 +277,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -320,7 +318,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
} as unknown as Query);
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: makeSpec('signoz/HeatmapPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.HEATMAP,
setSpec,
@@ -338,7 +336,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
mockUseQueryBuilder.mockReturnValue(builderState(promQuery));
const { result } = renderHook(() =>
usePanelKindAndQueryModeSwitch({
usePanelKindChange({
spec: makeSpec('signoz/TimeSeriesPanel', {}, TABLE_QUERIES),
panelType: PANEL_TYPES.TIME_SERIES,
setSpec,
@@ -347,7 +345,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
// The hook asks the guard to resolve the active query type against the new kind…
expect(mockResolveQueryMode).toHaveBeenCalledWith(
expect(mockResolveQueryType).toHaveBeenCalledWith(
'signoz/ListPanel',
'promql',
);
@@ -356,211 +354,128 @@ describe('usePanelKindAndQueryModeSwitch', () => {
expect((queryArg as Query).queryType).toBe('builder');
});
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');
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];
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);
beforeEach(() => {
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 }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
usePanelKindChange({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
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');
// Leave Table on AI; on List the user moves to Query Builder, then returns.
act(() => result.current.onChangePanelKind('signoz/ListPanel'));
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' },
]),
);
state = builderState(withBothSlots('list', 'builder'));
mockResolveQueryType.mockReturnValue('builder');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
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);
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);
});
it('swaps in the Query Builder query when revisiting a kind left on AI', () => {
const { result, rerender, setSpec, setState } = leaveTableOnAI();
// On List the user is back on Query Builder, then returns to Table.
const state = setState(
fakeQuery('list-qb', 'builder', [{ dataSource: 'logs' }]),
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' }]),
);
mockResolveQueryMode.mockReturnValue('builder');
mockUseQueryBuilder.mockImplementation(() => state);
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindChange({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);
// 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');
rerender({ spec: listSpec, panelType: PANEL_TYPES.LIST });
act(() => result.current.onChangePanelKind('signoz/TablePanel'));
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);
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);
});
});
@@ -573,7 +488,7 @@ describe('usePanelKindAndQueryModeSwitch', () => {
const { result, rerender } = renderHook(
(props: { spec: DashboardtypesPanelSpecDTO; panelType: PANEL_TYPES }) =>
usePanelKindAndQueryModeSwitch({ ...props, setSpec }),
usePanelKindChange({ ...props, setSpec }),
{ initialProps: { spec: tableSpec, panelType: PANEL_TYPES.TABLE } },
);

View File

@@ -9,6 +9,7 @@ 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';
@@ -111,9 +112,10 @@ 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(
() => (currentQuery.builder?.queryData ?? []).map((q) => q.dataSource),
[currentQuery.builder],
() => (activeQueryData ?? []).map((q) => q.dataSource),
[activeQueryData],
);
const dataSourceSignature = dataSources.join(',');
const prevDataSourcesRef = useRef(dataSources);

View File

@@ -11,27 +11,16 @@ 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 BuilderVariant,
type BuildersByVariant,
type OrderByPayload,
type Query,
QueryMode,
import type {
OrderByPayload,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
getQueryPanelDefinition,
isStaticPanelKind,
resolveQueryMode,
resolveQueryType,
} 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';
@@ -43,7 +32,6 @@ 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[] = [
@@ -69,21 +57,17 @@ interface KindState {
pluginSpec: DashboardtypesPanelPluginDTO['spec'];
queries: DashboardtypesQueryDTO[];
query: Query;
/** Both builder variants' queries as the kind was left. */
builders: BuildersByVariant;
}
interface UsePanelKindAndQueryModeSwitchArgs {
interface UsePanelKindChangeArgs {
spec: DashboardtypesPanelSpecDTO;
panelType: PANEL_TYPES;
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
}
interface UsePanelKindAndQueryModeSwitchApi {
interface UsePanelKindChangeApi {
/** 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;
}
/**
@@ -92,22 +76,15 @@ interface UsePanelKindAndQueryModeSwitchApi {
* 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 usePanelKindAndQueryModeSwitch({
export function usePanelKindChange({
spec,
panelType,
setSpec,
}: UsePanelKindAndQueryModeSwitchArgs): UsePanelKindAndQueryModeSwitchApi {
const {
currentQuery,
redirectWithQueryBuilderData,
updateAllQueriesOperators,
} = useQueryBuilder();
}: UsePanelKindChangeArgs): UsePanelKindChangeApi {
const { currentQuery, redirectWithQueryBuilderData } = 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);
@@ -117,14 +94,6 @@ export function usePanelKindAndQueryModeSwitch({
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;
@@ -142,20 +111,9 @@ export function usePanelKindAndQueryModeSwitch({
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
@@ -172,10 +130,12 @@ export function usePanelKindAndQueryModeSwitch({
} as DashboardtypesPanelPluginDTO,
queries,
});
const rebuildForNewKind = (source: Query): Query => {
const queryType = resolveQueryType(newKind, query.queryType);
const rebuildForNewKind = (builder: Query['builder']): Query['builder'] => {
const transformed = handleQueryChange(
newPanelType as keyof PartialPanelTypes,
{ ...source, queryType: targetQueryType },
{ ...query, queryType: EQueryType.QUERY_BUILDER, builder },
panelTypeRef.current,
);
// Match a fresh list panel's default order so the builder's Order By isn't empty.
@@ -183,62 +143,41 @@ export function usePanelKindAndQueryModeSwitch({
newKind === 'signoz/ListPanel'
? withDefaultListOrder(transformed)
: transformed;
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 };
return (
newKind === 'signoz/HeatmapPanel'
? withSingleHeatmapQuery(ordered)
: withPercentileHistogramAggregation(ordered)
).builder;
};
// Revisit → restore the stash in the active mode; a static kind has no builder to re-seed.
// 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.
const cached = cacheRef.current.get(newKind);
if (cached) {
if (isStaticPanelKind(newKind)) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
return;
}
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.
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.
setSpec(
buildSpec(
cached.pluginSpec,
restored.query.builder === cached.query.builder
queryType === cached.query.queryType
? cached.queries
: toPerses(restored.query, newPanelType),
: toPerses(restored, newPanelType),
),
);
redirectWithQueryBuilderData({
...restored.query,
queryType: targetQueryType,
});
redirectWithQueryBuilderData(restored);
return;
}
@@ -252,25 +191,15 @@ export function usePanelKindAndQueryModeSwitch({
return;
}
// 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;
// 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),
}),
};
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
@@ -282,8 +211,8 @@ export function usePanelKindAndQueryModeSwitch({
);
redirectWithQueryBuilderData(nextQuery);
},
[setSpec, redirectWithQueryBuilderData, updateAllQueriesOperators],
[setSpec, redirectWithQueryBuilderData],
);
return { onChangePanelKind, onChangeQueryMode };
return { onChangePanelKind };
}

View File

@@ -1,83 +0,0 @@
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,
],
);
}

View File

@@ -6,7 +6,7 @@ import type { NewPanelTarget } from '../patchOps';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelKindAndQueryModeSwitch } from './hooks/usePanelKindAndQueryModeSwitch';
import { usePanelKindChange } from './hooks/usePanelKindChange';
export interface PanelEditorContainerProps {
dashboardId: string;
@@ -40,12 +40,11 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const panelDefinition = getPanelDefinition(panelKind);
const originalPanelKind = (savedPanel ?? panel).spec.plugin.kind;
const { onChangePanelKind, onChangeQueryMode } =
usePanelKindAndQueryModeSwitch({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
const { onChangePanelKind } = usePanelKindChange({
spec: draftApi.draft.spec,
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});
if (panelDefinition.mode === 'static') {
return (
@@ -66,7 +65,6 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
originalPanelKind={originalPanelKind}
onChangeQueryMode={onChangeQueryMode}
/>
);
}

View File

@@ -3,7 +3,6 @@ import {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { UNSUPPORTED_PANEL } from '../kinds/UnsupportedPanel/definition';
import { getPanelDefinition, isPanelKindSupported } from '../registry';
@@ -12,13 +11,12 @@ import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryModes,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
resolveQueryMode,
resolveQueryType,
} from '../capabilities';
import type { PanelKind } from '../types/panelKind';
@@ -27,59 +25,44 @@ const { logs, traces, metrics } = TelemetrytypesSignalDTO;
const { time_series, scalar, raw, heatmap } = Querybuildertypesv5RequestTypeDTO;
const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/TimeSeriesPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/BarChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/AreaChartPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/NumberPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/HistogramPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
'signoz/ScatterPlotPanel': [QUERY_BUILDER, CLICKHOUSE],
// Static kind: no query surface at all.
'signoz/TextPanel': [],
};
const EXPECTED_QUERY_MODES: Record<PanelKind, QueryMode[]> = {
'signoz/TimeSeriesPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
EQueryType.AI_QUERY_BUILDER,
],
'signoz/BarChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
EQueryType.AI_QUERY_BUILDER,
],
'signoz/AreaChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
EQueryType.AI_QUERY_BUILDER,
],
'signoz/NumberPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
EQueryType.AI_QUERY_BUILDER,
],
'signoz/HistogramPanel': [
QUERY_BUILDER,
CLICKHOUSE,
PROM,
QueryMode.AI_QUERY_BUILDER,
EQueryType.AI_QUERY_BUILDER,
],
'signoz/HeatmapPanel': [QUERY_BUILDER, CLICKHOUSE, PROM],
'signoz/PieChartPanel': [
QUERY_BUILDER,
CLICKHOUSE,
QueryMode.AI_QUERY_BUILDER,
EQueryType.AI_QUERY_BUILDER,
],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE, QueryMode.AI_QUERY_BUILDER],
'signoz/ListPanel': [QUERY_BUILDER, QueryMode.AI_QUERY_BUILDER],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE, EQueryType.AI_QUERY_BUILDER],
'signoz/ListPanel': [QUERY_BUILDER, EQueryType.AI_QUERY_BUILDER],
'signoz/ScatterPlotPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TextPanel': [],
};
@@ -281,10 +264,10 @@ describe('panel capabilities guard', () => {
it('reads a specific mode when one is given', () => {
expect(
getSupportedSignals('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
getSupportedSignals('signoz/TablePanel', EQueryType.AI_QUERY_BUILDER),
).toStrictEqual([traces]);
expect(
getSupportedSignals('signoz/TablePanel', QueryMode.CLICKHOUSE),
getSupportedSignals('signoz/TablePanel', EQueryType.CLICKHOUSE),
).toStrictEqual([]);
});
});
@@ -335,36 +318,21 @@ describe('panel capabilities guard', () => {
});
});
describe('getSupportedQueryModes', () => {
it.each(Object.entries(EXPECTED_QUERY_MODES))(
'%s supports %j',
(kind, modes) => {
expect(getSupportedQueryModes(kind as PanelKind)).toStrictEqual(modes);
},
);
it('keeps AI out of the query types', () => {
expect(getSupportedQueryTypes('signoz/TimeSeriesPanel')).not.toContain(
QueryMode.AI_QUERY_BUILDER,
);
});
});
describe('resolveQueryMode', () => {
describe('resolveQueryType', () => {
it('keeps a supported mode', () => {
expect(resolveQueryMode('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(resolveQueryType('signoz/TimeSeriesPanel', PROM)).toBe(PROM);
expect(
resolveQueryMode('signoz/TablePanel', QueryMode.AI_QUERY_BUILDER),
).toBe(QueryMode.AI_QUERY_BUILDER);
resolveQueryType('signoz/TablePanel', EQueryType.AI_QUERY_BUILDER),
).toBe(EQueryType.AI_QUERY_BUILDER);
expect(
resolveQueryMode('signoz/ListPanel', QueryMode.AI_QUERY_BUILDER),
).toBe(QueryMode.AI_QUERY_BUILDER);
resolveQueryType('signoz/ListPanel', EQueryType.AI_QUERY_BUILDER),
).toBe(EQueryType.AI_QUERY_BUILDER);
});
it('coerces an unsupported mode to the first supported one', () => {
// PromQL → List has no PromQL, falls back to its first (and only) type.
expect(resolveQueryMode('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryMode('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/ListPanel', PROM)).toBe(QUERY_BUILDER);
expect(resolveQueryType('signoz/TablePanel', PROM)).toBe(QUERY_BUILDER);
});
});
});

View File

@@ -1,9 +1,8 @@
import { type TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
import { getPanelDefinition } from './registry';
import { getQueryModeSignals } from './types/panelCapabilities';
import { getQueryTypeSignals } from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { PanelKind } from './types/panelKind';
@@ -50,16 +49,16 @@ export function requireQueryPanelDefinition(
return definition;
}
const DEFAULT_QUERY_MODE = QueryMode.QUERY_BUILDER;
const DEFAULT_QUERY_TYPE = EQueryType.QUERY_BUILDER;
/** Signals a kind can visualize in `mode`. */
/** Signals a kind can visualize in `queryType`. */
export function getSupportedSignals(
kind: PanelKind,
mode: QueryMode = DEFAULT_QUERY_MODE,
queryType: EQueryType = DEFAULT_QUERY_TYPE,
): TelemetrytypesSignalDTO[] {
return getQueryModeSignals(
getQueryPanelDefinition(kind)?.supportedQueryModesKindMap,
mode,
return getQueryTypeSignals(
getQueryPanelDefinition(kind)?.supportedQueryTypesKindMap,
queryType,
);
}
@@ -70,16 +69,11 @@ export function isSignalSupported(
return getSupportedSignals(kind).includes(signal);
}
/** Authoring modes a kind supports, in tab order. */
export function getSupportedQueryModes(kind: PanelKind): QueryMode[] {
const modes = getQueryPanelDefinition(kind)?.supportedQueryModesKindMap ?? {};
return Object.keys(modes) as QueryMode[];
}
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
/** Query types a kind supports, in tab order. */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getSupportedQueryModes(kind).filter(
(mode): mode is EQueryType => mode !== QueryMode.AI_QUERY_BUILDER,
);
const queryTypes =
getQueryPanelDefinition(kind)?.supportedQueryTypesKindMap ?? {};
return Object.keys(queryTypes) as EQueryType[];
}
export function isQueryTypeSupportedByPanelKind(
@@ -116,15 +110,15 @@ export function isPanelCombinationValid({
return true;
}
/** `preferred` if the kind supports it, else its first supported mode (e.g. PromQL → List gives Query Builder). */
export function resolveQueryMode(
/** `preferred` if the kind supports it, else its first supported query type (e.g. PromQL → List gives Query Builder). */
export function resolveQueryType(
kind: PanelKind,
preferred: QueryMode,
): QueryMode {
const supported = getSupportedQueryModes(kind);
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported modes; the builder is the neutral answer.
return supported[0] ?? DEFAULT_QUERY_MODE;
// A query-less kind has no supported query types; the builder is the neutral answer.
return supported[0] ?? DEFAULT_QUERY_TYPE;
}

View File

@@ -1,7 +1,8 @@
import { EQueryType } from 'types/common/dashboard';
import { ChartArea } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import { QueryTypeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -9,7 +10,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
kind: 'signoz/AreaChartPanel',
@@ -19,19 +19,19 @@ export const definition: PanelDefinition<'signoz/AreaChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
supportedQueryTypesKindMap: {
[EQueryType.QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
[EQueryType.CLICKHOUSE]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.PROM]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.AI_QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},

View File

@@ -1,7 +1,8 @@
import { EQueryType } from 'types/common/dashboard';
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import { QueryTypeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -9,7 +10,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
@@ -19,19 +19,19 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
supportedQueryTypesKindMap: {
[EQueryType.QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
[EQueryType.CLICKHOUSE]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.PROM]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.AI_QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},

View File

@@ -1,9 +1,10 @@
import { EQueryType } from 'types/common/dashboard';
import { LayoutGrid } from '@signozhq/icons';
import { QueryBuilderField } from 'components/QueryBuilderV2/queryBuilderFields.types';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import { QueryTypeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -11,7 +12,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
kind: 'signoz/HeatmapPanel',
@@ -21,14 +21,14 @@ export const definition: PanelDefinition<'signoz/HeatmapPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedQueryModesKindMap: {
supportedQueryTypesKindMap: {
// Only metrics carry a bucket axis; the request rejects the other signals.
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
[EQueryType.QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.metrics],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[EQueryType.CLICKHOUSE]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.PROM]: { kind: QueryTypeKind.SIGNAL_LESS },
},
// The request rejects both: a point is a count per bucket, not a single value.
queryBuilderFields: {

View File

@@ -1,7 +1,8 @@
import { EQueryType } from 'types/common/dashboard';
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import { QueryTypeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -9,7 +10,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
@@ -19,19 +19,19 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
supportedQueryTypesKindMap: {
[EQueryType.QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
[EQueryType.CLICKHOUSE]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.PROM]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.AI_QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},

View File

@@ -10,7 +10,6 @@ function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onChangeQueryMode,
onStageRunQuery,
onCancelQuery,
stickyHeader,
@@ -21,7 +20,6 @@ function ListEditorPane({
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
isLoadingQueries={isLoadingQueries}
onChangeQueryMode={onChangeQueryMode}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}

View File

@@ -1,7 +1,8 @@
import { EQueryType } from 'types/common/dashboard';
import { List } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import { QueryTypeKind } from '../../types/panelCapabilities';
import ListEditorPane from './ListEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -9,7 +10,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
@@ -19,13 +19,13 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
supportedQueryTypesKindMap: {
[EQueryType.QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.logs, TelemetrytypesSignalDTO.traces],
},
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
[EQueryType.AI_QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},

View File

@@ -1,7 +1,8 @@
import { EQueryType } from 'types/common/dashboard';
import { Hash } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import { QueryTypeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -9,7 +10,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
@@ -19,19 +19,19 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
supportedQueryTypesKindMap: {
[EQueryType.QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.PROM]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
[EQueryType.CLICKHOUSE]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.PROM]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.AI_QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},

View File

@@ -1,7 +1,8 @@
import { EQueryType } from 'types/common/dashboard';
import { ChartPie } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import { QueryModeKind } from '../../types/panelCapabilities';
import { QueryTypeKind } from '../../types/panelCapabilities';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
@@ -9,7 +10,6 @@ import {
Querybuildertypesv5RequestTypeDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
@@ -19,18 +19,18 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedQueryModesKindMap: {
[QueryMode.QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
supportedQueryTypesKindMap: {
[EQueryType.QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [
TelemetrytypesSignalDTO.metrics,
TelemetrytypesSignalDTO.logs,
TelemetrytypesSignalDTO.traces,
],
},
[QueryMode.CLICKHOUSE]: { kind: QueryModeKind.SIGNAL_LESS },
[QueryMode.AI_QUERY_BUILDER]: {
kind: QueryModeKind.SIGNAL,
[EQueryType.CLICKHOUSE]: { kind: QueryTypeKind.SIGNAL_LESS },
[EQueryType.AI_QUERY_BUILDER]: {
kind: QueryTypeKind.SIGNAL,
signals: [TelemetrytypesSignalDTO.traces],
},
},

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