Compare commits

...

8 Commits

Author SHA1 Message Date
aks07
9a570b1d31 test(query-builder): limit normalize tests to focused cases 2026-10-26 13:27:25 +05:30
aks07
555cb142db fix(query-builder): keep url params when repairing compositeQuery 2026-10-26 13:27:25 +05:30
Aditya Singh
a6b6655e35 Merge branch 'main' into fix/composite-query-normalize 2026-10-26 12:35:01 +05:30
aks07
330b41f0ed feat(query-builder): add query constraints for the ai, metrics and meter explorers 2026-10-26 12:23:32 +05:30
aks07
e1b717fe36 fix(query-builder): seed state from the normalized url query 2026-10-26 11:37:05 +05:30
aks07
d2565071e1 feat(query-builder): normalize url compositeQuery per explorer route 2026-10-26 11:32:54 +05:30
ashu8912
2139580419 fix(settings): remove notification channels from settings nav (#13225)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
#### Description

- Removed the Notification Channels entry from the settings side nav;
the channel screens are still reachable at their own route.
- Renamed the `shift+g+n` shortcut from
`NavigateToSettingsNotificationChannels` to
`NavigateToNotificationChannels`, since it no longer points into
Settings. The key binding and target route are unchanged.
- Updated the Settings nav test so it checks that the entry is gone.

Closes https://github.com/SigNoz/pulse-pod/issues/240

#### Additional Information

Resolves the TODO linked to SigNoz/engineering-pod#5323.
2026-10-26 01:43:18 +00:00
Vinicius Lourenço
9ba2a25eb9 refactor(workspaced-locked): avoid using images from signoz.io (#12880)
Some checks failed
build-staging / prepare (push) Has been cancelled
cacheci / tests (push) Has been cancelled
Release Drafter / update_release_draft (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
<!--A few plain bullets saying what changed and why, for a reviewer
skimming it - not a wall of text, not a restatement of the diff, not
generated boilerplate.-->
#### Description

This prevents issues similar to
https://github.com/SigNoz/engineering-pod/issues/6083

I didn't find any other image being imported from signoz.io

<!--Reference issues using `Closes #issue-number` to enable automatic
closure on merge. -->
#### Issues closed by this PR

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

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

<!--If applicable, include screenshots or screen recordings that clearly
show the behavior before the change and the result after the change. -->
#### Screenshots / Screen Recordings

Before (when the images were removed from signoz.io):

<img width="809" height="872" alt="image"
src="https://github.com/user-attachments/assets/4a30aad9-cc46-457c-af88-88758db79c5f"
/>

After:

<img width="970" height="980" alt="image"
src="https://github.com/user-attachments/assets/328b17a9-bcbf-44d3-90df-f5f71beb53a7"
/>
2026-10-23 15:35:31 +00:00
21 changed files with 842 additions and 77 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@@ -2,6 +2,7 @@ export const GlobalShortcuts = {
NavigateToServices: 'shift+s',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -23,7 +24,6 @@ export const GlobalShortcuts = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -34,6 +34,7 @@ export const GlobalShortcutsName = {
NavigateToTraces: 'shift+t',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToNotificationChannels: 'shift+g+n',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
@@ -44,7 +45,6 @@ export const GlobalShortcutsName = {
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToSettingsServiceAccounts: 'shift+g+k',
NavigateToSettingsRoles: 'shift+g+r',
NavigateToSettingsMembers: 'shift+g+m',
@@ -59,6 +59,7 @@ export const GlobalShortcutsDescription = {
NavigateToLogs: 'Navigate to Logs Explorer',
NavigateToDashboards: 'Navigate to Dashboards List',
NavigateToAlerts: 'Navigate to Alerts List',
NavigateToNotificationChannels: 'Navigate to Notification Channels',
NavigateToExceptions: 'Navigate to Exceptions List',
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
ToggleSidebar: 'Toggle sidebar visibility',
@@ -68,8 +69,6 @@ export const GlobalShortcutsDescription = {
NavigateToSettings: 'Navigate to Settings',
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
NavigateToSettingsBilling: 'Navigate to Billing Settings',
NavigateToSettingsNotificationChannels:
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToSettingsServiceAccounts: 'Navigate to Service Accounts Settings',
NavigateToSettingsRoles: 'Navigate to Roles Settings',

View File

@@ -65,8 +65,7 @@ import { useAppContext } from 'providers/App/App';
import { AppState } from 'store/reducers';
import AppReducer from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
import { checkVersionState } from 'utils/app';
import { isModifierKeyPressed } from 'utils/app';
import { checkVersionState, isModifierKeyPressed } from 'utils/app';
import { showErrorNotification } from 'utils/error';
import { openInNewTab } from 'utils/navigation';
@@ -714,7 +713,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
registerShortcut(GlobalShortcuts.NavigateToSettingsBilling, () =>
onClickHandler(ROUTES.BILLING, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels, () =>
registerShortcut(GlobalShortcuts.NavigateToNotificationChannels, () =>
onClickHandler(ROUTES.ALL_CHANNELS, null),
);
registerShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts, () =>
@@ -744,7 +743,7 @@ function SideNav({ isPinned }: { isPinned: boolean }): JSX.Element {
deregisterShortcut(GlobalShortcuts.NavigateToSettings);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsIngestion);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsBilling);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToNotificationChannels);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsServiceAccounts);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsRoles);
deregisterShortcut(GlobalShortcuts.NavigateToSettingsMembers);

View File

@@ -343,14 +343,6 @@ export const settingsNavSections: SettingsNavSection[] = [
isEnabled: true,
itemKey: 'account',
},
// TODO(@SigNoz/pulse-frontend): https://github.com/SigNoz/engineering-pod/issues/5323
{
key: ROUTES.ALL_CHANNELS,
label: 'Notification Channels',
icon: <FileKey2 size={16} />,
isEnabled: true,
itemKey: 'notification-channels',
},
{
key: ROUTES.BILLING,
label: 'Billing',

View File

@@ -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);
});
});

View 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);
});
});

View 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) };
}

View 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);

View File

@@ -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;

View File

@@ -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 };
}

View File

@@ -44,7 +44,6 @@ describe('SettingsPage nav sections', () => {
'settings-page-sidenav',
'workspace',
'account',
'notification-channels',
'billing',
'roles',
'members',
@@ -56,6 +55,12 @@ describe('SettingsPage nav sections', () => {
expect(screen.getByTestId(id)).toBeInTheDocument();
});
it('does not render "notification-channels" element', () => {
expect(
screen.queryByTestId('notification-channels'),
).not.toBeInTheDocument();
});
it.each(['Identity & Access', 'Authentication'])(
'renders "%s" section title',
(text) => {

View File

@@ -1,3 +1,12 @@
import anselmEickhoffUrl from '@/assets/Images/users/anselm.jpg';
import danielSchellUrl from '@/assets/Images/users/daniel.webp';
import dhruvGargUrl from '@/assets/Images/users/dhruv-garg.webp';
import goFrendiGunawanUrl from '@/assets/Images/users/go-frendi.webp';
import pranayNarangUrl from '@/assets/Images/users/pranay-narang.webp';
import sheheryarSewaniUrl from '@/assets/Images/users/shey.webp';
import subomiOluwalanaUrl from '@/assets/Images/users/subomi-oluwalana.webp';
import vivekBhaktaUrl from '@/assets/Images/users/vivek-bhakta.webp';
export const infoData = [
{
id: 'infoBlock-1',
@@ -43,7 +52,7 @@ export const enterpriseGradeValuesData = [
export const customerStoriesData = [
{
key: 'story-subomi-oluwalana',
avatar: 'https://signoz.io/img/users/subomi-oluwalana.webp',
avatar: subomiOluwalanaUrl,
personName: 'Subomi Oluwalana',
role: 'Founder & CEO at Convoy',
customerName: 'Convoy',
@@ -54,7 +63,7 @@ export const customerStoriesData = [
},
{
key: 'story-dhruv-garg',
avatar: 'https://signoz.io/img/users/dhruv-garg.webp',
avatar: dhruvGargUrl,
personName: 'Dhruv Garg',
role: 'Tech Lead at Nudge',
customerName: 'Nudge',
@@ -65,7 +74,7 @@ export const customerStoriesData = [
},
{
key: 'story-vivek-bhakta',
avatar: 'https://signoz.io/img/users/vivek-bhakta.webp',
avatar: vivekBhaktaUrl,
personName: 'Vivek Bhakta',
role: 'CTO at Wombo AI',
customerName: 'Wombo AI',
@@ -75,7 +84,7 @@ export const customerStoriesData = [
},
{
key: 'story-pranay-narang',
avatar: 'https://signoz.io/img/users/pranay-narang.webp',
avatar: pranayNarangUrl,
personName: 'Pranay Narang',
role: 'Engineering at Azodha',
customerName: 'Azodha',
@@ -85,7 +94,7 @@ export const customerStoriesData = [
},
{
key: 'story-Sheheryar-Sewani',
avatar: 'https://signoz.io/img/users/shey.webp',
avatar: sheheryarSewaniUrl,
personName: 'Sheheryar Sewani',
role: 'Seasoned Rails Dev & Founder',
customerName: '',
@@ -96,7 +105,7 @@ export const customerStoriesData = [
},
{
key: 'story-daniel-schell',
avatar: 'https://signoz.io/img/users/daniel.webp',
avatar: danielSchellUrl,
personName: 'Daniel Schell',
role: 'Founder & CTO at Airlockdigital',
customerName: 'Airlockdigital',
@@ -106,7 +115,7 @@ export const customerStoriesData = [
},
{
key: 'c-story-6',
avatar: 'https://signoz.io/img/users/go-frendi.webp',
avatar: goFrendiGunawanUrl,
personName: 'Go Frendi Gunawan',
role: 'Data Engineer at Ctlyst.id',
customerName: 'Ctlyst.id',
@@ -116,7 +125,7 @@ export const customerStoriesData = [
},
{
key: 'story-anselm-eickhoff',
avatar: 'https://signoz.io/img/users/anselm.jpg',
avatar: anselmEickhoffUrl,
personName: 'Anselm Eickhoff',
role: 'Software Architect',
customerName: '',

View File

@@ -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,

View File

@@ -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');
});
});