mirror of
https://github.com/SigNoz/signoz.git
synced 2026-10-26 11:00:42 +00:00
Compare commits
6 Commits
issue_nerv
...
fix/compos
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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);
|
||||
});
|
||||
});
|
||||
297
frontend/src/hooks/queryBuilder/compositeQuerySchema.test.ts
Normal file
297
frontend/src/hooks/queryBuilder/compositeQuerySchema.test.ts
Normal file
@@ -0,0 +1,297 @@
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import {
|
||||
Querybuildertypesv5QueryTypeDTO,
|
||||
TelemetrytypesSourceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
QueryMode,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import {
|
||||
buildPageQuerySchema,
|
||||
normalizeCompositeQuery,
|
||||
} from './compositeQuerySchema';
|
||||
|
||||
const logsSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.LOGS,
|
||||
});
|
||||
const tracesSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
traceOperator: true,
|
||||
});
|
||||
const meterSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
});
|
||||
const aiSchema = buildPageQuerySchema({
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
});
|
||||
|
||||
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 logsItem = (overrides: object = {}): object => ({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const queryOf = (
|
||||
base: typeof initialQueriesMap.logs,
|
||||
queryData: unknown[],
|
||||
builder: object = {},
|
||||
): object => ({
|
||||
...base,
|
||||
builder: { ...base.builder, queryData, ...builder },
|
||||
});
|
||||
|
||||
const logsQuery = (queryData: unknown[], builder?: object): object =>
|
||||
queryOf(initialQueriesMap.logs, queryData, builder);
|
||||
|
||||
const without = (item: object, key: keyof IBuilderQuery): object => {
|
||||
const { [key]: _, ...rest } = item as IBuilderQuery;
|
||||
return rest;
|
||||
};
|
||||
|
||||
describe('normalizeCompositeQuery', () => {
|
||||
it('passes valid queries through unchanged', () => {
|
||||
const withoutSource = logsQuery([
|
||||
without(initialQueriesMap.logs.builder.queryData[0], 'source'),
|
||||
]);
|
||||
const clickhouse = {
|
||||
...initialQueriesMap.logs,
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
};
|
||||
const unmodeled = normalizeCompositeQuery({
|
||||
...logsQuery([logsItem({ builderQueryType: QueryMode.AI_QUERY_BUILDER })]),
|
||||
extra: 'kept',
|
||||
}).query;
|
||||
|
||||
expect(normalizeCompositeQuery(initialQueriesMap.logs)).toStrictEqual({
|
||||
query: initialQueriesMap.logs,
|
||||
changed: false,
|
||||
});
|
||||
expect(
|
||||
normalizeCompositeQuery(initialQueriesMap.logs, logsSchema),
|
||||
).toStrictEqual({ query: initialQueriesMap.logs, changed: false });
|
||||
expect(normalizeCompositeQuery(withoutSource, logsSchema).changed).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
clickhouse,
|
||||
buildPageQuerySchema({ dataSource: DataSource.LOGS }),
|
||||
),
|
||||
).toStrictEqual({ query: clickhouse, changed: false });
|
||||
expect(unmodeled.builder.queryData[0].builderQueryType).toBe(
|
||||
QueryMode.AI_QUERY_BUILDER,
|
||||
);
|
||||
expect((unmodeled as { extra?: string }).extra).toBe('kept');
|
||||
});
|
||||
|
||||
it('fills missing blocks with defaults and a stable id', () => {
|
||||
const sharedBefore = JSON.stringify(initialQueryState.builder);
|
||||
const builderless = normalizeCompositeQuery(builderlessUrlQuery);
|
||||
builderless.query.builder.queryFormulas.push(
|
||||
{} as (typeof builderless.query.builder.queryFormulas)[number],
|
||||
);
|
||||
const noArrays = normalizeCompositeQuery({
|
||||
...initialQueriesMap.logs,
|
||||
builder: { queryData: initialQueriesMap.logs.builder.queryData },
|
||||
});
|
||||
const omittedBlocks = normalizeCompositeQuery(
|
||||
{
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: { queryData: [logsItem()], queryFormulas: [] },
|
||||
},
|
||||
logsSchema,
|
||||
);
|
||||
const { id: _, ...withoutId } = initialQueriesMap.logs;
|
||||
const id = normalizeCompositeQuery(withoutId).query.id;
|
||||
|
||||
expect(builderless.query.queryType).toBe(EQueryType.CLICKHOUSE);
|
||||
expect(builderless.query.id).toBe(builderlessUrlQuery.id);
|
||||
expect(builderless.changed).toBe(true);
|
||||
expect(JSON.stringify(initialQueryState.builder)).toBe(sharedBefore);
|
||||
expect(noArrays.query.builder.queryFormulas).toStrictEqual([]);
|
||||
expect(noArrays.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(noArrays.changed).toBe(true);
|
||||
[[], [5, logsItem({ queryName: 'B' })]].forEach((queryData) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(logsQuery(queryData)).query.builder.queryData,
|
||||
).toStrictEqual(initialQueryState.builder.queryData),
|
||||
);
|
||||
expect(omittedBlocks.query.promql).toStrictEqual(initialQueryState.promql);
|
||||
expect(omittedBlocks.query.clickhouse_sql).toStrictEqual(
|
||||
initialQueryState.clickhouse_sql,
|
||||
);
|
||||
expect(omittedBlocks.query.builder.queryData[0].dataSource).toBe(
|
||||
DataSource.LOGS,
|
||||
);
|
||||
expect(id).toHaveLength(36);
|
||||
expect(normalizeCompositeQuery(withoutId).query.id).toBe(id);
|
||||
expect(
|
||||
normalizeCompositeQuery({ ...withoutId, queryType: EQueryType.PROM }).query
|
||||
.id,
|
||||
).not.toBe(id);
|
||||
});
|
||||
|
||||
it('resets query types and text the page cannot run', () => {
|
||||
const builderless = normalizeCompositeQuery(builderlessUrlQuery, logsSchema);
|
||||
const withText = normalizeCompositeQuery(
|
||||
{
|
||||
...initialQueriesMap.logs,
|
||||
clickhouse_sql: [
|
||||
{ name: 'A', legend: '', disabled: false, query: 'SELECT 1' },
|
||||
],
|
||||
promql: [{ name: 'A', legend: '', disabled: false, query: 'up' }],
|
||||
},
|
||||
logsSchema,
|
||||
);
|
||||
|
||||
expect(builderless.query.queryType).toBe(EQueryType.QUERY_BUILDER);
|
||||
expect(builderless.query.builder.queryData).toStrictEqual([
|
||||
{ ...initialQueryBuilderFormValuesMap.logs, source: '' },
|
||||
]);
|
||||
expect(withText.query.clickhouse_sql).toStrictEqual(
|
||||
initialQueryState.clickhouse_sql,
|
||||
);
|
||||
expect(withText.query.promql).toStrictEqual(initialQueryState.promql);
|
||||
expect(withText.changed).toBe(true);
|
||||
[EQueryType.CLICKHOUSE, EQueryType.PROM].forEach((queryType) =>
|
||||
expect(
|
||||
normalizeCompositeQuery(
|
||||
{ ...initialQueriesMap.traces, queryType },
|
||||
tracesSchema,
|
||||
).query.queryType,
|
||||
).toBe(EQueryType.QUERY_BUILDER),
|
||||
);
|
||||
});
|
||||
|
||||
it('resets items that do not match the page and keeps their names', () => {
|
||||
const mixed = normalizeCompositeQuery(
|
||||
logsQuery([
|
||||
logsItem({
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
filter: { expression: "name='GET /api'" },
|
||||
}),
|
||||
without(
|
||||
logsItem({ filter: { expression: "severity='error'" } }),
|
||||
'dataSource',
|
||||
),
|
||||
]),
|
||||
logsSchema,
|
||||
).query.builder.queryData;
|
||||
const meterFilled = normalizeCompositeQuery(
|
||||
queryOf(initialQueriesMap.metrics, [
|
||||
{
|
||||
...without(initialQueriesMap.metrics.builder.queryData[0], 'source'),
|
||||
filter: { expression: "x='y'" },
|
||||
},
|
||||
]),
|
||||
meterSchema,
|
||||
).query.builder.queryData[0];
|
||||
const metricsOnMeter = normalizeCompositeQuery(
|
||||
initialQueriesMap.metrics,
|
||||
meterSchema,
|
||||
);
|
||||
const tracesItem = {
|
||||
...initialQueryBuilderFormValuesMap.traces,
|
||||
filter: { expression: "name='llm'" },
|
||||
};
|
||||
const [aiMissing, aiWrong, aiLogs] = [
|
||||
[tracesItem],
|
||||
[
|
||||
{
|
||||
...tracesItem,
|
||||
queryName: 'B',
|
||||
builderQueryType: Querybuildertypesv5QueryTypeDTO.builder_query,
|
||||
},
|
||||
],
|
||||
[logsItem({ queryName: 'C' })],
|
||||
].map(
|
||||
(queryData) =>
|
||||
normalizeCompositeQuery(
|
||||
queryOf(initialQueriesMap.traces, queryData),
|
||||
aiSchema,
|
||||
).query.builder.queryData[0],
|
||||
);
|
||||
|
||||
expect(mixed[0]).toStrictEqual({
|
||||
...initialQueryBuilderFormValuesMap.logs,
|
||||
source: '',
|
||||
queryName: 'B',
|
||||
expression: 'B',
|
||||
});
|
||||
expect(mixed[1]).toMatchObject({
|
||||
dataSource: DataSource.LOGS,
|
||||
filter: { expression: "severity='error'" },
|
||||
});
|
||||
expect(meterFilled).toMatchObject({
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
filter: { expression: "x='y'" },
|
||||
});
|
||||
expect(metricsOnMeter.query.builder.queryData[0].source).toBe(
|
||||
TelemetrytypesSourceDTO.meter,
|
||||
);
|
||||
expect(metricsOnMeter.changed).toBe(true);
|
||||
expect(aiMissing).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
filter: { expression: "name='llm'" },
|
||||
});
|
||||
expect(aiWrong).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
queryName: 'B',
|
||||
filter: { expression: '' },
|
||||
});
|
||||
expect(aiLogs).toMatchObject({
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'C',
|
||||
});
|
||||
});
|
||||
|
||||
it('clears a trace operator unless the page allows it', () => {
|
||||
const operator = {
|
||||
queryTraceOperator: [initialQueryBuilderFormValuesMap.traces],
|
||||
};
|
||||
const onLogs = normalizeCompositeQuery(
|
||||
logsQuery([logsItem()], operator),
|
||||
logsSchema,
|
||||
);
|
||||
const onTraces = normalizeCompositeQuery(
|
||||
queryOf(
|
||||
initialQueriesMap.traces,
|
||||
initialQueriesMap.traces.builder.queryData,
|
||||
operator,
|
||||
),
|
||||
tracesSchema,
|
||||
);
|
||||
|
||||
expect(onLogs.query.builder.queryTraceOperator).toStrictEqual([]);
|
||||
expect(onLogs.changed).toBe(true);
|
||||
expect(onTraces.query.builder.queryTraceOperator).toHaveLength(1);
|
||||
expect(onTraces.changed).toBe(false);
|
||||
});
|
||||
});
|
||||
134
frontend/src/hooks/queryBuilder/compositeQuerySchema.ts
Normal file
134
frontend/src/hooks/queryBuilder/compositeQuerySchema.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import { cloneDeep, isEqual, omitBy } from 'lodash-es';
|
||||
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { BuilderQueryType } from 'types/api/v5/queryRange';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { BuilderQuerySource, DataSource } from 'types/common/queryBuilder';
|
||||
import { v5 as uuidv5 } from 'uuid';
|
||||
import { z } from 'zod';
|
||||
|
||||
const COMPOSITE_QUERY_ID_NAMESPACE = '2f1c6a4e-9b3d-4c57-8e21-7a0d5f6b9c13';
|
||||
|
||||
export type QueryConstraints = {
|
||||
dataSource: DataSource;
|
||||
source?: BuilderQuerySource;
|
||||
builderQueryType?: BuilderQueryType;
|
||||
traceOperator?: boolean;
|
||||
queryTypes?: EQueryType[];
|
||||
};
|
||||
|
||||
export type NormalizedQuery = {
|
||||
query: Query;
|
||||
changed: boolean;
|
||||
};
|
||||
|
||||
const fallback = (value: unknown) => (): never => cloneDeep(value) as never;
|
||||
|
||||
const builderItemSchema = z
|
||||
.looseObject({
|
||||
dataSource: z.enum(DataSource).optional().catch(undefined),
|
||||
source: z.string().optional().catch(undefined),
|
||||
})
|
||||
.transform((item) => omitBy(item, (value) => value === undefined));
|
||||
|
||||
const textItemSchema = z.looseObject({ query: z.string().catch('') });
|
||||
|
||||
const builderSchema = z.looseObject({
|
||||
queryData: z
|
||||
.array(builderItemSchema)
|
||||
.min(1)
|
||||
.catch(fallback(initialQueryState.builder.queryData)),
|
||||
queryFormulas: z.unknown().transform((value) => value ?? []),
|
||||
queryTraceOperator: z.unknown().transform((value) => value ?? []),
|
||||
});
|
||||
|
||||
const compositeQuerySchema = z.looseObject({
|
||||
queryType: z.enum(EQueryType).catch(fallback(EQueryType.QUERY_BUILDER)),
|
||||
builder: builderSchema.catch(fallback(initialQueryState.builder)),
|
||||
promql: z.array(textItemSchema).catch(fallback(initialQueryState.promql)),
|
||||
clickhouse_sql: z
|
||||
.array(textItemSchema)
|
||||
.catch(fallback(initialQueryState.clickhouse_sql)),
|
||||
});
|
||||
|
||||
const mustBeEmpty = (value: unknown): z.ZodType =>
|
||||
z.array(z.looseObject({ query: z.literal('') })).catch(fallback(value));
|
||||
|
||||
export function buildPageQuerySchema(
|
||||
constraints: QueryConstraints,
|
||||
): z.ZodType<unknown> {
|
||||
const { queryTypes } = constraints;
|
||||
const itemDefault: IBuilderQuery = {
|
||||
...initialQueryBuilderFormValuesMap[constraints.dataSource],
|
||||
source: constraints.source ?? '',
|
||||
...(constraints.builderQueryType && {
|
||||
builderQueryType: constraints.builderQueryType,
|
||||
}),
|
||||
};
|
||||
const allows = (type: EQueryType): boolean =>
|
||||
!queryTypes || queryTypes.includes(type);
|
||||
|
||||
const pageItemSchema = builderItemSchema
|
||||
.transform((item) => ({
|
||||
...item,
|
||||
dataSource: item.dataSource ?? constraints.dataSource,
|
||||
...(constraints.source && { source: item.source ?? constraints.source }),
|
||||
...(constraints.builderQueryType && {
|
||||
builderQueryType: item.builderQueryType ?? constraints.builderQueryType,
|
||||
}),
|
||||
}))
|
||||
.refine(
|
||||
(item) =>
|
||||
item.dataSource === constraints.dataSource &&
|
||||
(item.source ?? '') === (constraints.source ?? '') &&
|
||||
(!constraints.builderQueryType ||
|
||||
item.builderQueryType === constraints.builderQueryType),
|
||||
)
|
||||
.catch(({ input }): never => {
|
||||
const { queryName, expression } = input as Partial<IBuilderQuery>;
|
||||
return {
|
||||
...cloneDeep(itemDefault),
|
||||
queryName: queryName ?? itemDefault.queryName,
|
||||
expression: expression ?? itemDefault.expression,
|
||||
} as never;
|
||||
});
|
||||
|
||||
return compositeQuerySchema.extend({
|
||||
queryType: queryTypes
|
||||
? z
|
||||
.enum(EQueryType)
|
||||
.refine((type) => queryTypes.includes(type))
|
||||
.catch(fallback(queryTypes[0]))
|
||||
: compositeQuerySchema.shape.queryType,
|
||||
promql: allows(EQueryType.PROM)
|
||||
? compositeQuerySchema.shape.promql
|
||||
: mustBeEmpty(initialQueryState.promql),
|
||||
clickhouse_sql: allows(EQueryType.CLICKHOUSE)
|
||||
? compositeQuerySchema.shape.clickhouse_sql
|
||||
: mustBeEmpty(initialQueryState.clickhouse_sql),
|
||||
builder: builderSchema
|
||||
.extend({
|
||||
queryData: z
|
||||
.array(pageItemSchema)
|
||||
.min(1)
|
||||
.catch(fallback([itemDefault])),
|
||||
queryTraceOperator: constraints.traceOperator
|
||||
? builderSchema.shape.queryTraceOperator
|
||||
: z.array(z.never()).catch(fallback([])),
|
||||
})
|
||||
.catch(fallback({ ...initialQueryState.builder, queryData: [itemDefault] })),
|
||||
});
|
||||
}
|
||||
|
||||
export function normalizeCompositeQuery(
|
||||
raw: unknown,
|
||||
pageSchema?: z.ZodType<unknown>,
|
||||
): NormalizedQuery {
|
||||
const base = compositeQuerySchema.parse(raw) as unknown as Query;
|
||||
base.id ??= uuidv5(JSON.stringify(raw), COMPOSITE_QUERY_ID_NAMESPACE);
|
||||
const query = (pageSchema ? pageSchema.parse(base) : base) as Query;
|
||||
return { query, changed: !isEqual(query, raw) };
|
||||
}
|
||||
45
frontend/src/hooks/queryBuilder/pageQueryConstraints.ts
Normal file
45
frontend/src/hooks/queryBuilder/pageQueryConstraints.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { TelemetrytypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { QueryMode } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { buildPageQuerySchema, QueryConstraints } from './compositeQuerySchema';
|
||||
|
||||
const PAGE_QUERY_CONSTRAINTS: Partial<Record<string, QueryConstraints>> = {
|
||||
[ROUTES.LOGS_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.LOGS,
|
||||
},
|
||||
[ROUTES.TRACES_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
traceOperator: true,
|
||||
},
|
||||
[ROUTES.AI_OBSERVABILITY_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.TRACES,
|
||||
builderQueryType: QueryMode.AI_QUERY_BUILDER,
|
||||
},
|
||||
[ROUTES.METRICS_EXPLORER_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
},
|
||||
[ROUTES.METER_EXPLORER]: {
|
||||
queryTypes: [EQueryType.QUERY_BUILDER],
|
||||
dataSource: DataSource.METRICS,
|
||||
source: TelemetrytypesSourceDTO.meter,
|
||||
},
|
||||
};
|
||||
|
||||
const pageSchemas = new Map<string, z.ZodType<unknown>>(
|
||||
Object.entries(PAGE_QUERY_CONSTRAINTS).map(([route, constraints]) => [
|
||||
route,
|
||||
buildPageQuerySchema(constraints as QueryConstraints),
|
||||
]),
|
||||
);
|
||||
|
||||
export const pageQuerySchemaFor = (
|
||||
pathname: string,
|
||||
): z.ZodType<unknown> | undefined => pageSchemas.get(pathname);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import {
|
||||
convertAggregationToExpression,
|
||||
convertFiltersToExpressionWithExistingQuery,
|
||||
@@ -6,15 +7,24 @@ 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 './compositeQuerySchema';
|
||||
import { pageQuerySchemaFor } from './pageQueryConstraints';
|
||||
|
||||
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) {
|
||||
@@ -27,13 +37,10 @@ export const useGetCompositeQueryParam = (): Query | null => {
|
||||
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 ?? [];
|
||||
}
|
||||
({ query: parsedCompositeQuery, changed } = normalizeCompositeQuery(
|
||||
parsedCompositeQuery,
|
||||
pageQuerySchemaFor(pathname),
|
||||
));
|
||||
|
||||
// Convert old format to new format for each query in builder.queryData
|
||||
if (parsedCompositeQuery?.builder?.queryData) {
|
||||
@@ -51,7 +58,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 +84,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