mirror of
https://github.com/SigNoz/signoz.git
synced 2026-07-06 22:50:38 +01:00
Compare commits
1 Commits
issue-4960
...
nv/str-equ
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3028f19298 |
@@ -155,8 +155,8 @@ querier:
|
||||
cache_ttl: 168h
|
||||
# The interval for recent data that should not be cached.
|
||||
flux_interval: 5m
|
||||
# The maximum number of queries a single query range request runs at once.
|
||||
max_concurrent_queries: 8
|
||||
# The maximum number of concurrent queries for missing ranges.
|
||||
max_concurrent_queries: 4
|
||||
# When filtering logs by trace_id, clamp the query window to the trace time
|
||||
# range with padding to include slightly delayed log exports. Logs only; set
|
||||
# to 0 to disable.
|
||||
|
||||
@@ -515,13 +515,6 @@ components:
|
||||
url:
|
||||
type: string
|
||||
type: object
|
||||
AuthtypesDeprecatedPostableUserRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
AuthtypesGettableAuthDomain:
|
||||
properties:
|
||||
authNProviderInfo:
|
||||
@@ -667,20 +660,17 @@ components:
|
||||
type: string
|
||||
userRoles:
|
||||
items:
|
||||
$ref: '#/components/schemas/AuthtypesDeprecatedPostableUserRole'
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
type: array
|
||||
required:
|
||||
- email
|
||||
type: object
|
||||
AuthtypesPostableUserRole:
|
||||
properties:
|
||||
roleId:
|
||||
type: string
|
||||
userId:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- userId
|
||||
- roleId
|
||||
- id
|
||||
type: object
|
||||
AuthtypesRelation:
|
||||
enum:
|
||||
@@ -2927,13 +2917,6 @@ components:
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesGettablePublicDashboardDataV2:
|
||||
properties:
|
||||
dashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettableDashboardV2'
|
||||
publicDashboard:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDasbhboard'
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -5241,16 +5224,6 @@ components:
|
||||
- offset
|
||||
- limit
|
||||
type: object
|
||||
LlmpricingruletypesGettableUnmappedModels:
|
||||
properties:
|
||||
items:
|
||||
items:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesUnmappedModel'
|
||||
nullable: true
|
||||
type: array
|
||||
required:
|
||||
- items
|
||||
type: object
|
||||
LlmpricingruletypesLLMPricingCacheCosts:
|
||||
properties:
|
||||
mode:
|
||||
@@ -5340,19 +5313,6 @@ components:
|
||||
type: string
|
||||
nullable: true
|
||||
type: array
|
||||
LlmpricingruletypesUnmappedModel:
|
||||
properties:
|
||||
modelName:
|
||||
type: string
|
||||
provider:
|
||||
type: string
|
||||
spanCount:
|
||||
minimum: 0
|
||||
type: integer
|
||||
required:
|
||||
- modelName
|
||||
- spanCount
|
||||
type: object
|
||||
LlmpricingruletypesUpdatableLLMPricingRule:
|
||||
properties:
|
||||
enabled:
|
||||
@@ -7440,13 +7400,6 @@ components:
|
||||
enum:
|
||||
- basic
|
||||
type: string
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRole:
|
||||
properties:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesGettableFactorAPIKey:
|
||||
properties:
|
||||
createdAt:
|
||||
@@ -7503,13 +7456,10 @@ components:
|
||||
type: object
|
||||
ServiceaccounttypesPostableServiceAccountRole:
|
||||
properties:
|
||||
roleId:
|
||||
type: string
|
||||
serviceAccountId:
|
||||
id:
|
||||
type: string
|
||||
required:
|
||||
- serviceAccountId
|
||||
- roleId
|
||||
- id
|
||||
type: object
|
||||
ServiceaccounttypesServiceAccount:
|
||||
properties:
|
||||
@@ -11240,60 +11190,6 @@ paths:
|
||||
summary: Get a pricing rule
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/llm_pricing_rules/unmapped_models:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns models seen in the last hour of trace data (gen_ai.request.model)
|
||||
that no pricing rule pattern matches, so the user can add them to an existing
|
||||
rule or create a new one.
|
||||
operationId: ListUnmappedLLMModels
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/LlmpricingruletypesGettableUnmappedModels'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- VIEWER
|
||||
- tokenizer:
|
||||
- VIEWER
|
||||
summary: List unmapped models
|
||||
tags:
|
||||
- llmpricingrules
|
||||
/api/v1/logs/promote_paths:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -12272,188 +12168,6 @@ paths:
|
||||
summary: Update route policy
|
||||
tags:
|
||||
- routepolicies
|
||||
/api/v1/service_account_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
- tokenizer:
|
||||
- serviceaccount:attach
|
||||
- role:attach
|
||||
summary: Create service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_account_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a service account
|
||||
operationId: DeleteServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
- tokenizer:
|
||||
- serviceaccount:detach
|
||||
- role:detach
|
||||
summary: Delete service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing service account role
|
||||
operationId: GetServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesServiceAccountRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- serviceaccount:read
|
||||
- tokenizer:
|
||||
- serviceaccount:read
|
||||
summary: Get service account role
|
||||
tags:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -13023,9 +12737,9 @@ paths:
|
||||
tags:
|
||||
- serviceaccount
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a service account
|
||||
operationId: CreateServiceAccountRoleDeprecated
|
||||
operationId: CreateServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -13036,7 +12750,7 @@ paths:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/ServiceaccounttypesDeprecatedPostableServiceAccountRole'
|
||||
$ref: '#/components/schemas/ServiceaccounttypesPostableServiceAccountRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
@@ -13088,9 +12802,9 @@ paths:
|
||||
- serviceaccount
|
||||
/api/v1/service_accounts/{id}/roles/{rid}:
|
||||
delete:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from service account
|
||||
operationId: DeleteServiceAccountRoleDeprecated
|
||||
operationId: DeleteServiceAccountRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
@@ -17727,138 +17441,6 @@ paths:
|
||||
summary: Update my organization
|
||||
tags:
|
||||
- orgs
|
||||
/api/v2/public/dashboards/{id}:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns the sanitized v2-shape dashboard data for
|
||||
public access. Each panel query is reduced to a safe field subset, so filters
|
||||
and raw query strings are not exposed.
|
||||
operationId: GetPublicDashboardDataV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/DashboardtypesGettablePublicDashboardDataV2'
|
||||
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:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get public dashboard data (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/public/dashboards/{id}/panels/{key}/query_range:
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns query range results for a panel of a v2-shape
|
||||
public dashboard. The panel is addressed by its key in spec.panels.
|
||||
operationId: GetPublicDashboardPanelQueryRangeV2
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
- in: path
|
||||
name: key
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5QueryRangeResponse'
|
||||
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:
|
||||
- anonymous:
|
||||
- public-dashboard:read
|
||||
summary: Get query range result (v2)
|
||||
tags:
|
||||
- dashboard
|
||||
/api/v2/readyz:
|
||||
get:
|
||||
operationId: Readyz
|
||||
@@ -22192,184 +21774,6 @@ paths:
|
||||
summary: Rotate session
|
||||
tags:
|
||||
- sessions
|
||||
/api/v2/user_roles:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint assigns a role to a user
|
||||
operationId: CreateUserRole
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AuthtypesPostableUserRole'
|
||||
responses:
|
||||
"201":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/TypesIdentifiable'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: Created
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Create user role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/user_roles/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint revokes a role from a user
|
||||
operationId: DeleteUserRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Delete user role
|
||||
tags:
|
||||
- users
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint gets an existing user role
|
||||
operationId: GetUserRole
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AuthtypesUserRole'
|
||||
status:
|
||||
type: string
|
||||
required:
|
||||
- status
|
||||
- data
|
||||
type: object
|
||||
description: OK
|
||||
"400":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Bad Request
|
||||
"401":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Unauthorized
|
||||
"403":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Forbidden
|
||||
"404":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Not Found
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- ADMIN
|
||||
- tokenizer:
|
||||
- ADMIN
|
||||
summary: Get user role
|
||||
tags:
|
||||
- users
|
||||
/api/v2/users:
|
||||
get:
|
||||
deprecated: false
|
||||
@@ -22823,7 +22227,7 @@ paths:
|
||||
tags:
|
||||
- users
|
||||
post:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint assigns the role to the user roles by user id
|
||||
operationId: SetRoleByUserID
|
||||
parameters:
|
||||
@@ -22874,7 +22278,7 @@ paths:
|
||||
- users
|
||||
/api/v2/users/{id}/roles/{roleId}:
|
||||
delete:
|
||||
deprecated: true
|
||||
deprecated: false
|
||||
description: This endpoint removes a role from the user by user id and role
|
||||
id
|
||||
operationId: RemoveUserRoleByUserIDAndRoleID
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -29,7 +29,6 @@ type module struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
querier querier.Querier
|
||||
licensing licensing.Licensing
|
||||
tagModule tag.Module
|
||||
}
|
||||
|
||||
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
|
||||
@@ -42,7 +41,6 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
|
||||
settings: scopedProviderSettings,
|
||||
querier: querier,
|
||||
licensing: licensing,
|
||||
tagModule: tagModule,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -134,55 +132,6 @@ func (module *module) GetPublicWidgetQueryRange(ctx context.Context, id valuer.U
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) GetDashboardByPublicIDV2(ctx context.Context, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
tags, err := module.tagModule.ListForResource(ctx, storableDashboard.OrgID, coretypes.KindDashboard, storableDashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return storableDashboard.ToDashboardV2(tags)
|
||||
}
|
||||
|
||||
func (module *module) GetPublicWidgetQueryRangeV2(ctx context.Context, id valuer.UUID, panelKey, startTimeRaw, endTimeRaw string) (*querybuildertypesv5.QueryRangeResponse, error) {
|
||||
ctx = ctxtypes.NewContextWithCommentVals(ctx, map[string]string{
|
||||
instrumentationtypes.CodeNamespace: "dashboard",
|
||||
instrumentationtypes.CodeFunctionName: "GetPublicWidgetQueryRangeV2",
|
||||
})
|
||||
|
||||
storableDashboard, err := module.store.GetDashboardByPublicID(ctx, id.StringValue())
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// tags are not needed for query range.
|
||||
dashboard, err := storableDashboard.ToDashboardV2(nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
publicDashboard, err := module.GetPublic(ctx, dashboard.OrgID, dashboard.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
startTime, endTime, err := publicDashboard.ResolveTimeRange(startTimeRaw, endTimeRaw)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
query, err := dashboard.GetPanelQuery(startTime, endTime, panelKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return module.querier.QueryRange(ctx, dashboard.OrgID, query)
|
||||
}
|
||||
|
||||
func (module *module) UpdatePublic(ctx context.Context, orgID valuer.UUID, publicDashboard *dashboardtypes.PublicDashboard) error {
|
||||
_, err := module.licensing.GetActive(ctx, orgID)
|
||||
if err != nil {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -293,8 +293,6 @@
|
||||
// Forces subpath imports (@signozhq/ui/<component>) instead of the eagerly-loaded barrel
|
||||
"signoz/no-css-module-bracket-access": "warn",
|
||||
// Prevents bracket access on CSS modules (styles['kebab-case']) which fails with camelCaseOnly config
|
||||
"signoz/no-dashboard-fetch-outside-root": "error",
|
||||
// Forces useDashboardFetchRequired() outside the root V2 pages (allowlisted in overrides below)
|
||||
"no-restricted-globals": [
|
||||
"error",
|
||||
{
|
||||
@@ -560,18 +558,6 @@
|
||||
"rules": {
|
||||
"signoz/no-zustand-getstate-in-hooks": "off"
|
||||
}
|
||||
},
|
||||
{
|
||||
// Root V2 pages own the dashboard fetch lifecycle; useDashboardFetchRequired wraps it.
|
||||
// Everywhere else must use useDashboardFetchRequired().
|
||||
"files": [
|
||||
"src/pages/DashboardPageV2/DashboardPageV2.tsx",
|
||||
"src/pages/DashboardPageV2/PanelEditorPage/PanelEditorPage.tsx",
|
||||
"src/pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired.ts"
|
||||
],
|
||||
"rules": {
|
||||
"signoz/no-dashboard-fetch-outside-root": "off"
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -25,7 +25,6 @@ You are operating within a constrained context window and strict system prompts.
|
||||
- Never create barrel files.
|
||||
- When writing new css, prefer CSS Modules
|
||||
- Use ./docs/css-modules-guide.md as reference on how to write good CSS Modules.
|
||||
- When writing code that could need authorization checks, read ./src/lib/authz/README.md
|
||||
|
||||
3. FORCED VERIFICATION: Your internal tools mark file writes as successful even if the code does not compile. You are FORBIDDEN from reporting a task as complete until you have:
|
||||
- Run `pnpm tsgo --noEmit`
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
/**
|
||||
* Rule: no-dashboard-fetch-outside-root
|
||||
*
|
||||
* `useDashboardFetch` owns the V2 dashboard fetch and exposes its loading/error/refetch
|
||||
* lifecycle. That lifecycle is a root-page concern: DashboardPageV2 and PanelEditorPage
|
||||
* gate their subtree on a resolved dashboard before mounting anything below them.
|
||||
*
|
||||
* Every other component therefore renders inside an already-loaded subtree and must use
|
||||
* `useDashboardFetchRequired`, which reuses the same react-query cache entry but guarantees
|
||||
* a non-undefined dashboard (throwing otherwise) — so consumers don't re-handle states the
|
||||
* root page already owns.
|
||||
*
|
||||
* This rule flags `useDashboardFetch` imports. It is disabled via an override in
|
||||
* .oxlintrc.json for the two root pages and for useDashboardFetchRequired.ts (which wraps it).
|
||||
*/
|
||||
|
||||
const FETCH_HOOK = 'useDashboardFetch';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
type: 'suggestion',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow useDashboardFetch outside the root dashboard pages; use useDashboardFetchRequired instead',
|
||||
category: 'Dashboard V2',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
useRequired:
|
||||
'Use useDashboardFetchRequired() instead of useDashboardFetch — the latter is reserved for the root pages (DashboardPageV2, PanelEditorPage) that own the fetch lifecycle. Components below them render inside a loaded subtree and should assume a guaranteed dashboard.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
return {
|
||||
ImportDeclaration(node) {
|
||||
node.specifiers.forEach((specifier) => {
|
||||
if (
|
||||
specifier.type === 'ImportSpecifier' &&
|
||||
specifier.imported.name === FETCH_HOOK
|
||||
) {
|
||||
context.report({ node: specifier, messageId: 'useRequired' });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -12,7 +12,6 @@ import noRawAbsolutePath from './rules/no-raw-absolute-path.mjs';
|
||||
import noAntdComponents from './rules/no-antd-components.mjs';
|
||||
import noSignozhqUiBarrel from './rules/no-signozhq-ui-barrel.mjs';
|
||||
import noCssModuleBracketAccess from './rules/no-css-module-bracket-access.mjs';
|
||||
import noDashboardFetchOutsideRoot from './rules/no-dashboard-fetch-outside-root.mjs';
|
||||
|
||||
export default {
|
||||
meta: {
|
||||
@@ -26,6 +25,5 @@ export default {
|
||||
'no-antd-components': noAntdComponents,
|
||||
'no-signozhq-ui-barrel': noSignozhqUiBarrel,
|
||||
'no-css-module-bracket-access': noCssModuleBracketAccess,
|
||||
'no-dashboard-fetch-outside-root': noDashboardFetchOutsideRoot,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -38,10 +38,6 @@ import type {
|
||||
GetPublicDashboard200,
|
||||
GetPublicDashboardData200,
|
||||
GetPublicDashboardDataPathParameters,
|
||||
GetPublicDashboardDataV2200,
|
||||
GetPublicDashboardDataV2PathParameters,
|
||||
GetPublicDashboardPanelQueryRangeV2200,
|
||||
GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
GetPublicDashboardPathParameters,
|
||||
GetPublicDashboardWidgetQueryRange200,
|
||||
GetPublicDashboardWidgetQueryRangePathParameters,
|
||||
@@ -1803,217 +1799,6 @@ export const useLockDashboardV2 = <
|
||||
> => {
|
||||
return useMutation(getLockDashboardV2MutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns the sanitized v2-shape dashboard data for public access. Each panel query is reduced to a safe field subset, so filters and raw query strings are not exposed.
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const getPublicDashboardDataV2 = (
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardDataV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryKey = ({
|
||||
id,
|
||||
}: GetPublicDashboardDataV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardDataV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetPublicDashboardDataV2QueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
> = ({ signal }) => getPublicDashboardDataV2({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>
|
||||
>;
|
||||
export type GetPublicDashboardDataV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardDataV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardDataV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardDataV2QueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get public dashboard data (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardDataV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetPublicDashboardDataV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardDataV2QueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint returns query range results for a panel of a v2-shape public dashboard. The panel is addressed by its key in spec.panels.
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const getPublicDashboardPanelQueryRangeV2 = (
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetPublicDashboardPanelQueryRangeV2200>({
|
||||
url: `/api/v2/public/dashboards/${id}/panels/${key}/query_range`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryKey = ({
|
||||
id,
|
||||
key,
|
||||
}: GetPublicDashboardPanelQueryRangeV2PathParameters) => {
|
||||
return [`/api/v2/public/dashboards/${id}/panels/${key}/query_range`] as const;
|
||||
};
|
||||
|
||||
export const getGetPublicDashboardPanelQueryRangeV2QueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ??
|
||||
getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
> = ({ signal }) => getPublicDashboardPanelQueryRangeV2({ id, key }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!(id && key),
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>
|
||||
>;
|
||||
export type GetPublicDashboardPanelQueryRangeV2QueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
|
||||
export function useGetPublicDashboardPanelQueryRangeV2<
|
||||
TData = Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPublicDashboardPanelQueryRangeV2>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPublicDashboardPanelQueryRangeV2QueryOptions(
|
||||
{ id, key },
|
||||
options,
|
||||
);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get query range result (v2)
|
||||
*/
|
||||
export const invalidateGetPublicDashboardPanelQueryRangeV2 = async (
|
||||
queryClient: QueryClient,
|
||||
{ id, key }: GetPublicDashboardPanelQueryRangeV2PathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetPublicDashboardPanelQueryRangeV2QueryKey({ id, key }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Same as ListDashboardsV2 but personalized for the calling user: each dashboard carries the caller's `pinned` state, and pinned dashboards float to the top of the requested ordering. Supports the same filter DSL, sort, order, and pagination.
|
||||
* @summary List dashboards for the current user (v2)
|
||||
|
||||
@@ -23,7 +23,6 @@ import type {
|
||||
GetLLMPricingRulePathParameters,
|
||||
ListLLMPricingRules200,
|
||||
ListLLMPricingRulesParams,
|
||||
ListUnmappedLLMModels200,
|
||||
LlmpricingruletypesUpdatableLLMPricingRulesDTO,
|
||||
RenderErrorResponseDTO,
|
||||
} from '../sigNoz.schemas';
|
||||
@@ -394,87 +393,3 @@ export const invalidateGetLLMPricingRule = async (
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns models seen in the last hour of trace data (gen_ai.request.model) that no pricing rule pattern matches, so the user can add them to an existing rule or create a new one.
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const listUnmappedLLMModels = (signal?: AbortSignal) => {
|
||||
return GeneratedAPIInstance<ListUnmappedLLMModels200>({
|
||||
url: `/api/v1/llm_pricing_rules/unmapped_models`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryKey = () => {
|
||||
return [`/api/v1/llm_pricing_rules/unmapped_models`] as const;
|
||||
};
|
||||
|
||||
export const getListUnmappedLLMModelsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getListUnmappedLLMModelsQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
> = ({ signal }) => listUnmappedLLMModels(signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModelsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>
|
||||
>;
|
||||
export type ListUnmappedLLMModelsQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
|
||||
export function useListUnmappedLLMModels<
|
||||
TData = Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listUnmappedLLMModels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListUnmappedLLMModelsQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List unmapped models
|
||||
*/
|
||||
export const invalidateListUnmappedLLMModels = async (
|
||||
queryClient: QueryClient,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListUnmappedLLMModelsQueryKey() },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
@@ -22,16 +22,12 @@ import type {
|
||||
CreateServiceAccountKey201,
|
||||
CreateServiceAccountKeyPathParameters,
|
||||
CreateServiceAccountRole201,
|
||||
CreateServiceAccountRoleDeprecated201,
|
||||
CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
CreateServiceAccountRolePathParameters,
|
||||
DeleteServiceAccountPathParameters,
|
||||
DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
DeleteServiceAccountRolePathParameters,
|
||||
GetMyServiceAccount200,
|
||||
GetServiceAccount200,
|
||||
GetServiceAccountPathParameters,
|
||||
GetServiceAccountRole200,
|
||||
GetServiceAccountRolePathParameters,
|
||||
GetServiceAccountRoles200,
|
||||
GetServiceAccountRolesPathParameters,
|
||||
ListServiceAccountKeys200,
|
||||
@@ -39,7 +35,6 @@ import type {
|
||||
ListServiceAccounts200,
|
||||
RenderErrorResponseDTO,
|
||||
RevokeServiceAccountKeyPathParameters,
|
||||
ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
ServiceaccounttypesPostableFactorAPIKeyDTO,
|
||||
ServiceaccounttypesPostableServiceAccountDTO,
|
||||
ServiceaccounttypesPostableServiceAccountRoleDTO,
|
||||
@@ -51,272 +46,6 @@ import type {
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
import type { ErrorType, BodyType } from '../../../generatedAPIInstance';
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRole = (
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_account_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createServiceAccountRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a service account
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing service account role
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const getServiceAccountRole = (
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetServiceAccountRole200>({
|
||||
url: `/api/v1/service_account_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryKey = ({
|
||||
id,
|
||||
}: GetServiceAccountRolePathParameters) => {
|
||||
return [`/api/v1/service_account_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetServiceAccountRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetServiceAccountRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
> = ({ signal }) => getServiceAccountRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetServiceAccountRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>
|
||||
>;
|
||||
export type GetServiceAccountRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
|
||||
export function useGetServiceAccountRole<
|
||||
TData = Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getServiceAccountRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetServiceAccountRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get service account role
|
||||
*/
|
||||
export const invalidateGetServiceAccountRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetServiceAccountRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetServiceAccountRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists the service accounts for an organisation
|
||||
* @summary List service accounts
|
||||
@@ -1255,46 +984,45 @@ export const invalidateGetServiceAccountRoles = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a service account
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const createServiceAccountRoleDeprecated = (
|
||||
{ id }: CreateServiceAccountRoleDeprecatedPathParameters,
|
||||
serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>,
|
||||
export const createServiceAccountRole = (
|
||||
{ id }: CreateServiceAccountRolePathParameters,
|
||||
serviceaccounttypesPostableServiceAccountRoleDTO?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateServiceAccountRoleDeprecated201>({
|
||||
return GeneratedAPIInstance<CreateServiceAccountRole201>({
|
||||
url: `/api/v1/service_accounts/${id}/roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: serviceaccounttypesDeprecatedPostableServiceAccountRoleDTO,
|
||||
data: serviceaccounttypesPostableServiceAccountRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
export const getCreateServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createServiceAccountRoleDeprecated'];
|
||||
const mutationKey = ['createServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1304,66 +1032,62 @@ export const getCreateServiceAccountRoleDeprecatedMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return createServiceAccountRoleDeprecated(pathParams, data);
|
||||
return createServiceAccountRole(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>
|
||||
export type CreateServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>
|
||||
>;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationBody =
|
||||
| BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>
|
||||
export type CreateServiceAccountRoleMutationBody =
|
||||
| BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>
|
||||
| undefined;
|
||||
export type CreateServiceAccountRoleDeprecatedMutationError =
|
||||
export type CreateServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Create service account role
|
||||
*/
|
||||
export const useCreateServiceAccountRoleDeprecated = <
|
||||
export const useCreateServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof createServiceAccountRole>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: CreateServiceAccountRoleDeprecatedPathParameters;
|
||||
data?: BodyType<ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO>;
|
||||
pathParams: CreateServiceAccountRolePathParameters;
|
||||
data?: BodyType<ServiceaccounttypesPostableServiceAccountRoleDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getCreateServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
return useMutation(getCreateServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from service account
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const deleteServiceAccountRoleDeprecated = (
|
||||
{ id, rid }: DeleteServiceAccountRoleDeprecatedPathParameters,
|
||||
export const deleteServiceAccountRole = (
|
||||
{ id, rid }: DeleteServiceAccountRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
@@ -1373,23 +1097,23 @@ export const deleteServiceAccountRoleDeprecated = (
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
export const getDeleteServiceAccountRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteServiceAccountRoleDeprecated'];
|
||||
const mutationKey = ['deleteServiceAccountRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
@@ -1399,47 +1123,44 @@ export const getDeleteServiceAccountRoleDeprecatedMutationOptions = <
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters }
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteServiceAccountRoleDeprecated(pathParams);
|
||||
return deleteServiceAccountRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>
|
||||
export type DeleteServiceAccountRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>
|
||||
>;
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedMutationError =
|
||||
export type DeleteServiceAccountRoleMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Delete service account role
|
||||
*/
|
||||
export const useDeleteServiceAccountRoleDeprecated = <
|
||||
export const useDeleteServiceAccountRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRoleDeprecated>>,
|
||||
Awaited<ReturnType<typeof deleteServiceAccountRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteServiceAccountRoleDeprecatedPathParameters },
|
||||
{ pathParams: DeleteServiceAccountRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(
|
||||
getDeleteServiceAccountRoleDeprecatedMutationOptions(options),
|
||||
);
|
||||
return useMutation(getDeleteServiceAccountRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets my service account
|
||||
|
||||
@@ -2048,13 +2048,6 @@ export interface AuthtypesAuthNSupportDTO {
|
||||
password?: AuthtypesPasswordAuthNSupportDTO[] | null;
|
||||
}
|
||||
|
||||
export interface AuthtypesDeprecatedPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesGettableAuthDomainDTO {
|
||||
authNProviderInfo?: AuthtypesAuthNProviderInfoDTO;
|
||||
config?: AuthtypesAuthDomainConfigDTO;
|
||||
@@ -2294,6 +2287,13 @@ export interface AuthtypesPostableRotateTokenDTO {
|
||||
refreshToken?: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -2310,18 +2310,7 @@ export interface AuthtypesPostableUserDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
userRoles?: AuthtypesDeprecatedPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesPostableUserRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
userId: string;
|
||||
userRoles?: AuthtypesPostableUserRoleDTO[];
|
||||
}
|
||||
|
||||
export interface AuthtypesRoleDTO {
|
||||
@@ -4952,11 +4941,6 @@ export interface DashboardtypesGettablePublicDashboardDataDTO {
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesGettablePublicDashboardDataV2DTO {
|
||||
dashboard?: DashboardtypesGettableDashboardV2DTO;
|
||||
publicDashboard?: DashboardtypesGettablePublicDasbhboardDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPatchOpDTO {
|
||||
add = 'add',
|
||||
remove = 'remove',
|
||||
@@ -6834,29 +6818,6 @@ export interface LlmpricingruletypesGettablePricingRulesDTO {
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUnmappedModelDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
modelName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
provider?: string;
|
||||
/**
|
||||
* @type integer
|
||||
* @minimum 0
|
||||
*/
|
||||
spanCount: number;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesGettableUnmappedModelsDTO {
|
||||
/**
|
||||
* @type array,null
|
||||
*/
|
||||
items: LlmpricingruletypesUnmappedModelDTO[] | null;
|
||||
}
|
||||
|
||||
export interface LlmpricingruletypesUpdatableLLMPricingRuleDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
@@ -8519,13 +8480,6 @@ export interface RuletypesRuleDTO {
|
||||
export enum RuletypesThresholdKindDTO {
|
||||
basic = 'basic',
|
||||
}
|
||||
export interface ServiceaccounttypesDeprecatedPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesGettableFactorAPIKeyDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -8595,11 +8549,7 @@ export interface ServiceaccounttypesPostableServiceAccountRoleDTO {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
roleId: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
serviceAccountId: string;
|
||||
id: string;
|
||||
}
|
||||
|
||||
export interface ServiceaccounttypesServiceAccountDTO {
|
||||
@@ -10218,14 +10168,6 @@ export type GetLLMPricingRule200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUnmappedLLMModels200 = {
|
||||
data: LlmpricingruletypesGettableUnmappedModelsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListPromotedAndIndexedPaths200 = {
|
||||
/**
|
||||
* @type array,null
|
||||
@@ -10365,28 +10307,6 @@ export type UpdateRoutePolicy200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetServiceAccountRole200 = {
|
||||
data: ServiceaccounttypesServiceAccountRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListServiceAccounts200 = {
|
||||
/**
|
||||
* @type array
|
||||
@@ -10470,10 +10390,10 @@ export type GetServiceAccountRoles200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateServiceAccountRoleDeprecatedPathParameters = {
|
||||
export type CreateServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type CreateServiceAccountRoleDeprecated201 = {
|
||||
export type CreateServiceAccountRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
@@ -10481,7 +10401,7 @@ export type CreateServiceAccountRoleDeprecated201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteServiceAccountRoleDeprecatedPathParameters = {
|
||||
export type DeleteServiceAccountRolePathParameters = {
|
||||
id: string;
|
||||
rid: string;
|
||||
};
|
||||
@@ -11241,29 +11161,6 @@ export type GetMyOrganization200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardDataV2PathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetPublicDashboardDataV2200 = {
|
||||
data: DashboardtypesGettablePublicDashboardDataV2DTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetPublicDashboardPanelQueryRangeV2PathParameters = {
|
||||
id: string;
|
||||
key: string;
|
||||
};
|
||||
export type GetPublicDashboardPanelQueryRangeV2200 = {
|
||||
data: Querybuildertypesv5QueryRangeResponseDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type Readyz200 = {
|
||||
data: FactoryResponseDTO;
|
||||
/**
|
||||
@@ -11641,28 +11538,6 @@ export type RotateSession200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateUserRole201 = {
|
||||
data: TypesIdentifiableDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRolePathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetUserRole200 = {
|
||||
data: AuthtypesUserRoleDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListUsers200 = {
|
||||
/**
|
||||
* @type array
|
||||
|
||||
@@ -19,15 +19,12 @@ import type {
|
||||
|
||||
import type {
|
||||
AuthtypesPostableUserDTO,
|
||||
AuthtypesPostableUserRoleDTO,
|
||||
CreateInvite201,
|
||||
CreateResetPasswordToken201,
|
||||
CreateResetPasswordTokenPathParameters,
|
||||
CreateUser201,
|
||||
CreateUserRole201,
|
||||
DeleteUserDeprecatedPathParameters,
|
||||
DeleteUserPathParameters,
|
||||
DeleteUserRolePathParameters,
|
||||
GetMyUser200,
|
||||
GetMyUserDeprecated200,
|
||||
GetResetPasswordToken200,
|
||||
@@ -40,8 +37,6 @@ import type {
|
||||
GetUserDeprecated200,
|
||||
GetUserDeprecatedPathParameters,
|
||||
GetUserPathParameters,
|
||||
GetUserRole200,
|
||||
GetUserRolePathParameters,
|
||||
GetUsersByRoleID200,
|
||||
GetUsersByRoleIDPathParameters,
|
||||
ListUsers200,
|
||||
@@ -1159,267 +1154,6 @@ export const invalidateGetUsersByRoleID = async (
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint assigns a role to a user
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const createUserRole = (
|
||||
authtypesPostableUserRoleDTO?: BodyType<AuthtypesPostableUserRoleDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<CreateUserRole201>({
|
||||
url: `/api/v2/user_roles`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: authtypesPostableUserRoleDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getCreateUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['createUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return createUserRole(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type CreateUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof createUserRole>>
|
||||
>;
|
||||
export type CreateUserRoleMutationBody =
|
||||
| BodyType<AuthtypesPostableUserRoleDTO>
|
||||
| undefined;
|
||||
export type CreateUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Create user role
|
||||
*/
|
||||
export const useCreateUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof createUserRole>>,
|
||||
TError,
|
||||
{ data?: BodyType<AuthtypesPostableUserRoleDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getCreateUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint revokes a role from a user
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const deleteUserRole = (
|
||||
{ id }: DeleteUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteUserRoleMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteUserRole'];
|
||||
const { mutation: mutationOptions } = options
|
||||
? options.mutation &&
|
||||
'mutationKey' in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey } };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
{ pathParams: DeleteUserRolePathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteUserRole(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteUserRoleMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>
|
||||
>;
|
||||
|
||||
export type DeleteUserRoleMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete user role
|
||||
*/
|
||||
export const useDeleteUserRole = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteUserRole>>,
|
||||
TError,
|
||||
{ pathParams: DeleteUserRolePathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteUserRoleMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint gets an existing user role
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const getUserRole = (
|
||||
{ id }: GetUserRolePathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetUserRole200>({
|
||||
url: `/api/v2/user_roles/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryKey = ({ id }: GetUserRolePathParameters) => {
|
||||
return [`/api/v2/user_roles/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetUserRoleQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetUserRoleQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<Awaited<ReturnType<typeof getUserRole>>> = ({
|
||||
signal,
|
||||
}) => getUserRole({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetUserRoleQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getUserRole>>
|
||||
>;
|
||||
export type GetUserRoleQueryError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
|
||||
export function useGetUserRole<
|
||||
TData = Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getUserRole>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetUserRoleQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get user role
|
||||
*/
|
||||
export const invalidateGetUserRole = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetUserRolePathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetUserRoleQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint lists all users for the organization
|
||||
* @summary List users v2
|
||||
@@ -2131,7 +1865,6 @@ export const invalidateGetRolesByUserID = async (
|
||||
|
||||
/**
|
||||
* This endpoint assigns the role to the user roles by user id
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const setRoleByUserID = (
|
||||
@@ -2203,7 +1936,6 @@ export type SetRoleByUserIDMutationBody =
|
||||
export type SetRoleByUserIDMutationError = ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Set user roles
|
||||
*/
|
||||
export const useSetRoleByUserID = <
|
||||
@@ -2232,7 +1964,6 @@ export const useSetRoleByUserID = <
|
||||
};
|
||||
/**
|
||||
* This endpoint removes a role from the user by user id and role id
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const removeUserRoleByUserIDAndRoleID = (
|
||||
@@ -2291,7 +2022,6 @@ export type RemoveUserRoleByUserIDAndRoleIDMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @deprecated
|
||||
* @summary Remove a role from user
|
||||
*/
|
||||
export const useRemoveUserRoleByUserIDAndRoleID = <
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { QueryParams } from 'constants/query';
|
||||
|
||||
export const ExploreHeaderToolTip = {
|
||||
url: 'https://signoz.io/docs/querying/overview/?utm_source=product&utm_medium=new-query-builder',
|
||||
url: 'https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=new-query-builder',
|
||||
text: 'More details on how to use query builder',
|
||||
};
|
||||
|
||||
|
||||
@@ -131,7 +131,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
@@ -254,7 +254,7 @@ const MetricsAggregateSection = memo(function MetricsAggregateSection({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -52,7 +52,7 @@ const ADD_ONS = [
|
||||
key: ADD_ONS_KEYS.GROUP_BY,
|
||||
description:
|
||||
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
|
||||
docLink: 'https://signoz.io/docs/querying/aggregation-grouping/#grouping',
|
||||
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -61,7 +61,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -70,7 +70,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#sorting--limiting',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -79,7 +79,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/result-manipulation/#how-limit-works-for-time-series',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting',
|
||||
},
|
||||
{
|
||||
icon: <ScrollText size={14} />,
|
||||
@@ -88,7 +88,7 @@ const ADD_ONS = [
|
||||
description:
|
||||
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/querying/aggregation-grouping/#legend-formatting',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#legend-formatting',
|
||||
},
|
||||
];
|
||||
|
||||
@@ -99,7 +99,7 @@ const REDUCE_TO = {
|
||||
description:
|
||||
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
|
||||
docLink:
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation',
|
||||
'https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations',
|
||||
};
|
||||
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
@@ -349,7 +349,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Group By"
|
||||
description="Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments."
|
||||
docLink="https://signoz.io/docs/querying/aggregation-grouping/#grouping"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#grouping"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -385,7 +385,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Having"
|
||||
description="Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500"
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#conditional-filtering-with-having"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#conditional-filtering-with-having"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -434,7 +434,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Order By"
|
||||
description="Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers."
|
||||
docLink="https://signoz.io/docs/querying/result-manipulation/#sorting--limiting"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#sorting--limiting"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
@@ -473,7 +473,7 @@ function QueryAddOns({
|
||||
<TooltipContent
|
||||
label="Reduce to"
|
||||
description="Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value."
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#result-manipulation"
|
||||
docLink="https://signoz.io/docs/userguide/query-builder-v5/#reduce-operations"
|
||||
/>
|
||||
}
|
||||
placement="top"
|
||||
|
||||
@@ -65,7 +65,7 @@ function QueryAggregationOptions({
|
||||
Set the time interval for aggregation
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#temporal-aggregation-within-each-time-series"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#time-aggregation-windows"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -676,7 +676,7 @@ function QueryAggregationSelect({
|
||||
</span>
|
||||
<br />
|
||||
<a
|
||||
href="https://signoz.io/docs/querying/aggregation-grouping/#core-aggregation-functions-logs--traces"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#core-aggregation-functions"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
style={{ color: '#1890ff', textDecoration: 'underline' }}
|
||||
|
||||
@@ -44,7 +44,7 @@ function TraceOperatorSection({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add Trace Matching
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#trace-matching"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
@@ -106,7 +106,7 @@ export default function QueryFooter({
|
||||
<div style={{ textAlign: 'center' }}>
|
||||
Add New Formula
|
||||
<Typography.Link
|
||||
href="https://signoz.io/docs/querying/multi-query-analysis/#advanced-comparisons"
|
||||
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-advanced-comparisons"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
>
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { TabsProps } from 'antd';
|
||||
import { History } from 'history';
|
||||
|
||||
export type TabRoutes = {
|
||||
name: React.ReactNode;
|
||||
route: string;
|
||||
Component: ComponentType;
|
||||
Component: () => JSX.Element;
|
||||
key: string;
|
||||
};
|
||||
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
import { type MouseEvent, type ReactNode } from 'react';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
|
||||
interface TagBadgeProps {
|
||||
children: ReactNode;
|
||||
// Show a remove button (editable contexts: create modal, settings drawer).
|
||||
closable?: boolean;
|
||||
onClose?: (event: MouseEvent<HTMLButtonElement>) => void;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// The single sienna tag chip used everywhere dashboards render tags — list rows,
|
||||
// the details header, and the tag editors. Kept as one component so the tag
|
||||
// styling stays identical across all of them.
|
||||
function TagBadge({
|
||||
children,
|
||||
closable,
|
||||
onClose,
|
||||
className,
|
||||
}: TagBadgeProps): JSX.Element {
|
||||
return (
|
||||
<Badge
|
||||
color="sienna"
|
||||
variant="outline"
|
||||
className={className}
|
||||
closable={closable}
|
||||
onClose={onClose}
|
||||
>
|
||||
{children}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default TagBadge;
|
||||
@@ -15,28 +15,59 @@
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
// Sienna chip via the @signozhq Badge; this only constrains its width.
|
||||
// Sienna chip — matches the dashboard list-row tag badge.
|
||||
.tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 240px;
|
||||
height: 24px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
border-radius: 50px;
|
||||
border: 1px solid color-mix(in srgb, var(--bg-sienna-500) 20%, transparent);
|
||||
background: color-mix(in srgb, var(--bg-sienna-500) 10%, transparent);
|
||||
color: var(--bg-sienna-400);
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
line-height: 20px;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
// The tag label is a bare, chrome-less button inside the Badge (double-click to
|
||||
// edit); strip its Button styling and let it ellipsize.
|
||||
.tagLabel {
|
||||
--button-height: auto;
|
||||
--button-padding: 0;
|
||||
--button-gap: 0;
|
||||
--button-variant-ghost-background-color: transparent;
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
--button-variant-ghost-color: currentColor;
|
||||
--button-variant-ghost-hover-color: currentColor;
|
||||
--button-variant-ghost-color: inherit;
|
||||
--button-variant-ghost-hover-color: inherit;
|
||||
overflow: hidden;
|
||||
max-width: 200px;
|
||||
font-size: 13px;
|
||||
font-weight: var(--font-weight-normal);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.remove {
|
||||
// Size overrides to fit the chip, plus a sienna-tinted hover — the Button's
|
||||
// default ghost hover is a grey that clashes with the chip. Resting color is
|
||||
// left at the Button default.
|
||||
--button-height: 16px;
|
||||
--button-padding: 0;
|
||||
--button-border-radius: 50%;
|
||||
--button-variant-ghost-hover-background-color: color-mix(
|
||||
in srgb,
|
||||
var(--bg-sienna-500) 22%,
|
||||
transparent
|
||||
);
|
||||
--button-variant-ghost-hover-color: var(--bg-sienna-400);
|
||||
width: 16px;
|
||||
min-width: 16px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
.input {
|
||||
flex: 1;
|
||||
min-width: 120px;
|
||||
|
||||
@@ -2,9 +2,9 @@ import { type ChangeEvent, type KeyboardEvent, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { X } from '@signozhq/icons';
|
||||
import cx from 'classnames';
|
||||
|
||||
import TagBadge from '../TagBadge/TagBadge';
|
||||
import { parseKeyValueTag } from './utils';
|
||||
|
||||
import styles from './TagKeyValueInput.module.scss';
|
||||
@@ -120,23 +120,27 @@ function TagKeyValueInput({
|
||||
onBlur={commitEdit}
|
||||
/>
|
||||
) : (
|
||||
<TagBadge
|
||||
key={tag}
|
||||
className={styles.tag}
|
||||
closable
|
||||
onClose={(): void => removeTag(tag)}
|
||||
>
|
||||
<div key={tag} className={styles.tag} data-testid={`${testId}-chip`}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.tagLabel}
|
||||
title="Double-click to edit"
|
||||
testId={`${testId}-chip`}
|
||||
onDoubleClick={(): void => startEdit(index)}
|
||||
>
|
||||
{tag}
|
||||
</Button>
|
||||
</TagBadge>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${tag}`}
|
||||
onClick={(): void => removeTag(tag)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
<Input
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
export const apDexToolTipText =
|
||||
"Apdex is a way to measure your users' satisfaction with the response time of your web service. It's represented as a score from 0-1.";
|
||||
export const apDexToolTipUrl =
|
||||
'https://signoz.io/docs/alerts-management/apdex-alerts/?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
'https://signoz.io/docs/userguide/metrics/#apdex?utm_source=product&utm_medium=frontend&utm_campaign=apdex';
|
||||
export const apDexToolTipUrlText = 'Learn more about Apdex.';
|
||||
|
||||
@@ -68,7 +68,7 @@ function AlertChannels(): JSX.Element {
|
||||
<RightActionContainer>
|
||||
<TextToolTip
|
||||
text={t('tooltip_notification_channels')}
|
||||
url="https://signoz.io/docs/setup-alerts-notification/"
|
||||
url="https://signoz.io/docs/userguide/alerts-management/#setting-notification-channel"
|
||||
/>
|
||||
|
||||
<Tooltip
|
||||
|
||||
@@ -29,7 +29,8 @@ function SelectAlertType({ onSelect }: SelectAlertTypeProps): JSX.Element {
|
||||
let url = '';
|
||||
switch (option) {
|
||||
case AlertTypes.ANOMALY_BASED_ALERT:
|
||||
url = 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/';
|
||||
url =
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples';
|
||||
break;
|
||||
case AlertTypes.METRICS_BASED_ALERT:
|
||||
url =
|
||||
|
||||
@@ -31,7 +31,8 @@ export const ALERT_TYPE_URL_MAP: Record<
|
||||
'https://signoz.io/docs/alerts-management/exceptions-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
|
||||
},
|
||||
[AlertTypes.ANOMALY_BASED_ALERT]: {
|
||||
selection: 'https://signoz.io/docs/alerts-management/anomaly-based-alerts/',
|
||||
selection:
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-source-selection-page#examples',
|
||||
creation:
|
||||
'https://signoz.io/docs/alerts-management/anomaly-based-alerts/?utm_source=product&utm_medium=alert-creation-page',
|
||||
},
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import type { PrecisionOption } from 'components/Graph/types';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
@@ -28,7 +27,7 @@ interface PieArcProps {
|
||||
fill: string;
|
||||
onEnter: (slice: PieSlice, centroidX: number, centroidY: number) => void;
|
||||
onLeave: () => void;
|
||||
onClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
onClick?: (slice: PieSlice) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -73,7 +72,7 @@ export default function PieArc({
|
||||
<g
|
||||
onMouseEnter={(): void => onEnter(slice, centroidX, centroidY)}
|
||||
onMouseLeave={onLeave}
|
||||
onClick={(event): void => onClick?.(slice, event)}
|
||||
onClick={(): void => onClick?.(slice)}
|
||||
>
|
||||
<path d={arcPath} fill={fill} />
|
||||
{shouldShowLabel && (
|
||||
|
||||
@@ -80,7 +80,6 @@ describe('PieArc', () => {
|
||||
expect(onLeave).toHaveBeenCalledTimes(1);
|
||||
|
||||
fireEvent.click(g);
|
||||
// onClick now also receives the DOM event (for drill-down popover positioning).
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE, expect.anything());
|
||||
expect(onClick).toHaveBeenCalledWith(SLICE);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { MouseEvent as ReactMouseEvent } from 'react';
|
||||
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { PrecisionOption } from 'components/Graph/types';
|
||||
import {
|
||||
@@ -80,10 +79,6 @@ export interface PieSlice {
|
||||
label: string;
|
||||
value: number;
|
||||
color: string;
|
||||
/** Source query of the slice's value column — the drill-down target (present for V2 panels). */
|
||||
queryName?: string;
|
||||
/** Group-by key→value of the slice's source row, used to build drill-down filters. */
|
||||
labels?: Record<string, string>;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -104,7 +99,7 @@ export interface PieChartProps {
|
||||
* (shared GRAPH_VISIBILITY_STATES, keyed by label). Omit to disable persistence.
|
||||
*/
|
||||
id?: string;
|
||||
/** Fired when a slice's arc is clicked; carries the DOM event for popover positioning. */
|
||||
onSliceClick?: (slice: PieSlice, event: ReactMouseEvent) => void;
|
||||
/** Fired when a slice (or its legend entry) is clicked. */
|
||||
onSliceClick?: (slice: PieSlice) => void;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
|
||||
@@ -10,7 +11,6 @@ import {
|
||||
selectIsDashboardLocked,
|
||||
useDashboardStore,
|
||||
} from 'providers/Dashboard/store/useDashboardStore';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
|
||||
import { getChartManagerColumns } from './getChartMangerColumns';
|
||||
import { ExtendedChartDataset, getDefaultTableDataSet } from './utils';
|
||||
@@ -44,6 +44,7 @@ export default function ChartManager({
|
||||
decimalPrecision = PrecisionOptionsEnum.TWO,
|
||||
onCancel,
|
||||
}: ChartManagerProps): JSX.Element {
|
||||
const { notifications } = useNotifications();
|
||||
const { legendItemsMap } = useLegendsSync({
|
||||
config,
|
||||
subscribeToFocusChange: false,
|
||||
@@ -135,9 +136,11 @@ export default function ChartManager({
|
||||
|
||||
const handleSave = useCallback((): void => {
|
||||
syncSeriesVisibilityToLocalStorage();
|
||||
toast.success('The updated graphs & legends are saved');
|
||||
notifications.success({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
onCancel?.();
|
||||
}, [syncSeriesVisibilityToLocalStorage, onCancel]);
|
||||
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
|
||||
|
||||
return (
|
||||
<div className="chart-manager-container">
|
||||
|
||||
@@ -5,7 +5,7 @@ import { render, screen } from 'tests/test-utils';
|
||||
import ChartManager from '../ChartManager';
|
||||
|
||||
const mockSyncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
const mockToastSuccess = jest.fn();
|
||||
const mockNotificationsSuccess = jest.fn();
|
||||
|
||||
jest.mock('lib/uPlotV2/context/PlotContext', () => ({
|
||||
usePlotContext: (): {
|
||||
@@ -46,11 +46,12 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
|
||||
}): boolean => s.dashboardData?.locked ?? false,
|
||||
}));
|
||||
|
||||
jest.mock('@signozhq/ui/sonner', () => ({
|
||||
...jest.requireActual('@signozhq/ui/sonner'),
|
||||
toast: {
|
||||
success: (...args: unknown[]): unknown => mockToastSuccess(...args),
|
||||
},
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: { success: jest.Mock } } => ({
|
||||
notifications: {
|
||||
success: mockNotificationsSuccess,
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('components/ResizeTable', () => {
|
||||
@@ -159,7 +160,7 @@ describe('ChartManager', () => {
|
||||
expect(screen.queryByTestId('row-2')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('calls syncSeriesVisibilityToLocalStorage, toast.success, and onCancel when Save is clicked', async () => {
|
||||
it('calls syncSeriesVisibilityToLocalStorage, notifications.success, and onCancel when Save is clicked', async () => {
|
||||
render(
|
||||
<ChartManager
|
||||
config={createMockConfig() as UPlotConfigBuilder}
|
||||
@@ -171,9 +172,9 @@ describe('ChartManager', () => {
|
||||
await userEvent.click(screen.getByRole('button', { name: /Save/ }));
|
||||
|
||||
expect(mockSyncSeriesVisibilityToLocalStorage).toHaveBeenCalledTimes(1);
|
||||
expect(mockToastSuccess).toHaveBeenCalledWith(
|
||||
'The updated graphs & legends are saved',
|
||||
);
|
||||
expect(mockNotificationsSuccess).toHaveBeenCalledWith({
|
||||
message: 'The updated graphs & legends are saved',
|
||||
});
|
||||
expect(mockOnCancel).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,17 +2,9 @@
|
||||
position: relative;
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
|
||||
// Stacked children (the FullView / standalone graph-manager) sit below the chart
|
||||
// in the same container; size the chart region to its content so they aren't
|
||||
// pushed out. Only this case opts out of filling the height — the dashboard grid,
|
||||
// alert preview, and other charts keep 100% so they fill their container.
|
||||
&--with-layout-children {
|
||||
height: auto;
|
||||
}
|
||||
|
||||
&--legend-right {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
@@ -63,7 +63,6 @@ export default function ChartLayout({
|
||||
className={cx('chart-layout', {
|
||||
'chart-layout--legend-right':
|
||||
legendConfig.position === LegendPosition.RIGHT,
|
||||
'chart-layout--with-layout-children': !!layoutChildren,
|
||||
})}
|
||||
>
|
||||
<div className="chart-layout__content">
|
||||
|
||||
@@ -717,13 +717,13 @@ function ExplorerOptions({
|
||||
|
||||
const infoIconLink = useMemo(() => {
|
||||
if (isLogsExplorer) {
|
||||
return 'https://signoz.io/docs/userguide/logs_query_builder/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/product-features/logs-explorer/?utm_source=product&utm_medium=logs-explorer-toolbar';
|
||||
}
|
||||
// TODO: Add metrics explorer info icon link
|
||||
if (isMetricsExplorer) {
|
||||
return '';
|
||||
}
|
||||
return 'https://signoz.io/docs/userguide/traces/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
return 'https://signoz.io/docs/product-features/trace-explorer/?utm_source=product&utm_medium=trace-explorer-toolbar';
|
||||
}, [isLogsExplorer, isMetricsExplorer]);
|
||||
|
||||
const getQueryName = (query: Query): string => {
|
||||
|
||||
@@ -201,7 +201,7 @@ export default function SavedViews({
|
||||
});
|
||||
|
||||
window.open(
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
'https://signoz.io/docs/product-features/saved-view/',
|
||||
'_blank',
|
||||
'noopener noreferrer',
|
||||
);
|
||||
|
||||
@@ -29,12 +29,12 @@ export const checkListStepToPreferenceKeyMap = {
|
||||
|
||||
export const DOCS_LINKS = {
|
||||
ADD_DATA_SOURCE: 'https://signoz.io/docs/instrumentation/overview/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs_query_builder/',
|
||||
SEND_LOGS: 'https://signoz.io/docs/userguide/logs/',
|
||||
SEND_TRACES: 'https://signoz.io/docs/userguide/traces/',
|
||||
SEND_METRICS: 'https://signoz.io/docs/metrics-management/metrics-explorer/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/alerts/',
|
||||
SETUP_ALERTS: 'https://signoz.io/docs/userguide/alerts-management/',
|
||||
SETUP_SAVED_VIEWS:
|
||||
'https://signoz.io/docs/metrics-management/metrics-explorer/#saved-views-in-metrics-explorer',
|
||||
'https://signoz.io/docs/product-features/saved-view/#step-2-save-your-view',
|
||||
SETUP_DASHBOARDS: 'https://signoz.io/docs/userguide/manage-dashboards/',
|
||||
};
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ export function K8sEmptyState({
|
||||
<span className={styles.message}>
|
||||
Please refer to{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/infrastructure-monitoring/hostmetrics/"
|
||||
href="https://signoz.io/docs/userguide/hostmetrics/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -611,7 +611,7 @@ describe('K8sBaseList', () => {
|
||||
expect(link).toBeInTheDocument();
|
||||
expect(link).toHaveAttribute(
|
||||
'href',
|
||||
'https://signoz.io/docs/infrastructure-monitoring/hostmetrics/',
|
||||
'https://signoz.io/docs/userguide/hostmetrics/',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -66,6 +66,20 @@
|
||||
background: colox-mix(var(--accent-primary), var(--bg-vanilla-100), 20%);
|
||||
}
|
||||
|
||||
:global(.ant-collapse-header) {
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
padding: 12px 8px;
|
||||
}
|
||||
|
||||
:global(.ant-collapse-content-box) {
|
||||
padding: 0 !important;
|
||||
padding-block: 0 !important;
|
||||
|
||||
:global(.quick-filters .checkbox-filter) {
|
||||
padding-left: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.quick-filters) {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
@@ -97,6 +111,22 @@
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelIcon {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.quickFiltersCategoryLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.listContainer {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
@@ -130,106 +160,3 @@
|
||||
.listContainerFiltersVisible {
|
||||
max-width: calc(100% - 280px);
|
||||
}
|
||||
|
||||
.categorySelectorSection {
|
||||
padding: var(--spacing-4);
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-4);
|
||||
margin-bottom: var(--spacing-4);
|
||||
|
||||
&[data-type='filter'] {
|
||||
padding: 0 var(--spacing-4);
|
||||
}
|
||||
}
|
||||
|
||||
.sectionLabel {
|
||||
font-size: var(--periscope-font-size-small);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: var(--l3-foreground);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.sectionLine {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.categoryCard {
|
||||
background: var(--l2-background);
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: var(--spacing-3);
|
||||
padding: var(--spacing-2);
|
||||
}
|
||||
|
||||
.categoryList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-1);
|
||||
}
|
||||
|
||||
.categoryItem {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-5);
|
||||
padding: var(--spacing-3) var(--spacing-4);
|
||||
border: none;
|
||||
border-radius: var(--spacing-2);
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
transition:
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease;
|
||||
color: var(--l2-foreground);
|
||||
font-size: var(--periscope-font-size-base);
|
||||
--typography-color: var(--l1-foreground);
|
||||
|
||||
&:hover:not(.categoryItemSelected) {
|
||||
background: var(--l2-background-hover);
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l3-foreground);
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
&.categoryItemSelected {
|
||||
background: var(--accent-primary);
|
||||
color: var(--l1-background);
|
||||
font-weight: 600;
|
||||
|
||||
&:hover {
|
||||
background: var(--accent-primary-hover, var(--accent-primary));
|
||||
}
|
||||
|
||||
svg {
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.quickFiltersSection {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.1rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--accent-primary);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { Button, Tooltip } from 'antd';
|
||||
import { Button, CollapseProps } from 'antd';
|
||||
import { Collapse, Tooltip } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import QuickFilters from 'components/QuickFilters/QuickFilters';
|
||||
@@ -112,74 +113,192 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
});
|
||||
};
|
||||
|
||||
const categories = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: K8sCategories.PODS,
|
||||
label: 'Pods',
|
||||
icon: <Container size={14} />,
|
||||
config: GetPodsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NODES,
|
||||
label: 'Nodes',
|
||||
icon: <Workflow size={14} />,
|
||||
config: GetNodesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.NAMESPACES,
|
||||
label: 'Namespaces',
|
||||
icon: <FilePenLine size={14} />,
|
||||
config: GetNamespaceQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.CLUSTERS,
|
||||
label: 'Clusters',
|
||||
icon: <Boxes size={14} />,
|
||||
config: GetClustersQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
label: 'Deployments',
|
||||
icon: <Computer size={14} />,
|
||||
config: GetDeploymentsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.JOBS,
|
||||
label: 'Jobs',
|
||||
icon: <Bolt size={14} />,
|
||||
config: GetJobsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
label: 'DaemonSets',
|
||||
icon: <Group size={14} />,
|
||||
config: GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
label: 'StatefulSets',
|
||||
icon: <ArrowUpDown size={14} />,
|
||||
config: GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
{
|
||||
key: K8sCategories.VOLUMES,
|
||||
label: 'Volumes',
|
||||
icon: <HardDrive size={14} />,
|
||||
config: GetVolumesQuickFiltersConfig(dotMetricsEnabled),
|
||||
},
|
||||
],
|
||||
[dotMetricsEnabled],
|
||||
const renderCategoryLabel = (
|
||||
icon: JSX.Element,
|
||||
label: string,
|
||||
): JSX.Element => (
|
||||
<div className={styles.quickFiltersCategoryLabel}>
|
||||
<div className={styles.quickFiltersCategoryLabelContainer}>
|
||||
{icon}
|
||||
<Typography.Text>{label}</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
const selectedCategoryConfig = useMemo(
|
||||
() => categories.find((cat) => cat.key === selectedCategory)?.config,
|
||||
[categories, selectedCategory],
|
||||
);
|
||||
const items: CollapseProps['items'] = [
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Container size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Pods',
|
||||
),
|
||||
key: K8sCategories.PODS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetPodsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Workflow size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Nodes',
|
||||
),
|
||||
key: K8sCategories.NODES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNodesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<FilePenLine size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Namespaces',
|
||||
),
|
||||
key: K8sCategories.NAMESPACES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetNamespaceQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Boxes size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Clusters',
|
||||
),
|
||||
key: K8sCategories.CLUSTERS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetClustersQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Computer size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Deployments',
|
||||
),
|
||||
key: K8sCategories.DEPLOYMENTS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDeploymentsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Bolt size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Jobs',
|
||||
),
|
||||
key: K8sCategories.JOBS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetJobsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<Group size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'DaemonSets',
|
||||
),
|
||||
key: K8sCategories.DAEMONSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetDaemonsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<ArrowUpDown size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'StatefulSets',
|
||||
),
|
||||
key: K8sCategories.STATEFULSETS,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetStatefulsetsQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: renderCategoryLabel(
|
||||
<HardDrive size={14} className={styles.quickFiltersCategoryLabelIcon} />,
|
||||
'Volumes',
|
||||
),
|
||||
key: K8sCategories.VOLUMES,
|
||||
showArrow: false,
|
||||
children: (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={GetVolumesQuickFiltersConfig(dotMetricsEnabled)}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
/>
|
||||
),
|
||||
},
|
||||
// TODO: Enable once we have implemented containers.
|
||||
// {
|
||||
// label: (
|
||||
// <div className="k8s-quick-filters-category-label">
|
||||
// <div className="k8s-quick-filters-category-label-container">
|
||||
// <PackageOpen
|
||||
// size={14}
|
||||
// className="k8s-quick-filters-category-label-icon"
|
||||
// />
|
||||
// <Typography.Text>Containers</Typography.Text>
|
||||
// </div>
|
||||
// </div>
|
||||
// ),
|
||||
// key: K8sCategories.CONTAINERS,
|
||||
// showArrow: false,
|
||||
// children: (
|
||||
// <QuickFilters
|
||||
// source={QuickFiltersSource.INFRA_MONITORING}
|
||||
// config={ContainersQuickFiltersConfig}
|
||||
// handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
// onFilterChange={handleFilterChange}
|
||||
// />
|
||||
// ),
|
||||
// },
|
||||
];
|
||||
|
||||
const handleCategorySelect = (key: string): void => {
|
||||
if (key !== selectedCategory) {
|
||||
setSelectedCategory(key);
|
||||
const handleCategoryChange = (key: string | string[]): void => {
|
||||
if (Array.isArray(key) && key.length > 0) {
|
||||
setSelectedCategory(key[0] as string);
|
||||
// Reset filters
|
||||
setUrlFilters(null);
|
||||
setOrderBy(null);
|
||||
@@ -213,58 +332,26 @@ export default function InfraMonitoringK8s(): JSX.Element {
|
||||
<div className={styles.infraContentRow}>
|
||||
{showFilters && (
|
||||
<div className={styles.quickFiltersContainer}>
|
||||
<div className={styles.categorySelectorSection}>
|
||||
<div className={styles.sectionHeader} data-type="resource">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Viewing · Resource
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className={styles.categoryCard}>
|
||||
<div className={styles.categoryList}>
|
||||
{categories.map((category) => (
|
||||
<button
|
||||
key={category.key}
|
||||
type="button"
|
||||
className={`${styles.categoryItem} ${
|
||||
selectedCategory === category.key
|
||||
? styles.categoryItemSelected
|
||||
: ''
|
||||
}`}
|
||||
onClick={(): void => handleCategorySelect(category.key)}
|
||||
data-testid={`category-${category.key}`}
|
||||
>
|
||||
{category.icon}
|
||||
<Typography.Text>{category.label}</Typography.Text>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.quickFiltersContainerHeader}>
|
||||
<Typography.Text>Filters</Typography.Text>
|
||||
|
||||
<div className={styles.quickFiltersSection}>
|
||||
<div className={styles.sectionHeader} data-type="filter">
|
||||
<Typography.Text className={styles.sectionLabel}>
|
||||
Filter by
|
||||
</Typography.Text>
|
||||
<div className={styles.sectionLine} />
|
||||
</div>
|
||||
{selectedCategoryConfig && (
|
||||
<QuickFilters
|
||||
source={QuickFiltersSource.INFRA_MONITORING}
|
||||
config={selectedCategoryConfig}
|
||||
handleFilterVisibilityChange={handleFilterVisibilityChange}
|
||||
onFilterChange={handleFilterChange}
|
||||
<Tooltip title="Collapse Filters">
|
||||
<ArrowUpToLine
|
||||
style={{ transform: 'rotate(270deg)' }}
|
||||
onClick={handleFilterVisibilityChange}
|
||||
size="md"
|
||||
/>
|
||||
)}
|
||||
</Tooltip>
|
||||
</div>
|
||||
<Collapse
|
||||
onChange={handleCategoryChange}
|
||||
items={items}
|
||||
defaultActiveKey={[selectedCategory]}
|
||||
activeKey={[selectedCategory]}
|
||||
accordion
|
||||
bordered={false}
|
||||
ghost
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesContentProps {
|
||||
onCreateNewDashboard: () => void;
|
||||
/** When provided, renders the modal-style header with a close affordance. Omitted for inline use. */
|
||||
onCancel?: () => void;
|
||||
}
|
||||
|
||||
// The template gallery (search + list + preview + create), extracted from the
|
||||
// modal so it can be embedded inline (e.g. the V2 new-dashboard modal's template
|
||||
// tab) as well as inside DashboardTemplatesModal. Styles live under the global
|
||||
// `.new-dashboard-templates-modal` scope, so inline callers wrap it in that class.
|
||||
export default function DashboardTemplatesContent({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesContentProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
{onCancel && (
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,129 @@
|
||||
import { Modal } from 'antd';
|
||||
/* eslint-disable @typescript-eslint/explicit-function-return-type */
|
||||
import { ChangeEvent, useState } from 'react';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Button, Modal } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import ApacheIcon from 'assets/CustomIcons/ApacheIcon';
|
||||
import DockerIcon from 'assets/CustomIcons/DockerIcon';
|
||||
import ElasticSearchIcon from 'assets/CustomIcons/ElasticSearchIcon';
|
||||
import HerokuIcon from 'assets/CustomIcons/HerokuIcon';
|
||||
import KubernetesIcon from 'assets/CustomIcons/KubernetesIcon';
|
||||
import MongoDBIcon from 'assets/CustomIcons/MongoDBIcon';
|
||||
import MySQLIcon from 'assets/CustomIcons/MySQLIcon';
|
||||
import NginxIcon from 'assets/CustomIcons/NginxIcon';
|
||||
import PostgreSQLIcon from 'assets/CustomIcons/PostgreSQLIcon';
|
||||
import RedisIcon from 'assets/CustomIcons/RedisIcon';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
ConciergeBell,
|
||||
DraftingCompass,
|
||||
Drill,
|
||||
Plus,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { DashboardTemplate } from 'types/api/dashboard/getAll';
|
||||
|
||||
import DashboardTemplatesContent from './DashboardTemplatesContent';
|
||||
import blankDashboardTemplatePreviewUrl from '@/assets/Images/blankDashboardTemplatePreview.svg';
|
||||
import redisTemplatePreviewUrl from '@/assets/Images/redisTemplatePreview.svg';
|
||||
|
||||
import { filterTemplates } from '../utils';
|
||||
|
||||
import './DashboardTemplatesModal.styles.scss';
|
||||
|
||||
const templatesList: DashboardTemplate[] = [
|
||||
{
|
||||
name: 'Blank dashboard',
|
||||
icon: <Drill />,
|
||||
id: 'blank',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Alert Manager',
|
||||
icon: <ConciergeBell />,
|
||||
id: 'alertManager',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Apache',
|
||||
icon: <ApacheIcon />,
|
||||
id: 'apache',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Docker',
|
||||
icon: <DockerIcon />,
|
||||
id: 'docker',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Elasticsearch',
|
||||
icon: <ElasticSearchIcon />,
|
||||
id: 'elasticSearch',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MongoDB',
|
||||
icon: <MongoDBIcon />,
|
||||
id: 'mongoDB',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Heroku',
|
||||
icon: <HerokuIcon />,
|
||||
id: 'heroku',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Nginx',
|
||||
icon: <NginxIcon />,
|
||||
id: 'nginx',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Kubernetes',
|
||||
icon: <KubernetesIcon />,
|
||||
id: 'kubernetes',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'MySQL',
|
||||
icon: <MySQLIcon />,
|
||||
id: 'mySQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'PostgreSQL',
|
||||
icon: <PostgreSQLIcon />,
|
||||
id: 'postgreSQL',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'Redis',
|
||||
icon: <RedisIcon />,
|
||||
id: 'redis',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: redisTemplatePreviewUrl,
|
||||
},
|
||||
{
|
||||
name: 'AWS',
|
||||
icon: <DraftingCompass size={14} />,
|
||||
id: 'aws',
|
||||
description: 'Create a custom dashboard from scratch.',
|
||||
previewImage: blankDashboardTemplatePreviewUrl,
|
||||
},
|
||||
];
|
||||
|
||||
interface DashboardTemplatesModalProps {
|
||||
showNewDashboardTemplatesModal: boolean;
|
||||
onCreateNewDashboard: () => void;
|
||||
@@ -15,6 +135,20 @@ export default function DashboardTemplatesModal({
|
||||
onCreateNewDashboard,
|
||||
onCancel,
|
||||
}: DashboardTemplatesModalProps): JSX.Element {
|
||||
const [selectedDashboardTemplate, setSelectedDashboardTemplate] = useState(
|
||||
templatesList[0],
|
||||
);
|
||||
|
||||
const [dashboardTemplates, setDashboardTemplates] = useState(templatesList);
|
||||
|
||||
const handleDashboardTemplateSearch = (
|
||||
event: ChangeEvent<HTMLInputElement>,
|
||||
) => {
|
||||
const searchText = event.target.value;
|
||||
const filteredTemplates = filterTemplates(searchText, templatesList);
|
||||
setDashboardTemplates(filteredTemplates);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
wrapClassName="new-dashboard-templates-modal"
|
||||
@@ -25,10 +159,75 @@ export default function DashboardTemplatesModal({
|
||||
destroyOnClose
|
||||
width="60vw"
|
||||
>
|
||||
<DashboardTemplatesContent
|
||||
onCreateNewDashboard={onCreateNewDashboard}
|
||||
onCancel={onCancel}
|
||||
/>
|
||||
<div className="new-dashboard-templates-content-container">
|
||||
<div className="new-dashboard-templates-content-header">
|
||||
<Typography.Text>New Dashboard</Typography.Text>
|
||||
|
||||
<X size={14} className="periscope-btn ghost" onClick={onCancel} />
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-templates-content">
|
||||
<div className="new-dashboard-templates-list">
|
||||
<Input
|
||||
className="new-dashboard-templates-search"
|
||||
placeholder="🔍 Search..."
|
||||
onChange={handleDashboardTemplateSearch}
|
||||
/>
|
||||
|
||||
<div className="templates-list">
|
||||
{dashboardTemplates.map((template) => (
|
||||
<div
|
||||
className={cx(
|
||||
'template-list-item',
|
||||
selectedDashboardTemplate.id === template.id ? 'active' : '',
|
||||
)}
|
||||
key={template.name}
|
||||
onClick={() => setSelectedDashboardTemplate(template)}
|
||||
>
|
||||
<div className="template-icon">{template.icon}</div>
|
||||
<div className="template-name">{template.name}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="new-dashboard-template-preview">
|
||||
<div className="template-preview-header">
|
||||
<div className="template-preview-title">
|
||||
<div className="template-preview-icon">
|
||||
{selectedDashboardTemplate.icon}
|
||||
</div>
|
||||
|
||||
<div className="template-info">
|
||||
<div className="template-name">{selectedDashboardTemplate.name}</div>
|
||||
|
||||
<div className="template-description">
|
||||
{selectedDashboardTemplate.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="create-dashboard-btn">
|
||||
<Button
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
icon={<Plus size={14} />}
|
||||
onClick={onCreateNewDashboard}
|
||||
>
|
||||
New dashboard
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="template-preview-image">
|
||||
<img
|
||||
src={selectedDashboardTemplate.previewImage}
|
||||
alt={`${selectedDashboardTemplate.name}-preview`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -164,7 +164,7 @@ function BreakDown(): JSX.Element {
|
||||
Meter metrics data is aggregated over 1 hour period. Please select time
|
||||
range accordingly.
|
||||
<a
|
||||
href="https://signoz.io/docs/cost-meter/overview/#get-started"
|
||||
href="https://signoz.io/docs/cost-meter/overview/#accessing-cost-meter"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
style={{ textDecoration: 'underline' }}
|
||||
|
||||
@@ -197,7 +197,7 @@ function TopOperationsTable({
|
||||
|
||||
const entryPointSpanInfo = {
|
||||
text: 'Shows the spans where requests enter new services for the first time',
|
||||
url: 'https://signoz.io/docs/apm-and-distributed-tracing/application-details/',
|
||||
url: 'https://signoz.io/docs/traces-management/guides/entry-point-spans-service-overview/',
|
||||
urlText: 'Learn more about Entrypoint Spans.',
|
||||
};
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ function ConfigureGoogleAuthAuthnProvider({
|
||||
Enter OAuth 2.0 credentials obtained from the Google API Console below.
|
||||
Read the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -38,7 +38,7 @@ function ConfigureOIDCAuthnProvider({
|
||||
Configure OpenID Connect Single Sign-On with your Identity Provider. Read
|
||||
the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -37,7 +37,7 @@ function ConfigureSAMLAuthnProvider({
|
||||
<p className="authn-provider__description">
|
||||
Configure SAML 2.0 Single Sign-On with your Identity Provider. Read the{' '}
|
||||
<a
|
||||
href="https://signoz.io/docs/manage/administrator-guide/sso/overview/"
|
||||
href="https://signoz.io/docs/userguide/sso-authentication"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
|
||||
@@ -216,7 +216,7 @@ export default function QueryFunctions({
|
||||
Add new function
|
||||
<Typography.Link
|
||||
style={{ textDecoration: 'underline' }}
|
||||
href="https://signoz.io/docs/querying/functions-extended-analysis/?utm_source=product&utm_medium=query-builder"
|
||||
href="https://signoz.io/docs/userguide/query-builder/?utm_source=product&utm_medium=query-builder#functions-for-extended-data-analysis"
|
||||
target="_blank"
|
||||
>
|
||||
{' '}
|
||||
|
||||
@@ -100,7 +100,7 @@ function Version(): JSX.Element {
|
||||
{!isError && !isLatestVersion && (
|
||||
<div className="version-page-upgrade-container">
|
||||
<Button
|
||||
href="https://signoz.io/docs/opentelemetry-collection-agents/docker/overview/"
|
||||
href="https://signoz.io/docs/operate/docker-standalone/#upgrade"
|
||||
target="_blank"
|
||||
type="primary"
|
||||
className="periscope-btn primary"
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useCallback, useMemo } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import {
|
||||
getGetServiceAccountRolesQueryKey,
|
||||
useCreateServiceAccountRoleDeprecated,
|
||||
useDeleteServiceAccountRoleDeprecated,
|
||||
useCreateServiceAccountRole,
|
||||
useDeleteServiceAccountRole,
|
||||
useGetServiceAccountRoles,
|
||||
} from 'api/generated/services/serviceaccount';
|
||||
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
@@ -46,10 +46,10 @@ export function useServiceAccountRoleManager(
|
||||
);
|
||||
|
||||
// the retry for these mutations is safe due to being idempotent on backend
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRoleDeprecated({
|
||||
const { mutateAsync: createRole } = useCreateServiceAccountRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRoleDeprecated({
|
||||
const { mutateAsync: deleteRole } = useDeleteServiceAccountRole({
|
||||
mutation: { retry: retryOn429 },
|
||||
});
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# AuthZ
|
||||
|
||||
Permission-based authorization system for SigNoz frontend.
|
||||
|
||||
## Supported Resources
|
||||
|
||||
See [hooks/useAuthZ/permissions.type.ts](./hooks/useAuthZ/permissions.type.ts) for available resources and verbs.
|
||||
|
||||
If your page/content represents a resource not listed there, skip authz implementation — the backend doesn't enforce it yet.
|
||||
|
||||
## UI Gating
|
||||
|
||||
Need to gate UI based on permissions? See [components/README.md](./components/README.md).
|
||||
|
||||
Covers: AuthZButton, AuthZTooltip, withAuthZ*, AuthZGuard*, when to use each.
|
||||
|
||||
## Testing
|
||||
|
||||
Need to test authz behavior? See [utils/README.md](./utils/README.md).
|
||||
|
||||
Covers: MSW handlers, mock hooks, test patterns.
|
||||
@@ -1,81 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import AuthZButton from './AuthZButton';
|
||||
|
||||
// AuthZButton is a thin composition over AuthZTooltip + Button. The denial
|
||||
// tooltip / disabled-on-deny UX is owned and tested by AuthZTooltip; here we
|
||||
// assert AuthZButton forwards the right props and renders a Button child.
|
||||
jest.mock('lib/authz/components/AuthZTooltip/AuthZTooltip');
|
||||
const mockTooltip = AuthZTooltip as unknown as jest.Mock;
|
||||
|
||||
const createPerm = buildPermission(
|
||||
'create',
|
||||
'serviceaccount:*' as AuthZObject<'create'>,
|
||||
);
|
||||
|
||||
describe('AuthZButton', () => {
|
||||
beforeEach(() => {
|
||||
mockTooltip.mockImplementation(
|
||||
({ children }: { children: ReactElement }) => children,
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
mockTooltip.mockReset();
|
||||
});
|
||||
|
||||
it('renders a Button child with forwarded props', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('create-btn')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('create-btn').tagName).toBe('BUTTON');
|
||||
});
|
||||
|
||||
it('forwards checks and enables the check by default', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip).toHaveBeenCalledTimes(1);
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
checks: [createPerm],
|
||||
enabled: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('forwards a custom tooltipMessage', () => {
|
||||
render(
|
||||
<AuthZButton
|
||||
checks={[createPerm]}
|
||||
tooltipMessage="Ask an admin"
|
||||
testId="create-btn"
|
||||
>
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({
|
||||
tooltipMessage: 'Ask an admin',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes authZEnabled through as the tooltip enabled flag', () => {
|
||||
render(
|
||||
<AuthZButton checks={[createPerm]} authZEnabled={false} testId="create-btn">
|
||||
Create
|
||||
</AuthZButton>,
|
||||
);
|
||||
|
||||
expect(mockTooltip.mock.calls[0][0]).toMatchObject({ enabled: false });
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { Button, ButtonProps } from '@signozhq/ui/button';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type AuthZButtonProps = ButtonProps & {
|
||||
/**
|
||||
* Permissions required to enable the button (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
/**
|
||||
* Override the default denial tooltip message.
|
||||
*/
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* Gate the permission check itself. When false, renders a plain button.
|
||||
*/
|
||||
authZEnabled?: boolean;
|
||||
};
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
tooltipMessage,
|
||||
authZEnabled = true,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
return (
|
||||
<AuthZTooltip
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
</AuthZTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
export default AuthZButton;
|
||||
@@ -1,202 +0,0 @@
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { AuthZGuard } from './AuthZGuard';
|
||||
import { AuthZGuardContent } from './AuthZGuardContent';
|
||||
import { AuthZGuardPage } from './AuthZGuardPage';
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
const Protected = (): JSX.Element => <div>Protected content</div>;
|
||||
|
||||
describe('AuthZGuard', () => {
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('passes denied permissions to a function fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
fallback={({ deniedPermissions }): JSX.Element => (
|
||||
<div>denied: {deniedPermissions.length}</div>
|
||||
)}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied: 1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing for a denied check with no fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
const { container } = render(
|
||||
<AuthZGuard checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
});
|
||||
|
||||
it('renders the loading fallback while checking', () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallbackOnLoading={<div>Loading…</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading…')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('fails open on error by default (renders children)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard checks={[readPerm]} fallback={<div>No access</div>}>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the fallback on error when failOpenOnError is false', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'boom' })),
|
||||
),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZGuard
|
||||
checks={[readPerm]}
|
||||
onFailRenderContent={false}
|
||||
fallback={<div>No access</div>}
|
||||
>
|
||||
<Protected />
|
||||
</AuthZGuard>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the app loader while checking', () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardPage checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardPage>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'OpenTelemetry-Native Logs, Metrics and Traces in a single pane',
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('AuthZGuardContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Protected content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders children when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
|
||||
render(
|
||||
<AuthZGuardContent checks={[readPerm]}>
|
||||
<Protected />
|
||||
</AuthZGuardContent>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,82 +0,0 @@
|
||||
import { ReactElement, ReactNode, useMemo } from 'react';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export type AuthZGuardFallback =
|
||||
| ReactNode
|
||||
| ((info: { deniedPermissions: BrandedPermission[] }) => ReactNode);
|
||||
|
||||
export type AuthZGuardProps = {
|
||||
/**
|
||||
* Permissions required to render `children` (AND semantics).
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
/**
|
||||
* Rendered when denied. A function receives the denied permissions.
|
||||
*/
|
||||
fallback?: AuthZGuardFallback;
|
||||
fallbackOnLoading?: ReactNode;
|
||||
/**
|
||||
* By default, we don't expect the check API request to fail, in those cases, we prefer to show the content and then let the API fail (during list/create).
|
||||
*
|
||||
* In case you want to have a different behavior when request fail, set to false.
|
||||
*
|
||||
* @default true
|
||||
*/
|
||||
onFailRenderContent?: boolean;
|
||||
};
|
||||
|
||||
function resolveFallback(
|
||||
fallback: AuthZGuardFallback | undefined,
|
||||
deniedPermissions: BrandedPermission[],
|
||||
): ReactNode {
|
||||
if (typeof fallback === 'function') {
|
||||
return fallback({ deniedPermissions });
|
||||
}
|
||||
return fallback ?? null;
|
||||
}
|
||||
|
||||
export function AuthZGuard({
|
||||
checks,
|
||||
children,
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
onFailRenderContent = true,
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
const { isLoading, error, permissions } = useAuthZ(checks);
|
||||
|
||||
// TODO(authz): Use allowed/deniedPermissions from useAuthZ after devtools PR merges
|
||||
const { allowed, deniedPermissions } = useMemo(() => {
|
||||
if (!permissions) {
|
||||
return { allowed: false, deniedPermissions: [] as BrandedPermission[] };
|
||||
}
|
||||
|
||||
const denied = Object.entries(permissions)
|
||||
.filter(([, { isGranted }]) => !isGranted)
|
||||
.map(([perm]) => perm as BrandedPermission);
|
||||
|
||||
return {
|
||||
allowed: denied.length === 0,
|
||||
deniedPermissions: denied,
|
||||
};
|
||||
}, [permissions]);
|
||||
|
||||
if (isLoading) {
|
||||
return <>{fallbackOnLoading ?? null}</>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return onFailRenderContent ? (
|
||||
children
|
||||
) : (
|
||||
<>{resolveFallback(fallback, deniedPermissions)}</>
|
||||
);
|
||||
}
|
||||
|
||||
if (!allowed) {
|
||||
return <>{resolveFallback(fallback, deniedPermissions)}</>;
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import PermissionDeniedCallout from 'lib/authz/components/PermissionDeniedCallout/PermissionDeniedCallout';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardContent({
|
||||
fallback,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedCallout deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
import { ReactElement } from 'react';
|
||||
import AppLoading from 'components/AppLoading/AppLoading';
|
||||
import PermissionDeniedFullPage from 'lib/authz/components/PermissionDeniedFullPage/PermissionDeniedFullPage';
|
||||
|
||||
import { AuthZGuard, AuthZGuardProps } from './AuthZGuard';
|
||||
|
||||
export function AuthZGuardPage({
|
||||
fallback,
|
||||
fallbackOnLoading,
|
||||
...rest
|
||||
}: AuthZGuardProps): JSX.Element | null {
|
||||
return (
|
||||
<AuthZGuard
|
||||
{...rest}
|
||||
fallbackOnLoading={fallbackOnLoading ?? <AppLoading />}
|
||||
fallback={
|
||||
fallback ??
|
||||
(({ deniedPermissions }): ReactElement => (
|
||||
<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />
|
||||
))
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -16,8 +16,6 @@ const noPermissions = {
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
@@ -162,11 +160,11 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
</AuthZTooltip>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button', { name: 'Action' });
|
||||
expect(button).toBeDisabled();
|
||||
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
|
||||
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(button.getAttribute('data-denied-permissions')).toContain(
|
||||
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
|
||||
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
|
||||
attachRolePerm,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
.wrapper {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { CSSProperties, ReactElement, cloneElement, useMemo } from 'react';
|
||||
import { ReactElement, cloneElement, useMemo } from 'react';
|
||||
import {
|
||||
TooltipRoot,
|
||||
TooltipContent,
|
||||
@@ -11,13 +11,6 @@ import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
const DISABLED_STYLE: CSSProperties = {
|
||||
pointerEvents: 'all',
|
||||
cursor: 'not-allowed',
|
||||
};
|
||||
|
||||
const noOp = (): void => {};
|
||||
|
||||
interface AuthZTooltipProps {
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
@@ -56,13 +49,11 @@ function AuthZTooltip({
|
||||
}, [checks, permissions]);
|
||||
|
||||
if (shouldCheck && isLoading) {
|
||||
return cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
});
|
||||
return (
|
||||
<span className={styles.wrapper}>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
@@ -73,14 +64,12 @@ function AuthZTooltip({
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
style: DISABLED_STYLE,
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
})}
|
||||
<span
|
||||
className={styles.wrapper}
|
||||
data-denied-permissions={deniedPermissions.join(',')}
|
||||
>
|
||||
{cloneElement(children, { disabled: true })}
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
import { ReactElement } from 'react';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { GuardAuthZ } from './GuardAuthZ';
|
||||
|
||||
describe('GuardAuthZ', () => {
|
||||
const TestChild = (): ReactElement => <div>Protected Content</div>;
|
||||
const LoadingFallback = (): ReactElement => <div>Loading...</div>;
|
||||
const NoPermissionFallback = (_response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => <div>Access denied</div>;
|
||||
const NoPermissionFallbackWithSuggestions = (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
Access denied. Required permission: {response.requiredPermissionName}
|
||||
</div>
|
||||
);
|
||||
|
||||
it('should render children when permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnLoading when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnLoading={<LoadingFallback />}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Loading...')).toBeInTheDocument();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when loading and no fallbackOnLoading provided', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
expect(container.firstChild).toBeNull();
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render children when API error occurs and no fallbackOnError provided (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render fallbackOnError when API error occurs and fallbackOnError is provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="read"
|
||||
object="role:*"
|
||||
fallbackOnError={<div>Custom error fallback</div>}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Custom error fallback')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render fallbackOnNoPermissions when permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallback}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Access denied')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permission is denied and no fallbackOnNoPermissions provided', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="update" object="role:123">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render null when permissions object is null', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(200), ctx.json({ data: [], status: 'success' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const { container } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.firstChild).toBeNull();
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass requiredPermissionName to fallbackOnNoPermissions', async () => {
|
||||
const permission = buildPermission('update', 'role:123');
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<GuardAuthZ
|
||||
relation="update"
|
||||
object="role:123"
|
||||
fallbackOnNoPermissions={NoPermissionFallbackWithSuggestions}
|
||||
>
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText(/Access denied. Required permission:/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
new RegExp(permission.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')),
|
||||
).length,
|
||||
).toBeGreaterThan(0);
|
||||
expect(screen.queryByText('Protected Content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle different relation and object combinations', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const { rerender } = render(
|
||||
<GuardAuthZ relation="read" object="role:*">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
rerender(
|
||||
<GuardAuthZ relation="delete" object="role:456">
|
||||
<TestChild />
|
||||
</GuardAuthZ>,
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Protected Content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
50
frontend/src/lib/authz/components/GuardAuthZ/GuardAuthZ.tsx
Normal file
50
frontend/src/lib/authz/components/GuardAuthZ/GuardAuthZ.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ReactElement } from 'react';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export type GuardAuthZProps<R extends AuthZRelation> = {
|
||||
children: ReactElement;
|
||||
relation: R;
|
||||
object: AuthZObject<R>;
|
||||
fallbackOnLoading?: JSX.Element;
|
||||
fallbackOnError?: JSX.Element;
|
||||
fallbackOnNoPermissions?: (response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}) => JSX.Element;
|
||||
};
|
||||
|
||||
export function GuardAuthZ<R extends AuthZRelation>({
|
||||
children,
|
||||
relation,
|
||||
object,
|
||||
fallbackOnLoading,
|
||||
fallbackOnError,
|
||||
fallbackOnNoPermissions,
|
||||
}: GuardAuthZProps<R>): JSX.Element | null {
|
||||
const permission = buildPermission<R>(relation, object);
|
||||
|
||||
const { permissions, isLoading, error } = useAuthZ([permission]);
|
||||
|
||||
if (isLoading) {
|
||||
return fallbackOnLoading ?? null;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return fallbackOnError ?? children;
|
||||
}
|
||||
|
||||
if (!permissions?.[permission]?.isGranted) {
|
||||
return (
|
||||
fallbackOnNoPermissions?.({
|
||||
requiredPermissionName: permission,
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
return children;
|
||||
}
|
||||
@@ -1,39 +1,18 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedCallout from './PermissionDeniedCallout';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedCallout', () => {
|
||||
it('renders the permission name in the callout message', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
|
||||
|
||||
expect(screen.getByText(/is not authorized/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedCallout deniedPermissions={deniedPermissions} />);
|
||||
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('accepts an optional className', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
const { container } = render(
|
||||
<PermissionDeniedCallout
|
||||
deniedPermissions={deniedPermissions}
|
||||
permissionName="serviceaccount:read"
|
||||
className="custom-class"
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -3,32 +3,18 @@ import cx from 'classnames';
|
||||
import styles from './PermissionDeniedCallout.module.scss';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export interface PermissionDeniedCalloutProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
interface PermissionDeniedCalloutProps {
|
||||
permissionName: string;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedCallout({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
className,
|
||||
}: PermissionDeniedCalloutProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<Callout
|
||||
type="error"
|
||||
@@ -39,12 +25,7 @@ function PermissionDeniedCallout({
|
||||
<Typography.Text className={styles.permission}>
|
||||
<code className={styles.permissionCode}>user/{user.id}</code> is not
|
||||
authorized to perform{' '}
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permissionCode}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
<code className={styles.permissionCode}>{permissionName}</code>
|
||||
</Typography.Text>
|
||||
</Callout>
|
||||
);
|
||||
|
||||
@@ -1,29 +1,17 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
|
||||
import {
|
||||
buildPermission,
|
||||
buildObjectString,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
describe('PermissionDeniedFullPage', () => {
|
||||
it('renders the title and subtitle with the permissionName interpolated', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('serviceaccount', '*')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
|
||||
|
||||
expect(screen.getByText('Uh-oh! You are not authorized')).toBeInTheDocument();
|
||||
expect(screen.getByText(/read:serviceaccount:\*/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/is not authorized to perform/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders with multiple denied permissions', () => {
|
||||
const deniedPermissions = [
|
||||
buildPermission('read', buildObjectString('role', 'admin')),
|
||||
buildPermission('update', buildObjectString('role', 'admin')),
|
||||
];
|
||||
render(<PermissionDeniedFullPage deniedPermissions={deniedPermissions} />);
|
||||
expect(screen.getByText(/read:role:admin/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/update:role:admin/)).toBeInTheDocument();
|
||||
it('renders with a different permissionName', () => {
|
||||
render(<PermissionDeniedFullPage permissionName="role:read" />);
|
||||
expect(screen.getByText(/role:read/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,30 +3,16 @@ import { CircleSlash2 } from '@signozhq/icons';
|
||||
import styles from './PermissionDeniedFullPage.module.scss';
|
||||
import { Style } from '@signozhq/design-tokens';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export interface PermissionDeniedFullPageProps {
|
||||
/**
|
||||
* @deprecated Use `deniedPermissions` instead. Will be removed after authz devtools PR merges.
|
||||
*/
|
||||
permissionName?: string;
|
||||
deniedPermissions?: BrandedPermission[];
|
||||
interface PermissionDeniedFullPageProps {
|
||||
permissionName: string;
|
||||
}
|
||||
|
||||
function PermissionDeniedFullPage({
|
||||
permissionName,
|
||||
deniedPermissions,
|
||||
}: PermissionDeniedFullPageProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
|
||||
// TODO(authz): Remove permissionName support after devtools PR merges
|
||||
const formattedPermissions = deniedPermissions
|
||||
? deniedPermissions.map(formatPermission)
|
||||
: permissionName
|
||||
? [permissionName]
|
||||
: [];
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.content}>
|
||||
@@ -36,13 +22,7 @@ function PermissionDeniedFullPage({
|
||||
<p className={styles.title}>Uh-oh! You are not authorized</p>
|
||||
<p className={styles.subtitle}>
|
||||
<code className={styles.permission}>user/{user.id}</code> is not authorized
|
||||
to perform{' '}
|
||||
{formattedPermissions.map((perm, idx) => (
|
||||
<span key={perm}>
|
||||
<code className={styles.permission}>{perm}</code>
|
||||
{idx < formattedPermissions.length - 1 && ', '}
|
||||
</span>
|
||||
))}
|
||||
to perform <code className={styles.permission}>{permissionName}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,185 +0,0 @@
|
||||
# AuthZ Components
|
||||
|
||||
Quick reference for permission-gating UI. All components use AND semantics: user needs ALL permissions in `checks` array.
|
||||
|
||||
## Decision Tree
|
||||
|
||||
```
|
||||
Need to gate...
|
||||
├── A button? → AuthZButton
|
||||
├── Any element with tooltip on deny? → AuthZTooltip
|
||||
├── A section inside a page? → withAuthZContent (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardContent
|
||||
├── An entire page/route? → withAuthZPage (preferred)
|
||||
│ └── Need JSX wrapper? → AuthZGuardPage
|
||||
├── Need full control over fallback? → withAuthZ / AuthZGuard
|
||||
└── None of above fit?
|
||||
├── Can create wrapper component? → Create it (like AuthZButton)
|
||||
└── Last resort → useAuthZ hook directly
|
||||
```
|
||||
|
||||
## Building Permissions
|
||||
|
||||
Use `buildPermission`, `buildObjectString` or pre-built constants. Never cast with `as BrandedPermission`.
|
||||
|
||||
```tsx
|
||||
import { buildPermission, buildObjectString } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import {
|
||||
RoleCreatePermission,
|
||||
buildRoleReadPermission
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
// Static permission (pre-built)
|
||||
const checks = [RoleCreatePermission];
|
||||
|
||||
// Dynamic permission (builder fn)
|
||||
const checks = [buildRoleReadPermission(roleId)];
|
||||
|
||||
// Custom permission (buildPermission + buildObjectString)
|
||||
const checks = [buildPermission('read', buildObjectString('dashboard', dashboardId))];
|
||||
```
|
||||
|
||||
## Creating Permission Helpers
|
||||
|
||||
When adding authz to a new resource, create a permissions file under `lib/authz/hooks/useAuthZ/permissions/`.
|
||||
|
||||
```tsx
|
||||
// lib/authz/hooks/useAuthZ/permissions/dashboard.permissions.ts
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard, no specific id needed
|
||||
export const DashboardCreatePermission = buildPermission('create', 'dashboard:*');
|
||||
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
|
||||
|
||||
// Resource-level — require specific id
|
||||
export const buildDashboardReadPermission = (id: string): BrandedPermission =>
|
||||
buildPermission('read', `dashboard:${id}`);
|
||||
export const buildDashboardUpdatePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('update', `dashboard:${id}`);
|
||||
export const buildDashboardDeletePermission = (id: string): BrandedPermission =>
|
||||
buildPermission('delete', `dashboard:${id}`);
|
||||
```
|
||||
|
||||
Pattern:
|
||||
- `<Resource><Action>Permission` → collection-level const (wildcard `*`)
|
||||
- `build<Resource><Action>Permission(id)` → resource-level fn (specific id)
|
||||
|
||||
## Components
|
||||
|
||||
### AuthZButton
|
||||
|
||||
Button that disables + shows tooltip when denied.
|
||||
|
||||
```tsx
|
||||
import { SACreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZButton checks={[SACreatePermission]} onClick={handleCreate}>
|
||||
Create
|
||||
</AuthZButton>
|
||||
```
|
||||
|
||||
### AuthZTooltip
|
||||
|
||||
Wraps any element. Disables child + shows denial tooltip.
|
||||
|
||||
```tsx
|
||||
import { buildSADeletePermission } from 'lib/authz/hooks/useAuthZ/permissions/service-account.permissions';
|
||||
|
||||
<AuthZTooltip checks={[buildSADeletePermission(accountId)]}>
|
||||
<IconButton icon={<Trash />} onClick={handleDelete} />
|
||||
</AuthZTooltip>
|
||||
```
|
||||
|
||||
### withAuthZPage (preferred for pages)
|
||||
|
||||
HOC for route-level gating. Wrap at export. Shows `PermissionDeniedFullPage` + `AppLoading`.
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RolesPage(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
export default withAuthZPage(RolesPage, {
|
||||
checks: [RoleListPermission],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZContent (preferred for sections)
|
||||
|
||||
HOC for inline sections. Shows `PermissionDeniedCallout` on deny.
|
||||
|
||||
```tsx
|
||||
import { buildRoleReadPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
function RoleEditor(): JSX.Element {
|
||||
return <div>...</div>;
|
||||
}
|
||||
|
||||
// Dynamic checks from route params
|
||||
export default withAuthZContent(RoleEditor, {
|
||||
checks: (_props, ctx) => [buildRoleReadPermission(ctx.params.roleId)],
|
||||
});
|
||||
```
|
||||
|
||||
### withAuthZ
|
||||
|
||||
HOC base. No default fallback. Use when you need custom fallback.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
export default withAuthZ(SecretPanel, {
|
||||
checks: [buildPermission('write', 'settings:org')],
|
||||
fallback: <p>No access</p>,
|
||||
});
|
||||
```
|
||||
|
||||
### AuthZGuardPage
|
||||
|
||||
JSX variant of `withAuthZPage`. Use when HOC not possible (conditional rendering).
|
||||
|
||||
```tsx
|
||||
import { RoleListPermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardPage checks={[RoleListPermission]}>
|
||||
<RolesPage />
|
||||
</AuthZGuardPage>
|
||||
```
|
||||
|
||||
### AuthZGuardContent
|
||||
|
||||
JSX variant of `withAuthZContent`. Use when HOC not possible.
|
||||
|
||||
```tsx
|
||||
import { RoleCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/role.permissions';
|
||||
|
||||
<AuthZGuardContent checks={[RoleCreatePermission]}>
|
||||
<RoleEditor />
|
||||
</AuthZGuardContent>
|
||||
```
|
||||
|
||||
### AuthZGuard
|
||||
|
||||
JSX base guard. No default fallback. Use when you need custom fallback in JSX.
|
||||
|
||||
```tsx
|
||||
import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
<AuthZGuard
|
||||
checks={[buildPermission('write', 'settings:org')]}
|
||||
fallback={<p>No access</p>}
|
||||
fallbackOnLoading={<Spinner />}
|
||||
>
|
||||
<SecretContent />
|
||||
</AuthZGuard>
|
||||
```
|
||||
|
||||
## Fallback Components
|
||||
|
||||
Don't use these components directly, always prefer using via `withAuthZ` and their variants.
|
||||
|
||||
- PermissionDeniedCallout: inline error callout. Shows `user/{id} is not authorized to perform {permissions}`.
|
||||
- PermissionDeniedFullPage: full-page centered error. Same message, bigger presentation.
|
||||
@@ -0,0 +1,440 @@
|
||||
import { ReactElement } from 'react';
|
||||
import type { RouteComponentProps } from 'react-router-dom';
|
||||
import type {
|
||||
AuthtypesGettableTransactionDTO,
|
||||
AuthtypesTransactionDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import { createGuardedRoute } from './createGuardedRoute';
|
||||
|
||||
describe('createGuardedRoute', () => {
|
||||
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
|
||||
<div>Test Component: {testProp}</div>
|
||||
);
|
||||
|
||||
it('should render component when permission is granted', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should substitute route parameters in object string', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle multiple route parameters', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const txn = payload[0];
|
||||
const responseData: AuthtypesGettableTransactionDTO[] = [
|
||||
{
|
||||
relation: txn.relation,
|
||||
object: {
|
||||
resource: {
|
||||
kind: txn.object.resource.kind,
|
||||
type: txn.object.resource.type,
|
||||
},
|
||||
selector: '123:456',
|
||||
},
|
||||
authorized: true,
|
||||
},
|
||||
];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: responseData, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}:{version}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123', version: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id/:version',
|
||||
url: '/dashboard/123/456',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should keep placeholder when route parameter is missing', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render loading fallback when loading', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (_req, res, ctx) => {
|
||||
return res(
|
||||
ctx.delay('infinite'),
|
||||
ctx.status(200),
|
||||
ctx.json({ data: [], status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
expect(screen.getByText('SigNoz')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the component when API error occurs (fail open)', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => {
|
||||
return res(ctx.status(500), ctx.json({ error: 'Internal Server Error' }));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(TestComponent, 'read', 'role:*');
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should render no permissions fallback when permission is denied', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [false])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'update',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
const heading = document.querySelector('h3');
|
||||
expect(heading).toBeInTheDocument();
|
||||
expect(heading?.textContent).toMatch(/not authorized/i);
|
||||
});
|
||||
|
||||
expect(screen.getByText(/update:role:123/)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByText('Test Component: test-value'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should pass all props to wrapped component', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const ComponentWithMultipleProps = ({
|
||||
prop1,
|
||||
prop2,
|
||||
prop3,
|
||||
}: {
|
||||
prop1: string;
|
||||
prop2: number;
|
||||
prop3: boolean;
|
||||
}): ReactElement => (
|
||||
<div>
|
||||
{prop1} - {prop2} - {prop3.toString()}
|
||||
</div>
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
ComponentWithMultipleProps,
|
||||
'read',
|
||||
'role:*',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: {},
|
||||
isExact: true,
|
||||
path: '/dashboard',
|
||||
url: '/dashboard',
|
||||
};
|
||||
|
||||
const props = {
|
||||
prop1: 'value1',
|
||||
prop2: 42,
|
||||
prop3: true,
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('value1 - 42 - true')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should memoize resolved object based on route params', async () => {
|
||||
let requestCount = 0;
|
||||
const requestedObjects: string[] = [];
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
requestCount++;
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
const obj = payload[0]?.object;
|
||||
const kind = obj?.resource?.kind;
|
||||
const selector = obj?.selector ?? '*';
|
||||
const objectStr = `${kind}:${selector}`;
|
||||
requestedObjects.push(objectStr ?? '');
|
||||
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'read',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch1 = {
|
||||
params: { id: '123' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/123',
|
||||
};
|
||||
|
||||
const props1 = {
|
||||
testProp: 'test-value-1',
|
||||
match: mockMatch1,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
const { unmount } = render(<GuardedComponent {...props1} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-1')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(1);
|
||||
expect(requestedObjects).toContain('role:123');
|
||||
|
||||
unmount();
|
||||
|
||||
const mockMatch2 = {
|
||||
params: { id: '456' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/456',
|
||||
};
|
||||
|
||||
const props2 = {
|
||||
testProp: 'test-value-2',
|
||||
match: mockMatch2,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props2} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value-2')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(requestCount).toBe(2);
|
||||
expect(requestedObjects).toContain('role:456');
|
||||
});
|
||||
|
||||
it('should handle different relation types', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true])));
|
||||
}),
|
||||
);
|
||||
|
||||
const GuardedComponent = createGuardedRoute(
|
||||
TestComponent,
|
||||
'delete',
|
||||
'role:{id}',
|
||||
);
|
||||
|
||||
const mockMatch = {
|
||||
params: { id: '789' },
|
||||
isExact: true,
|
||||
path: '/dashboard/:id',
|
||||
url: '/dashboard/789',
|
||||
};
|
||||
|
||||
const props = {
|
||||
testProp: 'test-value',
|
||||
match: mockMatch,
|
||||
location: {} as unknown as RouteComponentProps['location'],
|
||||
history: {} as unknown as RouteComponentProps['history'],
|
||||
};
|
||||
|
||||
render(<GuardedComponent {...props} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Component: test-value')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
.guard-authz-error-no-authz {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
padding: 24px;
|
||||
|
||||
.guard-authz-error-no-authz-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 18px;
|
||||
color: var(--l1-foreground);
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 14px;
|
||||
color: var(--l3-foreground);
|
||||
line-height: 18px;
|
||||
|
||||
span {
|
||||
background-color: var(--l3-background);
|
||||
white-space: nowrap;
|
||||
padding: 0 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { ComponentType, ReactElement, useMemo } from 'react';
|
||||
import { RouteComponentProps } from 'react-router-dom';
|
||||
import {
|
||||
AuthZObject,
|
||||
AuthZRelation,
|
||||
BrandedPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
|
||||
import noDataUrl from 'assets/Icons/no-data.svg';
|
||||
|
||||
import AppLoading from '../../../../components/AppLoading/AppLoading';
|
||||
import { GuardAuthZ } from '../GuardAuthZ/GuardAuthZ';
|
||||
|
||||
import './createGuardedRoute.styles.scss';
|
||||
|
||||
function OnNoPermissionsFallback(response: {
|
||||
requiredPermissionName: BrandedPermission;
|
||||
}): ReactElement {
|
||||
const { user } = useAppContext();
|
||||
|
||||
return (
|
||||
<div className="guard-authz-error-no-authz">
|
||||
<div className="guard-authz-error-no-authz-content">
|
||||
<img src={noDataUrl} alt="No permission" />
|
||||
<h3>Uh-oh! You are not authorized</h3>
|
||||
<p>
|
||||
<code>user/{user.id}</code> is not authorized to perform{' '}
|
||||
<code>{formatPermission(response.requiredPermissionName)}</code>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/ban-types
|
||||
export function createGuardedRoute<P extends object, R extends AuthZRelation>(
|
||||
Component: ComponentType<P>,
|
||||
relation: R,
|
||||
object: AuthZObject<R>,
|
||||
): ComponentType<P & RouteComponentProps<Record<string, string>>> {
|
||||
return function GuardedRouteComponent(
|
||||
props: P & RouteComponentProps<Record<string, string>>,
|
||||
): ReactElement {
|
||||
const resolvedObject = useMemo(() => {
|
||||
const paramPattern = /\{([^}]+)\}/g;
|
||||
return object.replace(paramPattern, (match, paramName) => {
|
||||
const paramValue = props.match?.params?.[paramName];
|
||||
return paramValue !== undefined ? paramValue : match;
|
||||
}) as AuthZObject<R>;
|
||||
}, [props.match?.params]);
|
||||
|
||||
return (
|
||||
<GuardAuthZ
|
||||
relation={relation}
|
||||
object={resolvedObject}
|
||||
fallbackOnLoading={<AppLoading />}
|
||||
fallbackOnNoPermissions={(response): ReactElement => (
|
||||
<OnNoPermissionsFallback {...response} />
|
||||
)}
|
||||
>
|
||||
<Component {...props} />
|
||||
</GuardAuthZ>
|
||||
);
|
||||
};
|
||||
}
|
||||
@@ -1,578 +0,0 @@
|
||||
import React from 'react';
|
||||
import { render, screen, waitFor, act } from 'tests/test-utils';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
authzMockResponse,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDeny,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import type { AuthZObject } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import {
|
||||
buildObjectString,
|
||||
buildPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/utils';
|
||||
|
||||
import { withAuthZ, RouterContext } from './withAuthZ';
|
||||
import { withAuthZContent } from './withAuthZContent';
|
||||
import { withAuthZPage } from './withAuthZPage';
|
||||
|
||||
const mockUseParams = jest.fn();
|
||||
const mockUseLocation = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useParams: (): Record<string, string> => mockUseParams(),
|
||||
useLocation: (): { pathname: string; search: string } => mockUseLocation(),
|
||||
}));
|
||||
|
||||
const readPerm = buildPermission('read', 'role:*' as AuthZObject<'read'>);
|
||||
|
||||
function Base(): JSX.Element {
|
||||
return <div>Base component</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
});
|
||||
|
||||
describe('withAuthZ', () => {
|
||||
it('renders the wrapped component when allowed', async () => {
|
||||
server.use(setupAuthzAllow(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Base component')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders nothing when denied without a fallback', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders the provided fallback when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZ(Base, {
|
||||
checks: [readPerm],
|
||||
fallback: <div>No access</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('No access')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves checks from props via the selector form', async () => {
|
||||
type Props = { roleId: string };
|
||||
const RoleView = ({ roleId }: Props): JSX.Element => <div>role {roleId}</div>;
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-1'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const Guarded = withAuthZ<Props>(RoleView, {
|
||||
checks: ({ roleId }) => [
|
||||
buildPermission('read', buildObjectString<'read'>('role', roleId)),
|
||||
],
|
||||
fallback: <div>denied selector</div>,
|
||||
});
|
||||
|
||||
render(<Guarded roleId="r-1" />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('denied selector')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role r-1')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sets a descriptive displayName', () => {
|
||||
const Guarded = withAuthZ(Base, { checks: [readPerm] });
|
||||
expect(Guarded.displayName).toBe('withAuthZ(Base)');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZPage', () => {
|
||||
it('renders the full-page denied screen when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZPage(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh! You are not authorized'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent', () => {
|
||||
it('renders the denied callout when denied', async () => {
|
||||
server.use(setupAuthzDeny(readPerm));
|
||||
const Guarded = withAuthZContent(Base, { checks: [readPerm] });
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('read:role:*')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('Base component')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context', () => {
|
||||
it('extracts checks from route params via router.params', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-123' });
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-123'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from query params via router.searchParams', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/roles',
|
||||
search: '?roleId=r-456',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-456'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const RoleListView = (): JSX.Element => <div>role list view</div>;
|
||||
const Guarded = withAuthZ(RoleListView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.searchParams.get('roleId') ?? ''),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('role list view')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('extracts checks from pathname via router.matchPath', async () => {
|
||||
mockUseLocation.mockReturnValue({
|
||||
pathname: '/settings/roles/r-789/edit',
|
||||
search: '',
|
||||
});
|
||||
|
||||
const rolePerm = buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', 'r-789'),
|
||||
);
|
||||
server.use(setupAuthzAllow(rolePerm));
|
||||
|
||||
const EditRoleView = (): JSX.Element => <div>edit role</div>;
|
||||
const Guarded = withAuthZ(EditRoleView, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ roleId: string }>(
|
||||
'/settings/roles/:roleId/edit',
|
||||
);
|
||||
return match
|
||||
? [
|
||||
buildPermission(
|
||||
'update',
|
||||
buildObjectString<'update'>('role', match.roleId),
|
||||
),
|
||||
]
|
||||
: [];
|
||||
},
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('edit role')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('denies when router-derived permission is not allowed', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-denied' });
|
||||
|
||||
const deniedPerm = buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', 'r-denied'),
|
||||
);
|
||||
server.use(setupAuthzDeny(deniedPerm));
|
||||
|
||||
const RoleView = (): JSX.Element => <div>role view</div>;
|
||||
const Guarded = withAuthZ(RoleView, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? ''),
|
||||
),
|
||||
],
|
||||
fallback: <div>access denied</div>,
|
||||
});
|
||||
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('access denied')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText('role view')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZ router context stability', () => {
|
||||
let renderCount = 0;
|
||||
|
||||
function RenderCounter(): JSX.Element {
|
||||
renderCount += 1;
|
||||
return <div data-testid="render-count">{renderCount}</div>;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
renderCount = 0;
|
||||
server.use(setupAuthzGrantByPrefix('read||__||role'));
|
||||
});
|
||||
|
||||
it('does not re-render when useParams returns new object with same values', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME values — should not cause re-render
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
// Allow any pending effects to flush
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same pathname', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
const match = router.matchPath<{ id: string }>('/roles/:id');
|
||||
return match
|
||||
? [buildPermission('read', buildObjectString<'read'>('role', match.id))]
|
||||
: [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME pathname — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('does not re-render when useLocation returns new object with same search params', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => {
|
||||
// Access searchParams to ensure it's part of the dependency chain
|
||||
void router.searchParams.get('tab');
|
||||
return [readPerm];
|
||||
},
|
||||
});
|
||||
|
||||
const { rerender } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
|
||||
// Return NEW object with SAME search — should not cause re-render
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
rerender(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBe(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when params values actually change', async () => {
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-1' });
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: (_props, router: RouterContext) => [
|
||||
buildPermission(
|
||||
'read',
|
||||
buildObjectString<'read'>('role', router.params.roleId ?? '*'),
|
||||
),
|
||||
],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// Return DIFFERENT values — re-mount with new mock values
|
||||
mockUseParams.mockReturnValue({ roleId: 'r-2' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when pathname actually changes', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/roles', search: '' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT pathname — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/users', search: '' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
|
||||
it('re-renders when search params actually change', async () => {
|
||||
mockUseParams.mockReturnValue({});
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=keys' });
|
||||
|
||||
const Guarded = withAuthZ(RenderCounter, {
|
||||
checks: [readPerm],
|
||||
});
|
||||
|
||||
const { unmount } = render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('render-count')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
const initialCount = renderCount;
|
||||
unmount();
|
||||
|
||||
// DIFFERENT search — re-mount with new mock values
|
||||
mockUseLocation.mockReturnValue({ pathname: '/', search: '?tab=details' });
|
||||
render(<Guarded />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(renderCount).toBeGreaterThan(initialCount);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('withAuthZContent cache invalidation', () => {
|
||||
const testPerm = buildPermission(
|
||||
'read',
|
||||
'role:test-invalidation' as AuthZObject<'read'>,
|
||||
);
|
||||
// Callout displays permission as "relation:object" format
|
||||
const displayedPerm = 'read:role:test-invalidation';
|
||||
|
||||
function ContentComponent(): JSX.Element {
|
||||
return <div data-testid="protected-content">Protected Content</div>;
|
||||
}
|
||||
|
||||
function InvalidationTrigger({
|
||||
permission,
|
||||
onReady,
|
||||
}: {
|
||||
permission: string;
|
||||
onReady: (invalidate: () => Promise<void>) => void;
|
||||
}): null {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
React.useEffect(() => {
|
||||
onReady(async () => {
|
||||
// Reset query to initial state and trigger refetch (matches devtools behavior)
|
||||
await queryClient.resetQueries(['authz', permission]);
|
||||
});
|
||||
}, [queryClient, permission, onReady]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
it('re-renders from allowed to denied when cache is invalidated', async () => {
|
||||
let shouldGrant = true;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially allowed - should show content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Change server response to deny
|
||||
shouldGrant = false;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show denied callout
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Callout should show the denied permission
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
expect(screen.getByText(displayedPerm)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('re-renders from denied to allowed when cache is invalidated', async () => {
|
||||
let shouldGrant = false;
|
||||
let invalidateFn: (() => Promise<void>) | null = null;
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(authzMockResponse(payload, [shouldGrant])),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
const Guarded = withAuthZContent(ContentComponent, { checks: [testPerm] });
|
||||
|
||||
render(
|
||||
<>
|
||||
<Guarded />
|
||||
<InvalidationTrigger
|
||||
permission={testPerm}
|
||||
onReady={(fn): void => {
|
||||
invalidateFn = fn;
|
||||
}}
|
||||
/>
|
||||
</>,
|
||||
);
|
||||
|
||||
// Initially denied - should show callout
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByTestId('protected-content')).not.toBeInTheDocument();
|
||||
|
||||
// Change server response to allow
|
||||
shouldGrant = true;
|
||||
|
||||
// Invalidate cache
|
||||
await act(async () => {
|
||||
await invalidateFn?.();
|
||||
});
|
||||
|
||||
// Should now show protected content
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,13 +0,0 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuard } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export type { RouterContext, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZ<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuard, 'withAuthZ', Component, opts);
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { ComponentType, ReactElement, createElement, useMemo } from 'react';
|
||||
import {
|
||||
matchPath as reactRouterMatchPath,
|
||||
useLocation,
|
||||
useParams,
|
||||
} from 'react-router-dom';
|
||||
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
export type RouterContext = {
|
||||
/**
|
||||
* Route params from useParams (e.g. `/roles/:roleId` → `{ roleId: "r-1" }`)
|
||||
*/
|
||||
params: Record<string, string | undefined>;
|
||||
pathname: string;
|
||||
/**
|
||||
* Query params as URLSearchParams (use `.get('key')` to read)
|
||||
*/
|
||||
searchParams: URLSearchParams;
|
||||
/**
|
||||
* Extract params from pathname using a route pattern.
|
||||
* Returns null if pattern doesn't match.
|
||||
* @example router.matchPath<{ id: string }>('/edit/:id')?.id
|
||||
*/
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
) => Params | null;
|
||||
};
|
||||
|
||||
export type WithAuthZOptions<P> = {
|
||||
/**
|
||||
* Static checks, or a selector deriving them from props and router context.
|
||||
* Use router context to extract dynamic values from route params, pathname, or query params.
|
||||
* @example
|
||||
* // From route params
|
||||
* checks: (props, router) => [buildPermission('read', `role:${router.params.roleId}`)]
|
||||
* // From query params
|
||||
* checks: (props, router) => [buildPermission('read', `dashboard:${router.searchParams.get('id')}`)]
|
||||
* // From pathname matching
|
||||
* checks: (props, router) => {
|
||||
* const match = router.matchPath<{ id: string }>('/edit/:id');
|
||||
* return match ? [buildPermission('update', `role:${match.id}`)] : [];
|
||||
* }
|
||||
*/
|
||||
checks:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
fallback?: AuthZGuardProps['fallback'];
|
||||
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
|
||||
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
|
||||
};
|
||||
|
||||
function useStableParams(): Record<string, string | undefined> {
|
||||
const params = useParams();
|
||||
const paramsJson = JSON.stringify(params);
|
||||
return useMemo(() => JSON.parse(paramsJson), [paramsJson]);
|
||||
}
|
||||
|
||||
function useRouterContext(): RouterContext {
|
||||
const params = useStableParams();
|
||||
const { pathname, search } = useLocation();
|
||||
const searchParams = useMemo(() => new URLSearchParams(search), [search]);
|
||||
|
||||
return useMemo(
|
||||
(): RouterContext => ({
|
||||
params,
|
||||
pathname,
|
||||
searchParams,
|
||||
matchPath: <Params extends Record<string, string>>(
|
||||
pattern: string,
|
||||
): Params | null => {
|
||||
const match = reactRouterMatchPath<Params>(pathname, {
|
||||
path: pattern,
|
||||
exact: false,
|
||||
});
|
||||
return match?.params ?? null;
|
||||
},
|
||||
}),
|
||||
[params, pathname, searchParams],
|
||||
);
|
||||
}
|
||||
|
||||
export function createAuthZHOC<P extends object>(
|
||||
Guard: ComponentType<AuthZGuardProps>,
|
||||
hocName: string,
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
const { checks, ...guardProps } = opts;
|
||||
|
||||
function Wrapped(props: P): ReactElement | null {
|
||||
const router = useRouterContext();
|
||||
const resolvedChecks =
|
||||
typeof checks === 'function' ? checks(props, router) : checks;
|
||||
|
||||
return (
|
||||
<Guard checks={resolvedChecks} {...guardProps}>
|
||||
{createElement(Component, props)}
|
||||
</Guard>
|
||||
);
|
||||
}
|
||||
|
||||
Wrapped.displayName = `${hocName}(${
|
||||
Component.displayName || Component.name || 'Component'
|
||||
})`;
|
||||
|
||||
return Wrapped;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardContent } from 'lib/authz/components/AuthZGuard/AuthZGuardContent';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZContent<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardContent, 'withAuthZContent', Component, opts);
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { ComponentType } from 'react';
|
||||
import { AuthZGuardPage } from 'lib/authz/components/AuthZGuard/AuthZGuardPage';
|
||||
|
||||
import { createAuthZHOC, WithAuthZOptions } from './withAuthZ.utils';
|
||||
|
||||
export function withAuthZPage<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
return createAuthZHOC(AuthZGuardPage, 'withAuthZPage', Component, opts);
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
# AuthZ Test Utilities
|
||||
|
||||
Helpers for testing permission-gated components.
|
||||
|
||||
## File Naming
|
||||
|
||||
AuthZ tests live in `*.authz.test.tsx` files alongside other test files:
|
||||
|
||||
```
|
||||
ComponentName/
|
||||
├── ComponentName.tsx
|
||||
├── __tests__/
|
||||
│ ├── ComponentName.test.tsx # functional tests
|
||||
│ └── ComponentName.authz.test.tsx # permission tests
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
```tsx
|
||||
import { server } from 'mocks-server/server';
|
||||
import { setupAuthzAdmin, setupAuthzDenyAll } from 'lib/authz/utils/authz-test-utils';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
describe('ComponentName - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers(); // reset MSW handlers after each test
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('shows permission denied when read denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
render(<ComponentName />);
|
||||
|
||||
await expect(
|
||||
screen.findByText(/not authorized/i),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('renders content when permitted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<ComponentName />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('protected-content')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Key points:
|
||||
- Use `server.use()` at start of each test (not `beforeEach`) for explicit setup
|
||||
- Call `server.resetHandlers()` in `afterEach` to avoid test pollution
|
||||
- Use `waitFor` or `findBy*` queries since authz checks are async
|
||||
- Group tests by permission scenario: denied, granted, partial, loading
|
||||
|
||||
## MSW Handlers
|
||||
|
||||
Mock `/api/v1/authz/check` endpoint responses.
|
||||
|
||||
```tsx
|
||||
import { server } from 'mocks-server/server';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
setupAuthzDeny,
|
||||
setupAuthzAllow,
|
||||
setupAuthzGrantByPrefix,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
// Grant all permissions
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
// Deny all permissions
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
// Grant all except specific permissions
|
||||
server.use(setupAuthzDeny(RoleCreatePermission, RoleDeletePermission));
|
||||
|
||||
// Deny all except specific permissions
|
||||
server.use(setupAuthzAllow(RoleListPermission));
|
||||
|
||||
// Grant by relation prefix (e.g., grant read/delete, deny update)
|
||||
server.use(setupAuthzGrantByPrefix('read', 'delete'));
|
||||
```
|
||||
|
||||
## Custom Mock Response
|
||||
|
||||
For fine-grained control over responses.
|
||||
|
||||
```tsx
|
||||
import { rest } from 'msw';
|
||||
import { AUTHZ_CHECK_URL, authzMockResponse } from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = await req.json();
|
||||
// [true, false] = first permission granted, second denied
|
||||
return res(ctx.status(200), ctx.json(authzMockResponse(payload, [true, false])));
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
## Testing Loading State
|
||||
|
||||
Use `ctx.delay('infinite')` to hold response indefinitely:
|
||||
|
||||
```tsx
|
||||
it('shows skeleton while checking permissions', () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) =>
|
||||
res(ctx.delay('infinite')),
|
||||
),
|
||||
);
|
||||
|
||||
render(<ComponentName />);
|
||||
|
||||
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
|
||||
});
|
||||
```
|
||||
@@ -104,25 +104,6 @@ export function setupAuthzAllow(
|
||||
});
|
||||
}
|
||||
|
||||
/** Grants permissions that start with any of the given prefixes. */
|
||||
export function setupAuthzGrantByPrefix(...prefixes: string[]): RestHandler {
|
||||
return rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
const payload = (await req.json()) as AuthtypesTransactionDTO[];
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json(
|
||||
authzMockResponse(
|
||||
payload,
|
||||
payload.map((txn) => {
|
||||
const perm = gettableTransactionToPermission(txn);
|
||||
return prefixes.some((prefix) => perm.startsWith(prefix));
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function buildLicense(
|
||||
overrides?: Partial<LicenseResModel>,
|
||||
): LicenseResModel {
|
||||
|
||||
@@ -32,34 +32,6 @@
|
||||
cursor: help;
|
||||
}
|
||||
|
||||
.publicLink {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lockButton {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.lockButton:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex-shrink: 0;
|
||||
width: 1px;
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { KeyboardEvent } from 'react';
|
||||
import {
|
||||
Check,
|
||||
Globe,
|
||||
LockKeyhole,
|
||||
LockKeyholeOpen,
|
||||
SolidInfoCircle,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import TagBadge from 'components/TagBadge/TagBadge';
|
||||
import { Check, Globe, LockKeyhole, SolidInfoCircle, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { openInNewTab } from 'utils/navigation';
|
||||
|
||||
import styles from './DashboardInfo.module.scss';
|
||||
import { useVisibleTagCount } from './useVisibleTagCount';
|
||||
@@ -26,13 +18,7 @@ interface DashboardInfoProps {
|
||||
tags: string[];
|
||||
description: string;
|
||||
isPublicDashboard: boolean;
|
||||
/** Absolute URL of the public dashboard page; opened when the globe is clicked. */
|
||||
publicUrl: string;
|
||||
isDashboardLocked: boolean;
|
||||
/** Whether to render the lock toggle at all (hidden for never-locked dashboards). */
|
||||
showLockToggle: boolean;
|
||||
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
|
||||
onToggleLock?: () => void;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
@@ -47,10 +33,7 @@ function DashboardInfo({
|
||||
tags,
|
||||
description,
|
||||
isPublicDashboard,
|
||||
publicUrl,
|
||||
isDashboardLocked,
|
||||
showLockToggle,
|
||||
onToggleLock,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
@@ -68,17 +51,6 @@ function DashboardInfo({
|
||||
const visibleTags = needsOverflow ? tags.slice(0, visibleCount) : tags;
|
||||
const remainingTags = needsOverflow ? tags.slice(visibleCount) : [];
|
||||
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
: 'This dashboard is unlocked';
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>): void => {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
@@ -129,7 +101,7 @@ function DashboardInfo({
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<TooltipSimple title={title} disableHoverableContent>
|
||||
<TooltipSimple title={title}>
|
||||
<Typography.Text
|
||||
className={cx(styles.dashboardTitle, {
|
||||
[styles.dashboardTitleHover]: canEdit,
|
||||
@@ -143,7 +115,7 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{hasDescription && (
|
||||
<TooltipSimple title={description} disableHoverableContent>
|
||||
<TooltipSimple title={description}>
|
||||
<SolidInfoCircle
|
||||
className={styles.descriptionIcon}
|
||||
size={14}
|
||||
@@ -153,44 +125,14 @@ function DashboardInfo({
|
||||
)}
|
||||
|
||||
{isPublicDashboard && (
|
||||
<TooltipSimple
|
||||
title="This dashboard is publicly accessible. Click to open the public page."
|
||||
disableHoverableContent
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.publicLink}
|
||||
aria-label="Open public dashboard"
|
||||
testId="dashboard-public-link"
|
||||
onClick={(): void => openInNewTab(publicUrl)}
|
||||
>
|
||||
<Globe size={14} />
|
||||
</Button>
|
||||
<TooltipSimple title="This dashboard is publicly accessible">
|
||||
<Globe size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
{showLockToggle && (
|
||||
<TooltipSimple title={lockTooltip} disableHoverableContent>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.lockButton}
|
||||
aria-label={isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard'}
|
||||
testId="dashboard-lock"
|
||||
disabled={!onToggleLock}
|
||||
onClick={onToggleLock}
|
||||
>
|
||||
{isDashboardLocked ? (
|
||||
<LockKeyhole size={14} />
|
||||
) : (
|
||||
<LockKeyholeOpen size={14} />
|
||||
)}
|
||||
</Button>
|
||||
{isDashboardLocked && (
|
||||
<TooltipSimple title="This dashboard is locked">
|
||||
<LockKeyhole size={14} />
|
||||
</TooltipSimple>
|
||||
)}
|
||||
|
||||
@@ -203,13 +145,19 @@ function DashboardInfo({
|
||||
data-testid="dashboard-tags"
|
||||
>
|
||||
{visibleTags.map((tag) => (
|
||||
<TagBadge key={tag}>{tag}</TagBadge>
|
||||
<Badge key={tag} color="warning" variant="outline">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<TooltipSimple title={remainingTags.join(', ')}>
|
||||
<span data-testid="dashboard-tags-overflow">
|
||||
<TagBadge>+{remainingTags.length}</TagBadge>
|
||||
</span>
|
||||
<Badge
|
||||
color="warning"
|
||||
variant="outline"
|
||||
data-testid="dashboard-tags-overflow"
|
||||
>
|
||||
+{remainingTags.length}
|
||||
</Badge>
|
||||
</TooltipSimple>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
@@ -15,12 +15,9 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
import { useOptimisticPatch } from '../hooks/useOptimisticPatch';
|
||||
import { usePublicDashboardMeta } from '../DashboardSettings/PublicDashboard/usePublicDashboardMeta';
|
||||
import PanelTypeSelectionModal from '../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/PanelTypeSelectionModal';
|
||||
import DashboardActions from './DashboardActions/DashboardActions';
|
||||
import DashboardInfo from './DashboardInfo/DashboardInfo';
|
||||
@@ -39,15 +36,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle, refetch } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
const [showLockToggle, setShowLockToggle] = useState(!!dashboard.locked);
|
||||
useEffect(() => {
|
||||
setIsDashboardLocked(!!dashboard.locked);
|
||||
setShowLockToggle(!!dashboard.locked);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.id]);
|
||||
const isDashboardLocked = !!dashboard.locked;
|
||||
|
||||
const title = dashboard.spec.display.name;
|
||||
const description = dashboard.spec.display.description ?? '';
|
||||
@@ -69,36 +58,20 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
|
||||
// dashboards are never toggleable.
|
||||
const canToggleLock =
|
||||
(isAuthor || user.role === USER_ROLES.ADMIN) &&
|
||||
dashboard.createdBy !== 'integration';
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
const handleLockDashboardToggle = useCallback(async (): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
try {
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
if (isDashboardLocked) {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
} else {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
}
|
||||
refetch();
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
}, [id, isDashboardLocked, refetch, showErrorModal]);
|
||||
@@ -146,11 +119,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
image={image}
|
||||
tags={tags}
|
||||
description={description}
|
||||
isPublicDashboard={isPublic}
|
||||
publicUrl={publicUrl}
|
||||
isPublicDashboard={false}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={canToggleLock ? handleLockDashboardToggle : undefined}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
|
||||
@@ -3,10 +3,10 @@ import type { TagtypesPostableTagDTO } from 'api/generated/services/sigNoz.schem
|
||||
export { Base64Icons } from 'container/DashboardContainer/DashboardSettings/General/utils';
|
||||
export { parseKeyValueTag } from 'components/TagKeyValueInput/utils';
|
||||
|
||||
// The tag editor is strictly key:value, so always render both sides — a
|
||||
// `key:key` tag stays `key:key` rather than collapsing to a bare `key`.
|
||||
// tag UX, a string with no ':' is round-tripped as `{key: x, value: x}` and
|
||||
// collapsed back to just `x` for display.
|
||||
export function tagsToStrings(tags: TagtypesPostableTagDTO[]): string[] {
|
||||
return tags.map((t) => `${t.key}:${t.value}`);
|
||||
return tags.map((t) => (t.key === t.value ? t.key : `${t.key}:${t.value}`));
|
||||
}
|
||||
|
||||
export function stringsToTags(tagStrings: string[]): TagtypesPostableTagDTO[] {
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
padding-top: 2px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import ConfigSelect from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from './PanelTypeSwitcher.module.scss';
|
||||
import { usePanelTypeSelectItems } from './usePanelTypeSelectItems';
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface PanelTypeSwitcherProps {
|
||||
/** The current panel kind (selected value). */
|
||||
panelKind: PanelKind;
|
||||
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). */
|
||||
queryType: EQueryType;
|
||||
/** Active query type — a kind that can't be authored in it is disabled (e.g. List is Query-Builder-only, so PromQL/ClickHouse disable it). Defaults to Query Builder. */
|
||||
queryType?: EQueryType;
|
||||
/** Panel's current signal — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
onChange: (kind: PanelKind) => void;
|
||||
@@ -30,7 +31,22 @@ function PanelTypeSwitcher({
|
||||
signal,
|
||||
onChange,
|
||||
}: PanelTypeSwitcherProps): JSX.Element {
|
||||
const items = usePanelTypeSelectItems({ queryType, signal });
|
||||
const items = PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType: queryType ?? EQueryType.QUERY_BUILDER,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
|
||||
@@ -49,11 +49,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
it('fires onChange with the chosen plugin kind', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
<PanelTypeSwitcher panelKind="signoz/TimeSeriesPanel" onChange={onChange} />,
|
||||
);
|
||||
|
||||
openDropdown();
|
||||
@@ -66,7 +62,6 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
@@ -82,7 +77,6 @@ describe('PanelTypeSwitcher', () => {
|
||||
render(
|
||||
<PanelTypeSwitcher
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
@@ -1,48 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { PanelKind } from '../../../Panels/types/panelKind';
|
||||
import { PANEL_TYPES } from '../../../PanelsAndSectionsLayout/Panel/PanelTypeSelectionModal/constants';
|
||||
import type { ConfigSelectItem } from '../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import { getPanelTypeDisabledReason } from './utils';
|
||||
|
||||
interface UsePanelTypeSelectItemsArgs {
|
||||
/** Active query type — a kind that can't be authored in it is disabled. */
|
||||
queryType: EQueryType;
|
||||
/** Current datasource — also gates the disabled rule (List needs logs/traces, not metrics). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visualization-kind options for a `ConfigSelect`, each disabled (with a reason
|
||||
* tooltip) when the active query type or signal is incompatible — resolved through
|
||||
* the capabilities guard. Shared by the editor's `PanelTypeSwitcher` and the View
|
||||
* modal's header so the two selectors apply the same rule and can't drift.
|
||||
*/
|
||||
export function usePanelTypeSelectItems({
|
||||
queryType,
|
||||
signal,
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
};
|
||||
}),
|
||||
[queryType, signal],
|
||||
);
|
||||
}
|
||||
@@ -1,259 +0,0 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DialogWrapper } from '@signozhq/ui/dialog';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { UrlParam, VariableItem } from './types';
|
||||
import {
|
||||
getUrlParams,
|
||||
insertVariableAtCursor,
|
||||
isValidContextLinkUrl,
|
||||
updateUrlWithParams,
|
||||
} from './utils';
|
||||
import VariablesPopover from './VariablesPopover';
|
||||
|
||||
import styles from './ContextLinksSection.module.scss';
|
||||
|
||||
interface ContextLinkDialogProps {
|
||||
open: boolean;
|
||||
/** The link being edited, or null when adding a new one. */
|
||||
initialLink: DashboardLinkDTO | null;
|
||||
variables: VariableItem[];
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSave: (link: DashboardLinkDTO) => void;
|
||||
}
|
||||
|
||||
const URL_ERROR = 'URL must start with http(s)://, /, or {{variable}}/';
|
||||
|
||||
const cursorOf = (e: { target: EventTarget }): number =>
|
||||
(e.target as HTMLInputElement).selectionStart ?? 0;
|
||||
|
||||
/**
|
||||
* Modal editor for a single context link (V1 parity): label + URL with `{{variable}}`
|
||||
* autocomplete + validation, a key/value URL-parameters editor, and an "open in new tab"
|
||||
* toggle. The URL is the source of truth; parameter rows are its query string projected
|
||||
* out (blank-key rows live only in local state until they get a key). Save is gated on a
|
||||
* non-empty, well-formed URL. `renderVariables` is set so consumers interpolate the URL.
|
||||
*/
|
||||
function ContextLinkDialog({
|
||||
open,
|
||||
initialLink,
|
||||
variables,
|
||||
onOpenChange,
|
||||
onSave,
|
||||
}: ContextLinkDialogProps): JSX.Element {
|
||||
const [name, setName] = useState('');
|
||||
const [url, setUrlState] = useState('');
|
||||
const [targetBlank, setTargetBlank] = useState(true);
|
||||
const [params, setParams] = useState<UrlParam[]>([]);
|
||||
|
||||
// Seed the draft each time the dialog opens for a (possibly different) link.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName(initialLink?.name ?? '');
|
||||
setUrlState(initialLink?.url ?? '');
|
||||
setTargetBlank(initialLink?.targetBlank ?? true);
|
||||
setParams(getUrlParams(initialLink?.url ?? ''));
|
||||
}
|
||||
}, [open, initialLink]);
|
||||
|
||||
const setUrl = (next: string): void => {
|
||||
setUrlState(next);
|
||||
setParams(getUrlParams(next));
|
||||
};
|
||||
|
||||
const applyParams = (next: UrlParam[]): void => {
|
||||
setParams(next);
|
||||
setUrlState(updateUrlWithParams(url, next));
|
||||
};
|
||||
|
||||
const patchParam = (index: number, patch: Partial<UrlParam>): void =>
|
||||
applyParams(params.map((p, i) => (i === index ? { ...p, ...patch } : p)));
|
||||
|
||||
const addParam = (): void => {
|
||||
const last = params[params.length - 1];
|
||||
if (!last || last.key.trim() || last.value.trim()) {
|
||||
setParams([...params, { key: '', value: '' }]);
|
||||
}
|
||||
};
|
||||
|
||||
const urlInvalid = !isValidContextLinkUrl(url);
|
||||
const canSave = !!url.trim() && !urlInvalid;
|
||||
|
||||
const handleSave = (): void => {
|
||||
if (canSave) {
|
||||
onSave({ name, url, targetBlank, renderVariables: true });
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DialogWrapper
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
title={initialLink ? 'Edit context link' : 'Add a context link'}
|
||||
width="wide"
|
||||
testId="context-link-dialog"
|
||||
footer={
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
data-testid="context-link-cancel"
|
||||
onClick={(): void => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={!canSave}
|
||||
data-testid="context-link-save"
|
||||
onClick={handleSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</>
|
||||
}
|
||||
>
|
||||
<div className={styles.form}>
|
||||
<div className={styles.formField}>
|
||||
<Typography.Text className={styles.formLabel}>Label</Typography.Text>
|
||||
<Input
|
||||
data-testid="context-link-label"
|
||||
placeholder="View trace details: {{_traceId}}"
|
||||
value={name}
|
||||
onChange={(e): void => setName(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formField}>
|
||||
<Typography.Text className={styles.formLabel}>
|
||||
URL <span className={styles.required}>*</span>
|
||||
</Typography.Text>
|
||||
<VariablesPopover
|
||||
variables={variables}
|
||||
onVariableSelect={(token, cursor): void =>
|
||||
setUrl(insertVariableAtCursor(url, token, cursor))
|
||||
}
|
||||
>
|
||||
{({ setIsOpen, setCursorPosition }): JSX.Element => (
|
||||
<Input
|
||||
data-testid="context-link-url"
|
||||
placeholder="https://… or /path?var={{variable}}"
|
||||
value={url}
|
||||
status={urlInvalid ? 'error' : undefined}
|
||||
autoComplete="off"
|
||||
onChange={(e): void => {
|
||||
setCursorPosition(e.target.selectionStart ?? 0);
|
||||
setUrl(e.target.value);
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onClick={(e): void => setCursorPosition(cursorOf(e))}
|
||||
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
|
||||
/>
|
||||
)}
|
||||
</VariablesPopover>
|
||||
{urlInvalid && (
|
||||
<Typography.Text
|
||||
className={styles.urlError}
|
||||
data-testid="context-link-url-error"
|
||||
>
|
||||
{URL_ERROR}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{params.length > 0 && (
|
||||
<div className={styles.formField}>
|
||||
<Typography.Text className={styles.formLabel}>
|
||||
URL parameters
|
||||
</Typography.Text>
|
||||
<div className={styles.params}>
|
||||
{params.map((param, index) => (
|
||||
<div
|
||||
className={styles.paramRow}
|
||||
// Parameter rows have no stable id; index is the row identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
>
|
||||
<Input
|
||||
data-testid={`context-link-param-key-${index}`}
|
||||
placeholder="Key"
|
||||
value={param.key}
|
||||
onChange={(e): void => patchParam(index, { key: e.target.value })}
|
||||
/>
|
||||
<VariablesPopover
|
||||
variables={variables}
|
||||
onVariableSelect={(token, cursor): void =>
|
||||
patchParam(index, {
|
||||
value: insertVariableAtCursor(param.value, token, cursor),
|
||||
})
|
||||
}
|
||||
>
|
||||
{({ setIsOpen, setCursorPosition }): JSX.Element => (
|
||||
<Input
|
||||
data-testid={`context-link-param-value-${index}`}
|
||||
placeholder="Value"
|
||||
value={param.value}
|
||||
onChange={(e): void => {
|
||||
setCursorPosition(e.target.selectionStart ?? 0);
|
||||
patchParam(index, { value: e.target.value });
|
||||
}}
|
||||
onFocus={(): void => setIsOpen(true)}
|
||||
onClick={(e): void => setCursorPosition(cursorOf(e))}
|
||||
onKeyUp={(e): void => setCursorPosition(cursorOf(e))}
|
||||
/>
|
||||
)}
|
||||
</VariablesPopover>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove parameter ${index + 1}`}
|
||||
data-testid={`context-link-param-remove-${index}`}
|
||||
onClick={(): void =>
|
||||
applyParams(params.filter((_, i) => i !== index))
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={12} />}
|
||||
data-testid="context-link-add-param"
|
||||
onClick={addParam}
|
||||
>
|
||||
Add URL parameter
|
||||
</Button>
|
||||
|
||||
<div className={styles.newTab}>
|
||||
<Switch
|
||||
testId="context-link-newtab"
|
||||
value={targetBlank}
|
||||
onChange={setTargetBlank}
|
||||
/>
|
||||
<Typography.Text className={styles.newTabLabel}>
|
||||
Open in new tab
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</div>
|
||||
</DialogWrapper>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextLinkDialog;
|
||||
@@ -1,62 +0,0 @@
|
||||
import { Pencil, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import styles from './ContextLinksSection.module.scss';
|
||||
|
||||
interface ContextLinkListItemProps {
|
||||
link: DashboardLinkDTO;
|
||||
index: number;
|
||||
onEdit: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/** A saved context link in the section list: its label + URL, with edit / delete actions. */
|
||||
function ContextLinkListItem({
|
||||
link,
|
||||
index,
|
||||
onEdit,
|
||||
onRemove,
|
||||
}: ContextLinkListItemProps): JSX.Element {
|
||||
const label = link.name?.trim() || link.url || 'Untitled link';
|
||||
|
||||
return (
|
||||
<div className={styles.listItem} data-testid={`context-link-item-${index}`}>
|
||||
<div className={styles.listItemText}>
|
||||
<Typography.Text className={styles.listItemLabel}>{label}</Typography.Text>
|
||||
{!!link.url && (
|
||||
<Typography.Text className={styles.listItemUrl}>
|
||||
{link.url}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<div className={styles.listItemActions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label={`Edit link ${index + 1}`}
|
||||
data-testid={`context-link-edit-${index}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove link ${index + 1}`}
|
||||
data-testid={`context-link-remove-${index}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextLinkListItem;
|
||||
@@ -1,99 +1,32 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* --- Saved-link list item --- */
|
||||
.listItem {
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.listItemText {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.listItemLabel,
|
||||
.listItemUrl {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.listItemLabel {
|
||||
font-size: 13px;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.listItemUrl {
|
||||
font-size: 11px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.listItemActions {
|
||||
.rowFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* --- Dialog form --- */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.formField {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.formLabel {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.required {
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.urlError {
|
||||
font-size: 11px;
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
|
||||
.params {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.paramRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.newTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newTabLabel {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
@@ -1,63 +1,83 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ContextLinkDialog from './ContextLinkDialog';
|
||||
import ContextLinkListItem from './ContextLinkListItem';
|
||||
import { useContextLinkVariables } from './useContextLinkVariables';
|
||||
|
||||
import styles from './ContextLinksSection.module.scss';
|
||||
|
||||
/**
|
||||
* Edits the panel's context links (`spec.links`) as a list of saved links, each added or
|
||||
* edited through a modal ({@link ContextLinkDialog}) that carries V1's full authoring UX
|
||||
* (variable autocomplete, URL-parameters editor, validation).
|
||||
* Edits the panel's context links (`spec.links`): a list of label + URL rows with an
|
||||
* "open in new tab" toggle, plus add/remove. Atomic section — no per-kind sub-controls.
|
||||
* URLs may reference dashboard/query variables; that interpolation is resolved at render
|
||||
* time, so this editor just captures the raw strings.
|
||||
*/
|
||||
function ContextLinksSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.ContextLinks>): JSX.Element {
|
||||
const links = value ?? [];
|
||||
const variables = useContextLinkVariables();
|
||||
|
||||
// `index === null` while adding a new link; a number while editing an existing one.
|
||||
const [dialog, setDialog] = useState<{ open: boolean; index: number | null }>({
|
||||
open: false,
|
||||
index: null,
|
||||
});
|
||||
const updateAt = (index: number, patch: Partial<DashboardLinkDTO>): void =>
|
||||
onChange(
|
||||
links.map((link, i) => (i === index ? { ...link, ...patch } : link)),
|
||||
);
|
||||
|
||||
const addLink = (): void =>
|
||||
onChange([...links, { name: '', url: '', targetBlank: true }]);
|
||||
|
||||
const removeAt = (index: number): void =>
|
||||
onChange(links.filter((_, i) => i !== index));
|
||||
|
||||
const handleSave = (link: DashboardLinkDTO): void => {
|
||||
onChange(
|
||||
dialog.index === null
|
||||
? [...links, link]
|
||||
: links.map((existing, i) => (i === dialog.index ? link : existing)),
|
||||
);
|
||||
setDialog((d) => ({ ...d, open: false }));
|
||||
};
|
||||
|
||||
const editingLink =
|
||||
dialog.index !== null ? (links[dialog.index] ?? null) : null;
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{links.map((link, index) => (
|
||||
<ContextLinkListItem
|
||||
// Links have no stable id on the wire; index is the row identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
link={link}
|
||||
index={index}
|
||||
onEdit={(): void => setDialog({ open: true, index })}
|
||||
onRemove={(): void => removeAt(index)}
|
||||
/>
|
||||
// Links have no stable id on the wire; index is the row identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className={styles.row} key={index}>
|
||||
<Input
|
||||
data-testid={`context-link-label-${index}`}
|
||||
placeholder="Label"
|
||||
value={link.name ?? ''}
|
||||
onChange={(e): void => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
data-testid={`context-link-url-${index}`}
|
||||
placeholder="https://… or /path?var=$variable"
|
||||
value={link.url ?? ''}
|
||||
onChange={(e): void => updateAt(index, { url: e.target.value })}
|
||||
/>
|
||||
<div className={styles.rowFooter}>
|
||||
<div className={styles.newTab}>
|
||||
<Switch
|
||||
testId={`context-link-newtab-${index}`}
|
||||
value={link.targetBlank ?? false}
|
||||
onChange={(checked: boolean): void =>
|
||||
updateAt(index, { targetBlank: checked })
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.newTabLabel}>
|
||||
Open in new tab
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove link ${index + 1}`}
|
||||
data-testid={`context-link-remove-${index}`}
|
||||
onClick={(): void => removeAt(index)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
@@ -66,18 +86,10 @@ function ContextLinksSection({
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
data-testid="panel-editor-v2-add-link"
|
||||
onClick={(): void => setDialog({ open: true, index: null })}
|
||||
onClick={addLink}
|
||||
>
|
||||
Add Context Link
|
||||
Add link
|
||||
</Button>
|
||||
|
||||
<ContextLinkDialog
|
||||
open={dialog.open}
|
||||
initialLink={editingLink}
|
||||
variables={variables}
|
||||
onOpenChange={(open): void => setDialog((d) => ({ ...d, open }))}
|
||||
onSave={handleSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,64 +0,0 @@
|
||||
.container {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.anchor {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.content {
|
||||
z-index: 1100;
|
||||
padding: 4px 0;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
min-width: var(--radix-popover-trigger-width);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 8px 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
font-size: 12px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.item {
|
||||
--button-display: flex;
|
||||
--button-justify-content: flex-start;
|
||||
--button-height: auto;
|
||||
--button-padding: 8px 12px;
|
||||
--button-border-radius: 0;
|
||||
--button-font-size: 13px;
|
||||
--button-line-height: 1.4;
|
||||
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.name,
|
||||
.source {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.name {
|
||||
flex: 1 1 auto;
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.source {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 12px;
|
||||
flex: 0 1 auto;
|
||||
}
|
||||
@@ -1,117 +0,0 @@
|
||||
// Uses Popover (not DropdownMenu): DropdownMenuTrigger preventDefaults pointerdown,
|
||||
// which steals input focus and dismisses on every keystroke. PopoverAnchor is a passive
|
||||
// positioning element that leaves the wrapped input fully interactive.
|
||||
import { ReactNode, useRef, useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableItem } from './types';
|
||||
|
||||
import styles from './VariablesPopover.module.scss';
|
||||
|
||||
interface VariablesPopoverRenderProps {
|
||||
setIsOpen: (open: boolean) => void;
|
||||
setCursorPosition: (position: number | null) => void;
|
||||
}
|
||||
|
||||
interface VariablesPopoverProps {
|
||||
variables: VariableItem[];
|
||||
/** Called with the braces-wrapped token (e.g. `{{env}}`) and the tracked cursor offset. */
|
||||
onVariableSelect: (token: string, cursorPosition?: number) => void;
|
||||
children: (props: VariablesPopoverRenderProps) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Autocomplete for context-link variables. Wraps an input (via the render-prop) and
|
||||
* shows the available `{{variables}}` grouped by source; picking one inserts it at the
|
||||
* tracked cursor. The consumer drives `setIsOpen` (on focus) and `setCursorPosition`.
|
||||
*/
|
||||
function VariablesPopover({
|
||||
variables,
|
||||
onVariableSelect,
|
||||
children,
|
||||
}: VariablesPopoverProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [cursorPosition, setCursorPosition] = useState<number | null>(null);
|
||||
const anchorRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const handleOpenChange = (open: boolean): void => {
|
||||
// Accept "close" events (outside-click, Esc) but ignore opens — opening is driven
|
||||
// by the input's onFocus in the consumer.
|
||||
if (!open) {
|
||||
setIsOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Keep the popover open while the pointer/focus is inside the anchored input.
|
||||
const keepOpenIfInsideAnchor = (event: {
|
||||
target: EventTarget | null;
|
||||
}): void => {
|
||||
const target = event.target as Node | null;
|
||||
if (
|
||||
target &&
|
||||
anchorRef.current?.contains(target) &&
|
||||
'preventDefault' in event
|
||||
) {
|
||||
(event as Event).preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<Popover open={isOpen} onOpenChange={handleOpenChange} modal={false}>
|
||||
<PopoverAnchor asChild>
|
||||
<div className={styles.anchor} ref={anchorRef}>
|
||||
{children({ setIsOpen, setCursorPosition })}
|
||||
</div>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
sideOffset={4}
|
||||
// Render inside the anchor (not a body portal): the editor lives in a modal
|
||||
// DialogWrapper, and radix's modal sets pointer-events:none on everything
|
||||
// portalled outside it, which would make the suggestions unclickable.
|
||||
withPortal={false}
|
||||
className={styles.content}
|
||||
onOpenAutoFocus={(e): void => e.preventDefault()}
|
||||
onCloseAutoFocus={(e): void => e.preventDefault()}
|
||||
onInteractOutside={keepOpenIfInsideAnchor}
|
||||
onFocusOutside={keepOpenIfInsideAnchor}
|
||||
>
|
||||
{variables.length === 0 ? (
|
||||
<div className={styles.empty}>No variables available</div>
|
||||
) : (
|
||||
variables.map((v) => (
|
||||
<Button
|
||||
key={`${v.source}-${v.name}`}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="md"
|
||||
className={styles.item}
|
||||
aria-label={`Insert {{${v.name}}}`}
|
||||
testId={`context-link-variable-${v.name}`}
|
||||
// Prevent the input from losing focus when clicking an item.
|
||||
onMouseDown={(e): void => e.preventDefault()}
|
||||
onClick={(): void => {
|
||||
onVariableSelect(`{{${v.name}}}`, cursorPosition ?? undefined);
|
||||
setIsOpen(false);
|
||||
}}
|
||||
>
|
||||
<div className={styles.row}>
|
||||
<Typography.Text
|
||||
className={styles.name}
|
||||
>{`{{${v.name}}}`}</Typography.Text>
|
||||
<Typography.Text className={styles.source}>{v.source}</Typography.Text>
|
||||
</div>
|
||||
</Button>
|
||||
))
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariablesPopover;
|
||||
@@ -3,92 +3,47 @@ import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ContextLinksSection from '../ContextLinksSection';
|
||||
|
||||
// The variable-source hook reads the query-builder provider + store; stub it so the
|
||||
// editor can be exercised in isolation (its own suite covers the sourcing logic).
|
||||
jest.mock('../useContextLinkVariables', () => ({
|
||||
useContextLinkVariables: (): unknown => [
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'env', source: 'Dashboard variable' },
|
||||
],
|
||||
}));
|
||||
|
||||
const LINKS: DashboardLinkDTO[] = [
|
||||
{ name: 'Docs', url: 'https://signoz.io', targetBlank: true },
|
||||
];
|
||||
|
||||
const lastCall = (fn: jest.Mock): DashboardLinkDTO[] =>
|
||||
fn.mock.calls[fn.mock.calls.length - 1][0];
|
||||
|
||||
describe('ContextLinksSection', () => {
|
||||
it('renders only the add button when there are no links', () => {
|
||||
render(<ContextLinksSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-add-link')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('context-link-item-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('context-link-label-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders existing links as list items showing the label', () => {
|
||||
render(<ContextLinksSection value={LINKS} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('context-link-item-0')).toHaveTextContent('Docs');
|
||||
// The editor is a modal — no inline fields until it's opened.
|
||||
expect(screen.queryByTestId('context-link-url')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a link through the dialog (Save gated on a valid URL)', async () => {
|
||||
it('appends a blank link (open-in-new-tab on) when Add link is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={[]} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
const save = await screen.findByTestId('context-link-save');
|
||||
expect(save).toBeDisabled();
|
||||
|
||||
fireEvent.change(screen.getByTestId('context-link-url'), {
|
||||
target: { value: 'https://signoz.io' },
|
||||
});
|
||||
fireEvent.change(screen.getByTestId('context-link-label'), {
|
||||
target: { value: 'Docs' },
|
||||
});
|
||||
expect(save).not.toBeDisabled();
|
||||
|
||||
fireEvent.click(save);
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
name: 'Docs',
|
||||
url: 'https://signoz.io',
|
||||
targetBlank: true,
|
||||
renderVariables: true,
|
||||
},
|
||||
{ name: '', url: '', targetBlank: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('edits an existing link through the dialog', async () => {
|
||||
it('renders existing links and edits a label through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('context-link-edit-0'));
|
||||
|
||||
const label = await screen.findByTestId('context-link-label');
|
||||
expect(label).toHaveValue('Docs');
|
||||
expect(screen.getByTestId('context-link-url')).toHaveValue(
|
||||
expect(screen.getByTestId('context-link-label-0')).toHaveValue('Docs');
|
||||
expect(screen.getByTestId('context-link-url-0')).toHaveValue(
|
||||
'https://signoz.io',
|
||||
);
|
||||
|
||||
fireEvent.change(label, { target: { value: 'Runbook' } });
|
||||
fireEvent.click(screen.getByTestId('context-link-save'));
|
||||
|
||||
fireEvent.change(screen.getByTestId('context-link-label-0'), {
|
||||
target: { value: 'Runbook' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
name: 'Runbook',
|
||||
url: 'https://signoz.io',
|
||||
targetBlank: true,
|
||||
renderVariables: true,
|
||||
},
|
||||
{ name: 'Runbook', url: 'https://signoz.io', targetBlank: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a link from the list', () => {
|
||||
it('removes a link through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
|
||||
|
||||
@@ -96,52 +51,4 @@ describe('ContextLinksSection', () => {
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('shows a validation error only for a malformed URL', async () => {
|
||||
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
const urlInput = await screen.findByTestId('context-link-url');
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: 'not-a-url' } });
|
||||
expect(screen.getByTestId('context-link-url-error')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('context-link-save')).toBeDisabled();
|
||||
|
||||
fireEvent.change(urlInput, { target: { value: '/valid/path' } });
|
||||
expect(
|
||||
screen.queryByTestId('context-link-url-error'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('adds a URL parameter and writes it into the URL query string', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={[]} onChange={onChange} />);
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
fireEvent.change(await screen.findByTestId('context-link-url'), {
|
||||
target: { value: '/logs' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('context-link-add-param'));
|
||||
fireEvent.change(screen.getByTestId('context-link-param-key-0'), {
|
||||
target: { value: 'env' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('context-link-save'));
|
||||
|
||||
expect(lastCall(onChange)).toStrictEqual([
|
||||
{ name: '', url: '/logs?env=', targetBlank: true, renderVariables: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('inserts a {{variable}} into the URL from the autocomplete popover', async () => {
|
||||
render(<ContextLinksSection value={[]} onChange={jest.fn()} />);
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
const urlInput = await screen.findByTestId('context-link-url');
|
||||
fireEvent.focus(urlInput);
|
||||
fireEvent.click(
|
||||
await screen.findByTestId('context-link-variable-timestamp_start'),
|
||||
);
|
||||
|
||||
expect(urlInput).toHaveValue('{{timestamp_start}}');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useContextLinkVariables } from '../useContextLinkVariables';
|
||||
|
||||
const mockUseGetDashboardV2 = jest.fn();
|
||||
const mockUseQueryBuilder = jest.fn();
|
||||
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/store/useDashboardStore',
|
||||
() => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { dashboardId: string }) => unknown,
|
||||
): unknown => selector({ dashboardId: 'dash-1' }),
|
||||
}),
|
||||
);
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): unknown => mockUseQueryBuilder(),
|
||||
}));
|
||||
|
||||
jest.mock('api/generated/services/dashboard', () => ({
|
||||
useGetDashboardV2: (): unknown => mockUseGetDashboardV2(),
|
||||
}));
|
||||
|
||||
// dtoToFormModel is exercised by its own suite; here we only need it to surface a name.
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters',
|
||||
() => ({
|
||||
dtoToFormModel: (dto: { name: string }): { name: string } => ({
|
||||
name: dto.name,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
describe('useContextLinkVariables', () => {
|
||||
beforeEach(() => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{ groupBy: [{ key: 'service.name' }, { key: 'env' }] },
|
||||
// Duplicate across queries — must dedupe.
|
||||
{ groupBy: [{ key: 'service.name' }] },
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
mockUseGetDashboardV2.mockReturnValue({
|
||||
data: {
|
||||
data: { spec: { variables: [{ name: 'region' }, { name: 'tier' }] } },
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('orders globals, then _prefixed query fields (deduped), then dashboard vars', () => {
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
expect(result.current).toStrictEqual([
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'timestamp_end', source: 'Global timestamp' },
|
||||
{ name: '_service.name', source: 'Query variable' },
|
||||
{ name: '_env', source: 'Query variable' },
|
||||
{ name: 'region', source: 'Dashboard variable' },
|
||||
{ name: 'tier', source: 'Dashboard variable' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('still returns the global timestamps when there are no queries or variables', () => {
|
||||
mockUseQueryBuilder.mockReturnValue({
|
||||
currentQuery: { builder: { queryData: [] } },
|
||||
});
|
||||
// Loaded dashboard with no variables — useDashboardFetchRequired guarantees the
|
||||
// dashboard is present, so the empty case is an empty spec, not `undefined`.
|
||||
mockUseGetDashboardV2.mockReturnValue({ data: { data: { spec: {} } } });
|
||||
|
||||
const { result } = renderHook(() => useContextLinkVariables());
|
||||
|
||||
expect(result.current).toStrictEqual([
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'timestamp_end', source: 'Global timestamp' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,68 +0,0 @@
|
||||
import {
|
||||
getUrlParams,
|
||||
insertVariableAtCursor,
|
||||
isValidContextLinkUrl,
|
||||
updateUrlWithParams,
|
||||
} from '../utils';
|
||||
|
||||
describe('ContextLinksSection utils', () => {
|
||||
describe('isValidContextLinkUrl', () => {
|
||||
it.each([
|
||||
['', true],
|
||||
['https://signoz.io', true],
|
||||
['http://localhost:3301/trace', true],
|
||||
['/trace/{{_traceId}}', true],
|
||||
['{{host}}/trace', true],
|
||||
['trace/{{_traceId}}', false],
|
||||
['ftp://signoz.io', false],
|
||||
])('validates %p as %p', (url, expected) => {
|
||||
expect(isValidContextLinkUrl(url)).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertVariableAtCursor', () => {
|
||||
it('inserts at the cursor position', () => {
|
||||
expect(insertVariableAtCursor('/trace/', '{{id}}', 7)).toBe('/trace/{{id}}');
|
||||
expect(insertVariableAtCursor('ab', '{{x}}', 1)).toBe('a{{x}}b');
|
||||
});
|
||||
|
||||
it('appends when no cursor position is given', () => {
|
||||
expect(insertVariableAtCursor('/trace/', '{{id}}')).toBe('/trace/{{id}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUrlParams', () => {
|
||||
it('returns [] when there is no query string', () => {
|
||||
expect(getUrlParams('/trace/{{id}}')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('parses and decodes key/value pairs', () => {
|
||||
expect(getUrlParams('/logs?service={{svc}}&env=prod')).toStrictEqual([
|
||||
{ key: 'service', value: '{{svc}}' },
|
||||
{ key: 'env', value: 'prod' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('double-decodes over-encoded values', () => {
|
||||
// %257B%257B == encodeURIComponent('%7B%7B') == encodeURIComponent('{{')
|
||||
expect(getUrlParams('/logs?q=%257B%257Bx%257D%257D')).toStrictEqual([
|
||||
{ key: 'q', value: '{{x}}' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('updateUrlWithParams', () => {
|
||||
it('rebuilds the query string and drops empty keys', () => {
|
||||
expect(
|
||||
updateUrlWithParams('/logs?old=1', [
|
||||
{ key: 'service', value: '{{svc}}' },
|
||||
{ key: '', value: 'ignored' },
|
||||
]),
|
||||
).toBe('/logs?service=%7B%7Bsvc%7D%7D');
|
||||
});
|
||||
|
||||
it('drops the query string entirely when no valid params remain', () => {
|
||||
expect(updateUrlWithParams('/logs?a=b', [])).toBe('/logs');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,29 +0,0 @@
|
||||
/**
|
||||
* Where a context-link variable comes from — shown as the right-hand label in the popover.
|
||||
*
|
||||
* - `Global timestamp` — the dashboard's selected time range: `timestamp_start` /
|
||||
* `timestamp_end`. Always available, independent of the panel or its queries.
|
||||
* - `Query variable` — a `groupBy` field from the panel's own queries, `_`-prefixed
|
||||
* (e.g. `_service_name`). Resolved at click time from the data point the user clicked,
|
||||
* so it reflects the current query definition and changes as the queries are edited.
|
||||
* - `Dashboard variable` — a user-defined dashboard variable from Dashboard Settings
|
||||
* (e.g. `env`, `region`). Shared across every panel and driven by the dashboard's
|
||||
* variable selectors rather than by an individual query.
|
||||
*/
|
||||
export type VariableSource =
|
||||
| 'Global timestamp'
|
||||
| 'Query variable'
|
||||
| 'Dashboard variable';
|
||||
|
||||
/** One entry in the context-link variable autocomplete. */
|
||||
export interface VariableItem {
|
||||
/** Bare variable name without braces, e.g. `timestamp_start`, `_service_name`, `env`. */
|
||||
name: string;
|
||||
source: VariableSource;
|
||||
}
|
||||
|
||||
/** A single decoded key/value pair parsed from (or written back to) a URL query string. */
|
||||
export interface UrlParam {
|
||||
key: string;
|
||||
value: string;
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { dtoToFormModel } from 'pages/DashboardPageV2/DashboardContainer/DashboardSettings/Variables/variableAdapters';
|
||||
import { useDashboardFetchRequired } from 'pages/DashboardPageV2/DashboardContainer/hooks/useDashboardFetchRequired';
|
||||
|
||||
import type { VariableItem } from './types';
|
||||
|
||||
// Global time-range variables, always available (V1 parity: `timestamp_start` / `_end`).
|
||||
const GLOBAL_TIMESTAMP_VARIABLES: VariableItem[] = [
|
||||
{ name: 'timestamp_start', source: 'Global timestamp' },
|
||||
{ name: 'timestamp_end', source: 'Global timestamp' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Variables offered by the context-link autocomplete, ordered as in V1: global
|
||||
* timestamps, then per-query `groupBy` fields (prefixed `_`), then dashboard variables.
|
||||
*
|
||||
* Self-contained — the editor renders inside the query-builder provider and the
|
||||
* DashboardContainer, so every source is read here rather than threaded through the
|
||||
* section registry. The dashboard fetch dedupes against the editor page's own query.
|
||||
*/
|
||||
export function useContextLinkVariables(): VariableItem[] {
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { variables: variableDtos } = useDashboardFetchRequired();
|
||||
|
||||
const dashboardVariableNames = useMemo(
|
||||
() =>
|
||||
variableDtos
|
||||
.map((dto) => dtoToFormModel(dto).name)
|
||||
.filter((name): name is string => !!name),
|
||||
[variableDtos],
|
||||
);
|
||||
|
||||
// `_`-prefixed to match V1 and avoid colliding with dashboard-variable names.
|
||||
const fieldVariableNames = useMemo(() => {
|
||||
const names = new Set<string>();
|
||||
(currentQuery?.builder?.queryData ?? []).forEach((query) => {
|
||||
(query.groupBy ?? []).forEach((field) => {
|
||||
if (field.key) {
|
||||
names.add(`_${field.key}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
return Array.from(names);
|
||||
}, [currentQuery?.builder?.queryData]);
|
||||
|
||||
return useMemo(
|
||||
() => [
|
||||
...GLOBAL_TIMESTAMP_VARIABLES,
|
||||
...fieldVariableNames.map(
|
||||
(name): VariableItem => ({ name, source: 'Query variable' }),
|
||||
),
|
||||
...dashboardVariableNames.map(
|
||||
(name): VariableItem => ({ name, source: 'Dashboard variable' }),
|
||||
),
|
||||
],
|
||||
[fieldVariableNames, dashboardVariableNames],
|
||||
);
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import type { UrlParam } from './types';
|
||||
|
||||
// A link URL must be absolute (http/https), root-relative (`/path`), or start with a
|
||||
// leading `{{var}}/` segment (a variable that resolves to a host/path at click-time).
|
||||
const CONTEXT_LINK_URL_PATTERN = /^(https?:\/\/|\/|{{.*}}\/)/;
|
||||
|
||||
/**
|
||||
* Whether the URL is well-formed for a context link. Empty is treated as valid so the
|
||||
* editor doesn't nag while a row is still being filled in — emptiness is a separate concern.
|
||||
*/
|
||||
export function isValidContextLinkUrl(url: string): boolean {
|
||||
if (!url) {
|
||||
return true;
|
||||
}
|
||||
return CONTEXT_LINK_URL_PATTERN.test(url);
|
||||
}
|
||||
|
||||
/** Inserts `variable` into `current` at `cursorPosition`, or appends when it's unknown. */
|
||||
export function insertVariableAtCursor(
|
||||
current: string,
|
||||
variable: string,
|
||||
cursorPosition?: number,
|
||||
): string {
|
||||
if (cursorPosition === undefined) {
|
||||
return current + variable;
|
||||
}
|
||||
return (
|
||||
current.slice(0, cursorPosition) + variable + current.slice(cursorPosition)
|
||||
);
|
||||
}
|
||||
|
||||
// Values may be double-encoded on the wire; decode a second time only when it changes
|
||||
// the string, so already-single-encoded values are left intact.
|
||||
function decodeForDisplay(value: string): string {
|
||||
const decoded = decodeURIComponent(value);
|
||||
try {
|
||||
const doubleDecoded = decodeURIComponent(decoded);
|
||||
return doubleDecoded !== decoded ? doubleDecoded : decoded;
|
||||
} catch {
|
||||
return decoded;
|
||||
}
|
||||
}
|
||||
|
||||
/** Parses the `?a=b&c=d` query string of a URL into decoded key/value rows. */
|
||||
export function getUrlParams(url: string): UrlParam[] {
|
||||
const [, queryString] = url.split('?');
|
||||
if (!queryString) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const params: UrlParam[] = [];
|
||||
queryString.split('&').forEach((pair) => {
|
||||
const [key, value] = pair.split('=');
|
||||
if (key) {
|
||||
params.push({
|
||||
key: decodeURIComponent(key),
|
||||
value: decodeForDisplay(value || ''),
|
||||
});
|
||||
}
|
||||
});
|
||||
return params;
|
||||
}
|
||||
|
||||
/** Rewrites `url`'s query string from `params`, dropping rows with an empty key. */
|
||||
export function updateUrlWithParams(url: string, params: UrlParam[]): string {
|
||||
const [baseUrl] = url.split('?');
|
||||
const queryString = params
|
||||
.filter((param) => param.key.trim() !== '')
|
||||
.map(
|
||||
(param) =>
|
||||
`${encodeURIComponent(param.key.trim())}=${encodeURIComponent(
|
||||
param.value,
|
||||
)}`,
|
||||
)
|
||||
.join('&');
|
||||
|
||||
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
import type {
|
||||
AnyThreshold,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
@@ -77,7 +77,7 @@ function ThresholdsSection({
|
||||
yAxisUnit,
|
||||
tableColumns = [],
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
const variant = controls?.variant ?? ThresholdVariant.LABEL;
|
||||
const variant = controls?.variant ?? 'label';
|
||||
const thresholds = value ?? [];
|
||||
// Which row is being edited, and whether it was just added (so Discard removes it).
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user