mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-27 11:30:41 +00:00
Compare commits
8 Commits
feat/scatt
...
fix/compos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dc5a4c006e | ||
|
|
bb2c2382a7 | ||
|
|
9a570b1d31 | ||
|
|
555cb142db | ||
|
|
a6b6655e35 | ||
|
|
330b41f0ed | ||
|
|
e1b717fe36 | ||
|
|
d2565071e1 |
@@ -0,0 +1,134 @@
|
||||
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 { AllTheProviders, renderHook } from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
const UNCONSTRAINED_ROUTE = '/unconstrained';
|
||||
|
||||
const renderOn = (
|
||||
compositeQuery: string,
|
||||
route: string = UNCONSTRAINED_ROUTE,
|
||||
): Query | null => {
|
||||
const search = new URLSearchParams();
|
||||
search.set('compositeQuery', compositeQuery);
|
||||
|
||||
const { result } = renderHook(() => useGetCompositeQueryParam(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={`${route}?${search}`}>
|
||||
{children}
|
||||
</AllTheProviders>
|
||||
),
|
||||
});
|
||||
|
||||
return result.current;
|
||||
};
|
||||
|
||||
const logsWith = (item: object): string =>
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [{ ...initialQueriesMap.logs.builder.queryData[0], ...item }],
|
||||
},
|
||||
});
|
||||
|
||||
const withHavingConverted = (queryData: object[]): object[] =>
|
||||
queryData.map((item) => ({ ...item, having: { expression: '' } }));
|
||||
|
||||
describe('useGetCompositeQueryParam', () => {
|
||||
it('treats a non-object or broken compositeQuery as no query', () => {
|
||||
['[]', '"x"', '5', 'null', '{not json'].forEach((raw) =>
|
||||
expect(renderOn(raw)).toBeNull(),
|
||||
);
|
||||
});
|
||||
|
||||
it('repairs the builder shape and converts the legacy having', () => {
|
||||
const defaultQueryData = withHavingConverted(
|
||||
initialQueryState.builder.queryData,
|
||||
);
|
||||
[{ a: 1 }, [null, initialQueriesMap.logs.builder.queryData[0]]].forEach(
|
||||
(queryData) =>
|
||||
expect(
|
||||
renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData },
|
||||
}),
|
||||
)?.builder.queryData,
|
||||
).toStrictEqual(defaultQueryData),
|
||||
);
|
||||
|
||||
expect(
|
||||
renderOn(logsWith({ having: [null] }))?.builder.queryData[0].having,
|
||||
).toStrictEqual({ expression: '' });
|
||||
expect(renderOn(JSON.stringify(initialQueriesMap.logs))).toStrictEqual({
|
||||
...initialQueriesMap.logs,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: withHavingConverted(initialQueriesMap.logs.builder.queryData),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('gives an id-less query with a filter the same id on every parse', () => {
|
||||
const { id: _, ...withoutId } = JSON.parse(
|
||||
logsWith({ filter: { expression: "service.name='x'" } }),
|
||||
);
|
||||
const first = renderOn(JSON.stringify(withoutId));
|
||||
const again = renderOn(JSON.stringify(withoutId));
|
||||
const other = renderOn(
|
||||
JSON.stringify({ ...withoutId, queryType: EQueryType.PROM }),
|
||||
);
|
||||
|
||||
expect(first?.id).toHaveLength(36);
|
||||
expect(again?.id).toBe(first?.id);
|
||||
expect(other?.id).not.toBe(first?.id);
|
||||
});
|
||||
|
||||
it('applies the page constraints on every constrained route', () => {
|
||||
const clickhouseWithOperator = JSON.stringify({
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
builder: {
|
||||
...initialQueriesMap.logs.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueriesMap.metrics.builder.queryData[0],
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
});
|
||||
|
||||
[
|
||||
[ROUTES.LOGS_EXPLORER, DataSource.LOGS, ''],
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER, DataSource.TRACES, ''],
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER, DataSource.METRICS, ''],
|
||||
[ROUTES.METER_EXPLORER, DataSource.METRICS, TelemetrytypesSourceDTO.meter],
|
||||
].forEach(([route, dataSource, source]) => {
|
||||
const query = renderOn(clickhouseWithOperator, route);
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps the query type on a route without constraints', () => {
|
||||
const query = renderOn(
|
||||
JSON.stringify({
|
||||
...initialQueriesMap.metrics,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(query?.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(query?.builder.queryData[0].dataSource).toBe(DataSource.METRICS);
|
||||
});
|
||||
});
|
||||
54
frontend/src/hooks/queryBuilder/explorerQueryRules.ts
Normal file
54
frontend/src/hooks/queryBuilder/explorerQueryRules.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
export type ExplorerQueryRules = {
|
||||
dataSource: DataSource;
|
||||
/** `meter` on the meter explorer, which shares the metrics signal; '' elsewhere. */
|
||||
source: BuilderQuerySource;
|
||||
builderQueryType: BuilderQueryType;
|
||||
allowsTraceOperator: boolean;
|
||||
};
|
||||
|
||||
const EXPLORER_QUERY_RULES: Partial<Record<string, ExplorerQueryRules>> = {
|
||||
[ROUTES.LOGS_EXPLORER]: {
|
||||
dataSource: DataSource.LOGS,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.TRACES_EXPLORER]: {
|
||||
dataSource: DataSource.TRACES,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: true,
|
||||
},
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
|
||||
dataSource: DataSource.TRACES,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_ai_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
|
||||
dataSource: DataSource.METRICS,
|
||||
source: '',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
[ROUTES.METER_EXPLORER]: {
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
allowsTraceOperator: false,
|
||||
},
|
||||
};
|
||||
|
||||
export function getExplorerQueryRules(
|
||||
pathname: string,
|
||||
): ExplorerQueryRules | undefined {
|
||||
return EXPLORER_QUERY_RULES[pathname];
|
||||
}
|
||||
297
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.test.ts
Normal file
297
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
Query,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { normalizeCompositeQuery } from './normalizeCompositeQuery';
|
||||
|
||||
const NOT_AN_EXPLORER = ROUTES.HOME;
|
||||
|
||||
const logsQuery = initialQueryBuilderFormValuesMap.logs;
|
||||
const tracesQuery = initialQueryBuilderFormValuesMap.traces;
|
||||
const metricsQuery = initialQueryBuilderFormValuesMap.metrics;
|
||||
|
||||
function withBuilderQueries(
|
||||
query: Query,
|
||||
queryData: unknown[],
|
||||
builder: object = {},
|
||||
): Query {
|
||||
return {
|
||||
...query,
|
||||
builder: {
|
||||
...query.builder,
|
||||
queryData: queryData as IBuilderQuery[],
|
||||
...builder,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function firstBuilderQuery(
|
||||
query: Query,
|
||||
queryData: unknown[],
|
||||
pathname: string,
|
||||
): IBuilderQuery {
|
||||
return normalizeCompositeQuery(withBuilderQueries(query, queryData), pathname)
|
||||
.query.builder.queryData[0];
|
||||
}
|
||||
|
||||
function without(builderQuery: object, key: keyof IBuilderQuery): object {
|
||||
const { [key]: _, ...rest } = builderQuery as IBuilderQuery;
|
||||
return rest;
|
||||
}
|
||||
|
||||
const traceOperator = { queryTraceOperator: [tracesQuery] };
|
||||
|
||||
describe('normalizeCompositeQuery', () => {
|
||||
it('leaves complete queries untouched', () => {
|
||||
const clickhouse = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
extra: 'kept',
|
||||
};
|
||||
const aiQuery = withBuilderQueries(initialQueriesMap.traces, [
|
||||
{ ...tracesQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER },
|
||||
]);
|
||||
|
||||
[NOT_AN_EXPLORER, ROUTES.LOGS_EXPLORER].forEach((pathname) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.logs, pathname),
|
||||
).toStrictEqual({ query: initialQueriesMap.logs, changed: false }),
|
||||
);
|
||||
expect(normalizeCompositeQuery(clickhouse, NOT_AN_EXPLORER)).toStrictEqual({
|
||||
query: clickhouse,
|
||||
changed: false,
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(initialQueriesMap.logs, [without(logsQuery, 'source')]),
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
).changed,
|
||||
).toBe(false);
|
||||
expect(
|
||||
normalizeCompositeQuery(aiQuery, ROUTES.AI_OBSERVABILITY_EXPLORER).changed,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('adds missing blocks and a stable id without changing its inputs', () => {
|
||||
const { builder: _, ...builderless } = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
};
|
||||
const urlQueryBefore = JSON.stringify(builderless);
|
||||
const defaultsBefore = JSON.stringify(initialQueryState.builder);
|
||||
const repaired = normalizeCompositeQuery(builderless, NOT_AN_EXPLORER);
|
||||
repaired.query.builder.queryData[0].queryName = 'changed';
|
||||
const {
|
||||
promql: _p,
|
||||
clickhouse_sql: _c,
|
||||
...withoutText
|
||||
} = initialQueriesMap.logs;
|
||||
const withoutLists = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
builder: { queryData: initialQueriesMap.logs.builder.queryData },
|
||||
},
|
||||
NOT_AN_EXPLORER,
|
||||
);
|
||||
const { id: _id, ...withoutId } = initialQueriesMap.logs;
|
||||
const { id } = normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query;
|
||||
|
||||
expect(repaired.query.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(repaired.changed).toBe(true);
|
||||
expect(JSON.stringify(builderless)).toBe(urlQueryBefore);
|
||||
expect(JSON.stringify(initialQueryState.builder)).toBe(defaultsBefore);
|
||||
expect(
|
||||
normalizeCompositeQuery(withoutText, NOT_AN_EXPLORER).query,
|
||||
).toMatchObject({
|
||||
promql: initialQueryState.promql,
|
||||
clickhouse_sql: initialQueryState.clickhouse_sql,
|
||||
});
|
||||
expect(withoutLists.query.builder.queryFormulas).toStrictEqual([]);
|
||||
expect(withoutLists.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(withoutLists.changed).toBe(true);
|
||||
[[], [5, logsQuery]].forEach((queryData) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(initialQueriesMap.logs, queryData),
|
||||
NOT_AN_EXPLORER,
|
||||
).query.builder.queryData,
|
||||
).toStrictEqual(initialQueryState.builder.queryData),
|
||||
);
|
||||
expect(id).toHaveLength(36);
|
||||
expect(normalizeCompositeQuery(withoutId, NOT_AN_EXPLORER).query.id).toBe(id);
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...withoutId, queryType: EQueryType.PROM },
|
||||
NOT_AN_EXPLORER,
|
||||
).query.id,
|
||||
).not.toBe(id);
|
||||
});
|
||||
|
||||
it('switches explorer queries to the query builder and clears their text', () => {
|
||||
const withText = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
|
||||
},
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
);
|
||||
const { builder: _, ...builderless } = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
};
|
||||
|
||||
expect(withText.query).toMatchObject({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
clickhouse_sql: initialQueryState.clickhouse_sql,
|
||||
promql: initialQueryState.promql,
|
||||
});
|
||||
expect(withText.changed).toBe(true);
|
||||
expect(
|
||||
normalizeCompositeQuery(builderless, ROUTES.LOGS_EXPLORER).query,
|
||||
).toMatchObject({
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [{ ...logsQuery, source: '' }] },
|
||||
});
|
||||
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...initialQueriesMap.traces, queryType },
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
).query.queryType,
|
||||
).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets builder queries that do not match the explorer, keeping their names', () => {
|
||||
const logs = initialQueriesMap.logs;
|
||||
const traces = initialQueriesMap.traces;
|
||||
const ai = ROUTES.AI_OBSERVABILITY_EXPLORER;
|
||||
const severityFilter = { filter: { expression: "severity='error'" } };
|
||||
const llmFilter = { filter: { expression: "name='llm'" } };
|
||||
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...tracesQuery, queryName: 'B', expression: 'B', ...llmFilter }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
),
|
||||
).toStrictEqual({
|
||||
...logsQuery,
|
||||
source: '',
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...without(logsQuery, 'dataSource'), ...severityFilter }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
),
|
||||
).toMatchObject({ dataSource: DataSource.LOGS, ...severityFilter });
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
logs,
|
||||
[{ ...logsQuery, builderQueryType: QueryMode.AI_QUERY_BUILDER }],
|
||||
ROUTES.LOGS_EXPLORER,
|
||||
).builderQueryType,
|
||||
).toBeUndefined();
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
traces,
|
||||
[
|
||||
{
|
||||
...tracesQuery,
|
||||
queryName: 'D',
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
...llmFilter,
|
||||
},
|
||||
],
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
),
|
||||
).toStrictEqual({ ...tracesQuery, source: '', queryName: 'D' });
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
initialQueriesMap.metrics,
|
||||
[{ ...without(metricsQuery, 'source'), filter: { expression: "x='y'" } }],
|
||||
ROUTES.METER_EXPLORER,
|
||||
),
|
||||
).toMatchObject({
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
filter: { expression: "x='y'" },
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.metrics, ROUTES.METER_EXPLORER)
|
||||
.query.builder.queryData[0].source,
|
||||
).toBe(TelemetrytypesSourceDTO.meter);
|
||||
expect(
|
||||
firstBuilderQuery(traces, [{ ...tracesQuery, ...llmFilter }], ai),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
...llmFilter,
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(
|
||||
traces,
|
||||
[
|
||||
{
|
||||
...tracesQuery,
|
||||
queryName: 'B',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
...llmFilter,
|
||||
},
|
||||
],
|
||||
ai,
|
||||
),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
queryName: 'B',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
expect(
|
||||
firstBuilderQuery(traces, [{ ...logsQuery, queryName: 'C' }], ai),
|
||||
).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'C',
|
||||
});
|
||||
});
|
||||
|
||||
it('drops a trace operator everywhere but the traces explorer', () => {
|
||||
const withOperator = (
|
||||
query: Query,
|
||||
pathname: string,
|
||||
): ReturnType<typeof normalizeCompositeQuery> =>
|
||||
normalizeCompositeQuery(
|
||||
withBuilderQueries(query, query.builder.queryData, traceOperator),
|
||||
pathname,
|
||||
);
|
||||
const onLogs = withOperator(initialQueriesMap.logs, ROUTES.LOGS_EXPLORER);
|
||||
const onTraces = withOperator(
|
||||
initialQueriesMap.traces,
|
||||
ROUTES.TRACES_EXPLORER,
|
||||
);
|
||||
|
||||
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(onLogs.changed).toBe(true);
|
||||
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
|
||||
expect(onTraces.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
196
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.ts
Normal file
196
frontend/src/hooks/queryBuilder/normalizeCompositeQuery.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, isPlainObject } from 'lodash-es';
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
|
||||
import {
|
||||
ExplorerQueryRules,
|
||||
getExplorerQueryRules,
|
||||
} from './explorerQueryRules';
|
||||
|
||||
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
|
||||
|
||||
export type NormalizedQuery = {
|
||||
query: Query;
|
||||
/** The url query had to be repaired, so the url should be rewritten. */
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Repairs a `compositeQuery` read from the url so the query builder can load it.
|
||||
* Links built by agents can leave out whole blocks, or carry a query the
|
||||
* current explorer cannot run.
|
||||
*/
|
||||
export function normalizeCompositeQuery(
|
||||
urlQuery: object,
|
||||
pathname: string,
|
||||
): NormalizedQuery {
|
||||
const query = cloneDeep(urlQuery) as Query;
|
||||
|
||||
addMissingBlocks(query, urlQuery);
|
||||
|
||||
const rules = getExplorerQueryRules(pathname);
|
||||
if (rules) {
|
||||
fitQueryToExplorer(query, rules);
|
||||
}
|
||||
|
||||
return { query, changed: !isEqual(query, urlQuery) };
|
||||
}
|
||||
|
||||
function addMissingBlocks(query: Query, urlQuery: object): void {
|
||||
if (!query.id) {
|
||||
query.id = getStableId(urlQuery);
|
||||
}
|
||||
|
||||
if (!Object.values(EQueryType).includes(query.queryType)) {
|
||||
query.queryType = EQueryType.QUERY_BUILDER;
|
||||
}
|
||||
|
||||
if (!isListOfObjects(query.promql)) {
|
||||
query.promql = cloneDeep(initialQueryState.promql);
|
||||
}
|
||||
|
||||
if (!isListOfObjects(query.clickhouse_sql)) {
|
||||
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
|
||||
}
|
||||
|
||||
if (!isPlainObject(query.builder)) {
|
||||
query.builder = cloneDeep(initialQueryState.builder);
|
||||
}
|
||||
|
||||
const { queryData } = query.builder;
|
||||
if (!isListOfObjects(queryData) || queryData.length === 0) {
|
||||
query.builder.queryData = cloneDeep(initialQueryState.builder.queryData);
|
||||
}
|
||||
|
||||
if (!query.builder.queryFormulas) {
|
||||
query.builder.queryFormulas = [];
|
||||
}
|
||||
|
||||
if (!query.builder.queryTraceOperator) {
|
||||
query.builder.queryTraceOperator = [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The provider matches the staged query to the url query by id, so a link
|
||||
* without an id must get the same one on every read.
|
||||
*/
|
||||
function getStableId(urlQuery: object): string {
|
||||
return uuidv5(JSON.stringify(urlQuery), COMPOSITE_QUERY_ID_NAMESPACE);
|
||||
}
|
||||
|
||||
function isListOfObjects(value: unknown): boolean {
|
||||
return Array.isArray(value) && value.every((item) => isPlainObject(item));
|
||||
}
|
||||
|
||||
function fitQueryToExplorer(query: Query, rules: ExplorerQueryRules): void {
|
||||
// Explorers only have the query builder: no PromQL or ClickHouse editor.
|
||||
query.queryType = EQueryType.QUERY_BUILDER;
|
||||
|
||||
if (query.promql.some((promql) => promql.query !== '')) {
|
||||
query.promql = cloneDeep(initialQueryState.promql);
|
||||
}
|
||||
|
||||
if (query.clickhouse_sql.some((clickhouse) => clickhouse.query !== '')) {
|
||||
query.clickhouse_sql = cloneDeep(initialQueryState.clickhouse_sql);
|
||||
}
|
||||
|
||||
if (!rules.allowsTraceOperator) {
|
||||
query.builder.queryTraceOperator = [];
|
||||
}
|
||||
|
||||
query.builder.queryData = query.builder.queryData.map((builderQuery) =>
|
||||
fitBuilderQueryToExplorer(builderQuery, rules),
|
||||
);
|
||||
}
|
||||
|
||||
function fitBuilderQueryToExplorer(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): IBuilderQuery {
|
||||
fillMissingSignal(builderQuery, rules);
|
||||
|
||||
if (belongsToExplorer(builderQuery, rules)) {
|
||||
return builderQuery;
|
||||
}
|
||||
|
||||
const replacement = getDefaultBuilderQuery(rules);
|
||||
// Keep the name, so formulas that reference this query still resolve.
|
||||
replacement.queryName = builderQuery.queryName ?? replacement.queryName;
|
||||
replacement.expression = builderQuery.expression ?? replacement.expression;
|
||||
|
||||
return replacement;
|
||||
}
|
||||
|
||||
/** A link may leave out the fields that pick the signal: read them as the explorer's. */
|
||||
function fillMissingSignal(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): void {
|
||||
if (builderQuery.dataSource === undefined) {
|
||||
builderQuery.dataSource = rules.dataSource;
|
||||
}
|
||||
|
||||
// A missing `source` already means '', so only meter needs it written.
|
||||
if (
|
||||
builderQuery.source === undefined &&
|
||||
rules.source === TelemetrytypesSourceDTO.meter
|
||||
) {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
function belongsToExplorer(
|
||||
builderQuery: IBuilderQuery,
|
||||
rules: ExplorerQueryRules,
|
||||
): boolean {
|
||||
if (builderQuery.dataSource !== rules.dataSource) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((builderQuery.source ?? '') !== rules.source) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const builderQueryType =
|
||||
builderQuery.builderQueryType ??
|
||||
Querybuildertypesv5QueryTypeDTO.builder_query;
|
||||
|
||||
if (builderQueryType !== rules.builderQueryType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getDefaultBuilderQuery(rules: ExplorerQueryRules): IBuilderQuery {
|
||||
const builderQuery = cloneDeep(
|
||||
initialQueryBuilderFormValuesMap[rules.dataSource],
|
||||
);
|
||||
builderQuery.source = rules.source;
|
||||
|
||||
if (
|
||||
rules.builderQueryType === Querybuildertypesv5QueryTypeDTO.builder_ai_query
|
||||
) {
|
||||
builderQuery.builderQueryType = rules.builderQueryType;
|
||||
}
|
||||
|
||||
return builderQuery;
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
@@ -6,15 +7,23 @@ import {
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isPlainObject } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null => {
|
||||
import {
|
||||
NormalizedQuery,
|
||||
normalizeCompositeQuery,
|
||||
} from './normalizeCompositeQuery';
|
||||
|
||||
export const useNormalizedCompositeQueryParam = (): NormalizedQuery | null => {
|
||||
const urlQuery = useUrlQuery();
|
||||
const { pathname } = useLocation();
|
||||
|
||||
return useMemo(() => {
|
||||
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
|
||||
let parsedCompositeQuery: Query | null = null;
|
||||
let changed = false;
|
||||
|
||||
try {
|
||||
if (!compositeQuery) {
|
||||
@@ -23,18 +32,18 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// MDN reference - https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/decodeURIComponent#decoding_query_parameters_from_a_url
|
||||
// MDN reference to support + characters using encoding - https://developer.mozilla.org/en-US/docs/Web/API/URLSearchParams#preserving_plus_signs add later
|
||||
parsedCompositeQuery = JSON.parse(
|
||||
const rawQuery: unknown = JSON.parse(
|
||||
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
|
||||
);
|
||||
|
||||
// Add default values for optional fields if empty
|
||||
if (parsedCompositeQuery?.builder) {
|
||||
parsedCompositeQuery.builder.queryFormulas =
|
||||
parsedCompositeQuery.builder.queryFormulas ?? [];
|
||||
parsedCompositeQuery.builder.queryTraceOperator =
|
||||
parsedCompositeQuery.builder.queryTraceOperator ?? [];
|
||||
if (!isPlainObject(rawQuery)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
|
||||
rawQuery as object,
|
||||
pathname,
|
||||
));
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
parsedCompositeQuery.builder.queryData =
|
||||
@@ -51,7 +60,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
|
||||
// Convert having if needed
|
||||
if (Array.isArray(query.having)) {
|
||||
const convertedHaving = convertHavingToExpression(query.having);
|
||||
const convertedHaving = convertHavingToExpression(
|
||||
query.having.filter(isPlainObject),
|
||||
);
|
||||
convertedQuery.having = convertedHaving;
|
||||
}
|
||||
|
||||
@@ -75,6 +86,9 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
parsedCompositeQuery = null;
|
||||
}
|
||||
|
||||
return parsedCompositeQuery;
|
||||
}, [urlQuery]);
|
||||
return parsedCompositeQuery ? { query: parsedCompositeQuery, changed } : null;
|
||||
}, [urlQuery, pathname]);
|
||||
};
|
||||
|
||||
export const useGetCompositeQueryParam = (): Query | null =>
|
||||
useNormalizedCompositeQueryParam()?.query ?? null;
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
export function replaceIncorrectObjectFields<
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
TargetValue extends object,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
ResultValue extends object,
|
||||
>(
|
||||
targetObject: TargetValue,
|
||||
defaultObject: ResultValue,
|
||||
): { isValid: boolean; validData: ResultValue } {
|
||||
const targetObjectKeys = Object.keys(targetObject);
|
||||
const defaultObjectKeys = Object.keys(defaultObject);
|
||||
|
||||
let isValid = true;
|
||||
|
||||
const result: ResultValue = { ...defaultObject };
|
||||
|
||||
defaultObjectKeys.forEach((key) => {
|
||||
if (targetObjectKeys.includes(key)) {
|
||||
result[key as keyof ResultValue] = targetObject[
|
||||
key as keyof TargetValue
|
||||
] as unknown as ResultValue[keyof ResultValue];
|
||||
} else {
|
||||
isValid = false;
|
||||
}
|
||||
});
|
||||
|
||||
return { isValid, validData: result };
|
||||
}
|
||||
@@ -34,14 +34,13 @@ import {
|
||||
PartialPanelTypes,
|
||||
} from 'lib/query/panelTypeDataSourceFormValuesMap';
|
||||
import { OptionsQuery } from 'container/OptionsMenu/types';
|
||||
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { useNormalizedCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
|
||||
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
|
||||
import { createNewBuilderItemName } from 'lib/newQueryBuilder/createNewBuilderItemName';
|
||||
import { getOperatorsBySourceAndPanelType } from 'lib/newQueryBuilder/getOperatorsBySourceAndPanelType';
|
||||
import { saveRecentQuery } from 'lib/recentQueries/saveRecentQuery';
|
||||
import { replaceIncorrectObjectFields } from 'lib/replaceIncorrectObjectFields';
|
||||
import { cloneDeep, get, isEqual, set } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
// ** Types
|
||||
@@ -114,7 +113,8 @@ export function QueryBuilderProvider({
|
||||
const [calledFromHandleRunQuery, setCalledFromHandleRunQuery] =
|
||||
useState<boolean>(false);
|
||||
|
||||
const compositeQueryParam = useGetCompositeQueryParam();
|
||||
const normalizedCompositeQuery = useNormalizedCompositeQueryParam();
|
||||
const compositeQueryParam = normalizedCompositeQuery?.query ?? null;
|
||||
const { queryType: queryTypeParam, ...queryState } =
|
||||
compositeQueryParam || initialQueriesMap.metrics;
|
||||
|
||||
@@ -1115,20 +1115,22 @@ export function QueryBuilderProvider({
|
||||
return;
|
||||
}
|
||||
|
||||
const { isValid, validData } = replaceIncorrectObjectFields(
|
||||
compositeQueryParam,
|
||||
initialQueriesMap.metrics,
|
||||
);
|
||||
|
||||
if (!isValid) {
|
||||
redirectWithQueryBuilderData(validData);
|
||||
} else {
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
if (normalizedCompositeQuery?.changed) {
|
||||
const urlQuery = getUnstableCurrentSearchParams();
|
||||
urlQuery.set(
|
||||
QueryParams.compositeQuery,
|
||||
encodeURIComponent(JSON.stringify(compositeQueryParam)),
|
||||
);
|
||||
safeNavigate(`${location.pathname}?${urlQuery}`, { replace: true });
|
||||
return;
|
||||
}
|
||||
|
||||
initQueryBuilderData(compositeQueryParam);
|
||||
}
|
||||
}, [
|
||||
initQueryBuilderData,
|
||||
redirectWithQueryBuilderData,
|
||||
safeNavigate,
|
||||
normalizedCompositeQuery,
|
||||
compositeQueryParam,
|
||||
stagedQuery,
|
||||
location.pathname,
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import {
|
||||
act,
|
||||
AllTheProviders,
|
||||
renderHook,
|
||||
RenderHookResult,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () =>
|
||||
jest
|
||||
.requireActual('tests/browser-history-safe-navigate')
|
||||
.createBrowserHistorySafeNavigateMock(),
|
||||
);
|
||||
|
||||
const builderlessUrlQuery = {
|
||||
queryType: 'clickhouse_sql',
|
||||
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
|
||||
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
|
||||
id: 'b8084e0c-c13d-4172-a775-f61aedadb79e',
|
||||
unit: '',
|
||||
};
|
||||
|
||||
const clickhouseUrlQuery = {
|
||||
...builderlessUrlQuery,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
};
|
||||
|
||||
const tracesWithOperator = {
|
||||
...initialQueriesMap.traces,
|
||||
builder: {
|
||||
...initialQueriesMap.traces.builder,
|
||||
queryTraceOperator: [initialQueriesMap.traces.builder.queryData[0]],
|
||||
},
|
||||
};
|
||||
|
||||
const urlQuery = (): Query =>
|
||||
JSON.parse(
|
||||
decodeURIComponent(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery') ?? '',
|
||||
),
|
||||
);
|
||||
|
||||
const activeLogIdParam = (): string | null =>
|
||||
new URLSearchParams(window.location.search).get('activeLogId');
|
||||
|
||||
const renderOn = (
|
||||
route: string,
|
||||
compositeQuery: object,
|
||||
params: Record<string, string> = {},
|
||||
): RenderHookResult<QueryBuilderContextType, unknown> => {
|
||||
const search = new URLSearchParams(params);
|
||||
search.set('compositeQuery', JSON.stringify(compositeQuery));
|
||||
const url = `${route}?${search}`;
|
||||
window.history.replaceState(null, '', url);
|
||||
|
||||
return renderHook(() => useQueryBuilder(), {
|
||||
wrapper: ({ children }): JSX.Element => (
|
||||
<AllTheProviders initialRoute={url}>{children}</AllTheProviders>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
describe('QueryBuilderProvider on a url compositeQuery without builder', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds supersetQuery with a builder so the first edit does not throw', () => {
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, builderlessUrlQuery);
|
||||
|
||||
expect(result.current.supersetQuery.builder).toBeDefined();
|
||||
|
||||
act(() => {
|
||||
result.current.handleSetQueryData(0, {
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
limit: 10,
|
||||
});
|
||||
});
|
||||
|
||||
expect(result.current.supersetQuery.builder.queryData[0].limit).toBe(10);
|
||||
expect(result.current.currentQuery.builder.queryData[0].limit).toBe(10);
|
||||
});
|
||||
});
|
||||
|
||||
describe('QueryBuilderProvider with page constraints', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('seeds and stages the logs query and rewrites only compositeQuery', async () => {
|
||||
const replaceState = jest.spyOn(window.history, 'replaceState');
|
||||
const pushState = jest.spyOn(window.history, 'pushState');
|
||||
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, clickhouseUrlQuery, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(result.current.supersetQuery.builder.queryData[0].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
expect(result.current.stagedQuery?.clickhouse_sql[0].query).toBe('');
|
||||
|
||||
expect(urlQuery().queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(urlQuery().builder.queryData[0].dataSource).toBe(DataSource.LOGS);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('');
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
expect(replaceState).toHaveBeenCalled();
|
||||
expect(pushState).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps a trace operator on the traces explorer', async () => {
|
||||
const { result } = renderOn(ROUTES.TRACES_EXPLORER, tracesWithOperator);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.builder.queryTraceOperator).toHaveLength(
|
||||
1,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves a valid url without source untouched and keeps activeLogId', async () => {
|
||||
const { source: _, ...item } = initialQueriesMap.logs.builder.queryData[0];
|
||||
const query = {
|
||||
...initialQueriesMap.logs,
|
||||
builder: { ...initialQueriesMap.logs.builder, queryData: [item] },
|
||||
};
|
||||
const { result } = renderOn(ROUTES.LOGS_EXPLORER, query, {
|
||||
activeLogId: '"log-1"',
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.id).toBe(initialQueriesMap.logs.id),
|
||||
);
|
||||
expect(
|
||||
new URLSearchParams(window.location.search).get('compositeQuery'),
|
||||
).toBe(JSON.stringify(query));
|
||||
expect(activeLogIdParam()).toBe('"log-1"');
|
||||
});
|
||||
|
||||
it('leaves a clickhouse query alone on a route without constraints', async () => {
|
||||
const { result } = renderOn('/unconstrained', clickhouseUrlQuery);
|
||||
|
||||
expect(result.current.currentQuery.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
await waitFor(() =>
|
||||
expect(result.current.stagedQuery?.queryType).toBe(EQueryType.CLICKHOUSE),
|
||||
);
|
||||
expect(urlQuery().clickhouse_sql[0].query).toBe('SELECT 1');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user