mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 22:50:38 +01:00
Compare commits
7 Commits
infraM/loc
...
fix/panel-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f06859ebf | ||
|
|
7ea3ba4e09 | ||
|
|
af02e8b722 | ||
|
|
1644e35d9c | ||
|
|
379331ee77 | ||
|
|
4d079a8338 | ||
|
|
91938925ec |
@@ -515,6 +515,13 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesDeprecatedPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
@@ -660,17 +667,20 @@ components:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
roleId:
|
||||
type: string
|
||||
userId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- userId
|
||||
- roleId
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
@@ -4251,8 +4261,6 @@ components:
|
||||
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- clusterName
|
||||
- clusterCPU
|
||||
@@ -4261,7 +4269,6 @@ components:
|
||||
- clusterMemoryAllocatable
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesClusters:
|
||||
@@ -4317,8 +4324,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- daemonSetName
|
||||
- daemonSetCPU
|
||||
@@ -4330,7 +4335,6 @@ components:
|
||||
- desiredNodes
|
||||
- currentNodes
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesDaemonSets:
|
||||
@@ -4386,8 +4390,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- deploymentName
|
||||
- deploymentCPU
|
||||
@@ -4399,7 +4401,6 @@ components:
|
||||
- desiredPods
|
||||
- availablePods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesDeployments:
|
||||
@@ -4532,8 +4533,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
successfulPods:
|
||||
type: integer
|
||||
required:
|
||||
@@ -4549,7 +4548,6 @@ components:
|
||||
- failedPods
|
||||
- successfulPods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesJobs:
|
||||
@@ -4640,14 +4638,11 @@ components:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- namespaceName
|
||||
- namespaceCPU
|
||||
- namespaceMemory
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesNamespaces:
|
||||
@@ -4713,14 +4708,11 @@ components:
|
||||
type: string
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
required:
|
||||
- nodeName
|
||||
- condition
|
||||
- nodeCountsByReadiness
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- nodeCPU
|
||||
- nodeCPUAllocatable
|
||||
- nodeMemory
|
||||
@@ -4766,64 +4758,6 @@ 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
|
||||
@@ -4854,8 +4788,6 @@ components:
|
||||
type: number
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
podMemory:
|
||||
format: double
|
||||
type: number
|
||||
@@ -4867,11 +4799,6 @@ components:
|
||||
type: number
|
||||
podPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
|
||||
podRestarts:
|
||||
format: int64
|
||||
type: integer
|
||||
podStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodStatus'
|
||||
podUID:
|
||||
type: string
|
||||
required:
|
||||
@@ -4884,34 +4811,9 @@ 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:
|
||||
@@ -5210,8 +5112,6 @@ components:
|
||||
type: object
|
||||
podCountsByPhase:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
|
||||
podCountsByStatus:
|
||||
$ref: '#/components/schemas/InframonitoringtypesPodCountsByStatus'
|
||||
statefulSetCPU:
|
||||
format: double
|
||||
type: number
|
||||
@@ -5243,7 +5143,6 @@ components:
|
||||
- desiredPods
|
||||
- currentPods
|
||||
- podCountsByPhase
|
||||
- podCountsByStatus
|
||||
- meta
|
||||
type: object
|
||||
InframonitoringtypesStatefulSets:
|
||||
@@ -7541,6 +7440,13 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -7597,10 +7503,13 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
roleId:
|
||||
type: string
|
||||
serviceAccountId:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- serviceAccountId
|
||||
- roleId
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
@@ -12363,6 +12272,188 @@ 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
|
||||
@@ -12932,9 +13023,9 @@ paths:
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
operationId: CreateServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -12945,7 +13036,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -12997,9 +13088,9 @@ paths:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
operationId: DeleteServiceAccountRoleDeprecated
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -22101,6 +22192,184 @@ 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
|
||||
@@ -22554,7 +22823,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint assigns the role to the user roles by user id
|
||||
operationId: SetRoleByUserID
|
||||
parameters:
|
||||
@@ -22605,7 +22874,7 @@ paths:
|
||||
- users
|
||||
/api/v2/users/{id}/roles/{roleId}:
|
||||
delete:
|
||||
deprecated: false
|
||||
deprecated: true
|
||||
description: This endpoint removes a role from the user by user id and role
|
||||
id
|
||||
operationId: RemoveUserRoleByUserIDAndRoleID
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -22,12 +22,16 @@ import type {
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRolePathParameters,
|
||||
CreateServiceAccountRoleDeprecated201,
|
||||
CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
GetServiceAccountRole200,
|
||||
GetServiceAccountRolePathParameters,
|
||||
GetServiceAccountRoles200,
|
||||
GetServiceAccountRolesPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
@@ -35,6 +39,7 @@ import type {
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
@@ -46,6 +51,272 @@ 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
|
||||
@@ -984,45 +1255,46 @@ export const invalidateGetServiceAccountRoles = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
{ id }: CreateServiceAccountRolePathParameters,
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
export const createServiceAccountRoleDeprecated = (
|
||||
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const mutationKey = ['createServiceAccountRoleDeprecated'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1032,62 +1304,66 @@ export const getCreateServiceAccountRoleMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(pathParams, data);
|
||||
return createServiceAccountRoleDeprecated(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
export type CreateServiceAccountRoleDeprecatedMutationBody =
|
||||
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
export type CreateServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
export const useCreateServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
return useMutation(
|
||||
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id, rid }: DeleteServiceAccountRolePathParameters,
|
||||
export const deleteServiceAccountRoleDeprecated = (
|
||||
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
@@ -1097,23 +1373,23 @@ export const deleteServiceAccountRole = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1123,44 +1399,47 @@ export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
return deleteServiceAccountRoleDeprecated(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRole = <
|
||||
export const useDeleteServiceAccountRoleDeprecated = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
return useMutation(
|
||||
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
|
||||
@@ -2048,6 +2048,13 @@ export interface AuthtypesAuthNSupportDTO {
|
||||
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesDeprecatedPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
@@ -2287,13 +2294,6 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2310,7 +2310,18 @@ export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles?: AuthtypesPostableUserRoleDTO[];
|
||||
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
@@ -5693,81 +5704,6 @@ 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
|
||||
@@ -5799,7 +5735,6 @@ export interface InframonitoringtypesClusterRecordDTO {
|
||||
meta: InframonitoringtypesClusterRecordDTOMeta;
|
||||
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export enum InframonitoringtypesResponseTypeDTO {
|
||||
@@ -5903,7 +5838,6 @@ export interface InframonitoringtypesDaemonSetRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDaemonSetsDTO {
|
||||
@@ -5981,7 +5915,6 @@ export interface InframonitoringtypesDeploymentRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesDeploymentRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesDeploymentsDTO {
|
||||
@@ -6148,7 +6081,6 @@ export interface InframonitoringtypesJobRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesJobRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
@@ -6202,7 +6134,6 @@ export interface InframonitoringtypesNamespaceRecordDTO {
|
||||
*/
|
||||
namespaceName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNamespacesDTO {
|
||||
@@ -6269,7 +6200,6 @@ export interface InframonitoringtypesNodeRecordDTO {
|
||||
*/
|
||||
nodeName: string;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
}
|
||||
|
||||
export interface InframonitoringtypesNodesDTO {
|
||||
@@ -6307,27 +6237,6 @@ 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
|
||||
@@ -6354,7 +6263,6 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
*/
|
||||
podCPURequest: number;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -6371,12 +6279,6 @@ export interface InframonitoringtypesPodRecordDTO {
|
||||
*/
|
||||
podMemoryRequest: number;
|
||||
podPhase: InframonitoringtypesPodPhaseDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
podRestarts: number;
|
||||
podStatus: InframonitoringtypesPodStatusDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -6694,7 +6596,6 @@ export interface InframonitoringtypesStatefulSetRecordDTO {
|
||||
*/
|
||||
meta: InframonitoringtypesStatefulSetRecordDTOMeta;
|
||||
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
|
||||
podCountsByStatus: InframonitoringtypesPodCountsByStatusDTO;
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
@@ -8618,6 +8519,13 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8687,7 +8595,11 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceAccountId: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
@@ -10453,6 +10365,28 @@ 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
|
||||
@@ -10536,10 +10470,10 @@ export type GetServiceAccountRoles200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRolePathParameters = {
|
||||
export type CreateServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRole201 = {
|
||||
export type CreateServiceAccountRoleDeprecated201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -10547,7 +10481,7 @@ export type CreateServiceAccountRole201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
@@ -11707,6 +11641,28 @@ 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
|
||||
|
||||
@@ -19,12 +19,15 @@ import type {
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
AuthtypesPostableUserRoleDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
CreateUserRole201,
|
||||
DeleteUserDeprecatedPathParameters,
|
||||
DeleteUserPathParameters,
|
||||
DeleteUserRolePathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
GetResetPasswordToken200,
|
||||
@@ -37,6 +40,8 @@ import type {
|
||||
GetUserDeprecated200,
|
||||
GetUserDeprecatedPathParameters,
|
||||
GetUserPathParameters,
|
||||
GetUserRole200,
|
||||
GetUserRolePathParameters,
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListUsers200,
|
||||
@@ -1154,6 +1159,267 @@ 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
|
||||
@@ -1865,6 +2131,7 @@ export const invalidateGetRolesByUserID = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns the role to the user roles by user id
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const setRoleByUserID = (
|
||||
@@ -1936,6 +2203,7 @@ export type SetRoleByUserIDMutationBody =
|
||||
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const useSetRoleByUserID = <
|
||||
@@ -1964,6 +2232,7 @@ 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 = (
|
||||
@@ -2022,6 +2291,7 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const useRemoveUserRoleByUserIDAndRoleID = <
|
||||
|
||||
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
34
frontend/src/components/TagBadge/TagBadge.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
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;
|
||||
@@ -15,59 +15,28 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
// Sienna chip via the @signozhq Badge; this only constrains its width.
|
||||
.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: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
--button-variant-ghost-color: currentColor;
|
||||
--button-variant-ghost-hover-color: currentColor;
|
||||
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;
|
||||
|
||||
@@ -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,27 +120,23 @@ function TagKeyValueInput({
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<TagBadge
|
||||
key={tag}
|
||||
className={styles.tag}
|
||||
closable
|
||||
onClose={(): void => removeTag(tag)}
|
||||
>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
testId={`${testId}-chip`}
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
</TagBadge>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
@@ -27,7 +28,7 @@ interface PieArcProps {
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +73,7 @@ export default function PieArc({
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
|
||||
@@ -80,6 +80,7 @@ describe('PieArc', () => {
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
// onClick now also receives the DOM event (for drill-down popover positioning).
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -79,6 +80,10 @@ 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>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -99,7 +104,7 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
height: auto;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
|
||||
@@ -66,20 +66,6 @@
|
||||
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;
|
||||
@@ -111,22 +97,6 @@
|
||||
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;
|
||||
@@ -160,3 +130,106 @@
|
||||
.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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, CollapseProps } from 'antd';
|
||||
import { Collapse, Tooltip } from 'antd';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
@@ -113,192 +112,74 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
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 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 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 selectedCategoryConfig = useMemo(
|
||||
() => categories.find((cat) => cat.key === selectedCategory)?.config,
|
||||
[categories, selectedCategory],
|
||||
);
|
||||
|
||||
const handleCategoryChange = (key: string | string[]): void => {
|
||||
if (Array.isArray(key) && key.length > 0) {
|
||||
setSelectedCategory(key[0] as string);
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
setSelectedCategory(key);
|
||||
// Reset filters
|
||||
setUrlFilters(null);
|
||||
setOrderBy(null);
|
||||
@@ -332,26 +213,58 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
<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.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}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<Collapse
|
||||
onChange={handleCategoryChange}
|
||||
items={items}
|
||||
defaultActiveKey={[selectedCategory]}
|
||||
activeKey={[selectedCategory]}
|
||||
accordion
|
||||
bordered={false}
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useCreateServiceAccountRoleDeprecated,
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
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 } = useCreateServiceAccountRole({
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
SolidInfoCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
@@ -203,19 +203,13 @@ function DashboardInfo({
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<Badge key={tag} color="sienna" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
+{remainingTags.length}
|
||||
</Badge>
|
||||
<span data-testid="dashboard-tags-overflow">
|
||||
<TagBadge>+{remainingTags.length}</TagBadge>
|
||||
</span>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -63,8 +63,13 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
@@ -183,6 +188,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -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';
|
||||
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
// 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`.
|
||||
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
return tags.map((t) => `${t.key}:${t.value}`);
|
||||
}
|
||||
|
||||
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
|
||||
@@ -14,8 +14,6 @@ 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';
|
||||
@@ -25,7 +23,10 @@ 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';
|
||||
@@ -40,6 +41,7 @@ function BarPanelRenderer({
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/BarChartPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -56,12 +58,10 @@ function BarPanelRenderer({
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
// 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.
|
||||
// X-scale clamps come from the request that produced the data, so each panel
|
||||
// pins to the window it fetched.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -155,10 +155,29 @@ function BarPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
(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 } });
|
||||
},
|
||||
[onClick],
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -188,7 +207,7 @@ function BarPanelRenderer({
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
isStackedBarChart={spec.visualization?.stackedBarChart ?? false}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -20,7 +20,6 @@ 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,
|
||||
@@ -28,7 +27,6 @@ function HistogramPanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
panelMode,
|
||||
onClick,
|
||||
}: PanelRendererProps<'signoz/HistogramPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -100,13 +98,6 @@ function HistogramPanelRenderer({
|
||||
|
||||
const isQueriesMerged = spec.histogramBuckets?.mergeAllActiveQueries ?? false;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
},
|
||||
[onClick],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={graphRef}
|
||||
@@ -127,7 +118,6 @@ function HistogramPanelRenderer({
|
||||
width={containerDimensions.width}
|
||||
height={containerDimensions.height}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,5 +37,6 @@ export const definition: PanelDefinition<'signoz/ListPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: true,
|
||||
drilldown: false,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,9 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type KeyboardEvent as ReactKeyboardEvent,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} 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';
|
||||
@@ -8,6 +13,9 @@ 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';
|
||||
@@ -17,24 +25,31 @@ 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 value = useMemo(
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const tables = useMemo(
|
||||
() =>
|
||||
prepareNumberData(
|
||||
prepareScalarTables({
|
||||
results: getScalarResults(data.response),
|
||||
legendMap: data.legendMap ?? {},
|
||||
requestPayload: data.requestPayload,
|
||||
}),
|
||||
),
|
||||
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],
|
||||
@@ -54,10 +69,60 @@ 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} />
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/NumberPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useMemo,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} 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';
|
||||
@@ -13,6 +17,9 @@ 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';
|
||||
|
||||
@@ -22,6 +29,7 @@ function PiePanelRenderer({
|
||||
data,
|
||||
refetch,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/PieChartPanel'>): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -30,6 +38,11 @@ function PiePanelRenderer({
|
||||
[panel.spec.plugin.spec],
|
||||
);
|
||||
|
||||
const builderQueries = useMemo(
|
||||
() => getBuilderQueries(panel.spec.queries || []),
|
||||
[panel.spec.queries],
|
||||
);
|
||||
|
||||
const slices = useMemo(
|
||||
() =>
|
||||
preparePieData({
|
||||
@@ -61,10 +74,21 @@ function PiePanelRenderer({
|
||||
);
|
||||
|
||||
const handleSliceClick = useCallback(
|
||||
(slice: PieSlice) => {
|
||||
onClick?.({ label: slice.label, value: slice.value });
|
||||
(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);
|
||||
}
|
||||
},
|
||||
[onClick],
|
||||
[onClick, builderQueries, data.requestPayload],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -79,7 +103,7 @@ function PiePanelRenderer({
|
||||
isDarkMode={isDarkMode}
|
||||
position={legendPosition}
|
||||
id={panelId}
|
||||
onSliceClick={handleSliceClick}
|
||||
onSliceClick={enableDrillDown ? handleSliceClick : undefined}
|
||||
data-testid="pie-chart"
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -23,5 +23,6 @@ export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
|
||||
download: false,
|
||||
createAlert: false,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type MouseEvent as ReactMouseEvent,
|
||||
} from 'react';
|
||||
import { Table } from 'antd';
|
||||
import type { DashboardtypesTablePanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useResizeObserver } from 'hooks/useDimensions';
|
||||
@@ -8,6 +15,9 @@ 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';
|
||||
|
||||
@@ -27,6 +37,8 @@ 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);
|
||||
@@ -42,6 +54,11 @@ 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(
|
||||
@@ -64,6 +81,34 @@ 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
|
||||
@@ -72,9 +117,17 @@ function TablePanelRenderer({
|
||||
columnUnits: spec.formatting?.columnUnits ?? {},
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
onCellClick: enableDrillDown ? handleCellClick : undefined,
|
||||
})
|
||||
: [],
|
||||
[table, spec.formatting?.columnUnits, decimalPrecision, thresholdsByColumn],
|
||||
[
|
||||
table,
|
||||
spec.formatting?.columnUnits,
|
||||
decimalPrecision,
|
||||
thresholdsByColumn,
|
||||
enableDrillDown,
|
||||
handleCellClick,
|
||||
],
|
||||
);
|
||||
|
||||
// User-resizable columns, persisted per panel to localStorage.
|
||||
|
||||
@@ -25,5 +25,6 @@ export const definition: PanelDefinition<'signoz/TablePanel'> = {
|
||||
createAlert: false,
|
||||
// V1 parity: only tables (and lists) expose the header search box.
|
||||
search: true,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -52,6 +52,12 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,6 +71,7 @@ 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
|
||||
@@ -97,19 +104,26 @@ export function buildTableColumns({
|
||||
}
|
||||
return text;
|
||||
},
|
||||
onCell: (record: TableRowData): { style?: React.CSSProperties } => {
|
||||
if (!col.isValueColumn || colThresholds.length === 0) {
|
||||
return {};
|
||||
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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
const num = Number(record[key]);
|
||||
if (!Number.isFinite(num)) {
|
||||
return {};
|
||||
|
||||
if (onCellClick) {
|
||||
cellProps.onClick = (event): void =>
|
||||
onCellClick({ columnId: key, record, event });
|
||||
cellProps.style = { ...cellProps.style, cursor: 'pointer' };
|
||||
}
|
||||
const { threshold } = resolveActiveThreshold(colThresholds, num, unit);
|
||||
if (threshold?.format === 'background') {
|
||||
return { style: { backgroundColor: threshold.color } };
|
||||
}
|
||||
return {};
|
||||
|
||||
return cellProps;
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
@@ -14,8 +14,6 @@ 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';
|
||||
@@ -25,7 +23,10 @@ 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';
|
||||
@@ -40,6 +41,7 @@ function TimeSeriesPanelRenderer({
|
||||
dashboardPreference,
|
||||
panelMode,
|
||||
onCloseStandaloneView,
|
||||
enableDrillDown,
|
||||
}: PanelRendererProps<'signoz/TimeSeriesPanel'>): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -58,11 +60,9 @@ 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. The generated request DTO is structurally the V5 request.
|
||||
// new data arrives.
|
||||
const { minTimeScale, maxTimeScale } = useMemo(() => {
|
||||
const { startTime, endTime } = getTimeRangeFromQueryRangeRequest(
|
||||
data.requestPayload as unknown as QueryRangeRequestV5 | undefined,
|
||||
);
|
||||
const { startTime, endTime } = getPanelTimeRange(data.requestPayload);
|
||||
return { minTimeScale: startTime, maxTimeScale: endTime };
|
||||
}, [data.requestPayload]);
|
||||
|
||||
@@ -156,10 +156,29 @@ function TimeSeriesPanelRenderer({
|
||||
const key = `${dashboardPreference?.syncMode}-${dashboardPreference?.syncFilterMode}`;
|
||||
|
||||
const handleChartClick = useCallback(
|
||||
(args: ChartClickData) => {
|
||||
onClick?.(args);
|
||||
(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 } });
|
||||
},
|
||||
[onClick],
|
||||
[onClick, flatSeries, builderQueries, data.response],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -188,7 +207,7 @@ function TimeSeriesPanelRenderer({
|
||||
syncMode={dashboardPreference?.syncMode}
|
||||
syncFilterMode={dashboardPreference?.syncFilterMode}
|
||||
renderTooltipFooter={renderTooltipFooter}
|
||||
onClick={handleChartClick}
|
||||
onClick={enableDrillDown ? handleChartClick : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -27,5 +27,6 @@ export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
|
||||
download: false,
|
||||
createAlert: true,
|
||||
search: false,
|
||||
drilldown: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,24 +1,5 @@
|
||||
import type { ChartClickData } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
|
||||
|
||||
import type { PanelKind } from './panelKind';
|
||||
|
||||
/** 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;
|
||||
import type { DrilldownClickPayload } from './drilldown';
|
||||
|
||||
type DragSelect = (start: number, end: number) => void;
|
||||
|
||||
@@ -29,23 +10,27 @@ 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: ChartClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'signoz/BarChartPanel': {
|
||||
onClick?: (event: ChartClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
};
|
||||
'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>;
|
||||
'signoz/TablePanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/PieChartPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
'signoz/NumberPanel': { onClick?: (event: DrilldownClickPayload) => void };
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -53,7 +38,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: PanelClickEvent) => void;
|
||||
onClick?: (event: DrilldownClickPayload) => void;
|
||||
onDragSelect?: DragSelect;
|
||||
onCloseStandaloneView?: CloseStandaloneView;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,11 @@ 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> {
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,483 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
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) };
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
.signal {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
font-weight: normal;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
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;
|
||||
@@ -0,0 +1,9 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
@@ -0,0 +1,9 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.backArrow {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
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;
|
||||
@@ -0,0 +1,40 @@
|
||||
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;
|
||||
@@ -3,11 +3,13 @@ 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';
|
||||
@@ -68,6 +70,7 @@ function Panel({
|
||||
});
|
||||
|
||||
const { onDragSelect, dashboardPreference } = usePanelInteractions();
|
||||
const drilldown = useDrilldown(panel, panelId);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -100,8 +103,11 @@ function Panel({
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onClick={drilldown.onPanelClick}
|
||||
enableDrillDown={drilldown.enableDrillDown}
|
||||
/>
|
||||
)}
|
||||
<ContextMenu {...drilldown.contextMenuProps} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -294,4 +294,13 @@ 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');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,12 +3,13 @@ import type { ComponentTypes } from 'utils/permission';
|
||||
|
||||
/**
|
||||
* Every action the panel menu can offer: per-kind gated capabilities (minus
|
||||
* `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.
|
||||
* `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.
|
||||
*/
|
||||
export type PanelActionId =
|
||||
| Exclude<keyof PanelActionCapabilities, 'search'>
|
||||
| Exclude<keyof PanelActionCapabilities, 'search' | 'drilldown'>
|
||||
| 'move'
|
||||
| 'delete';
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -37,6 +38,10 @@ 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,6 +63,8 @@ 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.
|
||||
@@ -120,7 +127,8 @@ function PanelBody({
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
enableDrillDown={false}
|
||||
enableDrillDown={enableDrillDown}
|
||||
onClick={onClick}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchTerm}
|
||||
pagination={pagination}
|
||||
|
||||
@@ -1,22 +1,69 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
.typeButton {
|
||||
.panelTypeCard {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background: var(--l2-background);
|
||||
padding: 12px;
|
||||
background: var(--bg-ink-400, #0b0c0e);
|
||||
border: 1px solid var(--l1-border);
|
||||
gap: 12px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
border-radius: 4px;
|
||||
color: var(--l1-foreground);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition:
|
||||
transform 180ms ease,
|
||||
border-color 180ms ease;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--bg-robin-500);
|
||||
background-color: var(--l2-background-hover);
|
||||
border-color: var(--bg-robin-400);
|
||||
}
|
||||
&:active {
|
||||
transform: translateY(2px);
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,45 +1,135 @@
|
||||
import { Modal } from 'antd';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
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) => void;
|
||||
onSelect: (panelKind: PanelKind, layoutIndex?: number) => void;
|
||||
/** Section the picker opens on; omit → the untitled root / first section. */
|
||||
defaultLayoutIndex?: number;
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<Modal
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
title="Select a panel type"
|
||||
onCancel={onClose}
|
||||
footer={null}
|
||||
destroyOnClose
|
||||
onOpenChange={(isOpen): void => {
|
||||
if (!isOpen) {
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
title="New Panel"
|
||||
footer={footer}
|
||||
>
|
||||
<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 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>
|
||||
</Modal>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
.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;
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
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;
|
||||
@@ -14,3 +14,16 @@ 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>;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
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;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
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;
|
||||
@@ -0,0 +1,74 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,299 @@
|
||||
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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,213 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,243 @@
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
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,
|
||||
]);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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 };
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
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 };
|
||||
}
|
||||
@@ -1,22 +1,33 @@
|
||||
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;
|
||||
/** Close the View modal by clearing the URL param. */
|
||||
/**
|
||||
* 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. */
|
||||
closeView: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function useViewPanel(): UseViewPanelApi {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -39,6 +50,22 @@ 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);
|
||||
@@ -50,5 +77,5 @@ export function useViewPanel(): UseViewPanelApi {
|
||||
safeNavigate(search ? `${pathname}?${search}` : pathname);
|
||||
}, [pathname, safeNavigate, urlQuery]);
|
||||
|
||||
return { expandedPanelId, openView, closeView };
|
||||
return { expandedPanelId, openView, openViewWithQuery, closeView };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,46 @@
|
||||
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 };
|
||||
}
|
||||
@@ -29,8 +29,13 @@ interface SectionProps {
|
||||
|
||||
function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isPickerOpen, openPicker, closePicker, createPanel } =
|
||||
useCreatePanel();
|
||||
const {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
createPanel,
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
const [isDeleteOpen, setIsDeleteOpen] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
// Placeholder signal for lazy panel query-loading (consumed in a later PR):
|
||||
@@ -141,6 +146,7 @@ function Section({ section, sections, dragHandle }: SectionProps): JSX.Element {
|
||||
open={isPickerOpen}
|
||||
onClose={closePicker}
|
||||
onSelect={createPanel}
|
||||
defaultLayoutIndex={targetLayoutIndex}
|
||||
/>
|
||||
<ConfirmDeleteDialog
|
||||
open={isDeleteOpen}
|
||||
|
||||
@@ -30,7 +30,9 @@ 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.
|
||||
// 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.
|
||||
const { expandedPanelId, closeView } = useViewPanel();
|
||||
const expandedPanel = expandedPanelId ? panels[expandedPanelId] : undefined;
|
||||
|
||||
|
||||
@@ -20,11 +20,12 @@ import {
|
||||
export const ALL_SELECTED = '__ALL__';
|
||||
|
||||
/** `?variables=` holds `{ [name]: value }` (ALL encoded as the sentinel). */
|
||||
const variablesUrlParser = parseAsJson<Record<string, SelectedVariableValue>>(
|
||||
(v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
export const variablesUrlParser = parseAsJson<
|
||||
Record<string, SelectedVariableValue>
|
||||
>((v) =>
|
||||
typeof v === 'object' && v !== null
|
||||
? (v as Record<string, SelectedVariableValue>)
|
||||
: null,
|
||||
);
|
||||
|
||||
function defaultSelection(model: VariableFormModel): VariableSelection {
|
||||
|
||||
@@ -12,7 +12,10 @@ interface UseCreatePanelResult {
|
||||
/** Pass the target section's layout index; omit → last/new section. */
|
||||
openPicker: (layoutIndex?: number) => void;
|
||||
closePicker: () => void;
|
||||
createPanel: (panelKind: PanelKind) => 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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -38,16 +41,23 @@ export function useCreatePanel(): UseCreatePanelResult {
|
||||
}, []);
|
||||
|
||||
const createPanel = useCallback(
|
||||
(panelKind: PanelKind): void => {
|
||||
(panelKind: PanelKind, targetIndex?: number): void => {
|
||||
setIsPickerOpen(false);
|
||||
const path = generatePath(ROUTES.DASHBOARD_PANEL_EDITOR, {
|
||||
dashboardId,
|
||||
panelId: NEW_PANEL_ID,
|
||||
});
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, layoutIndex)}`);
|
||||
const target = targetIndex ?? layoutIndex;
|
||||
safeNavigate(`${path}${newPanelSearch(panelKind, target)}`);
|
||||
},
|
||||
[safeNavigate, dashboardId, layoutIndex],
|
||||
);
|
||||
|
||||
return { isPickerOpen, openPicker, closePicker, createPanel };
|
||||
return {
|
||||
isPickerOpen,
|
||||
openPicker,
|
||||
closePicker,
|
||||
targetLayoutIndex: layoutIndex,
|
||||
createPanel,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
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],
|
||||
);
|
||||
}
|
||||
@@ -38,22 +38,14 @@ function DashboardContainer({
|
||||
user.role,
|
||||
);
|
||||
|
||||
// Publish edit context to the store so hooks/components read it from there
|
||||
// instead of receiving dashboardId/isEditable/refetch as props down the tree.
|
||||
// Seed during render (not an effect) so the first Panel render already sees the id —
|
||||
// useDashboardFetchRequired throws on a missing id. setEditContext self-guards.
|
||||
const setEditContext = useDashboardStore((s) => s.setEditContext);
|
||||
useEffect(() => {
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !dashboard.locked && editDashboardPermission,
|
||||
refetch,
|
||||
});
|
||||
}, [
|
||||
dashboard.id,
|
||||
dashboard.locked,
|
||||
editDashboardPermission,
|
||||
setEditContext({
|
||||
dashboardId: dashboard.id,
|
||||
isEditable: !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.
|
||||
|
||||
@@ -24,11 +24,20 @@ export const createEditContextSlice: StateCreator<
|
||||
[['zustand/persist', unknown]],
|
||||
[],
|
||||
EditContextSlice
|
||||
> = (set) => ({
|
||||
> = (set, get) => ({
|
||||
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,
|
||||
|
||||
@@ -130,27 +130,6 @@
|
||||
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;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
@@ -105,14 +105,10 @@ function DashboardRow({
|
||||
{tags.length > 0 && (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 3).map((tag) => (
|
||||
<Badge className={styles.tag} key={tag}>
|
||||
{tag}
|
||||
</Badge>
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
))}
|
||||
{tags.length > 3 && (
|
||||
<Badge className={styles.tag} key={tags[3]}>
|
||||
+ <Typography.Text> {tags.length - 3} </Typography.Text>
|
||||
</Badge>
|
||||
<TagBadge key={tags[3]}>+{tags.length - 3}</TagBadge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -65,6 +65,7 @@ type provider struct {
|
||||
zeusHandler zeus.Handler
|
||||
querierHandler querier.Handler
|
||||
serviceAccountHandler serviceaccount.Handler
|
||||
serviceAccountGetter serviceaccount.Getter
|
||||
factoryHandler factory.Handler
|
||||
cloudIntegrationHandler cloudintegration.Handler
|
||||
ruleStateHistoryHandler rulestatehistory.Handler
|
||||
@@ -99,6 +100,7 @@ func NewFactory(
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
serviceAccountGetter serviceaccount.Getter,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
@@ -136,6 +138,7 @@ func NewFactory(
|
||||
zeusHandler,
|
||||
querierHandler,
|
||||
serviceAccountHandler,
|
||||
serviceAccountGetter,
|
||||
factoryHandler,
|
||||
cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler,
|
||||
@@ -175,6 +178,7 @@ func newProvider(
|
||||
zeusHandler zeus.Handler,
|
||||
querierHandler querier.Handler,
|
||||
serviceAccountHandler serviceaccount.Handler,
|
||||
serviceAccountGetter serviceaccount.Getter,
|
||||
factoryHandler factory.Handler,
|
||||
cloudIntegrationHandler cloudintegration.Handler,
|
||||
ruleStateHistoryHandler rulestatehistory.Handler,
|
||||
@@ -213,6 +217,7 @@ func newProvider(
|
||||
zeusHandler: zeusHandler,
|
||||
querierHandler: querierHandler,
|
||||
serviceAccountHandler: serviceAccountHandler,
|
||||
serviceAccountGetter: serviceAccountGetter,
|
||||
factoryHandler: factoryHandler,
|
||||
cloudIntegrationHandler: cloudIntegrationHandler,
|
||||
ruleStateHistoryHandler: ruleStateHistoryHandler,
|
||||
|
||||
@@ -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: "CreateServiceAccountRole",
|
||||
ID: "CreateServiceAccountRoleDeprecated",
|
||||
Tags: []string{"serviceaccount"},
|
||||
Summary: "Create service account role",
|
||||
Description: "This endpoint assigns a role to a service account",
|
||||
Request: new(serviceaccounttypes.PostableServiceAccountRole),
|
||||
Request: new(serviceaccounttypes.DeprecatedPostableServiceAccountRole),
|
||||
RequestContentType: "",
|
||||
Response: new(types.Identifiable),
|
||||
ResponseContentType: "application/json",
|
||||
SuccessStatusCode: http.StatusCreated,
|
||||
ErrorStatusCodes: []int{http.StatusBadRequest},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
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: "DeleteServiceAccountRole",
|
||||
ID: "DeleteServiceAccountRoleDeprecated",
|
||||
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: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceServiceAccount.Scope(coretypes.VerbDetach), coretypes.ResourceRole.Scope(coretypes.VerbDetach)}),
|
||||
},
|
||||
handler.WithResourceDefs(handler.AttachDetachSiblingResourceDef{
|
||||
@@ -398,13 +398,96 @@ 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 {
|
||||
@@ -421,3 +504,53 @@ 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
|
||||
})
|
||||
}
|
||||
|
||||
@@ -394,7 +394,7 @@ func (provider *provider) addUserRoutes(router *mux.Router) error {
|
||||
ResponseContentType: "",
|
||||
SuccessStatusCode: http.StatusOK,
|
||||
ErrorStatusCodes: []int{http.StatusNotFound},
|
||||
Deprecated: false,
|
||||
Deprecated: true,
|
||||
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: false,
|
||||
Deprecated: true,
|
||||
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
|
||||
})).Methods(http.MethodDelete).GetError(); err != nil {
|
||||
return err
|
||||
@@ -434,5 +434,56 @@ 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
|
||||
}
|
||||
|
||||
@@ -216,6 +216,11 @@ 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
|
||||
|
||||
@@ -117,16 +117,8 @@ var podsSpec = checkSpec{
|
||||
DocumentationLink: docLinkKubeletStatsReceiver,
|
||||
},
|
||||
{
|
||||
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",
|
||||
},
|
||||
Component: componentK8sClusterReceiver,
|
||||
DefaultMetrics: []string{"k8s.pod.phase"},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -157,10 +149,6 @@ 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,
|
||||
},
|
||||
{
|
||||
@@ -194,10 +182,6 @@ var deploymentsSpec = checkSpec{
|
||||
"k8s.deployment.desired",
|
||||
"k8s.deployment.available",
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -236,10 +220,6 @@ 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,
|
||||
},
|
||||
{
|
||||
@@ -278,10 +258,6 @@ var statefulsetsSpec = checkSpec{
|
||||
"k8s.statefulset.desired_pods",
|
||||
"k8s.statefulset.current_pods",
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -322,10 +298,6 @@ var jobsSpec = checkSpec{
|
||||
"k8s.job.failed_pods",
|
||||
"k8s.job.successful_pods",
|
||||
},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -352,12 +324,8 @@ var namespacesSpec = checkSpec{
|
||||
DocumentationLink: docLinkKubeletStatsReceiver,
|
||||
},
|
||||
{
|
||||
Component: componentK8sClusterReceiver,
|
||||
DefaultMetrics: []string{"k8s.pod.phase"},
|
||||
OptionalMetrics: []string{
|
||||
"k8s.pod.status_reason",
|
||||
"k8s.container.status.reason",
|
||||
},
|
||||
Component: componentK8sClusterReceiver,
|
||||
DefaultMetrics: []string{"k8s.pod.phase"},
|
||||
DocumentationLink: docLinkK8sClusterReceiver,
|
||||
},
|
||||
{
|
||||
@@ -392,10 +360,6 @@ 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,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -20,7 +20,6 @@ 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)
|
||||
|
||||
@@ -70,10 +69,6 @@ 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
|
||||
|
||||
@@ -27,8 +27,6 @@ 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{
|
||||
|
||||
@@ -19,7 +19,6 @@ 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)
|
||||
|
||||
@@ -78,10 +77,6 @@ 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
|
||||
|
||||
@@ -23,8 +23,6 @@ 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",
|
||||
|
||||
@@ -19,7 +19,6 @@ 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)
|
||||
|
||||
@@ -78,10 +77,6 @@ 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
|
||||
|
||||
@@ -23,8 +23,6 @@ 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
Reference in New Issue
Block a user