Compare commits

..

32 Commits

Author SHA1 Message Date
Nikhil Mantri
dc9c127925 Merge branch 'infraM/pod_status_restart_counts' into infraM/goroutines_for_concurrent_queries 2026-07-06 18:01:41 +05:30
Nikhil Mantri
612ffd8cf7 Merge branch 'main' into infraM/pod_status_restart_counts 2026-07-06 16:51:47 +05:30
nikhilmantri0902
babb2e2cd1 chore: added goroutines for ListStatefulsets, ListJobs and ListDaemonsets 2026-07-06 12:51:07 +05:30
nikhilmantri0902
0bd0f7ada9 chore: goroutines for ListStatefulsets 2026-07-06 12:45:31 +05:30
nikhilmantri0902
66b40b5509 chore: goroutines for ListDeployments 2026-07-06 12:43:48 +05:30
nikhilmantri0902
77e56eea8c chore: goroutines for ListClusters 2026-07-06 12:34:59 +05:30
nikhilmantri0902
c4448c9940 chore: goroutines for ListNamespaces 2026-07-06 12:33:52 +05:30
nikhilmantri0902
60c33003b8 chore: goroutines for ListNodes 2026-07-06 12:30:31 +05:30
nikhilmantri0902
a126cee885 chore: goroutines for ListPods 2026-07-06 12:26:59 +05:30
nikhilmantri0902
916466d7d1 chore: goroutines for ListHosts 2026-07-06 12:19:15 +05:30
nikhilmantri0902
11fa6bbb10 chore: removed dead constants 2026-07-03 20:02:14 +05:30
Nikhil Mantri
b1136348c4 Merge branch 'main' into infraM/pod_status_restart_counts 2026-07-02 20:07:56 +05:30
nikhilmantri0902
3bdff5a083 chore: corrected metrics list for metadata lookup 2026-07-02 17:58:54 +05:30
nikhilmantri0902
76eccec91c chore: query beautified 2026-07-02 16:48:53 +05:30
Nikhil Mantri
4d94a4090d Merge branch 'main' into infraM/pod_status_restart_counts 2026-07-01 16:54:00 +05:30
nikhilmantri0902
406f676e7c chore: removed succeeded status and mark it as completed 2026-07-01 16:53:04 +05:30
Nikhil Mantri
73c267e3f7 Merge branch 'main' into infraM/pod_status_restart_counts 2026-06-30 16:39:48 +05:30
nikhilmantri0902
e7fbf89d4a chore: rearrangement 2026-06-30 10:48:00 +05:30
Nikhil Mantri
f08cea5491 Merge branch 'main' into infraM/pod_status_restart_counts 2026-06-29 21:30:14 +05:30
nikhilmantri0902
79160910d1 chore: added checks api changes for other entities 2026-06-29 21:25:14 +05:30
nikhilmantri0902
fdcdd935a7 chore: added integration tests for other entities 2026-06-29 21:02:19 +05:30
nikhilmantri0902
2c4a661edb chore: added pod status counts for other entities 2026-06-29 20:38:05 +05:30
nikhilmantri0902
20ddd8df8c chore: added podcountsbyphase in other entities 2026-06-29 20:18:04 +05:30
nikhilmantri0902
744c5cf587 chore: integration tests added 2026-06-29 18:56:50 +05:30
nikhilmantri0902
d4ddfceba5 chore: onboarding API changes 2026-06-29 17:46:44 +05:30
nikhilmantri0902
c752131fe0 chore: bug in query fix 2026-06-29 17:30:02 +05:30
nikhilmantri0902
223c2a6c31 chore: added restart counts for the group 2026-06-29 16:08:35 +05:30
nikhilmantri0902
6bf20c5e93 chore: pod restarts type added 2026-06-29 16:01:14 +05:30
nikhilmantri0902
09fce10c1d chore: wired up pod status counts 2026-06-29 15:28:54 +05:30
nikhilmantri0902
8e6891cf58 chore: per group pod status counts with req metric checks method added 2026-06-29 15:14:41 +05:30
nikhilmantri0902
49019f44b6 chore: added method to calculate reason 2026-06-29 15:00:33 +05:30
nikhilmantri0902
075611fc6c chore: added types and open api spec changes 2026-06-29 14:00:52 +05:30
157 changed files with 2697 additions and 5764 deletions

View File

@@ -515,13 +515,6 @@ components:
url:
type: string
type: object
AuthtypesDeprecatedPostableUserRole:
properties:
id:
type: string
required:
- id
type: object
AuthtypesGettableAuthDomain:
properties:
authNProviderInfo:
@@ -667,20 +660,17 @@ components:
type: string
userRoles:
items:
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
$ref: '#/components/schemas/AuthtypesPostableUserRole'
type: array
required:
- email
type: object
AuthtypesPostableUserRole:
properties:
roleId:
type: string
userId:
id:
type: string
required:
- userId
- roleId
- id
type: object
AuthtypesRelation:
enum:
@@ -4261,6 +4251,8 @@ components:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- clusterName
- clusterCPU
@@ -4269,6 +4261,7 @@ components:
- clusterMemoryAllocatable
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesClusters:
@@ -4324,6 +4317,8 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- daemonSetName
- daemonSetCPU
@@ -4335,6 +4330,7 @@ components:
- desiredNodes
- currentNodes
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDaemonSets:
@@ -4390,6 +4386,8 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- deploymentName
- deploymentCPU
@@ -4401,6 +4399,7 @@ components:
- desiredPods
- availablePods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesDeployments:
@@ -4533,6 +4532,8 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
successfulPods:
type: integer
required:
@@ -4548,6 +4549,7 @@ components:
- failedPods
- successfulPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesJobs:
@@ -4638,11 +4640,14 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- namespaceName
- namespaceCPU
- namespaceMemory
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesNamespaces:
@@ -4708,11 +4713,14 @@ components:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- podCountsByStatus
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
@@ -4758,6 +4766,64 @@ components:
- failed
- unknown
type: object
InframonitoringtypesPodCountsByStatus:
properties:
completed:
type: integer
containerCannotRun:
type: integer
containerCreating:
type: integer
crashLoopBackOff:
type: integer
createContainerConfigError:
type: integer
errImagePull:
type: integer
error:
type: integer
evicted:
type: integer
failed:
type: integer
imagePullBackOff:
type: integer
nodeAffinity:
type: integer
nodeLost:
type: integer
oomKilled:
type: integer
pending:
type: integer
running:
type: integer
shutdown:
type: integer
unexpectedAdmissionError:
type: integer
unknown:
type: integer
required:
- pending
- running
- failed
- unknown
- crashLoopBackOff
- imagePullBackOff
- errImagePull
- createContainerConfigError
- containerCreating
- oomKilled
- completed
- error
- containerCannotRun
- evicted
- nodeAffinity
- nodeLost
- shutdown
- unexpectedAdmissionError
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -4788,6 +4854,8 @@ components:
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
podMemory:
format: double
type: number
@@ -4799,6 +4867,11 @@ components:
type: number
podPhase:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podRestarts:
format: int64
type: integer
podStatus:
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
podUID:
type: string
required:
@@ -4811,9 +4884,34 @@ components:
- podMemoryLimit
- podPhase
- podCountsByPhase
- podStatus
- podCountsByStatus
- podRestarts
- podAge
- meta
type: object
InframonitoringtypesPodStatus:
enum:
- pending
- running
- failed
- unknown
- crashloopbackoff
- imagepullbackoff
- errimagepull
- createcontainerconfigerror
- containercreating
- oomkilled
- completed
- error
- containercannotrun
- evicted
- nodeaffinity
- nodelost
- shutdown
- unexpectedadmissionerror
- no_data
type: string
InframonitoringtypesPods:
properties:
endTimeBeforeRetention:
@@ -5112,6 +5210,8 @@ components:
type: object
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podCountsByStatus:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
statefulSetCPU:
format: double
type: number
@@ -5143,6 +5243,7 @@ components:
- desiredPods
- currentPods
- podCountsByPhase
- podCountsByStatus
- meta
type: object
InframonitoringtypesStatefulSets:
@@ -7440,13 +7541,6 @@ components:
enum:
- basic
type: string
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
properties:
id:
type: string
required:
- id
type: object
ServiceaccounttypesGettableFactorAPIKey:
properties:
createdAt:
@@ -7503,13 +7597,10 @@ components:
type: object
ServiceaccounttypesPostableServiceAccountRole:
properties:
roleId:
type: string
serviceAccountId:
id:
type: string
required:
- serviceAccountId
- roleId
- id
type: object
ServiceaccounttypesServiceAccount:
properties:
@@ -12272,188 +12363,6 @@ paths:
summary: Update route policy
tags:
- routepolicies
/api/v1/service_account_roles:
post:
deprecated: false
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
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
"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:
- serviceaccount:attach
- role:attach
- tokenizer:
- serviceaccount:attach
- role:attach
summary: Create service account role
tags:
- serviceaccount
/api/v1/service_account_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a service account
operationId: DeleteServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- serviceaccount:detach
- role:detach
- tokenizer:
- serviceaccount:detach
- role:detach
summary: Delete service account role
tags:
- serviceaccount
get:
deprecated: false
description: This endpoint gets an existing service account role
operationId: GetServiceAccountRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
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:
- serviceaccount:read
- tokenizer:
- serviceaccount:read
summary: Get service account role
tags:
- serviceaccount
/api/v1/service_accounts:
get:
deprecated: false
@@ -13023,9 +12932,9 @@ paths:
tags:
- serviceaccount
post:
deprecated: true
deprecated: false
description: This endpoint assigns a role to a service account
operationId: CreateServiceAccountRoleDeprecated
operationId: CreateServiceAccountRole
parameters:
- in: path
name: id
@@ -13036,7 +12945,7 @@ paths:
content:
application/json:
schema:
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
responses:
"201":
content:
@@ -13088,9 +12997,9 @@ paths:
- serviceaccount
/api/v1/service_accounts/{id}/roles/{rid}:
delete:
deprecated: true
deprecated: false
description: This endpoint revokes a role from service account
operationId: DeleteServiceAccountRoleDeprecated
operationId: DeleteServiceAccountRole
parameters:
- in: path
name: id
@@ -22192,184 +22101,6 @@ paths:
summary: Rotate session
tags:
- sessions
/api/v2/user_roles:
post:
deprecated: false
description: This endpoint assigns a role to a user
operationId: CreateUserRole
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AuthtypesPostableUserRole'
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
"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:
- ADMIN
- tokenizer:
- ADMIN
summary: Create user role
tags:
- users
/api/v2/user_roles/{id}:
delete:
deprecated: false
description: This endpoint revokes a role from a user
operationId: DeleteUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete user role
tags:
- users
get:
deprecated: false
description: This endpoint gets an existing user role
operationId: GetUserRole
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AuthtypesUserRole'
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:
- ADMIN
- tokenizer:
- ADMIN
summary: Get user role
tags:
- users
/api/v2/users:
get:
deprecated: false
@@ -22823,7 +22554,7 @@ paths:
tags:
- users
post:
deprecated: true
deprecated: false
description: This endpoint assigns the role to the user roles by user id
operationId: SetRoleByUserID
parameters:
@@ -22874,7 +22605,7 @@ paths:
- users
/api/v2/users/{id}/roles/{roleId}:
delete:
deprecated: true
deprecated: false
description: This endpoint removes a role from the user by user id and role
id
operationId: RemoveUserRoleByUserIDAndRoleID

View File

@@ -519,7 +519,7 @@ func (module *module) getOrCreateAPIKey(ctx context.Context, orgID valuer.UUID,
if err != nil {
return "", err
}
_, err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
err = module.serviceAccount.SetRoleByName(ctx, orgID, serviceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return "", err
}

View File

@@ -141,7 +141,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationServiceAccount(ctx context.Cont
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}
_, err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
err = ah.Signoz.Modules.ServiceAccount.SetRoleByName(ctx, orgId, cloudIntegrationServiceAccount.ID, authtypes.SigNozViewerRoleName)
if err != nil {
return nil, basemodel.InternalError(fmt.Errorf("couldn't create cloud integration service account: %w", err))
}

View File

@@ -22,16 +22,12 @@ import type {
CreateServiceAccountKey201,
CreateServiceAccountKeyPathParameters,
CreateServiceAccountRole201,
CreateServiceAccountRoleDeprecated201,
CreateServiceAccountRoleDeprecatedPathParameters,
CreateServiceAccountRolePathParameters,
DeleteServiceAccountPathParameters,
DeleteServiceAccountRoleDeprecatedPathParameters,
DeleteServiceAccountRolePathParameters,
GetMyServiceAccount200,
GetServiceAccount200,
GetServiceAccountPathParameters,
GetServiceAccountRole200,
GetServiceAccountRolePathParameters,
GetServiceAccountRoles200,
GetServiceAccountRolesPathParameters,
ListServiceAccountKeys200,
@@ -39,7 +35,6 @@ import type {
ListServiceAccounts200,
RenderErrorResponseDTO,
RevokeServiceAccountKeyPathParameters,
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
ServiceaccounttypesPostableFactorAPIKeyDTO,
ServiceaccounttypesPostableServiceAccountDTO,
ServiceaccounttypesPostableServiceAccountRoleDTO,
@@ -51,272 +46,6 @@ import type {
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
/**
* This endpoint assigns a role to a service account
* @summary Create service account role
*/
export const createServiceAccountRole = (
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRole201>({
url: `/api/v1/service_account_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
const mutationKey = ['createServiceAccountRole'];
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 createServiceAccountRole>>,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createServiceAccountRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create service account role
*/
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
TContext
> => {
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a service account
* @summary Delete service account role
*/
export const deleteServiceAccountRole = (
{ id }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v1/service_account_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRole'];
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 deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
>;
export type DeleteServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete service account role
*/
export const useDeleteServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint gets an existing service account role
* @summary Get service account role
*/
export const getServiceAccountRole = (
{ id }: GetServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetServiceAccountRole200>({
url: `/api/v1/service_account_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetServiceAccountRoleQueryKey = ({
id,
}: GetServiceAccountRolePathParameters) => {
return [`/api/v1/service_account_roles/${id}`] as const;
};
export const getGetServiceAccountRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getServiceAccountRole>>
> = ({ signal }) => getServiceAccountRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetServiceAccountRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getServiceAccountRole>>
>;
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get service account role
*/
export function useGetServiceAccountRole<
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetServiceAccountRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getServiceAccountRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get service account role
*/
export const invalidateGetServiceAccountRole = async (
queryClient: QueryClient,
{ id }: GetServiceAccountRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint lists the service accounts for an organisation
* @summary List service accounts
@@ -1255,46 +984,45 @@ export const invalidateGetServiceAccountRoles = async (
/**
* This endpoint assigns a role to a service account
* @deprecated
* @summary Create service account role
*/
export const createServiceAccountRoleDeprecated = (
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
export const createServiceAccountRole = (
{ id }: CreateServiceAccountRolePathParameters,
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
return GeneratedAPIInstance<CreateServiceAccountRole201>({
url: `/api/v1/service_accounts/${id}/roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
data: serviceaccounttypesPostableServiceAccountRoleDTO,
signal,
});
};
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
export const getCreateServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
const mutationKey = ['createServiceAccountRoleDeprecated'];
const mutationKey = ['createServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1304,66 +1032,62 @@ export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createServiceAccountRoleDeprecated(pathParams, data);
return createServiceAccountRole(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
export type CreateServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createServiceAccountRole>>
>;
export type CreateServiceAccountRoleDeprecatedMutationBody =
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
export type CreateServiceAccountRoleMutationBody =
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
| undefined;
export type CreateServiceAccountRoleDeprecatedMutationError =
export type CreateServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Create service account role
*/
export const useCreateServiceAccountRoleDeprecated = <
export const useCreateServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof createServiceAccountRole>>,
TError,
{
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
pathParams: CreateServiceAccountRolePathParameters;
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
},
TContext
> => {
return useMutation(
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
);
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from service account
* @deprecated
* @summary Delete service account role
*/
export const deleteServiceAccountRoleDeprecated = (
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
export const deleteServiceAccountRole = (
{ id, rid }: DeleteServiceAccountRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
@@ -1373,23 +1097,23 @@ export const deleteServiceAccountRoleDeprecated = (
});
};
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
export const getDeleteServiceAccountRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
const mutationKey = ['deleteServiceAccountRole'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
@@ -1399,47 +1123,44 @@ export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
{ pathParams: DeleteServiceAccountRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteServiceAccountRoleDeprecated(pathParams);
return deleteServiceAccountRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
export type DeleteServiceAccountRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteServiceAccountRole>>
>;
export type DeleteServiceAccountRoleDeprecatedMutationError =
export type DeleteServiceAccountRoleMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Delete service account role
*/
export const useDeleteServiceAccountRoleDeprecated = <
export const useDeleteServiceAccountRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
TError,
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
{ pathParams: DeleteServiceAccountRolePathParameters },
TContext
> => {
return useMutation(
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
);
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
};
/**
* This endpoint gets my service account

View File

@@ -2048,13 +2048,6 @@ export interface AuthtypesAuthNSupportDTO {
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
}
export interface AuthtypesDeprecatedPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesGettableAuthDomainDTO {
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
config?: AuthtypesAuthDomainConfigDTO;
@@ -2294,6 +2287,13 @@ export interface AuthtypesPostableRotateTokenDTO {
refreshToken?: string;
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
id: string;
}
export interface AuthtypesPostableUserDTO {
/**
* @type string
@@ -2310,18 +2310,7 @@ export interface AuthtypesPostableUserDTO {
/**
* @type array
*/
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
}
export interface AuthtypesPostableUserRoleDTO {
/**
* @type string
*/
roleId: string;
/**
* @type string
*/
userId: string;
userRoles?: AuthtypesPostableUserRoleDTO[];
}
export interface AuthtypesRoleDTO {
@@ -5704,6 +5693,81 @@ export interface InframonitoringtypesPodCountsByPhaseDTO {
unknown: number;
}
export interface InframonitoringtypesPodCountsByStatusDTO {
/**
* @type integer
*/
completed: number;
/**
* @type integer
*/
containerCannotRun: number;
/**
* @type integer
*/
containerCreating: number;
/**
* @type integer
*/
crashLoopBackOff: number;
/**
* @type integer
*/
createContainerConfigError: number;
/**
* @type integer
*/
errImagePull: number;
/**
* @type integer
*/
error: number;
/**
* @type integer
*/
evicted: number;
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
imagePullBackOff: number;
/**
* @type integer
*/
nodeAffinity: number;
/**
* @type integer
*/
nodeLost: number;
/**
* @type integer
*/
oomKilled: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
shutdown: number;
/**
* @type integer
*/
unexpectedAdmissionError: number;
/**
* @type integer
*/
unknown: number;
}
export interface InframonitoringtypesClusterRecordDTO {
/**
* @type number
@@ -5735,6 +5799,7 @@ export interface InframonitoringtypesClusterRecordDTO {
meta: InframonitoringtypesClusterRecordDTOMeta;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export enum InframonitoringtypesResponseTypeDTO {
@@ -5838,6 +5903,7 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDaemonSetsDTO {
@@ -5915,6 +5981,7 @@ export interface InframonitoringtypesDeploymentRecordDTO {
*/
meta: InframonitoringtypesDeploymentRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesDeploymentsDTO {
@@ -6081,6 +6148,7 @@ export interface InframonitoringtypesJobRecordDTO {
*/
meta: InframonitoringtypesJobRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type integer
*/
@@ -6134,6 +6202,7 @@ export interface InframonitoringtypesNamespaceRecordDTO {
*/
namespaceName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNamespacesDTO {
@@ -6200,6 +6269,7 @@ export interface InframonitoringtypesNodeRecordDTO {
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
}
export interface InframonitoringtypesNodesDTO {
@@ -6237,6 +6307,27 @@ export type InframonitoringtypesPodRecordDTOMetaAnyOf = {
export type InframonitoringtypesPodRecordDTOMeta =
InframonitoringtypesPodRecordDTOMetaAnyOf | null;
export enum InframonitoringtypesPodStatusDTO {
pending = 'pending',
running = 'running',
failed = 'failed',
unknown = 'unknown',
crashloopbackoff = 'crashloopbackoff',
imagepullbackoff = 'imagepullbackoff',
errimagepull = 'errimagepull',
createcontainerconfigerror = 'createcontainerconfigerror',
containercreating = 'containercreating',
oomkilled = 'oomkilled',
completed = 'completed',
error = 'error',
containercannotrun = 'containercannotrun',
evicted = 'evicted',
nodeaffinity = 'nodeaffinity',
nodelost = 'nodelost',
shutdown = 'shutdown',
unexpectedadmissionerror = 'unexpectedadmissionerror',
no_data = 'no_data',
}
export interface InframonitoringtypesPodRecordDTO {
/**
* @type object,null
@@ -6263,6 +6354,7 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -6279,6 +6371,12 @@ export interface InframonitoringtypesPodRecordDTO {
*/
podMemoryRequest: number;
podPhase: InframonitoringtypesPodPhaseDTO;
/**
* @type integer
* @format int64
*/
podRestarts: number;
podStatus: InframonitoringtypesPodStatusDTO;
/**
* @type string
*/
@@ -6596,6 +6694,7 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
*/
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
/**
* @type number
* @format double
@@ -8519,13 +8618,6 @@ export interface RuletypesRuleDTO {
export enum RuletypesThresholdKindDTO {
basic = 'basic',
}
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
/**
* @type string
*/
id: string;
}
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
/**
* @type string
@@ -8595,11 +8687,7 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
/**
* @type string
*/
roleId: string;
/**
* @type string
*/
serviceAccountId: string;
id: string;
}
export interface ServiceaccounttypesServiceAccountDTO {
@@ -10365,28 +10453,6 @@ export type UpdateRoutePolicy200 = {
status: string;
};
export type CreateServiceAccountRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRolePathParameters = {
id: string;
};
export type GetServiceAccountRole200 = {
data: ServiceaccounttypesServiceAccountRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListServiceAccounts200 = {
/**
* @type array
@@ -10470,10 +10536,10 @@ export type GetServiceAccountRoles200 = {
status: string;
};
export type CreateServiceAccountRoleDeprecatedPathParameters = {
export type CreateServiceAccountRolePathParameters = {
id: string;
};
export type CreateServiceAccountRoleDeprecated201 = {
export type CreateServiceAccountRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
@@ -10481,7 +10547,7 @@ export type CreateServiceAccountRoleDeprecated201 = {
status: string;
};
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
export type DeleteServiceAccountRolePathParameters = {
id: string;
rid: string;
};
@@ -11641,28 +11707,6 @@ export type RotateSession200 = {
status: string;
};
export type CreateUserRole201 = {
data: TypesIdentifiableDTO;
/**
* @type string
*/
status: string;
};
export type DeleteUserRolePathParameters = {
id: string;
};
export type GetUserRolePathParameters = {
id: string;
};
export type GetUserRole200 = {
data: AuthtypesUserRoleDTO;
/**
* @type string
*/
status: string;
};
export type ListUsers200 = {
/**
* @type array

View File

@@ -19,15 +19,12 @@ import type {
import type {
AuthtypesPostableUserDTO,
AuthtypesPostableUserRoleDTO,
CreateInvite201,
CreateResetPasswordToken201,
CreateResetPasswordTokenPathParameters,
CreateUser201,
CreateUserRole201,
DeleteUserDeprecatedPathParameters,
DeleteUserPathParameters,
DeleteUserRolePathParameters,
GetMyUser200,
GetMyUserDeprecated200,
GetResetPasswordToken200,
@@ -40,8 +37,6 @@ import type {
GetUserDeprecated200,
GetUserDeprecatedPathParameters,
GetUserPathParameters,
GetUserRole200,
GetUserRolePathParameters,
GetUsersByRoleID200,
GetUsersByRoleIDPathParameters,
ListUsers200,
@@ -1159,267 +1154,6 @@ export const invalidateGetUsersByRoleID = async (
return queryClient;
};
/**
* This endpoint assigns a role to a user
* @summary Create user role
*/
export const createUserRole = (
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateUserRole201>({
url: `/api/v2/user_roles`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: authtypesPostableUserRoleDTO,
signal,
});
};
export const getCreateUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
const mutationKey = ['createUserRole'];
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 createUserRole>>,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
> = (props) => {
const { data } = props ?? {};
return createUserRole(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof createUserRole>>
>;
export type CreateUserRoleMutationBody =
| BodyType<AuthtypesPostableUserRoleDTO>
| undefined;
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create user role
*/
export const useCreateUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createUserRole>>,
TError,
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
TContext
> => {
return useMutation(getCreateUserRoleMutationOptions(options));
};
/**
* This endpoint revokes a role from a user
* @summary Delete user role
*/
export const deleteUserRole = (
{ id }: DeleteUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/user_roles/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteUserRoleMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
const mutationKey = ['deleteUserRole'];
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 deleteUserRole>>,
{ pathParams: DeleteUserRolePathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteUserRole(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteUserRoleMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteUserRole>>
>;
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete user role
*/
export const useDeleteUserRole = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteUserRole>>,
TError,
{ pathParams: DeleteUserRolePathParameters },
TContext
> => {
return useMutation(getDeleteUserRoleMutationOptions(options));
};
/**
* This endpoint gets an existing user role
* @summary Get user role
*/
export const getUserRole = (
{ id }: GetUserRolePathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetUserRole200>({
url: `/api/v2/user_roles/${id}`,
method: 'GET',
signal,
});
};
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
return [`/api/v2/user_roles/${id}`] as const;
};
export const getGetUserRoleQueryOptions = <
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
signal,
}) => getUserRole({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetUserRoleQueryResult = NonNullable<
Awaited<ReturnType<typeof getUserRole>>
>;
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get user role
*/
export function useGetUserRole<
TData = Awaited<ReturnType<typeof getUserRole>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetUserRolePathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getUserRole>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get user role
*/
export const invalidateGetUserRole = async (
queryClient: QueryClient,
{ id }: GetUserRolePathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetUserRoleQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint lists all users for the organization
* @summary List users v2
@@ -2131,7 +1865,6 @@ export const invalidateGetRolesByUserID = async (
/**
* This endpoint assigns the role to the user roles by user id
* @deprecated
* @summary Set user roles
*/
export const setRoleByUserID = (
@@ -2203,7 +1936,6 @@ export type SetRoleByUserIDMutationBody =
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Set user roles
*/
export const useSetRoleByUserID = <
@@ -2232,7 +1964,6 @@ export const useSetRoleByUserID = <
};
/**
* This endpoint removes a role from the user by user id and role id
* @deprecated
* @summary Remove a role from user
*/
export const removeUserRoleByUserIDAndRoleID = (
@@ -2291,7 +2022,6 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @deprecated
* @summary Remove a role from user
*/
export const useRemoveUserRoleByUserIDAndRoleID = <

View File

@@ -1,34 +0,0 @@
import { type MouseEvent, type ReactNode } from 'react';
import { Badge } from '@signozhq/ui/badge';
interface TagBadgeProps {
children: ReactNode;
// Show a remove button (editable contexts: create modal, settings drawer).
closable?: boolean;
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
className?: string;
}
// The single sienna tag chip used everywhere dashboards render tags — list rows,
// the details header, and the tag editors. Kept as one component so the tag
// styling stays identical across all of them.
function TagBadge({
children,
closable,
onClose,
className,
}: TagBadgeProps): JSX.Element {
return (
<Badge
color="sienna"
variant="outline"
className={className}
closable={closable}
onClose={onClose}
>
{children}
</Badge>
);
}
export default TagBadge;

View File

@@ -15,28 +15,59 @@
border: 1px solid var(--l2-border);
}
// Sienna chip via the @signozhq Badge; this only constrains its width.
// Sienna chip — matches the dashboard list-row tag badge.
.tag {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 240px;
height: 24px;
padding: 2px 4px 2px 8px;
border-radius: 50px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
font-size: 13px;
font-weight: var(--font-weight-normal);
line-height: 20px;
cursor: text;
}
// The tag label is a bare, chrome-less button inside the Badge (double-click to
// edit); strip its Button styling and let it ellipsize.
.tagLabel {
--button-height: auto;
--button-padding: 0;
--button-gap: 0;
--button-variant-ghost-background-color: transparent;
--button-variant-ghost-hover-background-color: transparent;
--button-variant-ghost-color: currentColor;
--button-variant-ghost-hover-color: currentColor;
--button-variant-ghost-color: inherit;
--button-variant-ghost-hover-color: inherit;
overflow: hidden;
max-width: 200px;
font-size: 13px;
font-weight: var(--font-weight-normal);
text-overflow: ellipsis;
white-space: nowrap;
cursor: text;
}
.remove {
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
// default ghost hover is a grey that clashes with the chip. Resting color is
// left at the Button default.
--button-height: 16px;
--button-padding: 0;
--button-border-radius: 50%;
--button-variant-ghost-hover-background-color: color-mix(
in srgb,
var(--bg-sienna-500) 22%,
transparent
);
--button-variant-ghost-hover-color: var(--bg-sienna-400);
width: 16px;
min-width: 16px;
flex: none;
}
.input {
flex: 1;
min-width: 120px;

View File

@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Typography } from '@signozhq/ui/typography';
import { X } from '@signozhq/icons';
import cx from 'classnames';
import TagBadge from '../TagBadge/TagBadge';
import { parseKeyValueTag } from './utils';
import styles from './TagKeyValueInput.module.scss';
@@ -120,23 +120,27 @@ function TagKeyValueInput({
onBlur={commitEdit}
/>
) : (
<TagBadge
key={tag}
className={styles.tag}
closable
onClose={(): void => removeTag(tag)}
>
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
<Button
variant="ghost"
color="secondary"
className={styles.tagLabel}
title="Double-click to edit"
testId={`${testId}-chip`}
onDoubleClick={(): void => startEdit(index)}
>
{tag}
</Button>
</TagBadge>
<Button
variant="ghost"
color="secondary"
size="icon"
className={styles.remove}
aria-label={`Remove ${tag}`}
onClick={(): void => removeTag(tag)}
>
<X size={12} />
</Button>
</div>
),
)}
<Input

View File

@@ -1,4 +1,3 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import type { PrecisionOption } from 'components/Graph/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
@@ -28,7 +27,7 @@ interface PieArcProps {
fill: string;
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
onLeave: () => void;
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
onClick?: (slice: PieSlice) => void;
}
/**
@@ -73,7 +72,7 @@ export default function PieArc({
<g
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
onMouseLeave={onLeave}
onClick={(event): void => onClick?.(slice, event)}
onClick={(): void => onClick?.(slice)}
>
<path d={arcPath} fill={fill} />
{shouldShowLabel && (

View File

@@ -80,7 +80,6 @@ describe('PieArc', () => {
expect(onLeave).toHaveBeenCalledTimes(1);
fireEvent.click(g);
// onClick now also receives the DOM event (for drill-down popover positioning).
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
expect(onClick).toHaveBeenCalledWith(SLICE);
});
});

View File

@@ -1,4 +1,3 @@
import type { MouseEvent as ReactMouseEvent } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import {
@@ -80,10 +79,6 @@ export interface PieSlice {
label: string;
value: number;
color: string;
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
queryName?: string;
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
labels?: Record<string, string>;
}
/**
@@ -104,7 +99,7 @@ export interface PieChartProps {
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
*/
id?: string;
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
/** Fired when a slice (or its legend entry) is clicked. */
onSliceClick?: (slice: PieSlice) => void;
'data-testid'?: string;
}

View File

@@ -2,7 +2,7 @@
position: relative;
display: flex;
width: 100%;
height: auto;
height: 100%;
flex-direction: column;
// Stacked children (the FullView / standalone graph-manager) sit below the chart

View File

@@ -66,6 +66,20 @@
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
}
:global(.ant-collapse-header) {
border-bottom: 1px solid var(--l1-border);
padding: 12px 8px;
}
:global(.ant-collapse-content-box) {
padding: 0 !important;
padding-block: 0 !important;
:global(.quick-filters .checkbox-filter) {
padding-left: 18px;
}
}
:global(.quick-filters) {
overflow-y: auto;
overflow-x: hidden;
@@ -97,6 +111,22 @@
justify-content: space-between;
}
.quickFiltersCategoryLabel {
display: flex;
align-items: center;
gap: 4px;
}
.quickFiltersCategoryLabelIcon {
margin-right: 8px;
}
.quickFiltersCategoryLabelContainer {
display: flex;
align-items: center;
gap: 4px;
}
.listContainer {
flex: 1;
min-width: 0;
@@ -130,106 +160,3 @@
.listContainerFiltersVisible {
max-width: calc(100% - 280px);
}
.categorySelectorSection {
padding: var(--spacing-4);
}
.sectionHeader {
display: flex;
align-items: center;
gap: var(--spacing-4);
margin-bottom: var(--spacing-4);
&[data-type='filter'] {
padding: 0 var(--spacing-4);
}
}
.sectionLabel {
font-size: var(--periscope-font-size-small);
font-weight: var(--font-weight-semibold);
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--l3-foreground);
white-space: nowrap;
}
.sectionLine {
flex: 1;
height: 1px;
background: var(--l1-border);
}
.categoryCard {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--spacing-3);
padding: var(--spacing-2);
}
.categoryList {
display: flex;
flex-direction: column;
gap: var(--spacing-1);
}
.categoryItem {
display: flex;
align-items: center;
gap: var(--spacing-5);
padding: var(--spacing-3) var(--spacing-4);
border: none;
border-radius: var(--spacing-2);
background: transparent;
cursor: pointer;
width: 100%;
text-align: left;
transition:
background-color 0.2s ease,
color 0.2s ease;
color: var(--l2-foreground);
font-size: var(--periscope-font-size-base);
--typography-color: var(--l1-foreground);
&:hover:not(.categoryItemSelected) {
background: var(--l2-background-hover);
}
svg {
color: var(--l3-foreground);
flex-shrink: 0;
transition: color 0.2s ease;
}
&.categoryItemSelected {
background: var(--accent-primary);
color: var(--l1-background);
font-weight: 600;
&:hover {
background: var(--accent-primary-hover, var(--accent-primary));
}
svg {
color: var(--l1-foreground);
}
}
}
.quickFiltersSection {
flex: 1;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.1rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--accent-primary);
}
}

View File

@@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import * as Sentry from '@sentry/react';
import { Button, Tooltip } from 'antd';
import { Button, CollapseProps } from 'antd';
import { Collapse, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import logEvent from 'api/common/logEvent';
import QuickFilters from 'components/QuickFilters/QuickFilters';
@@ -112,74 +113,192 @@ export default function InfraMonitoringK8s(): JSX.Element {
});
};
const categories = useMemo(
() => [
{
key: K8sCategories.PODS,
label: 'Pods',
icon: <Container size={14} />,
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NODES,
label: 'Nodes',
icon: <Workflow size={14} />,
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.NAMESPACES,
label: 'Namespaces',
icon: <FilePenLine size={14} />,
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.CLUSTERS,
label: 'Clusters',
icon: <Boxes size={14} />,
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DEPLOYMENTS,
label: 'Deployments',
icon: <Computer size={14} />,
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.JOBS,
label: 'Jobs',
icon: <Bolt size={14} />,
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.DAEMONSETS,
label: 'DaemonSets',
icon: <Group size={14} />,
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.STATEFULSETS,
label: 'StatefulSets',
icon: <ArrowUpDown size={14} />,
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
},
{
key: K8sCategories.VOLUMES,
label: 'Volumes',
icon: <HardDrive size={14} />,
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
},
],
[dotMetricsEnabled],
const renderCategoryLabel = (
icon: JSX.Element,
label: string,
): JSX.Element => (
<div className={styles.quickFiltersCategoryLabel}>
<div className={styles.quickFiltersCategoryLabelContainer}>
{icon}
<Typography.Text>{label}</Typography.Text>
</div>
</div>
);
const selectedCategoryConfig = useMemo(
() => categories.find((cat) => cat.key === selectedCategory)?.config,
[categories, selectedCategory],
);
const items: CollapseProps['items'] = [
{
label: renderCategoryLabel(
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Pods',
),
key: K8sCategories.PODS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Nodes',
),
key: K8sCategories.NODES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Namespaces',
),
key: K8sCategories.NAMESPACES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Clusters',
),
key: K8sCategories.CLUSTERS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Deployments',
),
key: K8sCategories.DEPLOYMENTS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Jobs',
),
key: K8sCategories.JOBS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'DaemonSets',
),
key: K8sCategories.DAEMONSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'StatefulSets',
),
key: K8sCategories.STATEFULSETS,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
{
label: renderCategoryLabel(
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
'Volumes',
),
key: K8sCategories.VOLUMES,
showArrow: false,
children: (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
/>
),
},
// TODO: Enable once we have implemented containers.
// {
// label: (
// <div className="k8s-quick-filters-category-label">
// <div className="k8s-quick-filters-category-label-container">
// <PackageOpen
// size={14}
// className="k8s-quick-filters-category-label-icon"
// />
// <Typography.Text>Containers</Typography.Text>
// </div>
// </div>
// ),
// key: K8sCategories.CONTAINERS,
// showArrow: false,
// children: (
// <QuickFilters
// source={QuickFiltersSource.INFRA_MONITORING}
// config={ContainersQuickFiltersConfig}
// handleFilterVisibilityChange={handleFilterVisibilityChange}
// onFilterChange={handleFilterChange}
// />
// ),
// },
];
const handleCategorySelect = (key: string): void => {
if (key !== selectedCategory) {
setSelectedCategory(key);
const handleCategoryChange = (key: string | string[]): void => {
if (Array.isArray(key) && key.length > 0) {
setSelectedCategory(key[0] as string);
// Reset filters
setUrlFilters(null);
setOrderBy(null);
@@ -213,58 +332,26 @@ export default function InfraMonitoringK8s(): JSX.Element {
<div className={styles.infraContentRow}>
{showFilters && (
<div className={styles.quickFiltersContainer}>
<div className={styles.categorySelectorSection}>
<div className={styles.sectionHeader} data-type="resource">
<Typography.Text className={styles.sectionLabel}>
Viewing · Resource
</Typography.Text>
<div className={styles.sectionLine} />
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
</Tooltip>
</div>
<div className={styles.categoryCard}>
<div className={styles.categoryList}>
{categories.map((category) => (
<button
key={category.key}
type="button"
className={`${styles.categoryItem} ${
selectedCategory === category.key
? styles.categoryItemSelected
: ''
}`}
onClick={(): void => handleCategorySelect(category.key)}
data-testid={`category-${category.key}`}
>
{category.icon}
<Typography.Text>{category.label}</Typography.Text>
</button>
))}
</div>
</div>
</div>
<div className={styles.quickFiltersContainerHeader}>
<Typography.Text>Filters</Typography.Text>
<div className={styles.quickFiltersSection}>
<div className={styles.sectionHeader} data-type="filter">
<Typography.Text className={styles.sectionLabel}>
Filter by
</Typography.Text>
<div className={styles.sectionLine} />
</div>
{selectedCategoryConfig && (
<QuickFilters
source={QuickFiltersSource.INFRA_MONITORING}
config={selectedCategoryConfig}
handleFilterVisibilityChange={handleFilterVisibilityChange}
onFilterChange={handleFilterChange}
<Tooltip title="Collapse Filters">
<ArrowUpToLine
style={{ transform: 'rotate(270deg)' }}
onClick={handleFilterVisibilityChange}
size="md"
/>
)}
</Tooltip>
</div>
<Collapse
onChange={handleCategoryChange}
items={items}
defaultActiveKey={[selectedCategory]}
activeKey={[selectedCategory]}
accordion
bordered={false}
ghost
/>
</div>
)}

View File

@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
import { useQueryClient } from 'react-query';
import {
getGetServiceAccountRolesQueryKey,
useCreateServiceAccountRoleDeprecated,
useDeleteServiceAccountRoleDeprecated,
useCreateServiceAccountRole,
useDeleteServiceAccountRole,
useGetServiceAccountRoles,
} from 'api/generated/services/serviceaccount';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
@@ -46,10 +46,10 @@ export function useServiceAccountRoleManager(
);
// the retry for these mutations is safe due to being idempotent on backend
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
const { mutateAsync: createRole } = useCreateServiceAccountRole({
mutation: { retry: retryOn429 },
});
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
mutation: { retry: retryOn429 },
});

View File

@@ -7,7 +7,7 @@ import {
SolidInfoCircle,
X,
} from '@signozhq/icons';
import TagBadge from 'components/TagBadge/TagBadge';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { TooltipSimple } from '@signozhq/ui/tooltip';
@@ -203,13 +203,19 @@ function DashboardInfo({
data-testid="dashboard-tags"
>
{visibleTags.map((tag) => (
<TagBadge key={tag}>{tag}</TagBadge>
<Badge key={tag} color="sienna" variant="outline">
{tag}
</Badge>
))}
{remainingTags.length > 0 && (
<TooltipSimple title={remainingTags.join(', ')}>
<span data-testid="dashboard-tags-overflow">
<TagBadge>+{remainingTags.length}</TagBadge>
</span>
<Badge
color="sienna"
variant="outline"
data-testid="dashboard-tags-overflow"
>
+{remainingTags.length}
</Badge>
</TooltipSimple>
)}
</div>

View File

@@ -63,13 +63,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
const { user } = useAppContext();
const { showErrorModal } = useErrorModal();
const { patchAsync } = useOptimisticPatch();
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const isAuthor =
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
@@ -188,7 +183,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
</section>
);

View File

@@ -3,10 +3,10 @@ import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schem
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
// The tag editor is strictly key:value, so always render both sides — a
// `key:key` tag stays `key:key` rather than collapsing to a bare `key`.
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
// collapsed back to just `x` for display.
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
return tags.map((t) => `${t.key}:${t.value}`);
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
}
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {

View File

@@ -14,6 +14,8 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
@@ -23,10 +25,7 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { buildBarChartConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -41,7 +40,6 @@ function BarPanelRenderer({
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -58,10 +56,12 @@ function BarPanelRenderer({
[panel.spec.queries],
);
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched.
// X-scale clamps come from the request that produced the data. The generated
// request DTO is structurally the V5 request; the cast is the boundary.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
@@ -155,29 +155,10 @@ function BarPanelRenderer({
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
(args: ChartClickData) => {
onClick?.(args);
},
[onClick, flatSeries, builderQueries, data.response],
[onClick],
);
return (
@@ -207,7 +188,7 @@ function BarPanelRenderer({
syncFilterMode={dashboardPreference?.syncFilterMode}
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
renderTooltipFooter={renderTooltipFooter}
onClick={enableDrillDown ? handleChartClick : undefined}
onClick={handleChartClick}
/>
)}
</div>

View File

@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -20,6 +20,7 @@ import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { buildHistogramConfig } from './utils/buildConfig';
import { prepareHistogramData } from './prepareData';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
function HistogramPanelRenderer({
panelId,
@@ -27,6 +28,7 @@ function HistogramPanelRenderer({
data,
refetch,
panelMode,
onClick,
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -98,6 +100,13 @@ function HistogramPanelRenderer({
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
const handleChartClick = useCallback(
(args: ChartClickData) => {
onClick?.(args);
},
[onClick],
);
return (
<div
ref={graphRef}
@@ -118,6 +127,7 @@ function HistogramPanelRenderer({
width={containerDimensions.width}
height={containerDimensions.height}
renderTooltipFooter={renderTooltipFooter}
onClick={handleChartClick}
/>
)}
</div>

View File

@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: false,
},
};

View File

@@ -37,6 +37,5 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
download: false,
createAlert: false,
search: true,
drilldown: false,
},
};

View File

@@ -1,9 +1,4 @@
import {
useCallback,
useMemo,
type KeyboardEvent as ReactKeyboardEvent,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { useMemo } from 'react';
import type { DashboardtypesNumberPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { prepareScalarTables } from 'pages/DashboardPageV2/DashboardContainer/queryV5/prepareScalarTables';
import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
@@ -13,9 +8,6 @@ import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { formatPanelValue } from '../../utils/formatPanelValue';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichNumberClick } from '../../utils/drilldown/enrichNumberClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { prepareNumberData } from './prepareData';
import { mapNumberThresholds } from './utils';
@@ -25,31 +17,24 @@ function NumberPanelRenderer({
panel,
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/NumberPanel'>): JSX.Element {
const spec = useMemo<DashboardtypesNumberPanelSpecDTO>(
() => panel.spec.plugin.spec,
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const tables = useMemo(
const value = useMemo(
() =>
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
prepareNumberData(
prepareScalarTables({
results: getScalarResults(data.response),
legendMap: data.legendMap ?? {},
requestPayload: data.requestPayload,
}),
),
[data.response, data.legendMap, data.requestPayload],
);
const value = useMemo(() => prepareNumberData(tables), [tables]);
const thresholds = useMemo(
() => mapNumberThresholds(spec.thresholds),
[spec.thresholds],
@@ -69,60 +54,10 @@ function NumberPanelRenderer({
[value, unit, decimalPrecision],
);
const openDrilldown = useCallback(
(coordinates: { x: number; y: number }): void => {
if (!onClick) {
return;
}
const payload = enrichNumberClick({
tables,
builderQueries,
coordinates,
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, tables, data.requestPayload, builderQueries],
);
const handleClick = useCallback(
(event: ReactMouseEvent<HTMLDivElement>): void =>
openDrilldown({ x: event.clientX, y: event.clientY }),
[openDrilldown],
);
const handleKeyDown = useCallback(
(event: ReactKeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
const rect = event.currentTarget.getBoundingClientRect();
openDrilldown({
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
});
}
},
[openDrilldown],
);
// The whole panel is the value, so the container itself is the drill-down target.
const isClickable = enableDrillDown && !!onClick && value !== null;
return (
<div
data-testid="number-panel-renderer"
className={PanelStyles.panelContainer}
{...(isClickable
? {
role: 'button',
tabIndex: 0,
onClick: handleClick,
onKeyDown: handleKeyDown,
style: { cursor: 'pointer' },
}
: {})}
>
{value === null ? (
<NoData data-testid="number-panel-no-data" onRetry={refetch} />

View File

@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -1,8 +1,4 @@
import {
useCallback,
useMemo,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { useCallback, useMemo } from 'react';
import type { DashboardtypesPieChartPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import Pie from 'container/DashboardContainer/visualization/charts/Pie/Pie';
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
@@ -17,9 +13,6 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { enrichPieClick } from '../../utils/drilldown/enrichPieClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { preparePieData } from './prepareData';
@@ -29,7 +22,6 @@ function PiePanelRenderer({
data,
refetch,
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
const isDarkMode = useIsDarkMode();
@@ -38,11 +30,6 @@ function PiePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
const slices = useMemo(
() =>
preparePieData({
@@ -74,21 +61,10 @@ function PiePanelRenderer({
);
const handleSliceClick = useCallback(
(slice: PieSlice, event: ReactMouseEvent): void => {
if (!onClick) {
return;
}
const payload = enrichPieClick({
slice,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
(slice: PieSlice) => {
onClick?.({ label: slice.label, value: slice.value });
},
[onClick, builderQueries, data.requestPayload],
[onClick],
);
return (
@@ -103,7 +79,7 @@ function PiePanelRenderer({
isDarkMode={isDarkMode}
position={legendPosition}
id={panelId}
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
onSliceClick={handleSliceClick}
data-testid="pie-chart"
/>
)}

View File

@@ -23,6 +23,5 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
download: false,
createAlert: false,
search: false,
drilldown: true,
},
};

View File

@@ -1,11 +1,4 @@
import {
useCallback,
useEffect,
useMemo,
useRef,
useState,
type MouseEvent as ReactMouseEvent,
} from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { Table } from 'antd';
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useResizeObserver } from 'hooks/useDimensions';
@@ -15,9 +8,6 @@ import { getScalarResults } from 'pages/DashboardPageV2/DashboardContainer/query
import PanelStyles from '../../panel.module.scss';
import { PanelRendererProps } from '../../types/rendererProps';
import { resolveDecimalPrecision } from '../../utils/chartAppearance/resolvers';
import { enrichTableClick } from '../../utils/drilldown/enrichTableClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { useResizableColumns } from '../../hooks/useResizableColumns';
import NoData from '../../components/NoData/NoData';
@@ -37,8 +27,6 @@ function TablePanelRenderer({
data,
refetch,
searchTerm = '',
onClick,
enableDrillDown,
}: PanelRendererProps<'signoz/TablePanel'>): JSX.Element {
// Measure the panel so each page roughly fills it (min 10 rows) with a pinned header.
const containerRef = useRef<HTMLDivElement>(null);
@@ -54,11 +42,6 @@ function TablePanelRenderer({
[panel.spec.plugin.spec],
);
const builderQueries = useMemo(
() => getBuilderQueries(panel.spec.queries || []),
[panel.spec.queries],
);
// V5 joins every query into a single scalar result, so the first non-empty
// table is the whole panel.
const table = useMemo(
@@ -81,34 +64,6 @@ function TablePanelRenderer({
[spec.thresholds],
);
const handleCellClick = useCallback(
({
columnId,
record,
event,
}: {
columnId: string;
record: TableRowData;
event: ReactMouseEvent<HTMLElement>;
}): void => {
if (!onClick || !table) {
return;
}
const payload = enrichTableClick({
record,
columnId,
table,
builderQueries,
coordinates: { x: event.clientX, y: event.clientY },
timeRange: getPanelTimeRange(data.requestPayload),
});
if (payload) {
onClick(payload);
}
},
[onClick, table, builderQueries, data.requestPayload],
);
const columns = useMemo(
() =>
table
@@ -117,17 +72,9 @@ function TablePanelRenderer({
columnUnits: spec.formatting?.columnUnits ?? {},
decimalPrecision,
thresholdsByColumn,
onCellClick: enableDrillDown ? handleCellClick : undefined,
})
: [],
[
table,
spec.formatting?.columnUnits,
decimalPrecision,
thresholdsByColumn,
enableDrillDown,
handleCellClick,
],
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
);
// User-resizable columns, persisted per panel to localStorage.

View File

@@ -25,6 +25,5 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
createAlert: false,
// V1 parity: only tables (and lists) expose the header search box.
search: true,
drilldown: true,
},
};

View File

@@ -52,12 +52,6 @@ export interface BuildTableColumnsArgs {
decimalPrecision?: PrecisionOption;
/** Thresholds grouped by column name (see `mapTableThresholds`). */
thresholdsByColumn: Record<string, PanelThreshold[]>;
/** When set, every body cell becomes a drill-down target (keyed by its column id). */
onCellClick?: (args: {
columnId: string;
record: TableRowData;
event: React.MouseEvent<HTMLElement>;
}) => void;
}
/**
@@ -71,7 +65,6 @@ export function buildTableColumns({
columnUnits,
decimalPrecision,
thresholdsByColumn,
onCellClick,
}: BuildTableColumnsArgs): TableProps<TableRowData>['columns'] {
return table.columns.map((col) => {
// Column key = query identifier for value columns, group name otherwise. Units
@@ -104,26 +97,19 @@ export function buildTableColumns({
}
return text;
},
onCell: (record: TableRowData): React.HTMLAttributes<HTMLElement> => {
const cellProps: React.HTMLAttributes<HTMLElement> = {};
if (col.isValueColumn && colThresholds.length > 0) {
const num = Number(record[key]);
if (Number.isFinite(num)) {
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {
cellProps.style = { backgroundColor: threshold.color };
}
}
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
if (!col.isValueColumn || colThresholds.length === 0) {
return {};
}
if (onCellClick) {
cellProps.onClick = (event): void =>
onCellClick({ columnId: key, record, event });
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
const num = Number(record[key]);
if (!Number.isFinite(num)) {
return {};
}
return cellProps;
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
if (threshold?.format === 'background') {
return { style: { backgroundColor: threshold.color } };
}
return {};
},
};
});

View File

@@ -14,6 +14,8 @@ import {
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
import { prepareAlignedData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/uplotData';
import { useTimezone } from 'providers/Timezone';
import type { QueryRangeRequestV5 } from 'types/api/v5/queryRange';
import { getTimeRangeFromQueryRangeRequest } from 'utils/getTimeRange';
import NoData from '../../components/NoData/NoData';
import { useGroupByPerQuery } from '../../hooks/useGroupByPerQuery';
@@ -23,10 +25,7 @@ import {
resolveDecimalPrecision,
resolveLegendPosition,
} from '../../utils/chartAppearance/resolvers';
import { stepClickTimeRange } from '../../utils/drilldown/chartClickTimeRange';
import { enrichChartClick } from '../../utils/drilldown/enrichChartClick';
import { getBuilderQueries } from '../../utils/getBuilderQueries';
import { getPanelTimeRange } from '../../utils/getPanelTimeRange';
import { buildTimeSeriesConfig } from './utils/buildConfig';
import { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
@@ -41,7 +40,6 @@ function TimeSeriesPanelRenderer({
dashboardPreference,
panelMode,
onCloseStandaloneView,
enableDrillDown,
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
const graphRef = useRef<HTMLDivElement>(null);
const containerDimensions = useResizeObserver(graphRef);
@@ -60,9 +58,11 @@ function TimeSeriesPanelRenderer({
// X-scale clamps come from the request that produced the data, so each panel
// pins to the window it fetched — matters during drag-zoom transitions before
// new data arrives.
// new data arrives. The generated request DTO is structurally the V5 request.
const { minTimeScale, maxTimeScale } = useMemo(() => {
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
);
return { minTimeScale: startTime, maxTimeScale: endTime };
}, [data.requestPayload]);
@@ -156,29 +156,10 @@ function TimeSeriesPanelRenderer({
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
const handleChartClick = useCallback(
(args: ChartClickData): void => {
if (!onClick) {
return;
}
const payload = enrichChartClick({
clickData: args,
series: flatSeries,
builderQueries,
});
if (!payload) {
return;
}
const timeRange = stepClickTimeRange({
clickedDataTimestamp: args.clickedDataTimestamp,
queryName: payload.context.queryName,
builderQueries,
stepInterval: getExecStats(data.response)?.stepIntervals?.[
payload.context.queryName
],
});
onClick({ ...payload, context: { ...payload.context, timeRange } });
(args: ChartClickData) => {
onClick?.(args);
},
[onClick, flatSeries, builderQueries, data.response],
[onClick],
);
return (
@@ -207,7 +188,7 @@ function TimeSeriesPanelRenderer({
syncMode={dashboardPreference?.syncMode}
syncFilterMode={dashboardPreference?.syncFilterMode}
renderTooltipFooter={renderTooltipFooter}
onClick={enableDrillDown ? handleChartClick : undefined}
onClick={handleChartClick}
/>
)}
</div>

View File

@@ -27,6 +27,5 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
download: false,
createAlert: true,
search: false,
drilldown: true,
},
};

View File

@@ -1,36 +0,0 @@
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
// Drilldown is the click-to-context-menu feature ported from V1. Every renderer turns its native
// click into one `DrilldownClickPayload`; the kind-agnostic orchestration layer consumes only that.
// `FilterData` is imported read-only from the V1 util so the payload feeds `buildDrilldownUrl`
// directly, with no intermediate translation.
/** The clicked point's drilldown context, derived from the flattened series/columns the renderer holds. */
export interface DrilldownContext {
/** The clicked series'/column's query. Drives query selection in `getViewQuery`. */
queryName: string;
/** Telemetry signal of the clicked query — picks the explorer the drilldown navigates to. */
signal: TelemetrytypesSignalDTO;
/** Key/value/op filters from the clicked point's group-by labels (empty when ungrouped). */
filters: FilterData[];
/** Explorer time window. Charts use the clicked bucket ±step; scalar panels use the fetched window. */
timeRange?: { startTime: number; endTime: number };
/** Series/slice display name, shown as the menu header's second line. */
label?: string;
/** Series/slice colour; tints the menu header label and item icons (charts/pie only). */
seriesColor?: string;
/** Tables only: a value column opens the aggregate menu; a group column opens filter-by-value. */
columnKind?: 'aggregate' | 'group';
/** Group-column click only: the clicked column's key, for the filter-by-value menu. */
clickedKey?: string;
/** Group-column click only: the clicked cell's value, for the filter-by-value menu. */
clickedValue?: string | number;
}
/** What each renderer's `onClick` emits: where to anchor the popover plus the drilldown context. */
export interface DrilldownClickPayload {
/** Absolute viewport coordinates for the popover anchor. */
coordinates: { x: number; y: number };
context: DrilldownContext;
}

View File

@@ -1,5 +1,24 @@
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { PanelKind } from './panelKind';
import type { DrilldownClickPayload } from './drilldown';
/** Source-tagged click events; each non-chart kind carries its own drill-down context. */
export type ChartClickEvent = ChartClickData;
export type TableClickEvent = {
rowData: Record<string, unknown>;
columnId?: string;
};
export type ListClickEvent = {
rowData: Record<string, unknown>;
};
export type PieClickEvent = { label: string; value: number };
/** Union of every panel click event — switched on by `source` at the boundary. */
export type PanelClickEvent =
| ChartClickEvent
| TableClickEvent
| ListClickEvent
| PieClickEvent;
type DragSelect = (start: number, end: number) => void;
@@ -10,27 +29,23 @@ type CloseStandaloneView = () => void;
* Per-kind interaction props — each kind exposes only the gestures it supports.
* Keyed by `PanelKind`; `PanelRendererProps<K>` indexes this, so a missing kind
* is a compile error there.
*
* Every interactive kind's `onClick` receives the unified `DrilldownClickPayload`
* its renderer enriches from the native click. Number/Value drills down on its
* single value. Histogram and List are omitted (V1 has no drill-down for either):
* they inherit the empty `object` base, so their renderers get only base props
* with no click gesture.
*/
export type PanelInteractionMap = Record<PanelKind, object> & {
'signoz/TimeSeriesPanel': {
onClick?: (event: DrilldownClickPayload) => void;
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/BarChartPanel': {
onClick?: (event: DrilldownClickPayload) => void;
onClick?: (event: ChartClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
};
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
'signoz/HistogramPanel': { onClick?: (event: ChartClickEvent) => void };
'signoz/TablePanel': { onClick?: (event: TableClickEvent) => void };
'signoz/ListPanel': { onClick?: (event: ListClickEvent) => void };
'signoz/PieChartPanel': { onClick?: (event: PieClickEvent) => void };
'signoz/NumberPanel': Record<string, never>;
};
/**
@@ -38,7 +53,7 @@ export type PanelInteractionMap = Record<PanelKind, object> & {
* registry render boundary). The supertype the per-kind shapes are cast to once.
*/
export interface AnyPanelInteractionProps {
onClick?: (event: DrilldownClickPayload) => void;
onClick?: (event: PanelClickEvent) => void;
onDragSelect?: DragSelect;
onCloseStandaloneView?: CloseStandaloneView;
}

View File

@@ -30,11 +30,6 @@ export interface PanelActionCapabilities {
* tabular kinds). Not a menu action — the renderer must consume `searchTerm`.
*/
search: boolean;
/**
* Kind supports click-to-drilldown (context menu + View/Breakout). V1 parity: charts + scalar
* Pie/Value/Table; Histogram/List opt out. AND-ed with "has a builder query" in `useDrilldown`.
*/
drilldown: boolean;
}
export interface PanelDefinition<K extends PanelKind = PanelKind> {

View File

@@ -1,46 +0,0 @@
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelTimeRange } from '../getPanelTimeRange';
// Fallback path reads the redux global-time selection; stub both so the no-payload branch
// is deterministic.
jest.mock('store', () => ({
__esModule: true,
default: { getState: (): unknown => ({ globalTime: { selectedTime: '5m' } }) },
}));
jest.mock('lib/getStartEndRangeTime', () => ({
__esModule: true,
default: (): { start: string; end: string } => ({
start: '1700',
end: '1800',
}),
}));
const request = (
start?: number,
end?: number,
): Querybuildertypesv5QueryRangeRequestDTO =>
({ start, end }) as Querybuildertypesv5QueryRangeRequestDTO;
describe('getPanelTimeRange', () => {
it('converts the request start/end from ms to seconds', () => {
expect(getPanelTimeRange(request(5_000, 9_000))).toStrictEqual({
startTime: 5,
endTime: 9,
});
});
it('falls back to the global-time window when there is no request', () => {
expect(getPanelTimeRange(undefined)).toStrictEqual({
startTime: 1700,
endTime: 1800,
});
});
it('falls back when the request is missing an endpoint', () => {
expect(getPanelTimeRange(request(5_000, undefined))).toStrictEqual({
startTime: 1700,
endTime: 1800,
});
});
});

View File

@@ -1,483 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type {
PanelSeries,
PanelTable,
} from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownContext } from '../../../types/drilldown';
import { buildAggregateData } from '../buildAggregateData';
import { stepClickTimeRange } from '../chartClickTimeRange';
import { enrichChartClick } from '../enrichChartClick';
import { enrichNumberClick } from '../enrichNumberClick';
import { enrichPieClick } from '../enrichPieClick';
import { enrichTableClick } from '../enrichTableClick';
import { getDataLinks } from '../getDataLinks';
import { resolvePanelContextLinks } from '../resolvePanelContextLinks';
import { resolveDrilldownSignal } from '../signal';
// The v5 BuilderQuery union is too verbose to construct field-typed inline; cast at the boundary.
function builderQuery(spec: Record<string, unknown>): BuilderQuery {
return spec as unknown as BuilderQuery;
}
function panelSeries(overrides: Partial<PanelSeries> = {}): PanelSeries {
return {
queryName: 'A',
legend: '',
labels: { 'service.name': 'frontend' },
kind: 'series',
values: [],
aggregation: { index: 0, alias: '' },
...overrides,
};
}
function chartClick(
focusedSeries: ChartClickData['focusedSeries'],
): ChartClickData {
return {
xValue: 0,
yValue: 0,
focusedSeries,
clickedDataTimestamp: 1_700_000_000,
mouseX: 10,
mouseY: 20,
absoluteMouseX: 110,
absoluteMouseY: 220,
};
}
function focused(seriesIndex: number): ChartClickData['focusedSeries'] {
return { seriesIndex, seriesName: 'frontend', value: 1, color: '#fff' };
}
describe('resolveDrilldownSignal', () => {
it('maps logs/traces directly', () => {
expect(resolveDrilldownSignal(builderQuery({ signal: 'logs' }))).toBe('logs');
expect(resolveDrilldownSignal(builderQuery({ signal: 'traces' }))).toBe(
'traces',
);
});
it('falls back to metrics for metrics, meter and unknown/missing signals', () => {
expect(resolveDrilldownSignal(builderQuery({ signal: 'metrics' }))).toBe(
'metrics',
);
expect(resolveDrilldownSignal(builderQuery({ signal: 'meter' }))).toBe(
'metrics',
);
expect(resolveDrilldownSignal(undefined)).toBe('metrics');
});
});
describe('enrichChartClick', () => {
const series = [
panelSeries({ queryName: 'A', labels: { 'service.name': 'frontend' } }),
panelSeries({ queryName: 'B', labels: { 'service.name': 'cart' } }),
];
it('maps the uPlot series index to the (index - 1) flattened series', () => {
// uPlot series[0] is the x-axis, so data series start at 1.
const payload = enrichChartClick({
clickData: chartClick(focused(2)),
series,
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
groupBy: [{ name: 'service.name' }],
}),
builderQuery({
name: 'B',
signal: 'logs',
groupBy: [{ name: 'service.name' }],
}),
],
});
expect(payload?.context.queryName).toBe('B');
expect(payload?.context.signal).toBe('logs');
expect(payload?.context.filters).toStrictEqual([
expect.objectContaining({ filterKey: 'service.name', filterValue: 'cart' }),
]);
expect(payload?.context.seriesColor).toBe('#fff');
expect(payload?.coordinates).toStrictEqual({ x: 110, y: 220 });
});
it('passes through the caller-computed time range and resolves the signal', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series,
builderQueries: [builderQuery({ name: 'A', signal: 'traces' })],
timeRange: { startTime: 100, endTime: 200 },
});
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.timeRange).toStrictEqual({
startTime: 100,
endTime: 200,
});
});
it('returns null when there is no focused series', () => {
expect(
enrichChartClick({
clickData: chartClick(null),
series,
builderQueries: [],
}),
).toBeNull();
});
it('returns null when the series index maps to no series', () => {
expect(
enrichChartClick({
clickData: chartClick(focused(99)),
series,
builderQueries: [],
}),
).toBeNull();
});
it('returns null for formula queries (queryName starts with F)', () => {
expect(
enrichChartClick({
clickData: chartClick(focused(1)),
series: [panelSeries({ queryName: 'F1' })],
builderQueries: [],
}),
).toBeNull();
});
it('emits empty filters for an ungrouped series (drops the legend backfill label)', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series: [panelSeries({ queryName: 'A', labels: { A: 'A' } })],
builderQueries: [builderQuery({ name: 'A', signal: 'metrics' })],
});
expect(payload?.context.filters).toStrictEqual([]);
expect(payload?.context.queryName).toBe('A');
});
it('drops labels that are not group-by dimensions', () => {
const payload = enrichChartClick({
clickData: chartClick(focused(1)),
series: [
panelSeries({
queryName: 'A',
labels: { 'service.name': 'frontend', __name__: 'http_requests' },
}),
],
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
groupBy: [{ name: 'service.name' }],
}),
],
});
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
});
describe('buildAggregateData', () => {
it('projects the drilldown context onto the V1 AggregateData shape', () => {
const context: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.logs,
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
timeRange: { startTime: 1, endTime: 2 },
label: 'frontend',
seriesColor: '#abc',
columnKind: 'aggregate',
};
expect(buildAggregateData(context)).toStrictEqual({
queryName: 'A',
filters: [{ filterKey: 'k', filterValue: 'v', operator: '=' }],
timeRange: { startTime: 1, endTime: 2 },
label: 'frontend',
seriesColor: '#abc',
});
});
});
describe('enrichNumberClick', () => {
const numberTable = (queryName: string): PanelTable => ({
queryName,
legend: '',
columns: [{ name: 'value', queryName, isValueColumn: true, id: queryName }],
rows: [{ data: { [queryName]: 42 } }],
});
it('drills down on the displayed value column with empty filters and no label', () => {
const payload = enrichNumberClick({
tables: [numberTable('A')],
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
coordinates: { x: 5, y: 6 },
timeRange: { startTime: 1, endTime: 2 },
});
// No label: the menu header falls back to the aggregation expression (V1 parity).
expect(payload?.context).toStrictEqual({
queryName: 'A',
signal: 'logs',
filters: [],
timeRange: { startTime: 1, endTime: 2 },
});
expect(payload?.coordinates).toStrictEqual({ x: 5, y: 6 });
});
it('drills into the displayed value column, not the first builder query', () => {
// Panel shows query B's value column; drilldown must target B, not A.
const payload = enrichNumberClick({
tables: [numberTable('B')],
builderQueries: [
builderQuery({ name: 'A', signal: 'logs' }),
builderQuery({ name: 'B', signal: 'traces' }),
],
coordinates: { x: 0, y: 0 },
});
expect(payload?.context.queryName).toBe('B');
expect(payload?.context.signal).toBe('traces');
});
it('returns null when there is no drillable query', () => {
expect(
enrichNumberClick({
tables: [],
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
it('returns null for a formula query', () => {
expect(
enrichNumberClick({
tables: [numberTable('F1')],
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
});
describe('enrichTableClick', () => {
const table: PanelTable = {
queryName: 'A',
legend: '',
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
{ name: 'p99', queryName: 'A', isValueColumn: true, id: 'A' },
],
rows: [{ data: { 'service.name': 'frontend', A: 42 } }],
};
const record = { 'service.name': 'frontend', A: 42 };
const builderQueries = [builderQuery({ name: 'A', signal: 'traces' })];
it('builds equality filters from the row group cells for a value-column click', () => {
const payload = enrichTableClick({
record,
columnId: 'A',
table,
builderQueries,
coordinates: { x: 1, y: 2 },
timeRange: { startTime: 10, endTime: 20 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.columnKind).toBe('aggregate');
expect(payload?.context.clickedKey).toBeUndefined();
// No label: the aggregate menu header falls back to the aggregation expression,
// not the value column name (V1 parity).
expect(payload?.context.label).toBeUndefined();
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
it('falls back to the row value column and carries the clicked cell for a group click', () => {
const payload = enrichTableClick({
record,
columnId: 'service.name',
table,
builderQueries,
coordinates: { x: 1, y: 2 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.columnKind).toBe('group');
expect(payload?.context.clickedKey).toBe('service.name');
expect(payload?.context.clickedValue).toBe('frontend');
});
it('returns null when the table has no value column', () => {
const groupOnly: PanelTable = {
queryName: 'A',
legend: '',
columns: [
{
name: 'service.name',
queryName: 'A',
isValueColumn: false,
id: 'service.name',
},
],
rows: [{ data: { 'service.name': 'frontend' } }],
};
expect(
enrichTableClick({
record,
columnId: 'service.name',
table: groupOnly,
builderQueries,
coordinates: { x: 1, y: 2 },
}),
).toBeNull();
});
});
describe('enrichPieClick', () => {
it('builds filters from the slice labels and resolves the signal', () => {
const payload = enrichPieClick({
slice: {
label: 'frontend',
value: 12,
color: '#abc',
queryName: 'A',
labels: { 'service.name': 'frontend' },
},
builderQueries: [
builderQuery({
name: 'A',
signal: 'traces',
groupBy: [{ name: 'service.name' }],
}),
],
coordinates: { x: 7, y: 8 },
timeRange: { startTime: 1, endTime: 2 },
});
expect(payload?.context.queryName).toBe('A');
expect(payload?.context.signal).toBe('traces');
expect(payload?.context.filters).toStrictEqual([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]);
});
it('returns null for a slice with no source query', () => {
expect(
enrichPieClick({
slice: { label: 'x', value: 1, color: '#000' },
builderQueries: [],
coordinates: { x: 0, y: 0 },
}),
).toBeNull();
});
});
describe('resolvePanelContextLinks', () => {
it('substitutes the clicked field value (_-prefixed) into the label and URL', () => {
const resolved = resolvePanelContextLinks(
[
{
name: 'Runbook for {{_service.name}}',
url: 'https://wiki/{{_service.name}}',
},
],
{ '_service.name': 'frontend' },
);
expect(resolved).toHaveLength(1);
expect(resolved[0].label).toBe('Runbook for frontend');
expect(resolved[0].url).toBe('https://wiki/frontend');
});
it('drops links without a URL', () => {
expect(resolvePanelContextLinks([{ name: 'No URL' }], {})).toStrictEqual([]);
expect(resolvePanelContextLinks(undefined, {})).toStrictEqual([]);
});
it('keeps the raw URL when renderVariables is false', () => {
const resolved = resolvePanelContextLinks(
[
{
name: 'Literal',
url: 'https://wiki/{{_service.name}}',
renderVariables: false,
},
],
{ '_service.name': 'frontend' },
);
expect(resolved[0].url).toBe('https://wiki/{{_service.name}}');
});
});
describe('stepClickTimeRange', () => {
it('returns [clickedTs, clickedTs + step] for a non-APM query', () => {
expect(
stepClickTimeRange({
clickedDataTimestamp: 1000,
queryName: 'A',
builderQueries: [builderQuery({ name: 'A', signal: 'logs' })],
stepInterval: 30,
}),
).toStrictEqual({ startTime: 1000, endTime: 1030 });
});
it('falls back to a 60s step when no interval is provided', () => {
expect(
stepClickTimeRange({
clickedDataTimestamp: 1000,
queryName: 'A',
builderQueries: [
builderQuery({
name: 'A',
signal: 'metrics',
aggregations: [{ metricName: 'custom_metric' }],
}),
],
}),
).toStrictEqual({ startTime: 1000, endTime: 1060 });
});
});
describe('getDataLinks', () => {
it('adds a "View Trace Details" link when the filters carry a trace_id', () => {
expect(
getDataLinks([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
{ filterKey: 'trace_id', filterValue: 'abc123', operator: '=' },
]),
).toStrictEqual([
{
id: 'view-trace-details',
label: 'View Trace Details',
url: '/trace/abc123',
},
]);
});
it('returns no links when there is no trace_id', () => {
expect(
getDataLinks([
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
]),
).toStrictEqual([]);
});
});

View File

@@ -1,18 +0,0 @@
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type { DrilldownContext } from '../../types/drilldown';
/**
* Adapts a V2 `DrilldownContext` to the V1 `AggregateData` that `buildDrilldownUrl`/the drilldown
* navigate hook consume. The single boundary between the V2 click payload and the reused V1
* navigation machinery.
*/
export function buildAggregateData(context: DrilldownContext): AggregateData {
return {
queryName: context.queryName,
filters: context.filters,
timeRange: context.timeRange,
label: context.label,
seriesColor: context.seriesColor,
};
}

View File

@@ -1,38 +0,0 @@
import {
getTimeRangeFromStepInterval,
isApmMetric,
} from 'container/PanelWrapper/utils';
import type { BuilderQuery, MetricAggregation } from 'types/api/v5/queryRange';
/** Fallback step (seconds) when the response carries no per-query step interval (V1 parity). */
const DEFAULT_STEP_INTERVAL = 60;
interface StepClickTimeRangeArgs {
/** Clicked bucket timestamp, in the chart's x-unit (epoch seconds). */
clickedDataTimestamp: number;
/** The clicked series' query — used to detect APM metrics. */
queryName: string;
builderQueries: BuilderQuery[];
/** Clicked series' step (seconds); falls back to DEFAULT_STEP_INTERVAL. */
stepInterval?: number;
}
/**
* Time window for a time-axis chart click: the clicked bucket plus one step (V1 parity). APM-metric
* panels widen the window one step to the left. Shared by the TimeSeries and Bar renderers; the
* matching field remapping happens later inside `getViewQuery`.
*/
export function stepClickTimeRange({
clickedDataTimestamp,
queryName,
builderQueries,
stepInterval = DEFAULT_STEP_INTERVAL,
}: StepClickTimeRangeArgs): { startTime: number; endTime: number } {
const builderQuery = builderQueries.find((query) => query.name === queryName);
const isApm =
builderQuery?.signal === 'metrics' &&
isApmMetric(
(builderQuery?.aggregations?.[0] as MetricAggregation)?.metricName ?? '',
);
return getTimeRangeFromStepInterval(stepInterval, clickedDataTimestamp, isApm);
}

View File

@@ -1,62 +0,0 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import type { PanelSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichChartClickArgs {
clickData: ChartClickData;
/** Flattened series in the same order they were added to uPlot (see `prepareAlignedData`/`addSeries`). */
series: PanelSeries[];
/** The panel's builder queries, for resolving the clicked series' signal by `queryName`. */
builderQueries: BuilderQuery[];
/** Explorer time window; the caller computes it (clicked bucket ±step for time charts, panel window for histograms). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a uPlot click (time-series or bar) into a drilldown payload. Resolves the clicked series via
* uPlot's series index (index 0 is the x-axis, so data series start at 1 → `series[seriesIndex - 1]`)
* and builds equality filters from its group-by label values. Returns `null` when the click can't be
* attributed to a drillable series (no focused series, unmapped index, or a formula query).
*/
export function enrichChartClick({
clickData,
series,
builderQueries,
timeRange,
}: EnrichChartClickArgs): DrilldownClickPayload | null {
const { focusedSeries } = clickData;
if (!focusedSeries) {
return null;
}
const panelSeries = series[focusedSeries.seriesIndex - 1];
if (!panelSeries || !isValidQueryName(panelSeries.queryName)) {
return null;
}
const builderQuery = builderQueries.find(
(query) => query.name === panelSeries.queryName,
);
const filters = builderQuery
? getGroupByFilters(panelSeries.labels, builderQuery)
: [];
return {
coordinates: { x: clickData.absoluteMouseX, y: clickData.absoluteMouseY },
context: {
queryName: panelSeries.queryName,
signal: resolveDrilldownSignal(builderQuery),
filters,
timeRange,
label: focusedSeries.seriesName,
seriesColor: focusedSeries.color,
},
};
}

View File

@@ -1,49 +0,0 @@
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { resolveDrilldownSignal } from './signal';
interface EnrichNumberClickArgs {
/** The panel's scalar tables — the displayed value's column selects the drilldown query. */
tables: PanelTable[];
/** The panel's builder queries; resolves the clicked query's signal by name. */
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (the value has no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a Number/Value click into a drilldown payload. Drills into the query the panel actually
* displays — the first table-with-rows' value column (mirrors `prepareNumberData`), not blindly
* `builderQueries[0]` (they diverge for multi-query panels). Returns `null` when that query isn't
* drillable (promql/formula).
*/
export function enrichNumberClick({
tables,
builderQueries,
coordinates,
timeRange,
}: EnrichNumberClickArgs): DrilldownClickPayload | null {
const valueColumn = tables
.find((table) => table.rows.length > 0)
?.columns.find((column) => column.isValueColumn);
const queryName = valueColumn?.queryName ?? builderQueries[0]?.name ?? '';
if (!isValidQueryName(queryName)) {
return null;
}
const builderQuery = builderQueries.find((query) => query.name === queryName);
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: [],
timeRange,
},
};
}

View File

@@ -1,50 +0,0 @@
import type { PieSlice } from 'container/DashboardContainer/visualization/charts/types';
import { isValidQueryName } from 'container/QueryTable/Drilldown/drilldownUtils';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { getGroupByFilters } from './getGroupByFilters';
import { resolveDrilldownSignal } from './signal';
interface EnrichPieClickArgs {
slice: PieSlice;
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (pie slices have no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a pie-slice click into a drilldown payload, using the slice's source-row group-by label
* values (carried by `preparePieData`) as equality filters. Returns `null` when the slice has no
* drillable query.
*/
export function enrichPieClick({
slice,
builderQueries,
coordinates,
timeRange,
}: EnrichPieClickArgs): DrilldownClickPayload | null {
const queryName = slice.queryName ?? '';
if (!isValidQueryName(queryName)) {
return null;
}
const builderQuery = builderQueries.find((query) => query.name === queryName);
const filters = builderQuery
? getGroupByFilters(slice.labels ?? {}, builderQuery)
: [];
return {
coordinates,
context: {
queryName,
signal: resolveDrilldownSignal(builderQuery),
filters: filters,
timeRange,
label: slice.label,
seriesColor: slice.color,
},
};
}

View File

@@ -1,91 +0,0 @@
import { OPERATORS } from 'constants/queryBuilder';
import {
type FilterData,
isValidQueryName,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { PanelTable } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
import type { BuilderQuery } from 'types/api/v5/queryRange';
import type { DrilldownClickPayload } from '../../types/drilldown';
import { resolveDrilldownSignal } from './signal';
interface EnrichTableClickArgs {
/** The clicked row's data, keyed by column id (see `prepareScalarTables`). */
record: Record<string, unknown>;
/** The clicked column's key (`column.id || column.name`). */
columnId: string;
table: PanelTable;
builderQueries: BuilderQuery[];
coordinates: { x: number; y: number };
/** Explorer time window — the panel's fetched window (scalar tables have no clicked bucket). */
timeRange?: { startTime: number; endTime: number };
}
/**
* Turns a table cell click into a drilldown payload. The clicked value column (or the row's first
* value column) selects the aggregate query, and the row's group-by cells become equality filters
* (V1 `getFiltersToAddToView` parity). `columnKind` records whether a value or group column was
* clicked, for the future filter-by-value menu. Returns `null` when the row has no drillable
* aggregate query.
*/
export function enrichTableClick({
record,
columnId,
table,
builderQueries,
coordinates,
timeRange,
}: EnrichTableClickArgs): DrilldownClickPayload | null {
const clickedColumn = table.columns.find(
(col) => (col.id || col.name) === columnId,
);
const valueColumn = clickedColumn?.isValueColumn
? clickedColumn
: table.columns.find((col) => col.isValueColumn);
if (!valueColumn || !isValidQueryName(valueColumn.queryName)) {
return null;
}
const filters = table.columns.reduce<FilterData[]>((acc, col) => {
if (col.isValueColumn) {
return acc;
}
const value = record[col.id || col.name];
if (value != null) {
// Group cell value → equality filter. Cast at the boundary: row data is `unknown`,
// group cells hold scalar label values.
acc.push({
filterKey: col.name,
filterValue: value as string | number,
operator: OPERATORS['='],
});
}
return acc;
}, []);
const builderQuery = builderQueries.find(
(query) => query.name === valueColumn.queryName,
);
// A group-column click filters by that single cell (V1 filter-by-value); a value-column click
// opens the aggregate menu scoped by the whole row.
const isGroupColumn = clickedColumn != null && !clickedColumn.isValueColumn;
return {
coordinates,
context: {
queryName: valueColumn.queryName,
signal: resolveDrilldownSignal(builderQuery),
filters,
timeRange,
// No `label`: like Number/Value, the aggregate menu header falls back to the
// aggregation expression (e.g. `sum(signoz_calls_total)`), not the column name (V1 parity).
columnKind: isGroupColumn ? 'group' : 'aggregate',
clickedKey: isGroupColumn ? clickedColumn?.name : undefined,
clickedValue: isGroupColumn
? (record[columnId] as string | number)
: undefined,
},
};
}

View File

@@ -1,29 +0,0 @@
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
/** An auto-generated drilldown link (label + destination URL). */
export interface DrilldownDataLink {
id: string;
label: string;
url: string;
}
/**
* Links derived automatically from the clicked point's filters — the V2 port of V1's `getDataLinks`.
* Currently a single "View Trace Details" link when the clicked row carries a `trace_id`.
*/
export function getDataLinks(filters: FilterData[]): DrilldownDataLink[] {
const links: DrilldownDataLink[] = [];
const traceId = filters.find(
(filter) => filter.filterKey === 'trace_id',
)?.filterValue;
if (traceId) {
links.push({
id: 'view-trace-details',
label: 'View Trace Details',
url: `/trace/${traceId}`,
});
}
return links;
}

View File

@@ -1,26 +0,0 @@
import {
type FilterData,
getFiltersFromMetric,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Equality filters for the clicked series/slice, restricted to the query's group-by dimensions.
* `labels` also carry a display-only legend backfill (`{queryName: queryName}` when ungrouped, see
* `resolveLegendAndLabels`); intersecting with group-by drops it so `filters` stays empty when
* ungrouped, matching V1.
*/
export function getGroupByFilters(
labels: Record<string, string>,
builderQuery: BuilderQuery,
): FilterData[] {
const groupByKeys = new Set(
(builderQuery.groupBy ?? []).map((group) => group.name),
);
if (groupByKeys.size === 0) {
return [];
}
return getFiltersFromMetric(labels).filter((filter) =>
groupByKeys.has(filter.filterKey),
);
}

View File

@@ -1,50 +0,0 @@
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import { processContextLinks } from 'container/NewWidget/RightContainer/ContextLinks/utils';
import type { ContextLinkProps } from 'types/api/dashboard/getAll';
/** A panel context link with its label and URL templates resolved, ready to render. */
export interface ResolvedDrilldownLink {
id: string;
label: string;
url: string;
}
/**
* Resolves a panel's context links for the drilldown menu. Adapts each `DashboardLinkDTO` to the V1
* `ContextLinkProps` the shared `processContextLinks` resolver expects, substitutes variables in the
* label + URL, and drops links without a URL. Links with `renderVariables === false` keep their raw
* label/URL (no substitution).
*/
export function resolvePanelContextLinks(
links: DashboardLinkDTO[] | undefined,
processedVariables: Record<string, string>,
): ResolvedDrilldownLink[] {
const usable = (links ?? []).filter((link) => !!link.url);
if (usable.length === 0) {
return [];
}
const adapted: ContextLinkProps[] = usable.map((link, index) => ({
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
}));
const resolved = processContextLinks(adapted, processedVariables, 50);
return usable.map((link, index) => {
// `renderVariables` defaults to on; only an explicit `false` opts out of substitution.
if (link.renderVariables === false) {
return {
id: String(index),
label: link.name || link.url || '',
url: link.url ?? '',
};
}
return {
id: resolved[index].id,
label: resolved[index].label,
url: resolved[index].url,
};
});
}

View File

@@ -1,19 +0,0 @@
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { BuilderQuery } from 'types/api/v5/queryRange';
/**
* Maps a V5 builder query's `signal` to the drilldown signal. Meter and unknown signals fall back to
* `metrics` so the drilldown always targets a real explorer.
*/
export function resolveDrilldownSignal(
query: BuilderQuery | undefined,
): TelemetrytypesSignalDTO {
switch (query?.signal) {
case 'logs':
return TelemetrytypesSignalDTO.logs;
case 'traces':
return TelemetrytypesSignalDTO.traces;
default:
return TelemetrytypesSignalDTO.metrics;
}
}

View File

@@ -1,28 +0,0 @@
import type { Querybuildertypesv5QueryRangeRequestDTO } from 'api/generated/services/sigNoz.schemas';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import store from 'store';
/** Panel time window in epoch SECONDS (uPlot X-scale + drilldown explorer window). */
interface PanelTimeRange {
startTime: number;
endTime: number;
}
/**
* Time window a panel's data was fetched over, read off the request's `start`/`end` (ms → s).
* Falls back to the dashboard global-time window when the panel hasn't fetched yet.
*/
export function getPanelTimeRange(
request: Querybuildertypesv5QueryRangeRequestDTO | undefined,
): PanelTimeRange {
if (request?.start && request?.end) {
return { startTime: request.start / 1000, endTime: request.end / 1000 };
}
const { globalTime } = store.getState();
const { start, end } = getStartEndRangeTime({
type: 'GLOBAL_TIME',
interval: globalTime.selectedTime,
});
return { startTime: parseInt(start, 10), endTime: parseInt(end, 10) };
}

View File

@@ -1,10 +0,0 @@
.signal {
text-transform: capitalize;
}
.label {
overflow: hidden;
font-weight: normal;
white-space: nowrap;
text-overflow: ellipsis;
}

View File

@@ -1,165 +0,0 @@
import { useMemo } from 'react';
import {
Braces,
ChartBar,
DraftingCompass,
Link,
Loader,
ScrollText,
} from '@signozhq/icons';
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
import { getAggregateColumnHeader } from 'container/QueryTable/Drilldown/drilldownUtils';
import ContextMenu from 'periscope/components/ContextMenu';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { getDataLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/getDataLinks';
import { resolvePanelContextLinks } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/resolvePanelContextLinks';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { openInNewTab } from 'utils/navigation';
import { useDrilldownContextVariables } from '../hooks/useDrilldownContextVariables';
import styles from './DrilldownAggregateMenu.module.scss';
interface DrilldownAggregateMenuProps {
context: DrilldownContext;
/** Panel's V5→V1 query — supplies the aggregation-expression header fallback. */
query: Query;
/** While dashboard variables resolve, the actions show a spinner and are disabled. */
isResolving?: boolean;
/** Panel's context links; resolved against the clicked point + variables here. */
links: DashboardLinkDTO[] | undefined;
/** Whether the clicked point exposes group-by fields to bind to dashboard variables. */
canSetDashboardVariables: boolean;
onViewLogs: () => void;
onViewTraces: () => void;
onBreakout: () => void;
/** Open the Dashboard Variables submenu (set/unset/create from the clicked value). */
onSetDashboardVariables: () => void;
/** Close the popover (context-link clicks). */
onClose: () => void;
}
/**
* The base aggregate drill-down menu: tinted header + View in Logs/Traces, Breakout, and the
* panel's context links. Mounted only while open, so the variable/link resolution runs only then.
* Metrics is omitted — V1 surfaces only Logs/Traces.
*/
function DrilldownAggregateMenu({
context,
query,
isResolving = false,
links,
canSetDashboardVariables,
onViewLogs,
onViewTraces,
onBreakout,
onSetDashboardVariables,
onClose,
}: DrilldownAggregateMenuProps): JSX.Element {
const aggregations = useMemo(
() => getAggregateColumnHeader(query, context.queryName).aggregations,
[query, context.queryName],
);
const processedVariables = useDrilldownContextVariables(context);
const contextLinks = useMemo(
() => resolvePanelContextLinks(links, processedVariables),
[links, processedVariables],
);
// Auto links derived from the clicked point itself (e.g. "View Trace Details" for a trace_id).
const dataLinks = useMemo(
() => getDataLinks(context.filters),
[context.filters],
);
return (
<>
<ContextMenu.Header>
<div className={styles.signal}>{context.signal}</div>
<div className={styles.label} style={{ color: context.seriesColor }}>
{context.label || aggregations}
</div>
</ContextMenu.Header>
{canSetDashboardVariables && (
<ContextMenu.Item
icon={
<span style={{ color: context.seriesColor }}>
<Braces size={16} />
</span>
}
onClick={onSetDashboardVariables}
>
<span data-testid="drilldown-dashboard-variables">
Dashboard Variables
</span>
</ContextMenu.Item>
)}
<ContextMenu.Item
icon={
isResolving ? (
<Loader className="animate-spin" size={16} color={context.seriesColor} />
) : (
<span style={{ color: context.seriesColor }}>
<ScrollText size={16} />
</span>
)
}
onClick={onViewLogs}
disabled={isResolving}
>
<span data-testid="drilldown-view-logs">View in Logs</span>
</ContextMenu.Item>
<ContextMenu.Item
icon={
isResolving ? (
<Loader className="animate-spin" color={context.seriesColor} size={16} />
) : (
<span style={{ color: context.seriesColor }}>
<DraftingCompass size={16} />
</span>
)
}
onClick={onViewTraces}
disabled={isResolving}
>
<span data-testid="drilldown-view-traces">View in Traces</span>
</ContextMenu.Item>
<ContextMenu.Item
icon={
<span style={{ color: context.seriesColor }}>
<ChartBar size={16} />
</span>
}
onClick={onBreakout}
>
<span data-testid="drilldown-breakout">Breakout by ..</span>
</ContextMenu.Item>
{dataLinks.map((link) => (
<ContextMenu.Item
key={link.id}
icon={<Link size={16} color={context.seriesColor} />}
onClick={(): void => {
openInNewTab(link.url);
onClose();
}}
>
<span data-testid="drilldown-data-link">{link.label}</span>
</ContextMenu.Item>
))}
{contextLinks.map((link) => (
<ContextMenu.Item
key={link.id}
icon={<Link size={16} color={context.seriesColor} />}
onClick={(): void => {
openInNewTab(link.url);
onClose();
}}
>
<span data-testid="drilldown-context-link">{link.label}</span>
</ContextMenu.Item>
))}
</>
);
}
export default DrilldownAggregateMenu;

View File

@@ -1,9 +0,0 @@
.header {
display: flex;
align-items: center;
gap: 8px;
}
.backArrow {
cursor: pointer;
}

View File

@@ -1,45 +0,0 @@
import { ArrowLeft } from '@signozhq/icons';
import BreakoutOptions from 'container/QueryTable/Drilldown/BreakoutOptions';
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
import ContextMenu from 'periscope/components/ContextMenu';
import type { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import styles from './DrilldownBreakoutMenu.module.scss';
interface DrilldownBreakoutMenuProps {
/** The clicked query's builder data — supplies the picker's available attributes. */
queryData: IBuilderQuery;
/** Regroup the clicked query by the picked attribute. */
onBreakout: (groupBy: BreakoutAttributeType) => void;
/** Return to the base aggregate menu. */
onBack: () => void;
}
/**
* The "Breakout by .." submenu — a back header + V1's read-only `BreakoutOptions` attribute picker,
* wired to `onBreakout`.
*/
function DrilldownBreakoutMenu({
queryData,
onBreakout,
onBack,
}: DrilldownBreakoutMenuProps): JSX.Element {
return (
<>
<ContextMenu.Header>
<div className={styles.header}>
<ArrowLeft
size={14}
className={styles.backArrow}
onClick={onBack}
data-testid="drilldown-breakout-back"
/>
<span>Breakout by</span>
</div>
</ContextMenu.Header>
<BreakoutOptions queryData={queryData} onColumnClick={onBreakout} />
</>
);
}
export default DrilldownBreakoutMenu;

View File

@@ -1,9 +0,0 @@
.header {
display: flex;
align-items: center;
gap: 8px;
}
.backArrow {
cursor: pointer;
}

View File

@@ -1,79 +0,0 @@
import { ArrowLeft, Plus, Settings, X } from '@signozhq/icons';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
type DrilldownVariableAction,
DrilldownVariableActionKind,
} from 'pages/DashboardPageV2/DashboardContainer/PanelsAndSectionsLayout/Panel/hooks/useDrilldownDashboardVariables';
import ContextMenu from 'periscope/components/ContextMenu';
import styles from './DrilldownDashboardVariablesMenu.module.scss';
interface DrilldownDashboardVariablesMenuProps {
/** Resolved entries from `useDrilldownDashboardVariables`. */
actions: DrilldownVariableAction[];
/** Return to the base aggregate menu. */
onBack: () => void;
}
const ICON_BY_KIND: Record<DrilldownVariableActionKind, JSX.Element> = {
[DrilldownVariableActionKind.Set]: <Settings size={16} />,
[DrilldownVariableActionKind.Unset]: <X size={16} />,
[DrilldownVariableActionKind.Create]: <Plus size={16} />,
};
/**
* The "Dashboard Variables" drilldown submenu — renders the entries resolved by
* `useDrilldownDashboardVariables` (Set/Unset an existing dynamic variable, or Create one).
*/
function DrilldownDashboardVariablesMenu({
actions,
onBack,
}: DrilldownDashboardVariablesMenuProps): JSX.Element {
return (
<>
<ContextMenu.Header>
<div className={styles.header}>
<ArrowLeft
size={14}
className={styles.backArrow}
onClick={onBack}
data-testid="drilldown-var-back"
/>
<span>Dashboard Variables</span>
</div>
</ContextMenu.Header>
<OverlayScrollbar
style={{ maxHeight: '200px' }}
options={{ overflow: { x: 'hidden' } }}
>
<>
{actions.map(({ fieldName, fieldValue, kind, onClick }) => (
<ContextMenu.Item
key={fieldName}
icon={ICON_BY_KIND[kind]}
onClick={onClick}
>
{kind === DrilldownVariableActionKind.Unset && (
<span data-testid="drilldown-var-unset">
Unset <strong>${fieldName}</strong>
</span>
)}
{kind === DrilldownVariableActionKind.Set && (
<span data-testid="drilldown-var-set">
Set <strong>${fieldName}</strong> to <strong>{fieldValue}</strong>
</span>
)}
{kind === DrilldownVariableActionKind.Create && (
<span data-testid="drilldown-var-create">
Create var <strong>${fieldName}</strong>:<strong>{fieldValue}</strong>
</span>
)}
</ContextMenu.Item>
))}
</>
</OverlayScrollbar>
</>
);
}
export default DrilldownDashboardVariablesMenu;

View File

@@ -1,40 +0,0 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getGroupContextMenuConfig } from 'container/QueryTable/Drilldown/contextConfig';
import type { ClickedData } from 'periscope/components/ContextMenu';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
interface DrilldownFilterMenuProps {
/** The panel's V5→V1 query the operator menu builds filters against. */
v1Query: Query;
/** The clicked group column's key. */
clickedKey: string;
/** Apply the chosen operator (adds the filter and opens the View modal). */
onFilter: (operator: string) => void;
}
/**
* The group-column "filter by value" submenu — the operator list from V1's read-only
* `getGroupContextMenuConfig`, wired to `onFilter`.
*/
function DrilldownFilterMenu({
v1Query,
clickedKey,
onFilter,
}: DrilldownFilterMenuProps): JSX.Element {
const clickedData: ClickedData = {
column: { dataIndex: clickedKey },
record: { key: clickedKey, timestamp: 0 },
};
return (
<>
{getGroupContextMenuConfig({
query: v1Query,
clickedData,
panelType: PANEL_TYPES.TABLE,
onColumnClick: onFilter,
}).items ?? null}
</>
);
}
export default DrilldownFilterMenu;

View File

@@ -3,13 +3,11 @@ import type {
DashboardtypesPanelDTO,
DashboardtypesTimePreferenceDTO,
} from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { panelTimePreferenceLabel } from 'pages/DashboardPageV2/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPageV2/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
@@ -70,7 +68,6 @@ function Panel({
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
return (
<div
@@ -103,11 +100,8 @@ function Panel({
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
)}
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}

View File

@@ -294,13 +294,4 @@ describe('usePanelActionItems', () => {
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
it('create-alert seeds an alert from this panel', () => {
const { result } = renderHook(() => usePanelActionItems(baseArgs));
const createAlert = result.current.items.find(
(i) => 'key' in i && i.key === 'create-alert',
);
(createAlert as { onClick: () => void }).onClick();
expect(mockCreateAlert).toHaveBeenCalledWith(mockPanel, 'panel-1');
});
});

View File

@@ -3,13 +3,12 @@ import type { ComponentTypes } from 'utils/permission';
/**
* Every action the panel menu can offer: per-kind gated capabilities (minus
* `search` and `drilldown`, which are renderer-wired controls, not menu items)
* plus the chrome actions every kind gets. The `Record<PanelActionId, …>` below
* forces a meta entry per id, so adding an action without declaring its gates is
* a compile error.
* `search`, a header control) plus the chrome actions every kind gets. The
* `Record<PanelActionId, …>` below forces a meta entry per id, so adding an
* action without declaring its gates is a compile error.
*/
export type PanelActionId =
| Exclude<keyof PanelActionCapabilities, 'search' | 'drilldown'>
| Exclude<keyof PanelActionCapabilities, 'search'>
| 'move'
| 'delete';

View File

@@ -3,7 +3,6 @@ import { Loader, RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'container/DashboardContainer/visualization/panels/types';
import PanelMessage from 'pages/DashboardPageV2/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { AnyPanelInteractionProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/interactions';
import type { RenderablePanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
@@ -38,10 +37,6 @@ interface PanelBodyProps {
pagination?: PanelPagination;
/** Close the standalone View modal — only consumed by the time-series/bar graph manager. */
onCloseStandaloneView?: () => void;
/** Opens the drill-down context menu; threaded to interactive renderers. */
onClick?: AnyPanelInteractionProps['onClick'];
/** Gate for the drill-down menu — kind supported and the panel has a builder query. */
enableDrillDown?: boolean;
}
/**
@@ -63,8 +58,6 @@ function PanelBody({
searchTerm,
pagination,
onCloseStandaloneView,
onClick,
enableDrillDown = false,
}: PanelBodyProps): JSX.Element {
// A retained response (keepPreviousData) counts as data only if its type matches the current
// request — else a prior panel kind's response (time_series → raw) flashes NoData on switch.
@@ -127,8 +120,7 @@ function PanelBody({
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
enableDrillDown={enableDrillDown}
onClick={onClick}
enableDrillDown={false}
dashboardPreference={dashboardPreference}
searchTerm={searchTerm}
pagination={pagination}

View File

@@ -1,69 +1,22 @@
.panelTypeSection {
display: flex;
flex-direction: column;
gap: 12px;
align-items: center;
}
.grid {
align-self: stretch;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.panelTypeCard {
.typeButton {
display: flex;
flex-direction: column;
align-items: center;
border: 1px solid var(--l2-border);
background: var(--l2-background);
gap: 8px;
padding: 12px;
gap: 12px;
cursor: pointer;
font: inherit;
background: var(--bg-ink-400, #0b0c0e);
border: 1px solid var(--l1-border);
border-radius: 4px;
color: var(--l1-foreground);
transition:
transform 180ms ease,
border-color 180ms ease;
cursor: pointer;
text-align: left;
&:hover {
background-color: var(--l2-background-hover);
border-color: var(--bg-robin-400);
}
&:active {
transform: translateY(2px);
border-color: var(--bg-robin-500);
}
}
.panelTypeCardSelected {
border-color: var(--bg-robin-400);
background-color: var(--l2-background-hover);
box-shadow: inset 0 0 0 1px var(--bg-robin-400);
}
.footerActions {
display: flex;
align-items: flex-end;
justify-content: flex-end;
gap: 8px;
width: 100%;
}
.footerPicker {
// Take all the width left over by the (natural-width) confirm button.
display: flex;
flex: 1;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.pickerLabel {
color: var(--l3-foreground);
font-size: 11px;
font-weight: 500;
letter-spacing: 0.06em;
text-transform: uppercase;
}

View File

@@ -1,135 +1,45 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Modal } from 'antd';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import cx from 'classnames';
import { useDashboardSections } from '../../../hooks/useDashboardSections';
import type { PanelKind } from '../../../Panels/types/panelKind';
import { PANEL_TYPES } from './constants';
import SectionPicker from './SectionPicker';
import { buildSectionOptions, resolveDefaultSectionValue } from './utils';
import styles from './PanelTypeSelectionModal.module.scss';
import { Plus } from '@signozhq/icons';
interface PanelTypeSelectionModalProps {
open: boolean;
onClose: () => void;
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
/** Section the picker opens on; omit → the untitled root / first section. */
defaultLayoutIndex?: number;
onSelect: (panelKind: PanelKind) => void;
}
/** Fake loader shown on the confirm button before navigating to the editor. */
const CONFIRM_LOADER_MS = 500;
function PanelTypeSelectionModal({
open,
onClose,
onSelect,
defaultLayoutIndex,
}: PanelTypeSelectionModalProps): JSX.Element {
const sections = useDashboardSections();
const options = useMemo(() => buildSectionOptions(sections), [sections]);
const hasSectionPicker = options.length > 1;
const [selectedValue, setSelectedValue] = useState('');
const [selectedPanelKind, setSelectedPanelKind] = useState<PanelKind | null>(
null,
);
const [isSubmitting, setIsSubmitting] = useState(false);
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const clearTimer = useCallback((): void => {
if (timerRef.current !== null) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
}, []);
// Seed the target section on open; cancel a pending navigation on close.
useEffect(() => {
if (open) {
setSelectedValue(resolveDefaultSectionValue(options, defaultLayoutIndex));
setSelectedPanelKind(null);
setIsSubmitting(false);
} else {
clearTimer();
}
}, [open, options, defaultLayoutIndex, clearTimer]);
useEffect(() => clearTimer, [clearTimer]);
const handleConfirm = (): void => {
if (selectedPanelKind === null || isSubmitting) {
return;
}
setIsSubmitting(true);
const layoutIndex = selectedValue === '' ? undefined : Number(selectedValue);
timerRef.current = setTimeout(() => {
onSelect(selectedPanelKind, layoutIndex);
}, CONFIRM_LOADER_MS);
};
// Footer is always shown; the confirm button is disabled until a panel type is picked.
const footer = (
<div className={styles.footerActions}>
{hasSectionPicker && (
<div className={styles.footerPicker}>
<span className={styles.pickerLabel}>Add panel to</span>
<SectionPicker
options={options}
value={selectedValue}
onChange={setSelectedValue}
/>
</div>
)}
<Button
color="primary"
size="md"
disabled={selectedPanelKind === null}
loading={isSubmitting}
prefix={<Plus size={16} />}
onClick={handleConfirm}
testId="panel-type-confirm"
>
Add Panel
</Button>
</div>
);
return (
<DialogWrapper
<Modal
open={open}
onOpenChange={(isOpen): void => {
if (!isOpen) {
onClose();
}
}}
title="New Panel"
footer={footer}
title="Select a panel type"
onCancel={onClose}
footer={null}
destroyOnClose
>
<div className={styles.panelTypeSection}>
<span className={styles.pickerLabel}>Select panel type</span>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<button
key={panelKind}
type="button"
className={cx(styles.panelTypeCard, {
[styles.panelTypeCardSelected]: panelKind === selectedPanelKind,
})}
data-testid={`panel-type-${panelKind}`}
aria-pressed={panelKind === selectedPanelKind}
onClick={(): void => setSelectedPanelKind(panelKind)}
>
<Icon size={24} color={Color.BG_ROBIN_400} />
{label}
</button>
))}
</div>
<div className={styles.grid}>
{PANEL_TYPES.map(({ panelKind, label, Icon }) => (
<Button
key={panelKind}
type="button"
variant="ghost"
className={styles.typeButton}
data-testid={`panel-type-${panelKind}`}
onClick={(): void => onSelect(panelKind)}
>
<Icon size={14} />
{label}
</Button>
))}
</div>
</DialogWrapper>
</Modal>
);
}

View File

@@ -1,55 +0,0 @@
.select {
width: 100%;
}
.dropdown {
min-width: 260px;
}
.rootIcon {
color: var(--bg-robin-400);
flex-shrink: 0;
}
.sectionIcon {
color: var(--l3-foreground);
flex-shrink: 0;
}
.triggerValue {
display: flex;
align-items: center;
gap: 8px;
overflow: hidden;
}
.triggerLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.optionRow {
display: flex;
align-items: center;
gap: 12px;
padding: 4px 0;
}
.optionText {
display: flex;
flex-direction: column;
gap: 2px;
overflow: hidden;
}
.optionLabel {
color: var(--l1-foreground);
line-height: 1.2;
}
.optionDescription {
color: var(--l3-foreground);
font-size: 12px;
line-height: 1.2;
}

View File

@@ -1,65 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line signoz/no-antd-components
import { Select } from 'antd';
import type { SectionOption } from './types';
import styles from './SectionPicker.module.scss';
interface SectionPickerProps {
options: SectionOption[];
value: string;
onChange: (value: string) => void;
}
function SectionPicker({
options,
value,
onChange,
}: SectionPickerProps): JSX.Element {
// `selectedLabel` (one line) shows in the trigger; `label` (two lines) in the list.
const selectOptions = useMemo(
() =>
options.map((option) => {
const iconClass = option.isRoot ? styles.rootIcon : styles.sectionIcon;
return {
value: option.value,
selectedLabel: (
<span className={styles.triggerValue}>
<option.Icon size={16} className={iconClass} />
<span className={styles.triggerLabel}>{option.label}</span>
</span>
),
label: (
<span
className={styles.optionRow}
data-testid={`panel-section-option-${option.layoutIndex}`}
>
<option.Icon size={16} className={iconClass} />
<span className={styles.optionText}>
<span className={styles.optionLabel}>{option.label}</span>
<span className={styles.optionDescription}>{option.description}</span>
</span>
</span>
),
};
}),
[options],
);
return (
<Select<string>
className={styles.select}
popupClassName={styles.dropdown}
value={value}
onChange={onChange}
data-testid="panel-section-select"
optionLabelProp="selectedLabel"
getPopupContainer={(trigger): HTMLElement =>
trigger.parentElement ?? document.body
}
options={selectOptions}
/>
);
}
export default SectionPicker;

View File

@@ -14,16 +14,3 @@ export interface PanelType {
/** Icon component — the consumer renders it and controls size/color/etc. */
Icon: ComponentType<IconProps>;
}
export interface SectionOption {
/** The section's `layoutIndex`, stringified for the Select value. */
value: string;
layoutIndex: number;
/** Section title, or "Dashboard (root)" for the untitled top-level layout. */
label: string;
/** Caption under the label. */
description: string;
/** Untitled top-level layout (has no section header). */
isRoot: boolean;
Icon: ComponentType<IconProps>;
}

View File

@@ -1,41 +0,0 @@
import { LayoutDashboard, Rows2 } from '@signozhq/icons';
import type { DashboardSection } from '../../../utils';
import type { SectionOption } from './types';
const ROOT_LABEL = 'Dashboard (root)';
const ROOT_DESCRIPTION = 'Top level — no section';
const SECTION_DESCRIPTION = 'Section';
/** Maps dashboard sections to section-picker options (untitled → "root"). */
export function buildSectionOptions(
sections: DashboardSection[],
): SectionOption[] {
return sections.map((section) => {
const isRoot = !section.title;
return {
value: String(section.layoutIndex),
layoutIndex: section.layoutIndex,
label: isRoot ? ROOT_LABEL : (section.title as string),
description: isRoot ? ROOT_DESCRIPTION : SECTION_DESCRIPTION,
isRoot,
Icon: isRoot ? LayoutDashboard : Rows2,
};
});
}
/**
* Picks the option the picker should open on: the section the "Add panel" was
* triggered from when present and still valid, otherwise the first option.
*/
export function resolveDefaultSectionValue(
options: SectionOption[],
defaultLayoutIndex: number | undefined,
): string {
const fallback = options[0]?.value ?? '';
if (defaultLayoutIndex === undefined) {
return fallback;
}
const target = String(defaultLayoutIndex);
return options.some((option) => option.value === target) ? target : fallback;
}

View File

@@ -1,64 +0,0 @@
import { type KeyboardEvent, useCallback } from 'react';
import { QueryBuilderV2 } from 'components/QueryBuilderV2/QueryBuilderV2';
import { PANEL_TYPES } from 'constants/queryBuilder';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import styles from './ViewPanelModal.module.scss';
interface ViewPanelQueryBuilderProps {
panelType: PANEL_TYPES;
/** Preview fetch in flight — drives the Run/Cancel button state. */
isLoadingQueries: boolean;
/** Run the current query (Run Query button / ⌘↵). */
onStageRunQuery: () => void;
/** Abort the in-flight preview fetch. */
onCancelQuery: () => void;
}
/**
* Drilldown query editor for the View modal. Mirrors V1's FullView: the query builder
* rows + a "Run Query" button, with NO query-type tabs (ClickHouse/PromQL) — drilldown
* is query-builder only, exactly as V1.
*/
function ViewPanelQueryBuilder({
panelType,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
}: ViewPanelQueryBuilderProps): JSX.Element {
const handleKeyDownCapture = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
event.preventDefault();
event.stopPropagation();
onStageRunQuery();
}
},
[onStageRunQuery],
);
return (
<div
className={styles.queryBuilder}
data-testid="view-panel-query-builder"
onKeyDownCapture={handleKeyDownCapture}
role="presentation"
>
<QueryBuilderV2
panelType={panelType}
version="v3"
isListViewPanel={panelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
/>
<div className={styles.queryBuilderToolbar}>
<RightToolbarActions
handleCancelQuery={onCancelQuery}
onStageRunQuery={onStageRunQuery}
isLoadingQueries={isLoadingQueries}
/>
</div>
</div>
);
}
export default ViewPanelQueryBuilder;

View File

@@ -1,74 +0,0 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import {
type DrilldownVariableAction,
DrilldownVariableActionKind,
} from '../hooks/useDrilldownDashboardVariables';
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
function action(
overrides: Partial<DrilldownVariableAction> = {},
): DrilldownVariableAction {
return {
fieldName: 'service.name',
fieldValue: 'frontend',
kind: DrilldownVariableActionKind.Set,
onClick: jest.fn(),
...overrides,
};
}
describe('DrilldownDashboardVariablesMenu', () => {
it('renders each kind with its own label and fires its onClick', async () => {
const onSet = jest.fn();
const onUnset = jest.fn();
const onCreate = jest.fn();
render(
<DrilldownDashboardVariablesMenu
onBack={jest.fn()}
actions={[
action({
fieldName: 'a',
kind: DrilldownVariableActionKind.Set,
onClick: onSet,
}),
action({
fieldName: 'b',
kind: DrilldownVariableActionKind.Unset,
onClick: onUnset,
}),
action({
fieldName: 'c',
kind: DrilldownVariableActionKind.Create,
onClick: onCreate,
}),
]}
/>,
);
expect(screen.getByTestId('drilldown-var-set')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-var-unset')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(onSet).toHaveBeenCalledTimes(1);
});
it('returns to the base menu when the back arrow is clicked', async () => {
const onBack = jest.fn();
render(
<DrilldownDashboardVariablesMenu onBack={onBack} actions={[action()]} />,
);
await userEvent.click(screen.getByTestId('drilldown-var-back'));
expect(onBack).toHaveBeenCalledTimes(1);
});
});

View File

@@ -1,299 +0,0 @@
import { act, render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { useDrilldown } from '../hooks/useDrilldown';
const mockOpenViewWithQuery = jest.fn();
const mockNavigate = jest.fn();
const mockGetBuilderQueries = jest.fn();
let mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
let mockDashboardVariables: {
hasFieldVariables: boolean;
actions: unknown[];
} = {
hasFieldVariables: true,
actions: [],
};
// Boundaries tested elsewhere / needing external context — mocked so this suite isolates
// useDrilldown's orchestration (gating, which menu shows, the View-modal handoff).
jest.mock('../hooks/useViewPanel', () => ({
useViewPanel: (): unknown => ({ openViewWithQuery: mockOpenViewWithQuery }),
}));
// Variable-substitution boundary (redux/store/react-query) — its own logic is out of scope here.
jest.mock('../hooks/useResolvedDrilldownQuery', () => ({
useResolvedDrilldownQuery: (): unknown => mockResolved,
}));
jest.mock('../hooks/useDrilldownBreakout', () => ({
useDrilldownBreakout: (): unknown => ({
queryData: { queryName: 'A' },
onBreakout: jest.fn(),
}),
}));
jest.mock('../DrilldownMenu/DrilldownBreakoutMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="breakout-submenu" />,
}));
jest.mock('../hooks/useDrilldownDashboardVariables', () => ({
useDrilldownDashboardVariables: (): unknown => mockDashboardVariables,
}));
jest.mock('../DrilldownMenu/DrilldownDashboardVariablesMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="dashboard-variables-submenu" />,
}));
// Context-link variable map (redux global time + store) — out of scope for this suite.
jest.mock('../hooks/useDrilldownContextVariables', () => ({
useDrilldownContextVariables: (): unknown => ({}),
}));
jest.mock('container/QueryTable/Drilldown/useBaseDrilldownNavigate', () => ({
__esModule: true,
default: (): unknown => mockNavigate,
}));
jest.mock('container/QueryTable/Drilldown/contextConfig', () => ({
getGroupContextMenuConfig: ({
onColumnClick,
}: {
onColumnClick: (op: string) => void;
}): unknown => ({
items: (
<button
type="button"
data-testid="filter-op"
onClick={(): void => onColumnClick('=')}
>
Is this
</button>
),
}),
}));
jest.mock('container/QueryTable/Drilldown/drilldownUtils', () => ({
addFilterToQuery: jest.fn(() => 'REFINED_QUERY'),
getAggregateColumnHeader: (): unknown => ({
aggregations: 'sum(x)',
dataSource: 'metrics',
}),
getBaseMeta: (): unknown => undefined,
isNumberDataType: (): boolean => false,
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters',
() => ({
fromPerses: (): string => 'V1_QUERY',
toPerses: jest.fn(() => [{ kind: 'REFINED' }]),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
() => ({
getBuilderQueries: (...args: unknown[]): unknown =>
mockGetBuilderQueries(...args),
}),
);
// Capability lookup mocked (its per-kind values are data in the definitions); avoids
// importing the whole renderer registry into the test.
jest.mock('pages/DashboardPageV2/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: (kind: string): unknown => ({
actions: { drilldown: kind !== 'signoz/ListPanel' },
}),
}));
function panelOfKind(kind: string): DashboardtypesPanelDTO {
return {
spec: { plugin: { kind, spec: {} }, queries: [{ x: 1 }] },
display: { name: 'P' },
} as unknown as DashboardtypesPanelDTO;
}
const tsPanel = panelOfKind('signoz/TimeSeriesPanel');
const aggregateContext: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.metrics,
filters: [],
label: 'frontend',
seriesColor: '#fff',
};
const groupContext: DrilldownContext = {
queryName: 'A',
signal: TelemetrytypesSignalDTO.metrics,
filters: [],
columnKind: 'group',
clickedKey: 'service.name',
clickedValue: 'frontend',
};
describe('useDrilldown', () => {
beforeEach(() => {
jest.clearAllMocks();
mockGetBuilderQueries.mockReturnValue([{ name: 'A' }]);
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: false };
mockDashboardVariables = {
hasFieldVariables: true,
actions: [],
};
});
describe('enableDrillDown', () => {
it('is true when the kind declares drilldown and has a builder query', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
expect(result.current.enableDrillDown).toBe(true);
});
it('is false when there is no builder query', () => {
mockGetBuilderQueries.mockReturnValue([]);
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
expect(result.current.enableDrillDown).toBe(false);
});
it('is false for a kind that opts out of drilldown', () => {
const { result } = renderHook(() =>
useDrilldown(panelOfKind('signoz/ListPanel'), 'p1'),
);
expect(result.current.enableDrillDown).toBe(false);
});
});
describe('aggregate menu', () => {
it('shows View in Logs/Traces + Breakout on an aggregate click', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(screen.getByTestId('drilldown-view-logs')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-view-traces')).toBeInTheDocument();
expect(screen.getByTestId('drilldown-breakout')).toBeInTheDocument();
});
it('navigates to logs when View in Logs is clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-view-logs'));
expect(mockNavigate).toHaveBeenCalledWith('view_logs');
});
it('disables navigation while dashboard variables resolve', async () => {
mockResolved = { resolvedQuery: 'RESOLVED_QUERY', isResolving: true };
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-view-logs'));
expect(mockNavigate).not.toHaveBeenCalled();
});
it('swaps to the breakout submenu when "Breakout by .." is clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
const view = render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-breakout'));
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
expect(screen.getByTestId('breakout-submenu')).toBeInTheDocument();
});
it('shows the Dashboard Variables entry when the click has group-by fields', () => {
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.getByTestId('drilldown-dashboard-variables'),
).toBeInTheDocument();
});
it('hides the Dashboard Variables entry when there are no group-by fields', () => {
mockDashboardVariables = { hasFieldVariables: false, actions: [] };
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.queryByTestId('drilldown-dashboard-variables'),
).not.toBeInTheDocument();
});
it('swaps to the Dashboard Variables submenu when clicked', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: aggregateContext,
}),
);
const view = render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('drilldown-dashboard-variables'));
view.rerender(<div>{result.current.contextMenuProps.items}</div>);
expect(
screen.getByTestId('dashboard-variables-submenu'),
).toBeInTheDocument();
});
});
describe('filter-by-value', () => {
it('opens the View modal with the refined query on a group-column filter', async () => {
const user = userEvent.setup();
const { result } = renderHook(() => useDrilldown(tsPanel, 'p1'));
act(() =>
result.current.onPanelClick({
coordinates: { x: 1, y: 1 },
context: groupContext,
}),
);
render(<div>{result.current.contextMenuProps.items}</div>);
await user.click(screen.getByTestId('filter-op'));
// Opens the View modal on the refined query at the panel's kind — persisted in the URL.
expect(mockOpenViewWithQuery).toHaveBeenCalledWith(
'p1',
'REFINED_QUERY',
PANEL_TYPES.TIME_SERIES,
);
});
});
});

View File

@@ -1,213 +0,0 @@
import { render, renderHook, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import { useDrilldownDashboardVariables } from '../hooks/useDrilldownDashboardVariables';
// Fake dashboard variables + runtime selection, mutated per test. `dtoToFormModel` is mocked to
// derive `type` from the plugin kind (like the real adapter), so these DTOs carry the plugin
// discriminant plus the flat form-model fields the hook reads.
let mockVariables: Array<{
name: string;
dynamicAttribute?: string;
multiSelect?: boolean;
spec: { plugin: { kind: string } };
}> = [];
let mockSelectionMap: Record<string, { value: unknown; allSelected: boolean }> =
{};
const mockSetVariableValue = jest.fn();
const mockSetUrlValues = jest.fn();
const mockPatchAsync = jest.fn().mockResolvedValue(undefined);
const DYNAMIC_KIND = 'signoz/DynamicVariable';
const QUERY_KIND = 'signoz/QueryVariable';
jest.mock('api/generated/services/dashboard', () => ({
useGetDashboardV2: (): unknown => ({
data: { data: { spec: { variables: mockVariables } } },
}),
}));
jest.mock(
'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch',
() => ({
useOptimisticPatch: (): unknown => ({ patchAsync: mockPatchAsync }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
() => ({
useDashboardStore: (selector: (state: unknown) => unknown): unknown =>
selector({
dashboardId: 'd1',
variableValues: { d1: mockSelectionMap },
setVariableValue: mockSetVariableValue,
}),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
() => ({
dtoToFormModel: (dto: {
spec?: { plugin?: { kind?: string } };
}): unknown => ({
...dto,
type:
dto.spec?.plugin?.kind === 'signoz/DynamicVariable' ? 'DYNAMIC' : 'QUERY',
}),
formModelToDto: (model: { name: string }): unknown => ({ dto: model.name }),
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel',
() => ({
emptyVariableFormModel: (): unknown => ({}),
DYNAMIC_SIGNAL_ALL: 'all',
}),
);
jest.mock(
'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection',
() => ({
ALL_SELECTED: '__ALL__',
variablesUrlParser: { withOptions: (): unknown => ({}) },
}),
);
jest.mock('nuqs', () => ({
useQueryState: (): unknown => [null, mockSetUrlValues],
}));
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,
default: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('@signozhq/ui/sonner', () => ({
toast: { success: jest.fn(), error: jest.fn() },
}));
const filters: FilterData[] = [
{ filterKey: 'service.name', filterValue: 'frontend', operator: '=' },
];
function renderItems(): void {
const { result } = renderHook(() =>
useDrilldownDashboardVariables({
filters,
signal: TelemetrytypesSignalDTO.metrics,
onClose: jest.fn(),
}),
);
render(
<DrilldownDashboardVariablesMenu
actions={result.current.actions}
onBack={jest.fn()}
/>,
);
}
describe('useDrilldownDashboardVariables', () => {
beforeEach(() => {
jest.clearAllMocks();
mockVariables = [];
mockSelectionMap = {};
});
it('hasFieldVariables reflects the clicked point group-by fields', () => {
const withFields = renderHook(() =>
useDrilldownDashboardVariables({ filters, onClose: jest.fn() }),
);
expect(withFields.result.current.hasFieldVariables).toBe(true);
const noFields = renderHook(() =>
useDrilldownDashboardVariables({ filters: [], onClose: jest.fn() }),
);
expect(noFields.result.current.hasFieldVariables).toBe(false);
});
it('offers Set — writing an array for a multi-select var so the selector shows it', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: true,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: ['backend'], allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: ['frontend'],
allSelected: false,
});
});
it('offers Set — writing a scalar for a single-select var', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: false,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: 'backend', allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-set'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: 'frontend',
allSelected: false,
});
});
it('offers Unset when the matching dynamic var already holds the clicked value', async () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
multiSelect: true,
spec: { plugin: { kind: DYNAMIC_KIND } },
},
];
mockSelectionMap = { svc: { value: ['frontend'], allSelected: false } };
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-unset'));
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'svc', {
value: [],
allSelected: false,
});
});
it('offers Create and persists a new dynamic variable when none matches', async () => {
mockVariables = [];
renderItems();
await userEvent.click(screen.getByTestId('drilldown-var-create'));
// Persisted to the spec via the optimistic patch...
expect(mockPatchAsync).toHaveBeenCalledTimes(1);
// ...and seeded (as an array — the created var is multi-select) with the clicked value.
expect(mockSetVariableValue).toHaveBeenCalledWith('d1', 'service.name', {
value: ['frontend'],
allSelected: false,
});
});
it('ignores a non-dynamic variable with the same attribute (still offers Create)', () => {
mockVariables = [
{
name: 'svc',
dynamicAttribute: 'service.name',
spec: { plugin: { kind: QUERY_KIND } },
},
];
renderItems();
expect(screen.getByTestId('drilldown-var-create')).toBeInTheDocument();
expect(screen.queryByTestId('drilldown-var-set')).not.toBeInTheDocument();
});
});

View File

@@ -1,243 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import type { ReactNode } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useBaseDrilldownNavigate from 'container/QueryTable/Drilldown/useBaseDrilldownNavigate';
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
import type {
DrilldownClickPayload,
DrilldownContext,
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
import { buildAggregateData } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/drilldown/buildAggregateData';
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
import { fromPerses } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import DrilldownAggregateMenu from '../DrilldownMenu/DrilldownAggregateMenu';
import DrilldownBreakoutMenu from '../DrilldownMenu/DrilldownBreakoutMenu';
import DrilldownDashboardVariablesMenu from '../DrilldownMenu/DrilldownDashboardVariablesMenu';
import DrilldownFilterMenu from '../DrilldownMenu/DrilldownFilterMenu';
import { useDrilldownBreakout } from './useDrilldownBreakout';
import { useDrilldownCoordinates } from './useDrilldownCoordinates';
import { useDrilldownDashboardVariables } from './useDrilldownDashboardVariables';
import { useDrilldownFilter } from './useDrilldownFilter';
import { useResolvedDrilldownQuery } from './useResolvedDrilldownQuery';
import { useViewPanel } from './useViewPanel';
/** Which menu the popover shows; extend as submenus are added (e.g. dashboard variables). */
enum DrilldownSubMenu {
Base = 'base',
Breakout = 'breakout',
DashboardVariables = 'dashboardVariables',
}
/** Stable empty-filters ref so the dashboard-variables hook doesn't re-run on every no-click render. */
const EMPTY_FILTERS: FilterData[] = [];
/** Props the panel shell spreads onto `<ContextMenu>`. */
export interface DrilldownContextMenuProps {
coordinates: Coordinates | null;
popoverPosition: PopoverPosition | null;
items: ReactNode;
onClose: () => void;
}
export interface UseDrilldownResult {
/** Whether interactive renderers should arm the drill-down click. */
enableDrillDown: boolean;
/** Renderer `onClick` handler — opens the menu at the clicked point. */
onPanelClick: (payload: DrilldownClickPayload) => void;
contextMenuProps: DrilldownContextMenuProps;
}
/**
* Orchestrates panel drill-down: owns the popover + which submenu is open, and routes the clicked
* point to the base aggregate menu (View in Logs/Traces), the group filter menu, or the breakout picker.
*/
export function useDrilldown(
panel: DashboardtypesPanelDTO,
panelId: string,
): UseDrilldownResult {
const kind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[kind];
const queries = panel.spec.queries;
// Kind must opt in via its capability AND have a builder query to drill into.
const enableDrillDown = useMemo(
() =>
getPanelDefinition(kind).actions.drilldown &&
getBuilderQueries(queries).length > 0,
[kind, queries],
);
const v1Query = useMemo(
() => fromPerses(queries, panelType),
[queries, panelType],
);
const {
coordinates,
popoverPosition,
clickedData: context,
onClick,
onClose,
} = useDrilldownCoordinates<DrilldownContext>();
const aggregateData = useMemo(
() => (context ? buildAggregateData(context) : null),
[context],
);
// A fresh click and any close reset to the base menu.
const [subMenu, setSubMenu] = useState<DrilldownSubMenu>(
DrilldownSubMenu.Base,
);
const openBreakout = useCallback(
(): void => setSubMenu(DrilldownSubMenu.Breakout),
[],
);
const backToBase = useCallback(
(): void => setSubMenu(DrilldownSubMenu.Base),
[],
);
const openDashboardVariables = useCallback(
(): void => setSubMenu(DrilldownSubMenu.DashboardVariables),
[],
);
const onPanelClick = useCallback(
(payload: DrilldownClickPayload): void => {
setSubMenu(DrilldownSubMenu.Base);
onClick(payload.coordinates, payload.context);
},
[onClick],
);
const handleClose = useCallback((): void => {
setSubMenu(DrilldownSubMenu.Base);
onClose();
}, [onClose]);
const { openViewWithQuery } = useViewPanel();
const breakout = useDrilldownBreakout({
panelId,
v1Query,
panelType,
aggregateData,
openViewWithQuery,
onClose: handleClose,
});
const filter = useDrilldownFilter({
context,
v1Query,
panelId,
panelType,
openViewWithQuery,
onClose: handleClose,
});
const dashboardVariables = useDrilldownDashboardVariables({
filters: context?.filters ?? EMPTY_FILTERS,
signal: context?.signal,
onClose: handleClose,
});
// The aggregate menu (View in Logs/Traces) shows for a non-group click on the base menu; the
// group click routes to filter-by-value instead. Only that menu resolves variables —
// filter/breakout open the View modal, which resolves at query-run time.
const showAggregateMenu =
subMenu === DrilldownSubMenu.Base && !!context && !filter.isGroupColumnClick;
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelType,
v1Query,
enabled: showAggregateMenu,
});
const navigate = useBaseDrilldownNavigate({
resolvedQuery,
aggregateData,
callback: handleClose,
});
const items = useMemo<ReactNode>(() => {
if (subMenu === DrilldownSubMenu.Breakout) {
return breakout.queryData ? (
<DrilldownBreakoutMenu
queryData={breakout.queryData}
onBreakout={breakout.onBreakout}
onBack={backToBase}
/>
) : null;
}
if (subMenu === DrilldownSubMenu.DashboardVariables) {
return (
<DrilldownDashboardVariablesMenu
actions={dashboardVariables.actions}
onBack={backToBase}
/>
);
}
if (filter.isGroupColumnClick && context?.clickedKey) {
return (
<DrilldownFilterMenu
v1Query={v1Query}
clickedKey={context.clickedKey}
onFilter={filter.onFilter}
/>
);
}
if (!context) {
return null;
}
return (
<DrilldownAggregateMenu
context={context}
query={v1Query}
isResolving={isResolving}
links={panel.spec.links}
canSetDashboardVariables={dashboardVariables.hasFieldVariables}
onViewLogs={(): void => navigate('view_logs')}
onViewTraces={(): void => navigate('view_traces')}
onBreakout={openBreakout}
onSetDashboardVariables={openDashboardVariables}
onClose={handleClose}
/>
);
}, [
subMenu,
breakout.queryData,
breakout.onBreakout,
dashboardVariables.actions,
dashboardVariables.hasFieldVariables,
filter.isGroupColumnClick,
filter.onFilter,
context,
v1Query,
isResolving,
panel.spec.links,
navigate,
openBreakout,
openDashboardVariables,
backToBase,
handleClose,
]);
return {
enableDrillDown,
onPanelClick,
contextMenuProps: {
coordinates,
popoverPosition,
items,
onClose: handleClose,
},
};
}

View File

@@ -1,76 +0,0 @@
import { useCallback, useMemo } from 'react';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { getQueryData } from 'container/QueryTable/Drilldown/drilldownUtils';
import {
getBreakoutPanelType,
getBreakoutQuery,
} from 'container/QueryTable/Drilldown/tableDrilldownUtils';
import type { BreakoutAttributeType } from 'container/QueryTable/Drilldown/types';
import type { AggregateData } from 'container/QueryTable/Drilldown/useAggregateDrilldown';
import type {
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
interface UseDrilldownBreakoutArgs {
panelId: string;
/** The panel's V5→V1 query; the breakout regroups the clicked query within it. */
v1Query: Query;
/** The panel's current V1 panel type — drives the breakout target type. */
panelType: PANEL_TYPES;
aggregateData: AggregateData | null;
/** Opens the View modal on the breakout query (at the breakout's target kind), persisting it in the URL. */
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the popover after navigating to the View modal. */
onClose: () => void;
}
export interface UseDrilldownBreakoutApi {
/** The clicked query's builder data for the attribute picker; `undefined` when there's no aggregate to break out. */
queryData: IBuilderQuery | undefined;
/** Regroup the clicked query by the picked attribute and open the result in the View modal. */
onBreakout: (groupBy: BreakoutAttributeType) => void;
}
/**
* The "Breakout by .." submenu logic: regroup the clicked query by a picked attribute and open the
* result in the View modal. Reuses V1's read-only `getBreakoutQuery`/`getBreakoutPanelType`; the
* caller renders `DrilldownBreakoutMenu` (V1's `BreakoutOptions` picker) from this hook's return.
*/
export function useDrilldownBreakout({
panelId,
v1Query,
panelType,
aggregateData,
openViewWithQuery,
onClose,
}: UseDrilldownBreakoutArgs): UseDrilldownBreakoutApi {
const onBreakout = useCallback(
(groupBy: BreakoutAttributeType): void => {
if (!aggregateData) {
return;
}
const breakoutQuery = getBreakoutQuery(
v1Query,
aggregateData,
groupBy,
aggregateData.filters ?? [],
);
openViewWithQuery(panelId, breakoutQuery, getBreakoutPanelType(panelType));
onClose();
},
[aggregateData, v1Query, panelType, panelId, openViewWithQuery, onClose],
);
const queryData = useMemo(
() =>
aggregateData ? getQueryData(v1Query, aggregateData.queryName) : undefined,
[aggregateData, v1Query],
);
return { queryData, onBreakout };
}

View File

@@ -1,58 +0,0 @@
import { useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- context links resolve global-time variables off redux (V1 parity)
import { useSelector } from 'react-redux';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { AppState } from 'store/reducers';
import type { GlobalReducer } from 'types/reducer/globalTime';
/**
* Builds the variable map that resolves context-link templates, mirroring V1's `useContextVariables`
* but sourced from V2 state: dashboard variable selections (by name), the global time window
* (`timestamp_start`/`timestamp_end`, ms), and the clicked point's field values (`_`-prefixed, the
* V1 convention that keeps them from clashing with dashboard variable names).
*/
export function useDrilldownContextVariables(
context: DrilldownContext | null,
): Record<string, string> {
// dashboardId from the store's edit context (set once by DashboardContainer), the same
// source the rest of V2 uses — not react-router params.
const dashboardId = useDashboardStore((state) => state.dashboardId);
// Select the stable top-level map (not `state.variableValues[id] ?? {}`, whose fresh `{}` each
// render makes useSyncExternalStore loop); index by dashboard inside the memo.
const variableValuesByDashboard = useDashboardStore(
(state) => state.variableValues,
);
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
return useMemo(() => {
const result: Record<string, string> = {};
const variableValues = variableValuesByDashboard[dashboardId] ?? {};
Object.entries(variableValues).forEach(([name, selection]) => {
const { value } = selection;
if (value == null) {
return;
}
result[name] = Array.isArray(value) ? value.join(', ') : String(value);
});
// Global time window, ns → ms (V1 parity).
result.timestamp_start = String(Math.floor(globalTime.minTime / 1e6));
result.timestamp_end = String(Math.floor(globalTime.maxTime / 1e6));
context?.filters.forEach(({ filterKey, filterValue }) => {
result[`_${filterKey}`] = String(filterValue);
});
return result;
}, [
variableValuesByDashboard,
dashboardId,
globalTime.minTime,
globalTime.maxTime,
context,
]);
}

View File

@@ -1,44 +0,0 @@
import { useCallback, useState } from 'react';
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
import { calculatePopoverPosition } from '../utils/calculatePopoverPosition';
/**
* Popover state for the drill-down context menu. V2's strongly-typed counterpart to V1's
* `useCoordinates` (whose `clickedData` is `any`): the caller pins `TData` to the click payload,
* so it flows through without a cast. `null` fields mean the menu is closed.
*/
export interface UseDrilldownCoordinatesResult<TData> {
coordinates: Coordinates | null;
popoverPosition: PopoverPosition | null;
clickedData: TData | null;
onClick: (coordinates: Coordinates, data: TData) => void;
onClose: () => void;
}
export function useDrilldownCoordinates<
TData,
>(): UseDrilldownCoordinatesResult<TData> {
const [coordinates, setCoordinates] = useState<Coordinates | null>(null);
const [popoverPosition, setPopoverPosition] = useState<PopoverPosition | null>(
null,
);
const [clickedData, setClickedData] = useState<TData | null>(null);
const onClick = useCallback((coords: Coordinates, data: TData): void => {
setClickedData(data);
setCoordinates(coords);
setPopoverPosition(calculatePopoverPosition(coords));
}, []);
const onClose = useCallback((): void => {
setCoordinates(null);
setPopoverPosition(null);
setClickedData(null);
}, []);
return { coordinates, popoverPosition, clickedData, onClick, onClose };
}

View File

@@ -1,191 +0,0 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { FilterData } from 'container/QueryTable/Drilldown/drilldownUtils';
import { useQueryState } from 'nuqs';
import {
dtoToFormModel,
formModelToDto,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import {
DYNAMIC_SIGNAL_ALL,
emptyVariableFormModel,
type VariableFormModel,
} from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableFormModel';
import { buildVariablesPatch } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variablePatchOps';
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
import { useOptimisticPatch } from 'pages/DashboardPageV2/DashboardContainer/hooks/useOptimisticPatch';
import { selectVariableValues } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import type { VariableSelection } from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/selectionTypes';
import {
ALL_SELECTED,
variablesUrlParser,
} from 'pages/DashboardPageV2/DashboardContainer/VariablesBar/useVariableSelection';
interface UseDrilldownDashboardVariablesArgs {
/** Group-by field filters from the clicked point (empty when the click has no group-by). */
filters: FilterData[];
/** Clicked query's telemetry signal — seeds a created variable's `dynamicSignal`. */
signal?: TelemetrytypesSignalDTO;
/** Close the popover after an action. */
onClose: () => void;
}
/** Set/Unset a matching dynamic variable's value, or Create one when none matches. */
export enum DrilldownVariableActionKind {
Set = 'set',
Unset = 'unset',
Create = 'create',
}
/** A resolved "Dashboard Variables" menu entry; the caller renders it. */
export interface DrilldownVariableAction {
fieldName: string;
fieldValue: string | number;
kind: DrilldownVariableActionKind;
/** Applies the action and closes the popover. */
onClick: () => void;
}
export interface UseDrilldownDashboardVariablesApi {
/** Whether the clicked point exposes any field to bind to a variable (gates the base-menu entry). */
hasFieldVariables: boolean;
/** Resolved menu entries, one per group-by field on the clicked point. */
actions: DrilldownVariableAction[];
}
/**
* "Dashboard Variables" submenu logic (V1 `useDashboardVarConfig` parity). Set/Unset are runtime-only
* (store + URL — V2 selections don't persist); Create is the one path that patches `spec.variables`.
*/
export function useDrilldownDashboardVariables({
filters,
signal,
onClose,
}: UseDrilldownDashboardVariablesArgs): UseDrilldownDashboardVariablesApi {
const dashboardId = useDashboardStore((state) => state.dashboardId);
const { variables } = useDashboardFetchRequired();
const dynamicVariables = useMemo(
() => variables.map(dtoToFormModel).filter((v) => v.type === 'DYNAMIC'),
[variables],
);
const existingNames = useMemo(
() => new Set(variables.map((v) => dtoToFormModel(v).name)),
[variables],
);
const selection = useDashboardStore(selectVariableValues(dashboardId));
const setVariableValue = useDashboardStore((state) => state.setVariableValue);
const [, setUrlValues] = useQueryState(
'variables',
variablesUrlParser.withOptions({ history: 'replace' }),
);
const { patchAsync } = useOptimisticPatch();
const fieldVariables = useMemo<[string, string | number][]>(
() =>
filters
.filter(
(f) => f.filterKey && f.filterValue !== undefined && f.filterValue !== '',
)
.map((f) => [f.filterKey, f.filterValue]),
[filters],
);
// Runtime-only write (store + URL), never the spec — mirrors VariablesBar's setSelection.
const setSelection = useCallback(
(name: string, next: VariableSelection): void => {
setVariableValue(dashboardId, name, next);
void setUrlValues((prev) => ({
...(prev ?? {}),
[name]: next.allSelected ? ALL_SELECTED : next.value,
}));
},
[dashboardId, setVariableValue, setUrlValues],
);
const handleCreate = useCallback(
async (fieldName: string, fieldValue: string | number): Promise<void> => {
if (existingNames.has(fieldName)) {
toast.error(`Variable "${fieldName}" already exists`);
return;
}
const model: VariableFormModel = {
...emptyVariableFormModel(),
name: fieldName,
description: `Created from panel drilldown (field: ${fieldName})`,
type: 'DYNAMIC',
multiSelect: true,
dynamicAttribute: fieldName,
dynamicSignal: signal ?? DYNAMIC_SIGNAL_ALL,
};
try {
await patchAsync(
buildVariablesPatch([...variables, formModelToDto(model)]),
);
// Multi-select var → seed the value as an array (the selector renders scalars as empty).
setSelection(fieldName, { value: [fieldValue], allSelected: false });
toast.success(`Created variable "${fieldName}"`);
} catch {
toast.error('Failed to create variable');
}
onClose();
},
[existingNames, signal, variables, patchAsync, setSelection, onClose],
);
const actions = useMemo<DrilldownVariableAction[]>(
() =>
fieldVariables.map(([fieldName, fieldValue]) => {
const existing = dynamicVariables.find(
(v) => v.dynamicAttribute === fieldName,
);
if (!existing) {
return {
fieldName,
fieldValue,
kind: DrilldownVariableActionKind.Create,
onClick: (): void => {
void handleCreate(fieldName, fieldValue);
},
};
}
const current = selection[existing.name]?.value;
const isSame = Array.isArray(current)
? current.length === 1 && current[0] === fieldValue
: current === fieldValue;
// Multi-select values must be arrays (a scalar renders as empty in the selector).
const cleared = existing.multiSelect ? [] : '';
const assigned = existing.multiSelect ? [fieldValue] : fieldValue;
return {
fieldName,
fieldValue,
kind: isSame
? DrilldownVariableActionKind.Unset
: DrilldownVariableActionKind.Set,
onClick: (): void => {
setSelection(existing.name, {
value: isSame ? cleared : assigned,
allSelected: false,
});
onClose();
},
};
}),
[
fieldVariables,
dynamicVariables,
selection,
setSelection,
handleCreate,
onClose,
],
);
return { hasFieldVariables: fieldVariables.length > 0, actions };
}

View File

@@ -1,72 +0,0 @@
import { useCallback } from 'react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
addFilterToQuery,
getBaseMeta,
isNumberDataType,
} from 'container/QueryTable/Drilldown/drilldownUtils';
import type { DrilldownContext } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/drilldown';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
interface UseDrilldownFilterArgs {
/** The clicked point's context; the filter menu only appears for a group-column click. */
context: DrilldownContext | null;
/** The panel's V5→V1 query the filter is added to. */
v1Query: Query;
panelId: string;
/** Panel's V1 type — the kind the refined query opens the View modal as. */
panelType: PANEL_TYPES;
/** Opens the View modal on the refined query, persisting it in the URL. */
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the popover after opening the View modal. */
onClose: () => void;
}
export interface UseDrilldownFilterApi {
/** True for a group-column click — the caller renders the filter-by-value menu. */
isGroupColumnClick: boolean;
/** Apply the chosen operator: add `key <op> value` and open the refined result in the View modal. */
onFilter: (operator: string) => void;
}
/**
* The group-column "filter by value" drill-down (V1 parity): adds `key <op> value` to the panel's
* query and opens the refined result in the View modal. The caller renders `DrilldownFilterMenu`
* (V1's read-only `getGroupContextMenuConfig`) from this hook's return.
*/
export function useDrilldownFilter({
context,
v1Query,
panelId,
panelType,
openViewWithQuery,
onClose,
}: UseDrilldownFilterArgs): UseDrilldownFilterApi {
const onFilter = useCallback(
(operator: string): void => {
if (!context?.clickedKey) {
return;
}
let filterValue: string | number = context.clickedValue ?? '';
const baseMeta = getBaseMeta(v1Query, context.clickedKey);
if (baseMeta && isNumberDataType(baseMeta.dataType) && filterValue !== '') {
filterValue = Number(filterValue);
}
const refinedQuery = addFilterToQuery(v1Query, [
{ filterKey: context.clickedKey, filterValue, operator },
]);
openViewWithQuery(panelId, refinedQuery, panelType);
onClose();
},
[context, v1Query, panelType, panelId, openViewWithQuery, onClose],
);
const isGroupColumnClick =
!!context && context.columnKind === 'group' && !!context.clickedKey;
return { isGroupColumnClick, onFilter };
}

View File

@@ -1,88 +0,0 @@
import { useEffect, useMemo } from 'react';
// eslint-disable-next-line no-restricted-imports -- global time still lives in redux
import { useSelector } from 'react-redux';
import { useReplaceVariables } from 'api/generated/services/querier';
import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { buildQueryRangeRequest } from 'pages/DashboardPageV2/DashboardContainer/queryV5/buildQueryRangeRequest';
import { envelopesToQuery } from 'pages/DashboardPageV2/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPageV2/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore';
import { AppState } from 'store/reducers';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
interface UseResolvedDrilldownQueryArgs {
/** Panel's perses queries — the substitution source (carries the `$var` refs). */
queries: DashboardtypesQueryDTO[];
panelType: PANEL_TYPES;
/** The raw V5→V1 query; the fallback until substitution resolves / when no vars exist. */
v1Query: Query;
/** Resolve only while the aggregate menu is open (V1 parity: fires when it appears). */
enabled: boolean;
}
interface UseResolvedDrilldownQueryResult {
/** The variable-substituted query for View-in-X navigation. */
resolvedQuery: Query;
/** True while the round-trip is in flight — View-in-X shows a spinner and is disabled. */
isResolving: boolean;
}
/**
* Resolves the panel's dashboard-variable references (`$var`) into concrete values before
* View in Logs/Traces builds the explorer URL — V1 parity (`useBaseAggregateOptions` runs the
* same `/substitute_vars` round-trip). Skipped when the dashboard has no selections: the raw
* query already carries the refs verbatim and the round-trip would be a no-op. Mirrors the
* V2-native path in {@link useCreateAlertFromPanel}.
*/
export function useResolvedDrilldownQuery({
queries,
panelType,
v1Query,
enabled,
}: UseResolvedDrilldownQueryArgs): UseResolvedDrilldownQueryResult {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const variables = useDashboardStore(selectResolvedVariables(dashboardId));
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { mutate: substituteVars, data, isLoading } = useReplaceVariables();
const hasVariables = Object.keys(variables).length > 0;
useEffect(() => {
if (!enabled || !hasVariables) {
return;
}
// Redux global time is nanoseconds; the request DTO takes integer epoch ms — the
// backend rejects fractional bounds, so floor after the ns→ms divide.
substituteVars({
data: buildQueryRangeRequest({
queries,
panelType,
startMs: Math.floor(minTime / 1e6),
endMs: Math.floor(maxTime / 1e6),
variables,
}),
});
}, [
enabled,
hasVariables,
queries,
panelType,
minTime,
maxTime,
variables,
substituteVars,
]);
const resolvedQuery = useMemo(() => {
if (!hasVariables || !data) {
return v1Query;
}
return envelopesToQuery(data.data.compositeQuery?.queries ?? [], panelType);
}, [hasVariables, data, v1Query, panelType]);
return { resolvedQuery, isResolving: enabled && hasVariables && isLoading };
}

View File

@@ -1,33 +1,22 @@
import { useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { QueryParams } from 'constants/query';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
export interface UseViewPanelApi {
/** Panel id currently expanded in the View modal; null when none is open. */
expandedPanelId: string | null;
/** Open the View modal on the saved panel (clears any leftover in-modal query/kind). */
openView: (panelId: string) => void;
/**
* Open the View modal pre-seeded with a drilldown query + kind, persisted in the URL so it
* survives refresh (V1 parity); the modal hydrates its draft from these on mount.
*/
openViewWithQuery: (
panelId: string,
query: Query,
panelType: PANEL_TYPES,
) => void;
/** Close the View modal by clearing its URL params. */
/** Close the View modal by clearing the URL param. */
closeView: () => void;
}
/**
* Drives the panel View modal off the URL (V1 parity): `expandedWidgetId` holds the open
* panel, and a drilldown additionally seeds `compositeQuery` + `graphType`. URL-backed state
* is shareable, survives refresh, and the browser back-button closes it.
* Drives the panel View modal off the `expandedWidgetId` URL param (V1 parity):
* the open state is shareable, survives refresh, and the browser back-button
* closes it. Reuses V1's param key so a deep-linked V1 URL maps cleanly.
*/
export function useViewPanel(): UseViewPanelApi {
const { safeNavigate } = useSafeNavigate();
@@ -50,22 +39,6 @@ export function useViewPanel(): UseViewPanelApi {
[pathname, safeNavigate, urlQuery],
);
const openViewWithQuery = useCallback(
(panelId: string, query: Query, panelType: PANEL_TYPES): void => {
const next = new URLSearchParams(urlQuery);
next.set(QueryParams.expandedWidgetId, panelId);
next.set(QueryParams.graphType, panelType);
// Same encoding the query builder uses (see `useGetCompositeQueryParam`): the URL
// value is `encodeURIComponent(JSON.stringify(query))`, decoded once on read.
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
safeNavigate(`${pathname}?${next.toString()}`);
},
[pathname, safeNavigate, urlQuery],
);
const closeView = useCallback((): void => {
const next = new URLSearchParams(urlQuery);
next.delete(QueryParams.expandedWidgetId);
@@ -77,5 +50,5 @@ export function useViewPanel(): UseViewPanelApi {
safeNavigate(search ? `${pathname}?${search}` : pathname);
}, [pathname, safeNavigate, urlQuery]);
return { expandedPanelId, openView, openViewWithQuery, closeView };
return { expandedPanelId, openView, closeView };
}

View File

@@ -1,53 +0,0 @@
import { calculatePopoverPosition } from '../calculatePopoverPosition';
// Popover is 300×254 with a 10px offset; these are the edges the placement flips against.
const setViewport = (width: number, height: number): void => {
Object.defineProperty(window, 'innerWidth', {
value: width,
configurable: true,
});
Object.defineProperty(window, 'innerHeight', {
value: height,
configurable: true,
});
};
describe('calculatePopoverPosition', () => {
beforeEach(() => setViewport(1920, 1080));
it('anchors to the right of the click when there is room', () => {
expect(calculatePopoverPosition({ x: 100, y: 100 })).toStrictEqual({
left: 110,
top: 90,
placement: 'right',
});
});
it('flips to the left near the right edge', () => {
const { left, placement } = calculatePopoverPosition({ x: 1900, y: 100 });
expect(placement).toBe('left');
expect(left).toBe(1900 - 300 + 10);
});
it('clamps back in when the left flip would overflow a narrow viewport', () => {
setViewport(320, 1080);
// Right flip fires (60 + 300 > 320) but its left (50 - 300 + 10) is off-screen, so it clamps.
expect(calculatePopoverPosition({ x: 50, y: 100 })).toMatchObject({
left: 10,
placement: 'right',
});
});
it('drops below the click near the top edge', () => {
expect(calculatePopoverPosition({ x: 100, y: 2 })).toMatchObject({
top: 10,
placement: 'bottomRight',
});
});
it('lifts above the click near the bottom edge', () => {
const { top, placement } = calculatePopoverPosition({ x: 100, y: 1070 });
expect(placement).toBe('topRight');
expect(top).toBe(1080 - 254 - 10);
});
});

View File

@@ -1,46 +0,0 @@
import type {
Coordinates,
PopoverPosition,
} from 'periscope/components/ContextMenu';
// Kept in sync with the popover's `overlayStyle` in `ContextMenu` (width 300, maxHeight 254).
const POPOVER_WIDTH = 300;
const POPOVER_HEIGHT = 254;
const OFFSET = 10;
/**
* Places the drill-down popover next to the clicked point, flipping/clamping so it stays within the
* viewport. Anchors to the right by default and mirrors to the left (and top/bottom) near an edge.
*/
export function calculatePopoverPosition({
x,
y,
}: Coordinates): PopoverPosition {
const { innerWidth: windowWidth, innerHeight: windowHeight } = window;
let left = x + OFFSET;
let top = y - OFFSET;
let placement: PopoverPosition['placement'] = 'right';
if (left + POPOVER_WIDTH > windowWidth) {
left = x - POPOVER_WIDTH + OFFSET;
placement = 'left';
}
if (left < 0) {
left = OFFSET;
placement = 'right';
}
if (top < 0) {
top = OFFSET;
placement = placement === 'right' ? 'bottomRight' : 'bottomLeft';
}
if (top + POPOVER_HEIGHT > windowHeight) {
top = windowHeight - POPOVER_HEIGHT - OFFSET;
placement = placement === 'right' ? 'topRight' : 'topLeft';
}
return { left, top, placement };
}

View File

@@ -29,13 +29,8 @@ interface SectionProps {
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
const isEditable = useDashboardStore((s) => s.isEditable);
const {
isPickerOpen,
openPicker,
closePicker,
createPanel,
targetLayoutIndex,
} = useCreatePanel();
const { isPickerOpen, openPicker, closePicker, createPanel } =
useCreatePanel();
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
const containerRef = useRef<HTMLDivElement>(null);
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
@@ -146,7 +141,6 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
open={isPickerOpen}
onClose={closePicker}
onSelect={createPanel}
defaultLayoutIndex={targetLayoutIndex}
/>
<ConfirmDeleteDialog
open={isDeleteOpen}

View File

@@ -30,9 +30,7 @@ function PanelsAndSectionsLayout({
// Single View-modal host for the whole dashboard, driven by the URL
// (`expandedWidgetId`). One mounted modal beats one-per-panel: no N location
// subscriptions, and the expanded panel is looked up by id from the map. A
// drilldown refinement rides in the URL (`compositeQuery`/`graphType`) and is
// hydrated inside the modal, so the host just hands it the saved panel.
// subscriptions, and the expanded panel is looked up by id from the map.
const { expandedPanelId, closeView } = useViewPanel();
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;

View File

@@ -20,12 +20,11 @@ import {
export const ALL_SELECTED = '__ALL__';
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
export const variablesUrlParser = parseAsJson<
Record<string, SelectedVariableValue>
>((v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
(v) =>
typeof v === 'object' && v !== null
? (v as Record<string, SelectedVariableValue>)
: null,
);
function defaultSelection(model: VariableFormModel): VariableSelection {

View File

@@ -12,10 +12,7 @@ interface UseCreatePanelResult {
/** Pass the target section's layout index; omit → last/new section. */
openPicker: (layoutIndex?: number) => void;
closePicker: () => void;
/** The section the picker was opened against — seeds its section dropdown. */
targetLayoutIndex: number | undefined;
/** `layoutIndex` overrides the opened-against target (the dropdown's choice). */
createPanel: (panelKind: PanelKind, layoutIndex?: number) => void;
createPanel: (panelKind: PanelKind) => void;
}
/**
@@ -41,23 +38,16 @@ export function useCreatePanel(): UseCreatePanelResult {
}, []);
const createPanel = useCallback(
(panelKind: PanelKind, targetIndex?: number): void => {
(panelKind: PanelKind): void => {
setIsPickerOpen(false);
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
dashboardId,
panelId: NEW_PANEL_ID,
});
const target = targetIndex ?? layoutIndex;
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
},
[safeNavigate, dashboardId, layoutIndex],
);
return {
isPickerOpen,
openPicker,
closePicker,
targetLayoutIndex: layoutIndex,
createPanel,
};
return { isPickerOpen, openPicker, closePicker, createPanel };
}

View File

@@ -1,21 +0,0 @@
import { useMemo } from 'react';
import { useGetDashboardV2 } from 'api/generated/services/dashboard';
import { useDashboardStore } from '../store/useDashboardStore';
import { type DashboardSection, layoutsToSections } from '../utils';
/**
* The current dashboard's sections, read from the already-loaded dashboard
* query. The page fetches via useGetDashboardV2 keyed by id, so this reuses that
* cache (no extra request) instead of prop-drilling the section list.
*/
export function useDashboardSections(): DashboardSection[] {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { data } = useGetDashboardV2({ id: dashboardId });
const spec = data?.data?.spec;
return useMemo(
() => layoutsToSections(spec?.layouts, spec?.panels),
[spec?.layouts, spec?.panels],
);
}

View File

@@ -38,14 +38,22 @@ function DashboardContainer({
user.role,
);
// Seed during render (not an effect) so the first Panel render already sees the id —
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
// Publish edit context to the store so hooks/components read it from there
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
const setEditContext = useDashboardStore((s) => s.setEditContext);
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
useEffect(() => {
setEditContext({
dashboardId: dashboard.id,
isEditable: !dashboard.locked && editDashboardPermission,
refetch,
});
}, [
dashboard.id,
dashboard.locked,
editDashboardPermission,
refetch,
});
setEditContext,
]);
// Resolve the variable selection into the V5 query payload and publish it to
// the store, so each panel's query substitutes the bar's selected values.

View File

@@ -24,20 +24,11 @@ export const createEditContextSlice: StateCreator<
[['zustand/persist', unknown]],
[],
EditContextSlice
> = (set, get) => ({
> = (set) => ({
dashboardId: '',
isEditable: false,
refetch: (): void => undefined,
// Idempotent (no-op when unchanged) so it's safe to call during render.
setEditContext: (ctx): void => {
const { dashboardId, isEditable, refetch } = get();
if (
dashboardId === ctx.dashboardId &&
isEditable === ctx.isEditable &&
refetch === ctx.refetch
) {
return;
}
set({
dashboardId: ctx.dashboardId,
isEditable: ctx.isEditable,

View File

@@ -130,6 +130,27 @@
justify-content: flex-end;
}
.tag {
display: flex;
padding: 4px 8px;
justify-content: center;
align-items: center;
gap: 4px;
height: 28px;
border-radius: 20px;
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
color: var(--bg-sienna-400);
text-align: center;
font-family: Inter;
font-size: var(--font-size-sm);
font-style: normal;
font-weight: var(--font-weight-normal);
line-height: 20px;
letter-spacing: -0.07px;
margin-inline-end: 0px;
}
.details {
margin-top: 12px;
display: flex;

View File

@@ -1,4 +1,4 @@
import TagBadge from 'components/TagBadge/TagBadge';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
@@ -105,10 +105,14 @@ function DashboardRow({
{tags.length > 0 && (
<div className={styles.tags}>
{tags.slice(0, 3).map((tag) => (
<TagBadge key={tag}>{tag}</TagBadge>
<Badge className={styles.tag} key={tag}>
{tag}
</Badge>
))}
{tags.length > 3 && (
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
<Badge className={styles.tag} key={tags[3]}>
+ <Typography.Text> {tags.length - 3} </Typography.Text>
</Badge>
)}
</div>
)}

View File

@@ -65,7 +65,6 @@ type provider struct {
zeusHandler zeus.Handler
querierHandler querier.Handler
serviceAccountHandler serviceaccount.Handler
serviceAccountGetter serviceaccount.Getter
factoryHandler factory.Handler
cloudIntegrationHandler cloudintegration.Handler
ruleStateHistoryHandler rulestatehistory.Handler
@@ -100,7 +99,6 @@ func NewFactory(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -138,7 +136,6 @@ func NewFactory(
zeusHandler,
querierHandler,
serviceAccountHandler,
serviceAccountGetter,
factoryHandler,
cloudIntegrationHandler,
ruleStateHistoryHandler,
@@ -178,7 +175,6 @@ func newProvider(
zeusHandler zeus.Handler,
querierHandler querier.Handler,
serviceAccountHandler serviceaccount.Handler,
serviceAccountGetter serviceaccount.Getter,
factoryHandler factory.Handler,
cloudIntegrationHandler cloudintegration.Handler,
ruleStateHistoryHandler rulestatehistory.Handler,
@@ -217,7 +213,6 @@ func newProvider(
zeusHandler: zeusHandler,
querierHandler: querierHandler,
serviceAccountHandler: serviceAccountHandler,
serviceAccountGetter: serviceAccountGetter,
factoryHandler: factoryHandler,
cloudIntegrationHandler: cloudIntegrationHandler,
ruleStateHistoryHandler: ruleStateHistoryHandler,

View File

@@ -141,17 +141,17 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
if err := router.Handle("/api/v1/service_accounts/{id}/roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.SetRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRoleDeprecated",
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole),
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbAttach), coretypes.ResourceRole.Scope(coretypes.VerbAttach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
@@ -171,7 +171,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
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",
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from service account",
@@ -181,7 +181,7 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
},
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
@@ -398,96 +398,13 @@ func (provider *provider) addServiceAccountRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v1/service_account_roles", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.CreateServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "CreateServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Create service account role",
Description: "This endpoint assigns a role to a service account",
Request: new(serviceaccounttypes.PostableServiceAccountRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
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.BodyJSONPath("serviceAccountId")),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(coretypes.BodyJSONPath("roleId")),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.GetServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "GetServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Get service account role",
Description: "This endpoint gets an existing service account role",
Request: nil,
RequestContentType: "",
Response: new(serviceaccounttypes.ServiceAccountRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceServiceAccount,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryAccessControl,
ID: provider.serviceAccountIDExtractor(),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/service_account_roles/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.serviceAccountHandler.DeleteServiceAccountRole, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteServiceAccountRole",
Tags: []string{"serviceaccount"},
Summary: "Delete service account role",
Description: "This endpoint revokes a role from a service account",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
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(provider.serviceAccountIDExtractor()),
SourceSelector: coretypes.IDSelector,
TargetResource: coretypes.ResourceRole,
TargetIDs: coretypes.OneID(provider.roleIDExtractor()),
TargetSelector: provider.roleSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}
// roleSelector resolves the FGA selectors for a role from its UUID. The id is
// already extracted by the ResourceDef (path or body); this only does the
// UUID -> name lookup the FGA object string requires. Shared by service account
// and role routes.
func (provider *provider) roleSelector(ctx context.Context, resource coretypes.Resource, id string, orgID valuer.UUID) ([]coretypes.Selector, error) {
roleID, err := valuer.NewUUID(id)
if err != nil {
@@ -504,53 +421,3 @@ func (provider *provider) roleSelector(ctx context.Context, resource coretypes.R
resource.Type().MustSelector(coretypes.WildCardSelectorString),
}, nil
}
func (provider *provider) roleIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
if err != nil {
return "", err
}
return serviceAccountRole.RoleID.String(), nil
})
}
func (provider *provider) serviceAccountIDExtractor() coretypes.ResourceIDExtractor {
return coretypes.NewResourceIDExtractor(coretypes.PhaseRequest, func(ec coretypes.ExtractorContext) (string, error) {
if ec.Request == nil {
return "", nil
}
claims, err := authtypes.ClaimsFromContext(ec.Request.Context())
if err != nil {
return "", err
}
serviceAccountRoleID, err := valuer.NewUUID(mux.Vars(ec.Request)["id"])
if err != nil {
return "", err
}
serviceAccountRole, err := provider.serviceAccountGetter.GetServiceAccountRole(ec.Request.Context(), valuer.MustNewUUID(claims.OrgID), serviceAccountRoleID)
if err != nil {
return "", err
}
return serviceAccountRole.ServiceAccountID.String(), nil
})
}

View File

@@ -394,7 +394,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
@@ -411,7 +411,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusNotFound},
Deprecated: true,
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
@@ -434,56 +434,5 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/user_roles", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.CreateUserRole), handler.OpenAPIDef{
ID: "CreateUserRole",
Tags: []string{"users"},
Summary: "Create user role",
Description: "This endpoint assigns a role to a user",
Request: new(authtypes.PostableUserRole),
RequestContentType: "",
Response: new(types.Identifiable),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.GetUserRole), handler.OpenAPIDef{
ID: "GetUserRole",
Tags: []string{"users"},
Summary: "Get user role",
Description: "This endpoint gets an existing user role",
Request: nil,
RequestContentType: "",
Response: new(authtypes.UserRole),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/user_roles/{id}", handler.New(provider.authzMiddleware.AdminAccess(provider.userHandler.DeleteUserRole), handler.OpenAPIDef{
ID: "DeleteUserRole",
Tags: []string{"users"},
Summary: "Delete user role",
Description: "This endpoint revokes a role from a user",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -216,11 +216,6 @@ func (middleware *AuthZ) CheckResources(next http.HandlerFunc, roles ...string)
}
for _, resource := range resolved {
if err := resource.Err(); err != nil {
render.Error(rw, err)
return
}
if err := middleware.checkResource(ctx, claims, orgID, resource.Verb(), resource.SourceResource(), resource.SourceIDs(), resource.SourceSelector(), roleSelectors); err != nil {
render.Error(rw, err)
return

View File

@@ -117,8 +117,16 @@ var podsSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{
"k8s.pod.phase",
"k8s.container.restarts", // pod restart count (default-on)
},
OptionalMetrics: []string{
// kubectl-style pod display status (default-off in the receiver).
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -149,6 +157,10 @@ var nodesSpec = checkSpec{
// By default, only k8s.node.condition_ready is enabled. (Check https://github.com/open-telemetry/opentelemetry-collector-contrib/blob/4f9a578b210a6dcb9f9bf47942f27208b5765298/receiver/k8sclusterreceiver/metadata.yaml#L802)
"k8s.pod.phase", // pod counts per node by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -182,6 +194,10 @@ var deploymentsSpec = checkSpec{
"k8s.deployment.desired",
"k8s.deployment.available",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -220,6 +236,10 @@ var daemonsetsSpec = checkSpec{
"k8s.daemonset.desired_scheduled_nodes",
"k8s.daemonset.current_scheduled_nodes",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -258,6 +278,10 @@ var statefulsetsSpec = checkSpec{
"k8s.statefulset.desired_pods",
"k8s.statefulset.current_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -298,6 +322,10 @@ var jobsSpec = checkSpec{
"k8s.job.failed_pods",
"k8s.job.successful_pods",
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -324,8 +352,12 @@ var namespacesSpec = checkSpec{
DocumentationLink: docLinkKubeletStatsReceiver,
},
{
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
Component: componentK8sClusterReceiver,
DefaultMetrics: []string{"k8s.pod.phase"},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{
@@ -360,6 +392,10 @@ var clustersSpec = checkSpec{
"k8s.node.condition_ready", // node counts by readiness
"k8s.pod.phase", // pod counts per cluster by phase
},
OptionalMetrics: []string{
"k8s.pod.status_reason",
"k8s.container.status.reason",
},
DocumentationLink: docLinkK8sClusterReceiver,
},
{

View File

@@ -20,6 +20,7 @@ func buildClusterRecords(
metadataMap map[string]map[string]string,
nodeConditionCountsMap map[string]nodeConditionCounts,
podPhaseCountsMap map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.ClusterRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -69,6 +70,10 @@ func buildClusterRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -27,6 +27,8 @@ var clustersTableMetricNamesList = []string{
"k8s.node.allocatable_memory",
"k8s.node.condition_ready", //TODO(nikhilmantri0902): should these metrics be used to count groups k8s.node.condition_ready and k8s.pod.phase
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
}
var clusterAttrKeysForMetadata = []string{

View File

@@ -19,6 +19,7 @@ func buildDaemonSetRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.DaemonSetRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -77,6 +78,10 @@ func buildDaemonSetRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,6 +23,8 @@ var daemonSetNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var daemonSetsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

View File

@@ -19,6 +19,7 @@ func buildDeploymentRecords(
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
phaseCounts map[string]podPhaseCounts,
podStatusCounts map[string]podStatusCounts,
) []inframonitoringtypes.DeploymentRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
@@ -77,6 +78,10 @@ func buildDeploymentRecords(
}
}
if podStatusCountsForGroup, ok := podStatusCounts[compositeKey]; ok {
record.PodCountsByStatus = podStatusCountsToResponse(podStatusCountsForGroup)
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v

View File

@@ -23,6 +23,8 @@ var deploymentNameGroupByKey = qbtypes.GroupByKey{
// response to short-circuit cleanly when the phase metric is absent.
var deploymentsTableMetricNamesList = []string{
"k8s.pod.phase",
"k8s.pod.status_reason",
"k8s.container.status.reason",
"k8s.pod.cpu.usage",
"k8s.pod.cpu_request_utilization",
"k8s.pod.cpu_limit_utilization",

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