Compare commits

..

8 Commits

Author SHA1 Message Date
Vinícius Lourenço
0d6c27ec5f test(compositeQuery): add qsAlias tests 2026-06-16 21:27:44 -03:00
Vinícius Lourenço
9d919e166b feat(compositeQuery): add qsAlias adapter 2026-06-16 21:27:33 -03:00
Vinícius Lourenço
8c86885090 feat(compositeQuery): add baseline objects 2026-06-16 19:56:25 -03:00
Vinícius Lourenço
e7be5ee17d chore(query-builder-operators): add noop operator
This is used to help reduce the amount of diff on future baseline objects
2026-06-16 18:29:44 -03:00
Vinícius Lourenço
49c11f51ac test(useSafeNavigate): move helpers to add testing 2026-06-16 18:28:55 -03:00
Vinícius Lourenço
0c35a8f6e5 feat(compositeQuery): drop query param in favor of serializer functions 2026-06-16 18:26:39 -03:00
Vinícius Lourenço
2c076a3d50 feat(compositeQuery): add base structure for serializer with json as default 2026-06-16 18:25:03 -03:00
Vinícius Lourenço
086040799c feat(compositeQuery): add contract for serialize/deserialize query 2026-06-16 17:11:03 -03:00
195 changed files with 8311 additions and 8990 deletions

View File

@@ -2591,41 +2591,6 @@ components:
- panels
- layouts
type: object
DashboardtypesDashboardView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
DashboardtypesDashboardViewData:
properties:
order:
$ref: '#/components/schemas/DashboardtypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/DashboardtypesListSort'
version:
type: string
required:
- version
type: object
DashboardtypesDatasourcePlugin:
discriminator:
mapping:
@@ -2910,15 +2875,6 @@ components:
- total
- tags
type: object
DashboardtypesListableDashboardView:
properties:
views:
items:
$ref: '#/components/schemas/DashboardtypesDashboardView'
type: array
required:
- views
type: object
DashboardtypesListedDashboardForUserV2:
properties:
createdAt:
@@ -3223,16 +3179,6 @@ components:
- tags
- spec
type: object
DashboardtypesPostableDashboardView:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
name:
type: string
required:
- name
- data
type: object
DashboardtypesPostablePublicDashboard:
properties:
defaultTimeRange:
@@ -13382,231 +13328,6 @@ paths:
summary: Update user preference
tags:
- preferences
/api/v2/dashboard_views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide.
operationId: ListDashboardViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboard saved views
tags:
- dashboard
post:
deprecated: false
description: Persists the calling user's dashboard listing state (query, sort,
order) as a named, reusable view shared across the org.
operationId: CreateDashboardView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Create dashboard saved view
tags:
- dashboard
/api/v2/dashboard_views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide. Deleting
a non-existent view returns 404.
operationId: DeleteDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Delete dashboard saved view
tags:
- dashboard
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide.
operationId: UpdateDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Update dashboard saved view
tags:
- dashboard
/api/v2/dashboards:
get:
deprecated: false
@@ -14000,74 +13721,6 @@ paths:
summary: Update dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/clone:
post:
deprecated: false
description: This endpoint clones an existing v2-shape dashboard. User and integration
dashboards can be cloned; system dashboards are rejected. The clone keeps
the source's display name, panels, and tags, but gets a freshly generated
unique internal name and is always created as an unlocked user dashboard owned
by the caller.
operationId: CloneDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Clone dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/lock:
delete:
deprecated: false

View File

@@ -217,10 +217,6 @@ func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, source, postable)
}
func (module *module) CloneV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CloneV2(ctx, orgID, createdBy, creator, id)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
@@ -266,22 +262,6 @@ func (module *module) DeletePreferencesForUser(ctx context.Context, orgID valuer
return module.pkgDashboardModule.DeletePreferencesForUser(ctx, orgID, userID)
}
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.CreateView(ctx, orgID, postable)
}
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
return module.pkgDashboardModule.ListViews(ctx, orgID)
}
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdatableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.UpdateView(ctx, orgID, id, updateable)
}
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.DeleteView(ctx, orgID, id)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -94,6 +94,7 @@
"overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1",
"posthog-js": "1.298.0",
"qs": "6.15.2",
"rc-select": "14.10.0",
"react": "18.2.0",
"react-addons-update": "15.6.3",
@@ -168,6 +169,7 @@
"@types/lodash-es": "^4.17.4",
"@types/node": "^16.10.3",
"@types/papaparse": "5.3.7",
"@types/qs": "6.15.1",
"@types/react": "18.0.26",
"@types/react-addons-update": "0.14.21",
"@types/react-beautiful-dnd": "13.1.8",

View File

@@ -208,6 +208,9 @@ importers:
posthog-js:
specifier: 1.298.0
version: 1.298.0
qs:
specifier: 6.15.2
version: 6.15.2
rc-select:
specifier: 14.10.0
version: 14.10.0(react-dom@18.2.0(react@18.2.0))(react@18.2.0)
@@ -389,6 +392,9 @@ importers:
'@types/papaparse':
specifier: 5.3.7
version: 5.3.7
'@types/qs':
specifier: 6.15.1
version: 6.15.1
'@types/react':
specifier: 18.0.26
version: 18.0.26
@@ -3558,6 +3564,9 @@ packages:
'@types/prop-types@15.7.5':
resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
'@types/react-addons-update@0.14.21':
resolution: {integrity: sha512-HOxr0Hd8C1L4uw8DHyv2etqMVIj78oLEpe567/HgjoE+1Lc+PUsTGXTrkr1BDvFqsu5r49mSlgI5evwrk9eutA==}
@@ -7209,6 +7218,10 @@ packages:
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0
qs@6.15.2:
resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==}
engines: {node: '>=0.6'}
querystringify@2.2.0:
resolution: {integrity: sha512-FIqgj2EUvTa7R50u0rGsyTftzjYmv/a3hO345bZNrqabNqjtgiDMgmo4mkUjd+nzU5oF3dClKqFIPUKybUyqoQ==}
@@ -12358,6 +12371,8 @@ snapshots:
'@types/prop-types@15.7.5': {}
'@types/qs@6.15.1': {}
'@types/react-addons-update@0.14.21':
dependencies:
'@types/react': 18.0.26
@@ -16686,6 +16701,10 @@ snapshots:
dependencies:
react: 18.2.0
qs@6.15.2:
dependencies:
side-channel: 1.1.0
querystringify@2.2.0: {}
queue-microtask@1.2.3: {}

View File

@@ -18,20 +18,15 @@ import type {
} from 'react-query';
import type {
CloneDashboardV2201,
CloneDashboardV2PathParameters,
CreateDashboardV2201,
CreateDashboardView201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostableDashboardViewDTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeleteDashboardViewPathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -41,7 +36,6 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardViews200,
ListDashboardsForUserV2200,
ListDashboardsForUserV2Params,
ListDashboardsV2200,
@@ -55,8 +49,6 @@ import type {
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdateDashboardView200,
UpdateDashboardViewPathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -656,354 +648,6 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide.
* @summary List dashboard saved views
*/
export const listDashboardViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListDashboardViews200>({
url: `/api/v2/dashboard_views`,
method: 'GET',
signal,
});
};
export const getListDashboardViewsQueryKey = () => {
return [`/api/v2/dashboard_views`] as const;
};
export const getListDashboardViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardViewsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardViews>>
> = ({ signal }) => listDashboardViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardViews>>
>;
export type ListDashboardViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboard saved views
*/
export function useListDashboardViews<
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboard saved views
*/
export const invalidateListDashboardViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.
* @summary Create dashboard saved view
*/
export const createDashboardView = (
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardView201>({
url: `/api/v2/dashboard_views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getCreateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
const mutationKey = ['createDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDashboardView>>,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardView>>
>;
export type CreateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type CreateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard saved view
*/
export const useCreateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
return useMutation(getCreateDashboardViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide. Deleting a non-existent view returns 404.
* @summary Delete dashboard saved view
*/
export const deleteDashboardView = (
{ id }: DeleteDashboardViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/dashboard_views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteDashboardView>>,
{ pathParams: DeleteDashboardViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardView>>
>;
export type DeleteDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard saved view
*/
export const useDeleteDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
return useMutation(getDeleteDashboardViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide.
* @summary Update dashboard saved view
*/
export const updateDashboardView = (
{ id }: UpdateDashboardViewPathParameters,
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardView200>({
url: `/api/v2/dashboard_views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getUpdateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardView'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardView>>,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardView>>
>;
export type UpdateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type UpdateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard saved view
*/
export const useUpdateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardViewMutationOptions(options));
};
/**
* Returns a page of v2-shape dashboards for the org. This is the pure, user-independent list — it carries no pin state. Use ListDashboardsForUserV2 for the personalized, pin-aware list. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List dashboards (v2)
@@ -1562,85 +1206,6 @@ export const useUpdateDashboardV2 = <
> => {
return useMutation(getUpdateDashboardV2MutationOptions(options));
};
/**
* This endpoint clones an existing v2-shape dashboard. User and integration dashboards can be cloned; system dashboards are rejected. The clone keeps the source's display name, panels, and tags, but gets a freshly generated unique internal name and is always created as an unlocked user dashboard owned by the caller.
* @summary Clone dashboard (v2)
*/
export const cloneDashboardV2 = (
{ id }: CloneDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CloneDashboardV2201>({
url: `/api/v2/dashboards/${id}/clone`,
method: 'POST',
signal,
});
};
export const getCloneDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['cloneDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof cloneDashboardV2>>,
{ pathParams: CloneDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return cloneDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type CloneDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof cloneDashboardV2>>
>;
export type CloneDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Clone dashboard (v2)
*/
export const useCloneDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof cloneDashboardV2>>,
TError,
{ pathParams: CloneDashboardV2PathParameters },
TContext
> => {
return useMutation(getCloneDashboardV2MutationOptions(options));
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)

View File

@@ -4633,54 +4633,6 @@ export interface DashboardtypesDashboardSpecDTO {
variables: DashboardtypesVariableDTO[];
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesDashboardViewDataDTO {
order?: DashboardtypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: DashboardtypesListSortDTO;
/**
* @type string
*/
version: string;
}
export interface DashboardtypesDashboardViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
@@ -4792,6 +4744,15 @@ export interface DashboardtypesJSONPatchOperationDTO {
value?: unknown;
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: DashboardtypesDisplayDTO;
}
@@ -4934,13 +4895,6 @@ export interface DashboardtypesListableDashboardV2DTO {
total: number;
}
export interface DashboardtypesListableDashboardViewDTO {
/**
* @type array
*/
views: DashboardtypesDashboardViewDTO[];
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4992,14 +4946,6 @@ export interface DashboardtypesPostableDashboardV2DTO {
tags: TagtypesPostableTagDTO[] | null;
}
export interface DashboardtypesPostableDashboardViewDTO {
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
name: string;
}
export interface DashboardtypesPostablePublicDashboardDTO {
/**
* @type string
@@ -9891,36 +9837,6 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardView201 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardView200 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
@@ -9999,17 +9915,6 @@ export type UpdateDashboardV2200 = {
status: string;
};
export type CloneDashboardV2PathParameters = {
id: string;
};
export type CloneDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type UnlockDashboardV2PathParameters = {
id: string;
};

View File

@@ -6,6 +6,10 @@ import { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import useUpdatedQuery from 'container/GridCardLayout/useResolveQuery';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { useNotifications } from 'hooks/useNotifications';
import { useDashboardStore } from 'providers/Dashboard/store/useDashboardStore';
import { AppState } from 'store/reducers';
@@ -124,15 +128,13 @@ export function useNavigateToExplorer(): (
});
}
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(preparedQuery));
applySerializedParams(serialize(preparedQuery), urlParams);
const basePath =
dataSource === DataSource.TRACES
? ROUTES.TRACES_EXPLORER
: ROUTES.LOGS_EXPLORER;
const newExplorerPath = `${basePath}?${urlParams.toString()}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
const newExplorerPath = `${basePath}?${urlParams.toString()}`;
window.open(withBasePath(newExplorerPath), sameTab ? '_self' : '_blank');
},

View File

@@ -32,6 +32,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { cloneDeep } from 'lodash-es';
import {
@@ -252,7 +253,7 @@ function LogDetailInner({
[QueryParams.activeLogId]: `"${log?.id}"`,
[QueryParams.startTime]: minTime?.toString() || '',
[QueryParams.endTime]: maxTime?.toString() || '',
[QueryParams.compositeQuery]: JSON.stringify(
...serializeToParams(
updateAllQueriesOperators(
initialQueriesMap[DataSource.LOGS],
PANEL_TYPES.LIST,

View File

@@ -18,7 +18,6 @@ export enum QueryParams {
q = 'q',
activeLogId = 'activeLogId',
timeRange = 'timeRange',
compositeQuery = 'compositeQuery',
panelTypes = 'panelTypes',
pageSize = 'pageSize',
viewMode = 'viewMode',

View File

@@ -6,6 +6,10 @@ import {
import { SelectOption } from 'types/common/select';
export const metricAggregateOperatorOptions: SelectOption<string, string>[] = [
{
value: MetricAggregateOperator.NOOP,
label: 'No aggregation',
},
{
value: MetricAggregateOperator.COUNT,
label: 'Count',

View File

@@ -1,5 +1,7 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getAutoContexts } from '../getAutoContexts';
@@ -147,4 +149,24 @@ describe('getAutoContexts', () => {
),
).toStrictEqual([]);
});
it('decodes the serialized composite query into metadata.query', () => {
const query = { builder: { queryData: [] } } as unknown as Query;
const search = `?${serialize(query).toString()}`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata?.query).toStrictEqual(query);
});
it('omits metadata.query when no serialized query is in the URL', () => {
// Detection no longer gates on the `compositeQuery` key — it routes
// through `deserialize`/the adapter list — so non-query params (time
// range, etc.) must not be mistaken for a query.
const search = `?${QueryParams.startTime}=1700000000000&${QueryParams.endTime}=1700003600000`;
const [context] = getAutoContexts(ROUTES.LOGS_EXPLORER, search);
expect(context.metadata).not.toHaveProperty('query');
});
});

View File

@@ -24,7 +24,7 @@ import {
undoExecution,
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { serialize } from 'lib/compositeQuery/serializer';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
@@ -363,8 +363,8 @@ function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
const params = serialize(normalized);
deps.history.push(`${base}?${params.toString()}`);
}
/** Picks the right rollback API call for a given action kind. */

View File

@@ -8,6 +8,7 @@ import { getViewById } from 'api/saveView/getViewById';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { deserialize } from 'lib/compositeQuery/serializer';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import { AllViewsProps, ViewProps } from 'types/api/saveViews/types';
import { DataSource } from 'types/common/queryBuilder';
@@ -218,7 +219,9 @@ describe('buildExplorerNavigationUrl', () => {
);
expect(url).toContain(ROUTES.LOGS_EXPLORER);
expect(url).toContain(`${QueryParams.compositeQuery}=`);
const params = new URLSearchParams(new URL(url, 'http://x').search);
expect(deserialize(params)).not.toBeNull();
expect(url).toContain(`${QueryParams.viewKey}=`);
});
});

View File

@@ -2,6 +2,10 @@ import { getAllViews } from 'api/saveView/getAllViews';
import { getViewById } from 'api/saveView/getViewById';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { SOURCEPAGE_VS_ROUTES } from 'pages/SaveView/constants';
import { ViewProps } from 'types/api/saveViews/types';
@@ -75,10 +79,7 @@ export function buildExplorerNavigationUrl(
searchParams: Record<string, unknown>,
): string {
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), params);
Object.entries(searchParams).forEach(([key, value]) => {
params.set(key, JSON.stringify(value));
});

View File

@@ -1,6 +1,7 @@
import type { MessageContext } from 'api/ai-assistant/chat';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { deserialize } from 'lib/compositeQuery/serializer';
import { AlertListTabs } from 'pages/AlertList/types';
import { matchPath } from 'react-router-dom';
@@ -339,15 +340,9 @@ function collectSharedMetadata(
out.timeRange = { start: startTime, end: endTime };
}
// Query Builder state — URL-encoded JSON written by `QueryBuilderProvider`.
const compositeQueryRaw = params.get(QueryParams.compositeQuery);
if (compositeQueryRaw) {
try {
out.query = JSON.parse(decodeURIComponent(compositeQueryRaw));
} catch {
// Malformed JSON in the URL — drop silently rather than throw
// inside a context-collection helper.
}
const decodedQuery = deserialize(params);
if (decodedQuery) {
out.query = decodedQuery;
}
// Saved view selectors (logs / traces explorer) and dashboard variables.

View File

@@ -2,8 +2,8 @@ import { memo } from 'react';
import { Card, Modal } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES, PANEL_TYPES_INITIAL_QUERY } from 'constants/queryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import history from 'lib/history';
import { usePanelTypeSelectionModalStore } from 'providers/Dashboard/helpers/panelTypeSelectionModalHelper';
@@ -28,9 +28,7 @@ function PanelTypeSelectionModal(): JSX.Element {
const queryParams = {
graphType: name,
widgetId: id,
[QueryParams.compositeQuery]: JSON.stringify(
PANEL_TYPES_INITIAL_QUERY[name],
),
...serializeToParams(PANEL_TYPES_INITIAL_QUERY[name]),
};
history.push(

View File

@@ -63,6 +63,5 @@
flex: 0 0 auto;
min-height: 0;
min-width: 0;
padding-left: 12px;
padding-bottom: 12px;
padding: 8px;
}

View File

@@ -16,7 +16,7 @@ import PieArc from './PieArc';
import PieCenterLabel from './PieCenterLabel';
import styles from './Pie.module.scss';
import { PieTooltipData } from './types';
import { getDonutGeometry, getFillColor } from './utils';
import { getFillColor } from './utils';
/**
* Donut chart rendered with @visx. Splits its area into chart + legend with the
@@ -78,12 +78,16 @@ export default function Pie({
[containerWidth, containerHeight, position, data],
);
// Donut geometry derived from the allocated chart box, sized to leave room
// for the external leader labels (see getDonutGeometry).
const { size, radius, innerRadius } = useMemo(
() => getDonutGeometry(width, height),
[width, height],
);
// Donut geometry derived from the allocated chart box.
const { size, radius, innerRadius } = useMemo(() => {
const nextSize = Math.min(width, height);
const nextRadius = nextSize * 0.35;
return {
size: nextSize,
radius: nextRadius,
innerRadius: nextRadius * 0.6,
};
}, [width, height]);
const totalValue = useMemo(
() => visibleData.reduce((sum, slice) => sum + slice.value, 0),

View File

@@ -1,40 +1,11 @@
import {
getArcGeometry,
getDonutGeometry,
getFillColor,
getScaledFontSize,
lightenColor,
} from '../utils';
describe('Pie utils', () => {
describe('getDonutGeometry', () => {
it('keeps the label anchor inside the box (reserves room for leader labels)', () => {
const { radius } = getDonutGeometry(400, 300);
const half = Math.min(400, 300) / 2; // 150
// The label anchor sits at radius * 1.3 and must stay within the box
// half-extent so labels are not clipped.
expect(radius * 1.3).toBeLessThanOrEqual(half);
// And it should use the available room (anchor = half - 22 allowance).
expect(radius * 1.3).toBeCloseTo(half - 22);
});
it('derives size and inner radius from the outer radius', () => {
const { size, radius, innerRadius } = getDonutGeometry(300, 300);
expect(size).toBeCloseTo(radius * 2);
expect(innerRadius).toBeCloseTo(radius * 0.6);
});
it('sizes off the smaller dimension so it fits both axes', () => {
expect(getDonutGeometry(1000, 200)).toStrictEqual(
getDonutGeometry(200, 1000),
);
});
it('never returns a negative radius for a box too small for labels', () => {
expect(getDonutGeometry(20, 20).radius).toBe(0);
});
});
describe('getScaledFontSize', () => {
it('returns the base size for empty text', () => {
expect(getScaledFontSize({ text: '', baseSize: 30, innerRadius: 100 })).toBe(

View File

@@ -10,16 +10,6 @@ export interface ScaledFontSizeArgs {
innerRadius: number;
}
/** Donut sizing for a given chart box: the outer/inner radii and the square it spans. */
export interface DonutGeometry {
/** Outer diameter — feeds the visx Pie width/height and the render guard. */
size: number;
/** Outer radius of the donut ring. */
radius: number;
/** Inner radius (the hole) — also bounds the centre-total font. */
innerRadius: number;
}
export interface ArcGeometry {
/** Outer point where the leader label sits. */
labelX: number;

View File

@@ -3,37 +3,7 @@
* so the renderer stays declarative (per the one-component-per-file rule).
*/
import {
ArcGeometry,
DonutGeometry,
ParsedRgb,
ScaledFontSizeArgs,
} from './types';
// Leader-line + two-line label/value drawn outside the donut. `getArcGeometry`
// anchors the label at `radius * LABEL_RADIUS_RATIO`; `LABEL_TEXT_ALLOWANCE` is
// the px reserved beyond that anchor for the (10px, two-line) text so it never
// clips against the SVG edge.
const LABEL_RADIUS_RATIO = 1.3;
const LABEL_TEXT_ALLOWANCE = 22;
const INNER_RADIUS_RATIO = 0.6;
/**
* Sizes the donut to fit inside a `width × height` box *with room for the
* external leader labels*. The label anchor sits at `radius * 1.3`, so we solve
* the outer radius back from the box's half-extent minus the text allowance —
* guaranteeing the labels stay inside the SVG instead of being clipped (V1 used
* a flat `0.35 * min(w,h)`, which left too little margin on small panels).
*/
export function getDonutGeometry(width: number, height: number): DonutGeometry {
const half = Math.min(width, height) / 2;
const radius = Math.max(0, (half - LABEL_TEXT_ALLOWANCE) / LABEL_RADIUS_RATIO);
return {
size: radius * 2,
radius,
innerRadius: radius * INNER_RADIUS_RATIO,
};
}
import { ArcGeometry, ParsedRgb, ScaledFontSizeArgs } from './types';
/**
* Shrinks the centre-total font as the text gets longer so it never overflows
@@ -67,7 +37,7 @@ export function getArcGeometry(
radius: number,
): ArcGeometry {
const angle = (startAngle + endAngle) / 2;
const labelRadius = radius * LABEL_RADIUS_RATIO;
const labelRadius = radius * 1.3;
const lineEndRadius = radius * 1.1;
return {
labelX: Math.sin(angle) * labelRadius,

View File

@@ -1,79 +0,0 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from '../utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
`label-${i}`.padEnd(length, 'x').slice(0, length),
);
describe('calculateChartDimensions', () => {
it('returns all zeros when the container has no space', () => {
expect(
calculateChartDimensions({
containerWidth: 0,
containerHeight: 300,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
}),
).toStrictEqual({
width: 0,
height: 0,
legendWidth: 0,
legendHeight: 0,
averageLegendWidth: 0,
});
});
it('RIGHT: reserves a side column capped at 30% of the width and keeps full height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
// 40-char labels approximate to 336px, capped at min(240, 30% of 1000).
expect(dims.legendWidth).toBe(240);
expect(dims.width).toBe(760);
expect(dims.height).toBe(400);
expect(dims.legendHeight).toBe(400);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One row = line height (28) + padding (12).
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: many items cap at two rows on a tall container', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
});
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 160,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
});
});

View File

@@ -116,15 +116,7 @@ export function calculateChartDimensions({
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const maxAllowedLegendHeight = Math.min(2 * legendRowHeight, 80);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,

View File

@@ -62,6 +62,8 @@ import { useIsDarkMode } from 'hooks/useDarkMode';
import useErrorNotification from 'hooks/useErrorNotification';
import { useHandleExplorerTabChange } from 'hooks/useHandleExplorerTabChange';
import { useNotifications } from 'hooks/useNotifications';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { mapCompositeQueryFromQuery } from 'lib/newQueryBuilder/queryBuilderMappers/mapCompositeQueryFromQuery';
import { cloneDeep, isEqual, omit } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
@@ -174,7 +176,7 @@ function ExplorerOptions({
const handleConditionalQueryModification = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(defaultQuery: Query | null): string => {
(defaultQuery: Query | null): Record<string, string> => {
const queryToUse = defaultQuery || query;
if (!queryToUse) {
throw new Error('No query provided');
@@ -184,7 +186,7 @@ function ExplorerOptions({
StringOperators.NOOP &&
sourcepage !== DataSource.LOGS
) {
return JSON.stringify(queryToUse);
return serializeToParams(queryToUse);
}
// Convert NOOP to COUNT for alerts and strip orderBy for logs
@@ -208,14 +210,7 @@ function ExplorerOptions({
);
}
try {
return JSON.stringify(modifiedQuery);
} catch (err) {
throw new Error(
'Failed to stringify modified query: ' +
(err instanceof Error ? err.message : String(err)),
);
}
return serializeToParams(modifiedQuery);
},
[panelType, query, sourcepage],
);
@@ -238,13 +233,9 @@ function ExplorerOptions({
});
}
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
const serializedParams = handleConditionalQueryModification(defaultQuery);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
history.push(`${ROUTES.ALERTS_NEW}?${createQueryParams(serializedParams)}`);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleConditionalQueryModification, history],

View File

@@ -34,6 +34,7 @@ import useGetYAxisUnit from 'hooks/useGetYAxisUnit';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { clearSerializedParams } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { mapQueryDataToApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataToApi';
import { isEmpty, isEqual } from 'lodash-es';
@@ -384,7 +385,7 @@ function FormAlertRules({
const onCancelHandler = useCallback(
(e?: React.MouseEvent) => {
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);
@@ -610,7 +611,7 @@ function FormAlertRules({
`${ruleId}`,
]);
urlQuery.delete(QueryParams.compositeQuery);
clearSerializedParams(urlQuery);
urlQuery.delete(QueryParams.panelTypes);
urlQuery.delete(QueryParams.ruleId);
urlQuery.delete(QueryParams.relativeTime);

View File

@@ -23,6 +23,10 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
clearSerializedParams,
serializeToParams,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import {
@@ -212,9 +216,7 @@ function WidgetGraphComponent({
[QueryParams.graphType]: clonedWidget?.panelTypes,
[QueryParams.widgetId]: uuid,
...(clonedWidget?.query && {
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(clonedWidget.query),
),
...serializeToParams(clonedWidget.query),
}),
};
safeNavigate(`${pathname}/new?${createQueryParams(queryParams)}`);
@@ -255,7 +257,7 @@ function WidgetGraphComponent({
const onToggleModelHandler = (): void => {
const existingSearchParams = new URLSearchParams(search);
existingSearchParams.delete(QueryParams.expandedWidgetId);
existingSearchParams.delete(QueryParams.compositeQuery);
clearSerializedParams(existingSearchParams);
existingSearchParams.delete(QueryParams.graphType);
const updatedQueryParams = Object.fromEntries(existingSearchParams.entries());
if (queryResponse.data?.payload) {

View File

@@ -29,6 +29,10 @@ import useCreateAlerts from 'hooks/queryBuilder/useCreateAlerts';
import useComponentPermission from 'hooks/useComponentPermission';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { isEmpty } from 'lodash-es';
import { unparse } from 'papaparse';
@@ -86,10 +90,7 @@ function WidgetHeader({
const widgetId = widget.id;
urlQuery.set(QueryParams.widgetId, widgetId);
urlQuery.set(QueryParams.graphType, widget.panelTypes);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(widget.query)),
);
applySerializedParams(serialize(widget.query), urlQuery);
const generatedUrl = buildAbsolutePath({
relativePath: 'new',
urlQueryString: urlQuery.toString(),

View File

@@ -7,6 +7,10 @@ import { useListRules } from 'api/generated/services/rules';
import type { RuletypesRuleDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import history from 'lib/history';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ArrowRight, ArrowUpRight, Plus } from '@signozhq/icons';
@@ -134,10 +138,7 @@ export default function AlertRules({
const compositeQuery = mapQueryDataFromApi(
toCompositeMetricQuery(record.condition.compositeQuery),
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = record.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -28,6 +28,10 @@ import {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
@@ -410,7 +414,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
@@ -435,7 +439,7 @@ export default function K8sBaseDetails<T>({
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), urlQuery);
openInNewTab(`${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`);
}

View File

@@ -53,6 +53,7 @@ import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
import { useGetGlobalConfig } from 'api/generated/services/global';
import useDebouncedFn from 'hooks/useDebouncedFunction';
import { useNotifications } from 'hooks/useNotifications';
import { serialize } from 'lib/compositeQuery/serializer';
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
import {
ArrowUpRight,
@@ -77,6 +78,7 @@ import {
UpdateLimitProps,
} from 'types/api/ingestionKeys/limits/types';
import { PaginationProps } from 'types/api/ingestionKeys/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { MeterAggregateOperator } from 'types/common/queryBuilder';
import { USER_ROLES } from 'types/roles';
import { getDaysUntilExpiry } from 'utils/timeUtils';
@@ -896,8 +898,6 @@ function MultiIngestionSettings(): JSX.Element {
},
};
const stringifiedQuery = JSON.stringify(query);
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
thresholds[0].thresholdValue = thresholdValue;
thresholds[0].unit = thresholdUnit;
@@ -907,17 +907,12 @@ function MultiIngestionSettings(): JSX.Element {
? `[ingestion][${signal.signal}] ${keyName} has exceeded daily ingestion limit`
: `[ingestion][${signal.signal}] ${signal.signal} has exceeded daily ingestion limit`;
const URL = `${ROUTES.ALERTS_NEW}?${
QueryParams.compositeQuery
}=${encodeURIComponent(stringifiedQuery)}&${
QueryParams.thresholds
}=${encodeURIComponent(JSON.stringify(thresholds))}&${
QueryParams.ruleName
}=${encodeURIComponent(ruleName)}&${
QueryParams.yAxisUnit
}=${encodeURIComponent(yAxisUnit)}`;
const params = serialize(query as Query);
params.set(QueryParams.thresholds, JSON.stringify(thresholds));
params.set(QueryParams.ruleName, ruleName);
params.set(QueryParams.yAxisUnit, yAxisUnit);
history.push(URL);
history.push(`${ROUTES.ALERTS_NEW}?${params.toString()}`);
};
const columns: AntDTableProps<GatewaytypesIngestionKeyDTO>['columns'] = [

View File

@@ -1,5 +1,6 @@
import { GatewaytypesGettableIngestionKeysDTO } from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { rest, server } from 'mocks-server/server';
import {
fireEvent,
@@ -132,17 +133,19 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(1000);
expect(thresholds[0].unit).toBe('{count}');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('{count}');
expect(compositeQuery.builder.queryData).toBeDefined();
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('{count}');
expect(compositeQuery?.builder.queryData).toBeDefined();
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k1'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe(
'signoz.meter.metric.datapoint.count',
);
@@ -213,18 +216,18 @@ describe('MultiIngestionSettings Page', () => {
expect(thresholds[0].thresholdValue).toBe(400);
expect(thresholds[0].unit).toBe('GiBy');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.unit).toBe('bytes');
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
expect(compositeQuery?.unit).toBe('bytes');
const firstQueryData = compositeQuery.builder.queryData[0];
expect(firstQueryData.filter.expression).toContain(
const firstQueryData = compositeQuery?.builder.queryData[0];
expect(firstQueryData?.filter?.expression).toContain(
"signoz.workspace.key.id='k2'",
);
expect(firstQueryData.aggregations[0].metricName).toBe(
'signoz.meter.log.size',
);
const firstAggregation = firstQueryData?.aggregations?.[0] as {
metricName: string;
};
expect(firstAggregation.metricName).toBe('signoz.meter.log.size');
expect(urlParams.get(QueryParams.yAxisUnit)).toBe('bytes');
expect(urlParams.get(QueryParams.ruleName)).toContain('logs');

View File

@@ -6,6 +6,10 @@ import { sanitizeDefaultAlertQuery } from 'container/EditAlertV2/utils';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { useTableRowClick } from 'hooks/useTableRowClick';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { toCompositeMetricQuery } from 'types/api/alerts/convert';
import { isModifierKeyPressed } from 'utils/app';
@@ -31,10 +35,7 @@ export function useAlertRulesHandlers(
mapQueryDataFromApi(toCompositeMetricQuery(rule.condition.compositeQuery)),
rule.alertType,
);
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(compositeQuery)),
);
applySerializedParams(serialize(compositeQuery), params);
const panelType = rule.condition.compositeQuery.panelType;
if (panelType) {

View File

@@ -14,6 +14,10 @@ import { FontSize } from 'container/OptionsMenu/types';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -111,10 +115,7 @@ function ContextLogRenderer({
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
urlQuery.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
applySerializedParams(serialize(query), urlQuery);
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(withBasePath(link), '_blank', 'noopener,noreferrer');

View File

@@ -247,16 +247,12 @@ function Application(): JSX.Element {
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -8,6 +8,10 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useClickOutside from 'hooks/useClickOutside';
import useResourceAttribute from 'hooks/useResourceAttribute';
import { resourceAttributesToTracesFilterItems } from 'hooks/useResourceAttribute/utils';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { prepareQueryWithDefaultTimestamp } from 'pages/LogsExplorer/utils';
import { traceFilterKeys } from 'pages/TracesExplorer/Filter/filterUtils';
@@ -60,16 +64,18 @@ export function generateExplorerPath(
urlParams: URLSearchParams,
servicename: string | undefined,
selectedTraceTags: string,
JSONCompositeQuery: string,
apmToTraceQuery: Query,
queryString: string[],
): string {
const basePath = isViewLogsClicked
? ROUTES.LOGS_EXPLORER
: ROUTES.TRACES_EXPLORER;
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}&${queryString.join('&')}`;
applySerializedParams(serialize(apmToTraceQuery), urlParams);
return `${basePath}?${urlParams.toString()}&selected={"serviceName":["${servicename}"]}&filterToFetchData=["duration","status","serviceName"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${queryString.join(
'&',
)}`;
}
// TODO(@rahul-signoz): update the name of this function once we have view logs button in every panel
@@ -105,16 +111,12 @@ export function onViewTracePopupClick({
const avialableParams = routeConfig[ROUTES.TRACE];
const queryString = getQueryString(avialableParams, urlParams);
const JSONCompositeQuery = encodeURIComponent(
JSON.stringify(apmToTraceQuery),
);
const newPath = generateExplorerPath(
isViewLogsClicked,
urlParams,
servicename,
selectedTraceTags,
JSONCompositeQuery,
apmToTraceQuery,
queryString,
);

View File

@@ -1,5 +1,6 @@
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { serialize } from 'lib/compositeQuery/serializer';
import { withBasePath } from 'utils/basePath';
import { TopOperationList } from './TopOperationsTable';
@@ -29,13 +30,11 @@ export const navigateToTrace = ({
);
urlParams.set(QueryParams.endTime, Math.floor(maxTime / 1_000_000).toString());
const JSONCompositeQuery = encodeURIComponent(JSON.stringify(apmToTraceQuery));
const newTraceExplorerPath = `${
ROUTES.TRACES_EXPLORER
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${
QueryParams.compositeQuery
}=${JSONCompositeQuery}`;
}?${urlParams.toString()}&selected={"serviceName":["${servicename}"],"operation":["${operation}"]}&filterToFetchData=["duration","status","serviceName","operation"]&spanAggregateCurrentPage=1&selectedTags=${selectedTraceTags}&${serialize(
apmToTraceQuery,
).toString()}`;
if (openInNewTab) {
window.open(withBasePath(newTraceExplorerPath), '_blank');

View File

@@ -33,6 +33,7 @@ import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
@@ -791,9 +792,7 @@ function NewWidget({
const queryParams = {
[QueryParams.expandedWidgetId]: widgetId,
[QueryParams.graphType]: graphType,
[QueryParams.compositeQuery]: encodeURIComponent(
JSON.stringify(currentQuery),
),
...serializeToParams(currentQuery),
[QueryParams.variables]: variables,
};

View File

@@ -2,6 +2,7 @@ import { useCallback } from 'react';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -49,7 +50,7 @@ const useBaseDrilldownNavigate = ({
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
@@ -94,7 +95,7 @@ export function buildDrilldownUrl(
const timeRange = aggregateData?.timeRange;
let queryParams: Record<string, string> = {
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
...serializeToParams(viewQuery),
...(timeRange && {
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),

View File

@@ -19,6 +19,7 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { serializeToParams } from 'lib/compositeQuery/serializer';
import createQueryParams from 'lib/createQueryParams';
import { Compass } from '@signozhq/icons';
import { ILog } from 'types/api/logs/log';
@@ -139,7 +140,7 @@ function SpanLogs({
[QueryParams.activeLogId]: `"${log.id}"`,
[QueryParams.startTime]: timeRange.startTime.toString(),
[QueryParams.endTime]: timeRange.endTime.toString(),
[QueryParams.compositeQuery]: JSON.stringify(updatedQuery),
...serializeToParams(updatedQuery),
};
const url = `${ROUTES.LOGS_EXPLORER}?${createQueryParams(queryParams)}`;

View File

@@ -15,6 +15,10 @@ import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { BarChart, Compass, X } from '@signozhq/icons';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Span } from 'types/api/trace/getTraceV2';
@@ -155,7 +159,7 @@ function SpanRelatedSignals({
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
applySerializedParams(serialize(compositeQuery as any), searchParams);
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());

View File

@@ -3,6 +3,7 @@ import getUserPreference from 'api/v1/user/preferences/name/get';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { deserialize } from 'lib/compositeQuery/serializer';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
@@ -545,14 +546,13 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000'); // traceEndTime + 5 minutes
// Verify composite query includes both trace_id and span_id filters
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
// Check that the filter expression contains trace_id
// Note: Current behavior uses only trace_id filter for navigation
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();
@@ -595,16 +595,16 @@ describe('SpanDetailsDrawer', () => {
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
// Verify composite query includes only trace_id filter (no span_id for context logs)
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
const { filter } = compositeQuery.builder.queryData[0];
// Verify composite query filters by trace_id and the context log's own span_id
const compositeQuery = deserialize(urlParams);
expect(compositeQuery).not.toBeNull();
const filter = compositeQuery?.builder.queryData[0]?.filter;
// Check that the filter expression contains trace_id but not span_id for context logs
expect(filter.expression).toContain("trace_id = 'test-trace-id'");
// Context logs should not have span_id filter
expect(filter.expression).not.toContain('span_id');
// Check that the filter expression contains trace_id
expect(filter?.expression).toContain("trace_id = 'test-trace-id'");
// Context logs use their own span id, not the currently selected span id
expect(filter?.expression).toContain("span_id = 'different-span-id'");
expect(filter?.expression).not.toContain('test-span-id');
// Verify mockSafeNavigate was NOT called
expect(mockSafeNavigate).not.toHaveBeenCalled();

View File

@@ -35,6 +35,11 @@ import { GlobalReducer } from 'types/reducer/globalTime';
import { addCustomTimeRange } from 'utils/customTimeRangeUtils';
import { persistTimeDurationForRoute } from 'utils/metricsTimeStorageUtils';
import { normalizeTimeToMs } from 'utils/timeUtils';
import {
applySerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
@@ -278,7 +283,7 @@ function DateTimeSelection({
return `Refreshed ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const getUpdatedCompositeQuery = useCallback((): string => {
const getUpdatedCompositeQuery = useCallback((): URLSearchParams => {
let updatedCompositeQuery = cloneDeep(currentQuery);
updatedCompositeQuery.id = uuid();
// Remove the filters
@@ -299,7 +304,7 @@ function DateTimeSelection({
})),
},
};
return encodeURIComponent(JSON.stringify(updatedCompositeQuery));
return serialize(updatedCompositeQuery);
}, [currentQuery]);
const onSelectHandler = useCallback(
@@ -334,9 +339,9 @@ function DateTimeSelection({
// Remove Hidden Filters from URL query parameters on time change
urlQuery.delete(QueryParams.activeLogId);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
@@ -424,9 +429,9 @@ function DateTimeSelection({
urlQuery.set(QueryParams.endTime, endTime?.toDate().getTime().toString());
urlQuery.delete(QueryParams.relativeTime);
if (urlQuery.has(QueryParams.compositeQuery)) {
const updatedCompositeQuery = getUpdatedCompositeQuery();
urlQuery.set(QueryParams.compositeQuery, updatedCompositeQuery);
const staledQuery = deserialize(urlQuery);
if (staledQuery) {
applySerializedParams(getUpdatedCompositeQuery(), urlQuery);
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;

View File

@@ -0,0 +1,170 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { serialize } from 'lib/compositeQuery/serializer';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
const BASE = 'http://localhost';
const urlFrom = (pathname: string, params?: URLSearchParams): URL => {
const search = params?.toString();
const query = search ? `?${search}` : '';
return new URL(`${pathname}${query}`, BASE);
};
/** Build params containing the serialized `compositeQuery` plus any extras. */
const withQuery = (
query: Query,
extra: Record<string, string> = {},
): URLSearchParams => {
const params = serialize(query);
Object.entries(extra).forEach(([key, value]) => params.set(key, value));
return params;
};
describe('areUrlsEffectivelySame', () => {
it('returns false when pathnames differ', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/traces'))).toBe(
false,
);
});
it('returns true for two identical param-less URLs', () => {
expect(areUrlsEffectivelySame(urlFrom('/logs'), urlFrom('/logs'))).toBe(true);
});
it('returns true when only the compositeQuery is present and identical', () => {
const params = withQuery(initialQueriesMap.logs);
expect(
areUrlsEffectivelySame(
urlFrom('/logs', params),
urlFrom('/logs', new URLSearchParams(params.toString())),
),
).toBe(true);
});
// Regression: a matching compositeQuery must NOT mask differences in other
// params. Previously every param was compared via the decoded query, so any
// two URLs sharing a compositeQuery were judged identical.
it('returns false when compositeQuery matches but another param differs', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '2000' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('returns false when compositeQuery matches but a param exists on only one URL', () => {
const url1 = urlFrom(
'/logs',
withQuery(initialQueriesMap.logs, { startTime: '1000' }),
);
const url2 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('ignores the volatile id when comparing compositeQuery', () => {
const url1 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-1' }),
);
const url2 = urlFrom(
'/logs',
withQuery({ ...initialQueriesMap.logs, id: 'id-2' }),
);
expect(areUrlsEffectivelySame(url1, url2)).toBe(true);
});
it('returns false when compositeQuery is semantically different', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/metrics', withQuery(initialQueriesMap.metrics));
// Force same pathname so only the query differs.
expect(
areUrlsEffectivelySame(
url1,
urlFrom('/logs', new URLSearchParams(url2.search)),
),
).toBe(false);
});
it('returns false when compositeQuery exists on only one URL', () => {
const url1 = urlFrom('/logs', withQuery(initialQueriesMap.logs));
const url2 = urlFrom('/logs');
expect(areUrlsEffectivelySame(url1, url2)).toBe(false);
});
it('compares non-compositeQuery params directly when no compositeQuery is present', () => {
const same1 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
const same2 = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '2' }),
);
expect(areUrlsEffectivelySame(same1, same2)).toBe(true);
const diff = urlFrom(
'/logs',
new URLSearchParams({ startTime: '1', endTime: '3' }),
);
expect(areUrlsEffectivelySame(same1, diff)).toBe(false);
});
it('falls back to raw comparison when compositeQuery cannot be decoded', () => {
const corrupt1 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
const corrupt2 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bnot-json' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt2)).toBe(true);
const corrupt3 = urlFrom(
'/logs',
new URLSearchParams({ compositeQuery: '%7Bother' }),
);
expect(areUrlsEffectivelySame(corrupt1, corrupt3)).toBe(false);
});
});
describe('isDefaultNavigation', () => {
it('returns false for different pathnames', () => {
expect(isDefaultNavigation(urlFrom('/logs'), urlFrom('/traces'))).toBe(false);
});
it('returns true when a clean URL gains params', () => {
expect(
isDefaultNavigation(
urlFrom('/logs'),
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
),
).toBe(true);
});
it('returns true when the target introduces a new param key', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '1', endTime: '2' })),
),
).toBe(true);
});
it('returns false when the target has no new param keys', () => {
expect(
isDefaultNavigation(
urlFrom('/logs', new URLSearchParams({ startTime: '1' })),
urlFrom('/logs', new URLSearchParams({ startTime: '9' })),
),
).toBe(false);
});
});

View File

@@ -5,7 +5,6 @@ import { useDispatch, useSelector } from 'react-redux';
import { useHistory, useLocation } from 'react-router-dom';
import { getAggregateKeys } from 'api/queryBuilder/getAttributeKeys';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { QueryParams } from 'constants/query';
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { MetricsType } from 'container/MetricsApplication/constant';
@@ -13,6 +12,7 @@ import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSea
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useNotifications } from 'hooks/useNotifications';
import useUrlQuery from 'hooks/useUrlQuery';
import { deserialize } from 'lib/compositeQuery/serializer';
import { getGeneratedFilterQueryString } from 'lib/getGeneratedFilterQueryString';
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
import { AppState } from 'store/reducers';
@@ -58,9 +58,14 @@ export const useActiveLog = (): UseActiveLog => {
const [activeLog, setActiveLog] = useState<ILog | null>(null);
// Close drawer/clear active log when query in URL changes
// Close drawer/clear active log when query in URL changes. Track the decoded
// query (not a single raw param) so it stays correct across serializer tiers
// that explode the query into many keys.
const urlQuery = useUrlQuery();
const compositeQuery = urlQuery.get(QueryParams.compositeQuery) ?? '';
const compositeQuery = useMemo(() => {
const decoded = deserialize(urlQuery);
return decoded ? JSON.stringify(decoded) : '';
}, [urlQuery]);
const prevQueryRef = useRef<string | null>(null);
useEffect(() => {
if (

View File

@@ -2,9 +2,10 @@ import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { deserialize } from 'lib/compositeQuery/serializer';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
@@ -79,14 +80,14 @@ const buildWidget = (queryType: EQueryType | undefined): Widgets =>
},
}) as unknown as Widgets;
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const getCompositeQueryFromLastOpen = (): Query => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
const composite = deserialize(query);
if (!composite) {
throw new Error('compositeQuery not found in URL');
}
return JSON.parse(decodeURIComponent(raw));
return composite;
};
describe('useCreateAlerts', () => {

View File

@@ -0,0 +1,26 @@
import { renderHook } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
let mockUrlQuery = new URLSearchParams();
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => mockUrlQuery,
}));
describe('useGetCompositeQueryParam', () => {
it('decodes a legacy compositeQuery param', () => {
mockUrlQuery = new URLSearchParams({
compositeQuery: encodeURIComponent(JSON.stringify(initialQueriesMap.logs)),
});
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null when the param is absent', () => {
mockUrlQuery = new URLSearchParams();
const { result } = renderHook(() => useGetCompositeQueryParam());
expect(result.current).toBeNull();
});
});

View File

@@ -14,6 +14,10 @@ import { MenuItemKeys } from 'container/GridCardLayout/WidgetHeader/contants';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
import { useNotifications } from 'hooks/useNotifications';
import {
applySerializedParams,
serialize,
} from 'lib/compositeQuery/serializer';
import { getDashboardVariables } from 'lib/dashboardVariables/getDashboardVariables';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { isEmpty } from 'lodash-es';
@@ -86,10 +90,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
}
const params = new URLSearchParams();
params.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(updatedQuery)),
);
applySerializedParams(serialize(updatedQuery), params);
params.set(QueryParams.panelTypes, widget.panelTypes);
params.set(QueryParams.version, ENTITY_VERSION_V5);
params.set(QueryParams.source, YAxisSource.DASHBOARDS);

View File

@@ -1,72 +1,10 @@
import { useMemo } from 'react';
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { deserialize } from 'lib/compositeQuery/serializer';
import { useMemo } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const useGetCompositeQueryParam = (): Query | null => {
const urlQuery = useUrlQuery();
return useMemo(() => {
const compositeQuery = urlQuery.get(QueryParams.compositeQuery);
let parsedCompositeQuery: Query | null = null;
try {
if (!compositeQuery) {
return 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(
decodeURIComponent(compositeQuery.replace(/\+/g, ' ')),
);
// Convert old format to new format for each query in builder.queryData
if (parsedCompositeQuery?.builder?.queryData) {
parsedCompositeQuery.builder.queryData =
parsedCompositeQuery.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
// Convert having if needed
if (Array.isArray(query.having)) {
const convertedHaving = convertHavingToExpression(query.having);
convertedQuery.having = convertedHaving;
}
// Convert aggregation if needed
if (!query.aggregations && query.aggregateOperator) {
const convertedAggregation = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
}) as any; // Type assertion to handle union type
convertedQuery.aggregations = convertedAggregation;
}
return convertedQuery;
});
}
} catch (e) {
parsedCompositeQuery = null;
}
return parsedCompositeQuery;
}, [urlQuery]);
return useMemo(() => deserialize(urlQuery), [urlQuery]);
};

View File

@@ -1,6 +1,9 @@
import {
areUrlsEffectivelySame,
isDefaultNavigation,
} from 'hooks/useSafeNavigate.utils';
import { useCallback } from 'react';
import { useLocation, useNavigate } from 'react-router-dom-v5-compat';
import { cloneDeep, isEqual } from 'lodash-es';
import { withBasePath } from 'utils/basePath';
interface NavigateOptions {
@@ -18,77 +21,6 @@ interface UseSafeNavigateProps {
preventSameUrlNavigation?: boolean;
}
const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
const allParams = new Set([...params1.keys(), ...params2.keys()]);
return [...allParams].every((param) => {
if (param === 'compositeQuery') {
try {
const query1 = params1.get('compositeQuery');
const query2 = params2.get('compositeQuery');
if (!query1 || !query2) {
return false;
}
const decoded1 = JSON.parse(decodeURIComponent(query1));
const decoded2 = JSON.parse(decodeURIComponent(query2));
const filtered1 = cloneDeep(decoded1);
const filtered2 = cloneDeep(decoded2);
delete filtered1.id;
delete filtered2.id;
return isEqual(filtered1, filtered2);
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
return false;
}
}
return params1.get(param) === params2.get(param);
});
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,

View File

@@ -0,0 +1,103 @@
import { deserialize } from 'lib/compositeQuery/serializer';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { isEqual } from 'lodash-es';
/**
* Compare the (optional) `compositeQuery` param of two URLSearchParams
* semantically. Its serialized form is not byte-stable — the volatile `id` and
* the adapter choice both vary — so we decode and deep-compare, ignoring `id`.
*
* compositeQuery is not guaranteed to be present: absent on both sides counts
* as equal, present on only one side counts as different. When either side is
* present but can't be decoded, we fall back to comparing the raw values.
*/
const compositeQueriesEqual = (
params1: URLSearchParams,
params2: URLSearchParams,
): boolean => {
const raw1 = params1.get(COMPOSITE_QUERY_KEY);
const raw2 = params2.get(COMPOSITE_QUERY_KEY);
if (!raw1 && !raw2) {
return true;
}
if (!raw1 || !raw2) {
return false;
}
try {
const decoded1 = deserialize(params1);
const decoded2 = deserialize(params2);
if (decoded1 && decoded2) {
// Ignore the volatile `id` when comparing queries.
const { id: _id1, ...rest1 } = decoded1;
const { id: _id2, ...rest2 } = decoded2;
return isEqual(rest1, rest2);
}
} catch (error) {
console.warn('Error comparing compositeQuery:', error);
}
// One or both could not be decoded — compare the raw encoded values.
return raw1 === raw2;
};
export const areUrlsEffectivelySame = (url1: URL, url2: URL): boolean => {
if (url1.pathname !== url2.pathname) {
return false;
}
const params1 = new URLSearchParams(url1.search);
const params2 = new URLSearchParams(url2.search);
// The compositeQuery is compared semantically (it round-trips through a
// non-stable serialized form); every other param is compared by raw value.
if (!compositeQueriesEqual(params1, params2)) {
return false;
}
const otherKeys = new Set(
[...params1.keys(), ...params2.keys()].filter(
(key) => key !== COMPOSITE_QUERY_KEY,
),
);
return [...otherKeys].every((key) => params1.get(key) === params2.get(key));
};
/**
* Determines if this navigation is adding default/initial parameters
* Returns true if:
* 1. We're staying on the same page (same pathname)
* 2. Either:
* - Current URL has no params and target URL has params, or
* - Target URL has new params that didn't exist in current URL
*/
export const isDefaultNavigation = (
currentUrl: URL,
targetUrl: URL,
): boolean => {
// Different pathnames means it's not a default navigation
if (currentUrl.pathname !== targetUrl.pathname) {
return false;
}
const currentParams = new URLSearchParams(currentUrl.search);
const targetParams = new URLSearchParams(targetUrl.search);
// Case 1: Clean URL getting params for the first time
if (!currentParams.toString() && targetParams.toString()) {
return true;
}
// Case 2: Check for new params that didn't exist before
const currentKeys = new Set(currentParams.keys());
const targetKeys = new Set(targetParams.keys());
// Find keys that exist in target but not in current
const newKeys = [...targetKeys].filter((key) => !currentKeys.has(key));
return newKeys.length > 0;
};

View File

@@ -0,0 +1,269 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`baseline immutability snapshots LOGS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots LOGS_BASELINE_V1_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots METRICS_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`baseline immutability snapshots TRACES_BASELINE_V1 must never change 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;

View File

@@ -0,0 +1,121 @@
/**
* ╔════════════════════════════════════════════════════════════════════════════╗
* ║ ⚠️ CRITICAL WARNING ⚠️ ║
* ╠════════════════════════════════════════════════════════════════════════════╣
* ║ These baselines are FROZEN FOREVER. They must NEVER be modified. ║
* ║ ║
* ║ WHY: Every URL ever emitted by the compositeQuery serializer encodes a ║
* ║ diff against these exact baselines. Changing a single byte here silently ║
* ║ BREAKS ALL EXISTING URLs — dashboards, saved views, shared links, etc. ║
* ║ ║
* ║ If these snapshot tests fail: ║
* ║ 1. DO NOT update the snapshots ║
* ║ 2. REVERT your changes to baseline.ts immediately ║
* ║ 3. If you need a new schema, create a NEW versioned baseline: ║
* ║ - METRICS_BASELINE_V2, LOGS_BASELINE_V2, TRACES_BASELINE_V2 ║
* ║ - Create a new adapter (e.g., V2~) that uses the new baselines ║
* ║ - Keep the old baselines untouched for backwards compatibility ║
* ╚════════════════════════════════════════════════════════════════════════════╝
*/
import getBaselineByTag, { pickBaseline } from '../baseline';
import { METRICS_BASELINE_V1 } from 'lib/compositeQuery/baseline.metrics';
import { LOGS_BASELINE_V1 } from 'lib/compositeQuery/baseline.logs';
import { TRACES_BASELINE_V1 } from 'lib/compositeQuery/baseline.traces';
describe('baseline immutability snapshots', () => {
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('METRICS_BASELINE_V1 must never change', () => {
expect(METRICS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('LOGS_BASELINE_V1 must never change', () => {
expect(LOGS_BASELINE_V1).toMatchSnapshot();
});
/**
* ⛔ DO NOT UPDATE THIS SNAPSHOT ⛔
* If this fails, you broke URL compatibility. Revert your changes.
*/
it('TRACES_BASELINE_V1 must never change', () => {
expect(TRACES_BASELINE_V1).toMatchSnapshot();
});
});
describe('pickBaseline', () => {
it('returns metrics baseline for metrics dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'metrics' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('returns logs baseline for logs dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'logs' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(LOGS_BASELINE_V1);
expect(result.tag).toBe('l');
});
it('returns traces baseline for traces dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'traces' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(TRACES_BASELINE_V1);
expect(result.tag).toBe('t');
});
it('defaults to metrics baseline for unknown dataSource', () => {
const query = {
builder: { queryData: [{ dataSource: 'unknown' }] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
it('defaults to metrics baseline when queryData is empty', () => {
const query = {
builder: { queryData: [] },
} as any;
const result = pickBaseline(query);
expect(result.baseline).toBe(METRICS_BASELINE_V1);
expect(result.tag).toBe('m');
});
});
describe('getBaselineByTag', () => {
it('returns LOGS_BASELINE_V1 for tag "l"', () => {
expect(getBaselineByTag('l')).toBe(LOGS_BASELINE_V1);
});
it('returns TRACES_BASELINE_V1 for tag "t"', () => {
expect(getBaselineByTag('t')).toBe(TRACES_BASELINE_V1);
});
it('returns METRICS_BASELINE_V1 for tag "m"', () => {
expect(getBaselineByTag('m')).toBe(METRICS_BASELINE_V1);
});
});

View File

@@ -0,0 +1,51 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import {
clearSerializedParams,
deserialize,
serialize,
} from 'lib/compositeQuery/serializer';
describe('composite query serializer', () => {
it('round-trips through serialize/deserialize', () => {
const query = initialQueriesMap.logs;
const decoded = deserialize(serialize(query));
expect(decoded?.builder.queryData[0].dataSource).toBe('logs');
});
it('returns null on corrupt input instead of throwing', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
expect(deserialize(params)).toBeNull();
});
it('returns null for empty/missing value', () => {
const params = new URLSearchParams();
expect(deserialize(params)).toBeNull();
});
it('preserves id field through roundtrip', () => {
const query = { ...initialQueriesMap.metrics, id: 'test-query-uuid-123' };
const serialized = serialize(query);
const decoded = deserialize(serialized);
expect(decoded?.id).toBe('test-query-uuid-123');
});
it('clearSerializedParams purges every serialized key, leaving others intact', () => {
const params = serialize(initialQueriesMap.logs);
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(deserialize(params)).toBeNull();
expect(params.get('panelTypes')).toBe('list');
});
it('clearSerializedParams drops a corrupt legacy key via fallback', () => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, '%7Bnot-json');
params.set('panelTypes', 'list');
clearSerializedParams(params);
expect(params.has(COMPOSITE_QUERY_KEY)).toBe(false);
expect(params.get('panelTypes')).toBe('list');
});
});

View File

@@ -0,0 +1,63 @@
import {
convertAggregationToExpression,
convertFiltersToExpressionWithExistingQuery,
convertHavingToExpression,
} from 'components/QueryBuilderV2/utils';
import {
CompositeQueryAdapter,
COMPOSITE_QUERY_KEY,
} from 'lib/compositeQuery/types';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
function migrateLegacyFormat(parsed: Query): Query {
if (!parsed?.builder?.queryData) {
return parsed;
}
const next = parsed;
next.builder.queryData = parsed.builder.queryData.map((query) => {
const existingExpression = query.filter?.expression || '';
const convertedQuery = { ...query };
const convertedFilter = convertFiltersToExpressionWithExistingQuery(
query.filters || { items: [], op: 'AND' },
existingExpression,
);
convertedQuery.filter = convertedFilter.filter;
convertedQuery.filters = convertedFilter.filters;
if (Array.isArray(query.having)) {
convertedQuery.having = convertHavingToExpression(query.having);
}
if (!query.aggregations && query.aggregateOperator) {
convertedQuery.aggregations = convertAggregationToExpression({
aggregateOperator: query.aggregateOperator,
aggregateAttribute: query.aggregateAttribute as BaseAutocompleteData,
dataSource: query.dataSource,
timeAggregation: query.timeAggregation,
spaceAggregation: query.spaceAggregation,
reduceTo: query.reduceTo,
temporality: query.temporality,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any;
}
return convertedQuery;
});
return next;
}
export const jsonAdapter: CompositeQueryAdapter = {
name: 'json(legacy)',
encode: (query) => {
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(query)));
return params;
},
matches: () => true,
decode: (params) => {
const raw = params.get(COMPOSITE_QUERY_KEY) ?? '';
const parsed: Query = JSON.parse(decodeURIComponent(raw.replace(/\+/g, ' ')));
return migrateLegacyFormat(parsed);
},
};

View File

@@ -0,0 +1,74 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { COMPOSITE_QUERY_KEY } from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { jsonAdapter } from './index';
const roundTrip = (query: Query): Query =>
jsonAdapter.decode(jsonAdapter.encode(query));
describe('jsonAdapter', () => {
describe('round-trip', () => {
it.each(['metrics', 'logs', 'traces'] as const)(
'round-trips %s baseline preserving dataSource',
(source) => {
const query = initialQueriesMap[source];
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].dataSource).toBe(source);
},
);
});
describe('legacy format compatibility', () => {
it('encodes to legacy format (encodeURIComponent + JSON)', () => {
const query = initialQueriesMap.logs;
const params = jsonAdapter.encode(query);
const encoded = params.get(COMPOSITE_QUERY_KEY) ?? '';
expect(encoded).toBe(encodeURIComponent(JSON.stringify(query)));
expect(encoded.startsWith('%7B')).toBe(true);
});
});
describe('tag matching', () => {
it('matches any value (catch-all fallback)', () => {
const params1 = new URLSearchParams();
params1.set(COMPOSITE_QUERY_KEY, '%7B%22queryType%22%3A%22builder%22%7D');
expect(jsonAdapter.matches(params1)).toBe(true);
const params2 = new URLSearchParams();
params2.set(COMPOSITE_QUERY_KEY, 'z1~abc');
expect(jsonAdapter.matches(params2)).toBe(true);
});
});
describe('migration', () => {
it('migrates old format (filters -> filter.expression)', () => {
const legacy = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filters: { op: 'AND', items: [] },
aggregateOperator: 'count',
aggregateAttribute: { key: '', dataType: '', type: '' },
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [],
clickhouse_sql: [],
id: 'x',
unit: '',
};
const params = new URLSearchParams();
params.set(COMPOSITE_QUERY_KEY, encodeURIComponent(JSON.stringify(legacy)));
const decoded = jsonAdapter.decode(params);
expect(decoded.builder.queryData[0].filter).toBeDefined();
expect(decoded.builder.queryData[0].aggregations).toBeDefined();
});
});
});

View File

@@ -0,0 +1,81 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter encoding format field aliasing emits the short alias instead of the full field name: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=sum&query0.source="`;
exports[`qsAliasAdapter encoding format prefix substitution rewrites builder.queryData.0 to the query0 prefix: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is independent of source key order: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability is stable after spread / reconstruct: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter encoding format stability re-encoding after a decode is byte-identical: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -0,0 +1,225 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias leaf codec decodeLeaf falls back to raw text on a malformed tagged token (never throws): decoded-fallback 1`] = `
{
"fallback": "_not json",
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged empty containers: decoded-containers 1`] = `
{
"array": [],
"object": {},
}
`;
exports[`qsAlias leaf codec decodeLeaf parses tagged scalars back to their type: decoded-scalars 1`] = `
{
"false": false,
"negative": -4.5,
"null": null,
"number": 123,
"true": true,
}
`;
exports[`qsAlias leaf codec decodeLeaf returns untagged tokens as plain strings: decoded-strings 1`] = `
{
"123": "123",
"empty": "",
"null": "null",
"traces": "traces",
"true": "true",
}
`;
exports[`qsAlias leaf codec decodeLeaf unescapes a doubled-tag string: decoded-escaped 1`] = `
{
"__": "_",
"___name__": "__name__",
"__x": "_x",
}
`;
exports[`qsAlias leaf codec encodeLeaf emits strings verbatim: encoded-strings 1`] = `
{
"empty": "",
"service.name": "service.name",
"traces": "traces",
}
`;
exports[`qsAlias leaf codec encodeLeaf escapes a string that begins with the tag char by doubling it: encoded-escaped 1`] = `
{
"_": "__",
"__name__": "___name__",
"_x": "__x",
}
`;
exports[`qsAlias leaf codec encodeLeaf normalizes undefined to null: encoded-undefined 1`] = `
{
"undefined": "_null",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags empty containers: encoded-containers 1`] = `
{
"array": "_[]",
"object": "_{}",
}
`;
exports[`qsAlias leaf codec encodeLeaf type-tags non-string scalars with a leading underscore: encoded-scalars 1`] = `
{
"false": "_false",
"negative": "_-4.5",
"null": "_null",
"number": "_123",
"true": "_true",
}
`;
exports[`qsAlias leaf codec round-trip "" survives encode → decode: roundtrip-"" 1`] = `
{
"decoded": "",
"encoded": "",
"input": "",
}
`;
exports[`qsAlias leaf codec round-trip "_" survives encode → decode: roundtrip-"_" 1`] = `
{
"decoded": "_",
"encoded": "__",
"input": "_",
}
`;
exports[`qsAlias leaf codec round-trip "_leading" survives encode → decode: roundtrip-"_leading" 1`] = `
{
"decoded": "_leading",
"encoded": "__leading",
"input": "_leading",
}
`;
exports[`qsAlias leaf codec round-trip "123" survives encode → decode: roundtrip-"123" 1`] = `
{
"decoded": "123",
"encoded": "123",
"input": "123",
}
`;
exports[`qsAlias leaf codec round-trip "a=b&c#d%e+f.g" survives encode → decode: roundtrip-"a=b&c#d%e+f.g" 1`] = `
{
"decoded": "a=b&c#d%e+f.g",
"encoded": "a=b&c#d%e+f.g",
"input": "a=b&c#d%e+f.g",
}
`;
exports[`qsAlias leaf codec round-trip "false" survives encode → decode: roundtrip-"false" 1`] = `
{
"decoded": "false",
"encoded": "false",
"input": "false",
}
`;
exports[`qsAlias leaf codec round-trip "null" survives encode → decode: roundtrip-"null" 1`] = `
{
"decoded": "null",
"encoded": "null",
"input": "null",
}
`;
exports[`qsAlias leaf codec round-trip "service.name" survives encode → decode: roundtrip-"service.name" 1`] = `
{
"decoded": "service.name",
"encoded": "service.name",
"input": "service.name",
}
`;
exports[`qsAlias leaf codec round-trip "traces" survives encode → decode: roundtrip-"traces" 1`] = `
{
"decoded": "traces",
"encoded": "traces",
"input": "traces",
}
`;
exports[`qsAlias leaf codec round-trip "true" survives encode → decode: roundtrip-"true" 1`] = `
{
"decoded": "true",
"encoded": "true",
"input": "true",
}
`;
exports[`qsAlias leaf codec round-trip [] survives encode → decode: roundtrip-[] 1`] = `
{
"decoded": [],
"encoded": "_[]",
"input": [],
}
`;
exports[`qsAlias leaf codec round-trip {} survives encode → decode: roundtrip-{} 1`] = `
{
"decoded": {},
"encoded": "_{}",
"input": {},
}
`;
exports[`qsAlias leaf codec round-trip -4.5 survives encode → decode: roundtrip--4.5 1`] = `
{
"decoded": -4.5,
"encoded": "_-4.5",
"input": -4.5,
}
`;
exports[`qsAlias leaf codec round-trip 0 survives encode → decode: roundtrip-0 1`] = `
{
"decoded": 0,
"encoded": "_0",
"input": 0,
}
`;
exports[`qsAlias leaf codec round-trip 123 survives encode → decode: roundtrip-123 1`] = `
{
"decoded": 123,
"encoded": "_123",
"input": 123,
}
`;
exports[`qsAlias leaf codec round-trip false survives encode → decode: roundtrip-false 1`] = `
{
"decoded": false,
"encoded": "_false",
"input": false,
}
`;
exports[`qsAlias leaf codec round-trip null survives encode → decode: roundtrip-null 1`] = `
{
"decoded": null,
"encoded": "_null",
"input": null,
}
`;
exports[`qsAlias leaf codec round-trip true survives encode → decode: roundtrip-true 1`] = `
{
"decoded": true,
"encoded": "_true",
"input": true,
}
`;

View File

@@ -0,0 +1,388 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAlias maps FIELD_ALIASES integrity FIELD_REVERSE is the exact inverse of FIELD_ALIASES: all-reverse 1`] = `
{
"aggAttr": "aggregateAttribute",
"aggOp": "aggregateOperator",
"ds": "dataSource",
"dt": "dataType",
"ic": "isColumn",
"ij": "isJSON",
"mn": "metricName",
"qn": "queryName",
"qt": "queryType",
"spaceAgg": "spaceAggregation",
"stepIn": "stepInterval",
"timeAgg": "timeAggregation",
"tp": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES integrity alias values are unique (no two fields share an alias): all-aliases 1`] = `
{
"aggregateAttribute": "aggAttr",
"aggregateOperator": "aggOp",
"dataSource": "ds",
"dataType": "dt",
"isColumn": "ic",
"isJSON": "ij",
"metricName": "mn",
"queryName": "qn",
"queryType": "qt",
"spaceAggregation": "spaceAgg",
"stepInterval": "stepIn",
"temporality": "tp",
"timeAggregation": "timeAgg",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateAttribute ⇄ aggAttr via aliasField / expandField: alias-aggregateAttribute 1`] = `
{
"alias": "aggAttr",
"aliased": "aggAttr",
"expanded": "aggregateAttribute",
"field": "aggregateAttribute",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips aggregateOperator ⇄ aggOp via aliasField / expandField: alias-aggregateOperator 1`] = `
{
"alias": "aggOp",
"aliased": "aggOp",
"expanded": "aggregateOperator",
"field": "aggregateOperator",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataSource ⇄ ds via aliasField / expandField: alias-dataSource 1`] = `
{
"alias": "ds",
"aliased": "ds",
"expanded": "dataSource",
"field": "dataSource",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips dataType ⇄ dt via aliasField / expandField: alias-dataType 1`] = `
{
"alias": "dt",
"aliased": "dt",
"expanded": "dataType",
"field": "dataType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isColumn ⇄ ic via aliasField / expandField: alias-isColumn 1`] = `
{
"alias": "ic",
"aliased": "ic",
"expanded": "isColumn",
"field": "isColumn",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips isJSON ⇄ ij via aliasField / expandField: alias-isJSON 1`] = `
{
"alias": "ij",
"aliased": "ij",
"expanded": "isJSON",
"field": "isJSON",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips metricName ⇄ mn via aliasField / expandField: alias-metricName 1`] = `
{
"alias": "mn",
"aliased": "mn",
"expanded": "metricName",
"field": "metricName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryName ⇄ qn via aliasField / expandField: alias-queryName 1`] = `
{
"alias": "qn",
"aliased": "qn",
"expanded": "queryName",
"field": "queryName",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips queryType ⇄ qt via aliasField / expandField: alias-queryType 1`] = `
{
"alias": "qt",
"aliased": "qt",
"expanded": "queryType",
"field": "queryType",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips spaceAggregation ⇄ spaceAgg via aliasField / expandField: alias-spaceAggregation 1`] = `
{
"alias": "spaceAgg",
"aliased": "spaceAgg",
"expanded": "spaceAggregation",
"field": "spaceAggregation",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips stepInterval ⇄ stepIn via aliasField / expandField: alias-stepInterval 1`] = `
{
"alias": "stepIn",
"aliased": "stepIn",
"expanded": "stepInterval",
"field": "stepInterval",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips temporality ⇄ tp via aliasField / expandField: alias-temporality 1`] = `
{
"alias": "tp",
"aliased": "tp",
"expanded": "temporality",
"field": "temporality",
}
`;
exports[`qsAlias maps FIELD_ALIASES — every key round-trips timeAggregation ⇄ timeAgg via aliasField / expandField: alias-timeAggregation 1`] = `
{
"alias": "timeAgg",
"aliased": "timeAgg",
"expanded": "timeAggregation",
"field": "timeAggregation",
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips chsql ⇄ [["clickhouse_sql"]] via transformPath / expandPath: prefix-chsql 1`] = `
{
"expanded": [
"clickhouse_sql",
0,
"someField",
],
"match": [
"clickhouse_sql",
],
"prefix": "chsql",
"transformed": [
"chsql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips formula ⇄ [["builder", "queryFormulas"]] via transformPath / expandPath: prefix-formula 1`] = `
{
"expanded": [
"builder",
"queryFormulas",
0,
"someField",
],
"match": [
"builder",
"queryFormulas",
],
"prefix": "formula",
"transformed": [
"formula0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips handles multi-digit indices: multi-digit 1`] = `
{
"expanded": [
"builder",
"queryData",
12,
"x",
],
"prefix": "query",
"transformed": [
"query12",
"x",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips promql ⇄ [["promql"]] via transformPath / expandPath: prefix-promql 1`] = `
{
"expanded": [
"promql",
0,
"someField",
],
"match": [
"promql",
],
"prefix": "promql",
"transformed": [
"promql0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips query ⇄ [["builder", "queryData"]] via transformPath / expandPath: prefix-query 1`] = `
{
"expanded": [
"builder",
"queryData",
0,
"someField",
],
"match": [
"builder",
"queryData",
],
"prefix": "query",
"transformed": [
"query0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_PATTERNS — every prefix round-trips traceOp ⇄ [["builder", "queryTraceOperator"]] via transformPath / expandPath: prefix-traceOp 1`] = `
{
"expanded": [
"builder",
"queryTraceOperator",
0,
"someField",
],
"match": [
"builder",
"queryTraceOperator",
],
"prefix": "traceOp",
"transformed": [
"traceOp0",
"someField",
],
}
`;
exports[`qsAlias maps PREFIX_REVERSE consistency mirrors PREFIX_PATTERNS one-to-one: all-prefix-reverse 1`] = `
{
"chsql": [
"clickhouse_sql",
],
"formula": [
"builder",
"queryFormulas",
],
"promql": [
"promql",
],
"query": [
"builder",
"queryData",
],
"traceOp": [
"builder",
"queryTraceOperator",
],
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric path segments untouched: numeric-passthrough 1`] = `
{
"seven": 7,
"zero": 0,
}
`;
exports[`qsAlias maps alias / expand passthrough leaves numeric-string segments untouched in expandField: numeric-string-passthrough 1`] = `
{
"fortyTwo": "42",
"zero": "0",
}
`;
exports[`qsAlias maps alias / expand passthrough leaves unknown field names untouched: unknown-passthrough 1`] = `
{
"aliasUnknown": "unknownField",
"expandUnknown": "zz",
}
`;
exports[`qsAlias maps isOwnedKey matches chsql prefix with index: owned-chsql 1`] = `
{
"chsql0": true,
"chsql0.field": true,
"chsql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches delete-prefixed keys: delete-prefixed 1`] = `
{
"-formula0": true,
"-query0.field": true,
}
`;
exports[`qsAlias maps isOwnedKey matches formula prefix with index: owned-formula 1`] = `
{
"formula0": true,
"formula0.field": true,
"formula12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches promql prefix with index: owned-promql 1`] = `
{
"promql0": true,
"promql0.field": true,
"promql12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches query prefix with index: owned-query 1`] = `
{
"query0": true,
"query0.field": true,
"query12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey matches the tag key: tag-key 1`] = `
{
"_t": true,
}
`;
exports[`qsAlias maps isOwnedKey matches top-level query keys: top-level-keys 1`] = `
{
"id": true,
"qt": true,
"queryType": true,
"unit": true,
}
`;
exports[`qsAlias maps isOwnedKey matches traceOp prefix with index: owned-traceOp 1`] = `
{
"traceOp0": true,
"traceOp0.field": true,
"traceOp12.nested.path": true,
}
`;
exports[`qsAlias maps isOwnedKey rejects foreign params: foreign-params 1`] = `
{
"compositeQuery": false,
"endTime": false,
"panelTypes": false,
"startTime": false,
}
`;
exports[`qsAlias maps isOwnedKey rejects prefix without index: prefix-without-index 1`] = `
{
"formula": false,
"query": false,
}
`;

View File

@@ -0,0 +1,795 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip decoded query keeps exactly the source top-level keys: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip is lodash isEqual to the source (ignoring volatile id): url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "SELECT count() FROM signoz_logs",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "clickhouse_sql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios clickhouse query survives encode → decode: clickhouse query-url 1`] = `"_t=QAm&chsql0.query=SELECT+count%28%29+FROM+signoz_logs&id=test-stable-id&qt=clickhouse_sql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios custom id survives encode → decode: custom id-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "sum",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios enum-like legend preserved survives encode → decode: enum-like legend preserved-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.legend=sum&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios logs baseline survives encode → decode: logs baseline-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios metrics baseline survives encode → decode: metrics baseline-url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "p95",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": true,
"expression": "A",
"filter": {
"expression": "severity_text = 'ERROR'",
},
"filters": {
"items": [
{
"id": "item-1",
"key": {
"dataType": "string",
"isColumn": false,
"isJSON": false,
"key": "severity_text",
"type": "tag",
},
"op": "=",
"value": "ERROR",
},
],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "error rate",
"limit": null,
"orderBy": [
{
"columnName": "timestamp",
"order": "desc",
},
],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": 60,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios modified builder query survives encode → decode: modified builder query-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=p95&query0.disabled=_true&query0.filter.expression=severity_text+%3D+%27ERROR%27&query0.filters.items.0.id=item-1&query0.filters.items.0.key.dt=string&query0.filters.items.0.key.ic=_false&query0.filters.items.0.key.ij=_false&query0.filters.items.0.key.key=severity_text&query0.filters.items.0.key.type=tag&query0.filters.items.0.op=%3D&query0.filters.items.0.value=ERROR&query0.legend=error+rate&query0.orderBy.0.columnName=timestamp&query0.orderBy.0.order=desc&query0.source=&query0.stepIn=_60"`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "rate(http_requests_total[5m])",
},
],
"queryType": "promql",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios promql query survives encode → decode: promql query-url 1`] = `"_t=QAm&id=test-stable-id&promql0.query=rate%28http_requests_total%5B5m%5D%29&qt=promql&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios traces baseline survives encode → decode: traces baseline-url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "!weird = "x_y*z"",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "_a*b_*c",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter round-trip scenarios wire delimiters in values survives encode → decode: wire delimiters in values-url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.filter.expression=%21weird+%3D+%22x_y*z%22&query0.legend=__a*b_*c&query0.source="`;

View File

@@ -0,0 +1,277 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`qsAliasAdapter tagging encode tags by dataSource logs → QAl: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource metrics → QAm: url 1`] = `"_t=QAm&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging encode tags by dataSource traces → QAt: url 1`] = `"_t=QAt&id=test-stable-id&query0.aggOp=count&query0.source="`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAl decodes to the logs baseline: decoded-QAl 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAm decodes to the metrics baseline: decoded-QAm 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "noop",
"aggregations": [
{
"metricName": "",
"reduceTo": "avg",
"spaceAggregation": "sum",
"temporality": "",
"timeAggregation": "avg",
},
],
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline QAt decodes to the traces baseline: decoded-QAt 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": null,
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "traces",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": null,
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: decoded 1`] = `
{
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "",
"id": "----",
"key": "",
"type": "",
},
"aggregateOperator": "count",
"aggregations": [
{
"expression": "count() ",
},
],
"dataSource": "logs",
"disabled": false,
"expression": "A",
"filter": {
"expression": "",
},
"filters": {
"items": [],
"op": "AND",
},
"functions": [],
"groupBy": [],
"having": [],
"legend": "",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"source": "",
"spaceAggregation": "sum",
"stepInterval": null,
"timeAggregation": "rate",
},
],
"queryFormulas": [],
"queryTraceOperator": [],
},
"clickhouse_sql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"id": "test-stable-id",
"promql": [
{
"disabled": false,
"legend": "",
"name": "A",
"query": "",
},
],
"queryType": "builder",
"unit": "",
}
`;
exports[`qsAliasAdapter tagging tag-only decode returns the baseline round-trips the baseline with no extra params: url 1`] = `"_t=QAl&id=test-stable-id&query0.aggOp=count&query0.source="`;

View File

@@ -0,0 +1,213 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const makeFilterItem = (value: string): any => ({
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
},
id: `item-${value}`,
op: '=',
value,
});
describe('qsAliasAdapter edge cases', () => {
describe('baseline field deletion', () => {
it('emits a delete token and decode drops the field', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
delete (query.builder.queryData[0] as any).aggregateOperator;
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('-query0.aggOp');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect('aggregateOperator' in decoded.builder.queryData[0]).toBe(false);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('array growth', () => {
it('round-trips multiple added filter items element-wise', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filters = {
op: 'AND',
items: [makeFilterItem('a'), makeFilterItem('b')],
};
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.filters.items.0.');
expect(wire).toContain('query0.filters.items.1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('null and empty containers', () => {
it('round-trips a null leaf', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).legend = null;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-object leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].filter =
{} as Query['builder']['queryData'][0]['filter'];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips an empty-array leaf', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].groupBy = [];
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('undefined values', () => {
it('does not break decode when fields are undefined', () => {
const query = clone(initialQueriesMap.logs);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).aggregateOperator = undefined;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(query.builder.queryData[0] as any).source = undefined;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
expect(() => roundTrip(query)).not.toThrow();
const decoded = roundTrip(query);
expect(decoded).not.toBeNull();
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
/**
* The wire type-tags non-strings (`_123`, `_true`, `_null`) and emits strings
* verbatim, while qs percent-encodes values. Every scalar therefore
* round-trips losslessly — including strings that look like numbers/booleans
* or contain query-string delimiters.
*/
describe('tricky scalar values (lossless)', () => {
it('keeps a numeric-looking string as a string', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '123';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps "true" / "false" / "null" string values as strings', () => {
['true', 'false', 'null'].forEach((literal) => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = literal;
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`url-${literal}`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${literal}`);
});
});
it('preserves a value containing the ampersand delimiter', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'x&y';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves assorted wire-special characters', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = 'a=b&c#d%e+f.g';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('preserves a string that begins with the type-tag char', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_underscored';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('scalar type fidelity', () => {
it('keeps number and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].stepInterval = 300;
query.builder.queryData[0].legend = '300';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].stepInterval).toBe(300);
expect(decoded.builder.queryData[0].legend).toBe('300');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('keeps boolean and look-alike string distinct', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].disabled = true;
query.builder.queryData[0].legend = 'true';
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded.builder.queryData[0].disabled).toBe(true);
expect(decoded.builder.queryData[0].legend).toBe('true');
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,89 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = (query: Query): Query =>
JSON.parse(JSON.stringify(query)) as Query;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
describe('qsAliasAdapter encoding format', () => {
describe('prefix substitution', () => {
it('rewrites builder.queryData.0 to the query0 prefix', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData[0].aggregateOperator = 'count';
const encoded = qsAliasAdapter.encode(query);
const keys = Array.from(encoded.keys());
expect(keys.some((k) => k.startsWith('query0.'))).toBe(true);
expect(keys.some((k) => k.includes('queryData'))).toBe(false);
expect(keys.some((k) => k.includes('builder'))).toBe(false);
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('field aliasing', () => {
it('emits the short alias instead of the full field name', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].aggregateOperator = 'sum';
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.aggOp=');
expect(wire).not.toContain('aggregateOperator');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('stability', () => {
it('re-encoding after a decode is byte-identical', () => {
const encoded1 = qsAliasAdapter.encode(initialQueriesMap.metrics);
const encoded2 = qsAliasAdapter.encode(qsAliasAdapter.decode(encoded1));
expect(encoded2.toString()).toBe(encoded1.toString());
expect(normalizeUrl(encoded1.toString())).toMatchSnapshot('url');
expect(normalizeId(qsAliasAdapter.decode(encoded1))).toMatchSnapshot(
'decoded',
);
});
it('is independent of source key order', () => {
const query1 = initialQueriesMap.metrics;
const query2 = JSON.parse(JSON.stringify(query1)) as Query;
const reordered = {
unit: query2.unit,
id: query2.id,
queryType: query2.queryType,
clickhouse_sql: query2.clickhouse_sql,
promql: query2.promql,
builder: query2.builder,
} as Query;
const wire1 = qsAliasAdapter.encode(query1).toString();
const wire2 = qsAliasAdapter.encode(reordered).toString();
expect(wire2).toBe(wire1);
expect(normalizeUrl(wire1)).toMatchSnapshot('url');
});
it('is stable after spread / reconstruct', () => {
const query = { ...initialQueriesMap.metrics };
const transformed = {
...query,
builder: {
...query.builder,
queryData: query.builder.queryData.map((item) => ({ ...item })),
},
};
const wire = qsAliasAdapter.encode(transformed).toString();
expect(wire).toBe(qsAliasAdapter.encode(query).toString());
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
});

View File

@@ -0,0 +1,154 @@
import { Json } from '../diff/predicates';
import { decodeLeaf, encodeLeaf } from '../leaf';
describe('qsAlias leaf codec', () => {
describe('encodeLeaf', () => {
it('emits strings verbatim', () => {
expect(encodeLeaf('traces')).toBe('traces');
expect(encodeLeaf('service.name')).toBe('service.name');
expect(encodeLeaf('')).toBe('');
expect({
traces: encodeLeaf('traces'),
'service.name': encodeLeaf('service.name'),
empty: encodeLeaf(''),
}).toMatchSnapshot('encoded-strings');
});
it('type-tags non-string scalars with a leading underscore', () => {
expect(encodeLeaf(123)).toBe('_123');
expect(encodeLeaf(-4.5)).toBe('_-4.5');
expect(encodeLeaf(true)).toBe('_true');
expect(encodeLeaf(false)).toBe('_false');
expect(encodeLeaf(null)).toBe('_null');
expect({
number: encodeLeaf(123),
negative: encodeLeaf(-4.5),
true: encodeLeaf(true),
false: encodeLeaf(false),
null: encodeLeaf(null),
}).toMatchSnapshot('encoded-scalars');
});
it('type-tags empty containers', () => {
expect(encodeLeaf([])).toBe('_[]');
expect(encodeLeaf({})).toBe('_{}');
expect({
array: encodeLeaf([]),
object: encodeLeaf({}),
}).toMatchSnapshot('encoded-containers');
});
it('normalizes undefined to null', () => {
expect(encodeLeaf(undefined)).toBe('_null');
expect({ undefined: encodeLeaf(undefined) }).toMatchSnapshot(
'encoded-undefined',
);
});
it('escapes a string that begins with the tag char by doubling it', () => {
expect(encodeLeaf('_x')).toBe('__x');
expect(encodeLeaf('_')).toBe('__');
expect(encodeLeaf('__name__')).toBe('___name__');
expect({
_x: encodeLeaf('_x'),
_: encodeLeaf('_'),
__name__: encodeLeaf('__name__'),
}).toMatchSnapshot('encoded-escaped');
});
});
describe('decodeLeaf', () => {
it('returns untagged tokens as plain strings', () => {
expect(decodeLeaf('traces')).toBe('traces');
expect(decodeLeaf('123')).toBe('123');
expect(decodeLeaf('true')).toBe('true');
expect(decodeLeaf('null')).toBe('null');
expect(decodeLeaf('')).toBe('');
expect({
traces: decodeLeaf('traces'),
'123': decodeLeaf('123'),
true: decodeLeaf('true'),
null: decodeLeaf('null'),
empty: decodeLeaf(''),
}).toMatchSnapshot('decoded-strings');
});
it('parses tagged scalars back to their type', () => {
expect(decodeLeaf('_123')).toBe(123);
expect(decodeLeaf('_-4.5')).toBe(-4.5);
expect(decodeLeaf('_true')).toBe(true);
expect(decodeLeaf('_false')).toBe(false);
expect(decodeLeaf('_null')).toBeNull();
expect({
number: decodeLeaf('_123'),
negative: decodeLeaf('_-4.5'),
true: decodeLeaf('_true'),
false: decodeLeaf('_false'),
null: decodeLeaf('_null'),
}).toMatchSnapshot('decoded-scalars');
});
it('parses tagged empty containers', () => {
expect(decodeLeaf('_[]')).toStrictEqual([]);
expect(decodeLeaf('_{}')).toStrictEqual({});
expect({
array: decodeLeaf('_[]'),
object: decodeLeaf('_{}'),
}).toMatchSnapshot('decoded-containers');
});
it('unescapes a doubled-tag string', () => {
expect(decodeLeaf('__x')).toBe('_x');
expect(decodeLeaf('__')).toBe('_');
expect(decodeLeaf('___name__')).toBe('__name__');
expect({
__x: decodeLeaf('__x'),
__: decodeLeaf('__'),
___name__: decodeLeaf('___name__'),
}).toMatchSnapshot('decoded-escaped');
});
it('falls back to raw text on a malformed tagged token (never throws)', () => {
expect(() => decodeLeaf('_not json')).not.toThrow();
expect(decodeLeaf('_not json')).toBe('_not json');
expect({ fallback: decodeLeaf('_not json') }).toMatchSnapshot(
'decoded-fallback',
);
});
});
describe('round-trip', () => {
const cases: Json[] = [
'traces',
'',
'123',
'true',
'false',
'null',
'_leading',
'_',
'a=b&c#d%e+f.g',
'service.name',
0,
123,
-4.5,
true,
false,
null,
[],
{},
];
it.each(cases.map((value) => [JSON.stringify(value), value] as const))(
'%s survives encode → decode',
(label, value) => {
const encoded = encodeLeaf(value);
const decoded = decodeLeaf(encoded);
expect(decoded).toStrictEqual(value);
expect({ input: value, encoded, decoded }).toMatchSnapshot(
`roundtrip-${label}`,
);
},
);
});
});

View File

@@ -0,0 +1,179 @@
import { aliasField, expandField, expandPath, transformPath } from '../codec';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from '../maps';
describe('qsAlias maps', () => {
describe('FIELD_ALIASES — every key round-trips', () => {
it.each(Object.entries(FIELD_ALIASES))(
'%s ⇄ %s via aliasField / expandField',
(field, alias) => {
expect(aliasField(field)).toBe(alias);
expect(expandField(alias)).toBe(field);
expect({
field,
alias,
aliased: aliasField(field),
expanded: expandField(alias),
}).toMatchSnapshot(`alias-${field}`);
},
);
});
describe('FIELD_ALIASES integrity', () => {
it('alias values are unique (no two fields share an alias)', () => {
const values = Object.values(FIELD_ALIASES);
expect(new Set(values).size).toBe(values.length);
expect(FIELD_ALIASES).toMatchSnapshot('all-aliases');
});
it('no alias contains "." (would corrupt path splitting)', () => {
Object.values(FIELD_ALIASES).forEach((alias) => {
expect(alias).not.toContain('.');
});
});
it('FIELD_REVERSE is the exact inverse of FIELD_ALIASES', () => {
expect(FIELD_REVERSE).toStrictEqual(
Object.fromEntries(
Object.entries(FIELD_ALIASES).map(([key, value]) => [value, key]),
),
);
expect(FIELD_REVERSE).toMatchSnapshot('all-reverse');
});
});
describe('PREFIX_PATTERNS — every prefix round-trips', () => {
it.each(PREFIX_PATTERNS)(
'$prefix ⇄ [$match] via transformPath / expandPath',
({ match, prefix }) => {
const fullPath = [...match, 0, 'someField'];
const transformed = transformPath(fullPath);
const expanded = expandPath(`${prefix}0.someField`);
expect(transformed).toStrictEqual([`${prefix}0`, 'someField']);
expect(expanded).toStrictEqual([...match, 0, 'someField']);
expect({ prefix, match, transformed, expanded }).toMatchSnapshot(
`prefix-${prefix}`,
);
},
);
it('handles multi-digit indices', () => {
const { match, prefix } = PREFIX_PATTERNS[0];
const transformed = transformPath([...match, 12, 'x']);
const expanded = expandPath(`${prefix}12.x`);
expect(transformed).toStrictEqual([`${prefix}12`, 'x']);
expect(expanded).toStrictEqual([...match, 12, 'x']);
expect({ prefix, transformed, expanded }).toMatchSnapshot('multi-digit');
});
});
describe('PREFIX_REVERSE consistency', () => {
it('mirrors PREFIX_PATTERNS one-to-one', () => {
PREFIX_PATTERNS.forEach(({ match, prefix }) => {
expect(PREFIX_REVERSE[prefix]).toStrictEqual(match);
});
expect(Object.keys(PREFIX_REVERSE).sort()).toStrictEqual(
PREFIX_PATTERNS.map((pattern) => pattern.prefix).sort(),
);
expect(PREFIX_REVERSE).toMatchSnapshot('all-prefix-reverse');
});
});
describe('alias / expand passthrough', () => {
it('leaves numeric path segments untouched', () => {
expect(aliasField(0)).toBe(0);
expect(aliasField(7)).toBe(7);
expect({ zero: aliasField(0), seven: aliasField(7) }).toMatchSnapshot(
'numeric-passthrough',
);
});
it('leaves unknown field names untouched', () => {
expect(aliasField('unknownField')).toBe('unknownField');
expect(expandField('zz')).toBe('zz');
expect({
aliasUnknown: aliasField('unknownField'),
expandUnknown: expandField('zz'),
}).toMatchSnapshot('unknown-passthrough');
});
it('leaves numeric-string segments untouched in expandField', () => {
expect(expandField('0')).toBe('0');
expect(expandField('42')).toBe('42');
expect({
zero: expandField('0'),
fortyTwo: expandField('42'),
}).toMatchSnapshot('numeric-string-passthrough');
});
});
describe('isOwnedKey', () => {
it('matches the tag key', () => {
expect(isOwnedKey('_t')).toBe(true);
expect({ _t: isOwnedKey('_t') }).toMatchSnapshot('tag-key');
});
it.each(PREFIX_PATTERNS.map((p) => p.prefix))(
'matches %s prefix with index',
(prefix) => {
expect(isOwnedKey(`${prefix}0`)).toBe(true);
expect(isOwnedKey(`${prefix}0.field`)).toBe(true);
expect(isOwnedKey(`${prefix}12.nested.path`)).toBe(true);
expect({
[`${prefix}0`]: isOwnedKey(`${prefix}0`),
[`${prefix}0.field`]: isOwnedKey(`${prefix}0.field`),
[`${prefix}12.nested.path`]: isOwnedKey(`${prefix}12.nested.path`),
}).toMatchSnapshot(`owned-${prefix}`);
},
);
it('matches delete-prefixed keys', () => {
expect(isOwnedKey('-query0.field')).toBe(true);
expect(isOwnedKey('-formula0')).toBe(true);
expect({
'-query0.field': isOwnedKey('-query0.field'),
'-formula0': isOwnedKey('-formula0'),
}).toMatchSnapshot('delete-prefixed');
});
it('matches top-level query keys', () => {
expect(isOwnedKey('id')).toBe(true);
expect(isOwnedKey('queryType')).toBe(true);
expect(isOwnedKey('qt')).toBe(true);
expect(isOwnedKey('unit')).toBe(true);
expect({
id: isOwnedKey('id'),
queryType: isOwnedKey('queryType'),
qt: isOwnedKey('qt'),
unit: isOwnedKey('unit'),
}).toMatchSnapshot('top-level-keys');
});
it('rejects foreign params', () => {
expect(isOwnedKey('panelTypes')).toBe(false);
expect(isOwnedKey('startTime')).toBe(false);
expect(isOwnedKey('endTime')).toBe(false);
expect(isOwnedKey('compositeQuery')).toBe(false);
expect({
panelTypes: isOwnedKey('panelTypes'),
startTime: isOwnedKey('startTime'),
endTime: isOwnedKey('endTime'),
compositeQuery: isOwnedKey('compositeQuery'),
}).toMatchSnapshot('foreign-params');
});
it('rejects prefix without index', () => {
expect(isOwnedKey('query')).toBe(false);
expect(isOwnedKey('formula')).toBe(false);
expect({
query: isOwnedKey('query'),
formula: isOwnedKey('formula'),
}).toMatchSnapshot('prefix-without-index');
});
});
});

View File

@@ -0,0 +1,364 @@
import {
initialQueriesMap,
initialQueryBuilderFormValuesMap,
} from 'constants/queryBuilder';
import {
IBuilderFormula,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
const makeSecondBuilderQuery = (name: string): IBuilderQuery => ({
...clone(initialQueryBuilderFormValuesMap.metrics),
queryName: name,
aggregateOperator: 'avg',
legend: `${name} legend`,
});
const makeFormula = (name: string, expression: string): IBuilderFormula => ({
queryName: name,
expression,
disabled: false,
legend: `${name} result`,
});
describe('qsAliasAdapter multi-queryData', () => {
describe('multiple builder queries', () => {
it('round-trips two queryData entries (A + B)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three queryData entries (A + B + C)', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('formula queries', () => {
it('round-trips single formula F1 = A/B', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips multiple formulas F1 + F2', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryData.push(makeSecondBuilderQuery('C'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
query.builder.queryFormulas.push(makeFormula('F2', 'A*100/C'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips formula with complex expression', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', '(A - B) / B * 100'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple clickhouse queries', () => {
it('round-trips two clickhouse_sql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query =
'SELECT count() FROM logs WHERE severity > 0';
query.clickhouse_sql.push({
name: 'B',
legend: 'total',
disabled: false,
query: 'SELECT count() FROM logs',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three clickhouse_sql entries with mixed disabled states', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT 1';
query.clickhouse_sql.push({
name: 'B',
legend: 'second',
disabled: true,
query: 'SELECT 2',
});
query.clickhouse_sql.push({
name: 'C',
legend: '',
disabled: false,
query: 'SELECT 3',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('multiple promql queries', () => {
it('round-trips two promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
query.promql.push({
name: 'B',
legend: 'errors',
disabled: false,
query: 'rate(http_errors_total[5m])',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips three promql entries', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'metric_a';
query.promql.push({
name: 'B',
legend: 'b-legend',
disabled: false,
query: 'metric_b',
});
query.promql.push({
name: 'C',
legend: '',
disabled: true,
query: 'metric_c',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('mixed data sources within builder', () => {
it('round-trips logs queryData with formulas', () => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.logs),
queryName: 'B',
aggregateOperator: 'count_distinct',
});
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('round-trips traces queryData with formulas', () => {
const query = clone(initialQueriesMap.traces);
query.builder.queryData.push({
...clone(initialQueryBuilderFormValuesMap.traces),
queryName: 'B',
aggregateOperator: 'p99',
});
query.builder.queryFormulas.push(makeFormula('F1', 'B - A'));
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
describe('wire format verification', () => {
it('encodes multiple queryData with indexed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('query0.');
expect(wire).toContain('query1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes formulas with formula-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push(makeSecondBuilderQuery('B'));
query.builder.queryFormulas.push(makeFormula('F1', 'A/B'));
const wire = qsAliasAdapter.encode(query).toString();
expect(query.builder.queryFormulas).toHaveLength(1);
expect(query.builder.queryFormulas[0].queryName).toBe('F1');
expect(wire).toContain('formula0.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes clickhouse with chsql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql.push({
name: 'B',
legend: '',
disabled: false,
query: 'SELECT 1',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('chsql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
it('encodes promql with promql-prefixed keys', () => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql.push({
name: 'B',
legend: '',
disabled: false,
query: 'metric_b',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(wire).toContain('promql1.');
expect(normalizeUrl(wire)).toMatchSnapshot('url');
});
});
describe('template diffing optimization', () => {
it('added queryData only emits changed fields vs baseline[0]', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
legend: 'B query',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const query1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('query1.'),
);
// Should have ~4-5 params (qn, aggOp, legend, source), not ~25
expect(query1Params.length).toBeLessThan(10);
// Should NOT have unchanged fields
expect(wire).not.toContain('query1.filters.op');
expect(wire).not.toContain('query1.groupBy');
expect(wire).not.toContain('query1.having');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('decoder correctly reconstructs from template-diffed wire', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData.push({
...clone(query.builder.queryData[0]),
queryName: 'B',
aggregateOperator: 'avg',
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
// Wire should be compact
expect(wire).not.toContain('query1.filters.op');
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('works for queryFormulas with template inheritance', () => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryFormulas.push(makeFormula('F1', 'A'));
query.builder.queryFormulas.push({
...makeFormula('F2', 'B'),
disabled: true,
});
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const params = new URLSearchParams(wire);
const f1Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula0.'),
);
const f2Params = Array.from(params.keys()).filter((k) =>
k.startsWith('formula1.'),
);
// F2 should be smaller or equal (diffs against F1)
expect(f2Params.length).toBeLessThanOrEqual(f1Params.length);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,52 @@
import { isEqual } from 'lodash-es';
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { roundTripScenarios } from '../../testing/scenarios';
import { qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const roundTrip = (query: Query): Query =>
qsAliasAdapter.decode(qsAliasAdapter.encode(query));
describe('qsAliasAdapter round-trip', () => {
describe('scenarios', () => {
it.each(roundTripScenarios)(
'$name survives encode → decode',
({ query, name }) => {
const wire = qsAliasAdapter.encode(query).toString();
expect(normalizeUrl(wire)).toMatchSnapshot(`${name}-url`);
const decoded = roundTrip(query);
expect(decoded).toStrictEqual(query);
expect(normalizeId(decoded)).toMatchSnapshot(`${name}-decoded`);
},
);
});
it('decoded query keeps exactly the source top-level keys', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
expect(Object.keys(decoded).sort()).toStrictEqual(
Object.keys(initialQueriesMap.metrics).sort(),
);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
it('is lodash isEqual to the source (ignoring volatile id)', () => {
const wire = qsAliasAdapter.encode(initialQueriesMap.metrics).toString();
expect(normalizeUrl(wire)).toMatchSnapshot('url');
const decoded = roundTrip(initialQueriesMap.metrics);
const { id: _sourceId, ...source } = initialQueriesMap.metrics;
const { id: _decodedId, ...result } = decoded;
expect(isEqual(source, result)).toBe(true);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});

View File

@@ -0,0 +1,92 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { decodeQsAlias, encodeQsAlias, qsAliasAdapter } from '../index';
const STABLE_ID = 'test-stable-id';
const normalizeId = (query: Query): Query => ({ ...query, id: STABLE_ID });
const normalizeUrl = (url: string): string =>
url.replace(/id=[^&]+/, `id=${STABLE_ID}`);
const tagOf = (params: URLSearchParams): string => params.get('_t') ?? '';
describe('qsAliasAdapter tagging', () => {
describe('encode tags by dataSource', () => {
it('metrics → QAm', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.metrics);
expect(tagOf(encoded)).toBe('QAm');
expect(encodeQsAlias(initialQueriesMap.metrics).tag).toBe('QAm');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('logs → QAl', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.logs);
expect(tagOf(encoded)).toBe('QAl');
expect(encodeQsAlias(initialQueriesMap.logs).tag).toBe('QAl');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
it('traces → QAt', () => {
const encoded = qsAliasAdapter.encode(initialQueriesMap.traces);
expect(tagOf(encoded)).toBe('QAt');
expect(encodeQsAlias(initialQueriesMap.traces).tag).toBe('QAt');
expect(normalizeUrl(encoded.toString())).toMatchSnapshot('url');
});
});
describe('matches', () => {
it('matches its own QAm/QAl/QAt tags', () => {
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.metrics)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.logs)),
).toBe(true);
expect(
qsAliasAdapter.matches(qsAliasAdapter.encode(initialQueriesMap.traces)),
).toBe(true);
});
it('rejects another serializer tag', () => {
const params = new URLSearchParams();
params.set('_t', 'FVm~');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects the legacy compositeQuery param', () => {
const params = new URLSearchParams();
params.set('compositeQuery', '{"queryType":"builder"}');
expect(qsAliasAdapter.matches(params)).toBe(false);
});
it('rejects empty params', () => {
expect(qsAliasAdapter.matches(new URLSearchParams())).toBe(false);
});
});
describe('tag-only decode returns the baseline', () => {
it.each([
['QAm', 'metrics'],
['QAl', 'logs'],
['QAt', 'traces'],
] as const)('%s decodes to the %s baseline', (tag, dataSource) => {
const params = new URLSearchParams();
params.set('_t', tag);
const decoded = decodeQsAlias(params);
expect(decoded.queryType).toBe('builder');
expect(decoded.builder.queryData[0].dataSource).toBe(dataSource);
expect(normalizeId(decoded)).toMatchSnapshot(`decoded-${tag}`);
});
it('round-trips the baseline with no extra params', () => {
const { params, tag } = encodeQsAlias(initialQueriesMap.logs);
expect(tag).toBe('QAl');
expect(normalizeUrl(params.toString())).toMatchSnapshot('url');
const decoded = decodeQsAlias(params);
expect(decoded).toStrictEqual(initialQueriesMap.logs);
expect(normalizeId(decoded)).toMatchSnapshot('decoded');
});
});
});

View File

@@ -0,0 +1,302 @@
/**
* qsAlias codec: content-aware URL serialization with prefix substitution
* and field aliasing for readable, compact URLs.
*
* Wire format: multiple query params with aliased paths
* _t=QAm&query0.ds=traces&query0.aa.key=http.status_code&query0.fl.it.0.key.key=service.name
*
* Prefix substitution: builder.queryData.0 → query0
* Field aliasing: aggregateAttribute → aa, filters → fl, etc.
*/
import set from 'lodash-es/set';
import qs from 'qs';
import getBaselineByTag, {
BaselineTag,
pickBaseline,
} from 'lib/compositeQuery/baseline';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { computeDiff, DiffCode } from './diff/diff';
import { isLeaf, Json, PathSeg } from './diff/predicates';
import { decodeLeaf, encodeLeaf } from './leaf';
import {
FIELD_ALIASES,
FIELD_REVERSE,
isOwnedKey,
PREFIX_PATTERNS,
PREFIX_REVERSE,
} from './maps';
const TAG_KEY = '_t';
const DEL_PREFIX = '-';
const isIndex = (seg: string): boolean => /^\d+$/.test(seg);
function matchesPrefix(path: PathSeg[], match: string[]): boolean {
for (let i = 0; i < match.length; i++) {
if (path[i] !== match[i]) {
return false;
}
}
return true;
}
// Path/alias helpers below are exported for direct unit testing; the adapter's
// public surface (index.ts) still exposes only encode/decode.
export function aliasField(seg: PathSeg): PathSeg {
if (typeof seg === 'number') {
return seg;
}
return FIELD_ALIASES[seg] ?? seg;
}
export function expandField(seg: string): string {
if (isIndex(seg)) {
return seg;
}
return FIELD_REVERSE[seg] ?? seg;
}
export function transformPath(path: PathSeg[]): PathSeg[] {
for (const { match, prefix } of PREFIX_PATTERNS) {
if (path.length > match.length && matchesPrefix(path, match)) {
const idx = path[match.length];
if (typeof idx === 'number') {
const rest = path.slice(match.length + 1).map(aliasField);
return [`${prefix}${idx}`, ...rest];
}
}
}
return path.map(aliasField);
}
export function expandPath(pathStr: string): PathSeg[] {
const segs = pathStr.split('.');
const first = segs[0];
for (const [prefixName, originalPath] of Object.entries(PREFIX_REVERSE)) {
const match = first.match(new RegExp(`^${prefixName}(\\d+)$`));
if (match) {
const idx = parseInt(match[1], 10);
const rest = segs.slice(1).map(expandField);
return [...originalPath, idx, ...rest];
}
}
return segs.map((s) => (isIndex(s) ? parseInt(s, 10) : expandField(s)));
}
function flattenValue(
target: Record<string, string>,
prefix: string,
value: Json,
): void {
if (value === null || typeof value !== 'object') {
target[prefix] = encodeLeaf(value);
return;
}
if (Array.isArray(value)) {
if (value.length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (let i = 0; i < value.length; i++) {
flattenValue(target, `${prefix}.${i}`, value[i]);
}
return;
}
const obj = value as Record<string, Json>;
if (Object.keys(obj).length === 0) {
target[prefix] = encodeLeaf(value);
return;
}
for (const [k, v] of Object.entries(obj)) {
flattenValue(target, `${prefix}.${aliasField(k)}`, v);
}
}
function diffToFlatObject(
baseline: Query,
query: Query,
): Record<string, string> {
const ops = computeDiff(baseline, query);
const obj: Record<string, string> = {};
for (const [code, path, value] of ops) {
const key = transformPath(path).join('.');
if (code === DiffCode.Delete) {
obj[`${DEL_PREFIX}${key}`] = '';
} else if (typeof value === 'object' && value !== null) {
flattenValue(obj, key, value);
} else {
obj[key] = encodeLeaf(value);
}
}
return obj;
}
function leafMap(obj: Json): Record<string, Json> {
const out: Record<string, Json> = {};
const walk = (node: Json, segs: PathSeg[]): void => {
if (isLeaf(node)) {
out[segs.join('.')] = node;
return;
}
if (Array.isArray(node)) {
node.forEach((value, index) => walk(value, [...segs, index]));
return;
}
Object.entries(node as Record<string, Json>).forEach(([key, value]) =>
walk(value, [...segs, key]),
);
};
walk(obj, []);
return out;
}
function rebuildFromLeaves(map: Record<string, Json>): Record<string, Json> {
const root: Record<string, Json> = {};
Object.entries(map).forEach(([path, value]) => {
const segs = path.split('.').map((s) => (isIndex(s) ? parseInt(s, 10) : s));
set(root, segs, value);
});
return root;
}
/**
* Clone baseline[0] paths to a higher index for template-based array diffing.
* When encoder emits `query1.aggOp=avg`, decoder needs `builder.queryData.1.*`
* to exist first (cloned from index 0) before applying the patch.
*/
function ensureArrayIndexFromTemplate(
baseMap: Record<string, Json>,
arrayPrefix: string,
targetIndex: number,
): void {
const sourcePrefix = `${arrayPrefix}.0.`;
const targetPrefix = `${arrayPrefix}.${targetIndex}.`;
// Skip if target already has entries (already cloned or from baseline)
const hasTarget = Object.keys(baseMap).some((k) => k.startsWith(targetPrefix));
if (hasTarget) {
return;
}
// Clone all index-0 paths to target index
for (const [path, value] of Object.entries(baseMap)) {
if (path.startsWith(sourcePrefix)) {
const suffix = path.slice(sourcePrefix.length);
baseMap[`${targetPrefix}${suffix}`] = value;
}
}
}
export function encode(query: Query): { params: URLSearchParams; tag: string } {
const { baseline, tag } = pickBaseline(query);
const obj = diffToFlatObject(baseline, query);
// `encodeValuesOnly` percent-encodes values (so `&`, `=`, `%`, … survive)
// while leaving the readable dotted keys untouched.
const queryString = qs.stringify(
{ [TAG_KEY]: `QA${tag}`, ...obj },
{
encodeValuesOnly: true,
sort: (a, b) => a.localeCompare(b),
},
);
return { params: new URLSearchParams(queryString), tag: `QA${tag}` };
}
/**
* When a nested path like `a.b.0.c` is set, any ancestor empty-container entry
* (`a.b` = `[]`) must be removed or `rebuildFromLeaves` order may clobber it.
*/
function deleteAncestorEmptyContainers(
map: Record<string, Json>,
fullPath: string,
): void {
const segs = fullPath.split('.');
for (let i = 1; i < segs.length; i += 1) {
const ancestor = segs.slice(0, i).join('.');
const value = map[ancestor];
if (
(Array.isArray(value) && value.length === 0) ||
(typeof value === 'object' &&
value !== null &&
Object.keys(value).length === 0)
) {
delete map[ancestor];
}
}
}
/**
* Check if expanded path refers to an array element beyond index 0.
* Returns [arrayPrefix, index] if so, null otherwise.
*/
function detectArrayGrowth(expandedPath: PathSeg[]): [string, number] | null {
for (const { match } of PREFIX_PATTERNS) {
if (expandedPath.length > match.length) {
const matchesPattern = match.every((seg, i) => expandedPath[i] === seg);
if (matchesPattern) {
const idx = expandedPath[match.length];
if (typeof idx === 'number' && idx > 0) {
return [match.join('.'), idx];
}
}
}
}
return null;
}
export function decode(params: URLSearchParams): Query {
const parsed = qs.parse(params.toString()) as Record<string, unknown>;
const tagValue = (parsed[TAG_KEY] as string) ?? '';
const baselineTag = tagValue.slice(2) as BaselineTag;
const baseline = getBaselineByTag(baselineTag);
const baseMap = leafMap(baseline);
const clonedIndices = new Set<string>();
for (const [key, value] of Object.entries(parsed)) {
if (key === TAG_KEY) {
continue;
}
// Skip foreign params (e.g. panelTypes, startTime) that qs.parse included.
if (!isOwnedKey(key)) {
continue;
}
if (key.startsWith(DEL_PREFIX)) {
const expandedPath = expandPath(key.slice(1));
const shortPath = expandedPath.join('.');
for (const basePath of Object.keys(baseMap)) {
if (basePath === shortPath || basePath.startsWith(`${shortPath}.`)) {
delete baseMap[basePath];
}
}
continue;
}
const expandedPath = expandPath(key);
// For paths like builder.queryData.1.*, clone from index 0 first
const growth = detectArrayGrowth(expandedPath);
if (growth) {
const [arrayPrefix, idx] = growth;
const cacheKey = `${arrayPrefix}.${idx}`;
if (!clonedIndices.has(cacheKey)) {
ensureArrayIndexFromTemplate(baseMap, arrayPrefix, idx);
clonedIndices.add(cacheKey);
}
}
const fullPath = expandedPath.join('.');
deleteAncestorEmptyContainers(baseMap, fullPath);
baseMap[fullPath] = typeof value === 'string' ? decodeLeaf(value) : value;
}
return rebuildFromLeaves(baseMap) as unknown as Query;
}

View File

@@ -0,0 +1,342 @@
import {
computeDiff,
DiffCode,
DiffOp,
diffArrays,
diffNodes,
diffObjects,
} from '../diff';
const noop = (): void => undefined;
const paths = (ops: DiffOp[]): string[] =>
ops.map(([, path]) => path.join('.'));
describe('qsAlias/diff', () => {
describe('DiffCode', () => {
it('has stable wire-significant numeric codes', () => {
// These leak onto the URL via the codec, so they must not drift.
expect(DiffCode.Set).toBe(1);
expect(DiffCode.Delete).toBe(2);
});
});
describe('computeDiff on leaves', () => {
it('returns no ops when scalars are equal', () => {
expect(computeDiff('a', 'a')).toStrictEqual([]);
expect(computeDiff(1, 1)).toStrictEqual([]);
expect(computeDiff(true, true)).toStrictEqual([]);
expect(computeDiff(null, null)).toStrictEqual([]);
});
it('emits a single Set rooted at [] when scalars differ', () => {
expect(computeDiff(1, 2)).toStrictEqual([[DiffCode.Set, [], 2]]);
expect(computeDiff('a', 'b')).toStrictEqual([[DiffCode.Set, [], 'b']]);
expect(computeDiff(true, false)).toStrictEqual([[DiffCode.Set, [], false]]);
});
it('distinguishes null, false, 0 and empty string', () => {
expect(computeDiff(null, false)).toStrictEqual([[DiffCode.Set, [], false]]);
expect(computeDiff(0, '')).toStrictEqual([[DiffCode.Set, [], '']]);
expect(computeDiff(0, null)).toStrictEqual([[DiffCode.Set, [], null]]);
});
});
describe('computeDiff on objects', () => {
it('returns no ops for deep-equal objects', () => {
expect(
computeDiff({ a: 1, b: { c: 2 } }, { a: 1, b: { c: 2 } }),
).toStrictEqual([]);
});
it('emits Set for an added key', () => {
expect(computeDiff({ a: 1 }, { a: 1, b: 2 })).toStrictEqual([
[DiffCode.Set, ['b'], 2],
]);
});
it('emits Delete (undefined value) for a removed key', () => {
expect(computeDiff({ a: 1, b: 2 }, { a: 1 })).toStrictEqual([
[DiffCode.Delete, ['b'], undefined],
]);
});
it('emits Set at the nested path for a changed deep value', () => {
expect(
computeDiff({ a: { b: { c: 1 } } }, { a: { b: { c: 9 } } }),
).toStrictEqual([[DiffCode.Set, ['a', 'b', 'c'], 9]]);
});
it('produces deterministic op order following base-then-query keys', () => {
const base = { ds: 'logs', ag: [{ mn: 'x', ao: 'noop' }], gb: [] };
const query = {
ds: 'traces',
ag: [{ mn: 'x', ao: 'sum' }, { mn: 'y' }],
gb: [],
};
// Generic arrays use wholesale SET for added elements.
// Template diffing only applies to known query builder arrays.
expect(computeDiff(base, query)).toStrictEqual([
[DiffCode.Set, ['ds'], 'traces'],
[DiffCode.Set, ['ag', 0, 'ao'], 'sum'],
[DiffCode.Set, ['ag', 1], { mn: 'y' }],
]);
});
});
describe('diffArrays', () => {
it('defaults the path to [] and diffs element-wise', () => {
expect(diffArrays([1, 2], [1, 9])).toStrictEqual([[DiffCode.Set, [1], 9]]);
});
it('Sets appended elements at their new index', () => {
expect(diffArrays([1], [1, 2, 3])).toStrictEqual([
[DiffCode.Set, [1], 2],
[DiffCode.Set, [2], 3],
]);
});
it('Deletes trailing elements removed from the query', () => {
expect(diffArrays([1, 2, 3], [1])).toStrictEqual([
[DiffCode.Delete, [1], undefined],
[DiffCode.Delete, [2], undefined],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffArrays([1], [2], ['items'])).toStrictEqual([
[DiffCode.Set, ['items', 0], 2],
]);
});
});
describe('template diffing for query builder arrays', () => {
const baseQuery = { qn: 'A', aggOp: 'count', ds: 'metrics' };
it('uses template for builder.queryData path', () => {
const base = [baseQuery];
const query = [baseQuery, { qn: 'B', aggOp: 'avg', ds: 'metrics' }];
const ops = diffArrays(base, query, ['builder', 'queryData']);
// Should diff query[1] against query[0], not wholesale SET
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryData', 1, 'qn'], 'B'],
[DiffCode.Set, ['builder', 'queryData', 1, 'aggOp'], 'avg'],
]);
});
it('uses template for builder.queryFormulas path', () => {
const baseFormula = { qn: 'F1', expression: 'A', disabled: false };
const base = [baseFormula];
const query = [
baseFormula,
{ qn: 'F2', expression: 'A+B', disabled: false },
];
const ops = diffArrays(base, query, ['builder', 'queryFormulas']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'qn'], 'F2'],
[DiffCode.Set, ['builder', 'queryFormulas', 1, 'expression'], 'A+B'],
]);
});
it('uses template for promql path', () => {
const baseProm = { name: 'A', query: 'up', legend: '', disabled: false };
const base = [baseProm];
const query = [
baseProm,
{ name: 'B', query: 'down', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['promql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['promql', 1, 'name'], 'B'],
[DiffCode.Set, ['promql', 1, 'query'], 'down'],
]);
});
it('uses template for clickhouse_sql path', () => {
const baseCh = { name: 'A', query: 'SELECT 1', legend: '', disabled: false };
const base = [baseCh];
const query = [
baseCh,
{ name: 'B', query: 'SELECT 2', legend: '', disabled: false },
];
const ops = diffArrays(base, query, ['clickhouse_sql']);
expect(ops).toStrictEqual([
[DiffCode.Set, ['clickhouse_sql', 1, 'name'], 'B'],
[DiffCode.Set, ['clickhouse_sql', 1, 'query'], 'SELECT 2'],
]);
});
it('does NOT use template for unknown paths', () => {
const base = [{ a: 1 }];
const query = [{ a: 1 }, { a: 2 }];
const ops = diffArrays(base, query, ['unknown', 'path']);
// Should emit wholesale SET, not field-level diff
expect(ops).toStrictEqual([
[DiffCode.Set, ['unknown', 'path', 1], { a: 2 }],
]);
});
it('emits DELETE for fields removed vs template', () => {
const base = [{ qn: 'A', aggOp: 'count', extra: 'field' }];
const query = [base[0], { qn: 'B', aggOp: 'avg' }]; // no 'extra'
const ops = diffArrays(base, query, ['builder', 'queryData']);
expect(ops).toContainEqual([
DiffCode.Delete,
['builder', 'queryData', 1, 'extra'],
undefined,
]);
});
});
describe('diffObjects', () => {
it('defaults the path to [] and diffs by own keys', () => {
expect(diffObjects({ a: 1 }, { a: 2 })).toStrictEqual([
[DiffCode.Set, ['a'], 2],
]);
});
it('prefixes the supplied path onto every op', () => {
expect(diffObjects({ a: 1 }, { a: 2 }, ['root'])).toStrictEqual([
[DiffCode.Set, ['root', 'a'], 2],
]);
});
});
describe('diffNodes shape transitions', () => {
it('replaces a leaf with a container wholesale', () => {
expect(diffNodes('a', { b: 1 })).toStrictEqual([
[DiffCode.Set, [], { b: 1 }],
]);
});
it('replaces a container with a leaf wholesale', () => {
expect(diffNodes({ b: 1 }, 'a')).toStrictEqual([[DiffCode.Set, [], 'a']]);
});
it('walks empty-to-non-empty array element-wise (for prefix substitution)', () => {
expect(diffNodes([], [1])).toStrictEqual([[DiffCode.Set, [0], 1]]);
});
it('emits SET [] when clearing a non-empty array (preserves empty array)', () => {
expect(diffNodes([1], [])).toStrictEqual([[DiffCode.Set, [], []]]);
expect(diffNodes([1, 2, 3], [])).toStrictEqual([[DiffCode.Set, [], []]]);
});
it('diffs array-vs-object key-wise (indices become string keys)', () => {
expect(diffNodes([1, 2], { 0: 'a' })).toStrictEqual([
[DiffCode.Set, ['0'], 'a'],
[DiffCode.Delete, ['1'], undefined],
]);
});
});
describe('undefined data', () => {
it('does not diff undefined against undefined', () => {
expect(computeDiff(undefined, undefined)).toStrictEqual([]);
expect(computeDiff({ a: undefined }, { a: undefined })).toStrictEqual([]);
});
it('Sets a real value over a baseline undefined', () => {
expect(computeDiff({ a: undefined }, { a: 1 })).toStrictEqual([
[DiffCode.Set, ['a'], 1],
]);
});
it('Sets undefined over a baseline value', () => {
expect(computeDiff({ a: 1 }, { a: undefined })).toStrictEqual([
[DiffCode.Set, ['a'], undefined],
]);
});
it('never throws when either whole input is undefined', () => {
expect(() => computeDiff(undefined, { a: 1 })).not.toThrow();
expect(() => computeDiff({ a: 1 }, undefined)).not.toThrow();
expect(computeDiff(undefined, { a: 1 })).toStrictEqual([
[DiffCode.Set, [], { a: 1 }],
]);
});
});
describe('unsupported / non-JSON values', () => {
it('treats functions as leaves and never throws', () => {
expect(() => computeDiff({ fn: noop }, { fn: noop })).not.toThrow();
// Two functions both serialize to `undefined`, so they look equal.
expect(computeDiff({ fn: noop }, { fn: noop })).toStrictEqual([]);
});
it('Sets a function over a scalar (treated as a differing leaf)', () => {
const ops = computeDiff({ a: 1 }, { a: noop });
expect(ops).toHaveLength(1);
expect(ops[0][0]).toBe(DiffCode.Set);
expect(ops[0][1]).toStrictEqual(['a']);
});
it('does not throw on NaN / Infinity leaves', () => {
expect(() => computeDiff({ a: NaN }, { a: Infinity })).not.toThrow();
// Both stringify to "null", so the diff cannot tell them apart.
expect(computeDiff({ a: NaN }, { a: Infinity })).toStrictEqual([]);
});
});
describe('prototype-pollution hardening', () => {
afterEach(() => {
// Guard against the test itself leaking pollution into later suites.
delete (Object.prototype as Record<string, unknown>).polluted;
});
it('skips a JSON-injected own __proto__ key (emits no op for it)', () => {
const malicious = JSON.parse(
'{"safe":2,"__proto__":{"polluted":true}}',
) as Record<string, unknown>;
// Base must be a non-empty object so both sides reach diffObjects;
// an empty `{}` is a leaf and would collapse to a wholesale Set.
const ops = computeDiff({ safe: 1 }, malicious);
expect(paths(ops)).toStrictEqual(['safe']);
expect(paths(ops)).not.toContain('__proto__');
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
it('skips own constructor and prototype keys', () => {
const ops = diffObjects({}, {
constructor: 'x',
prototype: 'y',
safe: 1,
} as Record<string, unknown>);
expect(paths(ops)).toStrictEqual(['safe']);
});
it('emits no Delete op when the baseline carries a forbidden key', () => {
const ops = diffObjects({ constructor: 'x' } as Record<string, unknown>, {});
expect(ops).toStrictEqual([]);
});
it('skips a nested __proto__ key reached via recursion', () => {
const malicious = JSON.parse(
'{"a":{"keep":1,"__proto__":{"polluted":true}}}',
) as Record<string, unknown>;
const ops = computeDiff({ a: { keep: 1 } }, malicious);
expect(ops).toStrictEqual([]);
expect(({} as Record<string, unknown>).polluted).toBeUndefined();
});
});
describe('op-list invariants', () => {
it('produces a unique path per op (order-independent list)', () => {
const base = { a: 1, b: [1, 2, 3], c: { d: 4 } };
const query = { a: 9, b: [1], c: { d: 4, e: 5 }, f: 6 };
const list = paths(computeDiff(base, query));
expect(new Set(list).size).toBe(list.length);
});
});
});

View File

@@ -0,0 +1,89 @@
import { isContainer, isEmptyContainer, isLeaf } from '../predicates';
const noop = (): void => undefined;
describe('qsAlias/diff predicates', () => {
describe('isContainer', () => {
it('is true for plain objects and arrays', () => {
expect(isContainer({})).toBe(true);
expect(isContainer({ a: 1 })).toBe(true);
expect(isContainer([])).toBe(true);
expect(isContainer([1, 2])).toBe(true);
});
it('is false for null and undefined', () => {
expect(isContainer(null)).toBe(false);
expect(isContainer(undefined)).toBe(false);
});
it('is false for scalars', () => {
expect(isContainer('')).toBe(false);
expect(isContainer('str')).toBe(false);
expect(isContainer(0)).toBe(false);
expect(isContainer(42)).toBe(false);
expect(isContainer(NaN)).toBe(false);
expect(isContainer(true)).toBe(false);
expect(isContainer(false)).toBe(false);
});
it('is false for functions and symbols', () => {
expect(isContainer(noop)).toBe(false);
expect(isContainer(Symbol('x'))).toBe(false);
});
it('is true for exotic objects like Date (typeof object)', () => {
expect(isContainer(new Date(0))).toBe(true);
});
});
describe('isEmptyContainer', () => {
it('is true only for [] and {}', () => {
expect(isEmptyContainer([])).toBe(true);
expect(isEmptyContainer({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isEmptyContainer([1])).toBe(false);
expect(isEmptyContainer({ a: 1 })).toBe(false);
});
it('is false for scalars, null and undefined', () => {
expect(isEmptyContainer(null)).toBe(false);
expect(isEmptyContainer(undefined)).toBe(false);
expect(isEmptyContainer('')).toBe(false);
expect(isEmptyContainer(0)).toBe(false);
});
it('treats objects with only non-enumerable keys (Date) as empty', () => {
// Date has no own *enumerable* keys, so Object.keys() is empty.
expect(isEmptyContainer(new Date(0))).toBe(true);
});
});
describe('isLeaf', () => {
it('is true for every scalar', () => {
['', 'str', 0, 1, -1, 3.14, true, false].forEach((value) => {
expect(isLeaf(value)).toBe(true);
});
});
it('is true for null and undefined', () => {
expect(isLeaf(null)).toBe(true);
expect(isLeaf(undefined)).toBe(true);
});
it('is true for empty containers', () => {
expect(isLeaf([])).toBe(true);
expect(isLeaf({})).toBe(true);
});
it('is false for non-empty containers', () => {
expect(isLeaf([1])).toBe(false);
expect(isLeaf({ a: 1 })).toBe(false);
});
it('counts a key whose value is undefined as non-empty (not a leaf)', () => {
expect(isLeaf({ a: undefined })).toBe(false);
});
});
});

View File

@@ -0,0 +1,178 @@
import { Json, PathSeg } from './predicates';
export const DiffCode = {
Set: 1,
Delete: 2,
} as const;
export type DiffCodeValue = (typeof DiffCode)[keyof typeof DiffCode];
/**
* A single diff operation: `[code, path, value]`. `value` is `undefined` for deletes.
*/
export type DiffOp = [code: DiffCodeValue, path: PathSeg[], value: Json];
/**
* Keys that must never reach a downstream `set`/rebuild step. Walking these
* would let a crafted query poison `Object.prototype`. They are skipped on both
* sides of the diff, so neither a SET nor a DELETE op is ever produced for them.
*/
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
/**
* Array paths that use template-based diffing (added elements diff against [0]).
* These are query builder arrays where added items are structurally similar.
*/
const TEMPLATE_ARRAY_PATHS = [
['builder', 'queryData'],
['builder', 'queryFormulas'],
['builder', 'queryTraceOperator'],
['promql'],
['clickhouse_sql'],
];
function isTemplateArrayPath(path: PathSeg[]): boolean {
return TEMPLATE_ARRAY_PATHS.some(
(pattern) =>
pattern.length === path.length && pattern.every((seg, i) => seg === path[i]),
);
}
const hasOwn = (obj: object, key: string): boolean =>
Object.prototype.hasOwnProperty.call(obj, key);
const leavesEqual = (a: Json, b: Json): boolean =>
JSON.stringify(a) === JSON.stringify(b);
/**
* Diff two arrays element-wise.
* Extra query items are SET; missing ones DELETE.
* Special case: if query is empty but baseline isn't, emit a single SET of `[]`
* rather than individual DELETEs, so the empty array survives the round-trip.
*
* For known query builder arrays (queryData, queryFormulas, etc.), added elements
* diff against baseArr[0] as template to minimize output size.
*/
export function diffArrays(
baseArr: Json[],
queryArr: Json[],
path: PathSeg[] = [],
): DiffOp[] {
// If query is empty but baseline has elements, emit SET of [] to preserve it.
if (queryArr.length === 0 && baseArr.length > 0) {
return [[DiffCode.Set, path, []]];
}
// Use template diffing for known query builder arrays
const useTemplate = isTemplateArrayPath(path) && baseArr.length > 0;
const template = useTemplate ? baseArr[0] : undefined;
const ops: DiffOp[] = [];
const maxLen = Math.max(baseArr.length, queryArr.length);
for (let i = 0; i < maxLen; i += 1) {
const segPath = [...path, i];
if (i >= queryArr.length) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (i >= baseArr.length) {
// Use template diffing if available, otherwise wholesale SET
if (template !== undefined) {
ops.push(...diffNodes(template, queryArr[i], segPath));
} else {
ops.push([DiffCode.Set, segPath, queryArr[i]]);
}
} else {
ops.push(...diffNodes(baseArr[i], queryArr[i], segPath));
}
}
return ops;
}
/**
* Diff two plain objects by own keys. Forbidden keys are skipped entirely.
* Special case: if query is empty but baseline isn't, emit a single SET of `{}`
* rather than individual DELETEs, so the empty object survives the round-trip.
*/
export function diffObjects(
baseObj: Record<string, Json>,
queryObj: Record<string, Json>,
path: PathSeg[] = [],
): DiffOp[] {
const baseKeys = Object.keys(baseObj).filter((k) => !FORBIDDEN_KEYS.has(k));
const queryKeys = Object.keys(queryObj).filter((k) => !FORBIDDEN_KEYS.has(k));
// If query is empty but baseline has keys, emit SET of {} to preserve it.
if (queryKeys.length === 0 && baseKeys.length > 0) {
return [[DiffCode.Set, path, {}]];
}
const ops: DiffOp[] = [];
const allKeys = new Set([...baseKeys, ...queryKeys]);
for (const key of allKeys) {
const segPath = [...path, key];
if (!hasOwn(queryObj, key)) {
ops.push([DiffCode.Delete, segPath, undefined]);
} else if (!hasOwn(baseObj, key)) {
ops.push([DiffCode.Set, segPath, queryObj[key]]);
} else {
ops.push(...diffNodes(baseObj[key], queryObj[key], segPath));
}
}
return ops;
}
/**
* Diff any two nodes, dispatching on their shape.
*/
export function diffNodes(
baseline: Json,
query: Json,
path: PathSeg[] = [],
): DiffOp[] {
const baseIsArray = Array.isArray(baseline);
const queryIsArray = Array.isArray(query);
const baseIsObj =
typeof baseline === 'object' && baseline !== null && !baseIsArray;
const queryIsObj =
typeof query === 'object' && query !== null && !queryIsArray;
// Both arrays: walk element-wise even if one is empty. This ensures paths
// like `['builder', 'queryFormulas', 0, ...]` are emitted (not a wholesale
// SET on the array itself), which is required for prefix substitution.
if (baseIsArray && queryIsArray) {
return diffArrays(baseline, query, path);
}
// Both plain objects (including empty ones): walk key-wise.
if (baseIsObj && queryIsObj) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// Both scalars (non-containers): emit a SET only when they differ.
if (!baseIsArray && !baseIsObj && !queryIsArray && !queryIsObj) {
return leavesEqual(baseline, query) ? [] : [[DiffCode.Set, path, query]];
}
// Mixed container types (array-vs-object): walk key-wise, treating array
// indices as string keys. This is an edge case but preserves intent.
if ((baseIsArray || baseIsObj) && (queryIsArray || queryIsObj)) {
return diffObjects(
baseline as Record<string, Json>,
query as Record<string, Json>,
path,
);
}
// True shape mismatch: scalar vs container → replace wholesale.
return [[DiffCode.Set, path, query]];
}
/**
* Entry point: diff a baseline against a query, rooted at the empty path.
*/
export function computeDiff(baseline: Json, query: Json): DiffOp[] {
return diffNodes(baseline, query, []);
}

View File

@@ -0,0 +1,21 @@
/**
* Value-shape predicates shared by the diff algorithm and the codec's leaf
* walker. A "leaf" is anything the serializer emits as a single token: a
* scalar (string/number/boolean/null/undefined), or an *empty* container
* (`[]` / `{}`). Non-empty containers are walked recursively.
*/
export type Json = unknown;
export type PathSeg = string | number;
export const isContainer = (
value: Json,
): value is Record<string, Json> | Json[] =>
typeof value === 'object' && value !== null;
export const isEmptyContainer = (value: Json): boolean =>
isContainer(value) &&
(Array.isArray(value) ? value.length === 0 : Object.keys(value).length === 0);
export const isLeaf = (value: Json): boolean =>
!isContainer(value) || isEmptyContainer(value);

View File

@@ -0,0 +1,33 @@
import { CompositeQueryAdapter } from 'lib/compositeQuery/types';
import { decode, encode } from './codec';
const TAG_KEY = '_t';
const TAG_PREFIX = 'QA';
/**
* qsAlias (QA~): readable URL serialization with prefix substitution
* and field aliasing. Outputs multiple query params instead of single
* compositeQuery param.
*
* Format: _t=QAm&query0.ds=traces&query0.aa.key=http.status_code...
*
* Tags: QAm (metrics), QAl (logs), QAt (traces)
*/
export const qsAliasAdapter: CompositeQueryAdapter = {
name: 'qs-alias',
encode: (query) => {
const { params } = encode(query);
return params;
},
matches: (params) => {
const tag = params.get(TAG_KEY) ?? '';
return (
tag === `${TAG_PREFIX}m` ||
tag === `${TAG_PREFIX}l` ||
tag === `${TAG_PREFIX}t`
);
},
decode: (params) => decode(params),
};
export { encode as encodeQsAlias, decode as decodeQsAlias } from './codec';

View File

@@ -0,0 +1,51 @@
/**
* Leaf value codec: lossless, readable scalar encoding for the qsAlias wire.
*
* The wire is untyped text, so a string `"123"` and a number `123` would
* otherwise be indistinguishable after a round-trip. To disambiguate without
* hurting readability:
*
* - Strings are emitted verbatim (`traces`, `service.name`, …) — readable.
* - Every non-string scalar and empty container is type-tagged with a leading
* `_` followed by its JSON form (`_123`, `_true`, `_null`, `_[]`, `_{}`).
* - A string that itself begins with `_` is escaped by doubling the leading
* `_`, so it round-trips as a string instead of being read as a tag.
* - `undefined` has no URL representation and is normalized to `null`.
*
* `_` is used as the tag because it is left unescaped by both qs
* (`encodeValuesOnly`) and `URLSearchParams`, keeping tagged values readable.
* Wire-special characters (`&`, `=`, `%`, …) are NOT handled here — the caller
* percent-encodes values via qs `encodeValuesOnly`.
*/
import { Json } from './diff/predicates';
const TYPE_TAG = '_';
/** Encode a single leaf value into its wire token. */
export function encodeLeaf(value: Json): string {
if (value === undefined) {
return `${TYPE_TAG}null`;
}
if (typeof value === 'string') {
// Double the leading tag so a literal string survives as a string.
return value.startsWith(TYPE_TAG) ? `${TYPE_TAG}${value}` : value;
}
return `${TYPE_TAG}${JSON.stringify(value)}`;
}
/** Decode a wire token back into its leaf value. */
export function decodeLeaf(token: string): Json {
if (!token.startsWith(TYPE_TAG)) {
return token;
}
// `__…` is an escaped string — strip exactly one tag.
if (token[TYPE_TAG.length] === TYPE_TAG) {
return token.slice(TYPE_TAG.length);
}
try {
return JSON.parse(token.slice(TYPE_TAG.length));
} catch {
// Hand-crafted / corrupted token — fall back to raw text, never throw.
return token;
}
}

View File

@@ -0,0 +1,70 @@
/**
* Path and field alias maps for qsAlias encoder.
*
* PREFIX SUBSTITUTION:
* builder.queryData.0.field → query0.field
* builder.queryFormulas.0.field → formula0.field
* builder.queryTraceOperator.0.field → traceOp0.field
* promql.0.field → promql0.field
* clickhouse_sql.0.field → chsql0.field
*
* FIELD ALIASES (long → short):
* aggregateAttribute → aggAttr
* timeAggregation → timeAgg
* spaceAggregation → spaceAgg
*/
interface PrefixPattern {
match: string[];
prefix: string;
}
export const PREFIX_PATTERNS: PrefixPattern[] = [
{ match: ['builder', 'queryData'], prefix: 'query' },
{ match: ['builder', 'queryFormulas'], prefix: 'formula' },
{ match: ['builder', 'queryTraceOperator'], prefix: 'traceOp' },
{ match: ['promql'], prefix: 'promql' },
{ match: ['clickhouse_sql'], prefix: 'chsql' },
];
export const PREFIX_REVERSE: Record<string, string[]> = {
query: ['builder', 'queryData'],
formula: ['builder', 'queryFormulas'],
traceOp: ['builder', 'queryTraceOperator'],
promql: ['promql'],
chsql: ['clickhouse_sql'],
};
export const FIELD_ALIASES: Record<string, string> = {
aggregateAttribute: 'aggAttr',
aggregateOperator: 'aggOp',
timeAggregation: 'timeAgg',
spaceAggregation: 'spaceAgg',
stepInterval: 'stepIn',
dataSource: 'ds',
queryName: 'qn',
dataType: 'dt',
isColumn: 'ic',
isJSON: 'ij',
metricName: 'mn',
temporality: 'tp',
queryType: 'qt',
};
export const FIELD_REVERSE: Record<string, string> = Object.fromEntries(
Object.entries(FIELD_ALIASES).map(([k, v]) => [v, k]),
);
/**
* Keys that belong to the qsAlias format. Anything else is a foreign param.
* Derived from PREFIX_PATTERNS + known top-level Query keys (and their aliases).
*/
const OWNED_PREFIXES = PREFIX_PATTERNS.map((p) => p.prefix).join('|');
const OWNED_TOP_LEVEL = ['id', 'queryType', 'qt', 'unit'];
const OWNED_KEY_PATTERN = new RegExp(
`^(?:_t|-?(?:${OWNED_PREFIXES})\\d+|${OWNED_TOP_LEVEL.join('|')})(?:\\.|$)`,
);
export function isOwnedKey(key: string): boolean {
return OWNED_KEY_PATTERN.test(key);
}

View File

@@ -0,0 +1,85 @@
import { initialQueriesMap } from 'constants/queryBuilder';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
export interface RoundTripScenario {
name: string;
query: Query;
}
const clone = <T>(obj: T): T => JSON.parse(JSON.stringify(obj)) as T;
const makePromqlQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.PROM;
query.promql[0].query = 'rate(http_requests_total[5m])';
return query;
};
const makeClickhouseQuery = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.queryType = EQueryType.CLICKHOUSE;
query.clickhouse_sql[0].query = 'SELECT count() FROM signoz_logs';
return query;
};
const makeModifiedBuilderQuery = (): Query => {
const query = clone(initialQueriesMap.logs);
const qd = query.builder.queryData[0];
qd.aggregateOperator = 'p95';
qd.disabled = true;
qd.stepInterval = 60;
qd.legend = 'error rate';
qd.filter = { expression: "severity_text = 'ERROR'" };
qd.filters = {
op: 'AND',
items: [
{
key: {
key: 'severity_text',
dataType: 'string',
type: 'tag',
isColumn: false,
isJSON: false,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any,
id: 'item-1',
op: '=',
value: 'ERROR',
},
],
};
qd.orderBy = [{ columnName: 'timestamp', order: 'desc' }];
return query;
};
const makeQueryWithCustomId = (): Query => ({
...initialQueriesMap.metrics,
id: 'test-query-uuid-123',
});
const makeQueryWithEnumLikeLegend = (): Query => {
const query = clone(initialQueriesMap.metrics);
query.builder.queryData[0].legend = 'sum';
query.id = 'my-query-id';
return query;
};
const makeQueryWithWireDelimiters = (): Query => {
const query = clone(initialQueriesMap.logs);
query.builder.queryData[0].legend = '_a*b_*c';
query.builder.queryData[0].filter = { expression: '!weird = "x_y*z"' };
return query;
};
export const roundTripScenarios: RoundTripScenario[] = [
{ name: 'metrics baseline', query: initialQueriesMap.metrics },
{ name: 'logs baseline', query: initialQueriesMap.logs },
{ name: 'traces baseline', query: initialQueriesMap.traces },
{ name: 'promql query', query: makePromqlQuery() },
{ name: 'clickhouse query', query: makeClickhouseQuery() },
{ name: 'modified builder query', query: makeModifiedBuilderQuery() },
{ name: 'custom id', query: makeQueryWithCustomId() },
{ name: 'enum-like legend preserved', query: makeQueryWithEnumLikeLegend() },
{ name: 'wire delimiters in values', query: makeQueryWithWireDelimiters() },
];

View File

@@ -0,0 +1,57 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for logs/traces queries — uses expression-style aggregations.
*/
export const LOGS_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -0,0 +1,65 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for metrics queries — uses metric-style aggregations object.
*/
export const METRICS_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'metrics',
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [
{
metricName: '',
temporality: '',
timeAggregation: 'avg',
spaceAggregation: 'sum',
reduceTo: 'avg',
},
],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -0,0 +1,57 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
/**
* Frozen canonical baselines the V-raw/TV adapters diff against. Encode stores
* only the diff from the chosen baseline; decode replays it onto a clone. These
* MUST stay byte-stable forever — changing them silently invalidates every URL
* already emitted against the old baseline. To evolve the schema, add NEW
* tagged adapters (V2~) with their own baselines rather than editing these.
*
* `id`/`unit` are empty so a real query's values round-trip as ordinary diff
* entries (nothing is stripped before diffing).
*/
/**
* Baseline for traces queries — same as logs but with dataSource: traces.
*/
export const TRACES_BASELINE_V1 = {
queryType: 'builder',
builder: {
queryData: [
{
dataSource: 'traces',
queryName: 'A',
aggregateOperator: null,
aggregateAttribute: {
id: '----',
key: '',
dataType: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
filter: { expression: '' },
aggregations: [{ expression: 'count() ' }],
functions: [],
filters: { items: [], op: 'AND' },
expression: 'A',
disabled: false,
stepInterval: null,
having: [],
limit: null,
orderBy: [],
groupBy: [],
legend: '',
reduceTo: 'avg',
source: null,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
promql: [{ name: 'A', query: '', legend: '', disabled: false }],
clickhouse_sql: [{ name: 'A', legend: '', disabled: false, query: '' }],
id: '',
unit: '',
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any as Query;

View File

@@ -0,0 +1,38 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { LOGS_BASELINE_V1 } from 'lib/compositeQuery/baseline.logs';
import { TRACES_BASELINE_V1 } from 'lib/compositeQuery/baseline.traces';
import { METRICS_BASELINE_V1 } from 'lib/compositeQuery/baseline.metrics';
/**
* Baseline tag indicators for URL encoding.
*/
export type BaselineTag = 'm' | 'l' | 't';
/**
* Pick optimal baseline based on query's primary dataSource.
*/
export function pickBaseline(query: Query): {
baseline: Query;
tag: BaselineTag;
} {
const ds = query.builder?.queryData?.[0]?.dataSource;
if (ds === 'logs') {
return { baseline: LOGS_BASELINE_V1, tag: 'l' };
}
if (ds === 'traces') {
return { baseline: TRACES_BASELINE_V1, tag: 't' };
}
return { baseline: METRICS_BASELINE_V1, tag: 'm' };
}
function getBaselineByTag(tag: BaselineTag): Query {
if (tag === 'l') {
return LOGS_BASELINE_V1;
}
if (tag === 't') {
return TRACES_BASELINE_V1;
}
return METRICS_BASELINE_V1;
}
export default getBaselineByTag;

View File

@@ -0,0 +1,80 @@
import { jsonAdapter } from 'lib/compositeQuery/adapters/json';
import {
COMPOSITE_QUERY_KEY,
CompositeQueryAdapter,
} from 'lib/compositeQuery/types';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { qsAliasAdapter } from 'lib/compositeQuery/adapters/qsAlias';
// Order matters for decode: most-specific (tagged) adapters first
const ADAPTERS: CompositeQueryAdapter[] = [qsAliasAdapter, jsonAdapter];
// Pick the adapter that owns a given URL. json's `matches` is always true, so
// it serves as the final fallback when no tagged adapter claims the params.
function adapterFor(params: URLSearchParams): CompositeQueryAdapter {
return ADAPTERS.find((adapter) => adapter.matches(params)) ?? jsonAdapter;
}
/**
* Encode a query to the shortest available URLSearchParams.
*/
export function serialize(query: Query): URLSearchParams {
return ADAPTERS[0].encode(query);
}
/**
* Decode URLSearchParams back to a Query. Total: returns null on any failure.
*/
export function deserialize(params: URLSearchParams): Query | null {
const hasParams = Array.from(params.keys()).length > 0;
if (!hasParams) {
return null;
}
try {
return adapterFor(params).decode(params);
} catch {
return null;
}
}
/**
* Apply all params from source into target URLSearchParams.
*/
export function applySerializedParams(
source: URLSearchParams,
target: URLSearchParams,
): void {
source.forEach((value, key) => target.set(key, value));
}
/**
* Remove every serialized-query param from target URLSearchParams. Use instead
* of `target.delete('compositeQuery')` so a stale query is fully purged even
* for adapters that explode a query into many content-dependent keys (e.g.
* `query0.ds`, `query0.fl.it.0.key.key`) which can't be listed statically.
*
* Keys are discovered by round-trip: decode the current params with their
* owning adapter, re-encode, then delete exactly the keys encoding produces.
* If the params don't decode (absent/corrupt), fall back to dropping the legacy
* single key so a stale `compositeQuery` is still cleared.
*/
export function clearSerializedParams(target: URLSearchParams): void {
const adapter = adapterFor(target);
try {
adapter.encode(adapter.decode(target)).forEach((_value, key) => {
target.delete(key);
});
} catch {
target.delete(COMPOSITE_QUERY_KEY);
}
}
/**
* Serialize a query to a plain record of all URL params it produces. Use when
* building a query-param object manually (e.g. for `createQueryParams`), so the
* call site carries every param the adapter emits — not just `compositeQuery`.
* Spread it: `{ ...serializeToParams(query), startTime, endTime }`.
*/
export function serializeToParams(query: Query): Record<string, string> {
return Object.fromEntries(serialize(query));
}

View File

@@ -0,0 +1,15 @@
import { Query } from 'types/api/queryBuilder/queryBuilderData';
export const COMPOSITE_QUERY_KEY = 'compositeQuery';
/**
* A serialization tier. `encode` returns URLSearchParams (default key =
* `compositeQuery`). `matches` checks if params belong to this adapter.
* `decode` receives URLSearchParams and returns Query.
*/
export interface CompositeQueryAdapter {
readonly name: string;
encode(query: Query): URLSearchParams;
matches(params: URLSearchParams): boolean;
decode(params: URLSearchParams): Query;
}

View File

@@ -44,6 +44,7 @@
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
row-gap: 4px;
column-gap: 12px;
}

View File

@@ -1,11 +0,0 @@
.noData {
display: flex;
justify-content: center;
align-items: center;
width: 100%;
height: 100%;
}
.noDataText {
font-size: 14px;
}

View File

@@ -1,28 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import styles from './NoData.module.scss';
interface NoDataProps {
/** Message to display. Defaults to "No data". */
label?: string;
'data-testid'?: string;
}
/**
* Shared empty-state for panel renderers, shown when a query resolves but
* returns nothing to plot. Centred in the panel body so every panel kind
* surfaces the same "No data" affordance instead of each renderer (or its
* underlying chart) inventing its own copy and casing.
*/
function NoData({
label = 'No data',
'data-testid': testId = 'panel-no-data',
}: NoDataProps): JSX.Element {
return (
<div className={styles.noData} data-testid={testId}>
<Typography.Text className={styles.noDataText}>{label}</Typography.Text>
</div>
);
}
export default NoData;

View File

@@ -1,30 +0,0 @@
import { useMemo } from 'react';
import type { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Builds a record keyed by builder-query name to that query's groupBy keys
* in the V1 `BaseAutocompleteData` shape — the shape `TimeSeries` and the
* tooltip plugin consume. Conversion from v5 `GroupByKey` lives at this one
* call site that needs the V1 shape; the rest of V2 panel code stays on
* v5 types.
*/
export function useGroupByPerQuery(
builderQueries: BuilderQuery[],
): Record<string, BaseAutocompleteData[]> {
return useMemo(() => {
const result: Record<string, BaseAutocompleteData[]> = {};
builderQueries.forEach((q) => {
if (!q.name) {
return;
}
result[q.name] = (q.groupBy ?? []).map((g) => ({
key: g.name,
dataType: g.fieldDataType as BaseAutocompleteData['dataType'],
type: (g.fieldContext as BaseAutocompleteData['type']) ?? '',
id: '',
}));
});
return result;
}, [builderQueries]);
}

View File

@@ -1,48 +0,0 @@
import { RefObject, useEffect, useRef, useState } from 'react';
const MIN_FONT_PX = 16;
const MAX_FONT_PX = 60;
// The value font is sized to a fraction of the container's smaller dimension so
// it scales with the panel without overflowing.
const FONT_SCALE_DIVISOR = 5;
/**
* Sizes a single large value to its container, recomputing on resize via a
* ResizeObserver. Returns the ref to attach to the container and the current
* font size (px) to apply to the value text.
*/
export function useResponsiveFontSize(): {
containerRef: RefObject<HTMLDivElement>;
fontSize: string;
} {
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState('2.5vw');
useEffect(() => {
const updateFontSize = (): void => {
if (!containerRef.current) {
return;
}
const { width, height } = containerRef.current.getBoundingClientRect();
const minDimension = Math.min(width, height);
const newSize = Math.max(
Math.min(minDimension / FONT_SCALE_DIVISOR, MAX_FONT_PX),
MIN_FONT_PX,
);
setFontSize(`${newSize}px`);
};
updateFontSize();
const resizeObserver = new ResizeObserver(updateFontSize);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return (): void => {
resizeObserver.disconnect();
};
}, []);
return { containerRef, fontSize };
}

View File

@@ -1,175 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import BarChart from 'container/DashboardContainer/visualization/charts/BarChart/BarChart';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getExecStats,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildBarChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function BarPanelRenderer({
panelId,
panel,
data,
onClick,
onDragSelect,
dashboardPreference,
panelMode,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/BarChartPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesBarChartPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesBarChartPanelSpecDTO,
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries),
[panel.spec.queries],
);
// X-scale clamps come from the request that produced the data (falls back
// to the global picker inside the helper). The generated request DTO is
// structurally the hand-written V5 request; the cast is the boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
const groupByPerQuery = useGroupByPerQuery(builderQueries);
const flatSeries = useMemo(
() =>
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
[data.response, data.legendMap],
);
const config = useMemo(
() =>
buildBarChartConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
stepIntervals: getExecStats(data.response)?.stepIntervals,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
}),
[
panelId,
spec,
builderQueries,
flatSeries,
data.response,
isDarkMode,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
onDragSelect,
// `config` gets mutated by TooltipPlugin (config.setCursor for cursor sync).
// Rebuild it on syncMode changes so the new chart instance starts from a
// clean config — otherwise switching to "No Sync" would inherit stale sync
// settings from the previous mode.
dashboardPreference?.syncMode,
],
);
const chartData = useMemo(() => prepareAlignedData(flatSeries), [flatSeries]);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const legendPosition = useMemo(() => {
return resolveLegendPosition(spec.legend?.position);
}, [spec.legend?.position]);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter id={panelId} isPinned={isPinned} dismiss={dismiss} />
),
[panelId],
);
// The uPlot key prop is the only way to force a full teardown and re-mount
// of the chart. Including syncMode/syncFilterMode in the key ensures changes
// to these preferences trigger a fresh chart instance, preventing stale
// sync wiring from being inherited.
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="bar-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (
<BarChart
key={key}
config={config}
data={chartData}
legendConfig={{ position: legendPosition }}
groupByPerQuery={groupByPerQuery}
canPinTooltip
timezone={timezone}
yAxisUnit={spec.formatting?.unit}
decimalPrecision={decimalPrecision}
width={containerDimensions.width}
height={containerDimensions.height}
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default BarPanelRenderer;

View File

@@ -1,13 +0,0 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -1,9 +0,0 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'formatting', controls: { unit: true, decimals: true } },
{ kind: 'axes', controls: { minMax: true, unit: true, logScale: true } },
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'thresholds', controls: { list: true } },
{ kind: 'chartAppearance', controls: { stacked: true } },
];

View File

@@ -1,138 +0,0 @@
import type { DashboardtypesBarChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { toClickPluginPayload } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
export interface BuildBarChartConfigArgs {
panelId: string;
spec: DashboardtypesBarChartPanelSpecDTO;
/**
* Flat list of builder queries on this panel (see `getBuilderQueries`).
* Powers per-query legend resolution; empty for non-builder panels.
*/
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
/** Per-query step intervals from the response exec stats. */
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
onDragSelect?: (start: number, end: number) => void;
onClick?: OnClickPluginOpts['onClick'];
minTimeScale?: number;
maxTimeScale?: number;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Bar chart panel.
*
* Delegates the panel-agnostic scaffolding (scales, thresholds, axes,
* drag-to-zoom, click plugin) to the shared `buildBaseConfig`, then layers
* in the Bar-specific concerns: optional stacking via uPlot bands, plus
* one bar series per result row.
*/
export function buildBarChartConfig({
panelId,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
timezone,
panelMode,
onDragSelect,
onClick,
minTimeScale,
maxTimeScale,
}: BuildBarChartConfigArgs): UPlotConfigBuilder {
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.BAR,
isDarkMode,
timezone,
panelMode,
isLogScale: spec.axes?.isLogScale,
softMin: spec.axes?.softMin ?? undefined,
softMax: spec.axes?.softMax ?? undefined,
formatting: spec.formatting,
thresholds: spec.thresholds,
stepIntervals,
clickPayload: toClickPluginPayload(series),
minTimeScale,
maxTimeScale,
onDragSelect,
onClick,
});
addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
});
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesBarChartPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
stepIntervals?: Record<string, number>;
isDarkMode: boolean;
}
/**
* Adds one bar series per flattened V5 series, plus uPlot bands for stacking
* when `spec.visualization.stackedBarChart` is set. Each series receives its
* own per-query step interval so bar widths line up with the actual
* sampling cadence reported by the backend.
*
* Order must match `prepareAlignedData` — both iterate the same flat list.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
stepIntervals,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
if (spec.visualization?.stackedBarChart) {
// uPlot uses 1-based series indices (index 0 is the timestamp axis);
// `+1` keeps the band targets aligned with the series we're about to add.
builder.setBands(getInitialStackedBands(series.length + 1));
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
const stepInterval = s.queryName ? stepIntervals?.[s.queryName] : undefined;
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
label,
colorMapping,
isDarkMode,
stepInterval,
metric: s.labels,
});
});
}

View File

@@ -1,139 +0,0 @@
import { useCallback, useMemo, useRef } from 'react';
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Histogram from 'container/DashboardContainer/visualization/charts/Histogram/Histogram';
import TooltipFooter from 'container/DashboardContainer/visualization/panels/components/TooltipFooter';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { IRenderTooltipFooterArgs } from 'lib/uPlotV2/components/types';
import {
flattenTimeSeries,
getTimeSeriesResults,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { useTimezone } from 'providers/Timezone';
import type uPlot from 'uplot';
import NoData from '../../components/NoData/NoData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveLegendPosition } from '../../utils/chartAppearance/resolvers';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './utils/buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
panel,
data,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/HistogramPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesHistogramPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesHistogramPanelSpecDTO,
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries),
[panel.spec.queries],
);
const flatSeries = useMemo(
() =>
flattenTimeSeries(getTimeSeriesResults(data.response), data.legendMap ?? {}),
[data.response, data.legendMap],
);
const config = useMemo(
() =>
buildHistogramConfig({
panelId,
spec,
builderQueries,
series: flatSeries,
isDarkMode,
timezone,
panelMode,
}),
[panelId, spec, builderQueries, flatSeries, isDarkMode, timezone, panelMode],
);
const chartData = useMemo(
() =>
prepareHistogramData({
series: flatSeries,
bucketWidth: spec.histogramBuckets?.bucketWidth ?? undefined,
bucketCount: spec.histogramBuckets?.bucketCount ?? undefined,
mergeAllActiveQueries: spec.histogramBuckets?.mergeAllActiveQueries,
}),
[
flatSeries,
spec.histogramBuckets?.bucketWidth,
spec.histogramBuckets?.bucketCount,
spec.histogramBuckets?.mergeAllActiveQueries,
],
);
const legendPosition = useMemo(
() => resolveLegendPosition(spec.legend?.position),
[spec.legend?.position],
);
const renderTooltipFooter = useCallback(
({ isPinned, dismiss }: IRenderTooltipFooterArgs) => (
<TooltipFooter
id={panelId}
isPinned={isPinned}
dismiss={dismiss}
canDrilldown={false}
/>
),
[panelId],
);
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
data-testid="histogram-panel-renderer"
className={PanelStyles.panelContainer}
>
{flatSeries.length === 0 && <NoData />}
{flatSeries.length > 0 &&
containerDimensions.width > 0 &&
containerDimensions.height > 0 && (
<Histogram
key={panelId}
config={config}
data={chartData as uPlot.AlignedData}
legendConfig={{ position: legendPosition }}
canPinTooltip
isQueriesMerged={isQueriesMerged}
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>
);
}
export default HistogramPanelRenderer;

View File

@@ -1,13 +0,0 @@
import { DataSource } from 'types/common/queryBuilder';
import type { PanelDefinition } from '../../types/panelDefinition';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
Renderer,
sections,
supportedSignals: [DataSource.METRICS, DataSource.LOGS, DataSource.TRACES],
};

View File

@@ -1,148 +0,0 @@
import { histogramBucketSizes } from '@grafana/data';
import { DEFAULT_BUCKET_COUNT } from 'container/PanelWrapper/constants';
import {
buildHistogramBuckets,
mergeAlignedDataTables,
prependNullBinToFirstHistogramSeries,
replaceUndefinedWithNullInAlignedData,
} from 'container/DashboardContainer/visualization/panels/utils/histogram';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { AlignedData } from 'uplot';
import { incrRoundDn, roundDecimals } from 'utils/round';
export interface PrepareHistogramDataArgs {
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
bucketWidth?: number;
bucketCount?: number;
mergeAllActiveQueries?: boolean;
}
const BUCKET_OFFSET = 0;
const sortAscending = (a: number, b: number): number => a - b;
/**
* Bins raw series values into a uPlot-aligned histogram. Picks a bucket size
* either from `bucketWidth` (explicit override) or the smallest predefined
* Grafana bucket that fits the data's `range / bucketCount` target while
* staying ≥ the data's smallest non-zero delta (so we never sub-divide below
* the resolution of the input).
*
* Empty input → `[[]]` (a valid empty AlignedData uPlot accepts).
*/
export function prepareHistogramData({
series,
bucketWidth,
bucketCount = DEFAULT_BUCKET_COUNT,
mergeAllActiveQueries = false,
}: PrepareHistogramDataArgs): AlignedData {
const values = extractNumericValues(series);
if (values.length === 0) {
return [[]];
}
const sorted = [...values].sort(sortAscending);
const range = sorted[sorted.length - 1] - sorted[0];
const smallestDelta = computeSmallestDelta(sorted);
let bucketSize = selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride: bucketWidth,
});
if (bucketSize <= 0) {
bucketSize = range > 0 ? range / bucketCount : 1;
}
const getBucket = (v: number): number =>
roundDecimals(incrRoundDn(v - BUCKET_OFFSET, bucketSize) + BUCKET_OFFSET, 9);
const frames = buildFrames(series, mergeAllActiveQueries);
// Merged mode folds every query into frame 0 and leaves trailing empty
// frames — drop those. Per-query mode must keep one column per result row
// (even empty queries), or the data column count drifts below the series
// count `buildHistogramConfig` adds per row → uPlot renders nothing.
const histograms: AlignedData[] = frames
.filter((frame) => !mergeAllActiveQueries || frame.length > 0)
.map((frame) => buildHistogramBuckets(frame, getBucket, sortAscending));
if (histograms.length === 0) {
return [[]];
}
const merged = mergeAlignedDataTables(histograms);
replaceUndefinedWithNullInAlignedData(merged);
prependNullBinToFirstHistogramSeries(merged, bucketSize);
return merged;
}
// Non-finite samples degrade to 0 (legacy `parseFloat(...) || 0` parity).
function toBinnableValue(value: number): number {
return Number.isFinite(value) ? value : 0;
}
function extractNumericValues(series: PanelSeries[]): number[] {
const values: number[] = [];
for (const s of series) {
for (const point of s.values) {
values.push(toBinnableValue(point.value));
}
}
return values;
}
function computeSmallestDelta(sortedValues: number[]): number {
if (sortedValues.length <= 1) {
return 0;
}
let smallest = Infinity;
for (let i = 1; i < sortedValues.length; i++) {
const delta = sortedValues[i] - sortedValues[i - 1];
if (delta > 0) {
smallest = Math.min(smallest, delta);
}
}
return smallest === Infinity ? 0 : smallest;
}
function selectBucketSize({
range,
bucketCount,
smallestDelta,
bucketWidthOverride,
}: {
range: number;
bucketCount: number;
smallestDelta: number;
bucketWidthOverride?: number;
}): number {
if (bucketWidthOverride != null && bucketWidthOverride > 0) {
return bucketWidthOverride;
}
const targetSize = range / bucketCount;
for (const candidate of histogramBucketSizes) {
if (targetSize < candidate && candidate >= smallestDelta) {
return candidate;
}
}
return 0;
}
// When merging is on, fold all frames into the first; the trailing empty
// frames stay in the array so downstream `.filter(length > 0)` drops them.
function buildFrames(
series: PanelSeries[],
mergeAllActiveQueries: boolean,
): number[][] {
const frames: number[][] = series.map((s) =>
s.values.map((point) => toBinnableValue(point.value)),
);
if (mergeAllActiveQueries && frames.length > 1) {
const first = frames[0];
for (let i = 1; i < frames.length; i++) {
first.push(...frames[i]);
frames[i] = [];
}
}
return frames;
}

View File

@@ -1,6 +0,0 @@
import type { SectionConfig } from '../../types/sections';
export const sections: SectionConfig[] = [
{ kind: 'legend', controls: { position: true, mode: true } },
{ kind: 'buckets', controls: { count: true } },
];

View File

@@ -1,129 +0,0 @@
import type { DashboardtypesHistogramPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import { buildBaseConfig } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/baseConfigBuilder';
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import getLabelName from 'lib/getLabelName';
import { DrawStyle } from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import type { BuilderQuery } from 'types/api/v5/queryRange';
const POINT_SIZE = 5;
const BAR_WIDTH_FACTOR = 1;
// Merged-series colors mirror the V1 default — single histogram bin gets a
// fixed blue-ish pair so the merged view looks the same as before.
const MERGED_SERIES_LINE_COLOR = '#3f5ecc';
const MERGED_SERIES_FILL_COLOR = '#4E74F8';
export interface BuildHistogramConfigArgs {
panelId: string;
spec: DashboardtypesHistogramPanelSpecDTO;
/** Builder queries on this panel — used to resolve per-series labels. */
builderQueries: BuilderQuery[];
/** Flattened V5 series (see `flattenTimeSeries`). */
series: PanelSeries[];
isDarkMode: boolean;
timezone: Timezone;
panelMode: PanelMode;
}
/**
* Builds a fully-wired `UPlotConfigBuilder` for a Histogram panel.
*
* Unlike time-axis panels, histograms have no time scale and no drag-to-zoom.
* We still reuse `buildBaseConfig` for the consistent scaffolding (thresholds,
* axes, click plugin) but then override the X/Y scales to be auto-linear
* (`time: false, auto: true`) and install a histogram-specific cursor that
* disables drag-pan and tightens focus proximity.
*/
export function buildHistogramConfig({
panelId,
spec,
builderQueries,
series,
isDarkMode,
timezone,
panelMode,
}: BuildHistogramConfigArgs): UPlotConfigBuilder {
// Histograms have no time axis — no stepIntervals, and no click plugin
// (the renderer passes no onClick), so the base config needs no response.
const builder = buildBaseConfig({
panelId,
panelType: PANEL_TYPES.HISTOGRAM,
isDarkMode,
timezone,
panelMode,
});
builder.setCursor({
drag: { x: false, y: false, setScale: true },
focus: { prox: 1e3 },
});
// Override the time-axis scales from `buildBaseConfig` — histograms are
// distribution plots, not time series.
builder.addScale({ scaleKey: 'x', time: false, auto: true });
builder.addScale({ scaleKey: 'y', time: false, auto: true, min: 0 });
addSeries({ builder, spec, builderQueries, series, isDarkMode });
return builder;
}
interface AddSeriesArgs {
builder: UPlotConfigBuilder;
spec: DashboardtypesHistogramPanelSpecDTO;
builderQueries: BuilderQuery[];
series: PanelSeries[];
isDarkMode: boolean;
}
/**
* Adds histogram bar series to the builder. When `mergeAllActiveQueries` is
* set, `prepareHistogramData` produces a single Y column, so we add exactly
* one series with the fixed merged-mode colors. Otherwise one series per
* result row, with labels resolved via the standard legend matrix.
*/
function addSeries({
builder,
spec,
builderQueries,
series,
isDarkMode,
}: AddSeriesArgs): void {
const colorMapping = spec.legend?.customColors ?? {};
const mergeAllActiveQueries =
spec.histogramBuckets?.mergeAllActiveQueries ?? false;
if (mergeAllActiveQueries) {
builder.addSeries({
scaleKey: 'y',
label: '',
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
lineColor: MERGED_SERIES_LINE_COLOR,
fillColor: MERGED_SERIES_FILL_COLOR,
isDarkMode,
});
return;
}
series.forEach((s) => {
const baseLabel = getLabelName(s.labels, s.queryName, s.legend);
const label = resolveSeriesLabelV5(s, builderQueries, baseLabel);
builder.addSeries({
scaleKey: 'y',
label,
drawStyle: DrawStyle.Histogram,
colorMapping,
barWidthFactor: BAR_WIDTH_FACTOR,
pointSize: POINT_SIZE,
isDarkMode,
});
});
}

View File

@@ -1,78 +0,0 @@
import { useMemo } from 'react';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import NoData from '../../components/NoData/NoData';
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
import ValueDisplay from './components/ValueDisplay/ValueDisplay';
function NumberPanelRenderer({
panel,
data,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
// The registry guarantees this Renderer only runs when
// `panel.spec.plugin.kind === 'signoz/NumberPanel'`, so the cast is a
// documented boundary narrowing.
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec as DashboardtypesNumberPanelSpecDTO,
[panel.spec.plugin.spec],
);
const value = useMemo(
() =>
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
),
[data.response, data.legendMap, data.requestPayload],
);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
);
const decimalPrecision = useMemo(
() => resolveDecimalPrecision(spec.formatting?.decimalPrecision),
[spec.formatting?.decimalPrecision],
);
const unit = spec.formatting?.unit;
// Precision is applied regardless of whether a unit is set (see
// `formatPanelValue`), so decimal-precision changes always take effect.
const formattedValue = useMemo(
() => (value === null ? '' : formatPanelValue(value, unit, decimalPrecision)),
[value, unit, decimalPrecision],
);
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" />
) : (
<ValueDisplay
value={formattedValue}
rawValue={value}
thresholds={thresholds}
unit={unit}
/>
)}
</div>
);
}
export default NumberPanelRenderer;

View File

@@ -1,163 +0,0 @@
import {
DashboardtypesComparisonOperatorDTO,
type DashboardtypesNumberPanelSpecDTO,
type DashboardtypesPanelDTO,
DashboardtypesThresholdFormatDTO,
type QueryRangeV5200,
} from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import { render } from 'tests/test-utils';
import { BaseRendererProps } from '../../../types/rendererProps';
import BaseNumberPanelRenderer from '../Renderer';
// The kind's interaction map is `Record<string, never>`, which makes the strict
// `PanelRendererProps<'signoz/NumberPanel'>` intersection impossible to satisfy
// with a literal. NumberPanel reads no interaction props, so render it against
// the base prop surface.
const NumberPanelRenderer =
BaseNumberPanelRenderer as React.FC<BaseRendererProps>;
// ValueDisplay observes its container to size the font.
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
disconnect: jest.fn(),
observe: jest.fn(),
unobserve: jest.fn(),
}));
function panelWith(
spec: DashboardtypesNumberPanelSpecDTO,
): DashboardtypesPanelDTO {
return {
kind: 'Panel',
spec: { plugin: { kind: 'signoz/NumberPanel', spec } },
} as unknown as DashboardtypesPanelDTO;
}
// V5 scalar response: one table per query, value in the aggregation column.
function dataWith(value: string | number): PanelQueryData {
return {
response: {
status: 'success',
data: {
type: 'scalar',
data: {
results: [
{
queryName: 'A',
columns: [
{
name: '__result',
queryName: 'A',
columnType: 'aggregation',
aggregationIndex: 0,
},
],
data: [[value]],
},
],
},
},
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
}
const emptyData: PanelQueryData = {
response: {
status: 'success',
data: { type: 'scalar', data: { results: [] } },
} as unknown as QueryRangeV5200,
requestPayload: undefined,
legendMap: {},
};
// `data` is always present per the renderer contract; an absent fetch surfaces
// as a missing `response`, not a missing `data`.
const absentResponseData: PanelQueryData = {
response: undefined,
requestPayload: undefined,
legendMap: {},
};
// NumberPanel adds no interaction props (its interaction map is
// `Record<string, never>`), so the base renderer props fully describe it.
function renderPanel(
props: Partial<BaseRendererProps>,
): ReturnType<typeof render> {
const baseProps: BaseRendererProps = {
panelId: 'panel-1',
panel: panelWith({}),
data: emptyData,
isLoading: false,
error: null,
panelMode: PanelMode.DASHBOARD_VIEW,
...props,
};
return render(<NumberPanelRenderer {...baseProps} />);
}
describe('NumberPanelRenderer', () => {
it('renders the value with its y-axis unit', () => {
const { getByText } = renderPanel({
panel: panelWith({ formatting: { unit: 'ms' } }),
data: dataWith('295.4299833508185'),
});
expect(getByText('295.43')).toBeInTheDocument();
expect(getByText('ms')).toBeInTheDocument();
});
// Regression: with no unit configured, decimal precision must still apply.
// Previously the renderer fell back to `value.toString()` whenever the unit
// was empty, so precision changes had no effect on unitless panels.
it('applies decimal precision even when no unit is set', () => {
const { getByText, queryByText } = renderPanel({
panel: panelWith({}),
data: dataWith('3.14159'),
});
expect(getByText('3.14')).toBeInTheDocument();
expect(queryByText('3.14159')).not.toBeInTheDocument();
});
it('renders No Data when the response has no scalar results', () => {
const { getByTestId } = renderPanel({ data: emptyData });
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('renders No Data when the response is absent', () => {
const { getByTestId } = renderPanel({ data: absentResponseData });
expect(getByTestId('number-panel-no-data')).toBeInTheDocument();
});
it('surfaces the conflicting-thresholds indicator when a value matches multiple thresholds', () => {
const { getByTestId } = renderPanel({
panel: panelWith({
thresholds: [
{
color: '#f00',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 0,
format: DashboardtypesThresholdFormatDTO.background,
},
{
color: '#0f0',
operator: DashboardtypesComparisonOperatorDTO.above,
value: 100,
format: DashboardtypesThresholdFormatDTO.background,
},
],
}),
data: dataWith('295.4299833508185'),
});
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
});
});

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