Compare commits
11 Commits
feat/heatm
...
issue_4501
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71dc06bc7e | ||
|
|
dca6aa497d | ||
|
|
bb47550c01 | ||
|
|
ecf510ed67 | ||
|
|
5b94d79e46 | ||
|
|
60a6a5b38c | ||
|
|
435471a18d | ||
|
|
816905f4cf | ||
|
|
691f724480 | ||
|
|
e04f26f5b7 | ||
|
|
4a72aab47c |
@@ -8024,13 +8024,6 @@ components:
|
||||
- schemaVersion
|
||||
- spec
|
||||
type: object
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -13262,117 +13255,6 @@ paths:
|
||||
summary: Gets service account roles
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: true
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
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:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: true
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: rid
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"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:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/me:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -14376,9 +14258,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- auth-domain:list
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- auth-domain:list
|
||||
summary: List all auth domains
|
||||
tags:
|
||||
- authdomains
|
||||
@@ -14438,9 +14320,13 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- auth-domain:create
|
||||
- auth-domain:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- auth-domain:create
|
||||
- auth-domain:attach
|
||||
- role:attach
|
||||
summary: Create auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
@@ -14484,9 +14370,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- auth-domain:delete
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- auth-domain:delete
|
||||
summary: Delete auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
@@ -14541,9 +14427,9 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- auth-domain:read
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- auth-domain:read
|
||||
summary: Get auth domain by ID
|
||||
tags:
|
||||
- authdomains
|
||||
@@ -14597,9 +14483,17 @@ paths:
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- auth-domain:update
|
||||
- auth-domain:attach
|
||||
- auth-domain:detach
|
||||
- role:attach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
- auth-domain:update
|
||||
- auth-domain:attach
|
||||
- auth-domain:detach
|
||||
- role:attach
|
||||
- role:detach
|
||||
summary: Update auth domain
|
||||
tags:
|
||||
- authdomains
|
||||
@@ -23055,6 +22949,73 @@ paths:
|
||||
summary: Rotate session
|
||||
tags:
|
||||
- sessions
|
||||
/api/v2/system/dashboards/{name}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a dashboard SigNoz ships and owns, addressed by its stable
|
||||
definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards
|
||||
are read-only and upgraded through releases. The dashboard's own `name` field
|
||||
carries a reserved prefix that the path segment must not include.
|
||||
operationId: GetSystemDashboard
|
||||
parameters:
|
||||
- in: path
|
||||
name: name
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
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:
|
||||
- dashboard:read
|
||||
- tokenizer:
|
||||
- dashboard:read
|
||||
summary: Get system dashboard
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/user_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
|
||||
@@ -276,6 +276,10 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
|
||||
}
|
||||
|
||||
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.GetByNameV2(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.MigrateV2(ctx, orgID, id)
|
||||
}
|
||||
@@ -284,6 +288,10 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.UpdateUnsafeV2(ctx, orgID, id, updatedBy, updatable)
|
||||
}
|
||||
|
||||
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
|
||||
}
|
||||
|
||||
@@ -46,6 +46,8 @@ import type {
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
GetSystemDashboard200,
|
||||
GetSystemDashboardPathParameters,
|
||||
ListDashboardViews200,
|
||||
ListDashboardsForUserV2200,
|
||||
ListDashboardsForUserV2Params,
|
||||
@@ -2111,6 +2113,108 @@ export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const getSystemDashboard = (
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetSystemDashboard200>({
|
||||
url: `/api/v2/system/dashboards/${name}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryKey = ({
|
||||
name,
|
||||
}: GetSystemDashboardPathParameters) => {
|
||||
return [`/api/v2/system/dashboards/${name}`] as const;
|
||||
};
|
||||
|
||||
export const getGetSystemDashboardQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetSystemDashboardQueryKey({ name });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
> = ({ signal }) => getSystemDashboard({ name }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!name,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSystemDashboardQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>
|
||||
>;
|
||||
export type GetSystemDashboardQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
|
||||
export function useGetSystemDashboard<
|
||||
TData = Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemDashboard>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSystemDashboardQueryOptions({ name }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get system dashboard
|
||||
*/
|
||||
export const invalidateGetSystemDashboard = async (
|
||||
queryClient: QueryClient,
|
||||
{ name }: GetSystemDashboardPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetSystemDashboardQueryKey({ name }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
|
||||
* @summary List dashboards for the current user (v2)
|
||||
|
||||
@@ -22,10 +22,7 @@ import type {
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRoleDeprecated201,
|
||||
CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
@@ -39,7 +36,6 @@ import type {
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
@@ -1253,194 +1249,6 @@ export const invalidateGetServiceAccountRoles = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRoleDeprecated = (
|
||||
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRoleDeprecated'];
|
||||
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 createServiceAccountRoleDeprecated>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRoleDeprecated(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationBody =
|
||||
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRoleDeprecated = (
|
||||
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_accounts/${id}/roles/${rid}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
|
||||
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 deleteServiceAccountRoleDeprecated>>,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRoleDeprecated(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
* @summary Gets my service account
|
||||
|
||||
@@ -9092,13 +9092,6 @@ export interface SavedviewtypesUpdatableSavedViewDTO {
|
||||
spec: SavedviewtypesSavedViewSpecDTO;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -11020,21 +11013,6 @@ export type GetServiceAccountRoles200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRoleDeprecated201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
export type GetMyServiceAccount200 = {
|
||||
data: ServiceaccounttypesServiceAccountWithRolesDTO;
|
||||
/**
|
||||
@@ -12271,6 +12249,17 @@ export type RotateSession200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetSystemDashboardPathParameters = {
|
||||
name: string;
|
||||
};
|
||||
export type GetSystemDashboard200 = {
|
||||
data: DashboardtypesGettableDashboardV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUserRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
|
||||
3
frontend/src/assets/Logos/azure.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#0078D4" d="M22.379 23.343a1.62 1.62 0 0 0 1.536-2.14v.002L17.35 1.76A1.62 1.62 0 0 0 15.816.657H8.184A1.62 1.62 0 0 0 6.65 1.76L.086 21.204a1.62 1.62 0 0 0 1.536 2.139h4.741a1.62 1.62 0 0 0 1.535-1.103l.977-2.892 4.947 3.675c.28.208.618.32.966.32m-3.084-12.531 3.624 10.739a.54.54 0 0 1-.51.713v-.001h-.03a.54.54 0 0 1-.322-.106l-9.287-6.9h4.853m6.313 7.006c.116-.326.13-.694.007-1.058L9.79 1.76a1.722 1.722 0 0 0-.007-.02h6.034a.54.54 0 0 1 .512.366l6.562 19.445a.54.54 0 0 1-.338.684"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 583 B |
1
frontend/src/assets/Logos/cloudnative-pg.svg
Normal file
|
After Width: | Height: | Size: 7.3 KiB |
5
frontend/src/assets/Logos/dify.svg
Normal file
@@ -0,0 +1,5 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<title>Dify</title>
|
||||
<path d="M7.043 6.487c1.635 0 2.241-1.003 2.241-2.243S8.681 2 7.044 2C5.405 2 4.801 3.003 4.801 4.244c0 1.24.604 2.243 2.241 2.243z" fill="#03F" />
|
||||
<path d="M14.883 6.97v1.443h-3.679v3.203h3.68v8.012H8.801V8.41h-8v3.203h4.48v8.012H0v3.203h24v-3.203h-5.6v-8.012H24V8.41h-5.6V5.206H24V2.003h-4.161a4.97 4.97 0 00-4.961 4.967h.005z" fill="#03F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 447 B |
@@ -1 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xml:space="preserve" viewBox="0 0 115.28 122.88"><path d="M25.38 57h64.88V37.34H69.59c-2.17 0-5.19-1.17-6.62-2.6s-2.3-4.01-2.3-6.17V7.64H8.15c-.18 0-.32.09-.41.18-.15.1-.19.23-.19.42v106.45c0 .14.09.32.18.41.09.14.28.18.41.18h81.51c.18 0 .17-.09.27-.18.14-.09.33-.28.33-.41v-11.16H25.38c-4.14 0-7.56-3.4-7.56-7.56V64.55c0-4.15 3.4-7.55 7.56-7.55m4.11 11.38h7.43v18.15h11.63v5.92H29.49zm20.4 12.05c0-3.93 1.09-6.99 3.28-9.17 2.19-2.19 5.24-3.28 9.15-3.28 4.01 0 7.09 1.08 9.26 3.22 2.17 2.15 3.25 5.16 3.25 9.04 0 2.81-.47 5.11-1.42 6.91q-1.425 2.7-4.11 4.2t-6.69 1.5c-2.71 0-4.96-.43-6.74-1.29-1.78-.87-3.22-2.23-4.32-4.11-1.11-1.87-1.66-4.21-1.66-7.02m7.42.01c0 2.43.45 4.17 1.36 5.23s2.14 1.59 3.7 1.59c1.6 0 2.84-.52 3.71-1.56.88-1.04 1.32-2.9 1.32-5.6 0-2.26-.46-3.92-1.37-4.96-.92-1.05-2.16-1.57-3.73-1.57-1.5 0-2.71.53-3.62 1.59-.91 1.08-1.37 2.83-1.37 5.28m33.11 3.3v-5.01h11.49v10.23c-2.2 1.5-4.15 2.53-5.83 3.07-1.69.54-3.7.81-6.02.81-2.86 0-5.19-.49-6.99-1.46s-3.19-2.42-4.18-4.35c-.99-1.92-1.48-4.13-1.48-6.63 0-2.63.54-4.91 1.62-6.85s2.67-3.41 4.76-4.42c1.63-.78 3.83-1.17 6.58-1.17 2.66 0 4.64.24 5.96.72s2.41 1.23 3.28 2.24 1.52 2.3 1.96 3.85l-7.16 1.29c-.3-.91-.8-1.61-1.5-2.09-.71-.49-1.6-.73-2.7-.73-1.62 0-2.92.57-3.89 1.7s-1.45 2.92-1.45 5.37c0 2.6.49 4.46 1.47 5.57.97 1.11 2.34 1.68 4.09 1.68q1.245 0 2.37-.36c.75-.24 1.61-.65 2.59-1.22v-2.25h-4.97zM97.79 57h9.93c4.16 0 7.56 3.41 7.56 7.56v31.42c0 4.15-3.41 7.56-7.56 7.56h-9.93v13.55c0 1.61-.65 3.04-1.7 4.1a5.74 5.74 0 0 1-4.1 1.7H5.81a5.74 5.74 0 0 1-4.1-1.7 5.74 5.74 0 0 1-1.7-4.1V5.85c0-1.61.65-3.04 1.7-4.1a5.8 5.8 0 0 1 4.1-1.7h58.72c.13-.05.27-.05.41-.05.64 0 1.29.28 1.75.69h.09c.09.05.14.09.23.18L97 31.23c.51.51.88 1.2.88 1.98 0 .23-.05.41-.09.65zM67.52 27.97V8.94l21.43 21.7H70.19c-.74 0-1.38-.32-1.89-.78-.46-.46-.78-1.15-.78-1.89" style="fill:#D1D5DB;fill-rule:evenodd;clip-rule:evenodd"/></svg>
|
||||
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 1.9 KiB |
3
frontend/src/assets/Logos/github-copilot.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
|
||||
<path fill="#9CA3AF" d="M23.922 16.997C23.061 18.492 18.063 22.02 12 22.02 5.937 22.02.939 18.492.078 16.997A.641.641 0 0 1 0 16.741v-2.869a.883.883 0 0 1 .053-.22c.372-.935 1.347-2.292 2.605-2.656.167-.429.414-1.055.644-1.517a10.098 10.098 0 0 1-.052-1.086c0-1.331.282-2.499 1.132-3.368.397-.406.89-.717 1.474-.952C7.255 2.937 9.248 1.98 11.978 1.98c2.731 0 4.767.957 6.166 2.093.584.235 1.077.546 1.474.952.85.869 1.132 2.037 1.132 3.368 0 .368-.014.733-.052 1.086.23.462.477 1.088.644 1.517 1.258.364 2.233 1.721 2.605 2.656a.841.841 0 0 1 .053.22v2.869a.641.641 0 0 1-.078.256Zm-11.75-5.992h-.344a4.359 4.359 0 0 1-.355.508c-.77.947-1.918 1.492-3.508 1.492-1.725 0-2.989-.359-3.782-1.259a2.137 2.137 0 0 1-.085-.104L4 11.746v6.585c1.435.779 4.514 2.179 8 2.179 3.486 0 6.565-1.4 8-2.179v-6.585l-.098-.104s-.033.045-.085.104c-.793.9-2.057 1.259-3.782 1.259-1.59 0-2.738-.545-3.508-1.492a4.359 4.359 0 0 1-.355-.508Zm2.328 3.25c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm-5 0c.549 0 1 .451 1 1v2c0 .549-.451 1-1 1-.549 0-1-.451-1-1v-2c0-.549.451-1 1-1Zm3.313-6.185c.136 1.057.403 1.913.878 2.497.442.544 1.134.938 2.344.938 1.573 0 2.292-.337 2.657-.751.384-.435.558-1.15.558-2.361 0-1.14-.243-1.847-.705-2.319-.477-.488-1.319-.862-2.824-1.025-1.487-.161-2.192.138-2.533.529-.269.307-.437.808-.438 1.578v.021c0 .265.021.562.063.893Zm-1.626 0c.042-.331.063-.628.063-.894v-.02c-.001-.77-.169-1.271-.438-1.578-.341-.391-1.046-.69-2.533-.529-1.505.163-2.347.537-2.824 1.025-.462.472-.705 1.179-.705 2.319 0 1.211.175 1.926.558 2.361.365.414 1.084.751 2.657.751 1.21 0 1.902-.394 2.344-.938.475-.584.742-1.44.878-2.497Z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.7 KiB |
1
frontend/src/assets/Logos/keda.svg
Normal file
|
After Width: | Height: | Size: 25 KiB |
24
frontend/src/assets/Logos/kong.svg
Normal file
@@ -0,0 +1,24 @@
|
||||
<svg viewBox="0 0 44.8 40" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<linearGradient id="kong-a" x1="38.204" x2="8.732" y1="18.417" y2="48.543" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-b" x1="38.107" x2="8.635" y1="18.322" y2="48.448" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-c" x1="29.439" x2="-0.033" y1="9.842" y2="39.968" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
<linearGradient id="kong-d" x1="30.291" x2="0.819" y1="10.676" y2="40.801" gradientUnits="userSpaceOnUse">
|
||||
<stop stop-color="#11A06B" />
|
||||
<stop offset="1" stop-color="#286FEB" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<path d="m14.7 32.9-1.1 1.4 2.5 3.9-.3 1.8h10.6l.7-1.8-4.2-5.3z" fill="url(#kong-a)" />
|
||||
<path d="M20.5 9.4 16.7 16l18.6 22-.5 2h8.5l1.5-7.1L24.9 9.4z" fill="url(#kong-b)" />
|
||||
<path d="m23 4.4-1.8 3.3h4.5l7.7 9.2 4.6-3.8v-2.4l-1.6-2.2 1.2-1.2L28.4 0z" fill="url(#kong-c)" />
|
||||
<path d="M9.1 22.9H6.6L0 31.3V40h7.1l1.3-1.6 5.5-7.1h7.9l2.4-3.7-8.6-10.2z" fill="url(#kong-d)" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
3
frontend/src/assets/Logos/neon.svg
Normal file
@@ -0,0 +1,3 @@
|
||||
<svg fill="none" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M63 0.018v63.535L38.418 42.25v21.303H0V0l63 .018ZM7.723 55.839h22.972V25.323l24.583 21.725V7.729L7.723 7.716v48.123Z" fill="#37C38F" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 226 B |
@@ -1,5 +0,0 @@
|
||||
.client-side-qb-search {
|
||||
.ant-select-selection-search {
|
||||
width: max-content !important;
|
||||
}
|
||||
}
|
||||
@@ -1,661 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
|
||||
import {
|
||||
KeyboardEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Select, Tooltip } from 'antd';
|
||||
import {
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
QUERY_BUILDER_SEARCH_VALUES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { CustomTagProps } from 'container/QueryBuilder/filters/QueryBuilderSearch';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
|
||||
import { PLACEHOLDER } from 'container/QueryBuilder/filters/QueryBuilderSearch/constant';
|
||||
import { TypographyText } from 'container/QueryBuilder/filters/QueryBuilderSearch/style';
|
||||
import {
|
||||
checkCommaInValue,
|
||||
getOperatorFromValue,
|
||||
getOperatorValue,
|
||||
getTagToken,
|
||||
isInNInOperator,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import {
|
||||
DropdownState,
|
||||
ITag,
|
||||
Option,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
|
||||
import Suggestions from 'container/QueryBuilder/filters/QueryBuilderSearchV2/Suggestions';
|
||||
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
|
||||
import { validationMapper } from 'hooks/queryBuilder/useIsValidTag';
|
||||
import { operatorTypeMapper } from 'hooks/queryBuilder/useOperatorType';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { isArray, isEmpty, isEqual, isObject } from 'lodash-es';
|
||||
import { ChevronDown, ChevronUp } from '@signozhq/icons';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { popupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import './ClientSideQBSearch.styles.scss';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
export interface AttributeKey {
|
||||
key: string;
|
||||
}
|
||||
|
||||
export interface AttributeValuesMap {
|
||||
[key: string]: AttributeValue;
|
||||
}
|
||||
|
||||
interface ClientSideQBSearchProps {
|
||||
filters: TagFilter;
|
||||
onChange: (value: TagFilter) => void;
|
||||
whereClauseConfig?: WhereClauseConfig;
|
||||
placeholder?: string;
|
||||
className?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
attributeValuesMap?: AttributeValuesMap;
|
||||
attributeKeys: AttributeKey[];
|
||||
}
|
||||
|
||||
interface AttributeValue {
|
||||
stringAttributeValues: string[] | [];
|
||||
numberAttributeValues: number[] | [];
|
||||
boolAttributeValues: boolean[] | [];
|
||||
}
|
||||
|
||||
function ClientSideQBSearch(
|
||||
props: ClientSideQBSearchProps,
|
||||
): React.ReactElement {
|
||||
const {
|
||||
onChange,
|
||||
placeholder,
|
||||
className,
|
||||
suffixIcon,
|
||||
whereClauseConfig,
|
||||
attributeValuesMap,
|
||||
attributeKeys,
|
||||
filters,
|
||||
} = props;
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
|
||||
// create the tags from the initial query here, this should only be computed on the first load as post that tags and query will be always in sync.
|
||||
const [tags, setTags] = useState<ITag[]>(filters.items as ITag[]);
|
||||
|
||||
// this will maintain the current state of in process filter item
|
||||
const [currentFilterItem, setCurrentFilterItem] = useState<ITag | undefined>();
|
||||
|
||||
const [currentState, setCurrentState] = useState<DropdownState>(
|
||||
DropdownState.ATTRIBUTE_KEY,
|
||||
);
|
||||
|
||||
// to maintain the current running state until the tokenization happens for the tag
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
|
||||
const [dropdownOptions, setDropdownOptions] = useState<Option[]>([]);
|
||||
|
||||
const attributeValues = useMemo(() => {
|
||||
if (currentFilterItem?.key?.key) {
|
||||
return attributeValuesMap?.[currentFilterItem.key.key];
|
||||
}
|
||||
return {
|
||||
stringAttributeValues: [],
|
||||
numberAttributeValues: [],
|
||||
boolAttributeValues: [],
|
||||
};
|
||||
}, [attributeValuesMap, currentFilterItem?.key?.key]);
|
||||
|
||||
const handleDropdownSelect = useCallback(
|
||||
(value: string) => {
|
||||
let parsedValue: BaseAutocompleteData | string;
|
||||
|
||||
try {
|
||||
parsedValue = JSON.parse(value);
|
||||
} catch {
|
||||
parsedValue = value;
|
||||
}
|
||||
if (currentState === DropdownState.ATTRIBUTE_KEY) {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
...prev,
|
||||
key: parsedValue as BaseAutocompleteData,
|
||||
op: '',
|
||||
value: '',
|
||||
}));
|
||||
setCurrentState(DropdownState.OPERATOR);
|
||||
setSearchValue((parsedValue as BaseAutocompleteData)?.key);
|
||||
} else if (currentState === DropdownState.OPERATOR) {
|
||||
if (value === OPERATORS.EXISTS || value === OPERATORS.NOT_EXISTS) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: value,
|
||||
value: '',
|
||||
} as ITag,
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: value as string,
|
||||
value: '',
|
||||
}));
|
||||
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
|
||||
setSearchValue(`${currentFilterItem?.key?.key} ${value}`);
|
||||
}
|
||||
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
|
||||
const operatorType =
|
||||
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
|
||||
const isMulti = operatorType === QUERY_BUILDER_SEARCH_VALUES.MULTIPLY;
|
||||
|
||||
if (isMulti) {
|
||||
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
|
||||
// this condition takes care of adding the IN/NIN multi values when pressed enter on an already existing value.
|
||||
// not the best interaction but in sync with what we have today!
|
||||
if (tagValue.includes(String(value))) {
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
setCurrentFilterItem(undefined);
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: currentFilterItem?.op,
|
||||
value: tagValue,
|
||||
} as ITag,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
// this is for adding subsequent comma seperated values
|
||||
const newSearch = [...tagValue];
|
||||
newSearch[newSearch.length === 0 ? 0 : newSearch.length - 1] = value;
|
||||
const newSearchValue = newSearch.join(',');
|
||||
setSearchValue(`${tagKey} ${tagOperator} ${newSearchValue},`);
|
||||
} else {
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
setCurrentFilterItem(undefined);
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: currentFilterItem?.op,
|
||||
value,
|
||||
} as ITag,
|
||||
]);
|
||||
}
|
||||
}
|
||||
},
|
||||
[currentFilterItem?.key, currentFilterItem?.op, currentState, searchValue],
|
||||
);
|
||||
|
||||
const handleSearch = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
}, []);
|
||||
|
||||
const onInputKeyDownHandler = useCallback(
|
||||
(event: KeyboardEvent<Element>): void => {
|
||||
if (event.key === 'Backspace' && !searchValue) {
|
||||
event.stopPropagation();
|
||||
setTags((prev) => prev.slice(0, -1));
|
||||
}
|
||||
},
|
||||
[searchValue],
|
||||
);
|
||||
|
||||
const handleOnBlur = useCallback((): void => {
|
||||
if (searchValue) {
|
||||
const operatorType =
|
||||
operatorTypeMapper[currentFilterItem?.op || ''] || 'NOT_VALID';
|
||||
// if key is added and operator is not present then convert to body CONTAINS key
|
||||
if (
|
||||
currentFilterItem?.key &&
|
||||
isEmpty(currentFilterItem?.op) &&
|
||||
whereClauseConfig?.customKey === 'body' &&
|
||||
whereClauseConfig?.customOp === OPERATORS.CONTAINS
|
||||
) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: {
|
||||
key: 'body',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
id: 'body--string----true',
|
||||
},
|
||||
op: OPERATORS.CONTAINS,
|
||||
value: currentFilterItem?.key?.key,
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (
|
||||
currentFilterItem?.op === OPERATORS.EXISTS ||
|
||||
currentFilterItem?.op === OPERATORS.NOT_EXISTS
|
||||
) {
|
||||
// is exists and not exists operator is present then convert directly to tag! no need of value here
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: currentFilterItem?.op,
|
||||
value: '',
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (
|
||||
// if the current state is in sync with the kind of operator used then convert into a tag
|
||||
validationMapper[operatorType]?.(
|
||||
isArray(currentFilterItem?.value)
|
||||
? currentFilterItem?.value.length || 0
|
||||
: 1,
|
||||
)
|
||||
) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key as BaseAutocompleteData,
|
||||
op: currentFilterItem?.op as string,
|
||||
value: currentFilterItem?.value || '',
|
||||
},
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentFilterItem?.key,
|
||||
currentFilterItem?.op,
|
||||
currentFilterItem?.value,
|
||||
searchValue,
|
||||
whereClauseConfig?.customKey,
|
||||
whereClauseConfig?.customOp,
|
||||
]);
|
||||
|
||||
// this useEffect takes care of tokenisation based on the search state
|
||||
useEffect(() => {
|
||||
// if there is no search value reset to the default state
|
||||
if (!searchValue) {
|
||||
setCurrentFilterItem(undefined);
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
}
|
||||
|
||||
// split the current search value based on delimiters
|
||||
const { tagKey, tagOperator, tagValue } = getTagToken(searchValue);
|
||||
|
||||
if (
|
||||
// Case 1 - if key is defined but the search text doesn't match with the set key,
|
||||
// can happen when user selects from dropdown and then deletes a few characters
|
||||
currentFilterItem?.key &&
|
||||
currentFilterItem?.key?.key !== tagKey.split(' ')[0]
|
||||
) {
|
||||
setCurrentFilterItem(undefined);
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else if (tagOperator && isEmpty(currentFilterItem?.op)) {
|
||||
// Case 2 -> key is set and now typing for the operator
|
||||
if (
|
||||
tagOperator === OPERATORS.EXISTS ||
|
||||
tagOperator === OPERATORS.NOT_EXISTS
|
||||
) {
|
||||
setTags((prev) => [
|
||||
...prev,
|
||||
{
|
||||
key: currentFilterItem?.key,
|
||||
op: tagOperator,
|
||||
value: '',
|
||||
} as ITag,
|
||||
]);
|
||||
setCurrentFilterItem(undefined);
|
||||
setSearchValue('');
|
||||
setCurrentState(DropdownState.ATTRIBUTE_KEY);
|
||||
} else {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: tagOperator,
|
||||
value: '',
|
||||
}));
|
||||
|
||||
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
|
||||
}
|
||||
} else if (
|
||||
// Case 3 -> selected operator from dropdown and then erased a part of it
|
||||
!isEmpty(currentFilterItem?.op) &&
|
||||
tagOperator !== currentFilterItem?.op
|
||||
) {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: '',
|
||||
value: '',
|
||||
}));
|
||||
setCurrentState(DropdownState.OPERATOR);
|
||||
} else if (currentState === DropdownState.ATTRIBUTE_VALUE) {
|
||||
// Case 4 -> the final value state where we set the current filter values and the tokenisation happens on either
|
||||
// dropdown click or blur event
|
||||
const currentValue = {
|
||||
key: currentFilterItem?.key as BaseAutocompleteData,
|
||||
op: currentFilterItem?.op as string,
|
||||
value: tagValue,
|
||||
};
|
||||
if (!isEqual(currentValue, currentFilterItem)) {
|
||||
setCurrentFilterItem((prev) => ({
|
||||
key: prev?.key as BaseAutocompleteData,
|
||||
op: prev?.op as string,
|
||||
value: tagValue,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}, [
|
||||
currentFilterItem,
|
||||
currentFilterItem?.key,
|
||||
currentFilterItem?.op,
|
||||
searchValue,
|
||||
currentState,
|
||||
]);
|
||||
|
||||
// the useEffect takes care of setting the dropdown values correctly on change of the current state
|
||||
useEffect(() => {
|
||||
if (currentState === DropdownState.ATTRIBUTE_KEY) {
|
||||
const filteredAttributeKeys = attributeKeys.filter((key) =>
|
||||
key.key.startsWith(searchValue),
|
||||
);
|
||||
setDropdownOptions(
|
||||
filteredAttributeKeys?.map(
|
||||
(key) =>
|
||||
({
|
||||
label: key.key,
|
||||
value: key,
|
||||
}) as Option,
|
||||
) || [],
|
||||
);
|
||||
}
|
||||
if (currentState === DropdownState.OPERATOR) {
|
||||
const keyOperator = searchValue.split(' ');
|
||||
const partialOperator = keyOperator?.[1];
|
||||
const strippedKey = keyOperator?.[0];
|
||||
|
||||
let operatorOptions;
|
||||
if (currentFilterItem?.key?.dataType) {
|
||||
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
currentFilterItem.key
|
||||
.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
].map((operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}));
|
||||
|
||||
if (partialOperator) {
|
||||
operatorOptions = operatorOptions.filter((op) =>
|
||||
op.label.startsWith(partialOperator.toLocaleUpperCase()),
|
||||
);
|
||||
}
|
||||
setDropdownOptions(operatorOptions);
|
||||
} else if (strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')) {
|
||||
operatorOptions = [OPERATORS.HAS, OPERATORS.NHAS].map((operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}));
|
||||
setDropdownOptions(operatorOptions);
|
||||
} else {
|
||||
operatorOptions = QUERY_BUILDER_OPERATORS_BY_TYPES.universal.map(
|
||||
(operator) => ({
|
||||
label: operator,
|
||||
value: operator,
|
||||
}),
|
||||
);
|
||||
|
||||
if (partialOperator) {
|
||||
operatorOptions = operatorOptions.filter((op) =>
|
||||
op.label.startsWith(partialOperator.toLocaleUpperCase()),
|
||||
);
|
||||
}
|
||||
setDropdownOptions(operatorOptions);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentState === DropdownState.ATTRIBUTE_VALUE) {
|
||||
const values: Array<string | number | boolean> = [];
|
||||
const { tagValue } = getTagToken(searchValue);
|
||||
if (isArray(tagValue)) {
|
||||
if (!isEmpty(tagValue[tagValue.length - 1])) {
|
||||
values.push(tagValue[tagValue.length - 1]);
|
||||
}
|
||||
} else if (!isEmpty(tagValue)) {
|
||||
values.push(tagValue);
|
||||
}
|
||||
|
||||
const currentAttributeValues =
|
||||
attributeValues?.stringAttributeValues ||
|
||||
attributeValues?.numberAttributeValues ||
|
||||
attributeValues?.boolAttributeValues ||
|
||||
[];
|
||||
|
||||
values.push(...currentAttributeValues);
|
||||
|
||||
if (attributeValuesMap) {
|
||||
setDropdownOptions(
|
||||
values.map(
|
||||
(val) =>
|
||||
({
|
||||
label: checkCommaInValue(String(val)),
|
||||
value: val,
|
||||
}) as Option,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
// If attributeValuesMap is not provided, don't set dropdown options
|
||||
setDropdownOptions([]);
|
||||
}
|
||||
}
|
||||
}, [
|
||||
attributeValues,
|
||||
currentFilterItem?.key?.dataType,
|
||||
currentState,
|
||||
attributeKeys,
|
||||
searchValue,
|
||||
attributeValuesMap,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const filterTags: IBuilderQuery['filters'] = {
|
||||
op: 'AND',
|
||||
items: [],
|
||||
};
|
||||
tags.forEach((tag) => {
|
||||
const computedTagValue =
|
||||
tag.value &&
|
||||
Array.isArray(tag.value) &&
|
||||
tag.value[tag.value.length - 1] === ''
|
||||
? tag.value?.slice(0, -1)
|
||||
: (tag.value ?? '');
|
||||
filterTags.items.push({
|
||||
id: tag.id || uuid().slice(0, 8),
|
||||
key: tag.key,
|
||||
op: getOperatorValue(tag.op),
|
||||
value: computedTagValue,
|
||||
});
|
||||
});
|
||||
|
||||
if (!isEqual(filters, filterTags)) {
|
||||
onChange(filterTags);
|
||||
setTags(
|
||||
filterTags.items.map((tag) => ({
|
||||
...tag,
|
||||
op: getOperatorFromValue(tag.op),
|
||||
})) as ITag[],
|
||||
);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tags]);
|
||||
|
||||
const queryTags = useMemo(
|
||||
() => tags.map((tag) => `${tag.key.key} ${tag.op} ${tag.value}`),
|
||||
[tags],
|
||||
);
|
||||
|
||||
const onTagRender = ({
|
||||
value,
|
||||
closable,
|
||||
onClose,
|
||||
}: CustomTagProps): React.ReactElement => {
|
||||
const { tagOperator } = getTagToken(value);
|
||||
const isInNin = isInNInOperator(tagOperator);
|
||||
const chipValue = isInNin
|
||||
? value?.trim()?.replace(/,\s*$/, '')
|
||||
: value?.trim();
|
||||
|
||||
const indexInQueryTags = queryTags.findIndex((qTag) => isEqual(qTag, value));
|
||||
const tagDetails = tags[indexInQueryTags];
|
||||
|
||||
const onCloseHandler = (): void => {
|
||||
onClose();
|
||||
setSearchValue('');
|
||||
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
|
||||
};
|
||||
|
||||
const tagEditHandler = (value: string): void => {
|
||||
setCurrentFilterItem(tagDetails);
|
||||
setSearchValue(value);
|
||||
setCurrentState(DropdownState.ATTRIBUTE_VALUE);
|
||||
setTags((prev) => prev.filter((t) => !isEqual(t, tagDetails)));
|
||||
};
|
||||
|
||||
const isDisabled = !!searchValue;
|
||||
|
||||
return (
|
||||
<span className="qb-search-bar-tokenised-tags">
|
||||
<Badge
|
||||
color="vanilla"
|
||||
className={tagDetails?.key?.type || ''}
|
||||
closable={!searchValue && closable}
|
||||
onClose={(e): void => {
|
||||
e.preventDefault();
|
||||
onCloseHandler();
|
||||
}}
|
||||
>
|
||||
<Tooltip title={chipValue}>
|
||||
<TypographyText
|
||||
$isInNin={isInNin}
|
||||
$isEnabled={!!searchValue}
|
||||
$disabled={isDisabled}
|
||||
onClick={(): void => {
|
||||
if (!isDisabled) {
|
||||
tagEditHandler(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{chipValue}
|
||||
</TypographyText>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
const suffixIconContent = useMemo(() => {
|
||||
if (suffixIcon) {
|
||||
return suffixIcon;
|
||||
}
|
||||
return isOpen ? (
|
||||
<ChevronUp
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
) : (
|
||||
<ChevronDown
|
||||
size={14}
|
||||
color={isDarkMode ? Color.TEXT_VANILLA_100 : Color.TEXT_INK_100}
|
||||
/>
|
||||
);
|
||||
}, [isDarkMode, isOpen, suffixIcon]);
|
||||
|
||||
return (
|
||||
<div className="query-builder-search-v2 ">
|
||||
<Select
|
||||
ref={selectRef}
|
||||
getPopupContainer={popupContainer}
|
||||
virtual={false}
|
||||
showSearch
|
||||
tagRender={onTagRender}
|
||||
transitionName=""
|
||||
choiceTransitionName=""
|
||||
filterOption={false}
|
||||
open={isOpen}
|
||||
suffixIcon={suffixIconContent}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
placeholder={placeholder}
|
||||
value={queryTags}
|
||||
searchValue={searchValue}
|
||||
className={className}
|
||||
rootClassName="query-builder-search client-side-qb-search"
|
||||
disabled={!attributeKeys.length}
|
||||
style={selectStyle}
|
||||
onSearch={handleSearch}
|
||||
onSelect={handleDropdownSelect}
|
||||
onInputKeyDown={onInputKeyDownHandler}
|
||||
notFoundContent={null}
|
||||
showAction={['focus']}
|
||||
onBlur={handleOnBlur}
|
||||
>
|
||||
{dropdownOptions.map((option) => {
|
||||
let val = option.value;
|
||||
try {
|
||||
if (isObject(option.value)) {
|
||||
val = JSON.stringify(option.value);
|
||||
} else {
|
||||
val = option.value;
|
||||
}
|
||||
} catch {
|
||||
val = option.value;
|
||||
}
|
||||
return (
|
||||
<Select.Option key={isObject(val) ? `select-option` : val} value={val}>
|
||||
<Suggestions
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
option={currentState}
|
||||
searchValue={searchValue}
|
||||
/>
|
||||
</Select.Option>
|
||||
);
|
||||
})}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
ClientSideQBSearch.defaultProps = {
|
||||
placeholder: PLACEHOLDER,
|
||||
className: '',
|
||||
suffixIcon: null,
|
||||
whereClauseConfig: {},
|
||||
attributeValuesMap: {},
|
||||
};
|
||||
|
||||
export default ClientSideQBSearch;
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_FUNCTIONS,
|
||||
} from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import FilterQueryLexer from 'parser/FilterQueryLexer';
|
||||
import FilterQueryParser, {
|
||||
AndExpressionContext,
|
||||
|
||||
@@ -5,7 +5,7 @@ import {
|
||||
QuickFiltersSource,
|
||||
} from 'components/QuickFilters/types';
|
||||
import { OPERATORS } from 'constants/antlrQueryConstants';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { cloneDeep, isArray } from 'lodash-es';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useRecoverFromEmptyPage,
|
||||
UseRecoverFromEmptyPageParams,
|
||||
} from '../useRecoverFromEmptyPage';
|
||||
|
||||
const REPLACE = { history: 'replace' };
|
||||
|
||||
function renderRecovery(
|
||||
overrides: Partial<UseRecoverFromEmptyPageParams> = {},
|
||||
): { setPage: jest.Mock; rerender: (next?: unknown) => void } {
|
||||
const setPage = jest.fn();
|
||||
const props: UseRecoverFromEmptyPageParams = {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
rowCount: 10,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
setPage,
|
||||
...overrides,
|
||||
};
|
||||
|
||||
const { rerender } = renderHook(
|
||||
(next: UseRecoverFromEmptyPageParams) => useRecoverFromEmptyPage(next),
|
||||
{ initialProps: props },
|
||||
);
|
||||
|
||||
return {
|
||||
setPage,
|
||||
rerender: (next?: unknown): void =>
|
||||
rerender({ ...props, ...(next as Partial<UseRecoverFromEmptyPageParams>) }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('useRecoverFromEmptyPage', () => {
|
||||
it('leaves the page alone while it still holds rows', () => {
|
||||
const { setPage } = renderRecovery({ page: 3, rowCount: 10 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('leaves the page alone on page 1 with no rows at all', () => {
|
||||
const { setPage } = renderRecovery({ page: 1, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('jumps to the last page that holds data when the page is out of range', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
});
|
||||
|
||||
it('replaces the history entry so the back button does not return to the empty page', () => {
|
||||
const { setPage } = renderRecovery({ page: 4, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when the total is unknown', () => {
|
||||
const { setPage } = renderRecovery({ page: 5, rowCount: 0, total: 0 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('steps back one page when the total claims the page should have data', () => {
|
||||
// total says 100 rows exist, yet page 5 came back empty — step back rather
|
||||
// than stall on a page the query cannot actually serve.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(4, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one', () => {
|
||||
const { setPage } = renderRecovery({ page: 0, rowCount: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('falls back to page 1 when pageSize is zero', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 0,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('waits for the request to settle before moving the user', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
|
||||
rerender({ page: 3, rowCount: 0, total: 10, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one even when the query failed', () => {
|
||||
// A negative offset is what made the request fail (400 "offset cannot be
|
||||
// negative"), so retrying the same page loops forever — clamp regardless.
|
||||
const { setPage } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('clamps a page below the first one while the query is still in flight', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: -2,
|
||||
rowCount: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(1, REPLACE);
|
||||
});
|
||||
|
||||
it('keeps the page when the query failed so a retry lands where the user was', () => {
|
||||
const { setPage } = renderRecovery({
|
||||
page: 3,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isDisabled: true,
|
||||
});
|
||||
|
||||
expect(setPage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('clamps a page below the first one exactly once while the request settles', () => {
|
||||
// The clamp runs ahead of both gates, so a request settling underneath an
|
||||
// uncorrected page must not re-issue the same history rewrite.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 0,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: true,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
rerender({ page: 0, rowCount: 0, total: 0, isFetching: false });
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stops correcting once the corrected page comes back with rows', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 7,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 25,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledWith(3, REPLACE);
|
||||
|
||||
// The correction lands: the query refetches, then resolves with the rows page 3 holds.
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 25, isFetching: true });
|
||||
rerender({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 5,
|
||||
total: 25,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('does not correct again while the same page is still being observed', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
|
||||
// A refetch cycle that leaves the page untouched — the correction is already in flight.
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: true,
|
||||
});
|
||||
rerender({
|
||||
page: 5,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 100,
|
||||
isFetching: false,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('gives up on step-backs and jumps to page 1 when the total keeps lying', () => {
|
||||
// `total` claims 400 rows exist, but every page comes back empty. Walking back one
|
||||
// page at a time would cost a request per hop, so bail out to page 1 instead.
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 40,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 400,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 39, REPLACE);
|
||||
|
||||
rerender({ page: 39, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 38, REPLACE);
|
||||
|
||||
rerender({ page: 38, pageSize: 10, rowCount: 0, total: 400 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(3, 1, REPLACE);
|
||||
expect(setPage).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('corrects again when the user returns to a page that is still empty', () => {
|
||||
const { setPage, rerender } = renderRecovery({
|
||||
page: 3,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 10,
|
||||
});
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(1, 1, REPLACE);
|
||||
|
||||
rerender({ page: 1, pageSize: 10, rowCount: 10, total: 10 });
|
||||
rerender({ page: 3, pageSize: 10, rowCount: 0, total: 10 });
|
||||
|
||||
expect(setPage).toHaveBeenNthCalledWith(2, 1, REPLACE);
|
||||
});
|
||||
|
||||
it('does not re-run the correction when setPage is a fresh function each render', () => {
|
||||
// The hook reads setPage through a ref, so an inline arrow must not turn the
|
||||
// ungated `page < 1` clamp into a per-render history rewrite.
|
||||
const setPage = jest.fn();
|
||||
const { rerender } = renderHook(
|
||||
() =>
|
||||
useRecoverFromEmptyPage({
|
||||
page: 0,
|
||||
pageSize: 10,
|
||||
rowCount: 0,
|
||||
total: 0,
|
||||
isFetching: false,
|
||||
setPage: (nextPage, options): void => setPage(nextPage, options),
|
||||
}),
|
||||
{ initialProps: undefined },
|
||||
);
|
||||
|
||||
rerender(undefined);
|
||||
rerender(undefined);
|
||||
|
||||
expect(setPage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import {
|
||||
useStableTotalCount,
|
||||
UseStableTotalCountParams,
|
||||
} from '../useStableTotalCount';
|
||||
|
||||
function renderStableTotalCount(initial: UseStableTotalCountParams): {
|
||||
result: { current: number };
|
||||
rerender: (next: UseStableTotalCountParams) => void;
|
||||
} {
|
||||
const { result, rerender } = renderHook(
|
||||
(params: UseStableTotalCountParams) => useStableTotalCount(params),
|
||||
{ initialProps: initial },
|
||||
);
|
||||
|
||||
return { result, rerender };
|
||||
}
|
||||
|
||||
describe('useStableTotalCount', () => {
|
||||
it('reports the total once the load settles', () => {
|
||||
const { result } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
|
||||
it('keeps the last count while the same list refetches', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
rerender({ total: 0, isLoading: true, resetKey: 'pods' });
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
|
||||
it('forgets the count when the list changes', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('reports the new list total once it arrives', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
rerender({ total: 0, isLoading: true, resetKey: 'nodes' });
|
||||
rerender({ total: 25, isLoading: false, resetKey: 'nodes' });
|
||||
|
||||
expect(result.current).toBe(25);
|
||||
});
|
||||
|
||||
it('reports zero for a list that is genuinely empty', () => {
|
||||
const { result } = renderStableTotalCount({
|
||||
total: 0,
|
||||
isLoading: false,
|
||||
resetKey: 'pods',
|
||||
});
|
||||
|
||||
expect(result.current).toBe(0);
|
||||
});
|
||||
|
||||
it('holds nothing back when no reset key is given', () => {
|
||||
const { result, rerender } = renderStableTotalCount({
|
||||
total: 100,
|
||||
isLoading: false,
|
||||
resetKey: undefined,
|
||||
});
|
||||
|
||||
// Without a key there is no context to compare, so the cache behaves as it
|
||||
// always did: it survives the refetch.
|
||||
rerender({ total: 0, isLoading: true, resetKey: undefined });
|
||||
|
||||
expect(result.current).toBe(100);
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ export * from './useCalculatedPageSize';
|
||||
export * from './useColumnState';
|
||||
export * from './useColumnStore';
|
||||
export * from './usePreferredPageSize.store';
|
||||
export * from './useRecoverFromEmptyPage';
|
||||
export * from './useTableParams';
|
||||
|
||||
/**
|
||||
@@ -285,6 +286,51 @@ export * from './useTableParams';
|
||||
*
|
||||
* **Pagination shows "Auto" option** when `calculatedPageSize` is passed, allowing users
|
||||
* to reset to auto-calculated size.
|
||||
*
|
||||
* **`setPage` accepts history options**: `setPage(page, { history: 'replace' })` rewrites the
|
||||
* current history entry instead of pushing a new one. Use `replace` for corrections the user
|
||||
* did not ask for — otherwise the back button walks straight back into the state that was just
|
||||
* corrected. Only applies when the page is synced to the URL; local (non-URL) pages ignore it.
|
||||
*
|
||||
* @example useRecoverFromEmptyPage — send the user back to a page that has data
|
||||
*
|
||||
* When rows disappear underneath the current page (filters narrowed, time range moved, items
|
||||
* deleted), the user is stranded on an empty page they cannot leave by scrolling. This hook
|
||||
* watches the fetched result and corrects the page with `history: 'replace'`, so the back
|
||||
* button does not return to the empty page.
|
||||
*
|
||||
* Correction rules:
|
||||
* - `page < 1` → jump to page 1, even while fetching or disabled. Such a page usually maps to a
|
||||
* negative offset the API rejects (400 `offset cannot be negative`), so the response can never
|
||||
* confirm the page is empty — deferring to it would strand the user on a permanent error.
|
||||
* - Page is empty and not page 1 → go to `min(ceil(total / pageSize), page - 1)`. When `total`
|
||||
* is trustworthy that lands on the last page holding data; when `total` is unknown or zero it
|
||||
* lands on page 1; and when `total` claims this page should have had rows it steps back a
|
||||
* single page. Repeated step-backs give up and jump to page 1 after the second one, so a
|
||||
* badly inflated `total` cannot walk the user down one request at a time.
|
||||
* - Page has rows, or the user is already on page 1 → do nothing (an empty page 1 means there
|
||||
* is genuinely nothing to show).
|
||||
*
|
||||
* Pass `isFetching` so the hook waits for the request to settle, and `isDisabled` so a failed
|
||||
* request is not mistaken for an empty page. Neither gate suppresses the `page < 1` clamp.
|
||||
*
|
||||
* ```tsx
|
||||
* import { useRecoverFromEmptyPage, useTableParams } from 'components/TanStackTableView';
|
||||
*
|
||||
* const { page, limit, setPage } = useTableParams(QUERY_PARAMS, { page: 1, limit: 20 });
|
||||
* const { data, isLoading, isFetching, isError } = useListQuery({ page, limit });
|
||||
*
|
||||
* useRecoverFromEmptyPage({
|
||||
* page,
|
||||
* pageSize: limit,
|
||||
* rowCount: data?.rows.length ?? 0,
|
||||
* total: data?.total ?? 0,
|
||||
* isFetching: isLoading || isFetching,
|
||||
* // Skip correction on errors — no rows there means "request failed", not "page is empty".
|
||||
* isDisabled: isError,
|
||||
* setPage,
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
const TanStackTable = Object.assign(TanStackTableBase, {
|
||||
Text: TanStackTableText,
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
|
||||
import { SetPageOptions } from './useTableParams';
|
||||
|
||||
const FIRST_PAGE = 1;
|
||||
const REPLACE_HISTORY: SetPageOptions = { history: 'replace' };
|
||||
|
||||
/**
|
||||
* How many single-page step-backs to attempt before giving up and going to page 1.
|
||||
*
|
||||
* A step-back only happens when `total` claims the current page should hold data but the
|
||||
* response came back empty. Each hop costs a request, so an inflated `total` on a high page
|
||||
* number would otherwise walk the user down one page at a time behind a spinner.
|
||||
*/
|
||||
const MAX_STEP_BACKS = 2;
|
||||
|
||||
type Correction = {
|
||||
from: number;
|
||||
to: number;
|
||||
};
|
||||
|
||||
export type UseRecoverFromEmptyPageParams = {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
rowCount: number;
|
||||
total: number;
|
||||
isFetching: boolean;
|
||||
isDisabled?: boolean;
|
||||
setPage: (page: number, options?: SetPageOptions) => void;
|
||||
};
|
||||
|
||||
export function useRecoverFromEmptyPage({
|
||||
page,
|
||||
pageSize,
|
||||
rowCount,
|
||||
total,
|
||||
isFetching,
|
||||
isDisabled = false,
|
||||
setPage,
|
||||
}: UseRecoverFromEmptyPageParams): void {
|
||||
const setPageRef = useRef(setPage);
|
||||
const lastCorrectionRef = useRef<Correction | null>(null);
|
||||
const stepBacksRef = useRef(0);
|
||||
|
||||
useEffect(() => {
|
||||
setPageRef.current = setPage;
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (lastCorrectionRef.current && lastCorrectionRef.current.from !== page) {
|
||||
lastCorrectionRef.current = null;
|
||||
}
|
||||
|
||||
const correctTo = (nextPage: number): boolean => {
|
||||
if (lastCorrectionRef.current?.to === nextPage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
lastCorrectionRef.current = { from: page, to: nextPage };
|
||||
setPageRef.current(nextPage, REPLACE_HISTORY);
|
||||
return true;
|
||||
};
|
||||
|
||||
// A page below the first one is invalid on its own terms — it usually maps to a
|
||||
// negative offset the API rejects outright, so waiting for a response that will
|
||||
// never arrive (or trusting a failed one) would strand the user for good.
|
||||
if (page < FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
void correctTo(FIRST_PAGE);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isFetching || isDisabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
// The page has data, or there is genuinely nothing to show anywhere.
|
||||
if (rowCount > 0 || page === FIRST_PAGE) {
|
||||
stepBacksRef.current = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentPage = Math.floor(page);
|
||||
const lastPageWithData =
|
||||
pageSize > 0 && total > 0 ? Math.ceil(total / pageSize) : FIRST_PAGE;
|
||||
const nextPage = Math.max(
|
||||
FIRST_PAGE,
|
||||
Math.min(lastPageWithData, currentPage - 1),
|
||||
);
|
||||
|
||||
// `total` disagrees with the response: it says this page should have rows, so the
|
||||
// only safe move is one page back. Cap how often that repeats — every hop is a
|
||||
// request, and a badly inflated `total` would otherwise crawl down from page 40.
|
||||
const isStepBack = nextPage === currentPage - 1;
|
||||
|
||||
if (isStepBack && stepBacksRef.current >= MAX_STEP_BACKS) {
|
||||
if (correctTo(FIRST_PAGE)) {
|
||||
stepBacksRef.current = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (correctTo(nextPage) && isStepBack) {
|
||||
stepBacksRef.current += 1;
|
||||
}
|
||||
}, [isFetching, isDisabled, page, pageSize, rowCount, total]);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { useRef } from 'react';
|
||||
|
||||
export type UseStableTotalCountParams = {
|
||||
total: number | undefined;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* Identifies the list being counted. When it changes, the cached count is
|
||||
* dropped so the previous list's page count cannot outlive it.
|
||||
*/
|
||||
resetKey: string | undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Holds on to the last non-zero total so the pagination does not flash while the
|
||||
* same list refetches, and forgets it as soon as `resetKey` moves to another list.
|
||||
*/
|
||||
export function useStableTotalCount({
|
||||
total,
|
||||
isLoading,
|
||||
resetKey,
|
||||
}: UseStableTotalCountParams): number {
|
||||
const prevTotalRef = useRef(total || 0);
|
||||
const prevResetKeyRef = useRef(resetKey);
|
||||
|
||||
if (prevResetKeyRef.current !== resetKey) {
|
||||
prevResetKeyRef.current = resetKey;
|
||||
prevTotalRef.current = 0;
|
||||
}
|
||||
|
||||
if (total && total > 0) {
|
||||
prevTotalRef.current = total;
|
||||
}
|
||||
|
||||
return isLoading ? prevTotalRef.current : total || 0;
|
||||
}
|
||||
@@ -29,12 +29,16 @@ type Defaults = {
|
||||
cleanupOnUnmount?: boolean;
|
||||
};
|
||||
|
||||
export type SetPageOptions = {
|
||||
history?: 'push' | 'replace';
|
||||
};
|
||||
|
||||
export type TableParamsResult = {
|
||||
page: number;
|
||||
limit: number;
|
||||
orderBy: SortState | null;
|
||||
expanded: ExpandedState;
|
||||
setPage: (p: number) => void;
|
||||
setPage: (p: number, options?: SetPageOptions) => void;
|
||||
setLimit: (l: number) => void;
|
||||
setOrderBy: (s: SortState | null) => void;
|
||||
setExpanded: (updaterOrValue: Updater<ExpandedState>) => void;
|
||||
@@ -249,6 +253,17 @@ export function useTableParams(
|
||||
[],
|
||||
);
|
||||
|
||||
const setUrlPageWithOptions = useCallback(
|
||||
(page: number, options?: SetPageOptions): void => {
|
||||
void setUrlPage(page, options);
|
||||
},
|
||||
[setUrlPage],
|
||||
);
|
||||
|
||||
const setLocalPageValue = useCallback((page: number): void => {
|
||||
setLocalPage(page);
|
||||
}, []);
|
||||
|
||||
const orderByUrlMemoKey = `${urlOrderBy?.columnName}${urlOrderBy?.order}`;
|
||||
const prevOrderByRef = useRef<string | null>(null);
|
||||
|
||||
@@ -303,7 +318,7 @@ export function useTableParams(
|
||||
limit: useUrlForLimit ? urlLimit : localLimit,
|
||||
orderBy: (useUrlForOrderBy ? urlOrderBy : localOrderBy) as SortState | null,
|
||||
expanded: useUrlForExpanded ? urlExpanded : localExpanded,
|
||||
setPage: useUrlForPage ? setUrlPage : setLocalPage,
|
||||
setPage: useUrlForPage ? setUrlPageWithOptions : setLocalPageValue,
|
||||
setLimit: useUrlForLimit ? setUrlLimit : setLocalLimitWithPersist,
|
||||
setOrderBy: useUrlForOrderBy ? setUrlOrderBy : setLocalOrderBy,
|
||||
setExpanded: useUrlForExpanded ? setUrlExpanded : handleSetLocalExpanded,
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo, useMemo } from 'react';
|
||||
import { Select, Spin } from 'antd';
|
||||
import { OrderByFilterProps } from 'container/QueryBuilder/filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
import { useOrderByFilter } from 'container/QueryBuilder/filters/OrderByFilter/useOrderByFilter';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearch/config';
|
||||
import { selectStyle } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/config';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { StringOperators } from 'types/common/queryBuilder';
|
||||
|
||||
@@ -7,6 +7,7 @@ import TanStackTable, {
|
||||
TableColumnDef,
|
||||
useCalculatedPageSize,
|
||||
useHiddenColumnIds,
|
||||
useRecoverFromEmptyPage,
|
||||
useTableParams,
|
||||
} from 'components/TanStackTableView';
|
||||
import { InfraMonitoringEvents } from 'constants/events';
|
||||
@@ -136,6 +137,7 @@ export function K8sBaseList<
|
||||
page: currentPage,
|
||||
limit: currentPageSize,
|
||||
setLimit,
|
||||
setPage,
|
||||
} = useTableParams(
|
||||
{
|
||||
page: INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
@@ -243,6 +245,16 @@ export function K8sBaseList<
|
||||
const totalCount = data?.total || 0;
|
||||
const hasFilters = !!expression?.trim();
|
||||
|
||||
useRecoverFromEmptyPage({
|
||||
page: currentPage,
|
||||
pageSize: currentPageSize,
|
||||
rowCount: pageData.length,
|
||||
total: totalCount,
|
||||
isFetching: isLoading || isFetching,
|
||||
isDisabled: isError || Boolean(data?.error),
|
||||
setPage,
|
||||
});
|
||||
|
||||
const getGroupKeyFn = useCallback(
|
||||
(item: T) => getGroupedByMeta(item, groupBy),
|
||||
[groupBy],
|
||||
|
||||
@@ -591,12 +591,14 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
describe('with empty data', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
fetchListDataMock.mockResolvedValue({
|
||||
data: [],
|
||||
@@ -605,6 +607,7 @@ describe('K8sBaseList', () => {
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
@@ -625,6 +628,177 @@ describe('K8sBaseList', () => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('should not rewrite the page when already on the first page', async () => {
|
||||
await waitFor(() => {
|
||||
expect(fetchListDataMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page beyond the end of the list', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
// 25 rows exist, so pages 1-3 serve data and page 7 of 10 comes back empty.
|
||||
const rows: TestItem[] = Array.from({ length: 25 }, (_, index) => ({
|
||||
id: `pod-${index + 1}`,
|
||||
}));
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// Offset-aware on purpose: a mock that answers empty for every offset would let
|
||||
// the assertions pass against a page the recovery has already moved on from.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0, limit = 10 }) => ({
|
||||
data: rows.slice(offset, offset + limit),
|
||||
total: rows.length,
|
||||
error: null,
|
||||
}));
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '7', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should send the user back to the last page holding data', async () => {
|
||||
// The rows of page 3 on screen are what proves the recovery settled there,
|
||||
// rather than passing through on its way somewhere else.
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageUpdates = onUrlUpdateMock.mock.calls
|
||||
.map((call) => call[0].searchParams.get('page'))
|
||||
.filter(Boolean);
|
||||
|
||||
expect(pageUpdates).toStrictEqual(['3']);
|
||||
});
|
||||
|
||||
it('should correct the page in a single hop', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
// Only the original out-of-range page and the corrected one are requested.
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.map((call) => call[0].offset),
|
||||
).toStrictEqual([60, 20]);
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await expect(screen.findByText('pod-21')).resolves.toBeInTheDocument();
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === '3',
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
});
|
||||
|
||||
describe('with a page below the first one', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
const fetchListDataMock = jest.fn<
|
||||
ReturnType<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>,
|
||||
Parameters<NonNullable<K8sBaseListProps<TestItem>['fetchListData']>>
|
||||
>();
|
||||
|
||||
beforeEach(() => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
fetchListDataMock.mockClear();
|
||||
// page=0 turns into offset=-10, which the API rejects outright — the list
|
||||
// can only recover by clamping the page, never by reading the response.
|
||||
fetchListDataMock.mockImplementation(async ({ offset = 0 }) => {
|
||||
if (offset < 0) {
|
||||
throw new APIError({
|
||||
httpStatusCode: 400,
|
||||
error: {
|
||||
code: 'invalid_input',
|
||||
message: 'offset cannot be negative',
|
||||
url: '',
|
||||
errors: [],
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { data: [{ id: 'pod-1' }], total: 1, error: null };
|
||||
});
|
||||
|
||||
renderComponent<TestItem>({
|
||||
onUrlUpdate: onUrlUpdateMock,
|
||||
entity: InfraMonitoringEntity.PODS,
|
||||
eventCategory: InfraMonitoringEvents.Pod,
|
||||
fetchListData: fetchListDataMock,
|
||||
queryParams: { page: '0', pageSize: '10' },
|
||||
tableColumns: createTestColumns(),
|
||||
getRowKey: (row): string => row.id,
|
||||
getItemKey: (row): string => row.id,
|
||||
});
|
||||
});
|
||||
|
||||
it('should reject the request that carried the negative offset', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === -10),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await expect(
|
||||
fetchListDataMock.mock.results[0].value as Promise<unknown>,
|
||||
).rejects.toThrow('offset cannot be negative');
|
||||
});
|
||||
|
||||
it('should clamp the page to the first one even though the request failed', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Page 1 is the default, so the correction drops the param rather than
|
||||
// writing `page=1`.
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection).toBeDefined();
|
||||
expect(pageCorrection?.[0].queryString).toBe('?pageSize=10');
|
||||
});
|
||||
|
||||
it('should replace the history entry instead of pushing the correction', async () => {
|
||||
await waitFor(() => {
|
||||
expect(onUrlUpdateMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
const pageCorrection = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('page') === null,
|
||||
);
|
||||
|
||||
expect(pageCorrection?.[0].options.history).toBe('replace');
|
||||
});
|
||||
|
||||
it('should refetch with a non-negative offset after clamping', async () => {
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
fetchListDataMock.mock.calls.some((call) => call[0].offset === 0),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('pod-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('with error response', () => {
|
||||
|
||||
@@ -47,6 +47,7 @@ import {
|
||||
useInfraMonitoringCategory,
|
||||
useInfraMonitoringGroupBy,
|
||||
useInfraMonitoringOrderBy,
|
||||
useInfraMonitoringPageListing,
|
||||
useInfraMonitoringSelectedItemParams,
|
||||
} from './hooks';
|
||||
|
||||
@@ -67,6 +68,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
const [, setGroupBy] = useInfraMonitoringGroupBy();
|
||||
const [, setOrderBy] = useInfraMonitoringOrderBy();
|
||||
const [, setSelectedItemParams] = useInfraMonitoringSelectedItemParams();
|
||||
const [, setCurrentPage] = useInfraMonitoringPageListing();
|
||||
|
||||
const compositeQuery = useGetCompositeQueryParam();
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
@@ -218,6 +220,7 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
void setSelectedCategory(key as string);
|
||||
void setOrderBy(null);
|
||||
void setGroupBy(null);
|
||||
void setCurrentPage(null);
|
||||
setSelectedItemParams(null);
|
||||
redirectWithQueryBuilderData({
|
||||
...currentQuery,
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { QueryClient, QueryClientProvider } from 'react-query';
|
||||
// eslint-disable-next-line no-restricted-imports
|
||||
import { Provider } from 'react-redux';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { MemoryRouter as MemoryRouterV5 } from 'react-router-dom-v5-compat';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { NuqsTestingAdapter, UrlUpdateEvent } from 'nuqs/adapters/testing';
|
||||
import { AppProvider } from 'providers/App/App';
|
||||
import { QueryBuilderProvider } from 'providers/QueryBuilder';
|
||||
import TimezoneProvider from 'providers/Timezone';
|
||||
import store from 'store';
|
||||
|
||||
import { K8sCategories } from '../constants';
|
||||
import InfraMonitoringK8s from '../InfraMonitoringK8s';
|
||||
|
||||
// Quick filters fire their own field APIs and are irrelevant to pagination.
|
||||
jest.mock('components/QuickFilters/QuickFilters', () => ({
|
||||
__esModule: true,
|
||||
default: (): JSX.Element => <div data-testid="quick-filters" />,
|
||||
}));
|
||||
|
||||
// The list owns its own page recovery; stubbing it keeps the page param under the
|
||||
// sole control of the category handler being tested here.
|
||||
jest.mock('../Base/K8sDynamicList', () => ({
|
||||
__esModule: true,
|
||||
K8sDynamicList: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
default: (): JSX.Element => <div data-testid="k8s-dynamic-list" />,
|
||||
}));
|
||||
|
||||
// Analytics only; jsdom lacks the Performance navigation entries it reads.
|
||||
jest.mock('lib/navigation', () => ({
|
||||
getNavigationReferrer: (): string => 'direct',
|
||||
}));
|
||||
|
||||
function renderPage(
|
||||
queryParams: Record<string, string>,
|
||||
onUrlUpdate: jest.Mock<void, [UrlUpdateEvent]>,
|
||||
): void {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<MemoryRouter>
|
||||
<MemoryRouterV5>
|
||||
<TimezoneProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AppProvider>
|
||||
<Provider store={store}>
|
||||
<NuqsTestingAdapter
|
||||
searchParams={queryParams}
|
||||
onUrlUpdate={onUrlUpdate}
|
||||
>
|
||||
<TooltipProvider>
|
||||
<QueryBuilderProvider>
|
||||
<InfraMonitoringK8s />
|
||||
</QueryBuilderProvider>
|
||||
</TooltipProvider>
|
||||
</NuqsTestingAdapter>
|
||||
</Provider>
|
||||
</AppProvider>
|
||||
</QueryClientProvider>
|
||||
</TimezoneProvider>
|
||||
</MemoryRouterV5>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('InfraMonitoringK8s', () => {
|
||||
describe('when the category changes from a page other than the first', () => {
|
||||
const onUrlUpdateMock = jest.fn<void, [UrlUpdateEvent]>();
|
||||
|
||||
beforeEach(async () => {
|
||||
onUrlUpdateMock.mockClear();
|
||||
|
||||
renderPage(
|
||||
{ category: K8sCategories.PODS, page: '3', pageSize: '10' },
|
||||
onUrlUpdateMock,
|
||||
);
|
||||
|
||||
await screen.findByTestId(`category-${K8sCategories.NODES}`);
|
||||
});
|
||||
|
||||
it('should drop the page so the new category starts at the first one', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
// Page 3 of pods says nothing about nodes — keeping it asks the new entity
|
||||
// for an offset it may not have. The param is cleared rather than set to 1,
|
||||
// since an absent page already means the first one.
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch).toBeDefined();
|
||||
expect(categorySwitch?.[0].searchParams.get('page')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep the page size, which is not category specific', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.NODES}`));
|
||||
|
||||
await waitFor(() => {
|
||||
const categorySwitch = onUrlUpdateMock.mock.calls.find(
|
||||
(call) => call[0].searchParams.get('category') === K8sCategories.NODES,
|
||||
);
|
||||
|
||||
expect(categorySwitch?.[0].searchParams.get('pageSize')).toBe('10');
|
||||
});
|
||||
});
|
||||
|
||||
it('should leave the page alone when the same category is clicked again', async () => {
|
||||
fireEvent.click(screen.getByTestId(`category-${K8sCategories.PODS}`));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('k8s-dynamic-list')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const droppedPage = onUrlUpdateMock.mock.calls.some(
|
||||
(call) => !call[0].searchParams.has('page'),
|
||||
);
|
||||
|
||||
expect(droppedPage).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,9 @@ export const useInfraMonitoringPageListing = (): UseQueryStateReturn<
|
||||
> =>
|
||||
useQueryState(
|
||||
INFRA_MONITORING_K8S_PARAMS_KEYS.PAGE,
|
||||
// do not use .withDefault here, this can cause bugs when
|
||||
// two hooks of nuqs define default twice, this is also
|
||||
// defined at useTableParams
|
||||
parseAsInteger.withOptions(defaultNuqsOptions),
|
||||
);
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
RESTRICTED_SELECTED_FIELDS,
|
||||
} from 'container/LogsFilters/config';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
|
||||
@@ -42,7 +42,7 @@ jest.mock('hooks/useDebounce', () => ({
|
||||
}));
|
||||
|
||||
jest.mock(
|
||||
'container/QueryBuilder/filters/QueryBuilderSearch/OptionRenderer',
|
||||
'container/QueryBuilder/filters/OptionRenderer/OptionRenderer',
|
||||
() => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
|
||||
|
||||
@@ -21,6 +21,7 @@ import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
|
||||
import azureOpenaiUrl from '@/assets/Logos/azure-openai.svg';
|
||||
import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metrics.svg';
|
||||
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
|
||||
import azureUrl from '@/assets/Logos/azure.svg';
|
||||
import basetenUrl from '@/assets/Logos/baseten.svg';
|
||||
import cassandraUrl from '@/assets/Logos/cassandra.svg';
|
||||
import celeryUrl from '@/assets/Logos/celery.svg';
|
||||
@@ -28,6 +29,7 @@ import certManagerUrl from '@/assets/Logos/cert-manager.svg';
|
||||
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
|
||||
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
|
||||
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
|
||||
import cloudnativePgUrl from '@/assets/Logos/cloudnative-pg.svg';
|
||||
import cloudwatchLogsUrl from '@/assets/Logos/cloudwatch-logs.svg';
|
||||
import cohereUrl from '@/assets/Logos/cohere.svg';
|
||||
import confluentKafkaUrl from '@/assets/Logos/confluent-kafka.svg';
|
||||
@@ -39,6 +41,7 @@ import datadogUrl from '@/assets/Logos/datadog.svg';
|
||||
import dbosUrl from '@/assets/Logos/dbos.svg';
|
||||
import deepseekUrl from '@/assets/Logos/deepseek.svg';
|
||||
import denoUrl from '@/assets/Logos/deno.svg';
|
||||
import difyUrl from '@/assets/Logos/dify.svg';
|
||||
import dockerUrl from '@/assets/Logos/docker.svg';
|
||||
import documentLoadUrl from '@/assets/Logos/document-load.svg';
|
||||
import dotnetUrl from '@/assets/Logos/dotnet.svg';
|
||||
@@ -70,6 +73,8 @@ import gcpCloudStorageUrl from '@/assets/Logos/gcp-cloud-storage.svg';
|
||||
import gcpComputeEngineUrl from '@/assets/Logos/gcp-compute-engine.svg';
|
||||
import gcpGkeUrl from '@/assets/Logos/gcp-gke.svg';
|
||||
import gcpVpcUrl from '@/assets/Logos/gcp-vpc.svg';
|
||||
import gcpUrl from '@/assets/Logos/gcp.svg';
|
||||
import githubCopilotUrl from '@/assets/Logos/github-copilot.svg';
|
||||
import githubUrl from '@/assets/Logos/github.svg';
|
||||
import goUrl from '@/assets/Logos/go.svg';
|
||||
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
|
||||
@@ -96,6 +101,8 @@ import javascriptUrl from '@/assets/Logos/javascript.svg';
|
||||
import jbossUrl from '@/assets/Logos/jboss.svg';
|
||||
import jenkinsUrl from '@/assets/Logos/jenkins.svg';
|
||||
import kafkaUrl from '@/assets/Logos/kafka.svg';
|
||||
import kedaUrl from '@/assets/Logos/keda.svg';
|
||||
import kongUrl from '@/assets/Logos/kong.svg';
|
||||
import kubernetesUrl from '@/assets/Logos/kubernetes.svg';
|
||||
import lambdaUrl from '@/assets/Logos/lambda.svg';
|
||||
import langchainUrl from '@/assets/Logos/langchain.svg';
|
||||
@@ -114,6 +121,7 @@ import microsoftSqlServerUrl from '@/assets/Logos/microsoft-sql-server.svg';
|
||||
import mistralUrl from '@/assets/Logos/mistral.svg';
|
||||
import mongoUrl from '@/assets/Logos/mongo.svg';
|
||||
import n8nUrl from '@/assets/Logos/n8n.svg';
|
||||
import neonUrl from '@/assets/Logos/neon.svg';
|
||||
import newrelicUrl from '@/assets/Logos/newrelic.svg';
|
||||
import nextjsUrl from '@/assets/Logos/nextjs.svg';
|
||||
import nginxUrl from '@/assets/Logos/nginx.svg';
|
||||
@@ -2859,6 +2867,25 @@ const onboardingConfigWithLinks = [
|
||||
label: 'Traces',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces/',
|
||||
question: {
|
||||
desc: 'Which runtime does your Lambda function use?',
|
||||
helpText:
|
||||
'Python, Node.js, Java, and Ruby use the OpenTelemetry auto-instrumentation layer. Go has no layer, so you add the SDK to your code.',
|
||||
options: [
|
||||
{
|
||||
key: 'aws-lambda-traces-auto',
|
||||
label: 'Python, Node.js, Java, Ruby',
|
||||
imgUrl: lambdaUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces/',
|
||||
},
|
||||
{
|
||||
key: 'aws-lambda-traces-golang',
|
||||
label: 'Go',
|
||||
imgUrl: goUrl,
|
||||
link: '/docs/aws-monitoring/lambda/lambda-traces-golang/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'aws-lambda-metrics',
|
||||
@@ -5510,8 +5537,10 @@ const onboardingConfigWithLinks = [
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'integrations',
|
||||
'logs',
|
||||
'metrics',
|
||||
'supabase',
|
||||
'supabase logs',
|
||||
'supabase metrics',
|
||||
'supabase monitoring',
|
||||
'supabase observability',
|
||||
@@ -5545,43 +5574,23 @@ const onboardingConfigWithLinks = [
|
||||
label: 'Traefik',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'infrastructure',
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'infrastructure',
|
||||
'traefik',
|
||||
'traefik access logs',
|
||||
'traefik logs',
|
||||
'traefik metrics',
|
||||
'traefik monitoring',
|
||||
'traefik observability',
|
||||
'traefik tracing',
|
||||
],
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
question: {
|
||||
desc: 'Which Traefik signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'traefik-metrics-traces',
|
||||
label: 'Metrics & Traces',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/tutorial/traefik-observability/',
|
||||
},
|
||||
{
|
||||
key: 'traefik-logs',
|
||||
label: 'Access Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-traefik/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'mongodb-atlas',
|
||||
label: 'MongoDB (Atlas)',
|
||||
imgUrl: mongoUrl,
|
||||
tags: ['database'],
|
||||
module: 'database',
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'atlas',
|
||||
'atlas metrics',
|
||||
@@ -5600,36 +5609,15 @@ const onboardingConfigWithLinks = [
|
||||
label: 'MySQL',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['database'],
|
||||
module: 'database',
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'mysql',
|
||||
'mysql error log',
|
||||
'mysql logs',
|
||||
'mysql metrics',
|
||||
'mysql monitoring',
|
||||
'mysql observability',
|
||||
'mysql slow query log',
|
||||
],
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
question: {
|
||||
desc: 'Which MySQL signals do you want to send to SigNoz?',
|
||||
type: 'select',
|
||||
options: [
|
||||
{
|
||||
key: 'mysql-metrics',
|
||||
label: 'Metrics',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/metrics-management/mysql-metrics/',
|
||||
},
|
||||
{
|
||||
key: 'mysql-logs',
|
||||
label: 'Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
link: '/docs/integrations/opentelemetry-mysql/',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
dataSource: 'jmx',
|
||||
@@ -6992,5 +6980,514 @@ const onboardingConfigWithLinks = [
|
||||
id: 'dspy',
|
||||
link: '/docs/dspy-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'grok-build',
|
||||
label: 'Grok Build',
|
||||
imgUrl: grokUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'coding agent',
|
||||
'grok build',
|
||||
'grok build events',
|
||||
'grok build logs',
|
||||
'grok build metrics',
|
||||
'grok build monitoring',
|
||||
'grok build observability',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel grok build integration',
|
||||
'terminal coding agent',
|
||||
'token usage',
|
||||
'xai',
|
||||
],
|
||||
id: 'grok-build',
|
||||
link: '/docs/grok-build-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'neon',
|
||||
label: 'Neon',
|
||||
imgUrl: neonUrl,
|
||||
tags: ['database'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'neon',
|
||||
'neon database',
|
||||
'neon db',
|
||||
'neon logs',
|
||||
'neon metrics',
|
||||
'neon monitoring',
|
||||
'neon observability',
|
||||
'neondb',
|
||||
'opentelemetry neon',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'serverless postgres',
|
||||
],
|
||||
id: 'neon',
|
||||
link: '/docs/integrations/opentelemetry-neondb/',
|
||||
},
|
||||
{
|
||||
dataSource: 'dify',
|
||||
label: 'Dify',
|
||||
imgUrl: difyUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'ai agent',
|
||||
'dify',
|
||||
'dify monitoring',
|
||||
'dify observability',
|
||||
'dify traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'metrics',
|
||||
'no code ai',
|
||||
'observability',
|
||||
'opentelemetry dify',
|
||||
'traces',
|
||||
],
|
||||
id: 'dify',
|
||||
link: '/docs/dify-observability/',
|
||||
},
|
||||
{
|
||||
dataSource: 'firecrawl',
|
||||
label: 'Firecrawl',
|
||||
imgUrl: llmMonitoringUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'crawl',
|
||||
'firecrawl',
|
||||
'firecrawl metrics',
|
||||
'firecrawl monitoring',
|
||||
'firecrawl observability',
|
||||
'firecrawl traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'opentelemetry firecrawl',
|
||||
'scrape',
|
||||
'traces',
|
||||
'web scraping',
|
||||
],
|
||||
id: 'firecrawl',
|
||||
link: '/docs/firecrawl-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'keda',
|
||||
label: 'KEDA',
|
||||
imgUrl: kedaUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'autoscaling',
|
||||
'event driven autoscaling',
|
||||
'keda',
|
||||
'keda metrics',
|
||||
'keda monitoring',
|
||||
'kubernetes',
|
||||
'kubernetes autoscaling',
|
||||
'metrics',
|
||||
'opentelemetry keda',
|
||||
'scaled jobs',
|
||||
'scaled objects',
|
||||
],
|
||||
id: 'keda',
|
||||
link: '/docs/metrics-management/keda-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'opentelemetry-collector-metrics',
|
||||
label: 'OpenTelemetry Collector Metrics',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['infrastructure monitoring', 'metrics'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'collector health',
|
||||
'collector internal metrics',
|
||||
'collector metrics',
|
||||
'metrics',
|
||||
'opentelemetry',
|
||||
'opentelemetry collector',
|
||||
'otel collector',
|
||||
'otelcol metrics',
|
||||
'pipeline health',
|
||||
],
|
||||
id: 'opentelemetry-collector-metrics',
|
||||
link: '/docs/metrics-management/opentelemetry-collector-metrics/',
|
||||
},
|
||||
{
|
||||
dataSource: 'cloudnative-pg',
|
||||
label: 'CloudNativePG',
|
||||
imgUrl: cloudnativePgUrl,
|
||||
tags: ['database'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cloud native postgres',
|
||||
'cloudnativepg',
|
||||
'cnpg',
|
||||
'cnpg metrics',
|
||||
'database',
|
||||
'kubernetes postgres',
|
||||
'metrics',
|
||||
'opentelemetry cloudnativepg',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
],
|
||||
id: 'cloudnative-pg',
|
||||
link: '/docs/metrics-management/opentelemetry-cloudnative-pg/',
|
||||
},
|
||||
{
|
||||
dataSource: 'kong-gateway',
|
||||
label: 'Kong Gateway',
|
||||
imgUrl: kongUrl,
|
||||
tags: ['infrastructure monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'api gateway',
|
||||
'kong',
|
||||
'kong gateway',
|
||||
'kong logs',
|
||||
'kong metrics',
|
||||
'kong monitoring',
|
||||
'kong observability',
|
||||
'kong traces',
|
||||
'opentelemetry kong',
|
||||
'proxy',
|
||||
'traces',
|
||||
],
|
||||
id: 'kong-gateway',
|
||||
link: '/docs/integrations/kong-gateway/',
|
||||
},
|
||||
{
|
||||
dataSource: 'github-copilot',
|
||||
label: 'GitHub Copilot',
|
||||
imgUrl: githubCopilotUrl,
|
||||
tags: ['LLM Monitoring'],
|
||||
module: 'apm',
|
||||
relatedSearchKeywords: [
|
||||
'coding agent',
|
||||
'copilot',
|
||||
'copilot chat',
|
||||
'github copilot',
|
||||
'github copilot metrics',
|
||||
'github copilot monitoring',
|
||||
'github copilot observability',
|
||||
'github copilot traces',
|
||||
'llm',
|
||||
'llm monitoring',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel github copilot integration',
|
||||
'traces',
|
||||
'tracing',
|
||||
'vs code',
|
||||
],
|
||||
id: 'github-copilot',
|
||||
link: '/docs/github-copilot-monitoring/',
|
||||
},
|
||||
{
|
||||
dataSource: 'serilog',
|
||||
label: 'Serilog',
|
||||
imgUrl: dotnetUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'.net',
|
||||
'asp.net',
|
||||
'c#',
|
||||
'csharp',
|
||||
'dotnet',
|
||||
'dotnet logs',
|
||||
'logging',
|
||||
'logs',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'otel serilog integration',
|
||||
'serilog',
|
||||
'serilog logs',
|
||||
'serilog sink',
|
||||
'structured logging',
|
||||
],
|
||||
id: 'serilog',
|
||||
link: '/docs/logs-management/send-logs/serilog-to-signoz/',
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-integration',
|
||||
label: 'GCP Integration',
|
||||
imgUrl: gcpUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'metrics',
|
||||
relatedSearchKeywords: [
|
||||
'cloud monitoring metrics',
|
||||
'connect gcp account',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'gcp manual setup',
|
||||
'gcp metrics',
|
||||
'gcp monitoring',
|
||||
'gcp observability',
|
||||
'gcp service account',
|
||||
'google cloud',
|
||||
'google cloud integration',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'opentelemetry collector gcp',
|
||||
],
|
||||
id: 'gcp-integration',
|
||||
link: '/docs/integrations/gcp/gcp-integration/',
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cosmos-db',
|
||||
label: 'Azure Cosmos DB',
|
||||
imgUrl: azureUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure cosmos db',
|
||||
'azure integration',
|
||||
'cosmos db',
|
||||
'cosmosdb',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'request units',
|
||||
],
|
||||
id: 'azure-cosmos-db',
|
||||
link: '/integrations/azure?service=cosmosdb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-mongodb',
|
||||
label: 'Azure MongoDB vCore',
|
||||
imgUrl: mongoUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure mongodb',
|
||||
'azure mongodb vcore',
|
||||
'database',
|
||||
'metrics',
|
||||
'mongodb',
|
||||
'mongodb vcore',
|
||||
'monitoring',
|
||||
'nosql',
|
||||
'observability',
|
||||
'one click azure',
|
||||
],
|
||||
id: 'azure-mongodb',
|
||||
link: '/integrations/azure?service=mongodb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-postgresql-flexible-server',
|
||||
label: 'Azure PostgreSQL Flexible Server',
|
||||
imgUrl: postgresqlUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure postgresql',
|
||||
'azure postgresql flexible server',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'postgresql flexible server',
|
||||
],
|
||||
id: 'azure-postgresql-flexible-server',
|
||||
link: '/integrations/azure?service=postgresqlflexibleserver',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cache-redis',
|
||||
label: 'Azure Cache for Redis',
|
||||
imgUrl: redisUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure cache for redis',
|
||||
'azure integration',
|
||||
'azure redis',
|
||||
'cache',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'redis',
|
||||
],
|
||||
id: 'azure-cache-redis',
|
||||
link: '/integrations/azure?service=redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-sql-managed-instance',
|
||||
label: 'Azure SQL Managed Instance',
|
||||
imgUrl: azureSqlDatabaseMetricsUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'azure',
|
||||
'azure integration',
|
||||
'azure sql',
|
||||
'azure sql managed instance',
|
||||
'database',
|
||||
'managed instance',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
'sql server',
|
||||
],
|
||||
id: 'azure-sql-managed-instance',
|
||||
link: '/integrations/azure?service=sqldatabasemi',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'azure-cassandra-db',
|
||||
label: 'Azure Managed Instance for Apache Cassandra',
|
||||
imgUrl: cassandraUrl,
|
||||
tags: ['Azure'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'apache cassandra',
|
||||
'azure',
|
||||
'azure cassandra',
|
||||
'azure integration',
|
||||
'azure managed instance for apache cassandra',
|
||||
'cassandra',
|
||||
'database',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'one click azure',
|
||||
],
|
||||
id: 'azure-cassandra-db',
|
||||
link: '/integrations/azure?service=cassandradb',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-cloud-sql-postgresql',
|
||||
label: 'GCP Cloud SQL for PostgreSQL',
|
||||
imgUrl: gcpCloudSqlUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'cloud sql',
|
||||
'cloud sql for postgresql',
|
||||
'database',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'google cloud',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
],
|
||||
id: 'gcp-cloud-sql-postgresql',
|
||||
link: '/integrations/gcp?service=cloudsql_postgres',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'gcp-memorystore-redis',
|
||||
label: 'GCP Memorystore for Redis',
|
||||
imgUrl: redisUrl,
|
||||
tags: ['GCP'],
|
||||
module: 'dashboards',
|
||||
relatedSearchKeywords: [
|
||||
'cache',
|
||||
'database',
|
||||
'gcp',
|
||||
'gcp integration',
|
||||
'google cloud',
|
||||
'memorystore',
|
||||
'memorystore for redis',
|
||||
'metrics',
|
||||
'monitoring',
|
||||
'observability',
|
||||
'redis',
|
||||
],
|
||||
id: 'gcp-memorystore-redis',
|
||||
link: '/integrations/gcp?service=memorystore_redis',
|
||||
internalRedirect: true,
|
||||
},
|
||||
{
|
||||
dataSource: 'supabase-logs',
|
||||
label: 'Supabase Logs',
|
||||
imgUrl: supabaseUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'logging',
|
||||
'logs',
|
||||
'postgres',
|
||||
'postgresql',
|
||||
'send supabase logs',
|
||||
'supabase',
|
||||
'supabase log drains',
|
||||
'supabase logs',
|
||||
'supabase observability',
|
||||
],
|
||||
id: 'supabase-logs',
|
||||
link: '/docs/logs-management/send-logs/supabase-logs/',
|
||||
},
|
||||
{
|
||||
dataSource: 'traefik-logs',
|
||||
label: 'Traefik Access Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'access logs',
|
||||
'logging',
|
||||
'logs',
|
||||
'opentelemetry traefik',
|
||||
'proxy',
|
||||
'reverse proxy',
|
||||
'traefik',
|
||||
'traefik access logs',
|
||||
'traefik logs',
|
||||
],
|
||||
id: 'traefik-logs',
|
||||
link: '/docs/integrations/opentelemetry-traefik/',
|
||||
},
|
||||
{
|
||||
dataSource: 'mysql-logs',
|
||||
label: 'MySQL Logs',
|
||||
imgUrl: opentelemetryUrl,
|
||||
tags: ['logs'],
|
||||
module: 'logs',
|
||||
relatedSearchKeywords: [
|
||||
'database',
|
||||
'logging',
|
||||
'logs',
|
||||
'mysql',
|
||||
'mysql error log',
|
||||
'mysql general query log',
|
||||
'mysql logs',
|
||||
'mysql slow query log',
|
||||
'opentelemetry mysql',
|
||||
],
|
||||
id: 'mysql-logs',
|
||||
link: '/docs/integrations/opentelemetry-mysql/',
|
||||
},
|
||||
];
|
||||
export default onboardingConfigWithLinks;
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { OrderByFilterProps } from './filters/OrderByFilter/OrderByFilter.interfaces';
|
||||
|
||||
export type WhereClauseConfig = {
|
||||
customKey: string;
|
||||
customOp: string;
|
||||
};
|
||||
|
||||
type FilterConfigs = {
|
||||
[Key in keyof Omit<IBuilderQuery, 'filters'>]: {
|
||||
isHidden: boolean;
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { InputNumber, InputNumberProps } from 'antd';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
|
||||
function AggregateEveryFilter({
|
||||
onChange,
|
||||
query,
|
||||
disabled,
|
||||
}: AggregateEveryFilterProps): JSX.Element {
|
||||
const isMetricsDataSource = useMemo(
|
||||
() => query.dataSource === DataSource.METRICS,
|
||||
[query.dataSource],
|
||||
);
|
||||
|
||||
const onChangeHandler: InputNumberProps<number>['onChange'] = (event) => {
|
||||
if (event && event >= 0) {
|
||||
onChange(event);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled =
|
||||
(isMetricsDataSource && !query.aggregateAttribute?.key) || disabled;
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
placeholder="Enter in seconds"
|
||||
disabled={isDisabled}
|
||||
style={selectStyle}
|
||||
value={query?.stepInterval}
|
||||
onChange={onChangeHandler}
|
||||
min={0}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface AggregateEveryFilterProps {
|
||||
onChange: (values: number) => void;
|
||||
query: IBuilderQuery;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
export default AggregateEveryFilter;
|
||||
@@ -24,8 +24,8 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import OptionRenderer from '../OptionRenderer/OptionRenderer';
|
||||
// ** Types
|
||||
import { AgregatorFilterProps } from './AggregatorFilter.intefaces';
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { InputNumber } from 'antd';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearch/config';
|
||||
import { selectStyle } from '../../QueryBuilderSearchV2/config';
|
||||
import { handleKeyDownLimitFilter } from '../../utils';
|
||||
import { LimitFilterProps } from './types';
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../../QueryBuilderSearch/config';
|
||||
import { selectStyle } from '../../QueryBuilderSearchV2/config';
|
||||
import { OrderByProps } from './types';
|
||||
import { useOrderByFormulaFilter } from './useOrderByFormulaFilter';
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ import {
|
||||
mapLabelValuePairs,
|
||||
orderByValueDelimiter,
|
||||
} from '../../OrderByFilter/utils';
|
||||
import { getRemoveOrderFromValue } from '../../QueryBuilderSearch/utils';
|
||||
import { getRemoveOrderFromValue } from '../../QueryBuilderSearchV2/utils';
|
||||
import { getUniqueOrderByValues, getValidOrderByResult } from '../../utils';
|
||||
import { IOrderByFormulaFilterProps } from './types';
|
||||
import { transformToOrderByStringValuesByFormula } from './utils';
|
||||
|
||||
@@ -23,8 +23,8 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import OptionRenderer from '../OptionRenderer/OptionRenderer';
|
||||
import { GroupByFilterProps } from './GroupByFilter.interfaces';
|
||||
|
||||
export const GroupByFilter = memo(function GroupByFilter({
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
import { InputNumber } from 'antd';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import { handleKeyDownLimitFilter } from '../utils';
|
||||
|
||||
function LimitFilter({ onChange, query }: LimitFilterProps): JSX.Element {
|
||||
const isMetricsDataSource = query.dataSource === DataSource.METRICS;
|
||||
|
||||
const isDisabled = isMetricsDataSource && !query.aggregateAttribute?.key;
|
||||
|
||||
return (
|
||||
<InputNumber
|
||||
min={1}
|
||||
type="number"
|
||||
value={query.limit}
|
||||
style={selectStyle}
|
||||
disabled={isDisabled}
|
||||
onChange={onChange}
|
||||
onKeyDown={handleKeyDownLimitFilter}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface LimitFilterProps {
|
||||
onChange: (values: number | null) => void;
|
||||
query: IBuilderQuery;
|
||||
}
|
||||
|
||||
export default LimitFilter;
|
||||
@@ -31,7 +31,7 @@ jest.mock('hooks/useDebounce', () => ({
|
||||
default: <T,>(value: T): T => value,
|
||||
}));
|
||||
|
||||
jest.mock('../QueryBuilderSearch/OptionRenderer', () => ({
|
||||
jest.mock('../OptionRenderer/OptionRenderer', () => ({
|
||||
__esModule: true,
|
||||
default: ({ value }: { value: string }): JSX.Element => <span>{value}</span>,
|
||||
}));
|
||||
|
||||
@@ -16,8 +16,8 @@ import { MetricAggregation } from 'types/api/v5/queryRange';
|
||||
import { ExtendedSelectOption } from 'types/common/select';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import OptionRenderer from '../QueryBuilderSearch/OptionRenderer';
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import OptionRenderer from '../OptionRenderer/OptionRenderer';
|
||||
|
||||
import './MetricNameSelector.styles.scss';
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import { memo } from 'react';
|
||||
import { Select } from 'antd';
|
||||
|
||||
// ** Types
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import { OperatorsSelectProps } from './OperatorsSelect.interfaces';
|
||||
|
||||
export const OperatorsSelect = memo(function OperatorsSelect({
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TagContainer = styled(Badge).attrs({
|
||||
color: 'secondary',
|
||||
variant: 'outline',
|
||||
})`
|
||||
&&& {
|
||||
display: flex;
|
||||
font-weight: 300;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TagLabel = styled.span`
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
export const TagValue = styled.span`
|
||||
text-transform: capitalize;
|
||||
font-weight: 400;
|
||||
`;
|
||||
@@ -5,7 +5,7 @@ import { DataSource, MetricAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { getParsedAggregationOptionsForOrderBy } from 'utils/aggregationConverter';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import { selectStyle } from '../QueryBuilderSearchV2/config';
|
||||
import { OrderByFilterProps } from './OrderByFilter.interfaces';
|
||||
import { useOrderByFilter } from './useOrderByFilter';
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import { parse } from 'papaparse';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { getRemoveOrderFromValue } from '../QueryBuilderSearch/utils';
|
||||
import { getRemoveOrderFromValue } from '../QueryBuilderSearchV2/utils';
|
||||
import { getUniqueOrderByValues, getValidOrderByResult } from '../utils';
|
||||
import { ORDERBY_FILTERS } from './config';
|
||||
import { SIGNOZ_VALUE } from './constants';
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { Zap } from '@signozhq/icons';
|
||||
|
||||
import { getOptionType } from './utils';
|
||||
|
||||
import './QueryBuilderSearch.styles.scss';
|
||||
|
||||
function OptionRendererForLogs({
|
||||
label,
|
||||
value,
|
||||
dataType,
|
||||
isIndexed,
|
||||
setDynamicPlaceholder,
|
||||
}: OptionRendererProps): JSX.Element {
|
||||
const optionType = getOptionType(label);
|
||||
|
||||
return (
|
||||
<span
|
||||
className="option"
|
||||
onMouseEnter={(): void => setDynamicPlaceholder(value)}
|
||||
onFocus={(): void => setDynamicPlaceholder(value)}
|
||||
>
|
||||
{optionType ? (
|
||||
<Tooltip title={value} placement="topLeft">
|
||||
<div className="logs-options-select">
|
||||
<section className="left-section">
|
||||
{isIndexed ? (
|
||||
<Zap size={12} fill={Color.BG_AMBER_500} />
|
||||
) : (
|
||||
<div className="dot" />
|
||||
)}
|
||||
<Typography.Text className="text value" truncate={1}>
|
||||
{value}
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className="right-section">
|
||||
<div className="text tags data-type-tag">{dataType}</div>
|
||||
<div className={cx('text tags option-type-tag', optionType)}>
|
||||
<div className="dot" />
|
||||
{optionType}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Tooltip title={label} placement="topLeft">
|
||||
<div className="without-option-type">
|
||||
<div className="dot" />
|
||||
<Typography.Text className="text" truncate={1}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
interface OptionRendererProps {
|
||||
label: string;
|
||||
value: string;
|
||||
dataType: string;
|
||||
isIndexed: boolean;
|
||||
setDynamicPlaceholder: React.Dispatch<React.SetStateAction<string>>;
|
||||
}
|
||||
|
||||
export default OptionRendererForLogs;
|
||||
@@ -1,298 +0,0 @@
|
||||
.query-builder-search-container {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.logs-popup {
|
||||
&.hide-scroll {
|
||||
.rc-virtual-list-holder {
|
||||
height: 100px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.logs-explorer-popup {
|
||||
padding: 0px;
|
||||
.ant-select-item-group {
|
||||
padding: 12px 14px 8px 14px;
|
||||
color: var(--muted-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.show-all-filter-props {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 13px;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.text:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
.right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
.keyboard-shortcut-slash {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2.286px;
|
||||
border-top: 1.143px solid var(--l1-border);
|
||||
border-right: 1.143px solid var(--l1-border);
|
||||
border-bottom: 2.286px solid var(--l1-border);
|
||||
border-left: 1.143px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.show-all-filter-props:hover {
|
||||
background: color-mix(
|
||||
in srgb,
|
||||
var(--l1-foreground) 4%,
|
||||
transparent
|
||||
) !important;
|
||||
}
|
||||
|
||||
.example-queries {
|
||||
cursor: default;
|
||||
.heading {
|
||||
padding: 12px 14px 8px 14px;
|
||||
color: var(--muted-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 18px; /* 163.636% */
|
||||
letter-spacing: 0.88px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.query-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 0px 12px 12px 12px;
|
||||
cursor: pointer;
|
||||
|
||||
.example-query {
|
||||
display: flex;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: normal;
|
||||
letter-spacing: -0.07px;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.example-query:hover {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-item-option-grouped {
|
||||
padding-inline-start: 0px;
|
||||
padding: 7px 13px;
|
||||
}
|
||||
|
||||
.keyboard-shortcuts {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
border-radius: 0px 0px 4px 4px;
|
||||
border: 1px solid var(--l1-border);
|
||||
background: var(--l2-background);
|
||||
padding: 11px 16px;
|
||||
cursor: default;
|
||||
|
||||
.icons {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 2.286px;
|
||||
border-top: 1.143px solid var(--l3-background);
|
||||
border-right: 1.143px solid var(--l3-background);
|
||||
border-bottom: 2.286px solid var(--l3-background);
|
||||
border-left: 1.143px solid var(--l3-background);
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.keyboard-text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 18px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.navigate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding-right: 12px;
|
||||
gap: 4px;
|
||||
border-right: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.update-query {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-left: 12px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
.without-option-type {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
.dot {
|
||||
height: 5px;
|
||||
width: 5px;
|
||||
border-radius: 50%;
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
}
|
||||
|
||||
.logs-options-select {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
.text {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.tags {
|
||||
display: flex;
|
||||
height: 20px;
|
||||
padding: 4px 8px;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
height: 5px;
|
||||
width: 5px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.left-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 90%;
|
||||
|
||||
.dot {
|
||||
background-color: var(--l3-background);
|
||||
}
|
||||
|
||||
.value {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.right-section {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
|
||||
.data-type-tag {
|
||||
background: color-mix(in srgb, var(--l1-foreground) 8%, transparent);
|
||||
}
|
||||
|
||||
.option-type-tag {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
padding: 0px 6px;
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.tag {
|
||||
border-radius: 50px;
|
||||
background: color-mix(in srgb, var(--bg-sienna-400) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
|
||||
.dot {
|
||||
background-color: var(--bg-sienna-400);
|
||||
}
|
||||
}
|
||||
|
||||
.resource {
|
||||
border-radius: 50px;
|
||||
background: color-mix(in srgb, var(--bg-sakura-400) 10%, transparent);
|
||||
color: var(--bg-sakura-400);
|
||||
|
||||
.dot {
|
||||
background-color: var(--bg-sakura-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.ant-select-item-option-active {
|
||||
.logs-options-select {
|
||||
.left-section {
|
||||
.value {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-scope-selector {
|
||||
width: 160px;
|
||||
}
|
||||
@@ -1,558 +0,0 @@
|
||||
import {
|
||||
KeyboardEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { Button, Select, Spin, Tooltip } from 'antd';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
INFRA_LONG_TO_SHORT_OPERATOR_MAP,
|
||||
OPERATORS,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import { getDataTypes } from 'container/LogDetailedView/utils';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import {
|
||||
useAutoComplete,
|
||||
WhereClauseConfig,
|
||||
} from 'hooks/queryBuilder/useAutoComplete';
|
||||
import { useFetchKeysAndValues } from 'hooks/queryBuilder/useFetchKeysAndValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { isEqual, isUndefined } from 'lodash-es';
|
||||
import {
|
||||
ArrowDown,
|
||||
ArrowUp,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Command,
|
||||
CornerDownLeft,
|
||||
Filter,
|
||||
Slash,
|
||||
} from '@signozhq/icons';
|
||||
import type { BaseSelectRef } from 'rc-select';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { selectStyle } from './config';
|
||||
import { PLACEHOLDER } from './constant';
|
||||
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
|
||||
import OptionRenderer from './OptionRenderer';
|
||||
import OptionRendererForLogs from './OptionRendererForLogs';
|
||||
import { StyledCheckOutlined, TypographyText } from './style';
|
||||
import {
|
||||
convertExampleQueriesToOptions,
|
||||
getOperatorValue,
|
||||
getRemovePrefixFromKey,
|
||||
getTagToken,
|
||||
isExistsNotExistsOperator,
|
||||
isInNInOperator,
|
||||
} from './utils';
|
||||
|
||||
import './QueryBuilderSearch.styles.scss';
|
||||
|
||||
function getOperatorValueForContext(
|
||||
op: string,
|
||||
isInfraMonitoring?: boolean,
|
||||
): string {
|
||||
const mappedOp =
|
||||
isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
|
||||
? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
|
||||
: op;
|
||||
return getOperatorValue(mappedOp);
|
||||
}
|
||||
|
||||
function QueryBuilderSearch({
|
||||
query,
|
||||
onChange,
|
||||
whereClauseConfig,
|
||||
className,
|
||||
placeholder,
|
||||
suffixIcon,
|
||||
isInfraMonitoring,
|
||||
isMetricsExplorer,
|
||||
disableNavigationShortcuts,
|
||||
entity,
|
||||
}: QueryBuilderSearchProps): JSX.Element {
|
||||
const getPopupContainer = useSelectPopupContainer();
|
||||
const { pathname } = useLocation();
|
||||
const isLogsExplorerPage = useMemo(
|
||||
() => pathname === ROUTES.LOGS_EXPLORER,
|
||||
[pathname],
|
||||
);
|
||||
|
||||
const [isEditingTag, setIsEditingTag] = useState(false);
|
||||
|
||||
const {
|
||||
updateTag,
|
||||
handleClearTag,
|
||||
handleKeyDown,
|
||||
handleOnBlur,
|
||||
handleSearch,
|
||||
handleSelect,
|
||||
tags,
|
||||
options,
|
||||
searchValue,
|
||||
isMulti,
|
||||
isFetching,
|
||||
setSearchKey,
|
||||
setSearchValue,
|
||||
searchKey,
|
||||
key,
|
||||
exampleQueries,
|
||||
} = useAutoComplete(
|
||||
query,
|
||||
whereClauseConfig,
|
||||
isLogsExplorerPage,
|
||||
isInfraMonitoring,
|
||||
entity,
|
||||
isMetricsExplorer,
|
||||
);
|
||||
|
||||
const [isOpen, setIsOpen] = useState<boolean>(false);
|
||||
const [showAllFilters, setShowAllFilters] = useState<boolean>(false);
|
||||
const [dynamicPlacholder, setDynamicPlaceholder] = useState<string>(
|
||||
placeholder || '',
|
||||
);
|
||||
const selectRef = useRef<BaseSelectRef>(null);
|
||||
|
||||
const { sourceKeys, handleRemoveSourceKey } = useFetchKeysAndValues(
|
||||
searchValue,
|
||||
query,
|
||||
searchKey,
|
||||
isLogsExplorerPage,
|
||||
isInfraMonitoring,
|
||||
entity,
|
||||
isMetricsExplorer,
|
||||
);
|
||||
|
||||
const { registerShortcut, deregisterShortcut } = useKeyboardHotkeys();
|
||||
|
||||
const { handleRunQuery, currentQuery } = useQueryBuilder();
|
||||
|
||||
const toggleEditMode = useCallback(
|
||||
(value: boolean) => {
|
||||
// Editing mode is required only in infra monitoring or metrics explorer
|
||||
if (isInfraMonitoring || isMetricsExplorer) {
|
||||
setIsEditingTag(value);
|
||||
}
|
||||
},
|
||||
[isInfraMonitoring, isMetricsExplorer],
|
||||
);
|
||||
|
||||
const onTagRender = ({
|
||||
value,
|
||||
closable,
|
||||
onClose,
|
||||
}: CustomTagProps): ReactElement => {
|
||||
const { tagOperator } = getTagToken(value);
|
||||
const isInNin = isInNInOperator(tagOperator);
|
||||
const chipValue = isInNin
|
||||
? value?.trim()?.replace(/,\s*$/, '')
|
||||
: value?.trim();
|
||||
|
||||
const onCloseHandler = (): void => {
|
||||
onClose();
|
||||
// Editing is done after closing a tag
|
||||
toggleEditMode(false);
|
||||
handleSearch('');
|
||||
setSearchKey('');
|
||||
};
|
||||
|
||||
const tagEditHandler = (value: string): void => {
|
||||
updateTag(value);
|
||||
// Editing starts
|
||||
toggleEditMode(true);
|
||||
if (isInfraMonitoring || isMetricsExplorer) {
|
||||
setSearchValue(value);
|
||||
} else {
|
||||
handleSearch(value);
|
||||
}
|
||||
};
|
||||
|
||||
const isDisabled = !!searchValue;
|
||||
|
||||
return (
|
||||
<Badge
|
||||
color="vanilla"
|
||||
closable={!searchValue && closable}
|
||||
onClose={(e): void => {
|
||||
e.preventDefault();
|
||||
onCloseHandler();
|
||||
}}
|
||||
>
|
||||
<Tooltip title={chipValue}>
|
||||
<TypographyText
|
||||
$isInNin={isInNin}
|
||||
$isEnabled={!!searchValue}
|
||||
onClick={(): void => {
|
||||
if (!isDisabled) {
|
||||
tagEditHandler(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{chipValue}
|
||||
</TypographyText>
|
||||
</Tooltip>
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
const onChangeHandler = (value: string[]): void => {
|
||||
if (!isMulti) {
|
||||
handleSearch(value[value.length - 1]);
|
||||
}
|
||||
};
|
||||
|
||||
const onInputKeyDownHandler = (event: KeyboardEvent<Element>): void => {
|
||||
if (isMulti || event.key === 'Backspace') {
|
||||
handleKeyDown(event);
|
||||
}
|
||||
if (isExistsNotExistsOperator(searchValue)) {
|
||||
handleKeyDown(event);
|
||||
}
|
||||
|
||||
// Editing is done after enter key press
|
||||
if (event.key === 'Enter') {
|
||||
toggleEditMode(false);
|
||||
}
|
||||
|
||||
if (
|
||||
!disableNavigationShortcuts &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key === 'Enter'
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
handleRunQuery();
|
||||
setIsOpen(false);
|
||||
}
|
||||
|
||||
if (
|
||||
!disableNavigationShortcuts &&
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key === '/'
|
||||
) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
setShowAllFilters((prev) => !prev);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeselect = useCallback(
|
||||
(deselectedItem: string) => {
|
||||
handleClearTag(deselectedItem);
|
||||
handleRemoveSourceKey(deselectedItem);
|
||||
},
|
||||
[handleClearTag, handleRemoveSourceKey],
|
||||
);
|
||||
|
||||
const isMetricsDataSource = useMemo(
|
||||
() =>
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!isInfraMonitoring &&
|
||||
!isMetricsExplorer,
|
||||
[query.dataSource, isInfraMonitoring, isMetricsExplorer],
|
||||
);
|
||||
|
||||
const fetchValueDataType = (value: unknown, operator: string): DataTypes => {
|
||||
if (operator === OPERATORS.HAS || operator === OPERATORS.NHAS) {
|
||||
return getDataTypes([value]);
|
||||
}
|
||||
|
||||
return DataTypes.EMPTY;
|
||||
};
|
||||
|
||||
const queryTags = useMemo(() => {
|
||||
if (!query.aggregateAttribute?.key && isMetricsDataSource) {
|
||||
return [];
|
||||
}
|
||||
return tags;
|
||||
}, [isMetricsDataSource, query.aggregateAttribute?.key, tags]);
|
||||
|
||||
useEffect(() => {
|
||||
const initialTagFilters: TagFilter = { items: [], op: 'AND' };
|
||||
const initialSourceKeys = query.filters?.items?.map(
|
||||
(item) => item.key as BaseAutocompleteData,
|
||||
);
|
||||
|
||||
initialTagFilters.items = tags.map((tag) => {
|
||||
const { tagKey, tagOperator, tagValue } = getTagToken(tag);
|
||||
|
||||
const filterAttribute = [
|
||||
...(initialSourceKeys || []),
|
||||
...(sourceKeys || []),
|
||||
].find((key) => key?.key === getRemovePrefixFromKey(tagKey));
|
||||
|
||||
const computedTagValue =
|
||||
tagValue && Array.isArray(tagValue) && tagValue[tagValue.length - 1] === ''
|
||||
? tagValue?.slice(0, -1)
|
||||
: (tagValue ?? '');
|
||||
|
||||
return {
|
||||
id: uuid().slice(0, 8),
|
||||
key: filterAttribute ?? {
|
||||
key: tagKey,
|
||||
dataType: fetchValueDataType(computedTagValue, tagOperator),
|
||||
type: '',
|
||||
},
|
||||
op: getOperatorValueForContext(tagOperator, isInfraMonitoring),
|
||||
value: computedTagValue,
|
||||
};
|
||||
});
|
||||
|
||||
// If in infra monitoring or metrics explorer, only run the onChange query when editing is finsished.
|
||||
if (isInfraMonitoring || isMetricsExplorer) {
|
||||
if (!isEditingTag) {
|
||||
onChange(initialTagFilters);
|
||||
}
|
||||
} else {
|
||||
onChange(initialTagFilters);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [sourceKeys]);
|
||||
|
||||
const isLastQuery = useMemo(
|
||||
() =>
|
||||
isEqual(
|
||||
currentQuery.builder.queryData[currentQuery.builder.queryData.length - 1],
|
||||
query,
|
||||
),
|
||||
[currentQuery, query],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (isLastQuery && !disableNavigationShortcuts) {
|
||||
registerShortcut(LogsExplorerShortcuts.FocusTheSearchBar, () => {
|
||||
// set timeout is needed here else the select treats the hotkey as input value
|
||||
setTimeout(() => {
|
||||
selectRef.current?.focus();
|
||||
}, 0);
|
||||
});
|
||||
}
|
||||
|
||||
return (): void =>
|
||||
deregisterShortcut(LogsExplorerShortcuts.FocusTheSearchBar);
|
||||
}, [
|
||||
deregisterShortcut,
|
||||
disableNavigationShortcuts,
|
||||
isLastQuery,
|
||||
registerShortcut,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
setDynamicPlaceholder(placeholder || '');
|
||||
}
|
||||
}, [isOpen, placeholder]);
|
||||
|
||||
const userOs = getUserOperatingSystem();
|
||||
|
||||
// conditional changes here to use a seperate component to render the example queries based on the option group label
|
||||
const customRendererForLogsExplorer = options.map((option) => (
|
||||
<Select.Option
|
||||
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
|
||||
value={option.value}
|
||||
>
|
||||
<OptionRendererForLogs
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
dataType={option.dataType || ''}
|
||||
isIndexed={option.isIndexed || false}
|
||||
setDynamicPlaceholder={setDynamicPlaceholder}
|
||||
/>
|
||||
{option.selected && <StyledCheckOutlined />}
|
||||
</Select.Option>
|
||||
));
|
||||
|
||||
return (
|
||||
<div className="query-builder-search-container">
|
||||
<Select
|
||||
data-testid={'qb-search-select'}
|
||||
ref={selectRef}
|
||||
getPopupContainer={getPopupContainer}
|
||||
transitionName=""
|
||||
choiceTransitionName=""
|
||||
virtual={false}
|
||||
showSearch
|
||||
tagRender={onTagRender}
|
||||
filterOption={false}
|
||||
open={isOpen}
|
||||
onDropdownVisibleChange={setIsOpen}
|
||||
autoClearSearchValue={false}
|
||||
mode="multiple"
|
||||
placeholder={dynamicPlacholder}
|
||||
value={queryTags}
|
||||
searchValue={searchValue}
|
||||
className={cx(
|
||||
className,
|
||||
isLogsExplorerPage ? 'logs-popup' : '',
|
||||
!showAllFilters && options.length > 3 && !key ? 'hide-scroll' : '',
|
||||
)}
|
||||
rootClassName="query-builder-search"
|
||||
disabled={isMetricsDataSource && !query.aggregateAttribute?.key}
|
||||
style={selectStyle}
|
||||
onSearch={handleSearch}
|
||||
onChange={onChangeHandler}
|
||||
onSelect={handleSelect}
|
||||
onDeselect={handleDeselect}
|
||||
onInputKeyDown={onInputKeyDownHandler}
|
||||
notFoundContent={isFetching ? <Spin size="small" /> : null}
|
||||
suffixIcon={
|
||||
!isUndefined(suffixIcon) ? (
|
||||
suffixIcon
|
||||
) : isOpen ? (
|
||||
<ChevronUp size={14} />
|
||||
) : (
|
||||
<ChevronDown size={14} />
|
||||
)
|
||||
}
|
||||
showAction={['focus']}
|
||||
onBlur={(e: React.FocusEvent<HTMLInputElement>): void => {
|
||||
handleOnBlur(e);
|
||||
// Editing is done after tapping out of the input
|
||||
toggleEditMode(false);
|
||||
}}
|
||||
popupClassName={isLogsExplorerPage ? 'logs-explorer-popup' : ''}
|
||||
dropdownRender={(menu): ReactElement => (
|
||||
<div>
|
||||
{!searchKey && isLogsExplorerPage && (
|
||||
<div className="ant-select-item-group ">Suggested Filters</div>
|
||||
)}
|
||||
{menu}
|
||||
{isLogsExplorerPage && (
|
||||
<div>
|
||||
{!searchKey && tags.length === 0 && (
|
||||
<div className="example-queries">
|
||||
<div className="heading"> Example Queries </div>
|
||||
<div className="query-container">
|
||||
{convertExampleQueriesToOptions(exampleQueries).map((query) => (
|
||||
<ExampleQueriesRendererForLogs
|
||||
key={query.label}
|
||||
label={query.label}
|
||||
value={query.value}
|
||||
handleAddTag={onChange}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{!key && !isFetching && !showAllFilters && options.length > 3 && (
|
||||
<Button
|
||||
type="text"
|
||||
className="show-all-filter-props"
|
||||
onClick={(): void => {
|
||||
setShowAllFilters(true);
|
||||
// when clicking on the button the search bar looses the focus
|
||||
selectRef?.current?.focus();
|
||||
}}
|
||||
>
|
||||
<div className="content">
|
||||
<section className="left-section">
|
||||
<Filter size={14} />
|
||||
<Typography.Text className="text">
|
||||
Show all filters properties
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className="right-section">
|
||||
{userOs === UserOperatingSystem.MACOS ? (
|
||||
<Command size={14} className="keyboard-shortcut-slash" />
|
||||
) : (
|
||||
<ChevronUp size={14} className="keyboard-shortcut-slash" />
|
||||
)}
|
||||
+
|
||||
<Slash size={14} className="keyboard-shortcut-slash" />
|
||||
</section>
|
||||
</div>
|
||||
</Button>
|
||||
)}
|
||||
<div className="keyboard-shortcuts">
|
||||
<section className="navigate">
|
||||
<ArrowDown size={10} className="icons" />
|
||||
<ArrowUp size={10} className="icons" />
|
||||
<span className="keyboard-text">to navigate</span>
|
||||
</section>
|
||||
<section className="update-query">
|
||||
<CornerDownLeft size={10} className="icons" />
|
||||
<span className="keyboard-text">to update query</span>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
{isLogsExplorerPage
|
||||
? customRendererForLogsExplorer
|
||||
: options.map((option) => (
|
||||
<Select.Option
|
||||
key={`${option.label}-${option.type || ''}-${option.dataType || ''}`}
|
||||
value={option.value}
|
||||
>
|
||||
<OptionRenderer
|
||||
label={option.label}
|
||||
value={option.value}
|
||||
dataType={option.dataType || ''}
|
||||
type={option.type || ''}
|
||||
/>
|
||||
{option.selected && <StyledCheckOutlined />}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface QueryBuilderSearchProps {
|
||||
query: IBuilderQuery;
|
||||
onChange: (value: TagFilter) => void;
|
||||
whereClauseConfig?: WhereClauseConfig;
|
||||
className?: string;
|
||||
placeholder?: string;
|
||||
suffixIcon?: React.ReactNode;
|
||||
isInfraMonitoring?: boolean;
|
||||
disableNavigationShortcuts?: boolean;
|
||||
// TODO: Remove the dependency of InfraMonitoring from this code
|
||||
entity?: InfraMonitoringEntity | null;
|
||||
isMetricsExplorer?: boolean;
|
||||
}
|
||||
|
||||
QueryBuilderSearch.defaultProps = {
|
||||
whereClauseConfig: undefined,
|
||||
className: '',
|
||||
placeholder: PLACEHOLDER,
|
||||
suffixIcon: undefined,
|
||||
isInfraMonitoring: false,
|
||||
disableNavigationShortcuts: false,
|
||||
entity: null,
|
||||
isMetricsExplorer: false,
|
||||
};
|
||||
|
||||
export interface CustomTagProps {
|
||||
label: ReactNode;
|
||||
value: string;
|
||||
disabled: boolean;
|
||||
onClose: () => void;
|
||||
closable: boolean;
|
||||
}
|
||||
|
||||
export default QueryBuilderSearch;
|
||||
@@ -1,7 +1,5 @@
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import './QueryBuilderSearch.styles.scss';
|
||||
|
||||
function ExampleQueriesRendererForLogs({
|
||||
label,
|
||||
value,
|
||||
@@ -11,8 +11,8 @@ import {
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { getUserOperatingSystem, UserOperatingSystem } from 'utils/getUserOS';
|
||||
|
||||
import ExampleQueriesRendererForLogs from '../QueryBuilderSearch/ExampleQueriesRendererForLogs';
|
||||
import { convertExampleQueriesToOptions } from '../QueryBuilderSearch/utils';
|
||||
import ExampleQueriesRendererForLogs from './ExampleQueriesRendererForLogs';
|
||||
import { convertExampleQueriesToOptions } from './utils';
|
||||
import { ITag, Option } from './QueryBuilderSearchV2';
|
||||
|
||||
import './QueryBuilderSearchV2.styles.scss';
|
||||
|
||||
@@ -19,10 +19,10 @@ import {
|
||||
QUERY_BUILDER_SEARCH_VALUES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import type { WhereClauseConfig } from 'container/QueryBuilder/QueryBuilder.interfaces';
|
||||
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { useKeyboardHotkeys } from 'hooks/hotkeys/useKeyboardHotkeys';
|
||||
import { WhereClauseConfig } from 'hooks/queryBuilder/useAutoComplete';
|
||||
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useGetAttributeSuggestions } from 'hooks/queryBuilder/useGetAttributeSuggestions';
|
||||
@@ -53,16 +53,16 @@ import { DataSource } from 'types/common/queryBuilder';
|
||||
import { useSelectPopupContainer } from 'utils/selectPopupContainer';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { selectStyle } from '../QueryBuilderSearch/config';
|
||||
import { PLACEHOLDER } from '../QueryBuilderSearch/constant';
|
||||
import { TypographyText } from '../QueryBuilderSearch/style';
|
||||
import { selectStyle } from './config';
|
||||
import { PLACEHOLDER } from './constant';
|
||||
import { TypographyText } from './style';
|
||||
import {
|
||||
checkCommaInValue,
|
||||
getOperatorFromValue,
|
||||
getOperatorValue,
|
||||
getTagToken,
|
||||
isInNInOperator,
|
||||
} from '../QueryBuilderSearch/utils';
|
||||
} from './utils';
|
||||
import { filterByOperatorConfig } from '../utils';
|
||||
import QueryBuilderSearchDropdown from './QueryBuilderSearchDropdown';
|
||||
import SpanScopeSelector from './SpanScopeSelector';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { isEmpty, isObject } from 'lodash-es';
|
||||
import { Check, Zap } from '@signozhq/icons';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { getTagToken } from '../QueryBuilderSearch/utils';
|
||||
import { getTagToken } from './utils';
|
||||
import { DropdownState } from './QueryBuilderSearchV2';
|
||||
|
||||
import './Suggestions.styles.scss';
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import styled from 'styled-components';
|
||||
|
||||
export const TypographyText = styled.span<{
|
||||
@@ -17,27 +15,3 @@ export const TypographyText = styled.span<{
|
||||
padding-left: 8px;
|
||||
${({ $disabled }): string => ($disabled ? 'opacity: 0.6' : '')}
|
||||
`;
|
||||
|
||||
export const StyledCheckOutlined = styled(Check)`
|
||||
float: right;
|
||||
`;
|
||||
|
||||
export const TagContainer = styled(Badge).attrs({
|
||||
color: 'secondary',
|
||||
variant: 'outline',
|
||||
})`
|
||||
&&& {
|
||||
display: flex;
|
||||
font-weight: 300;
|
||||
font-size: 0.6rem;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TagLabel = styled.span`
|
||||
font-weight: 400;
|
||||
`;
|
||||
|
||||
export const TagValue = styled.span`
|
||||
text-transform: capitalize;
|
||||
font-weight: 400;
|
||||
`;
|
||||
@@ -1,8 +1,7 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { queryFilterTags } from 'hooks/queryBuilder/useTag';
|
||||
import { parse } from 'papaparse';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { orderByValueDelimiter } from '../OrderByFilter/utils';
|
||||
|
||||
@@ -191,18 +190,6 @@ export function getRemoveOrderFromValue(tag: string): string {
|
||||
return tag;
|
||||
}
|
||||
|
||||
export function getOptionType(label: string): MetricsType | undefined {
|
||||
let optionType;
|
||||
|
||||
if (label.startsWith('tag_')) {
|
||||
optionType = MetricsType.Tag;
|
||||
} else if (label.startsWith('resource_')) {
|
||||
optionType = MetricsType.Resource;
|
||||
}
|
||||
|
||||
return optionType;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param exampleQueries the example queries based on recommendation engine
|
||||
@@ -1,4 +1,3 @@
|
||||
import { AttributeValuesMap } from 'components/ClientSideQBSearch/ClientSideQBSearch';
|
||||
import { OperatorConfigKeys, OPERATORS_CONFIG } from 'constants/queryBuilder';
|
||||
import { HAVING_FILTER_REGEXP } from 'constants/regExp';
|
||||
import { IOption } from 'hooks/useResourceAttribute/types';
|
||||
@@ -11,7 +10,7 @@ import {
|
||||
orderByValueDelimiter,
|
||||
splitOrderByFromString,
|
||||
} from './OrderByFilter/utils';
|
||||
import { getRemoveOrderFromValue } from './QueryBuilderSearch/utils';
|
||||
import { getRemoveOrderFromValue } from './QueryBuilderSearchV2/utils';
|
||||
|
||||
export const handleKeyDownLimitFilter: React.KeyboardEventHandler<
|
||||
HTMLInputElement
|
||||
@@ -101,23 +100,6 @@ export const getValidOrderByResult = (result: IOption[]): IOption[] =>
|
||||
return acc;
|
||||
}, []);
|
||||
|
||||
export const transformKeyValuesToAttributeValuesMap = (
|
||||
attributeValuesMap: Record<string, string[] | number[] | boolean[]>,
|
||||
): AttributeValuesMap =>
|
||||
Object.fromEntries(
|
||||
Object.entries(attributeValuesMap || {}).map(([key, values]) => [
|
||||
key,
|
||||
{
|
||||
stringAttributeValues:
|
||||
typeof values[0] === 'string' ? (values as string[]) : [],
|
||||
numberAttributeValues:
|
||||
typeof values[0] === 'number' ? (values as number[]) : [],
|
||||
boolAttributeValues:
|
||||
typeof values[0] === 'boolean' ? (values as boolean[]) : [],
|
||||
},
|
||||
]),
|
||||
);
|
||||
|
||||
export const filterByOperatorConfig = (
|
||||
options: IOption[],
|
||||
key?: OperatorConfigKeys,
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
formatValueForExpression,
|
||||
removeKeysFromExpression,
|
||||
} from 'components/QueryBuilderV2/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { IQueryPair } from 'types/antlrQueryTypes';
|
||||
import { extractQueryPairs } from 'utils/queryContextUtils';
|
||||
import { isQuoted, unquote } from 'utils/stringUtils';
|
||||
|
||||
@@ -9,7 +9,7 @@ import { QueryParams } from 'constants/query';
|
||||
import { OPERATORS, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { MetricsType } from 'container/MetricsApplication/constant';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
import { KeyboardEvent, useCallback, useState } from 'react';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import type { InfraMonitoringEntity } from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
getRemovePrefixFromKey,
|
||||
getTagToken,
|
||||
replaceStringWithMaxLength,
|
||||
tagRegexp,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { Option } from 'container/QueryBuilder/type';
|
||||
import { parse } from 'papaparse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { useFetchKeysAndValues } from './useFetchKeysAndValues';
|
||||
import { useOptions, WHERE_CLAUSE_CUSTOM_SUFFIX } from './useOptions';
|
||||
import { useSetCurrentKeyAndOperator } from './useSetCurrentKeyAndOperator';
|
||||
import { useTag } from './useTag';
|
||||
import { useTagValidation } from './useTagValidation';
|
||||
|
||||
export type WhereClauseConfig = {
|
||||
customKey: string;
|
||||
customOp: string;
|
||||
};
|
||||
|
||||
export const useAutoComplete = (
|
||||
query: IBuilderQuery,
|
||||
whereClauseConfig?: WhereClauseConfig,
|
||||
shouldUseSuggestions?: boolean,
|
||||
isInfraMonitoring?: boolean,
|
||||
entity?: InfraMonitoringEntity | null,
|
||||
isMetricsExplorer?: boolean,
|
||||
): IAutoComplete => {
|
||||
const [searchValue, setSearchValue] = useState<string>('');
|
||||
const [searchKey, setSearchKey] = useState<string>('');
|
||||
|
||||
const { keys, results, isFetching, exampleQueries } = useFetchKeysAndValues(
|
||||
searchValue,
|
||||
query,
|
||||
searchKey,
|
||||
shouldUseSuggestions,
|
||||
isInfraMonitoring,
|
||||
entity,
|
||||
isMetricsExplorer,
|
||||
);
|
||||
|
||||
const [key, operator, result] = useSetCurrentKeyAndOperator(searchValue, keys);
|
||||
|
||||
const handleSearch = (value: string): void => {
|
||||
const prefixFreeValue = getRemovePrefixFromKey(getTagToken(value).tagKey);
|
||||
setSearchValue(value);
|
||||
setSearchKey(prefixFreeValue);
|
||||
};
|
||||
|
||||
const { isValidTag, isExist, isValidOperator, isMulti } = useTagValidation(
|
||||
operator,
|
||||
result,
|
||||
);
|
||||
|
||||
const { handleAddTag, handleClearTag, tags, updateTag } = useTag(
|
||||
isValidTag,
|
||||
handleSearch,
|
||||
query,
|
||||
setSearchKey,
|
||||
whereClauseConfig,
|
||||
isInfraMonitoring,
|
||||
);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(value: string): void => {
|
||||
if (isMulti) {
|
||||
setSearchValue((prev: string) => {
|
||||
const matches = prev?.matchAll(tagRegexp);
|
||||
const [match] = matches ? Array.from(matches) : [];
|
||||
const [, , , matchTagValue] = match;
|
||||
const data = parse(matchTagValue).data.flat();
|
||||
return replaceStringWithMaxLength(prev, data as string[], value);
|
||||
});
|
||||
}
|
||||
if (!isMulti) {
|
||||
handleAddTag(value);
|
||||
}
|
||||
},
|
||||
[handleAddTag, isMulti],
|
||||
);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(event: KeyboardEvent): void => {
|
||||
if (
|
||||
event.key === ' ' &&
|
||||
(searchValue.endsWith(' ') || searchValue.length === 0)
|
||||
) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' && searchValue && isValidTag) {
|
||||
if (isMulti) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
event.preventDefault();
|
||||
handleAddTag(searchValue);
|
||||
}
|
||||
|
||||
if (event.key === 'Backspace' && !searchValue) {
|
||||
event.stopPropagation();
|
||||
const last = tags[tags.length - 1];
|
||||
handleClearTag(last);
|
||||
}
|
||||
},
|
||||
[handleAddTag, handleClearTag, isMulti, isValidTag, searchValue, tags],
|
||||
);
|
||||
|
||||
const handleOnBlur = (event: React.FocusEvent<HTMLInputElement>): void => {
|
||||
event.preventDefault();
|
||||
if (searchValue) {
|
||||
if (
|
||||
key &&
|
||||
!operator &&
|
||||
whereClauseConfig?.customKey === 'body' &&
|
||||
whereClauseConfig.customOp === OPERATORS.CONTAINS
|
||||
) {
|
||||
const value = `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`;
|
||||
handleAddTag(value);
|
||||
return;
|
||||
}
|
||||
handleAddTag(searchValue);
|
||||
}
|
||||
};
|
||||
|
||||
const options = useOptions(
|
||||
key,
|
||||
keys,
|
||||
operator,
|
||||
searchValue,
|
||||
isMulti,
|
||||
isValidOperator,
|
||||
isExist,
|
||||
results,
|
||||
result,
|
||||
isFetching,
|
||||
whereClauseConfig,
|
||||
isInfraMonitoring,
|
||||
);
|
||||
|
||||
return {
|
||||
updateTag,
|
||||
handleSearch,
|
||||
handleClearTag,
|
||||
handleSelect,
|
||||
handleKeyDown,
|
||||
handleOnBlur,
|
||||
options,
|
||||
tags,
|
||||
searchValue,
|
||||
isMulti,
|
||||
isFetching,
|
||||
setSearchKey,
|
||||
setSearchValue,
|
||||
searchKey,
|
||||
key,
|
||||
exampleQueries,
|
||||
};
|
||||
};
|
||||
|
||||
interface IAutoComplete {
|
||||
updateTag: (value: string) => void;
|
||||
handleSearch: (value: string) => void;
|
||||
handleClearTag: (value: string) => void;
|
||||
handleSelect: (value: string) => void;
|
||||
handleKeyDown: (event: React.KeyboardEvent) => void;
|
||||
handleOnBlur: (event: React.FocusEvent<HTMLInputElement>) => void;
|
||||
options: Option[];
|
||||
tags: string[];
|
||||
searchValue: string;
|
||||
isMulti: boolean;
|
||||
isFetching: boolean;
|
||||
setSearchKey: (value: string) => void;
|
||||
setSearchValue: (value: string) => void;
|
||||
searchKey: string;
|
||||
key: string;
|
||||
exampleQueries: TagFilter[];
|
||||
isInfraMonitoring?: boolean;
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useDebounce } from 'react-use';
|
||||
import { getAttributesValues } from 'api/queryBuilder/getAttributesValues';
|
||||
import { DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY } from 'constants/queryBuilder';
|
||||
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
|
||||
import {
|
||||
GetK8sEntityToAggregateAttribute,
|
||||
InfraMonitoringEntity,
|
||||
} from 'container/InfraMonitoringK8sV2/constants';
|
||||
import {
|
||||
getRemovePrefixFromKey,
|
||||
getTagToken,
|
||||
isInNInOperator,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import useDebounceValue from 'hooks/useDebounce';
|
||||
import { cloneDeep, isEqual, uniqWith, unset } from 'lodash-es';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
import {
|
||||
BaseAutocompleteData,
|
||||
DataTypes,
|
||||
} from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { useGetAggregateKeys } from './useGetAggregateKeys';
|
||||
import { useGetAttributeSuggestions } from './useGetAttributeSuggestions';
|
||||
|
||||
type IuseFetchKeysAndValues = {
|
||||
keys: BaseAutocompleteData[];
|
||||
results: string[];
|
||||
isFetching: boolean;
|
||||
sourceKeys: BaseAutocompleteData[];
|
||||
handleRemoveSourceKey: (newSourceKey: string) => void;
|
||||
exampleQueries: TagFilter[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Custom hook to fetch attribute keys and values from an API
|
||||
* @param searchValue - the search query value
|
||||
* @param query - an object containing data for the query
|
||||
* @returns an object containing the fetched attribute keys, results, and the status of the fetch
|
||||
*/
|
||||
|
||||
export const useFetchKeysAndValues = (
|
||||
searchValue: string,
|
||||
query: IBuilderQuery,
|
||||
searchKey: string,
|
||||
shouldUseSuggestions?: boolean,
|
||||
isInfraMonitoring?: boolean,
|
||||
entity?: InfraMonitoringEntity | null,
|
||||
isMetricsExplorer?: boolean,
|
||||
): IuseFetchKeysAndValues => {
|
||||
const [keys, setKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
const [exampleQueries, setExampleQueries] = useState<TagFilter[]>([]);
|
||||
const [sourceKeys, setSourceKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
const [results, setResults] = useState<string[]>([]);
|
||||
const [isAggregateFetching, setAggregateFetching] = useState<boolean>(false);
|
||||
|
||||
const memoizedSearchParams = useMemo(
|
||||
() => [
|
||||
searchKey,
|
||||
query.dataSource,
|
||||
query.aggregateOperator,
|
||||
query.aggregateAttribute?.key,
|
||||
],
|
||||
[
|
||||
searchKey,
|
||||
query.dataSource,
|
||||
query.aggregateOperator,
|
||||
query.aggregateAttribute?.key,
|
||||
],
|
||||
);
|
||||
|
||||
const searchParams = useDebounceValue(memoizedSearchParams, DEBOUNCE_DELAY);
|
||||
|
||||
const queryFiltersWithoutId = useMemo(
|
||||
() => ({
|
||||
...query.filters,
|
||||
items: query.filters?.items?.map((item) => {
|
||||
const filterWithoutId = cloneDeep(item);
|
||||
unset(filterWithoutId, 'id');
|
||||
return filterWithoutId;
|
||||
}),
|
||||
}),
|
||||
[query.filters],
|
||||
);
|
||||
|
||||
const memoizedSuggestionsParams = useMemo(
|
||||
() => [searchKey, query.dataSource, queryFiltersWithoutId],
|
||||
[query.dataSource, queryFiltersWithoutId, searchKey],
|
||||
);
|
||||
|
||||
const suggestionsParams = useDebounceValue(
|
||||
memoizedSuggestionsParams,
|
||||
DEBOUNCE_DELAY,
|
||||
);
|
||||
|
||||
const isQueryEnabled = useMemo(
|
||||
() =>
|
||||
query.dataSource === DataSource.METRICS &&
|
||||
!isInfraMonitoring &&
|
||||
!isMetricsExplorer
|
||||
? !!query.dataSource && !!query.aggregateAttribute?.dataType
|
||||
: true,
|
||||
[
|
||||
isInfraMonitoring,
|
||||
isMetricsExplorer,
|
||||
query.aggregateAttribute?.dataType,
|
||||
query.dataSource,
|
||||
],
|
||||
);
|
||||
|
||||
const { data, isFetching, status } = useGetAggregateKeys(
|
||||
{
|
||||
searchText: searchKey,
|
||||
dataSource: query.dataSource,
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
aggregateAttribute:
|
||||
isInfraMonitoring && entity
|
||||
? GetK8sEntityToAggregateAttribute(entity)
|
||||
: query.aggregateAttribute?.key || '',
|
||||
tagType: query.aggregateAttribute?.type ?? null,
|
||||
},
|
||||
{
|
||||
queryKey: [searchParams],
|
||||
enabled: isMetricsExplorer ? false : isQueryEnabled && !shouldUseSuggestions,
|
||||
},
|
||||
isInfraMonitoring, // isInfraMonitoring
|
||||
entity, // infraMonitoringEntity
|
||||
);
|
||||
|
||||
const {
|
||||
data: suggestionsData,
|
||||
isFetching: isFetchingSuggestions,
|
||||
status: fetchingSuggestionsStatus,
|
||||
} = useGetAttributeSuggestions(
|
||||
{
|
||||
searchText: searchKey,
|
||||
dataSource: query.dataSource,
|
||||
filters: query.filters || { items: [], op: 'AND' },
|
||||
},
|
||||
{
|
||||
queryKey: [suggestionsParams],
|
||||
enabled: isQueryEnabled && shouldUseSuggestions,
|
||||
},
|
||||
);
|
||||
|
||||
function isAttributeValuesResponse(
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
payload: any,
|
||||
): payload is IAttributeValuesResponse {
|
||||
return (
|
||||
payload &&
|
||||
(Array.isArray(payload.stringAttributeValues) ||
|
||||
payload.stringAttributeValues === null ||
|
||||
Array.isArray(payload.numberAttributeValues) ||
|
||||
payload.numberAttributeValues === null ||
|
||||
Array.isArray(payload.boolAttributeValues) ||
|
||||
payload.boolAttributeValues === null)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the options to be displayed based on the selected value
|
||||
* @param value - the selected value
|
||||
* @param query - an object containing data for the query
|
||||
*/
|
||||
const handleFetchOption = async (
|
||||
value: string,
|
||||
query: IBuilderQuery,
|
||||
keys: BaseAutocompleteData[],
|
||||
): Promise<void> => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
const { tagKey, tagOperator, tagValue } = getTagToken(value);
|
||||
const filterAttributeKey = keys.find(
|
||||
(item) => item.key === getRemovePrefixFromKey(tagKey),
|
||||
);
|
||||
setResults([]);
|
||||
|
||||
if (!tagKey || !tagOperator) {
|
||||
return;
|
||||
}
|
||||
setAggregateFetching(true);
|
||||
|
||||
try {
|
||||
let payload;
|
||||
if (isInfraMonitoring && entity) {
|
||||
const response = await getAttributesValues({
|
||||
aggregateOperator: 'noop',
|
||||
dataSource: query.dataSource,
|
||||
aggregateAttribute:
|
||||
GetK8sEntityToAggregateAttribute(entity) ||
|
||||
query.aggregateAttribute?.key ||
|
||||
'',
|
||||
attributeKey: filterAttributeKey?.key ?? tagKey,
|
||||
filterAttributeKeyDataType:
|
||||
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
|
||||
tagType: filterAttributeKey?.type ?? '',
|
||||
searchText: isInNInOperator(tagOperator)
|
||||
? (tagValue[tagValue.length - 1]?.toString() ?? '')
|
||||
: (tagValue?.toString() ?? ''),
|
||||
});
|
||||
payload = response.payload;
|
||||
} else {
|
||||
const response = await getAttributesValues({
|
||||
aggregateOperator: query.aggregateOperator || '',
|
||||
dataSource: query.dataSource,
|
||||
aggregateAttribute: query.aggregateAttribute?.key || '',
|
||||
attributeKey: filterAttributeKey?.key ?? tagKey,
|
||||
filterAttributeKeyDataType:
|
||||
filterAttributeKey?.dataType ?? DataTypes.EMPTY,
|
||||
tagType: filterAttributeKey?.type ?? '',
|
||||
searchText: isInNInOperator(tagOperator)
|
||||
? (tagValue[tagValue.length - 1]?.toString() ?? '')
|
||||
: (tagValue?.toString() ?? ''),
|
||||
});
|
||||
payload = response.payload;
|
||||
}
|
||||
|
||||
if (payload && isAttributeValuesResponse(payload)) {
|
||||
const dataType = filterAttributeKey?.dataType ?? DataTypes.String;
|
||||
const key = DATA_TYPE_VS_ATTRIBUTE_VALUES_KEY[dataType];
|
||||
setResults(key ? payload[key] || [] : []);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
setAggregateFetching(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveSourceKey = useCallback((sourceKey: string) => {
|
||||
setSourceKeys((prevState) =>
|
||||
prevState.filter((item) => item.key !== sourceKey),
|
||||
);
|
||||
}, []);
|
||||
|
||||
// creates a ref to the fetch function so that it doesn't change on every render
|
||||
const clearFetcher = useRef(handleFetchOption).current;
|
||||
|
||||
// debounces the fetch function to avoid excessive API calls
|
||||
useDebounce(() => clearFetcher(searchValue, query, keys), 750, [
|
||||
clearFetcher,
|
||||
searchValue,
|
||||
query,
|
||||
keys,
|
||||
]);
|
||||
|
||||
// update the fetched keys when the fetch status changes
|
||||
useEffect(() => {
|
||||
if (status === 'success' && data?.payload?.attributeKeys) {
|
||||
setKeys(data.payload.attributeKeys);
|
||||
setSourceKeys((prevState) =>
|
||||
uniqWith([...(data.payload.attributeKeys ?? []), ...prevState], isEqual),
|
||||
);
|
||||
} else {
|
||||
setKeys([]);
|
||||
}
|
||||
}, [data?.payload?.attributeKeys, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
fetchingSuggestionsStatus === 'success' &&
|
||||
suggestionsData?.payload?.attributes
|
||||
) {
|
||||
if (!isInfraMonitoring) {
|
||||
setKeys(suggestionsData.payload.attributes);
|
||||
setSourceKeys((prevState) =>
|
||||
uniqWith(
|
||||
[...(suggestionsData.payload.attributes ?? []), ...prevState],
|
||||
isEqual,
|
||||
),
|
||||
);
|
||||
}
|
||||
} else {
|
||||
setKeys([]);
|
||||
}
|
||||
if (
|
||||
fetchingSuggestionsStatus === 'success' &&
|
||||
suggestionsData?.payload?.example_queries
|
||||
) {
|
||||
setExampleQueries(suggestionsData.payload.example_queries);
|
||||
} else {
|
||||
setExampleQueries([]);
|
||||
}
|
||||
}, [
|
||||
suggestionsData?.payload?.attributes,
|
||||
fetchingSuggestionsStatus,
|
||||
suggestionsData?.payload?.example_queries,
|
||||
isInfraMonitoring,
|
||||
]);
|
||||
|
||||
return {
|
||||
keys,
|
||||
results,
|
||||
isFetching: isFetching || isAggregateFetching || isFetchingSuggestions,
|
||||
sourceKeys,
|
||||
handleRemoveSourceKey,
|
||||
exampleQueries,
|
||||
};
|
||||
};
|
||||
@@ -1,31 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
OPERATORS,
|
||||
QUERY_BUILDER_OPERATORS_BY_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { getRemovePrefixFromKey } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
type IOperators =
|
||||
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.universal
|
||||
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.string
|
||||
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.bool
|
||||
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.int64
|
||||
| typeof QUERY_BUILDER_OPERATORS_BY_TYPES.float64;
|
||||
|
||||
export const useOperators = (
|
||||
key: string,
|
||||
keys: BaseAutocompleteData[],
|
||||
): IOperators =>
|
||||
useMemo(() => {
|
||||
const currentKey = keys?.find((el) => el.key === getRemovePrefixFromKey(key));
|
||||
const strippedKey = key.split(' ')[0];
|
||||
|
||||
return currentKey?.dataType
|
||||
? QUERY_BUILDER_OPERATORS_BY_TYPES[
|
||||
currentKey.dataType as keyof typeof QUERY_BUILDER_OPERATORS_BY_TYPES
|
||||
]
|
||||
: strippedKey.endsWith('[*]') && strippedKey.startsWith('body.')
|
||||
? [OPERATORS.HAS, OPERATORS.NHAS]
|
||||
: QUERY_BUILDER_OPERATORS_BY_TYPES.universal;
|
||||
}, [keys, key]);
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
|
||||
import {
|
||||
checkCommaInValue,
|
||||
getTagToken,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { Option } from 'container/QueryBuilder/type';
|
||||
import { useDashboardVariablesByType } from 'hooks/dashboard/useDashboardVariablesByType';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
import { WhereClauseConfig } from './useAutoComplete';
|
||||
import { useOperators } from './useOperators';
|
||||
|
||||
export const WHERE_CLAUSE_CUSTOM_SUFFIX = '-custom';
|
||||
|
||||
export const useOptions = (
|
||||
key: string,
|
||||
keys: BaseAutocompleteData[],
|
||||
operator: string,
|
||||
searchValue: string,
|
||||
isMulti: boolean,
|
||||
isValidOperator: boolean,
|
||||
isExist: boolean,
|
||||
results: string[],
|
||||
result: string[],
|
||||
isFetching: boolean,
|
||||
whereClauseConfig?: WhereClauseConfig,
|
||||
isInfraMonitoring?: boolean,
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
): Option[] => {
|
||||
const [options, setOptions] = useState<Option[]>([]);
|
||||
const operators = useOperators(key, keys);
|
||||
|
||||
// get matching dynamic variables to suggest
|
||||
const dashboardDynamicVariables = useDashboardVariablesByType(
|
||||
'DYNAMIC',
|
||||
'values',
|
||||
);
|
||||
|
||||
const variableName = dashboardDynamicVariables?.find(
|
||||
(variable) => variable?.dynamicVariablesAttribute === key,
|
||||
)?.name;
|
||||
|
||||
const variableAsValue = variableName ? `$${variableName}` : '';
|
||||
|
||||
const getLabel = useCallback(
|
||||
(data: BaseAutocompleteData): Option['label'] => data?.key,
|
||||
[],
|
||||
);
|
||||
|
||||
const getOptionsFromKeys = useCallback(
|
||||
(items: BaseAutocompleteData[]): Option[] =>
|
||||
items?.map((item) => ({
|
||||
label: `${getLabel(item)}`,
|
||||
value: item.key,
|
||||
dataType: item.dataType,
|
||||
isIndexed: item?.isIndexed,
|
||||
type: item?.type || '',
|
||||
})),
|
||||
[getLabel],
|
||||
);
|
||||
|
||||
const getKeyOpValue = useCallback(
|
||||
(items: string[]): Option[] =>
|
||||
items?.map((item) => ({
|
||||
label: `${key} ${operator} ${item}`,
|
||||
value: `${key} ${operator} ${item}`,
|
||||
})),
|
||||
[key, operator],
|
||||
);
|
||||
|
||||
const getOptionsWithValidOperator = useCallback(
|
||||
(key: string, results: string[], searchValue: string) => {
|
||||
const hasAllResults = results.every((value) => result.includes(value));
|
||||
|
||||
let newResults = results;
|
||||
if (!isEmpty(variableAsValue)) {
|
||||
newResults = [variableAsValue, ...newResults];
|
||||
}
|
||||
|
||||
const values = getKeyOpValue(newResults);
|
||||
|
||||
return hasAllResults
|
||||
? [
|
||||
{
|
||||
label: searchValue,
|
||||
value: searchValue,
|
||||
},
|
||||
]
|
||||
: [
|
||||
{
|
||||
label: searchValue,
|
||||
value: searchValue,
|
||||
},
|
||||
...values,
|
||||
];
|
||||
},
|
||||
[getKeyOpValue, result, variableAsValue],
|
||||
);
|
||||
|
||||
const getKeyOperatorOptions = useCallback(
|
||||
(key: string) => {
|
||||
const keyOperator = key.split(' ');
|
||||
const partialOperator = keyOperator?.[1];
|
||||
const partialKey = keyOperator?.[0];
|
||||
const filteredOperators = !isEmpty(partialOperator)
|
||||
? operators?.filter((operator) =>
|
||||
operator.startsWith(partialOperator?.toUpperCase()),
|
||||
)
|
||||
: operators;
|
||||
const operatorsOptions = filteredOperators?.map((op) => {
|
||||
const labelOp =
|
||||
isInfraMonitoring && INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
|
||||
? INFRA_SHORT_TO_LONG_OPERATOR_MAP[op]
|
||||
: op;
|
||||
return {
|
||||
value: `${partialKey} ${op} `,
|
||||
label: `${partialKey} ${labelOp} `,
|
||||
};
|
||||
});
|
||||
if (whereClauseConfig) {
|
||||
return [
|
||||
{
|
||||
label: `${searchValue} `,
|
||||
value: `${searchValue}${WHERE_CLAUSE_CUSTOM_SUFFIX}`,
|
||||
},
|
||||
...operatorsOptions,
|
||||
];
|
||||
}
|
||||
return operatorsOptions;
|
||||
},
|
||||
[isInfraMonitoring, operators, searchValue, whereClauseConfig],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
let newOptions: Option[] = [];
|
||||
|
||||
if (!key) {
|
||||
newOptions = searchValue
|
||||
? [
|
||||
{
|
||||
label: `${searchValue} `,
|
||||
value: `${searchValue} `,
|
||||
},
|
||||
...getOptionsFromKeys(keys),
|
||||
]
|
||||
: getOptionsFromKeys(keys);
|
||||
} else if (key && !operator) {
|
||||
newOptions = getKeyOperatorOptions(key);
|
||||
} else if (key && operator) {
|
||||
if (isMulti) {
|
||||
const resultsWithVariable = isEmpty(variableAsValue)
|
||||
? results
|
||||
: [variableAsValue, ...results];
|
||||
newOptions = resultsWithVariable.map((item) => ({
|
||||
label: checkCommaInValue(String(item)),
|
||||
value: String(item),
|
||||
}));
|
||||
} else if (isExist) {
|
||||
newOptions = [];
|
||||
} else if (isValidOperator) {
|
||||
newOptions = getOptionsWithValidOperator(key, results, searchValue);
|
||||
}
|
||||
}
|
||||
if (newOptions.length > 0) {
|
||||
setOptions(newOptions);
|
||||
}
|
||||
if (isFetching) {
|
||||
setOptions([]);
|
||||
}
|
||||
}, [
|
||||
whereClauseConfig,
|
||||
getKeyOpValue,
|
||||
getOptionsFromKeys,
|
||||
isExist,
|
||||
isMulti,
|
||||
isValidOperator,
|
||||
key,
|
||||
keys,
|
||||
operator,
|
||||
operators,
|
||||
result,
|
||||
results,
|
||||
searchValue,
|
||||
getKeyOperatorOptions,
|
||||
getOptionsWithValidOperator,
|
||||
isFetching,
|
||||
variableAsValue,
|
||||
]);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
(
|
||||
options.filter(
|
||||
(option, index, self) =>
|
||||
index ===
|
||||
self.findIndex(
|
||||
(o) =>
|
||||
o.label === option.label &&
|
||||
o.value === option.value &&
|
||||
(o.type || '') === (option.type || '') &&
|
||||
(o.dataType || '') === (option.dataType || ''), // keep entries with same key but different type/dataType
|
||||
) && option.value !== '',
|
||||
) || []
|
||||
).map((option) => {
|
||||
const { tagValue } = getTagToken(searchValue);
|
||||
if (isMulti) {
|
||||
return {
|
||||
...option,
|
||||
selected: tagValue
|
||||
.filter((i) => i.trim().replace(/^\s+/, '') === option.value)
|
||||
.includes(option.value),
|
||||
};
|
||||
}
|
||||
return option;
|
||||
}),
|
||||
[isMulti, options, searchValue],
|
||||
);
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
getRemovePrefixFromKey,
|
||||
getTagToken,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
type ICurrentKeyAndOperator = [string, string, string[]];
|
||||
|
||||
export const useSetCurrentKeyAndOperator = (
|
||||
value: string,
|
||||
keys: BaseAutocompleteData[],
|
||||
): ICurrentKeyAndOperator => {
|
||||
const [key, operator, result] = useMemo(() => {
|
||||
let key = '';
|
||||
let operator = '';
|
||||
let result: string[] = [];
|
||||
const { tagKey, tagOperator, tagValue } = getTagToken(value);
|
||||
const isSuggestKey = keys?.some(
|
||||
(el) => el?.key === getRemovePrefixFromKey(tagKey),
|
||||
);
|
||||
if (isSuggestKey || keys.length === 0) {
|
||||
key = tagKey || '';
|
||||
operator = tagOperator || '';
|
||||
result = tagValue || [];
|
||||
}
|
||||
|
||||
return [key, operator, result];
|
||||
}, [value, keys]);
|
||||
|
||||
return [key, operator, result];
|
||||
};
|
||||
@@ -1,18 +1,10 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { INFRA_SHORT_TO_LONG_OPERATOR_MAP } from 'constants/queryBuilder';
|
||||
import {
|
||||
getOperatorFromValue,
|
||||
getTagToken,
|
||||
isExistsNotExistsOperator,
|
||||
isInNInOperator,
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
} from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { unparse } from 'papaparse';
|
||||
import {
|
||||
IBuilderQuery,
|
||||
TagFilter,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { WhereClauseConfig } from './useAutoComplete';
|
||||
import type { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
/**
|
||||
* Helper for formatting a TagFilter object into filter item strings
|
||||
@@ -42,95 +34,3 @@ export function queryFilterTags(
|
||||
return `${ele.key?.key} ${displayOp} ${ele.value}`;
|
||||
});
|
||||
}
|
||||
|
||||
type IUseTag = {
|
||||
handleAddTag: (value: string) => void;
|
||||
handleClearTag: (value: string) => void;
|
||||
tags: string[];
|
||||
updateTag: (value: string) => void;
|
||||
};
|
||||
|
||||
/**
|
||||
* A custom React hook for handling tags.
|
||||
* @param {string} key - A string value to identify tags.
|
||||
* @param {boolean} isValidTag - A boolean value to indicate whether the tag is valid.
|
||||
* @param {function} handleSearch - A callback function to handle search.
|
||||
* @returns {IUseTag} The return object containing handlers and tags.
|
||||
*/
|
||||
|
||||
export const useTag = (
|
||||
isValidTag: boolean,
|
||||
handleSearch: (value: string) => void,
|
||||
query: IBuilderQuery,
|
||||
setSearchKey: (value: string) => void,
|
||||
whereClauseConfig?: WhereClauseConfig,
|
||||
isInfraMonitoring?: boolean,
|
||||
): IUseTag => {
|
||||
const initTagsData = useMemo(
|
||||
() =>
|
||||
queryFilterTags(
|
||||
query?.filters || { items: [], op: 'AND' },
|
||||
isInfraMonitoring,
|
||||
),
|
||||
[query?.filters, isInfraMonitoring],
|
||||
);
|
||||
|
||||
const [tags, setTags] = useState<string[]>(initTagsData);
|
||||
|
||||
const updateTag = (value: string): void => {
|
||||
const newTags = tags?.filter((item: string) => item !== value);
|
||||
setTags(newTags);
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a new tag to the tag list.
|
||||
* @param {string} value - The tag value to be added.
|
||||
*/
|
||||
|
||||
const handleAddTag = useCallback(
|
||||
(value: string): void => {
|
||||
const { tagKey } = getTagToken(value);
|
||||
const parts = tagKey.split('-');
|
||||
// this is done to ensure that `hello-world` also gets converted to `body CONTAINS hello-world`
|
||||
let id = parts[parts.length - 1];
|
||||
let key = parts.slice(0, -1).join('-');
|
||||
if (parts.length === 1) {
|
||||
id = '';
|
||||
[key] = parts;
|
||||
}
|
||||
|
||||
if (id === 'custom') {
|
||||
const customValue = whereClauseConfig
|
||||
? `${whereClauseConfig.customKey} ${whereClauseConfig.customOp} ${key}`
|
||||
: '';
|
||||
setTags((prevTags) =>
|
||||
prevTags.includes(customValue) ? prevTags : [...prevTags, customValue],
|
||||
);
|
||||
handleSearch('');
|
||||
setSearchKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
if ((value && key && isValidTag) || isExistsNotExistsOperator(value)) {
|
||||
setTags((prevTags) => [...prevTags, value]);
|
||||
handleSearch('');
|
||||
setSearchKey('');
|
||||
}
|
||||
},
|
||||
[whereClauseConfig, isValidTag, handleSearch, setSearchKey],
|
||||
);
|
||||
|
||||
/**
|
||||
* Removes a tag from the tag list.
|
||||
* @param {string} value - The tag value to be removed.
|
||||
*/
|
||||
const handleClearTag = useCallback((value: string): void => {
|
||||
setTags((prevTags) => prevTags.filter((v) => v !== value));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
setTags(initTagsData);
|
||||
}, [initTagsData]);
|
||||
|
||||
return { handleAddTag, handleClearTag, tags, updateTag };
|
||||
};
|
||||
|
||||
@@ -6,7 +6,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
|
||||
@@ -16,7 +16,7 @@ import LogsError from 'container/LogsError/LogsError';
|
||||
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import createQueryParams from 'lib/createQueryParams';
|
||||
import { Compass } from '@signozhq/icons';
|
||||
|
||||
@@ -4,7 +4,7 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
|
||||
@@ -7,7 +7,7 @@ import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBui
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { PANEL_TYPES, QueryBuilderKeys } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearchV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { chooseAutocompleteFromCustomValue } from 'lib/newQueryBuilder/chooseAutocompleteFromCustomValue';
|
||||
import { ArrowDownToDot, ArrowUpFromDot } from '@signozhq/icons';
|
||||
|
||||
@@ -1,99 +1,269 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"slices"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func (provider *provider) addAuthDomainRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.List), handler.OpenAPIDef{
|
||||
ID: "ListAuthDomains",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "List all auth domains",
|
||||
Description: "This endpoint lists all auth domains",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.GettableAuthDomain, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/auth_domains", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authDomainHandler.List, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "ListAuthDomains",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "List all auth domains",
|
||||
Description: "This endpoint lists all auth domains",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: make([]*authtypes.GettableAuthDomain, 0),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbList)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
Verb: coretypes.VerbList,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
Selector: coretypes.WildcardSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/auth_domains", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Create), handler.OpenAPIDef{
|
||||
ID: "CreateAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Create auth domain",
|
||||
Description: "This endpoint creates an auth domain",
|
||||
Request: new(authtypes.PostableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPost).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/auth_domains", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Create, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Create auth domain",
|
||||
Description: "This endpoint creates an auth domain",
|
||||
Request: new(authtypes.PostableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbCreate),
|
||||
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
Verb: coretypes.VerbCreate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.ResponseJSONPath("data.id"),
|
||||
Selector: coretypes.WildcardSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
SourceIDs: coretypes.OneID(coretypes.ResponseJSONPath("data.id")),
|
||||
SourceSelector: coretypes.WildcardSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Get), handler.OpenAPIDef{
|
||||
ID: "GetAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Get auth domain by ID",
|
||||
Description: "This endpoint returns an auth domain by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.GettableAuthDomain),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodGet).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Get, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Get auth domain by ID",
|
||||
Description: "This endpoint returns an auth domain by ID",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(authtypes.GettableAuthDomain),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Update), handler.OpenAPIDef{
|
||||
ID: "UpdateAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Update auth domain",
|
||||
Description: "This endpoint updates an auth domain",
|
||||
Request: new(authtypes.UpdatableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodPut).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Update, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "UpdateAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Update auth domain",
|
||||
Description: "This endpoint updates an auth domain",
|
||||
Request: new(authtypes.UpdatableAuthDomain),
|
||||
RequestContentType: "application/json",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{
|
||||
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbUpdate),
|
||||
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDetach),
|
||||
coretypes.ResourceRole.Scope(coretypes.VerbAttach),
|
||||
coretypes.ResourceRole.Scope(coretypes.VerbDetach),
|
||||
}),
|
||||
},
|
||||
handler.WithResourceDefs(
|
||||
handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
Verb: coretypes.VerbUpdate,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: authDomainRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: provider.authDomainStoredRoleNamesExtractor(),
|
||||
TargetSelector: coretypes.IDSelector,
|
||||
},
|
||||
),
|
||||
)).Methods(http.MethodPut).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.authDomainHandler.Delete), handler.OpenAPIDef{
|
||||
ID: "DeleteAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Delete auth domain",
|
||||
Description: "This endpoint deletes an auth domain",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
if err := router.Handle("/api/v2/auth_domains/{id}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.authDomainHandler.Delete, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteAuthDomain",
|
||||
Tags: []string{"authdomains"},
|
||||
Summary: "Delete auth domain",
|
||||
Description: "This endpoint deletes an auth domain",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceAuthDomain.Scope(coretypes.VerbDelete)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceAuthDomain,
|
||||
Verb: coretypes.VerbDelete,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
ID: coretypes.PathParam("id"),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// The extracted names are the roles the request body's mapping grants at SSO
|
||||
// login — see authDomainEffectiveRoleNames.
|
||||
func authDomainRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
roleMappingJSON := gjson.GetBytes(ec.RequestBody, "roleMapping")
|
||||
if !roleMappingJSON.Exists() || roleMappingJSON.Type == gjson.Null {
|
||||
return authDomainEffectiveRoleNames(nil), nil
|
||||
}
|
||||
|
||||
roleMapping := new(authtypes.RoleMapping)
|
||||
if err := json.Unmarshal([]byte(roleMappingJSON.Raw), roleMapping); err != nil {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid role mapping: %v", err)
|
||||
}
|
||||
|
||||
return authDomainEffectiveRoleNames(roleMapping), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// The extracted names are the roles the stored domain's mapping grants at SSO
|
||||
// login — an update replaces that mapping, so the caller must be able to detach
|
||||
// them.
|
||||
func (provider *provider) authDomainStoredRoleNamesExtractor() coretypes.ResourceIDsExtractor {
|
||||
return coretypes.ResourceIDsExtractor{Phase: coretypes.PhaseRequest, Fn: func(ec coretypes.ExtractorContext) ([]string, error) {
|
||||
if ec.Request == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
orgID, err := valuer.NewUUID(claims.OrgID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
authDomain, err := provider.authDomainModule.GetByOrgIDAndID(ec.Request.Context(), orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return authDomainEffectiveRoleNames(authDomain.RoleMapping()), nil
|
||||
}}
|
||||
}
|
||||
|
||||
// The effective names are the roles a domain grants at SSO login: the mapped
|
||||
// roles plus the default (signoz-viewer when unset), or every role when the IDP
|
||||
// role attribute is trusted. Never empty — a check with no selectors is forbidden.
|
||||
func authDomainEffectiveRoleNames(roleMapping *authtypes.RoleMapping) []string {
|
||||
if roleMapping == nil {
|
||||
return []string{authtypes.SigNozViewerRoleName}
|
||||
}
|
||||
|
||||
if roleMapping.UseRoleAttribute {
|
||||
return []string{coretypes.WildCardSelectorString}
|
||||
}
|
||||
|
||||
roleNames := roleMapping.RoleNames()
|
||||
if !slices.Contains(roleNames, roleMapping.DefaultRoleName()) {
|
||||
roleNames = append(roleNames, roleMapping.DefaultRoleName())
|
||||
}
|
||||
|
||||
return roleNames
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -51,6 +52,7 @@ type provider struct {
|
||||
userHandler user.Handler
|
||||
sessionHandler session.Handler
|
||||
authDomainHandler authdomain.Handler
|
||||
authDomainModule authdomain.Module
|
||||
preferenceHandler preference.Handler
|
||||
globalHandler global.Handler
|
||||
promoteHandler promote.Handler
|
||||
@@ -79,6 +81,8 @@ type provider struct {
|
||||
llmPricingRuleHandler llmpricingrule.Handler
|
||||
statsHandler statsreporter.Handler
|
||||
savedViewHandler savedview.Handler
|
||||
systemDashboardModule systemdashboard.Module
|
||||
systemDashboardHandler systemdashboard.Handler
|
||||
}
|
||||
|
||||
func NewFactory(
|
||||
@@ -88,6 +92,7 @@ func NewFactory(
|
||||
userHandler user.Handler,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
preferenceHandler preference.Handler,
|
||||
globalHandler global.Handler,
|
||||
promoteHandler promote.Handler,
|
||||
@@ -116,6 +121,8 @@ func NewFactory(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
systemDashboardModule systemdashboard.Module,
|
||||
systemDashboardHandler systemdashboard.Handler,
|
||||
) factory.ProviderFactory[apiserver.APIServer, apiserver.Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("signoz"), func(ctx context.Context, providerSettings factory.ProviderSettings, config apiserver.Config) (apiserver.APIServer, error) {
|
||||
return newProvider(
|
||||
@@ -128,6 +135,7 @@ func NewFactory(
|
||||
userHandler,
|
||||
sessionHandler,
|
||||
authDomainHandler,
|
||||
authDomainModule,
|
||||
preferenceHandler,
|
||||
globalHandler,
|
||||
promoteHandler,
|
||||
@@ -156,6 +164,8 @@ func NewFactory(
|
||||
rulerHandler,
|
||||
statsHandler,
|
||||
savedViewHandler,
|
||||
systemDashboardModule,
|
||||
systemDashboardHandler,
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -170,6 +180,7 @@ func newProvider(
|
||||
userHandler user.Handler,
|
||||
sessionHandler session.Handler,
|
||||
authDomainHandler authdomain.Handler,
|
||||
authDomainModule authdomain.Module,
|
||||
preferenceHandler preference.Handler,
|
||||
globalHandler global.Handler,
|
||||
promoteHandler promote.Handler,
|
||||
@@ -198,6 +209,8 @@ func newProvider(
|
||||
rulerHandler ruler.Handler,
|
||||
statsHandler statsreporter.Handler,
|
||||
savedViewHandler savedview.Handler,
|
||||
systemDashboardModule systemdashboard.Module,
|
||||
systemDashboardHandler systemdashboard.Handler,
|
||||
) (apiserver.APIServer, error) {
|
||||
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/apiserver/signozapiserver")
|
||||
router := mux.NewRouter().UseEncodedPath()
|
||||
@@ -211,6 +224,7 @@ func newProvider(
|
||||
authzService: authzService,
|
||||
sessionHandler: sessionHandler,
|
||||
authDomainHandler: authDomainHandler,
|
||||
authDomainModule: authDomainModule,
|
||||
preferenceHandler: preferenceHandler,
|
||||
globalHandler: globalHandler,
|
||||
promoteHandler: promoteHandler,
|
||||
@@ -239,6 +253,8 @@ func newProvider(
|
||||
llmPricingRuleHandler: llmPricingRuleHandler,
|
||||
statsHandler: statsHandler,
|
||||
savedViewHandler: savedViewHandler,
|
||||
systemDashboardModule: systemDashboardModule,
|
||||
systemDashboardHandler: systemDashboardHandler,
|
||||
}
|
||||
|
||||
provider.authzMiddleware = middleware.NewAuthZ(settings.Logger(), orgGetter, authzService)
|
||||
@@ -291,6 +307,10 @@ func (provider *provider) AddToRouter(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addSystemDashboardRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := provider.addMetricsExplorerRoutes(router); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -138,66 +138,6 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "CreateServiceAccountRoleDeprecated",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
Description: "This endpoint assigns a role to a service account",
|
||||
Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbAttach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("id")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodPost).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/{id}/roles/{rid}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteRole, authtypes.SigNozAdminRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "DeleteServiceAccountRoleDeprecated",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Delete service account role",
|
||||
Description: "This endpoint revokes a role from service account",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: nil,
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusNoContent,
|
||||
ErrorStatusCodes: []int{},
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
Verb: coretypes.VerbDetach,
|
||||
Category: coretypes.ActionCategoryAccessControl,
|
||||
SourceResource: coretypes.ResourceServiceAccount,
|
||||
SourceIDs: coretypes.OneID(coretypes.PathParam("id")),
|
||||
SourceSelector: coretypes.IDSelector,
|
||||
TargetResource: coretypes.ResourceRole,
|
||||
TargetIDs: coretypes.OneID(coretypes.PathParam("rid")),
|
||||
TargetSelector: provider.roleSelector,
|
||||
}),
|
||||
)).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := router.Handle("/api/v1/service_accounts/me", handler.New(provider.authzMiddleware.OpenAccess(provider.serviceAccountHandler.UpdateMe), handler.OpenAPIDef{
|
||||
ID: "UpdateMyServiceAccount",
|
||||
Tags: []string{"serviceaccount"},
|
||||
|
||||
63
pkg/apiserver/signozapiserver/systemdashboard.go
Normal file
@@ -0,0 +1,63 @@
|
||||
package signozapiserver
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/http/handler"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
func (provider *provider) addSystemDashboardRoutes(router *mux.Router) error {
|
||||
if err := router.Handle("/api/v2/system/dashboards/{name}", handler.New(
|
||||
provider.authzMiddleware.CheckResources(provider.systemDashboardHandler.Get, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
|
||||
handler.OpenAPIDef{
|
||||
ID: "GetSystemDashboard",
|
||||
Tags: []string{"dashboard"},
|
||||
Summary: "Get system dashboard",
|
||||
Description: "Returns a dashboard SigNoz ships and owns, addressed by its stable definition name (e.g. `ai-o11y-overview`) rather than its id. System dashboards are read-only and upgraded through releases. The dashboard's own `name` field carries a reserved prefix that the path segment must not include.",
|
||||
Request: nil,
|
||||
RequestContentType: "",
|
||||
Response: new(dashboardtypes.GettableDashboardV2),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceDashboard.Scope(coretypes.VerbRead)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.BasicResourceDef{
|
||||
Resource: coretypes.ResourceMetaResourceDashboard,
|
||||
Verb: coretypes.VerbRead,
|
||||
Category: coretypes.ActionCategoryDataAccess,
|
||||
ID: provider.systemDashboardID(),
|
||||
Selector: coretypes.IDSelector,
|
||||
}),
|
||||
)).Methods(http.MethodGet).GetError(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// systemDashboardID resolves the {name} path param to the dashboard's id. Authz
|
||||
// tuples and audit records are written against ids, so the name has to be
|
||||
// resolved before either runs.
|
||||
func (provider *provider) systemDashboardID() coretypes.ResourceIDExtractor {
|
||||
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
|
||||
ctx := ec.Request.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
id, err := provider.systemDashboardModule.ResolveID(ctx, valuer.MustNewUUID(claims.OrgID), mux.Vars(ec.Request)["name"])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return id.StringValue(), nil
|
||||
})
|
||||
}
|
||||
@@ -63,6 +63,8 @@ type Module interface {
|
||||
|
||||
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored in the v1 schema.
|
||||
MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
@@ -72,6 +74,9 @@ type Module interface {
|
||||
|
||||
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// UpdateUnsafeV2 updates a dashboard bypassing the guards. Intended for internal system callers.
|
||||
UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
|
||||
|
||||
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
@@ -64,6 +64,23 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
func (store *store) GetByName(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.StorableDashboard, error) {
|
||||
storableDashboard := new(dashboardtypes.StorableDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
NewSelect().
|
||||
Model(storableDashboard).
|
||||
Where("name = ?", name).
|
||||
Where("org_id = ?", orgID).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, errors.CodeNotFound, "dashboard with name %s doesn't exist", name)
|
||||
}
|
||||
|
||||
return storableDashboard, nil
|
||||
}
|
||||
|
||||
// ListForUser emits the joined dashboard ⨝ user_dashboard_preference query the
|
||||
// spec calls for. Aliases:
|
||||
//
|
||||
|
||||
@@ -19,9 +19,12 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
|
||||
return nil, err
|
||||
}
|
||||
|
||||
dashboard := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
dashboard, err := postable.NewDashboardV2(orgID, createdBy, source)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
err = m.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -120,6 +123,20 @@ func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UU
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) GetByNameV2(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
storable, err := module.store.GetByName(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, storable.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storable.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
// MigrateV2 retries the v1→v2 migration on a dashboard still stored as v1 (one the
|
||||
// bulk 103 migration skipped or failed). Idempotent: an already-v2 one is unchanged.
|
||||
func (module *module) MigrateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
@@ -179,13 +196,32 @@ func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updatable.Tags)
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.Update)
|
||||
}
|
||||
|
||||
func (module *module) UpdateUnsafeV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error) {
|
||||
if err := updatable.Validate(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
existing, err := module.GetV2(ctx, orgID, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.updateV2(ctx, orgID, existing, updatedBy, updatable, existing.UpdateUnsafe)
|
||||
}
|
||||
|
||||
// apply is existing.Update or existing.UpdateUnsafe, so the gated path keeps its
|
||||
// in-transaction checks and only UpdateUnsafeV2 skips them.
|
||||
func (module *module) updateV2(ctx context.Context, orgID valuer.UUID, existing *dashboardtypes.DashboardV2, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2, apply func(dashboardtypes.UpdatableDashboardV2, string, []*tagtypes.Tag) error) (*dashboardtypes.DashboardV2, error) {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, existing.ID, updatable.Tags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = existing.Update(updatable, updatedBy, resolvedTags)
|
||||
err = apply(updatable, updatedBy, resolvedTags)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -6,18 +6,20 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/alertmanager"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/types"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type setter struct {
|
||||
store types.OrganizationStore
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
store types.OrganizationStore
|
||||
alertmanager alertmanager.Alertmanager
|
||||
quickfilter quickfilter.Module
|
||||
systemDashboard systemdashboard.Module
|
||||
}
|
||||
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter}
|
||||
func NewSetter(store types.OrganizationStore, alertmanager alertmanager.Alertmanager, quickfilter quickfilter.Module, systemDashboard systemdashboard.Module) organization.Setter {
|
||||
return &setter{store: store, alertmanager: alertmanager, quickfilter: quickfilter, systemDashboard: systemDashboard}
|
||||
}
|
||||
|
||||
func (module *setter) Create(ctx context.Context, organization *types.Organization, createManagedRoles func(context.Context, valuer.UUID) error) error {
|
||||
@@ -37,6 +39,10 @@ func (module *setter) Create(ctx context.Context, organization *types.Organizati
|
||||
return err
|
||||
}
|
||||
|
||||
if err := module.systemDashboard.Reconcile(ctx, organization.ID); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -214,64 +214,6 @@ func (handler *handler) UpdateMe(rw http.ResponseWriter, r *http.Request) {
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) SetRole(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
req := new(serviceaccounttypes.DeprecatedPostableServiceAccountRole)
|
||||
if err := binding.JSON.BindBody(r.Body, req); err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
_, err = handler.module.SetRole(ctx, valuer.MustNewUUID(claims.OrgID), id, req.ID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) DeleteRole(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
id, err := valuer.NewUUID(mux.Vars(r)["id"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
roleID, err := valuer.NewUUID(mux.Vars(r)["rid"])
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
err = handler.module.DeleteRole(ctx, valuer.MustNewUUID(claims.OrgID), id, roleID)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusNoContent, nil)
|
||||
}
|
||||
|
||||
func (handler *handler) CreateServiceAccountRole(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx := r.Context()
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
|
||||
@@ -94,10 +94,6 @@ type Handler interface {
|
||||
|
||||
UpdateMe(http.ResponseWriter, *http.Request)
|
||||
|
||||
SetRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
DeleteRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
CreateServiceAccountRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
GetServiceAccountRole(http.ResponseWriter, *http.Request)
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
)
|
||||
|
||||
const definitionsRoot = "fs/definitions"
|
||||
|
||||
//go:embed fs/definitions/*.json
|
||||
var definitionFiles embed.FS
|
||||
|
||||
// NewRegistry parses every embedded definition. Definitions are build-time assets
|
||||
// validated by a test, so a failure here means the binary shipped broken JSON.
|
||||
func NewRegistry() (systemdashboardtypes.Registry, error) {
|
||||
entries, err := fs.ReadDir(definitionFiles, definitionsRoot)
|
||||
if err != nil {
|
||||
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read system dashboard definitions")
|
||||
}
|
||||
|
||||
definitions := make([]systemdashboardtypes.Definition, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
|
||||
file := path.Join(definitionsRoot, entry.Name())
|
||||
raw, err := definitionFiles.ReadFile(file)
|
||||
if err != nil {
|
||||
return systemdashboardtypes.Registry{}, errors.WrapInternalf(err, errors.CodeInternal, "couldn't read %s", file)
|
||||
}
|
||||
|
||||
definition, err := systemdashboardtypes.NewDefinition(raw)
|
||||
if err != nil {
|
||||
return systemdashboardtypes.Registry{}, errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't parse %s", file)
|
||||
}
|
||||
definitions = append(definitions, definition)
|
||||
}
|
||||
|
||||
return systemdashboardtypes.NewRegistry(definitions)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// A schema migration cannot ship without updating the definitions: parsing them
|
||||
// runs the same validation a create goes through, at the current schemaVersion.
|
||||
func TestEmbeddedDefinitionsParseAtCurrentSchemaVersion(t *testing.T) {
|
||||
registry, err := NewRegistry()
|
||||
require.NoError(t, err)
|
||||
|
||||
// The frontend addresses the overview dashboard by this name.
|
||||
_, ok := registry.Get(dashboardtypes.SystemDashboardNamePrefix + "ai-o11y-overview")
|
||||
assert.True(t, ok)
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
{
|
||||
"version": 1,
|
||||
"definition": {
|
||||
"schemaVersion": "v6",
|
||||
"name": "signoz---ai-o11y-overview",
|
||||
"tags": [],
|
||||
"spec": {
|
||||
"display": {
|
||||
"name": "AI Observability Overview",
|
||||
"description": "Overview of LLM traffic. Panels ship in an upcoming release."
|
||||
},
|
||||
"variables": [],
|
||||
"panels": {},
|
||||
"layouts": []
|
||||
}
|
||||
}
|
||||
}
|
||||
48
pkg/modules/systemdashboard/implsystemdashboard/handler.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
type handler struct {
|
||||
module systemdashboard.Module
|
||||
}
|
||||
|
||||
func NewHandler(module systemdashboard.Module) systemdashboard.Handler {
|
||||
return &handler{module: module}
|
||||
}
|
||||
|
||||
func (handler *handler) Get(rw http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
claims, err := authtypes.ClaimsFromContext(ctx)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
name := mux.Vars(r)["name"]
|
||||
if name == "" {
|
||||
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "name is missing in the path"))
|
||||
return
|
||||
}
|
||||
|
||||
systemDashboard, err := handler.module.Get(ctx, valuer.MustNewUUID(claims.OrgID), name)
|
||||
if err != nil {
|
||||
render.Error(rw, err)
|
||||
return
|
||||
}
|
||||
|
||||
render.Success(rw, http.StatusOK, systemDashboard.ToGettableDashboardV2())
|
||||
}
|
||||
|
||||
150
pkg/modules/systemdashboard/implsystemdashboard/module.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
store systemdashboardtypes.Store
|
||||
registry systemdashboardtypes.Registry
|
||||
dashboardModule dashboard.Module
|
||||
}
|
||||
|
||||
func NewModule(
|
||||
providerSettings factory.ProviderSettings,
|
||||
store systemdashboardtypes.Store,
|
||||
registry systemdashboardtypes.Registry,
|
||||
dashboardModule dashboard.Module,
|
||||
) systemdashboard.Module {
|
||||
return &module{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
|
||||
store: store,
|
||||
registry: registry,
|
||||
dashboardModule: dashboardModule,
|
||||
}
|
||||
}
|
||||
|
||||
func (module *module) Reconcile(ctx context.Context, orgID valuer.UUID) error {
|
||||
for _, definition := range module.registry.List() {
|
||||
if err := module.reconcile(ctx, orgID, definition); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) reconcile(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
|
||||
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
if !errors.Ast(err, errors.TypeNotFound) {
|
||||
return err
|
||||
}
|
||||
return module.provision(ctx, orgID, definition)
|
||||
}
|
||||
|
||||
// Anything but the provisioner in updated_by means a foreign write. Leave the
|
||||
// row alone — never overwriting is the safe direction.
|
||||
if existing.UpdatedBy != systemdashboardtypes.ProvisionerIdentity {
|
||||
return nil
|
||||
}
|
||||
|
||||
state, err := module.store.Get(ctx, orgID, definition.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Only ever move forward: a downgrade must not rewrite the newer content.
|
||||
if state.Version >= definition.Version {
|
||||
return nil
|
||||
}
|
||||
|
||||
return module.upgrade(ctx, orgID, existing.ID, definition)
|
||||
}
|
||||
|
||||
// provision creates the dashboard and its state row in one transaction, so a
|
||||
// system dashboard can never exist without the version it was provisioned at.
|
||||
// A concurrent provisioner (another replica, or the org-creation hook racing the
|
||||
// startup sweep) loses on the state row's unique (org_id, name) index and rolls back.
|
||||
func (module *module) provision(ctx context.Context, orgID valuer.UUID, definition systemdashboardtypes.Definition) error {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
created, err := module.dashboardModule.CreateV2(
|
||||
ctx,
|
||||
orgID,
|
||||
systemdashboardtypes.ProvisionerIdentity,
|
||||
valuer.UUID{},
|
||||
dashboardtypes.SourceSystem,
|
||||
definition.Dashboard,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.Create(ctx, systemdashboardtypes.NewStorableSystemDashboard(orgID, created.ID, definition.Name(), definition.Version))
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Ast(err, errors.TypeAlreadyExists) {
|
||||
module.settings.Logger().DebugContext(ctx, "system dashboard already provisioned concurrently", slog.String("name", definition.Name()), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
module.settings.Logger().InfoContext(ctx, "provisioned system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) upgrade(ctx context.Context, orgID valuer.UUID, id valuer.UUID, definition systemdashboardtypes.Definition) error {
|
||||
err := module.store.RunInTx(ctx, func(ctx context.Context) error {
|
||||
if _, err := module.dashboardModule.UpdateUnsafeV2(ctx, orgID, id, systemdashboardtypes.ProvisionerIdentity, definition.ToUpdatable()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return module.store.UpdateVersion(ctx, orgID, definition.Name(), definition.Version)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
module.settings.Logger().InfoContext(ctx, "upgraded system dashboard", slog.String("name", definition.Name()), slog.Int("version", definition.Version), slog.String("org_id", orgID.StringValue()))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (module *module) Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
return module.get(ctx, orgID, name)
|
||||
}
|
||||
|
||||
func (module *module) ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error) {
|
||||
existing, err := module.get(ctx, orgID, name)
|
||||
if err != nil {
|
||||
return valuer.UUID{}, err
|
||||
}
|
||||
|
||||
return existing.ID, nil
|
||||
}
|
||||
|
||||
func (module *module) get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error) {
|
||||
if strings.HasPrefix(name, dashboardtypes.SystemDashboardNamePrefix) {
|
||||
return nil, errors.NewInvalidInputf(errors.CodeInvalidInput, "name must not carry the %q prefix", dashboardtypes.SystemDashboardNamePrefix)
|
||||
}
|
||||
|
||||
existing, err := module.dashboardModule.GetByNameV2(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+name)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := existing.ErrIfNotSystem(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return existing, nil
|
||||
}
|
||||
209
pkg/modules/systemdashboard/implsystemdashboard/module_test.go
Normal file
@@ -0,0 +1,209 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/analytics/analyticstest"
|
||||
"github.com/SigNoz/signoz/pkg/factory/factorytest"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/tagtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const testDashboardName = "test-overview"
|
||||
|
||||
func newTestSQLStore(t *testing.T) sqlstore.SQLStore {
|
||||
t.Helper()
|
||||
|
||||
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
|
||||
Provider: "sqlite",
|
||||
Connection: sqlstore.ConnectionConfig{MaxOpenConns: 10},
|
||||
Sqlite: sqlstore.SqliteConfig{
|
||||
Path: filepath.Join(t.TempDir(), "test.db"),
|
||||
Mode: "wal",
|
||||
BusyTimeout: 5 * time.Second,
|
||||
TransactionMode: "deferred",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
for _, model := range []any{
|
||||
(*dashboardtypes.StorableDashboard)(nil),
|
||||
(*tagtypes.Tag)(nil),
|
||||
(*tagtypes.TagRelation)(nil),
|
||||
(*systemdashboardtypes.StorableSystemDashboard)(nil),
|
||||
} {
|
||||
_, err := store.BunDB().NewCreateTable().Model(model).IfNotExists().Exec(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_system_dashboard_org_name ON system_dashboard (org_id, name)`)
|
||||
require.NoError(t, err)
|
||||
|
||||
return store
|
||||
}
|
||||
|
||||
func newTestModule(t *testing.T, sqlStore sqlstore.SQLStore, definitions ...systemdashboardtypes.Definition) (*module, dashboard.Module) {
|
||||
t.Helper()
|
||||
|
||||
providerSettings := factorytest.NewSettings()
|
||||
dashboardModule := impldashboard.NewModule(
|
||||
impldashboard.NewStore(sqlStore),
|
||||
providerSettings,
|
||||
analyticstest.New(),
|
||||
nil,
|
||||
queryparser.New(providerSettings),
|
||||
impltag.NewModule(impltag.NewStore(sqlStore)),
|
||||
)
|
||||
|
||||
registry, err := systemdashboardtypes.NewRegistry(definitions)
|
||||
require.NoError(t, err)
|
||||
|
||||
return NewModule(providerSettings, NewStore(sqlStore), registry, dashboardModule).(*module), dashboardModule
|
||||
}
|
||||
|
||||
func newTestDefinition(t *testing.T, version int, displayName string) systemdashboardtypes.Definition {
|
||||
t.Helper()
|
||||
|
||||
raw := `{
|
||||
"version": ` + strconv.Itoa(version) + `,
|
||||
"definition": {
|
||||
"schemaVersion": "` + dashboardtypes.SchemaVersion + `",
|
||||
"name": "` + dashboardtypes.SystemDashboardNamePrefix + testDashboardName + `",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "` + displayName + `"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}
|
||||
}`
|
||||
|
||||
definition, err := systemdashboardtypes.NewDefinition([]byte(raw))
|
||||
require.NoError(t, err)
|
||||
|
||||
return definition
|
||||
}
|
||||
|
||||
func TestReconcileProvisionsThenUpgradesUntilTheRowIsModified(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
systemDashboardModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
|
||||
|
||||
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, dashboardtypes.SourceSystem, provisioned.Source)
|
||||
assert.Equal(t, systemdashboardtypes.ProvisionerIdentity, provisioned.CreatedBy)
|
||||
assert.Equal(t, "v1", provisioned.Spec.Display.Name)
|
||||
assert.Equal(t, 1, stateVersion(t, systemDashboardModule, ctx, orgID))
|
||||
|
||||
// Reconciling the same version again is a no-op.
|
||||
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
|
||||
unchanged, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.UpdatedAt, unchanged.UpdatedAt)
|
||||
|
||||
// An unmodified copy is upgraded in place, keeping its id.
|
||||
upgradingModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, upgradingModule.Reconcile(ctx, orgID))
|
||||
|
||||
upgraded, err := upgradingModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, provisioned.ID, upgraded.ID)
|
||||
assert.Equal(t, "v2", upgraded.Spec.Display.Name)
|
||||
assert.Equal(t, 2, stateVersion(t, upgradingModule, ctx, orgID))
|
||||
|
||||
// Once anything but the provisioner writes the row, later releases leave it alone.
|
||||
updatable := newTestDefinition(t, 2, "edited out of band").ToUpdatable()
|
||||
_, err = dashboardModule.UpdateUnsafeV2(ctx, orgID, upgraded.ID, "user@signoz.io", updatable)
|
||||
require.NoError(t, err)
|
||||
|
||||
shippingModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
|
||||
require.NoError(t, shippingModule.Reconcile(ctx, orgID))
|
||||
|
||||
untouched, err := shippingModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "user@signoz.io", untouched.UpdatedBy)
|
||||
assert.Equal(t, "edited out of band", untouched.Spec.Display.Name)
|
||||
assert.Equal(t, 2, stateVersion(t, shippingModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func stateVersion(t *testing.T, module *module, ctx context.Context, orgID valuer.UUID) int {
|
||||
t.Helper()
|
||||
|
||||
state, err := module.store.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
return state.Version
|
||||
}
|
||||
|
||||
func TestSystemDashboardsAreImmutableToUsers(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore, newTestDefinition(t, 1, "v1"))
|
||||
require.NoError(t, systemDashboardModule.Reconcile(ctx, orgID))
|
||||
|
||||
provisioned, err := systemDashboardModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = dashboardModule.UpdateV2(ctx, orgID, provisioned.ID, "user@signoz.io", newTestDefinition(t, 1, "edited").ToUpdatable())
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "cannot be modified")
|
||||
}
|
||||
|
||||
func TestReconcileDoesNotDowngrade(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
newerModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 3, "v3"))
|
||||
require.NoError(t, newerModule.Reconcile(ctx, orgID))
|
||||
|
||||
olderModule, _ := newTestModule(t, sqlStore, newTestDefinition(t, 2, "v2"))
|
||||
require.NoError(t, olderModule.Reconcile(ctx, orgID))
|
||||
|
||||
got, err := newerModule.Get(ctx, orgID, testDashboardName)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "v3", got.Spec.Display.Name)
|
||||
assert.Equal(t, 3, stateVersion(t, newerModule, ctx, orgID))
|
||||
}
|
||||
|
||||
func TestGetRejectsANonSystemDashboard(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
sqlStore := newTestSQLStore(t)
|
||||
orgID := valuer.GenerateUUID()
|
||||
|
||||
systemDashboardModule, dashboardModule := newTestModule(t, sqlStore)
|
||||
|
||||
var postable dashboardtypes.PostableDashboardV2
|
||||
require.NoError(t, postable.UnmarshalJSON([]byte(`{
|
||||
"schemaVersion": "`+dashboardtypes.SchemaVersion+`",
|
||||
"name": "a-user-dashboard",
|
||||
"tags": [],
|
||||
"spec": {"display": {"name": "user"}, "variables": [], "panels": {}, "layouts": []}
|
||||
}`)))
|
||||
_, err := dashboardModule.CreateV2(ctx, orgID, "user@signoz.io", valuer.GenerateUUID(), dashboardtypes.SourceUser, postable)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The server-side prefix makes user names structurally unreachable here.
|
||||
_, err = systemDashboardModule.Get(ctx, orgID, "a-user-dashboard")
|
||||
require.Error(t, err)
|
||||
|
||||
_, err = systemDashboardModule.Get(ctx, orgID, dashboardtypes.SystemDashboardNamePrefix+testDashboardName)
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "must not carry")
|
||||
}
|
||||
81
pkg/modules/systemdashboard/implsystemdashboard/service.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/organization"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
)
|
||||
|
||||
const reconcileRetryInterval = 30 * time.Second
|
||||
|
||||
type service struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
module systemdashboard.Module
|
||||
orgGetter organization.Getter
|
||||
stopC chan struct{}
|
||||
healthyC chan struct{}
|
||||
}
|
||||
|
||||
// NewService reconciles every org's system dashboards once at startup. Orgs
|
||||
// created later are reconciled by the organization setter instead.
|
||||
func NewService(providerSettings factory.ProviderSettings, module systemdashboard.Module, orgGetter organization.Getter) factory.Service {
|
||||
return &service{
|
||||
settings: factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"),
|
||||
module: module,
|
||||
orgGetter: orgGetter,
|
||||
stopC: make(chan struct{}),
|
||||
healthyC: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Start(ctx context.Context) error {
|
||||
ticker := time.NewTicker(reconcileRetryInterval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
err := service.reconcile(ctx)
|
||||
if err == nil {
|
||||
close(service.healthyC)
|
||||
<-service.stopC
|
||||
return nil
|
||||
}
|
||||
|
||||
service.settings.Logger().WarnContext(ctx, "system dashboard reconciliation failed, retrying", errors.Attr(err))
|
||||
|
||||
select {
|
||||
case <-service.stopC:
|
||||
return nil
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (service *service) Healthy() <-chan struct{} {
|
||||
return service.healthyC
|
||||
}
|
||||
|
||||
func (service *service) Stop(_ context.Context) error {
|
||||
close(service.stopC)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (service *service) reconcile(ctx context.Context) error {
|
||||
orgs, err := service.orgGetter.ListByOwnedKeyRange(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, org := range orgs {
|
||||
if err := service.module.Reconcile(ctx, org.ID); err != nil {
|
||||
return errors.Wrapf(err, errors.TypeInternal, errors.CodeInternal, "couldn't reconcile system dashboards for org %s", org.ID.StringValue())
|
||||
}
|
||||
}
|
||||
|
||||
service.settings.Logger().InfoContext(ctx, "system dashboard reconciliation completed", slog.Int("orgs", len(orgs)))
|
||||
return nil
|
||||
}
|
||||
80
pkg/modules/systemdashboard/implsystemdashboard/store.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package implsystemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/systemdashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type store struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewStore(sqlstore sqlstore.SQLStore) systemdashboardtypes.Store {
|
||||
return &store{sqlstore: sqlstore}
|
||||
}
|
||||
|
||||
func (store *store) Create(ctx context.Context, storable *systemdashboardtypes.StorableSystemDashboard) error {
|
||||
_, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewInsert().
|
||||
Model(storable).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return store.sqlstore.WrapAlreadyExistsErrf(err, systemdashboardtypes.ErrCodeSystemDashboardAlreadyProvisioned, "system dashboard %s is already provisioned", storable.Name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) Get(ctx context.Context, orgID valuer.UUID, name string) (*systemdashboardtypes.StorableSystemDashboard, error) {
|
||||
storable := new(systemdashboardtypes.StorableSystemDashboard)
|
||||
err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewSelect().
|
||||
Model(storable).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Scan(ctx)
|
||||
if err != nil {
|
||||
return nil, store.sqlstore.WrapNotFoundErrf(err, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return storable, nil
|
||||
}
|
||||
|
||||
func (store *store) UpdateVersion(ctx context.Context, orgID valuer.UUID, name string, version int) error {
|
||||
result, err := store.
|
||||
sqlstore.
|
||||
BunDBCtx(ctx).
|
||||
NewUpdate().
|
||||
Model(new(systemdashboardtypes.StorableSystemDashboard)).
|
||||
Set("version = ?", version).
|
||||
Set("updated_at = ?", time.Now()).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("name = ?", name).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rows, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rows == 0 {
|
||||
return errors.Newf(errors.TypeNotFound, systemdashboardtypes.ErrCodeSystemDashboardNotFound, "system dashboard %s is not provisioned", name)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
|
||||
return store.sqlstore.RunInTxCtx(ctx, nil, cb)
|
||||
}
|
||||
28
pkg/modules/systemdashboard/systemdashboard.go
Normal file
@@ -0,0 +1,28 @@
|
||||
package systemdashboard
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
|
||||
"github.com/SigNoz/signoz/pkg/valuer"
|
||||
)
|
||||
|
||||
type Module interface {
|
||||
// Reconcile provisions the org's missing system dashboards and upgrades the
|
||||
// unmodified ones to the shipped version. It never touches a dashboard whose
|
||||
// row carries a foreign write and it never deletes.
|
||||
Reconcile(ctx context.Context, orgID valuer.UUID) error
|
||||
|
||||
// Get addresses the dashboard by its bare definition name; the reserved
|
||||
// prefix is a storage concern the API never exposes.
|
||||
Get(ctx context.Context, orgID valuer.UUID, name string) (*dashboardtypes.DashboardV2, error)
|
||||
|
||||
// ResolveID maps a system dashboard's name to its id, so routes addressed by
|
||||
// name can be authz-checked and audited against the id tuples carry.
|
||||
ResolveID(ctx context.Context, orgID valuer.UUID, name string) (valuer.UUID, error)
|
||||
}
|
||||
|
||||
type Handler interface {
|
||||
Get(http.ResponseWriter, *http.Request)
|
||||
}
|
||||
@@ -46,6 +46,8 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
|
||||
@@ -88,6 +90,7 @@ type Handlers struct {
|
||||
RulerHandler ruler.Handler
|
||||
LLMPricingRuleHandler llmpricingrule.Handler
|
||||
StatsHandler statsreporter.Handler
|
||||
SystemDashboard systemdashboard.Handler
|
||||
}
|
||||
|
||||
func NewHandlers(
|
||||
@@ -137,5 +140,6 @@ func NewHandlers(
|
||||
RulerHandler: signozruler.NewHandler(rulerService),
|
||||
LLMPricingRuleHandler: impllmpricingrule.NewHandler(modules.LLMPricingRule),
|
||||
StatsHandler: statsreporter.NewHandler(statsAggregator),
|
||||
SystemDashboard: implsystemdashboard.NewHandler(modules.SystemDashboard),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,7 @@ func TestNewHandlers(t *testing.T) {
|
||||
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, nil, retentionGetter, flagger, tagModule, nil, nil)
|
||||
|
||||
querierHandler := querier.NewHandler(providerSettings, nil, nil)
|
||||
registryHandler := factory.NewHandler(nil)
|
||||
|
||||
@@ -48,6 +48,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
|
||||
@@ -67,35 +68,36 @@ import (
|
||||
)
|
||||
|
||||
type Modules struct {
|
||||
OrgGetter organization.Getter
|
||||
OrgSetter organization.Setter
|
||||
Preference preference.Module
|
||||
UserSetter user.Setter
|
||||
UserGetter user.Getter
|
||||
RetentionGetter retention.Getter
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
Dashboard dashboard.Module
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
RawDataExport rawdataexport.Module
|
||||
AuthDomain authdomain.Module
|
||||
Session session.Module
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
OrgGetter organization.Getter
|
||||
OrgSetter organization.Setter
|
||||
Preference preference.Module
|
||||
UserSetter user.Setter
|
||||
UserGetter user.Getter
|
||||
RetentionGetter retention.Getter
|
||||
SavedView savedview.Module
|
||||
Apdex apdex.Module
|
||||
Dashboard dashboard.Module
|
||||
QuickFilter quickfilter.Module
|
||||
TraceFunnel tracefunnel.Module
|
||||
RawDataExport rawdataexport.Module
|
||||
AuthDomain authdomain.Module
|
||||
Session session.Module
|
||||
Services services.Module
|
||||
SpanPercentile spanpercentile.Module
|
||||
MetricsExplorer metricsexplorer.Module
|
||||
MetricReductionRule metricreductionrule.Module
|
||||
InfraMonitoring inframonitoring.Module
|
||||
Promote promote.Module
|
||||
ServiceAccount serviceaccount.Module
|
||||
ServiceAccountGetter serviceaccount.Getter
|
||||
CloudIntegration cloudintegration.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
LogsPipeline logspipeline.Module
|
||||
RuleStateHistory rulestatehistory.Module
|
||||
TraceDetail tracedetail.Module
|
||||
SpanMapper spanmapper.Module
|
||||
LLMPricingRule llmpricingrule.Module
|
||||
Tag tag.Module
|
||||
SystemDashboard systemdashboard.Module
|
||||
}
|
||||
|
||||
func NewModules(
|
||||
@@ -124,9 +126,10 @@ func NewModules(
|
||||
fl flagger.Flagger,
|
||||
tagModule tag.Module,
|
||||
metricReductionRule metricreductionrule.Module,
|
||||
systemDashboard systemdashboard.Module,
|
||||
) Modules {
|
||||
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
|
||||
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter, systemDashboard)
|
||||
// Cleanup callbacks from other modules, invoked when a user is deleted.
|
||||
onDeleteUser := []user.OnDeleteUser{
|
||||
dashboard.DeletePreferencesForUser,
|
||||
@@ -136,34 +139,35 @@ func NewModules(
|
||||
authDomainModule := implauthdomain.NewModule(implauthdomain.NewStore(sqlstore), authNs, authz)
|
||||
|
||||
return Modules{
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
OrgGetter: orgGetter,
|
||||
OrgSetter: orgSetter,
|
||||
Preference: implpreference.NewModule(implpreference.NewStore(sqlstore), preferencetypes.NewAvailablePreference()),
|
||||
SavedView: implsavedview.NewModule(implsavedview.NewStore(sqlstore)),
|
||||
Apdex: implapdex.NewModule(sqlstore),
|
||||
Dashboard: dashboard,
|
||||
UserSetter: userSetter,
|
||||
UserGetter: userGetter,
|
||||
RetentionGetter: retentionGetter,
|
||||
QuickFilter: quickfilter,
|
||||
TraceFunnel: impltracefunnel.NewModule(impltracefunnel.NewStore(sqlstore)),
|
||||
RawDataExport: implrawdataexport.NewModule(querier),
|
||||
AuthDomain: authDomainModule,
|
||||
Session: implsession.NewModule(providerSettings, authNs, userSetter, userGetter, authDomainModule, tokenizer, orgGetter, authz, config.Global),
|
||||
SpanPercentile: implspanpercentile.NewModule(querier, providerSettings),
|
||||
Services: implservices.NewModule(querier, telemetryStore),
|
||||
MetricsExplorer: implmetricsexplorer.NewModule(telemetryStore, telemetryMetadataStore, cache, ruleStore, dashboard, fl, providerSettings, config.MetricsExplorer),
|
||||
MetricReductionRule: metricReductionRule,
|
||||
InfraMonitoring: implinframonitoring.NewModule(telemetryStore, telemetryMetadataStore, querier, fl, providerSettings, config.InfraMonitoring),
|
||||
Promote: implpromote.NewModule(telemetryMetadataStore, telemetryStore),
|
||||
ServiceAccount: serviceAccount,
|
||||
ServiceAccountGetter: serviceAccountGetter,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
LogsPipeline: impllogspipeline.NewModule(sqlstore),
|
||||
RuleStateHistory: implrulestatehistory.NewModule(implrulestatehistory.NewStore(telemetryStore, telemetryMetadataStore, providerSettings.Logger), ruleStore),
|
||||
CloudIntegration: cloudIntegrationModule,
|
||||
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
|
||||
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore), fl),
|
||||
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore), fl, querier),
|
||||
Tag: tagModule,
|
||||
SystemDashboard: systemDashboard,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
"github.com/SigNoz/signoz/pkg/queryparser"
|
||||
@@ -66,7 +67,12 @@ func TestNewModules(t *testing.T) {
|
||||
|
||||
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule())
|
||||
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
|
||||
require.NoError(t, err)
|
||||
|
||||
systemDashboard := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboardModule)
|
||||
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule, implmetricreductionrule.NewModule(), systemDashboard)
|
||||
|
||||
reflectVal := reflect.ValueOf(modules)
|
||||
for i := 0; i < reflectVal.NumField(); i++ {
|
||||
|
||||
@@ -35,6 +35,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/session"
|
||||
"github.com/SigNoz/signoz/pkg/modules/spanmapper"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
"github.com/SigNoz/signoz/pkg/querier"
|
||||
@@ -64,6 +65,7 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ user.Handler }{},
|
||||
struct{ session.Handler }{},
|
||||
struct{ authdomain.Handler }{},
|
||||
struct{ authdomain.Module }{},
|
||||
struct{ preference.Handler }{},
|
||||
struct{ global.Handler }{},
|
||||
struct{ promote.Handler }{},
|
||||
@@ -92,6 +94,8 @@ func NewOpenAPI(ctx context.Context, instrumentation instrumentation.Instrumenta
|
||||
struct{ ruler.Handler }{},
|
||||
struct{ statsreporter.Handler }{},
|
||||
struct{ savedview.Handler }{},
|
||||
struct{ systemdashboard.Module }{},
|
||||
struct{ systemdashboard.Handler }{},
|
||||
).New(ctx, instrumentation.ToProviderSettings(), apiserver.Config{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -243,6 +243,8 @@ func NewSQLMigrationProviderFactories(
|
||||
sqlmigration.NewFixSavedViewSelectFieldsFactory(sqlstore),
|
||||
sqlmigration.NewDeleteOrphanUserRolesFactory(),
|
||||
sqlmigration.NewMigrateLambdaDashboardsFactory(),
|
||||
sqlmigration.NewAddAuthDomainTuplesFactory(sqlstore),
|
||||
sqlmigration.NewAddSystemDashboardFactory(sqlstore, sqlschema),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -317,6 +319,7 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
impluser.NewHandler(modules.UserSetter, modules.UserGetter),
|
||||
implsession.NewHandler(modules.Session, globalConfig),
|
||||
implauthdomain.NewHandler(modules.AuthDomain),
|
||||
modules.AuthDomain,
|
||||
implpreference.NewHandler(modules.Preference),
|
||||
handlers.Global,
|
||||
implpromote.NewHandler(modules.Promote),
|
||||
@@ -345,6 +348,8 @@ func NewAPIServerProviderFactories(orgGetter organization.Getter, authz authz.Au
|
||||
handlers.RulerHandler,
|
||||
handlers.StatsHandler,
|
||||
handlers.SavedView,
|
||||
modules.SystemDashboard,
|
||||
handlers.SystemDashboard,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ import (
|
||||
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
|
||||
"github.com/SigNoz/signoz/pkg/modules/systemdashboard/implsystemdashboard"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
|
||||
@@ -540,8 +541,16 @@ func New(
|
||||
|
||||
metricReductionRuleModule := metricReductionRuleModuleCallback(sqlstore, telemetrystore, dashboard, queryParser, licensing, flagger, telemetryMetadataStore, providerSettings, config.MetricsExplorer.TelemetryStore.Threads)
|
||||
|
||||
// Initialize the system dashboard module. The registry is parsed here so a
|
||||
// malformed embedded definition fails startup instead of a request.
|
||||
systemDashboardRegistry, err := implsystemdashboard.NewRegistry()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
systemDashboardModule := implsystemdashboard.NewModule(providerSettings, implsystemdashboard.NewStore(sqlstore), systemDashboardRegistry, dashboard)
|
||||
|
||||
// Initialize all modules
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule)
|
||||
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, serviceAccountGetter, cloudIntegrationModule, retentionGetter, flagger, tagModule, metricReductionRuleModule, systemDashboardModule)
|
||||
|
||||
// Initialize ruler from the variant-specific provider factories
|
||||
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")
|
||||
@@ -610,6 +619,7 @@ func New(
|
||||
factory.NewNamedService(factory.MustNewName("auditor"), auditor),
|
||||
factory.NewNamedService(factory.MustNewName("meterreporter"), meterReporter, factory.MustNewName("licensing")),
|
||||
factory.NewNamedService(factory.MustNewName("ruler"), rulerInstance),
|
||||
factory.NewNamedService(factory.MustNewName("systemdashboard"), implsystemdashboard.NewService(providerSettings, systemDashboardModule, orgGetter)),
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
167
pkg/sqlmigration/117_add_auth_domain_tuples.go
Normal file
@@ -0,0 +1,167 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/types/coretypes"
|
||||
"github.com/oklog/ulid/v2"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/dialect"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addAuthDomainTuples struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
}
|
||||
|
||||
func NewAddAuthDomainTuplesFactory(sqlstore sqlstore.SQLStore) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(factory.MustNewName("add_auth_domain_tuples"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addAuthDomainTuples{sqlstore: sqlstore}, nil
|
||||
})
|
||||
}
|
||||
|
||||
func (migration *addAuthDomainTuples) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addAuthDomainTuples) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var storeID string
|
||||
err = tx.QueryRowContext(ctx, `SELECT id FROM store WHERE name = ? LIMIT 1`, "signoz").Scan(&storeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var orgIDs []string
|
||||
err = tx.NewSelect().
|
||||
Table("organizations").
|
||||
Column("id").
|
||||
Scan(ctx, &orgIDs)
|
||||
if err != nil && err != sql.ErrNoRows {
|
||||
return err
|
||||
}
|
||||
|
||||
isPG := migration.sqlstore.BunDB().Dialect().Name() == dialect.PG
|
||||
|
||||
// auth-domain moved from the legacy AdminAccess role gate to CheckResources,
|
||||
// which on enterprise requires real tuples -- existing orgs never had these
|
||||
// written, only new orgs get them from the registry at bootstrap.
|
||||
tuples := []migrationTuple{
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "create"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "read"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "update"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "delete"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "list"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "attach"},
|
||||
{authtypes.SigNozAdminRoleName, "metaresource", "auth-domain", "detach"},
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for _, tuple := range tuples {
|
||||
entropy := ulid.DefaultEntropy()
|
||||
now := time.Now().UTC()
|
||||
tupleID := ulid.MustNew(ulid.Timestamp(now), entropy).String()
|
||||
|
||||
objectID := "organization/" + orgID + "/" + tuple.objectName + "/*"
|
||||
roleSubject := "organization/" + orgID + "/role/" + tuple.roleName
|
||||
|
||||
if isPG {
|
||||
user := "role:" + roleSubject + "#assignee"
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, _user, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, _user) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, _user, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, user, 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
result, err := tx.ExecContext(ctx, `
|
||||
INSERT INTO tuple (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, user_type, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", "userset", tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rowsAffected, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if rowsAffected == 0 {
|
||||
continue
|
||||
}
|
||||
_, err = tx.ExecContext(ctx, `
|
||||
INSERT INTO changelog (store, object_type, object_id, relation, user_object_type, user_object_id, user_relation, operation, ulid, inserted_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT (store, ulid, object_type) DO NOTHING`,
|
||||
storeID, tuple.objectType, objectID, tuple.relation, "role", roleSubject, "assignee", 0, tupleID, now,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The auth-domain attach/detach transactions are new to the registry, so the
|
||||
// managed-role transaction groups stored per org (served by the roles API)
|
||||
// must be re-synced from it, like 105_update_role_transaction_groups did.
|
||||
managedRoleGroups := make(map[string]string, len(coretypes.ManagedRoleToTransactions))
|
||||
for roleName, transactions := range coretypes.ManagedRoleToTransactions {
|
||||
data, err := json.Marshal(authtypes.NewTransactionGroupsFromTransactions(transactions))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
managedRoleGroups[roleName] = string(data)
|
||||
}
|
||||
|
||||
for _, orgID := range orgIDs {
|
||||
for roleName, data := range managedRoleGroups {
|
||||
if _, err := tx.NewUpdate().
|
||||
Model(new(roles)).
|
||||
Set("transaction_groups = ?", data).
|
||||
Where("org_id = ?", orgID).
|
||||
Where("type = ?", authtypes.RoleTypeManaged.StringValue()).
|
||||
Where("name = ?", roleName).
|
||||
Exec(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addAuthDomainTuples) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||
93
pkg/sqlmigration/118_add_system_dashboard.go
Normal file
@@ -0,0 +1,93 @@
|
||||
package sqlmigration
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun"
|
||||
"github.com/uptrace/bun/migrate"
|
||||
)
|
||||
|
||||
type addSystemDashboard struct {
|
||||
sqlstore sqlstore.SQLStore
|
||||
sqlschema sqlschema.SQLSchema
|
||||
}
|
||||
|
||||
func NewAddSystemDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
|
||||
return factory.NewProviderFactory(
|
||||
factory.MustNewName("add_system_dashboard"),
|
||||
func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
|
||||
return &addSystemDashboard{sqlstore: sqlstore, sqlschema: sqlschema}, nil
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Register(migrations *migrate.Migrations) error {
|
||||
return migrations.Register(migration.Up, migration.Down)
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Up(ctx context.Context, db *bun.DB) error {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
|
||||
Name: "system_dashboard",
|
||||
Columns: []*sqlschema.Column{
|
||||
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
|
||||
{Name: "version", DataType: sqlschema.DataTypeBigInt, Nullable: false},
|
||||
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
|
||||
},
|
||||
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{
|
||||
ColumnNames: []sqlschema.ColumnName{"id"},
|
||||
},
|
||||
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("org_id"),
|
||||
ReferencedTableName: sqlschema.TableName("organizations"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
{
|
||||
ReferencingColumnName: sqlschema.ColumnName("dashboard_id"),
|
||||
ReferencedTableName: sqlschema.TableName("dashboard"),
|
||||
ReferencedColumnName: sqlschema.ColumnName("id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
// (org_id, name) is what makes provisioning safe across replicas: the state
|
||||
// row is written in the same transaction as the dashboard, so a losing racer
|
||||
// rolls back its dashboard too.
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "system_dashboard",
|
||||
ColumnNames: []sqlschema.ColumnName{"org_id", "name"},
|
||||
},
|
||||
)...)
|
||||
sqls = append(sqls, migration.sqlschema.Operator().CreateIndex(
|
||||
&sqlschema.UniqueIndex{
|
||||
TableName: "system_dashboard",
|
||||
ColumnNames: []sqlschema.ColumnName{"dashboard_id"},
|
||||
},
|
||||
)...)
|
||||
|
||||
for _, sql := range sqls {
|
||||
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (migration *addSystemDashboard) Down(context.Context, *bun.DB) error {
|
||||
return nil
|
||||
}
|
||||