mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-17 14:00:34 +01:00
Compare commits
8 Commits
issue-5378
...
feat/compo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d6c27ec5f | ||
|
|
9d919e166b | ||
|
|
8c86885090 | ||
|
|
e7be5ee17d | ||
|
|
49c11f51ac | ||
|
|
0c35a8f6e5 | ||
|
|
2c076a3d50 | ||
|
|
086040799c |
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
19
frontend/pnpm-lock.yaml
generated
19
frontend/pnpm-lock.yaml
generated
@@ -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: {}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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');
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,6 @@ export enum QueryParams {
|
||||
q = 'q',
|
||||
activeLogId = 'activeLogId',
|
||||
timeRange = 'timeRange',
|
||||
compositeQuery = 'compositeQuery',
|
||||
panelTypes = 'panelTypes',
|
||||
pageSize = 'pageSize',
|
||||
viewMode = 'viewMode',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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}=`);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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));
|
||||
});
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -63,6 +63,5 @@
|
||||
flex: 0 0 auto;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
padding: 8px;
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()}`);
|
||||
}
|
||||
|
||||
@@ -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'] = [
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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)}`;
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()}`;
|
||||
|
||||
170
frontend/src/hooks/__tests__/useSafeNavigate.utils.test.ts
Normal file
170
frontend/src/hooks/__tests__/useSafeNavigate.utils.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
103
frontend/src/hooks/useSafeNavigate.utils.ts
Normal file
103
frontend/src/hooks/useSafeNavigate.utils.ts
Normal 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;
|
||||
};
|
||||
@@ -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": "",
|
||||
}
|
||||
`;
|
||||
121
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal file
121
frontend/src/lib/compositeQuery/__tests__/baseline.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
51
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal file
51
frontend/src/lib/compositeQuery/__tests__/serializer.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
63
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal file
63
frontend/src/lib/compositeQuery/adapters/json/index.ts
Normal 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);
|
||||
},
|
||||
};
|
||||
74
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal file
74
frontend/src/lib/compositeQuery/adapters/json/json.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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="`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
}
|
||||
`;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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="`;
|
||||
@@ -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="`;
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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}`,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
302
frontend/src/lib/compositeQuery/adapters/qsAlias/codec.ts
Normal file
302
frontend/src/lib/compositeQuery/adapters/qsAlias/codec.ts
Normal 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;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
178
frontend/src/lib/compositeQuery/adapters/qsAlias/diff/diff.ts
Normal file
178
frontend/src/lib/compositeQuery/adapters/qsAlias/diff/diff.ts
Normal 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, []);
|
||||
}
|
||||
@@ -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);
|
||||
33
frontend/src/lib/compositeQuery/adapters/qsAlias/index.ts
Normal file
33
frontend/src/lib/compositeQuery/adapters/qsAlias/index.ts
Normal 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';
|
||||
51
frontend/src/lib/compositeQuery/adapters/qsAlias/leaf.ts
Normal file
51
frontend/src/lib/compositeQuery/adapters/qsAlias/leaf.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
70
frontend/src/lib/compositeQuery/adapters/qsAlias/maps.ts
Normal file
70
frontend/src/lib/compositeQuery/adapters/qsAlias/maps.ts
Normal 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);
|
||||
}
|
||||
@@ -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() },
|
||||
];
|
||||
57
frontend/src/lib/compositeQuery/baseline.logs.ts
Normal file
57
frontend/src/lib/compositeQuery/baseline.logs.ts
Normal 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;
|
||||
65
frontend/src/lib/compositeQuery/baseline.metrics.ts
Normal file
65
frontend/src/lib/compositeQuery/baseline.metrics.ts
Normal 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;
|
||||
57
frontend/src/lib/compositeQuery/baseline.traces.ts
Normal file
57
frontend/src/lib/compositeQuery/baseline.traces.ts
Normal 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;
|
||||
38
frontend/src/lib/compositeQuery/baseline.ts
Normal file
38
frontend/src/lib/compositeQuery/baseline.ts
Normal 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;
|
||||
80
frontend/src/lib/compositeQuery/serializer.ts
Normal file
80
frontend/src/lib/compositeQuery/serializer.ts
Normal 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));
|
||||
}
|
||||
15
frontend/src/lib/compositeQuery/types.ts
Normal file
15
frontend/src/lib/compositeQuery/types.ts
Normal 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;
|
||||
}
|
||||
@@ -44,6 +44,7 @@
|
||||
auto-fill,
|
||||
minmax(var(--legend-average-width, 240px), 1fr)
|
||||
);
|
||||
row-gap: 4px;
|
||||
column-gap: 12px;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
.noData {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.noDataText {
|
||||
font-size: 14px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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],
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 } },
|
||||
];
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user