mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 22:10:42 +01:00
Compare commits
10 Commits
feat/story
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e9e362f7 | ||
|
|
4175f84815 | ||
|
|
fd032291f9 | ||
|
|
851abd2c93 | ||
|
|
c41899f2eb | ||
|
|
24b6debd78 | ||
|
|
2134adf735 | ||
|
|
8ee9f97f28 | ||
|
|
878e938b65 | ||
|
|
206aad1acd |
@@ -96,6 +96,7 @@ func runGenerateAuthz(_ context.Context) error {
|
||||
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceDashboard).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceLicense).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceMetaResourceSubscription).String(): true,
|
||||
coretypes.NewResourceRef(coretypes.ResourceTelemetryResourceLogs).String(): true,
|
||||
|
||||
@@ -296,6 +296,17 @@ components:
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
AlertmanagertypesChannelListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
@@ -576,6 +587,43 @@ components:
|
||||
wont_fix_resolution:
|
||||
type: string
|
||||
type: object
|
||||
AlertmanagertypesListableNotificationChannel:
|
||||
properties:
|
||||
channels:
|
||||
items:
|
||||
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
|
||||
type: array
|
||||
total:
|
||||
format: int64
|
||||
type: integer
|
||||
required:
|
||||
- channels
|
||||
- total
|
||||
type: object
|
||||
AlertmanagertypesListedNotificationChannel:
|
||||
properties:
|
||||
createdAt:
|
||||
format: date-time
|
||||
type: string
|
||||
displayName:
|
||||
type: string
|
||||
id:
|
||||
type: string
|
||||
kind:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
|
||||
name:
|
||||
type: string
|
||||
updatedAt:
|
||||
format: date-time
|
||||
type: string
|
||||
required:
|
||||
- id
|
||||
- name
|
||||
- displayName
|
||||
- kind
|
||||
- createdAt
|
||||
- updatedAt
|
||||
type: object
|
||||
AlertmanagertypesMaintenanceKind:
|
||||
enum:
|
||||
- fixed
|
||||
@@ -942,6 +990,20 @@ components:
|
||||
- timezone
|
||||
- startTime
|
||||
type: object
|
||||
AlertmanagertypesTestableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AlertmanagertypesUpdatableNotificationChannel:
|
||||
properties:
|
||||
config:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
|
||||
required:
|
||||
- config
|
||||
type: object
|
||||
AuthtypesAttributeMapping:
|
||||
properties:
|
||||
email:
|
||||
@@ -3474,6 +3536,11 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHeaderOptions:
|
||||
properties:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3831,6 +3898,7 @@ components:
|
||||
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
|
||||
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
|
||||
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
@@ -3841,6 +3909,7 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3851,6 +3920,7 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3924,6 +3994,18 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
enum:
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
spec:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
|
||||
properties:
|
||||
kind:
|
||||
@@ -4215,6 +4297,37 @@ components:
|
||||
- color
|
||||
- columnName
|
||||
type: object
|
||||
DashboardtypesTextAlign:
|
||||
enum:
|
||||
- left
|
||||
- center
|
||||
- right
|
||||
type: string
|
||||
DashboardtypesTextMode:
|
||||
enum:
|
||||
- markdown
|
||||
type: string
|
||||
DashboardtypesTextPanelSpec:
|
||||
properties:
|
||||
headerOptions:
|
||||
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
|
||||
mode:
|
||||
$ref: '#/components/schemas/DashboardtypesTextMode'
|
||||
presentation:
|
||||
$ref: '#/components/schemas/DashboardtypesTextPresentation'
|
||||
text:
|
||||
type: string
|
||||
type: object
|
||||
DashboardtypesTextPresentation:
|
||||
properties:
|
||||
background:
|
||||
nullable: true
|
||||
type: string
|
||||
textAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesTextAlign'
|
||||
verticalAlign:
|
||||
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
|
||||
type: object
|
||||
DashboardtypesTextVariableSpec:
|
||||
properties:
|
||||
constant:
|
||||
@@ -4424,6 +4537,12 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVerticalAlign:
|
||||
enum:
|
||||
- top
|
||||
- center
|
||||
- bottom
|
||||
type: string
|
||||
ErrorsJSON:
|
||||
properties:
|
||||
code:
|
||||
@@ -7395,10 +7514,7 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
meta:
|
||||
properties:
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
predictedSeries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
@@ -7413,12 +7529,51 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Bucket:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
step:
|
||||
format: double
|
||||
type: number
|
||||
buckets:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptions:
|
||||
discriminator:
|
||||
mapping:
|
||||
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
propertyName: kind
|
||||
oneOf:
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
|
||||
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLinear:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketOptionsLog:
|
||||
properties:
|
||||
kind:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
|
||||
spec:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
|
||||
required:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
Querybuildertypesv5BucketsKind:
|
||||
enum:
|
||||
- linear
|
||||
- log
|
||||
type: string
|
||||
Querybuildertypesv5BuilderQuerySpec:
|
||||
discriminator:
|
||||
mapping:
|
||||
@@ -7599,6 +7754,16 @@ components:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LinearBucketsSpec:
|
||||
properties:
|
||||
maxValue:
|
||||
format: double
|
||||
type: number
|
||||
numBuckets:
|
||||
type: integer
|
||||
required:
|
||||
- maxValue
|
||||
type: object
|
||||
Querybuildertypesv5LogAggregation:
|
||||
properties:
|
||||
alias:
|
||||
@@ -7606,6 +7771,12 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LogBucketsSpec:
|
||||
properties:
|
||||
scale:
|
||||
nullable: true
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -7686,6 +7857,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryBuilderFormula:
|
||||
properties:
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
disabled:
|
||||
type: boolean
|
||||
expression:
|
||||
@@ -7716,6 +7889,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7777,6 +7952,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7838,6 +8015,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -8163,6 +8342,7 @@ components:
|
||||
- raw
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -8237,8 +8417,6 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5TimeSeriesValue:
|
||||
properties:
|
||||
bucket:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
|
||||
partial:
|
||||
type: boolean
|
||||
timestamp:
|
||||
@@ -8349,6 +8527,8 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8392,6 +8572,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
nullable: true
|
||||
type: array
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8497,6 +8679,7 @@ components:
|
||||
- TRACES_BASED_ALERT
|
||||
- LOGS_BASED_ALERT
|
||||
- EXCEPTIONS_BASED_ALERT
|
||||
- AI_TRACES_BASED_ALERT
|
||||
type: string
|
||||
RuletypesBasicRuleThreshold:
|
||||
properties:
|
||||
@@ -19720,6 +19903,86 @@ paths:
|
||||
tags:
|
||||
- metrics
|
||||
/api/v2/notification_channels:
|
||||
get:
|
||||
deprecated: false
|
||||
description: Returns a page of notification channels for the org. Each entry
|
||||
carries the channel's identity and kind but not its configuration; fetch a
|
||||
channel by ID for that. Supports a case-insensitive display name search (`query`),
|
||||
a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`),
|
||||
and offset-based pagination (`limit`/`offset`).
|
||||
operationId: ListNotificationChannels
|
||||
parameters:
|
||||
- in: query
|
||||
name: query
|
||||
schema:
|
||||
type: string
|
||||
- in: query
|
||||
name: kind
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
|
||||
- in: query
|
||||
name: sort
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelListSort'
|
||||
- in: query
|
||||
name: order
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesChannelListOrder'
|
||||
- in: query
|
||||
name: limit
|
||||
schema:
|
||||
type: integer
|
||||
- in: query
|
||||
name: offset
|
||||
schema:
|
||||
type: integer
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesListableNotificationChannel'
|
||||
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:
|
||||
- notification-channel:list
|
||||
- tokenizer:
|
||||
- notification-channel:list
|
||||
summary: List notification channels
|
||||
tags:
|
||||
- channels
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint creates a notification channel
|
||||
@@ -19782,6 +20045,239 @@ paths:
|
||||
summary: Create notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/{id}:
|
||||
delete:
|
||||
deprecated: false
|
||||
description: This endpoint deletes a notification channel by ID
|
||||
operationId: DeleteNotificationChannel
|
||||
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:
|
||||
- notification-channel:delete
|
||||
- tokenizer:
|
||||
- notification-channel:delete
|
||||
summary: Delete notification channel
|
||||
tags:
|
||||
- channels
|
||||
get:
|
||||
deprecated: false
|
||||
description: This endpoint returns a notification channel by ID. A channel written
|
||||
by the v1 API can carry a configuration this API does not model.
|
||||
operationId: GetNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
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:
|
||||
- notification-channel:read
|
||||
- tokenizer:
|
||||
- notification-channel:read
|
||||
summary: Get notification channel by ID
|
||||
tags:
|
||||
- channels
|
||||
put:
|
||||
deprecated: false
|
||||
description: 'This endpoint replaces a notification channel''s configuration
|
||||
in full. Neither name is part of the request body: both are immutable. The
|
||||
kind may change, which replaces the channel''s notifier configuration.'
|
||||
operationId: UpdateNotificationChannel
|
||||
parameters:
|
||||
- in: path
|
||||
name: id
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesUpdatableNotificationChannel'
|
||||
responses:
|
||||
"200":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
properties:
|
||||
data:
|
||||
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
|
||||
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:
|
||||
- notification-channel:update
|
||||
- tokenizer:
|
||||
- notification-channel:update
|
||||
summary: Update notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/notification_channels/test:
|
||||
post:
|
||||
deprecated: false
|
||||
description: This endpoint sends a test notification for the configuration in
|
||||
the request body. The channel need not exist and nothing is persisted, so
|
||||
the body carries a configuration only.
|
||||
operationId: TestNotificationChannel
|
||||
requestBody:
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/AlertmanagertypesTestableNotificationChannel'
|
||||
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
|
||||
"500":
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: '#/components/schemas/RenderErrorResponse'
|
||||
description: Internal Server Error
|
||||
security:
|
||||
- api_key:
|
||||
- notification-channel:create
|
||||
- tokenizer:
|
||||
- notification-channel:create
|
||||
summary: Test notification channel
|
||||
tags:
|
||||
- channels
|
||||
/api/v2/orgs/me:
|
||||
get:
|
||||
deprecated: false
|
||||
|
||||
@@ -34,6 +34,9 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
4. **Never gate per row.** If a user can `list`, render every row. Check `read` only when the row is opened (drawer or
|
||||
detail route).
|
||||
5. **Gate the narrowest thing that works**, a button over a section, a section over a page.
|
||||
- **Gate a page on `read` alone.** It is the only verb the page's own request needs. `update` and `delete` gate
|
||||
individual controls, so waiting for them holds up the whole page for nothing — pass them as `preloadChecks` and
|
||||
they resolve in the same request, leaving the controls to read from cache.
|
||||
6. **A resource may be gated while a sub-resource is not.** A user without `read` on Service Accounts can still hold
|
||||
`create` on API Keys, so blocking the outer container would hide work they are allowed to do.
|
||||
7. **Verbs not covered here** (`attach`, `detach`, `assignee`) behave like `delete`: gate the control that triggers
|
||||
@@ -45,11 +48,20 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
Without `list`, but with any of `read` / `create` / `update`, the table and
|
||||
everything that only feeds it are blocked:
|
||||
|
||||
- Title, description, search filters and action buttons stay visible.
|
||||
- Filters and any control that drives the table are non-interactive.
|
||||
- The create button stays enabled if the user holds `create`.
|
||||
- Title, description, search, filters and action buttons stay visible. Nothing is
|
||||
hidden for lack of permission.
|
||||
- Disable what only shapes the blocked request — the search box, the filter chips,
|
||||
a Clear button — and give it the same denial. It edits a query that has nowhere
|
||||
to run, so leaving it live invites the user to compose a filter and watch
|
||||
nothing happen.
|
||||
- Gate a region as one section when several of its controls are dead. A saved
|
||||
views rail is a block with a single callout, not a column of identical
|
||||
tooltips; a row of filters is one tooltip zone, not one per control.
|
||||
- The create button stays enabled if the user holds `create`. It is independent
|
||||
of `list`.
|
||||
|
||||
### Edit page
|
||||
|
||||
@@ -96,6 +108,11 @@ Blocking is always a visible denial, never a silent removal. Use the components
|
||||
[`lib/authz/components`](../src/lib/authz/components/README.md) rather than hand-rolling a check, they carry the
|
||||
denial message and the loading state.
|
||||
|
||||
**Denial copy comes from the components.** Never write a custom message for a permission check. `disabledTooltip` is
|
||||
for a block that is *not* a permission — a lock, an immutable resource, a mount that is deliberately read-only. It
|
||||
takes precedence over the checks, which are then skipped, so set it only when that block is the real obstacle: a
|
||||
missing permission must still surface its own wording.
|
||||
|
||||
| Scope | Component | Denied state |
|
||||
| --------------- | ----------------------------------------- | ------------------------------------------- |
|
||||
| Button | `AuthZButton` | Disabled + tooltip |
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -25,11 +25,13 @@
|
||||
"dashboard_has_been_updated": "Dashboard has been updated",
|
||||
"do_you_want_to_refresh_the_dashboard": "Do you want to refresh the dashboard?",
|
||||
"locked_dashboard_delete_tooltip_admin_author": "Dashboard is locked. Please unlock the dashboard to enable delete.",
|
||||
"locked_dashboard_delete_tooltip_editor": "Dashboard is locked. Please contact admin to delete the dashboard.",
|
||||
"delete_dashboard_success": "{{name}} dashboard deleted successfully",
|
||||
"dashboard_unsave_changes": "There are unsaved changes in the Query builder, please stage and run the query or the changes will be lost. Press OK to discard.",
|
||||
"dashboard_save_changes": "Your graph built with {{queryTag}} query will be saved. Press OK to confirm.",
|
||||
"your_graph_build_with": "Your graph built with",
|
||||
"dashboard_ok_confirm": "query will be saved. Press OK to confirm.",
|
||||
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
|
||||
"variable_name_already_exists": "Variable \"{{name}}\" already exists",
|
||||
"dashboard_locked": "This dashboard is locked",
|
||||
"dashboard_read_only_here": "This dashboard is read-only here",
|
||||
"lock_integration_dashboard": "An integration dashboard cannot be locked or unlocked"
|
||||
}
|
||||
|
||||
@@ -1558,6 +1558,18 @@ describe('PrivateRoute', () => {
|
||||
keyof typeof routeWithInitialAuthZSupport,
|
||||
AuthzRouteCase
|
||||
> = {
|
||||
ALL_DASHBOARD: { path: ROUTES.ALL_DASHBOARD, deniedRoles: DENIED_ROLES },
|
||||
DASHBOARD: {
|
||||
path: ROUTES.DASHBOARD.replace(':dashboardId', 'dashboard-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
DASHBOARD_PANEL_EDITOR: {
|
||||
path: ROUTES.DASHBOARD_PANEL_EDITOR.replace(
|
||||
':dashboardId',
|
||||
'dashboard-id-1',
|
||||
).replace(':panelId', 'panel-id-1'),
|
||||
deniedRoles: DENIED_ROLES,
|
||||
},
|
||||
// Everything under /settings resolves to the non-exact SETTINGS route
|
||||
SETTINGS: { path: ROUTES.SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
MY_SETTINGS: { path: ROUTES.MY_SETTINGS, deniedRoles: DENIED_ROLES },
|
||||
|
||||
@@ -21,14 +21,23 @@ import type {
|
||||
AlertmanagertypesPostableChannelDTO,
|
||||
AlertmanagertypesPostableNotificationChannelDTO,
|
||||
AlertmanagertypesReceiverDTO,
|
||||
AlertmanagertypesTestableNotificationChannelDTO,
|
||||
AlertmanagertypesUpdatableNotificationChannelDTO,
|
||||
CreateChannel201,
|
||||
CreateNotificationChannel201,
|
||||
DeleteChannelByIDPathParameters,
|
||||
DeleteNotificationChannelPathParameters,
|
||||
GetChannelByID200,
|
||||
GetChannelByIDPathParameters,
|
||||
GetNotificationChannel200,
|
||||
GetNotificationChannelPathParameters,
|
||||
ListChannels200,
|
||||
ListNotificationChannels200,
|
||||
ListNotificationChannelsParams,
|
||||
RenderErrorResponseDTO,
|
||||
UpdateChannelByIDPathParameters,
|
||||
UpdateNotificationChannel200,
|
||||
UpdateNotificationChannelPathParameters,
|
||||
} from '../sigNoz.schemas';
|
||||
|
||||
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
|
||||
@@ -649,6 +658,105 @@ export const useTestChannelDeprecated = <
|
||||
> => {
|
||||
return useMutation(getTestChannelDeprecatedMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* Returns a page of notification channels for the org. Each entry carries the channel's identity and kind but not its configuration; fetch a channel by ID for that. Supports a case-insensitive display name search (`query`), a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
|
||||
* @summary List notification channels
|
||||
*/
|
||||
export const listNotificationChannels = (
|
||||
params?: ListNotificationChannelsParams,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<ListNotificationChannels200>({
|
||||
url: `/api/v2/notification_channels`,
|
||||
method: 'GET',
|
||||
params,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getListNotificationChannelsQueryKey = (
|
||||
params?: ListNotificationChannelsParams,
|
||||
) => {
|
||||
return [`/api/v2/notification_channels`, ...(params ? [params] : [])] as const;
|
||||
};
|
||||
|
||||
export const getListNotificationChannelsQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getListNotificationChannelsQueryKey(params);
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>
|
||||
> = ({ signal }) => listNotificationChannels(params, signal);
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type ListNotificationChannelsQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>
|
||||
>;
|
||||
export type ListNotificationChannelsQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary List notification channels
|
||||
*/
|
||||
|
||||
export function useListNotificationChannels<
|
||||
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof listNotificationChannels>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getListNotificationChannelsQueryOptions(params, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary List notification channels
|
||||
*/
|
||||
export const invalidateListNotificationChannels = async (
|
||||
queryClient: QueryClient,
|
||||
params?: ListNotificationChannelsParams,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getListNotificationChannelsQueryKey(params) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint creates a notification channel
|
||||
* @summary Create notification channel
|
||||
@@ -733,3 +841,370 @@ export const useCreateNotificationChannel = <
|
||||
> => {
|
||||
return useMutation(getCreateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint deletes a notification channel by ID
|
||||
* @summary Delete notification channel
|
||||
*/
|
||||
export const deleteNotificationChannel = (
|
||||
{ id }: DeleteNotificationChannelPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'DELETE',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeleteNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['deleteNotificationChannel'];
|
||||
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 deleteNotificationChannel>>,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters }
|
||||
> = (props) => {
|
||||
const { pathParams } = props ?? {};
|
||||
|
||||
return deleteNotificationChannel(pathParams);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeleteNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>
|
||||
>;
|
||||
|
||||
export type DeleteNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Delete notification channel
|
||||
*/
|
||||
export const useDeleteNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deleteNotificationChannel>>,
|
||||
TError,
|
||||
{ pathParams: DeleteNotificationChannelPathParameters },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeleteNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint returns a notification channel by ID. A channel written by the v1 API can carry a configuration this API does not model.
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
export const getNotificationChannel = (
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<GetNotificationChannel200>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'GET',
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetNotificationChannelQueryKey = ({
|
||||
id,
|
||||
}: GetNotificationChannelPathParameters) => {
|
||||
return [`/api/v2/notification_channels/${id}`] as const;
|
||||
};
|
||||
|
||||
export const getGetNotificationChannelQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
) => {
|
||||
const { query: queryOptions } = options ?? {};
|
||||
|
||||
const queryKey =
|
||||
queryOptions?.queryKey ?? getGetNotificationChannelQueryKey({ id });
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>
|
||||
> = ({ signal }) => getNotificationChannel({ id }, signal);
|
||||
|
||||
return {
|
||||
queryKey,
|
||||
queryFn,
|
||||
enabled: !!id,
|
||||
...queryOptions,
|
||||
} as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetNotificationChannelQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>
|
||||
>;
|
||||
export type GetNotificationChannelQueryError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
|
||||
export function useGetNotificationChannel<
|
||||
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
>(
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getNotificationChannel>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
},
|
||||
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetNotificationChannelQueryOptions({ id }, options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Get notification channel by ID
|
||||
*/
|
||||
export const invalidateGetNotificationChannel = async (
|
||||
queryClient: QueryClient,
|
||||
{ id }: GetNotificationChannelPathParameters,
|
||||
options?: InvalidateOptions,
|
||||
): Promise<QueryClient> => {
|
||||
await queryClient.invalidateQueries(
|
||||
{ queryKey: getGetNotificationChannelQueryKey({ id }) },
|
||||
options,
|
||||
);
|
||||
|
||||
return queryClient;
|
||||
};
|
||||
|
||||
/**
|
||||
* This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.
|
||||
* @summary Update notification channel
|
||||
*/
|
||||
export const updateNotificationChannel = (
|
||||
{ id }: UpdateNotificationChannelPathParameters,
|
||||
alertmanagertypesUpdatableNotificationChannelDTO?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<UpdateNotificationChannel200>({
|
||||
url: `/api/v2/notification_channels/${id}`,
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesUpdatableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getUpdateNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['updateNotificationChannel'];
|
||||
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 updateNotificationChannel>>,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
}
|
||||
> = (props) => {
|
||||
const { pathParams, data } = props ?? {};
|
||||
|
||||
return updateNotificationChannel(pathParams, data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UpdateNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>
|
||||
>;
|
||||
export type UpdateNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type UpdateNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Update notification channel
|
||||
*/
|
||||
export const useUpdateNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof updateNotificationChannel>>,
|
||||
TError,
|
||||
{
|
||||
pathParams: UpdateNotificationChannelPathParameters;
|
||||
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
|
||||
},
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUpdateNotificationChannelMutationOptions(options));
|
||||
};
|
||||
/**
|
||||
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
|
||||
* @summary Test notification channel
|
||||
*/
|
||||
export const testNotificationChannel = (
|
||||
alertmanagertypesTestableNotificationChannelDTO?: BodyType<AlertmanagertypesTestableNotificationChannelDTO>,
|
||||
signal?: AbortSignal,
|
||||
) => {
|
||||
return GeneratedAPIInstance<void>({
|
||||
url: `/api/v2/notification_channels/test`,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
data: alertmanagertypesTestableNotificationChannelDTO,
|
||||
signal,
|
||||
});
|
||||
};
|
||||
|
||||
export const getTestNotificationChannelMutationOptions = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ['testNotificationChannel'];
|
||||
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 testNotificationChannel>>,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return testNotificationChannel(data);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type TestNotificationChannelMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>
|
||||
>;
|
||||
export type TestNotificationChannelMutationBody =
|
||||
| BodyType<AlertmanagertypesTestableNotificationChannelDTO>
|
||||
| undefined;
|
||||
export type TestNotificationChannelMutationError =
|
||||
ErrorType<RenderErrorResponseDTO>;
|
||||
|
||||
/**
|
||||
* @summary Test notification channel
|
||||
*/
|
||||
export const useTestNotificationChannel = <
|
||||
TError = ErrorType<RenderErrorResponseDTO>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof testNotificationChannel>>,
|
||||
TError,
|
||||
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getTestNotificationChannelMutationOptions(options));
|
||||
};
|
||||
|
||||
@@ -507,6 +507,15 @@ export enum AlertmanagertypesChannelKindDTO {
|
||||
jsmops = 'jsmops',
|
||||
incidentio = 'incidentio',
|
||||
}
|
||||
export enum AlertmanagertypesChannelListOrderDTO {
|
||||
asc = 'asc',
|
||||
desc = 'desc',
|
||||
}
|
||||
export enum AlertmanagertypesChannelListSortDTO {
|
||||
updated_at = 'updated_at',
|
||||
created_at = 'created_at',
|
||||
name = 'name',
|
||||
}
|
||||
export interface ModelLabelSetDTO {
|
||||
[key: string]: string;
|
||||
}
|
||||
@@ -1000,6 +1009,44 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
|
||||
wont_fix_resolution?: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListedNotificationChannelDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
createdAt: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
displayName: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
id: string;
|
||||
kind: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* @type string
|
||||
* @format date-time
|
||||
*/
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesListableNotificationChannelDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
channels: AlertmanagertypesListedNotificationChannelDTO[];
|
||||
/**
|
||||
* @type integer
|
||||
* @format int64
|
||||
*/
|
||||
total: number;
|
||||
}
|
||||
|
||||
export enum AlertmanagertypesMaintenanceKindDTO {
|
||||
fixed = 'fixed',
|
||||
recurring = 'recurring',
|
||||
@@ -2391,6 +2438,14 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesTestableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4079,6 +4134,53 @@ export interface Querybuildertypesv5LogAggregationDTO {
|
||||
expression?: string;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
|
||||
linear = 'linear',
|
||||
}
|
||||
export interface Querybuildertypesv5LinearBucketsSpecDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
maxValue: number;
|
||||
/**
|
||||
* @type integer
|
||||
*/
|
||||
numBuckets?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLinearDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum linear
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
|
||||
spec: Querybuildertypesv5LinearBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
|
||||
log = 'log',
|
||||
}
|
||||
export interface Querybuildertypesv5LogBucketsSpecDTO {
|
||||
/**
|
||||
* @type integer,null
|
||||
*/
|
||||
scale?: number | null;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketOptionsLogDTO {
|
||||
/**
|
||||
* @type string
|
||||
* @enum log
|
||||
*/
|
||||
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
|
||||
spec: Querybuildertypesv5LogBucketsSpecDTO;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5BucketOptionsDTO =
|
||||
| Querybuildertypesv5BucketOptionsLinearDTO
|
||||
| Querybuildertypesv5BucketOptionsLogDTO;
|
||||
|
||||
export interface Querybuildertypesv5FilterDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4272,6 +4374,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4399,6 +4502,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4474,6 +4578,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4914,6 +5019,57 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
|
||||
spec: DashboardtypesListPanelSpecDTO;
|
||||
}
|
||||
|
||||
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
export interface DashboardtypesHeaderOptionsDTO {
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
hide?: boolean;
|
||||
}
|
||||
|
||||
export enum DashboardtypesTextModeDTO {
|
||||
markdown = 'markdown',
|
||||
}
|
||||
export enum DashboardtypesTextAlignDTO {
|
||||
left = 'left',
|
||||
center = 'center',
|
||||
right = 'right',
|
||||
}
|
||||
export enum DashboardtypesVerticalAlignDTO {
|
||||
top = 'top',
|
||||
center = 'center',
|
||||
bottom = 'bottom',
|
||||
}
|
||||
export interface DashboardtypesTextPresentationDTO {
|
||||
/**
|
||||
* @type string,null
|
||||
*/
|
||||
background?: string | null;
|
||||
textAlign?: DashboardtypesTextAlignDTO;
|
||||
verticalAlign?: DashboardtypesVerticalAlignDTO;
|
||||
}
|
||||
|
||||
export interface DashboardtypesTextPanelSpecDTO {
|
||||
headerOptions?: DashboardtypesHeaderOptionsDTO;
|
||||
mode?: DashboardtypesTextModeDTO;
|
||||
presentation?: DashboardtypesTextPresentationDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
|
||||
/**
|
||||
* @enum signoz/TextPanel
|
||||
* @type string
|
||||
*/
|
||||
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
|
||||
spec: DashboardtypesTextPanelSpecDTO;
|
||||
}
|
||||
|
||||
export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
|
||||
@@ -4921,7 +5077,8 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -4929,6 +5086,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
}
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
@@ -4975,6 +5133,7 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -5843,6 +6002,7 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -8542,16 +8702,7 @@ export interface Querybuildertypesv5LabelDTO {
|
||||
value?: Querybuildertypesv5LabelDTOValue;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
bucket?: Querybuildertypesv5BucketDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -9102,12 +9253,16 @@ export interface PromotetypesPromotePathDTO {
|
||||
promote?: boolean;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5AggregationBucketDTOMeta = {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
/**
|
||||
@@ -9126,10 +9281,7 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
* @type array
|
||||
*/
|
||||
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9144,6 +9296,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketsKindDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9610,6 +9766,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
overallStateChanged: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9658,6 +9818,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
|
||||
* @type array,null
|
||||
*/
|
||||
labels: Querybuildertypesv5LabelDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9753,6 +9917,7 @@ export enum RuletypesAlertTypeDTO {
|
||||
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
|
||||
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
|
||||
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
|
||||
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
|
||||
}
|
||||
export enum RuletypesMatchTypeDTO {
|
||||
at_least_once = 'at_least_once',
|
||||
@@ -13162,6 +13327,44 @@ export type GetMetricsTreemap200 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type ListNotificationChannelsParams = {
|
||||
/**
|
||||
* @type string
|
||||
* @description undefined
|
||||
*/
|
||||
query?: string;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
kind?: AlertmanagertypesChannelKindDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
sort?: AlertmanagertypesChannelListSortDTO;
|
||||
/**
|
||||
* @description undefined
|
||||
*/
|
||||
order?: AlertmanagertypesChannelListOrderDTO;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
limit?: number;
|
||||
/**
|
||||
* @type integer
|
||||
* @description undefined
|
||||
*/
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export type ListNotificationChannels200 = {
|
||||
data: AlertmanagertypesListableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type CreateNotificationChannel201 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
@@ -13170,6 +13373,31 @@ export type CreateNotificationChannel201 = {
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type DeleteNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type GetNotificationChannel200 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type UpdateNotificationChannelPathParameters = {
|
||||
id: string;
|
||||
};
|
||||
export type UpdateNotificationChannel200 = {
|
||||
data: AlertmanagertypesGettableNotificationChannelDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
status: string;
|
||||
};
|
||||
|
||||
export type GetMyOrganization200 = {
|
||||
data: TypesOrganizationDTO;
|
||||
/**
|
||||
|
||||
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import type { CursorPosition } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface EditorStatusBarProps {
|
||||
cursor: CursorPosition;
|
||||
length: number;
|
||||
maxLength: number;
|
||||
hint?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorStatusBar({
|
||||
cursor,
|
||||
length,
|
||||
maxLength,
|
||||
hint,
|
||||
}: EditorStatusBarProps): JSX.Element {
|
||||
const isOverLimit = length > maxLength;
|
||||
|
||||
return (
|
||||
<div className={styles.statusBar} data-testid="markdown-editor-status">
|
||||
<Typography.Text className={styles.statusPosition}>
|
||||
{`Ln ${cursor.line}, Col ${cursor.column}`}
|
||||
<span className={styles.statusSeparator}>·</span>
|
||||
<span
|
||||
className={cx(styles.statusCount, {
|
||||
[styles.statusCountOverLimit]: isOverLimit,
|
||||
})}
|
||||
data-testid="markdown-editor-char-count"
|
||||
>
|
||||
{isOverLimit ? `${length} / ${maxLength} chars` : `${length} chars`}
|
||||
</span>
|
||||
</Typography.Text>
|
||||
{hint && (
|
||||
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorStatusBar;
|
||||
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
Bold,
|
||||
CodeXml,
|
||||
Heading,
|
||||
Italic,
|
||||
Link,
|
||||
List,
|
||||
ListOrdered,
|
||||
Table,
|
||||
Type,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import InsertVariableMenu from './InsertVariableMenu';
|
||||
import MarkdownHelp from './MarkdownHelp';
|
||||
import type { EditorCommand, EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
const COMMAND_ICONS: Record<string, ReactNode> = {
|
||||
heading: <Heading size={14} />,
|
||||
bold: <Bold size={14} />,
|
||||
italic: <Italic size={14} />,
|
||||
'bulleted-list': <List size={14} />,
|
||||
'numbered-list': <ListOrdered size={14} />,
|
||||
link: <Link size={14} />,
|
||||
code: <CodeXml size={14} />,
|
||||
table: <Table size={14} />,
|
||||
};
|
||||
|
||||
interface EditorToolbarProps {
|
||||
formatLabel: string;
|
||||
commands: EditorCommand[];
|
||||
onRunCommand: (command: EditorCommand) => void;
|
||||
variables: EditorVariable[];
|
||||
onInsertVariable: (name: string) => void;
|
||||
disabled: boolean;
|
||||
extra?: ReactNode;
|
||||
}
|
||||
|
||||
function EditorToolbar({
|
||||
formatLabel,
|
||||
commands,
|
||||
onRunCommand,
|
||||
variables,
|
||||
onInsertVariable,
|
||||
disabled,
|
||||
extra,
|
||||
}: EditorToolbarProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
|
||||
<span className={styles.formatChip}>
|
||||
<Type size={14} />
|
||||
<Typography.Text className={styles.formatLabel}>
|
||||
{formatLabel}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
<span className={styles.toolbarDivider} />
|
||||
<div className={styles.commands}>
|
||||
{commands.map((command) => (
|
||||
<TooltipSimple key={command.id} title={command.label}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
aria-label={command.label}
|
||||
data-testid={`markdown-command-${command.id}`}
|
||||
onClick={(): void => onRunCommand(command)}
|
||||
>
|
||||
{COMMAND_ICONS[command.id]}
|
||||
</Button>
|
||||
</TooltipSimple>
|
||||
))}
|
||||
</div>
|
||||
<div className={styles.toolbarEnd}>
|
||||
{extra}
|
||||
<InsertVariableMenu
|
||||
variables={variables}
|
||||
onSelect={onInsertVariable}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<MarkdownHelp />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default EditorToolbar;
|
||||
@@ -0,0 +1,91 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
|
||||
|
||||
import type { EditorVariable } from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
interface InsertVariableMenuProps {
|
||||
variables: EditorVariable[];
|
||||
/** Receives the variable name; the caller decides the token syntax. */
|
||||
onSelect: (name: string) => void;
|
||||
disabled: boolean;
|
||||
}
|
||||
|
||||
function toMenuItems(
|
||||
variables: EditorVariable[],
|
||||
onSelect: (name: string) => void,
|
||||
): MenuItem[] {
|
||||
return variables.map((variable) => ({
|
||||
key: variable.name,
|
||||
label: (
|
||||
<span
|
||||
className={styles.variableRow}
|
||||
data-testid={`markdown-variable-${variable.name}`}
|
||||
>
|
||||
<span className={styles.variableName}>{`$${variable.name}`}</span>
|
||||
{variable.badge && (
|
||||
<span className={styles.variableBadge}>{variable.badge}</span>
|
||||
)}
|
||||
</span>
|
||||
),
|
||||
onClick: (): void => onSelect(variable.name),
|
||||
}));
|
||||
}
|
||||
|
||||
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
|
||||
function InsertVariableMenu({
|
||||
variables,
|
||||
onSelect,
|
||||
disabled,
|
||||
}: InsertVariableMenuProps): JSX.Element | null {
|
||||
const [search, setSearch] = useState('');
|
||||
|
||||
const matches = useMemo(() => {
|
||||
const query = search.trim().toLowerCase();
|
||||
return query
|
||||
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
|
||||
: variables;
|
||||
}, [variables, search]);
|
||||
|
||||
const items = useMemo(
|
||||
() => toMenuItems(matches, onSelect),
|
||||
[matches, onSelect],
|
||||
);
|
||||
|
||||
if (variables.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenuSimple
|
||||
className={styles.variableMenu}
|
||||
menu={{
|
||||
items,
|
||||
search: {
|
||||
placeholder: 'Search variables',
|
||||
searchIcon: <Search size={14} />,
|
||||
onSearchChange: setSearch,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
|
||||
suffix={<ChevronDown size={14} />}
|
||||
className={styles.insertVariable}
|
||||
data-testid="markdown-insert-variable"
|
||||
>
|
||||
Insert variable
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
);
|
||||
}
|
||||
|
||||
export default InsertVariableMenu;
|
||||
@@ -0,0 +1,253 @@
|
||||
@use '../../styles/scrollbar' as *;
|
||||
|
||||
.container {
|
||||
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
|
||||
--md-syntax-heading: var(--text-vanilla-100);
|
||||
--md-syntax-strong: var(--text-vanilla-100);
|
||||
--md-syntax-emphasis: var(--text-vanilla-300);
|
||||
--md-syntax-quote: var(--text-vanilla-400);
|
||||
--md-syntax-marker: var(--text-robin-300);
|
||||
--md-syntax-code: var(--text-forest-400);
|
||||
--md-syntax-link: var(--text-robin-400);
|
||||
--md-syntax-variable: var(--text-amber-400);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
background: var(--l1-background);
|
||||
}
|
||||
|
||||
:global(body.lightMode) .container {
|
||||
--md-syntax-heading: var(--text-ink-400);
|
||||
--md-syntax-strong: var(--text-ink-400);
|
||||
--md-syntax-emphasis: var(--text-ink-200);
|
||||
--md-syntax-quote: var(--text-neutral-light-100);
|
||||
--md-syntax-marker: var(--text-robin-500);
|
||||
--md-syntax-code: var(--text-forest-700);
|
||||
--md-syntax-link: var(--text-robin-500);
|
||||
--md-syntax-variable: var(--text-sienna-500);
|
||||
}
|
||||
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
padding: 8px 12px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.formatChip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
color: var(--text-sienna-400);
|
||||
}
|
||||
|
||||
.formatLabel {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.toolbarDivider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
background: var(--l1-border);
|
||||
}
|
||||
|
||||
.commands {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.toolbarEnd {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.insertVariable {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.insertVariableIcon {
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
// The ui library's dropdown assumes a global border-box reset this app doesn't
|
||||
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
|
||||
// `width: 100%` + padding, so in the portal they lay out content-box and
|
||||
// overflow the popup by the padding — clipping the flush-right badge.
|
||||
.variableMenu,
|
||||
.variableMenu * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.variableMenu {
|
||||
width: 320px;
|
||||
}
|
||||
|
||||
// Shrinkable, so a clamped popup truncates the name instead of clipping the
|
||||
// badge at the content's `overflow: hidden` edge.
|
||||
.variableRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.variableName {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.variableBadge {
|
||||
flex-shrink: 0;
|
||||
margin-left: auto;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.08em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-amber-400);
|
||||
}
|
||||
|
||||
.editorArea {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.codeMirror {
|
||||
height: 100%;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 13px;
|
||||
|
||||
:global(.cm-editor) {
|
||||
height: 100%;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
:global(.cm-editor.cm-focused) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
:global(.cm-gutters) {
|
||||
background: transparent;
|
||||
border-right: none;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
:global(.cm-scroller) {
|
||||
line-height: 20px;
|
||||
padding: 0 12px;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
:global(.cm-content) {
|
||||
padding: 8px 0;
|
||||
}
|
||||
}
|
||||
|
||||
.statusBar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
padding: 6px 12px;
|
||||
border-top: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.statusPosition {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.statusSeparator {
|
||||
color: var(--l1-border);
|
||||
}
|
||||
|
||||
.statusCount {
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.statusCountOverLimit {
|
||||
color: var(--text-cherry-400);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.statusHint {
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpContent {
|
||||
width: 280px;
|
||||
max-height: 320px;
|
||||
overflow-y: auto;
|
||||
@include custom-scrollbar;
|
||||
}
|
||||
|
||||
.helpTitle {
|
||||
display: block;
|
||||
margin-bottom: 8px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
|
||||
.helpList {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.helpRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
|
||||
dt {
|
||||
margin: 0;
|
||||
|
||||
code {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 11px;
|
||||
color: var(--text-forest-400);
|
||||
}
|
||||
}
|
||||
|
||||
dd {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
color: var(--text-neutral-dark-200);
|
||||
}
|
||||
}
|
||||
|
||||
// The help popover portals out of `.container`, so it can't inherit its tokens.
|
||||
:global(body.lightMode) .helpRow dt code {
|
||||
color: var(--text-forest-700);
|
||||
}
|
||||
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { copilot } from '@uiw/codemirror-theme-copilot';
|
||||
import { githubLight } from '@uiw/codemirror-theme-github';
|
||||
import CodeMirror, {
|
||||
type BasicSetupOptions,
|
||||
EditorView,
|
||||
type ViewUpdate,
|
||||
} from '@uiw/react-codemirror';
|
||||
import cx from 'classnames';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
|
||||
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
|
||||
import EditorStatusBar from './EditorStatusBar';
|
||||
import EditorToolbar from './EditorToolbar';
|
||||
import { applyTransform, replaceDocument } from './editorDocument';
|
||||
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
|
||||
import { markdownHighlight } from './markdownHighlight';
|
||||
import type {
|
||||
CursorPosition,
|
||||
EditorCommand,
|
||||
EditorTransform,
|
||||
EditorVariable,
|
||||
} from './types';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
/** What the status bar reports. */
|
||||
type DocumentStatus = CursorPosition & { length: number };
|
||||
|
||||
// No language grammar is loaded, so bracket/indent/completion behaviour would only
|
||||
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
|
||||
const BASIC_SETUP: BasicSetupOptions = {
|
||||
lineNumbers: true,
|
||||
highlightActiveLine: true,
|
||||
highlightActiveLineGutter: true,
|
||||
foldGutter: false,
|
||||
autocompletion: false,
|
||||
bracketMatching: false,
|
||||
closeBrackets: false,
|
||||
indentOnInput: false,
|
||||
syntaxHighlighting: false,
|
||||
highlightSelectionMatches: false,
|
||||
rectangularSelection: false,
|
||||
crosshairCursor: false,
|
||||
searchKeymap: false,
|
||||
foldKeymap: false,
|
||||
lintKeymap: false,
|
||||
completionKeymap: false,
|
||||
closeBracketsKeymap: false,
|
||||
};
|
||||
|
||||
const EMPTY_VARIABLES: EditorVariable[] = [];
|
||||
|
||||
export interface MarkdownEditorProps {
|
||||
/** Seeds the document; replaced only from outside. See the sync effect. */
|
||||
value: string;
|
||||
onChange: (value: string) => void;
|
||||
/** Offered by the "Insert variable" menu; the button disables when empty. */
|
||||
variables?: EditorVariable[];
|
||||
/** What the character counter reports against. */
|
||||
maxLength?: number;
|
||||
placeholder?: string;
|
||||
readOnly?: boolean;
|
||||
/** Shown on the toolbar chip. */
|
||||
formatLabel?: string;
|
||||
/** Rendered before the "Insert variable" menu. */
|
||||
toolbarExtra?: ReactNode;
|
||||
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
|
||||
statusHint?: ReactNode;
|
||||
autoFocus?: boolean;
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
|
||||
* the body, so the preview surface and its sanitisation stay the caller's concern.
|
||||
*/
|
||||
function MarkdownEditor({
|
||||
value,
|
||||
onChange,
|
||||
variables = EMPTY_VARIABLES,
|
||||
maxLength = MARKDOWN_MAX_LENGTH,
|
||||
placeholder = 'Write Markdown…',
|
||||
readOnly = false,
|
||||
formatLabel = 'Markdown',
|
||||
toolbarExtra,
|
||||
statusHint,
|
||||
autoFocus = false,
|
||||
className,
|
||||
testId = 'markdown-editor',
|
||||
}: MarkdownEditorProps): JSX.Element {
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const viewRef = useRef<EditorView | null>(null);
|
||||
// Set while a programmatic replacement is in flight, so the caller isn't told
|
||||
// about a change it asked for. `dispatch` runs listeners synchronously, so the
|
||||
// window is exactly one call.
|
||||
const isSyncingRef = useRef(false);
|
||||
const previousValueRef = useRef(value);
|
||||
const hasSeededRef = useRef(false);
|
||||
const [isEditorReady, setIsEditorReady] = useState(false);
|
||||
const [status, setStatus] = useState<DocumentStatus>(() => ({
|
||||
line: 1,
|
||||
column: 1,
|
||||
length: value.length,
|
||||
}));
|
||||
|
||||
const syncDocument = useCallback((view: EditorView, next: string): void => {
|
||||
isSyncingRef.current = true;
|
||||
replaceDocument(view, next);
|
||||
isSyncingRef.current = false;
|
||||
}, []);
|
||||
|
||||
const onCreateEditor = useCallback((view: EditorView): void => {
|
||||
viewRef.current = view;
|
||||
setIsEditorReady(true);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Seeds the document, then applies external replacements — nothing else. Keeping
|
||||
* keystrokes out of this round-trip is what stops a stale `value` from replacing
|
||||
* the document and resetting the caret when typing outpaces React.
|
||||
*
|
||||
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
|
||||
* `''` and reconciles against it once the view exists, wiping anything written
|
||||
* before that. `isEditorReady` puts this effect after that pass, since a parent's
|
||||
* effects flush after its children's.
|
||||
*
|
||||
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
|
||||
* applied over the author.
|
||||
*/
|
||||
useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = previousValueRef.current;
|
||||
previousValueRef.current = value;
|
||||
const isSeeding = !hasSeededRef.current;
|
||||
hasSeededRef.current = true;
|
||||
|
||||
if (!isSeeding && (value === previous || view.hasFocus)) {
|
||||
return;
|
||||
}
|
||||
if (view.state.doc.toString() !== value) {
|
||||
syncDocument(view, value);
|
||||
}
|
||||
}, [value, isEditorReady, syncDocument]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(next: string): void => {
|
||||
if (!isSyncingRef.current) {
|
||||
onChange(next);
|
||||
}
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const runTransform = useCallback((transform: EditorTransform): void => {
|
||||
const view = viewRef.current;
|
||||
if (view) {
|
||||
applyTransform(view, transform);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onRunCommand = useCallback(
|
||||
(command: EditorCommand): void => runTransform(command.run),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const onInsertVariable = useCallback(
|
||||
(name: string): void =>
|
||||
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
|
||||
[runTransform],
|
||||
);
|
||||
|
||||
const extensions = useMemo(
|
||||
() => [markdownHighlight(), EditorView.lineWrapping],
|
||||
[],
|
||||
);
|
||||
|
||||
// From the document, not `value`: the caller may debounce or drop a change, and
|
||||
// the counter has to match what the author sees.
|
||||
const onUpdate = useCallback((update: ViewUpdate): void => {
|
||||
if (!update.selectionSet && !update.docChanged) {
|
||||
return;
|
||||
}
|
||||
const { head } = update.state.selection.main;
|
||||
const line = update.state.doc.lineAt(head);
|
||||
setStatus({
|
||||
line: line.number,
|
||||
column: head - line.from + 1,
|
||||
length: update.state.doc.length,
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className={cx(styles.container, className)} data-testid={testId}>
|
||||
<EditorToolbar
|
||||
formatLabel={formatLabel}
|
||||
commands={MARKDOWN_COMMANDS}
|
||||
onRunCommand={onRunCommand}
|
||||
variables={variables}
|
||||
onInsertVariable={onInsertVariable}
|
||||
disabled={readOnly}
|
||||
extra={toolbarExtra}
|
||||
/>
|
||||
<div className={styles.editorArea}>
|
||||
<CodeMirror
|
||||
className={styles.codeMirror}
|
||||
// No `value`: passing it re-enables the wrapper's own reconciliation,
|
||||
// and with it the caret reset.
|
||||
onCreateEditor={onCreateEditor}
|
||||
onChange={handleChange}
|
||||
onUpdate={onUpdate}
|
||||
theme={isDarkMode ? copilot : githubLight}
|
||||
basicSetup={BASIC_SETUP}
|
||||
placeholder={placeholder}
|
||||
editable={!readOnly}
|
||||
readOnly={readOnly}
|
||||
indentWithTab={false}
|
||||
autoFocus={autoFocus}
|
||||
extensions={extensions}
|
||||
height="100%"
|
||||
/>
|
||||
</div>
|
||||
<EditorStatusBar
|
||||
cursor={status}
|
||||
length={status.length}
|
||||
maxLength={maxLength}
|
||||
hint={statusHint}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownEditor;
|
||||
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { MARKDOWN_HELP_ITEMS } from './constants';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
function MarkdownHelp(): JSX.Element {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Markdown syntax help"
|
||||
data-testid="markdown-help-trigger"
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.helpContent}>
|
||||
<Typography.Text className={styles.helpTitle}>
|
||||
Markdown syntax
|
||||
</Typography.Text>
|
||||
<dl className={styles.helpList}>
|
||||
{MARKDOWN_HELP_ITEMS.map((item) => (
|
||||
<div key={item.syntax} className={styles.helpRow}>
|
||||
<dt>
|
||||
<code>{item.syntax}</code>
|
||||
</dt>
|
||||
<dd>{item.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownHelp;
|
||||
@@ -0,0 +1,270 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
|
||||
import {
|
||||
act,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
|
||||
import MarkdownEditor from '../MarkdownEditor';
|
||||
import type { EditorVariable } from '../types';
|
||||
|
||||
beforeAll(() => {
|
||||
mockCodeMirrorDomApis();
|
||||
});
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({
|
||||
useIsDarkMode: (): boolean => true,
|
||||
}));
|
||||
|
||||
const VARIABLES: EditorVariable[] = [
|
||||
{ name: 'environment', badge: 'QUERY' },
|
||||
{ name: 'service', badge: 'CUSTOM' },
|
||||
];
|
||||
|
||||
/** A caller whose state trails the editor by one keystroke. */
|
||||
function LaggingHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('');
|
||||
const previousRef = useRef('');
|
||||
const onChange = useCallback((next: string): void => {
|
||||
setValue(previousRef.current);
|
||||
previousRef.current = next;
|
||||
}, []);
|
||||
|
||||
return <MarkdownEditor value={value} onChange={onChange} />;
|
||||
}
|
||||
|
||||
/** Pushes a replacement in from outside the editor. */
|
||||
function ExternalHarness(): JSX.Element {
|
||||
const [value, setValue] = useState('before');
|
||||
|
||||
return (
|
||||
<>
|
||||
<button type="button" onClick={(): void => setValue('after')}>
|
||||
push
|
||||
</button>
|
||||
<MarkdownEditor value={value} onChange={setValue} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function Harness({
|
||||
initialValue = '',
|
||||
maxLength,
|
||||
variables = VARIABLES,
|
||||
}: {
|
||||
initialValue?: string;
|
||||
maxLength?: number;
|
||||
variables?: EditorVariable[];
|
||||
}): JSX.Element {
|
||||
const [value, setValue] = useState(initialValue);
|
||||
return (
|
||||
<MarkdownEditor
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
variables={variables}
|
||||
maxLength={maxLength}
|
||||
statusHint="Preview updates as you type"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const getView = (): EditorView => {
|
||||
const dom = document.querySelector('.cm-editor');
|
||||
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
|
||||
if (!view) {
|
||||
throw new Error('editor view not mounted');
|
||||
}
|
||||
return view;
|
||||
};
|
||||
|
||||
const select = (from: number, to: number): void => {
|
||||
act(() => {
|
||||
getView().dispatch({ selection: { anchor: from, head: to } });
|
||||
});
|
||||
};
|
||||
|
||||
const documentText = (): string => getView().state.doc.toString();
|
||||
|
||||
describe('MarkdownEditor', () => {
|
||||
it('reports the caret position and character count', async () => {
|
||||
render(<Harness initialValue={'one\ntwo'} />);
|
||||
|
||||
select(5, 5);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
|
||||
'Ln 2, Col 2',
|
||||
);
|
||||
});
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'7 chars',
|
||||
);
|
||||
});
|
||||
|
||||
it('flags a body over the character cap', async () => {
|
||||
render(<Harness initialValue="123456" maxLength={5} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'6 / 5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('applies a toolbar command to the selection', async () => {
|
||||
render(<Harness initialValue="a word b" />);
|
||||
|
||||
select(2, 6);
|
||||
await userEvent.click(screen.getByTestId('markdown-command-bold'));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('a **word** b');
|
||||
});
|
||||
});
|
||||
|
||||
it('inserts a variable token at the caret', async () => {
|
||||
render(<Harness initialValue="env: " />);
|
||||
|
||||
select(5, 5);
|
||||
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
|
||||
|
||||
// The row shows the name and kind badge.
|
||||
const row = await screen.findByTestId('markdown-variable-environment');
|
||||
expect(row).toHaveTextContent('$environment');
|
||||
expect(row).toHaveTextContent('QUERY');
|
||||
|
||||
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
|
||||
// CodeMirror mocks stub out.
|
||||
fireEvent.click(row);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(documentText()).toBe('env: $environment');
|
||||
});
|
||||
});
|
||||
|
||||
it('colours Markdown syntax and variable tokens in the source', async () => {
|
||||
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
|
||||
});
|
||||
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
|
||||
'{{team}}',
|
||||
);
|
||||
});
|
||||
|
||||
describe('uncontrolled document', () => {
|
||||
const type = (at: number, text: string): void => {
|
||||
act(() => {
|
||||
getView().dispatch({
|
||||
changes: { from: at, insert: text },
|
||||
selection: { anchor: at + text.length },
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const focusEditor = (): void => {
|
||||
act(() => {
|
||||
getView().focus();
|
||||
});
|
||||
};
|
||||
|
||||
it('keeps the document and caret while the caller lags behind the typing', () => {
|
||||
render(<LaggingHarness />);
|
||||
focusEditor();
|
||||
|
||||
type(0, 'a');
|
||||
type(1, 'b');
|
||||
type(2, 'c');
|
||||
|
||||
expect(documentText()).toBe('abc');
|
||||
expect(getView().state.selection.main.head).toBe(3);
|
||||
});
|
||||
|
||||
it('reports every keystroke to the caller', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="ab" onChange={onChange} />);
|
||||
|
||||
type(2, 'c');
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('abc');
|
||||
});
|
||||
|
||||
it('does not report the seed back as a change', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<MarkdownEditor value="seeded" onChange={onChange} />);
|
||||
|
||||
expect(documentText()).toBe('seeded');
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('applies an external replacement while the editor is unfocused', async () => {
|
||||
render(<ExternalHarness />);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('after');
|
||||
});
|
||||
|
||||
it('ignores a replacement that arrives while the author is still typing', () => {
|
||||
render(<ExternalHarness />);
|
||||
focusEditor();
|
||||
|
||||
// fireEvent: a real click would blur the editor first. This covers an update
|
||||
// arriving on its own, while the author is still in the document.
|
||||
fireEvent.click(screen.getByRole('button', { name: 'push' }));
|
||||
|
||||
expect(documentText()).toBe('before');
|
||||
});
|
||||
|
||||
it('counts characters from the document, not from the lagging value', async () => {
|
||||
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
|
||||
|
||||
type(2, 'cde');
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
|
||||
'5 chars',
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('offers both list kinds in the toolbar', () => {
|
||||
render(<Harness />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-bulleted-list'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('markdown-command-numbered-list'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables authoring affordances when read-only', () => {
|
||||
render(
|
||||
<MarkdownEditor
|
||||
value="body"
|
||||
onChange={jest.fn()}
|
||||
variables={VARIABLES}
|
||||
readOnly
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
|
||||
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
|
||||
});
|
||||
|
||||
it('hides the insert-variable control when none are available', () => {
|
||||
render(<Harness variables={[]} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('markdown-insert-variable'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
|
||||
import type { EditorSnapshot, EditorTransform } from '../types';
|
||||
|
||||
const commandById = (id: string): EditorTransform => {
|
||||
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
|
||||
if (!command) {
|
||||
throw new Error(`unknown command: ${id}`);
|
||||
}
|
||||
return command.run;
|
||||
};
|
||||
|
||||
const heading = commandById('heading');
|
||||
const bold = commandById('bold');
|
||||
const italic = commandById('italic');
|
||||
const bulletedList = commandById('bulleted-list');
|
||||
const numberedList = commandById('numbered-list');
|
||||
const link = commandById('link');
|
||||
const code = commandById('code');
|
||||
const table = commandById('table');
|
||||
|
||||
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
|
||||
const snapshot = (marked: string): EditorSnapshot => {
|
||||
if (marked.includes('|')) {
|
||||
const caret = marked.indexOf('|');
|
||||
return {
|
||||
text: marked.replace('|', ''),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
const start = marked.indexOf('[');
|
||||
const end = marked.indexOf(']') - 1;
|
||||
return {
|
||||
text: marked.replace('[', '').replace(']', ''),
|
||||
selectionStart: start,
|
||||
selectionEnd: end,
|
||||
};
|
||||
};
|
||||
|
||||
const selectionOf = (result: EditorSnapshot): string =>
|
||||
result.text.slice(result.selectionStart, result.selectionEnd);
|
||||
|
||||
describe('heading', () => {
|
||||
it('prefixes the caret line and keeps the caret on the same character', () => {
|
||||
const result = heading(snapshot('Chec|kout'));
|
||||
|
||||
expect(result.text).toBe('## Checkout');
|
||||
expect(result.selectionStart).toBe(7);
|
||||
});
|
||||
|
||||
it('strips the prefix when every selected line already has one', () => {
|
||||
const result = heading({
|
||||
text: '## one\n### two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('adds the prefix when only some selected lines have one', () => {
|
||||
const result = heading({
|
||||
text: '## one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## ## one\n## two');
|
||||
});
|
||||
|
||||
it('does not pull in the line after a selection ending on a line break', () => {
|
||||
const result = heading({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulleted list', () => {
|
||||
it('bullets every line of a multi-line selection', () => {
|
||||
const result = bulletedList({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
expect(selectionOf(result)).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('unbullets a list written with a different marker', () => {
|
||||
const result = bulletedList({
|
||||
text: '* one\n+ two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numbered list', () => {
|
||||
it('numbers each line of the selection in order', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\ntwo\nthree',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two\n3. three');
|
||||
});
|
||||
|
||||
it('unnumbers a list whose numbering is not sequential', () => {
|
||||
const result = numberedList({
|
||||
text: '1. one\n5. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching between list kinds', () => {
|
||||
it('converts bullets to numbers rather than marking them twice', () => {
|
||||
const result = numberedList({
|
||||
text: '- one\n- two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two');
|
||||
});
|
||||
|
||||
it('converts numbers to bullets', () => {
|
||||
const result = bulletedList({
|
||||
text: '1. one\n2. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('keeps indentation so nested items stay nested', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\n - nested',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 16,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n 2. nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold and italic', () => {
|
||||
it('wraps the selection and keeps the original text selected', () => {
|
||||
const result = bold(snapshot('a [word] b'));
|
||||
|
||||
expect(result.text).toBe('a **word** b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit inside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit just outside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 8,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('leaves the caret between the markers when nothing is selected', () => {
|
||||
const result = italic(snapshot('a |b'));
|
||||
|
||||
expect(result.text).toBe('a __b');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mistake a leading document boundary for a marker', () => {
|
||||
const result = bold(snapshot('[word] tail'));
|
||||
|
||||
expect(result.text).toBe('**word** tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('selects the url when the label came from the selection', () => {
|
||||
const result = link(snapshot('see [docs] now'));
|
||||
|
||||
expect(result.text).toBe('see [docs](https://) now');
|
||||
expect(selectionOf(result)).toBe('https://');
|
||||
});
|
||||
|
||||
it('selects the label placeholder when nothing was selected', () => {
|
||||
const result = link(snapshot('see |'));
|
||||
|
||||
expect(result.text).toBe('see [text](https://)');
|
||||
expect(selectionOf(result)).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code', () => {
|
||||
it('uses backticks for a single-line selection', () => {
|
||||
const result = code(snapshot('run [npm] here'));
|
||||
|
||||
expect(result.text).toBe('run `npm` here');
|
||||
});
|
||||
|
||||
it('fences a multi-line selection and selects its content', () => {
|
||||
const result = code({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('```\none\ntwo\n```');
|
||||
expect(selectionOf(result)).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('starts the skeleton on its own line and selects the first header cell', () => {
|
||||
const result = table(snapshot('intro|'));
|
||||
|
||||
expect(result.text).toBe(
|
||||
'intro\n| Column | Column |\n| --- | --- |\n| | |',
|
||||
);
|
||||
expect(selectionOf(result)).toBe('Column');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertText', () => {
|
||||
it('replaces the selection and leaves the caret after the insertion', () => {
|
||||
const result = insertText(snapshot('env is [old]'), '{{env}}');
|
||||
|
||||
expect(result.text).toBe('env is {{env}}');
|
||||
expect(result.selectionStart).toBe(14);
|
||||
expect(result.selectionEnd).toBe(14);
|
||||
});
|
||||
});
|
||||
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// The body is persisted inline in the dashboard JSON, so its length is capped.
|
||||
export const MARKDOWN_MAX_LENGTH = 16000;
|
||||
|
||||
/** The canonical syntax; the renderer resolves the other three too. */
|
||||
export const formatVariableToken = (name: string): string => `$${name}`;
|
||||
|
||||
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
|
||||
// First: consecutive lines joining into one paragraph is the CommonMark rule
|
||||
// authors trip over before any of the formatting syntax.
|
||||
{ syntax: 'blank line', label: 'New paragraph' },
|
||||
{ syntax: '2 spaces + ⏎', label: 'Line break' },
|
||||
{ syntax: '# Heading', label: 'Heading (1–6 #)' },
|
||||
{ syntax: '**bold**', label: 'Bold' },
|
||||
{ syntax: '_italic_', label: 'Italic' },
|
||||
{ syntax: '- item', label: 'Bulleted list' },
|
||||
{ syntax: '1. item', label: 'Numbered list' },
|
||||
{ syntax: '- [ ] task', label: 'Task list' },
|
||||
{ syntax: '[label](url)', label: 'Link' },
|
||||
{ syntax: '', label: 'Image' },
|
||||
{ syntax: '`code`', label: 'Inline code' },
|
||||
{ syntax: '```lang', label: 'Code block' },
|
||||
{ syntax: '> quote', label: 'Blockquote' },
|
||||
{ syntax: '| a | b |', label: 'Table' },
|
||||
];
|
||||
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
// Narrows a whole-document replacement to the range that changed, so a toolbar
|
||||
// action doesn't invalidate the document's decorations or scroll position.
|
||||
function toChangeSpec(
|
||||
previous: string,
|
||||
next: string,
|
||||
): { from: number; to: number; insert: string } | null {
|
||||
if (previous === next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorter = Math.min(previous.length, next.length);
|
||||
let start = 0;
|
||||
while (start < shorter && previous[start] === next[start]) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let previousEnd = previous.length;
|
||||
let nextEnd = next.length;
|
||||
while (
|
||||
previousEnd > start &&
|
||||
nextEnd > start &&
|
||||
previous[previousEnd - 1] === next[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
|
||||
}
|
||||
|
||||
export function readSnapshot(view: EditorView): EditorSnapshot {
|
||||
const range = view.state.selection.main;
|
||||
return {
|
||||
text: view.state.doc.toString(),
|
||||
selectionStart: range.from,
|
||||
selectionEnd: range.to,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
|
||||
export function applyTransform(
|
||||
view: EditorView,
|
||||
transform: EditorTransform,
|
||||
): boolean {
|
||||
if (view.state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = transform(readSnapshot(view));
|
||||
const changes = toChangeSpec(view.state.doc.toString(), next.text);
|
||||
view.dispatch({
|
||||
...(changes ? { changes } : {}),
|
||||
selection: { anchor: next.selectionStart, head: next.selectionEnd },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replaces the whole document, for seeding and external replacements. */
|
||||
export function replaceDocument(view: EditorView, next: string): void {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: next },
|
||||
});
|
||||
}
|
||||
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
const BOLD_MARKER = '**';
|
||||
const ITALIC_MARKER = '_';
|
||||
const INLINE_CODE_MARKER = '`';
|
||||
const CODE_FENCE = '```';
|
||||
const HEADING_PREFIX = '## ';
|
||||
const BULLET_MARKER = '- ';
|
||||
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
|
||||
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
|
||||
// Either kind of marker, matched after the indent has been split off.
|
||||
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
|
||||
const INDENT_PATTERN = /^[ \t]*/;
|
||||
|
||||
const LINK_LABEL_PLACEHOLDER = 'text';
|
||||
const LINK_URL_PLACEHOLDER = 'https://';
|
||||
const TABLE_CELL_PLACEHOLDER = 'Column';
|
||||
const TABLE_SNIPPET = [
|
||||
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
|
||||
'| --- | --- |',
|
||||
'| | |',
|
||||
].join('\n');
|
||||
|
||||
interface LineRange {
|
||||
start: number;
|
||||
end: number;
|
||||
}
|
||||
|
||||
// A selection ending exactly on a line break stops there rather than pulling in
|
||||
// the next line, so "select the line, hit list" doesn't bullet the line below too.
|
||||
function expandToLines(text: string, from: number, to: number): LineRange {
|
||||
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
|
||||
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
|
||||
const breakAfter = text.indexOf('\n', end);
|
||||
return {
|
||||
start: breakBefore + 1,
|
||||
end: breakAfter === -1 ? text.length : breakAfter,
|
||||
};
|
||||
}
|
||||
|
||||
/** Pads `block` so it starts and ends on its own line. */
|
||||
function replaceWithBlock(
|
||||
snapshot: EditorSnapshot,
|
||||
block: string,
|
||||
): { text: string; blockStart: number } {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const before = text.slice(0, selectionStart);
|
||||
const after = text.slice(selectionEnd);
|
||||
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
|
||||
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
|
||||
return {
|
||||
text: before + lead + block + trail + after,
|
||||
blockStart: before.length + lead.length,
|
||||
};
|
||||
}
|
||||
|
||||
/** Rewrites every line the selection touches. */
|
||||
function replaceLines(
|
||||
snapshot: EditorSnapshot,
|
||||
mapLines: (lines: string[]) => string[],
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
|
||||
const lines = text.slice(start, end).split('\n');
|
||||
const nextLines = mapLines(lines);
|
||||
const block = nextLines.join('\n');
|
||||
const nextText = text.slice(0, start) + block + text.slice(end);
|
||||
|
||||
if (selectionStart !== selectionEnd) {
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: start,
|
||||
selectionEnd: start + block.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Caret-only: the range covers one line, so shift by that line's delta.
|
||||
const shifted = selectionStart + nextLines[0].length - lines[0].length;
|
||||
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
|
||||
return { text: nextText, selectionStart: caret, selectionEnd: caret };
|
||||
}
|
||||
|
||||
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
|
||||
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line) =>
|
||||
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles this kind of list marker. A line carrying the *other* kind is converted
|
||||
* rather than marked twice, and indentation is preserved so nesting survives.
|
||||
* `markerAt` takes the line's position, which is what lets an ordered list number.
|
||||
*/
|
||||
function toggleList(
|
||||
pattern: RegExp,
|
||||
markerAt: (index: number) => string,
|
||||
): EditorTransform {
|
||||
return (snapshot): EditorSnapshot =>
|
||||
replaceLines(snapshot, (lines) => {
|
||||
const isApplied = lines.every((line) => pattern.test(line));
|
||||
return lines.map((line, index) => {
|
||||
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
|
||||
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
|
||||
return isApplied
|
||||
? `${indent}${body}`
|
||||
: `${indent}${markerAt(index)}${body}`;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Unwraps when the markers are already there, whether they sit inside the selection
|
||||
* (`**bold**` selected whole) or just outside it (only `bold` selected).
|
||||
*/
|
||||
function toggleWrap(marker: string): EditorTransform {
|
||||
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const width = marker.length;
|
||||
|
||||
if (
|
||||
selected.length >= width * 2 &&
|
||||
selected.startsWith(marker) &&
|
||||
selected.endsWith(marker)
|
||||
) {
|
||||
const inner = selected.slice(width, -width);
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
|
||||
selectionStart,
|
||||
selectionEnd: selectionStart + inner.length,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
selectionStart >= width &&
|
||||
text.slice(selectionStart - width, selectionStart) === marker &&
|
||||
text.slice(selectionEnd, selectionEnd + width) === marker
|
||||
) {
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart - width) +
|
||||
selected +
|
||||
text.slice(selectionEnd + width),
|
||||
selectionStart: selectionStart - width,
|
||||
selectionEnd: selectionStart - width + selected.length,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
text:
|
||||
text.slice(0, selectionStart) +
|
||||
marker +
|
||||
selected +
|
||||
marker +
|
||||
text.slice(selectionEnd),
|
||||
selectionStart: selectionStart + width,
|
||||
selectionEnd: selectionStart + width + selected.length,
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
/** Lands the selection on whichever half is still a placeholder. */
|
||||
const insertLink: EditorTransform = ({
|
||||
text,
|
||||
selectionStart,
|
||||
selectionEnd,
|
||||
}): EditorSnapshot => {
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
const label = selected || LINK_LABEL_PLACEHOLDER;
|
||||
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
|
||||
const nextText =
|
||||
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
|
||||
// `[` + label + `](` is label.length + 3 characters.
|
||||
const target = selected
|
||||
? {
|
||||
from: selectionStart + label.length + 3,
|
||||
length: LINK_URL_PLACEHOLDER.length,
|
||||
}
|
||||
: { from: selectionStart + 1, length: label.length };
|
||||
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: target.from,
|
||||
selectionEnd: target.from + target.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Backticks for a single-line selection, a fence for a multi-line one. */
|
||||
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const selected = text.slice(selectionStart, selectionEnd);
|
||||
if (!selected.includes('\n')) {
|
||||
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
|
||||
}
|
||||
|
||||
const { text: nextText, blockStart } = replaceWithBlock(
|
||||
snapshot,
|
||||
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
|
||||
);
|
||||
const contentStart = blockStart + CODE_FENCE.length + 1;
|
||||
return {
|
||||
text: nextText,
|
||||
selectionStart: contentStart,
|
||||
selectionEnd: contentStart + selected.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Selects the first header cell, for immediate typing. */
|
||||
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
|
||||
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
|
||||
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
|
||||
return {
|
||||
text,
|
||||
selectionStart: firstCell,
|
||||
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
|
||||
};
|
||||
};
|
||||
|
||||
/** Replaces the selection and leaves the caret after the insertion. */
|
||||
export function insertText(
|
||||
snapshot: EditorSnapshot,
|
||||
value: string,
|
||||
): EditorSnapshot {
|
||||
const { text, selectionStart, selectionEnd } = snapshot;
|
||||
const caret = selectionStart + value.length;
|
||||
return {
|
||||
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
|
||||
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
|
||||
export const MARKDOWN_COMMANDS: EditorCommand[] = [
|
||||
{
|
||||
id: 'heading',
|
||||
label: 'Heading',
|
||||
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
|
||||
},
|
||||
{
|
||||
id: 'bold',
|
||||
label: 'Bold',
|
||||
run: toggleWrap(BOLD_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'italic',
|
||||
label: 'Italic',
|
||||
run: toggleWrap(ITALIC_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'bulleted-list',
|
||||
label: 'Bulleted list',
|
||||
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
|
||||
},
|
||||
{
|
||||
id: 'numbered-list',
|
||||
label: 'Numbered list',
|
||||
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
|
||||
},
|
||||
{ id: 'link', label: 'Link', run: insertLink },
|
||||
{ id: 'code', label: 'Code', run: insertCode },
|
||||
{ id: 'table', label: 'Table', run: insertTable },
|
||||
];
|
||||
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
import type { Extension, Line, Range } from '@codemirror/state';
|
||||
import {
|
||||
Decoration,
|
||||
type DecorationSet,
|
||||
EditorView,
|
||||
ViewPlugin,
|
||||
type ViewUpdate,
|
||||
} from '@codemirror/view';
|
||||
|
||||
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
|
||||
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
|
||||
const QUOTE_PATTERN = /^ {0,3}> ?/;
|
||||
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
|
||||
|
||||
/**
|
||||
* Convention: capture group 1, when present, is a left guard the token excludes —
|
||||
* the token runs from the end of that group to the end of the match. Lookbehind is
|
||||
* avoided for Safari compatibility, so guards are captured rather than asserted.
|
||||
*/
|
||||
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
|
||||
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
|
||||
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
|
||||
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
|
||||
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
|
||||
{
|
||||
// The four variable syntaxes a dashboard body may carry.
|
||||
pattern:
|
||||
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
|
||||
className: 'cm-md-variable',
|
||||
},
|
||||
];
|
||||
|
||||
const MARKS = {
|
||||
heading: Decoration.mark({ class: 'cm-md-heading' }),
|
||||
quote: Decoration.mark({ class: 'cm-md-quote' }),
|
||||
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
|
||||
code: Decoration.mark({ class: 'cm-md-code' }),
|
||||
} as const;
|
||||
|
||||
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
|
||||
pattern,
|
||||
mark: Decoration.mark({ class: className }),
|
||||
}));
|
||||
|
||||
function pushInlineMarks(
|
||||
lineText: string,
|
||||
lineFrom: number,
|
||||
ranges: Range<Decoration>[],
|
||||
): void {
|
||||
INLINE_MARKS.forEach(({ pattern, mark }) => {
|
||||
pattern.lastIndex = 0;
|
||||
let match = pattern.exec(lineText);
|
||||
while (match !== null) {
|
||||
const guardLength = match[1]?.length ?? 0;
|
||||
const from = lineFrom + match.index + guardLength;
|
||||
const to = lineFrom + match.index + match[0].length;
|
||||
if (to > from) {
|
||||
ranges.push(mark.range(from, to));
|
||||
}
|
||||
match = pattern.exec(lineText);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
|
||||
if (HEADING_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.heading.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
if (QUOTE_PATTERN.test(line.text)) {
|
||||
ranges.push(MARKS.quote.range(line.from, line.to));
|
||||
return;
|
||||
}
|
||||
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
|
||||
if (listMarker) {
|
||||
ranges.push(
|
||||
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Scans the whole document rather than the viewport: fenced blocks opening above
|
||||
// the visible range would otherwise be mis-detected. Bounded by the length cap.
|
||||
function buildDecorations(view: EditorView): DecorationSet {
|
||||
const { doc } = view.state;
|
||||
const ranges: Range<Decoration>[] = [];
|
||||
let isInsideFence = false;
|
||||
|
||||
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
|
||||
const line = doc.line(lineNumber);
|
||||
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
|
||||
|
||||
if (isFenceDelimiter || isInsideFence) {
|
||||
if (line.to > line.from) {
|
||||
ranges.push(MARKS.code.range(line.from, line.to));
|
||||
}
|
||||
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
|
||||
} else {
|
||||
pushBlockMark(line, ranges);
|
||||
pushInlineMarks(line.text, line.from, ranges);
|
||||
}
|
||||
}
|
||||
|
||||
return Decoration.set(ranges, true);
|
||||
}
|
||||
|
||||
// Colours come from custom properties so the SCSS module owns light/dark.
|
||||
const syntaxTheme = EditorView.theme({
|
||||
'.cm-md-heading': {
|
||||
color: 'var(--md-syntax-heading)',
|
||||
fontWeight: '600',
|
||||
},
|
||||
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
|
||||
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
|
||||
'.cm-md-code': { color: 'var(--md-syntax-code)' },
|
||||
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
|
||||
'.cm-md-emphasis': {
|
||||
color: 'var(--md-syntax-emphasis)',
|
||||
fontStyle: 'italic',
|
||||
},
|
||||
'.cm-md-link': { color: 'var(--md-syntax-link)' },
|
||||
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
|
||||
});
|
||||
|
||||
const highlightPlugin = ViewPlugin.fromClass(
|
||||
class {
|
||||
decorations: DecorationSet;
|
||||
|
||||
constructor(view: EditorView) {
|
||||
this.decorations = buildDecorations(view);
|
||||
}
|
||||
|
||||
update(update: ViewUpdate): void {
|
||||
if (update.docChanged || update.viewportChanged) {
|
||||
this.decorations = buildDecorations(update.view);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ decorations: (plugin): DecorationSet => plugin.decorations },
|
||||
);
|
||||
|
||||
/**
|
||||
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
|
||||
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
|
||||
* colouring pass over a body the renderer parses for real.
|
||||
*/
|
||||
export function markdownHighlight(): Extension {
|
||||
return [highlightPlugin, syntaxTheme];
|
||||
}
|
||||
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** The value every editor command reads and returns. */
|
||||
export interface EditorSnapshot {
|
||||
text: string;
|
||||
selectionStart: number;
|
||||
selectionEnd: number;
|
||||
}
|
||||
|
||||
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
|
||||
|
||||
export interface EditorVariable {
|
||||
name: string;
|
||||
/** Short tag for the variable's kind, e.g. "QUERY". */
|
||||
badge?: string;
|
||||
}
|
||||
|
||||
export interface EditorCommand {
|
||||
id: string;
|
||||
/** Accessible name and tooltip for the toolbar button. */
|
||||
label: string;
|
||||
run: EditorTransform;
|
||||
}
|
||||
|
||||
/** 1-based, as the status bar reports it. */
|
||||
export interface CursorPosition {
|
||||
line: number;
|
||||
column: number;
|
||||
}
|
||||
@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
|
||||
[PANEL_TYPES.BAR]: Uplot,
|
||||
[PANEL_TYPES.PIE]: null,
|
||||
[PANEL_TYPES.HISTOGRAM]: Uplot,
|
||||
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
};
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
|
||||
@@ -329,9 +329,10 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
@@ -420,9 +421,10 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DraftingCompass,
|
||||
FileKey,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
Key,
|
||||
Logs,
|
||||
Receipt,
|
||||
@@ -41,6 +42,14 @@ export interface ResourcePanelConfig {
|
||||
* not all of them
|
||||
*/
|
||||
export const RESOURCE_PANELS: Record<AuthZResource, ResourcePanelConfig> = {
|
||||
dashboard: {
|
||||
label: 'Dashboards',
|
||||
description: 'Dashboards and their panels across the workspace.',
|
||||
icon: Grid3X3,
|
||||
selectorPlaceholder:
|
||||
'Type dashboard ID, separate multiple with comma or space',
|
||||
docsAnchor: 'dashboard',
|
||||
},
|
||||
'factor-api-key': {
|
||||
label: 'API Keys',
|
||||
description: 'Programmatic access tokens for the workspace.',
|
||||
|
||||
@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -14,6 +14,8 @@ export const PanelTypeVsPanelWrapper = {
|
||||
[PANEL_TYPES.LIST]: ListPanelWrapper,
|
||||
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
|
||||
[PANEL_TYPES.TRACE]: null,
|
||||
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
|
||||
[PANEL_TYPES.TEXT]: null,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: null,
|
||||
[PANEL_TYPES.PIE]: PiePanelWrapper,
|
||||
[PANEL_TYPES.BAR]: BarPanel,
|
||||
|
||||
@@ -0,0 +1,137 @@
|
||||
import { AllTheProviders, renderHook, waitFor } from 'tests/test-utils';
|
||||
import { rest } from 'msw';
|
||||
import { server } from 'mocks-server/server';
|
||||
import {
|
||||
AUTHZ_CHECK_URL,
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import {
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import { useDashboardPermissions } from '../useDashboardPermissions';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
|
||||
describe('useDashboardPermissions - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('resolves every verb when all are granted', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(true);
|
||||
expect(result.current.canUpdate).toBe(true);
|
||||
expect(result.current.canDelete).toBe(true);
|
||||
expect(result.current.canEdit).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
it('resolves every verb as false when all are denied', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
expect(result.current.canUpdate).toBe(false);
|
||||
expect(result.current.canDelete).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial', () => {
|
||||
// Authz guide rule 2: an edit affordance needs read as well as update.
|
||||
it('denies canEdit when update is granted but read is not', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardUpdatePermission(DASHBOARD_ID)));
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canUpdate).toBe(true);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it('denies canEdit when read is granted but update is not', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardReadPermission(DASHBOARD_ID)));
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(true);
|
||||
expect(result.current.canUpdate).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('check failure', () => {
|
||||
// An authz outage must not read as a denial — callers fall open and let the
|
||||
// API decide, matching AuthZGuard's onFailRenderContent default.
|
||||
it('reports hasError and grants nothing when the check fails', async () => {
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, (_req, res, ctx) => res(ctx.status(500))),
|
||||
);
|
||||
|
||||
const { result } = renderHook(() => useDashboardPermissions(DASHBOARD_ID), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
// A check that cannot answer is not a grant.
|
||||
await waitFor(() =>
|
||||
expect(result.current.isReadPermissionLoading).toBe(false),
|
||||
);
|
||||
expect(result.current.canRead).toBe(false);
|
||||
expect(result.current.canEdit).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled', () => {
|
||||
it('fires no check when disabled', async () => {
|
||||
const onCheck = jest.fn();
|
||||
server.use(
|
||||
rest.post(AUTHZ_CHECK_URL, async (req, res, ctx) => {
|
||||
onCheck();
|
||||
const payload = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
ctx.json({ data: payload, status: 'success' }),
|
||||
);
|
||||
}),
|
||||
);
|
||||
|
||||
renderHook(() => useDashboardPermissions(DASHBOARD_ID, { enabled: false }), {
|
||||
wrapper: AllTheProviders,
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
expect(onCheck).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
import {
|
||||
DashboardCreatePermission,
|
||||
DashboardListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export interface DashboardCollectionPermissions {
|
||||
canList: boolean;
|
||||
canCreate: boolean;
|
||||
isLoading: boolean;
|
||||
/**
|
||||
* The check itself failed. Callers should fall open — behave as before authz
|
||||
* and let the API decide — rather than treat an outage as a denial.
|
||||
*/
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
// Module-level so the useQueries identity stays stable across renders.
|
||||
const CHECKS = [DashboardListPermission, DashboardCreatePermission];
|
||||
|
||||
/** Collection-level dashboard permissions (wildcard selector). */
|
||||
export function useDashboardCollectionPermissions(): DashboardCollectionPermissions {
|
||||
const { isGranted, isLoading, error } = useAuthZ(CHECKS);
|
||||
|
||||
return {
|
||||
canList: isGranted(DashboardListPermission),
|
||||
canCreate: isGranted(DashboardCreatePermission),
|
||||
isLoading,
|
||||
hasError: !!error,
|
||||
};
|
||||
}
|
||||
42
frontend/src/hooks/dashboards/useDashboardLockPermission.ts
Normal file
42
frontend/src/hooks/dashboards/useDashboardLockPermission.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { DashboardtypesSourceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useDashboardPermissions } from './useDashboardPermissions';
|
||||
|
||||
export interface DashboardLockPermission {
|
||||
canToggleLock: boolean;
|
||||
isLoading: boolean;
|
||||
/** Non-permission obstacle only; empty when a permission is what's missing. */
|
||||
disabledTooltip: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Needs `dashboard:update`, and the handler then rejects integration dashboards;
|
||||
* ownership is not a factor. It rejects system dashboards too, unchecked here
|
||||
* because the list query filters them out.
|
||||
*/
|
||||
export function useDashboardLockPermission({
|
||||
dashboardId,
|
||||
source,
|
||||
enabled = true,
|
||||
}: {
|
||||
dashboardId: string;
|
||||
source: DashboardtypesSourceDTO;
|
||||
enabled?: boolean;
|
||||
}): DashboardLockPermission {
|
||||
const { t } = useTranslation('dashboard');
|
||||
const { canEdit, areOtherPermissionsLoading } = useDashboardPermissions(
|
||||
dashboardId,
|
||||
{ enabled },
|
||||
);
|
||||
|
||||
const isLockable = source !== DashboardtypesSourceDTO.integration;
|
||||
|
||||
return {
|
||||
canToggleLock: !areOtherPermissionsLoading && isLockable && canEdit,
|
||||
isLoading: areOtherPermissionsLoading,
|
||||
// Empty without `canEdit` so the missing permission surfaces instead.
|
||||
disabledTooltip:
|
||||
canEdit && !isLockable ? t('lock_integration_dashboard') : '',
|
||||
};
|
||||
}
|
||||
76
frontend/src/hooks/dashboards/useDashboardPermissions.ts
Normal file
76
frontend/src/hooks/dashboards/useDashboardPermissions.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export interface DashboardPermissions {
|
||||
canRead: boolean;
|
||||
canUpdate: boolean;
|
||||
canDelete: boolean;
|
||||
/** Per the authz guide, an edit affordance needs `read` as well as `update`. */
|
||||
canEdit: boolean;
|
||||
/** `read` renders the dashboard, so a page gates its mount on this alone. */
|
||||
isReadPermissionLoading: boolean;
|
||||
/** `update`/`delete` gate controls only; the page does not wait on them. */
|
||||
areOtherPermissionsLoading: boolean;
|
||||
readPermission: BrandedPermission;
|
||||
updatePermission: BrandedPermission;
|
||||
deletePermission: BrandedPermission;
|
||||
/** `[read, update]`, so a denial names both. */
|
||||
editChecks: BrandedPermission[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resource-level dashboard permissions. Pass `enabled: false` while the id is
|
||||
* unknown, so no check fires against an empty selector.
|
||||
*/
|
||||
export function useDashboardPermissions(
|
||||
dashboardId: string,
|
||||
options?: { enabled?: boolean },
|
||||
): DashboardPermissions {
|
||||
const enabled = options?.enabled ?? true;
|
||||
|
||||
const { readPermission, updatePermission, deletePermission } = useMemo(
|
||||
() => ({
|
||||
readPermission: buildDashboardReadPermission(dashboardId),
|
||||
updatePermission: buildDashboardUpdatePermission(dashboardId),
|
||||
deletePermission: buildDashboardDeletePermission(dashboardId),
|
||||
}),
|
||||
[dashboardId],
|
||||
);
|
||||
|
||||
const checks = useMemo(
|
||||
() => [readPermission, updatePermission, deletePermission],
|
||||
[readPermission, updatePermission, deletePermission],
|
||||
);
|
||||
|
||||
const { isGranted, isLoading } = useAuthZ(checks, { enabled });
|
||||
|
||||
const canRead = isGranted(readPermission);
|
||||
const canUpdate = isGranted(updatePermission);
|
||||
const canDelete = isGranted(deletePermission);
|
||||
|
||||
const editChecks = useMemo(
|
||||
() => [readPermission, updatePermission],
|
||||
[readPermission, updatePermission],
|
||||
);
|
||||
|
||||
return {
|
||||
canRead,
|
||||
canUpdate,
|
||||
canDelete,
|
||||
canEdit: canRead && canUpdate,
|
||||
// One request covers all three, so the two flags only differ when a page
|
||||
// preloads `read` ahead of the rest — see `preloadChecks`.
|
||||
isReadPermissionLoading: isLoading,
|
||||
areOtherPermissionsLoading: isLoading,
|
||||
readPermission,
|
||||
updatePermission,
|
||||
deletePermission,
|
||||
editChecks,
|
||||
};
|
||||
}
|
||||
62
frontend/src/hooks/dashboards/useToggleDashboardLock.ts
Normal file
62
frontend/src/hooks/dashboards/useToggleDashboardLock.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { useMutation, useQueryClient } from 'react-query';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type { GetDashboardV2200 } from 'api/generated/services/sigNoz.schemas';
|
||||
import APIError from 'types/api/error';
|
||||
|
||||
interface Args {
|
||||
dashboardId: string;
|
||||
isLocked: boolean;
|
||||
/** Called with the new lock state — for analytics and any extra invalidation. */
|
||||
onSuccess?: (locked: boolean) => void;
|
||||
/** Called on failure — for rolling back optimistic state. */
|
||||
onError?: (error: APIError) => void;
|
||||
}
|
||||
|
||||
export interface ToggleDashboardLock {
|
||||
toggleLock: () => void;
|
||||
isTogglingLock: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggles a dashboard's lock and patches the detail-page cache, which runs
|
||||
* `staleTime: Infinity` + `refetchOnMount: false` and would otherwise show the
|
||||
* stale state. Only the flag is patched: a refetch would reload every panel.
|
||||
*/
|
||||
export function useToggleDashboardLock({
|
||||
dashboardId,
|
||||
isLocked,
|
||||
onSuccess,
|
||||
onError,
|
||||
}: Args): ToggleDashboardLock {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { mutate, isLoading } = useMutation({
|
||||
mutationFn: () =>
|
||||
isLocked
|
||||
? unlockDashboardV2({ id: dashboardId })
|
||||
: lockDashboardV2({ id: dashboardId }),
|
||||
onSuccess: () => {
|
||||
const next = !isLocked;
|
||||
toast.success(next ? 'Dashboard locked' : 'Dashboard unlocked');
|
||||
const key = getGetDashboardV2QueryKey({ id: dashboardId });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
onSuccess?.(next);
|
||||
},
|
||||
onError: (error: APIError) => {
|
||||
onError?.(error);
|
||||
},
|
||||
});
|
||||
|
||||
return { toggleLock: mutate, isTogglingLock: isLoading };
|
||||
}
|
||||
@@ -53,22 +53,6 @@ describe('AuthZButton', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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">
|
||||
|
||||
@@ -7,14 +7,19 @@ 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;
|
||||
/** Replace the standard denial wording; prefer the default. */
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* A non-permission block the consumer already knows about — a lock, an
|
||||
* immutable resource. Takes precedence over `checks`, which are then skipped.
|
||||
*/
|
||||
disabledTooltip?: string;
|
||||
/** Which side of the button to render the tooltip against. */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
/**
|
||||
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
|
||||
* otherwise the tooltip will not have the correct z-index
|
||||
@@ -24,8 +29,10 @@ export type AuthZButtonProps = ButtonProps & {
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
tooltipMessage,
|
||||
authZEnabled = true,
|
||||
tooltipMessage,
|
||||
disabledTooltip,
|
||||
side,
|
||||
withPortal,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
@@ -34,6 +41,8 @@ function AuthZButton({
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
disabledTooltip={disabledTooltip}
|
||||
side={side}
|
||||
withPortal={withPortal}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
|
||||
@@ -4,6 +4,7 @@ import { buildPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import type {
|
||||
AuthZObject,
|
||||
BrandedPermission,
|
||||
UseAuthZResult,
|
||||
} from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import AuthZTooltip from './AuthZTooltip';
|
||||
@@ -11,15 +12,26 @@ import AuthZTooltip from './AuthZTooltip';
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
const noPermissions = {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
// Builds a full UseAuthZResult so `isGranted` stays consistent with `permissions`
|
||||
// rather than being a stub that could drift from it.
|
||||
function authZResult(overrides: Partial<UseAuthZResult> = {}): UseAuthZResult {
|
||||
const base: UseAuthZResult = {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
isGranted: (): boolean => false,
|
||||
refetchPermissions: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
return {
|
||||
...base,
|
||||
isGranted: (permission: BrandedPermission): boolean =>
|
||||
base.permissions?.[permission]?.isGranted === true,
|
||||
};
|
||||
}
|
||||
|
||||
const TestButton = (
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -42,10 +54,11 @@ const attachRolePerm = buildPermission(
|
||||
|
||||
describe('AuthZTooltip — single check', () => {
|
||||
it('renders child unchanged when permission is granted', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: true } },
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: true } },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -57,10 +70,11 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('disables child when permission is denied', () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -72,10 +86,11 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('shows formatted permission message in tooltip when denied', async () => {
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -95,7 +110,7 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('disables child while loading', () => {
|
||||
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
|
||||
mockUseAuthZ.mockReturnValue(authZResult({ isLoading: true }));
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -110,13 +125,14 @@ describe('AuthZTooltip — single check', () => {
|
||||
describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
it('renders child enabled when all checks are granted', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: true },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: true },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -129,13 +145,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('disables child when first check is denied, second granted', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -148,13 +165,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('disables child when both checks are denied and lists denied permissions in data attr', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -173,13 +191,14 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('shows multiple formatted permissions in tooltip when both denied', async () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
.errorContent {
|
||||
background: var(--callout-error-background) !important;
|
||||
border-color: var(--callout-error-border) !important;
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px !important;
|
||||
color: var(--foreground) !important;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import { cloneElement, CSSProperties, ReactElement, useMemo } from 'react';
|
||||
import {
|
||||
cloneElement,
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@@ -9,7 +17,9 @@ import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
import { formatPermission } from 'lib/authz/hooks/useAuthZ/utils';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from '../tooltipContent.module.scss';
|
||||
|
||||
const DISABLED_STYLE: CSSProperties = {
|
||||
pointerEvents: 'all',
|
||||
@@ -22,7 +32,23 @@ interface AuthZTooltipProps {
|
||||
checks: BrandedPermission[];
|
||||
children: ReactElement;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Replace the standard denial wording. Prefer the default — it names the exact
|
||||
* scopes — and reach for this only when a surface genuinely needs its own.
|
||||
*/
|
||||
tooltipMessage?: string;
|
||||
/**
|
||||
* A block the consumer already knows about that is not a permission — a lock,
|
||||
* an immutable resource, a forced read-only mount.
|
||||
*
|
||||
* It takes precedence over the checks, which are skipped entirely: the control
|
||||
* is unavailable either way, so running them would only cost a request. Set it
|
||||
* only when the non-permission block is the real obstacle, so a missing
|
||||
* permission still surfaces its own message.
|
||||
*/
|
||||
disabledTooltip?: string;
|
||||
/** Which side of the control to render against. Defaults to the top. */
|
||||
side?: 'top' | 'bottom' | 'left' | 'right';
|
||||
/**
|
||||
* Set this false when this button is used inside a modal/drawer of signozhq/ui,
|
||||
* otherwise the tooltip will not have the correct z-index
|
||||
@@ -47,10 +73,17 @@ function AuthZTooltip({
|
||||
children,
|
||||
enabled = true,
|
||||
tooltipMessage,
|
||||
disabledTooltip,
|
||||
side,
|
||||
withPortal,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const shouldCheck = enabled && checks.length > 0;
|
||||
const isPointerOverRef = useRef(false);
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
|
||||
// The block the consumer passed is already decisive, so the check is not run.
|
||||
const isBlocked = !!disabledTooltip;
|
||||
const shouldCheck = enabled && checks.length > 0 && !isBlocked;
|
||||
|
||||
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
|
||||
|
||||
@@ -61,6 +94,20 @@ function AuthZTooltip({
|
||||
return checks.filter((p) => permissions[p]?.isGranted === false);
|
||||
}, [checks, permissions]);
|
||||
|
||||
/**
|
||||
* Radix closes the tooltip on pointerdown and on click, and merges its own
|
||||
* handlers after the trigger's regardless of `preventDefault`, so the close is
|
||||
* filtered here. Clicking a dead control does nothing, which is exactly when
|
||||
* its reason is still wanted, so a close is ignored while the pointer remains
|
||||
* on it. Everything else stays Radix's to decide.
|
||||
*/
|
||||
const handleOpenChange = useCallback((next: boolean): void => {
|
||||
if (!next && isPointerOverRef.current) {
|
||||
return;
|
||||
}
|
||||
setIsOpen(next);
|
||||
}, []);
|
||||
|
||||
if (shouldCheck && isLoading) {
|
||||
return cloneElement(children, {
|
||||
disabled: true,
|
||||
@@ -71,7 +118,7 @@ function AuthZTooltip({
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
if (!isBlocked && (!shouldCheck || deniedPermissions.length === 0)) {
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -79,7 +126,7 @@ function AuthZTooltip({
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot>
|
||||
<TooltipRoot open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<TooltipTrigger asChild testId={childTestId}>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
@@ -87,11 +134,31 @@ function AuthZTooltip({
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
onPointerEnter: (): void => {
|
||||
isPointerOverRef.current = true;
|
||||
},
|
||||
onPointerLeave: (): void => {
|
||||
isPointerOverRef.current = false;
|
||||
},
|
||||
...(isBlocked
|
||||
? {}
|
||||
: { 'data-denied-permissions': deniedPermissions.join(',') }),
|
||||
})}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent className={styles.errorContent} withPortal={withPortal}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
<TooltipContent
|
||||
side={side}
|
||||
// A denial has no arrow; a state the user can act on is not an error
|
||||
// and reads as a normal tooltip.
|
||||
arrow={isBlocked}
|
||||
className={cx(
|
||||
isBlocked ? styles.blockedContent : styles.errorContent,
|
||||
styles.aboveOverlay,
|
||||
)}
|
||||
withPortal={withPortal}
|
||||
>
|
||||
{isBlocked
|
||||
? disabledTooltip
|
||||
: formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
|
||||
42
frontend/src/lib/authz/components/tooltipContent.module.scss
Normal file
42
frontend/src/lib/authz/components/tooltipContent.module.scss
Normal file
@@ -0,0 +1,42 @@
|
||||
// Shared presentation for every "you can't use this, here's why" tooltip, so the
|
||||
// explanation looks the same wherever it surfaces.
|
||||
//
|
||||
// These drive the bubble AND the arrow: @signozhq/ui paints both from
|
||||
// --tooltip-background / --tooltip-border-color, so setting the variables keeps
|
||||
// them in step. Overriding `background`/`border-color` directly styles only the
|
||||
// bubble and leaves the arrow on the default fill.
|
||||
.errorContent {
|
||||
--tooltip-background: var(--callout-error-background);
|
||||
--tooltip-border-color: var(--callout-error-border);
|
||||
--tooltip-foreground: var(--foreground);
|
||||
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
// Reasons naming two permissions run long; wrap rather than stretch the
|
||||
// bubble across the viewport.
|
||||
max-width: 260px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
// A block the user can act on themselves — a lock, an integration-owned or
|
||||
// legacy dashboard — reads as state, not as an access error.
|
||||
.blockedContent {
|
||||
--tooltip-background: var(--l2-background);
|
||||
--tooltip-border-color: var(--l2-border);
|
||||
--tooltip-foreground: var(--l1-foreground);
|
||||
|
||||
backdrop-filter: blur(15px);
|
||||
border-radius: 4px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
max-width: 260px;
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.aboveOverlay {
|
||||
// Lift above the dropdown menu (z 50) and the antd Drawer (z 1000) so the
|
||||
// tooltip is never clipped behind them. The arrow reads this too.
|
||||
--tooltip-z-index: 1100;
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
} from 'react-router-dom';
|
||||
import type { AuthZGuardProps } from 'lib/authz/components/AuthZGuard/AuthZGuard';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { useAuthZ } from 'lib/authz/hooks/useAuthZ/useAuthZ';
|
||||
|
||||
export type RouterContext = {
|
||||
/**
|
||||
@@ -45,6 +46,18 @@ export type WithAuthZOptions<P> = {
|
||||
checks:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
/**
|
||||
* Extra permissions to fetch in the same batch as `checks`, without gating on
|
||||
* them. `useAuthZ` coalesces everything requested in the same tick into one
|
||||
* request and caches per permission, so a component below the guard that
|
||||
* needs these resolves from cache instead of firing a second round trip and
|
||||
* flipping its controls once it lands.
|
||||
*
|
||||
* Never affects whether the content renders — a denial here is ignored.
|
||||
*/
|
||||
preloadChecks?:
|
||||
| BrandedPermission[]
|
||||
| ((props: P, router: RouterContext) => BrandedPermission[]);
|
||||
fallback?: AuthZGuardProps['fallback'];
|
||||
fallbackOnLoading?: AuthZGuardProps['fallbackOnLoading'];
|
||||
failOpenOnError?: AuthZGuardProps['onFailRenderContent'];
|
||||
@@ -86,12 +99,21 @@ export function createAuthZHOC<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
const { checks, ...guardProps } = opts;
|
||||
const { checks, preloadChecks, ...guardProps } = opts;
|
||||
|
||||
function Wrapped(props: P): ReactElement | null {
|
||||
const router = useRouterContext();
|
||||
const resolvedChecks =
|
||||
typeof checks === 'function' ? checks(props, router) : checks;
|
||||
const resolvedPreload =
|
||||
typeof preloadChecks === 'function'
|
||||
? preloadChecks(props, router)
|
||||
: preloadChecks;
|
||||
|
||||
// Requested here rather than through the guard: `useAuthZ` coalesces
|
||||
// everything asked for in the same tick into one request, so this rides
|
||||
// along with the guard's own checks without being able to gate rendering.
|
||||
useAuthZ(resolvedPreload ?? [], { enabled: !!resolvedPreload?.length });
|
||||
|
||||
return (
|
||||
<Guard checks={resolvedChecks} {...guardProps}>
|
||||
|
||||
@@ -3,6 +3,11 @@ export default {
|
||||
status: 'success',
|
||||
data: {
|
||||
resources: [
|
||||
{
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'factor-api-key',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { buildPermission } from '../utils';
|
||||
import type { BrandedPermission } from '../types';
|
||||
|
||||
// Collection-level — wildcard selector required for correct response key matching.
|
||||
// `list` also covers pin/unpin and saved-view CRUD, which the backend gates on it.
|
||||
export const DashboardListPermission = buildPermission('list', 'dashboard:*');
|
||||
export const DashboardCreatePermission = buildPermission(
|
||||
'create',
|
||||
'dashboard:*',
|
||||
);
|
||||
|
||||
// Resource-level — require a specific dashboard 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}`);
|
||||
@@ -97,5 +97,9 @@ export type UseAuthZResult = {
|
||||
* Checks that resolved as not granted (empty while loading/error).
|
||||
*/
|
||||
deniedPermissions: BrandedPermission[];
|
||||
/**
|
||||
* Use this to check if a specific permission is granted, false while loading or on error.
|
||||
*/
|
||||
isGranted: (permission: BrandedPermission) => boolean;
|
||||
refetchPermissions: () => void;
|
||||
};
|
||||
|
||||
@@ -240,6 +240,12 @@ export function useAuthZ(
|
||||
return permissions.every((check) => data[check]?.isGranted === true);
|
||||
}, [permissions, data, isLoading, error]);
|
||||
|
||||
const isGranted = useCallback(
|
||||
(permission: BrandedPermission): boolean =>
|
||||
data?.[permission]?.isGranted === true,
|
||||
[data],
|
||||
);
|
||||
|
||||
const deniedPermissions = useMemo(() => {
|
||||
if (!data) {
|
||||
return [];
|
||||
@@ -254,6 +260,7 @@ export function useAuthZ(
|
||||
permissions: data ?? null,
|
||||
allowed,
|
||||
deniedPermissions,
|
||||
isGranted,
|
||||
refetchPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,6 +170,7 @@ export function mockUseAuthZGrantAll(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: true,
|
||||
deniedPermissions: [],
|
||||
isGranted: (): boolean => true,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -187,6 +188,7 @@ export function mockUseAuthZDenyAll(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: false,
|
||||
deniedPermissions: permissions,
|
||||
isGranted: (): boolean => false,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -213,6 +215,8 @@ export function mockUseAuthZGrantByPrefix(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: denied.length === 0,
|
||||
deniedPermissions: denied,
|
||||
isGranted: (permission): boolean =>
|
||||
prefixes.some((prefix) => permission.startsWith(prefix)),
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -29,29 +30,31 @@ import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/service
|
||||
import ROUTES from 'constants/routes';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboardCollectionPermissions } from 'hooks/dashboards/useDashboardCollectionPermissions';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { DashboardCreatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import MenuActionItem from '../../components/MenuActionItem/MenuActionItem';
|
||||
import DashboardSettings from '../../DashboardSettings';
|
||||
import { useAddSection } from '../../PanelsAndSectionsLayout/Section/hooks/useAddSection';
|
||||
import SectionTitleModal from '../../PanelsAndSectionsLayout/Section/SectionTitleModal';
|
||||
import JsonEditorDrawer from '../JsonEditorDrawer/JsonEditorDrawer';
|
||||
import SettingsDrawer from '../SettingsDrawer';
|
||||
import menuStyles from '../../components/MenuActionItem/MenuActionItem.module.scss';
|
||||
import styles from './DashboardActions.module.scss';
|
||||
import { useDeleteDashboardAction } from './useDeleteDashboardAction';
|
||||
import { DASHBOARD_LOCKED_REASON } from '../../hooks/useDashboardEditGuard';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface DashboardActionsProps {
|
||||
title: string;
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
handle: FullScreenHandle;
|
||||
isDashboardLocked: boolean;
|
||||
isAuthor: boolean;
|
||||
onAddPanel: () => void;
|
||||
onLockToggle: () => void;
|
||||
onOpenRename: () => void;
|
||||
@@ -62,19 +65,37 @@ function DashboardActions({
|
||||
dashboard,
|
||||
handle,
|
||||
isDashboardLocked,
|
||||
isAuthor,
|
||||
onAddPanel,
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const {
|
||||
isLocked,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
canDeleteDashboard,
|
||||
canReadDashboard,
|
||||
} = useDashboardEditContext();
|
||||
const settingsRequest = useDashboardStore((s) => s.settingsRequest);
|
||||
const clearSettingsRequest = useDashboardStore((s) => s.clearSettingsRequest);
|
||||
const { user } = useAppContext();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { canCreate } = useDashboardCollectionPermissions();
|
||||
const { canToggleLock, disabledTooltip: lockDisabledTooltip } =
|
||||
useDashboardLockPermission({
|
||||
dashboardId: dashboard.id,
|
||||
source: dashboard.source,
|
||||
});
|
||||
|
||||
// Cloning creates a new dashboard, so it needs `create` too, and no lock gate.
|
||||
const cloneChecks = useMemo(
|
||||
() => [...editChecks.slice(0, 1), DashboardCreatePermission],
|
||||
[editChecks],
|
||||
);
|
||||
const cloneDenied = !canCreate || !canReadDashboard;
|
||||
|
||||
const [isSettingsDrawerOpen, setIsSettingsDrawerOpen] =
|
||||
useState<boolean>(false);
|
||||
@@ -157,101 +178,110 @@ function DashboardActions({
|
||||
void handle.enter();
|
||||
}, [dashboard.id, handle]);
|
||||
|
||||
// Shown only to edit-permitted users, so the only disabled reason is the lock.
|
||||
const editLabel = useCallback(
|
||||
(text: string): ReactNode =>
|
||||
isLocked ? (
|
||||
<DisabledMenuItemLabel reason={DASHBOARD_LOCKED_REASON}>
|
||||
{text}
|
||||
</DisabledMenuItemLabel>
|
||||
) : (
|
||||
text
|
||||
),
|
||||
[isLocked],
|
||||
// Unavailable items stay in the menu, carrying the reason.
|
||||
// The row carries icon, label and reason; the item keeps `disabled`/`onClick`.
|
||||
const row = useCallback(
|
||||
(
|
||||
text: string,
|
||||
icon: ReactElement,
|
||||
checks: BrandedPermission[],
|
||||
opts: { disabledTooltip?: string; destructive?: boolean } = {},
|
||||
): ReactNode => (
|
||||
<MenuActionItem
|
||||
label={text}
|
||||
icon={icon}
|
||||
checks={checks}
|
||||
disabledTooltip={opts.disabledTooltip}
|
||||
destructive={opts.destructive}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
if (canEditDashboard) {
|
||||
dashboardGroup.push({
|
||||
const dashboardGroup: MenuItem[] = [
|
||||
{
|
||||
key: 'rename',
|
||||
label: editLabel('Rename'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: isLocked,
|
||||
label: row('Rename', <PenLine size={14} />, editChecks, {
|
||||
disabledTooltip: editDisabledTooltip,
|
||||
}),
|
||||
disabled: !isEditable,
|
||||
onClick: onOpenRename,
|
||||
});
|
||||
},
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
dashboardGroup.push({
|
||||
{
|
||||
key: 'clone',
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
label: row('Clone dashboard', <Copy size={14} />, cloneChecks),
|
||||
disabled: isCloning || cloneDenied,
|
||||
onClick: (): void => void handleClone(),
|
||||
});
|
||||
}
|
||||
|
||||
if (canEditDashboard && (isAuthor || user.role === USER_ROLES.ADMIN)) {
|
||||
dashboardGroup.push({
|
||||
},
|
||||
{
|
||||
key: 'lock',
|
||||
label: isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
icon: <LockKeyhole size={14} />,
|
||||
disabled: dashboard.createdBy === 'integration',
|
||||
label: row(
|
||||
isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
<LockKeyhole size={14} />,
|
||||
editChecks,
|
||||
{ disabledTooltip: lockDisabledTooltip },
|
||||
),
|
||||
disabled: !canToggleLock,
|
||||
onClick: onLockToggle,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'fullscreen',
|
||||
label: 'Full screen',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
});
|
||||
},
|
||||
{
|
||||
key: 'fullscreen',
|
||||
label: row('Full screen', <Fullscreen size={14} />, []),
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
];
|
||||
|
||||
const items: MenuItem[] = [
|
||||
return [
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-dashboard',
|
||||
label: 'Dashboard',
|
||||
children: dashboardGroup,
|
||||
},
|
||||
];
|
||||
// Omit the whole Layout group (header included) in view mode.
|
||||
if (canEditDashboard) {
|
||||
items.push({
|
||||
{
|
||||
type: 'group',
|
||||
key: 'group-layout',
|
||||
label: 'Layout',
|
||||
children: [
|
||||
{
|
||||
key: 'new-section',
|
||||
label: editLabel('New section'),
|
||||
icon: <SquareStack size={14} />,
|
||||
disabled: isLocked,
|
||||
label: row('New section', <SquareStack size={14} />, editChecks, {
|
||||
disabledTooltip: editDisabledTooltip,
|
||||
}),
|
||||
disabled: !isEditable,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
});
|
||||
items.push(
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: editLabel('Delete dashboard'),
|
||||
icon: <Trash2 size={14} />,
|
||||
danger: true,
|
||||
disabled: isLocked,
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
);
|
||||
}
|
||||
return items;
|
||||
},
|
||||
{ type: 'divider', key: 'divider-danger' },
|
||||
{
|
||||
key: 'delete',
|
||||
label: row('Delete dashboard', <Trash2 size={14} />, deleteChecks, {
|
||||
disabledTooltip: deleteDisabledTooltip,
|
||||
destructive: true,
|
||||
}),
|
||||
// Independent of read/update, but a locked dashboard can't be removed.
|
||||
disabled: isLocked || !canDeleteDashboard,
|
||||
onClick: confirmDeleteDashboard,
|
||||
},
|
||||
];
|
||||
}, [
|
||||
editLabel,
|
||||
canEditDashboard,
|
||||
row,
|
||||
isEditable,
|
||||
isLocked,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
canDeleteDashboard,
|
||||
cloneChecks,
|
||||
cloneDenied,
|
||||
isCloning,
|
||||
isAuthor,
|
||||
user.role,
|
||||
canToggleLock,
|
||||
lockDisabledTooltip,
|
||||
isDashboardLocked,
|
||||
dashboard.createdBy,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
@@ -261,7 +291,10 @@ function DashboardActions({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: menuItems }}
|
||||
className={menuStyles.menuContent}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -273,38 +306,31 @@ function DashboardActions({
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
{canEditDashboard && (
|
||||
<>
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={isLocked}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
</>
|
||||
)}
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
className={styles.toolbarButton}
|
||||
prefix={<Configure size="md" />}
|
||||
testId="show-drawer"
|
||||
disabled={!isEditable}
|
||||
onClick={handleOpenSettings}
|
||||
size="md"
|
||||
>
|
||||
Configure
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<SettingsDrawer
|
||||
drawerTitle="Dashboard Configuration"
|
||||
isOpen={isSettingsDrawerOpen}
|
||||
destroyOnClose
|
||||
onClose={(): void => {
|
||||
setIsSettingsDrawerOpen(false);
|
||||
clearSettingsRequest();
|
||||
}}
|
||||
>
|
||||
<DashboardSettings dashboard={dashboard} />
|
||||
</SettingsDrawer>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -316,24 +342,19 @@ function DashboardActions({
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
{canEditDashboard && (
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={isLocked}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
)}
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzAllow,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { IsAdminPermission } from 'lib/authz/hooks/useAuthZ/legacy';
|
||||
import {
|
||||
buildDashboardDeletePermission,
|
||||
buildDashboardReadPermission,
|
||||
buildDashboardUpdatePermission,
|
||||
DashboardListPermission,
|
||||
} from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import DashboardActions from '../DashboardActions';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
|
||||
const dashboard = {
|
||||
id: DASHBOARD_ID,
|
||||
createdBy: 'someone-else@signoz.io',
|
||||
locked: false,
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
// Composition is what's under test here; the derivation has its own suite.
|
||||
const mockEditContext = {
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
deleteChecks: [],
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
};
|
||||
function setEditContextMock(next: Partial<typeof mockEditContext>): void {
|
||||
Object.assign(mockEditContext, {
|
||||
isEditable: true,
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
...next,
|
||||
});
|
||||
}
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
|
||||
}),
|
||||
);
|
||||
|
||||
// The dropdown trigger's testId is swallowed by Radix's asChild clone.
|
||||
function openActionsMenu(): Promise<void> {
|
||||
return userEvent.click(screen.getByRole('button', { name: /Actions/ }));
|
||||
}
|
||||
|
||||
function renderActions(): ReturnType<typeof render> {
|
||||
return render(
|
||||
<DashboardActions
|
||||
title="D"
|
||||
dashboard={dashboard}
|
||||
handle={
|
||||
{
|
||||
active: false,
|
||||
enter: jest.fn(),
|
||||
exit: jest.fn(),
|
||||
node: { current: null },
|
||||
} as never
|
||||
}
|
||||
isDashboardLocked={false}
|
||||
onAddPanel={jest.fn()}
|
||||
onLockToggle={jest.fn()}
|
||||
onOpenRename={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe('DashboardActions - AuthZ', () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
describe('permission denied', () => {
|
||||
// These controls used to be removed from the DOM entirely.
|
||||
it('keeps the toolbar buttons visible and disabled', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledTooltip: 'no permission',
|
||||
});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeDisabled();
|
||||
});
|
||||
expect(screen.getByTestId('add-panel-header')).toBeDisabled();
|
||||
// JSON stays available — it's a read-only inspect.
|
||||
expect(screen.getByTestId('edit-json')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('keeps the menu items present and disabled', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
canEditDashboard: false,
|
||||
canDeleteDashboard: false,
|
||||
editDisabledTooltip: 'no permission',
|
||||
deleteDisabledTooltip: 'no permission',
|
||||
});
|
||||
|
||||
renderActions();
|
||||
await openActionsMenu();
|
||||
|
||||
await expect(screen.findByText('Rename')).resolves.toBeInTheDocument();
|
||||
expect(screen.getByText('New section')).toBeInTheDocument();
|
||||
expect(screen.getByText('Delete dashboard')).toBeInTheDocument();
|
||||
expect(screen.getByText('Clone dashboard')).toBeInTheDocument();
|
||||
// Full screen never depended on permission.
|
||||
expect(screen.getByText('Full screen')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('partial permissions', () => {
|
||||
// Delete is independent of read/update (authz guide rule 3).
|
||||
it('enables delete for a user who can only delete', async () => {
|
||||
server.use(setupAuthzAllow(buildDashboardDeletePermission(DASHBOARD_ID)));
|
||||
setEditContextMock({
|
||||
isEditable: false,
|
||||
canEditDashboard: false,
|
||||
editDisabledTooltip: 'no permission',
|
||||
});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeDisabled();
|
||||
});
|
||||
await openActionsMenu();
|
||||
await expect(
|
||||
screen.findByText('Delete dashboard'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('disables clone when create is denied but edit is allowed', async () => {
|
||||
server.use(
|
||||
setupAuthzAllow(
|
||||
buildDashboardReadPermission(DASHBOARD_ID),
|
||||
buildDashboardUpdatePermission(DASHBOARD_ID),
|
||||
DashboardListPermission,
|
||||
IsAdminPermission,
|
||||
),
|
||||
);
|
||||
setEditContextMock({});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeEnabled();
|
||||
});
|
||||
await openActionsMenu();
|
||||
await expect(
|
||||
screen.findByText('Clone dashboard'),
|
||||
).resolves.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission granted', () => {
|
||||
it('enables the toolbar for a full-rights user', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
setEditContextMock({});
|
||||
|
||||
renderActions();
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('show-drawer')).toBeEnabled();
|
||||
});
|
||||
expect(screen.getByTestId('add-panel-header')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -25,6 +25,7 @@ import { TOOLTIP_SCROLL_CONTENT_CLASS } from 'components/TooltipScrollArea/Toolt
|
||||
import TagsOverflowTooltip from './TagsOverflowTooltip';
|
||||
import { DASHBOARD_NAME_MAX_LENGTH } from '../../constants';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
// The tag cluster keeps a fixed footprint so a long title ellipsizes around it
|
||||
// instead of collapsing the tags: show up to two tags, then a `+N` overflow badge.
|
||||
@@ -43,6 +44,8 @@ interface DashboardInfoProps {
|
||||
showLockToggle: boolean;
|
||||
/** When provided, the lock icon toggles lock/unlock (author/admin only). */
|
||||
onToggleLock?: () => void;
|
||||
/** Why the toggle is unavailable, when onToggleLock is absent. */
|
||||
lockDisabledTooltip?: string;
|
||||
isEditing: boolean;
|
||||
draft: string;
|
||||
onDraftChange: (value: string) => void;
|
||||
@@ -61,6 +64,7 @@ function DashboardInfo({
|
||||
isDashboardLocked,
|
||||
showLockToggle,
|
||||
onToggleLock,
|
||||
lockDisabledTooltip,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
@@ -68,7 +72,7 @@ function DashboardInfo({
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const { isEditable: canEdit } = useDashboardEditContext();
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
@@ -77,11 +81,15 @@ function DashboardInfo({
|
||||
const visibleTags = tags.slice(0, MAX_VISIBLE_TAGS);
|
||||
const remainingTags = tags.slice(MAX_VISIBLE_TAGS);
|
||||
|
||||
// Without a toggle, say why it can't be toggled rather than only restating the
|
||||
// lock state.
|
||||
let lockTooltip: string;
|
||||
if (onToggleLock) {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'Locked — click to unlock'
|
||||
: 'Unlocked — click to lock';
|
||||
} else if (lockDisabledTooltip) {
|
||||
lockTooltip = lockDisabledTooltip;
|
||||
} else {
|
||||
lockTooltip = isDashboardLocked
|
||||
? 'This dashboard is locked'
|
||||
|
||||
@@ -16,8 +16,8 @@ import { defineJsonEditorTheme, JSON_EDITOR_THEME } from './editorTheme';
|
||||
import styles from './JsonEditorDrawer.module.scss';
|
||||
import JsonEditorToolbar from './JsonEditorToolbar';
|
||||
import { useJsonEditor } from './useJsonEditor';
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -30,10 +30,13 @@ function JsonEditorDrawer({
|
||||
isOpen,
|
||||
onClose,
|
||||
}: JsonEditorDrawerProps): JSX.Element {
|
||||
const {
|
||||
isEditable,
|
||||
editDisabledTooltip: readOnlyTooltip,
|
||||
editChecks: readOnlyChecks,
|
||||
} = useDashboardEditContext();
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const readOnlyReason = useDashboardStore((s) => s.editDisabledReason);
|
||||
// Inspect-only when not editable: Apply/Format/Reset disabled.
|
||||
const readOnly = !isEditable;
|
||||
|
||||
@@ -175,7 +178,10 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<DisabledControlTooltip reason={readOnlyReason} disabled={readOnly}>
|
||||
<AuthZTooltip
|
||||
checks={readOnlyChecks}
|
||||
disabledTooltip={readOnly ? readOnlyTooltip : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -186,7 +192,7 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
@@ -10,8 +10,21 @@ jest.mock('../useJsonEditor', () => ({ useJsonEditor: jest.fn() }));
|
||||
// Editable by default so the drawer renders in its editable (non-read-only) mode.
|
||||
jest.mock('../../../store/useDashboardStore', () => ({
|
||||
useDashboardStore: (
|
||||
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
|
||||
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
|
||||
selector: (s: {
|
||||
isEditable: boolean;
|
||||
editChecks: unknown[];
|
||||
deleteChecks: unknown[];
|
||||
areOtherPermissionsLoading: boolean;
|
||||
editDisabledTooltip: string;
|
||||
}) => unknown,
|
||||
): unknown =>
|
||||
selector({
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
deleteChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
@@ -65,6 +78,26 @@ function hookValue(
|
||||
} as ReturnType<typeof useJsonEditor>;
|
||||
}
|
||||
|
||||
// The derivation has its own suite (useDashboardEditContext.authz); these cases are
|
||||
// about what the UI does with a given edit context, so control it directly.
|
||||
const mockEditContext = {
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
deleteChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
};
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
|
||||
}),
|
||||
);
|
||||
|
||||
describe('JsonEditorDrawer', () => {
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
@@ -95,7 +128,7 @@ describe('JsonEditorDrawer', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
|
||||
);
|
||||
const { rerender } = render(
|
||||
const { unmount } = render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
@@ -104,8 +137,11 @@ describe('JsonEditorDrawer', () => {
|
||||
'2 panels not present in layout',
|
||||
);
|
||||
|
||||
// Mounted fresh rather than re-rendered: the provider wrapper holds the
|
||||
// first tree, so a rerender does not pick up the new hook value.
|
||||
unmount();
|
||||
mockUseJsonEditor.mockReturnValue(hookValue({ danglingPanelIds: [] }));
|
||||
rerender(
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
|
||||
@@ -1,25 +1,18 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { FullScreenHandle } from 'react-full-screen';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
getGetDashboardV2QueryKey,
|
||||
lockDashboardV2,
|
||||
unlockDashboardV2,
|
||||
} from 'api/generated/services/dashboard';
|
||||
import type {
|
||||
DashboardtypesGettableDashboardV2DTO,
|
||||
DashboardtypesJSONPatchOperationDTO,
|
||||
GetDashboardV2200,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { resolveDashboardImage } from 'pages/DashboardPage/DashboardContainer/dashboardIcons';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { useToggleDashboardLock } from 'hooks/dashboards/useToggleDashboardLock';
|
||||
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';
|
||||
@@ -42,7 +35,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
const { dashboard, handle } = props;
|
||||
|
||||
const id = dashboard.id;
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Session-local lock state: the toggle appears once locked and persists for the page.
|
||||
const [isDashboardLocked, setIsDashboardLocked] = useState(!!dashboard.locked);
|
||||
@@ -64,7 +56,6 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
@@ -75,59 +66,51 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
|
||||
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';
|
||||
// dashboard:update, then the backend's source and creator-or-admin rules.
|
||||
const { canToggleLock, disabledTooltip: lockDisabledTooltip } =
|
||||
useDashboardLockPermission({
|
||||
dashboardId: id,
|
||||
source: dashboard.source,
|
||||
});
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
// Shared with the list's row menu — it owns the API call, the toast and the
|
||||
// detail-cache patch; the optimistic local state and this page's event stay here.
|
||||
const lockSource = useRef<'menu' | 'header'>('header');
|
||||
const { toggleLock } = useToggleDashboardLock({
|
||||
dashboardId: id,
|
||||
isLocked: isDashboardLocked,
|
||||
onSuccess: (locked) => {
|
||||
void logEvent(DashboardDetailEvents.LockToggled, {
|
||||
dashboardId: id,
|
||||
dashboardName: title,
|
||||
locked,
|
||||
source: lockSource.current,
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setIsDashboardLocked(isDashboardLocked);
|
||||
showErrorModal(error);
|
||||
},
|
||||
});
|
||||
|
||||
const handleLockDashboardToggle = useCallback(
|
||||
async (source: 'menu' | 'header'): Promise<void> => {
|
||||
(source: 'menu' | 'header'): void => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
lockSource.current = source;
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
try {
|
||||
if (next) {
|
||||
await lockDashboardV2({ id });
|
||||
toast.success('Dashboard locked');
|
||||
} else {
|
||||
await unlockDashboardV2({ id });
|
||||
toast.success('Dashboard unlocked');
|
||||
}
|
||||
// Patch just the `locked` flag in the cache — a full refetch would reload
|
||||
// every panel's chart data for a metadata-only change.
|
||||
const key = getGetDashboardV2QueryKey({ id });
|
||||
const cached = queryClient.getQueryData<GetDashboardV2200>(key);
|
||||
if (cached) {
|
||||
queryClient.setQueryData<GetDashboardV2200>(key, {
|
||||
...cached,
|
||||
data: { ...cached.data, locked: next },
|
||||
});
|
||||
}
|
||||
void logEvent(DashboardDetailEvents.LockToggled, {
|
||||
dashboardId: id,
|
||||
dashboardName: title,
|
||||
locked: next,
|
||||
source,
|
||||
});
|
||||
} catch (error) {
|
||||
setIsDashboardLocked(!next);
|
||||
showErrorModal(error as APIError);
|
||||
}
|
||||
toggleLock();
|
||||
},
|
||||
[id, title, isDashboardLocked, queryClient, showErrorModal],
|
||||
[id, isDashboardLocked, toggleLock],
|
||||
);
|
||||
|
||||
const onNameSave = useCallback(
|
||||
@@ -184,9 +167,10 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => void handleLockDashboardToggle('header')
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
@@ -199,9 +183,8 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isAuthor={isAuthor}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => void handleLockDashboardToggle('menu')}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,19 @@
|
||||
import { Globe, RefreshCw, Trash } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import styles from './PublicDashboardActions.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
interface PublicDashboardActionsProps {
|
||||
isPublic: boolean;
|
||||
disabled: boolean;
|
||||
/**
|
||||
* Why publishing is unavailable. Non-empty both disables the buttons and
|
||||
* explains them, so they cannot be disabled silently.
|
||||
*/
|
||||
checks: BrandedPermission[];
|
||||
/** In-flight config read — transient, and a spinner explains itself. */
|
||||
isLoading?: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
isUnpublishing: boolean;
|
||||
@@ -16,7 +24,8 @@ interface PublicDashboardActionsProps {
|
||||
|
||||
function PublicDashboardActions({
|
||||
isPublic,
|
||||
disabled,
|
||||
checks,
|
||||
isLoading = false,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
isUnpublishing,
|
||||
@@ -24,45 +33,53 @@ function PublicDashboardActions({
|
||||
onUpdate,
|
||||
onUnpublish,
|
||||
}: PublicDashboardActionsProps): JSX.Element {
|
||||
const disabled = isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
{isPublic ? (
|
||||
<>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={15} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish Dashboard
|
||||
</Button>
|
||||
<AuthZTooltip checks={checks}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={15} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish Dashboard
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<AuthZTooltip checks={checks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isUpdating}
|
||||
prefix={<RefreshCw size={15} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
>
|
||||
Update Dashboard
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</>
|
||||
) : (
|
||||
<AuthZTooltip checks={checks}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isUpdating}
|
||||
prefix={<RefreshCw size={15} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Update Dashboard
|
||||
Publish Dashboard
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Publish Dashboard
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } 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 {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDeny,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
import { buildDashboardUpdatePermission } from 'lib/authz/hooks/useAuthZ/permissions/dashboard.permissions';
|
||||
|
||||
import PublicDashboardSettings from '../index';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
const PUBLIC_URL = `http://localhost/api/v1/dashboards/${DASHBOARD_ID}/public`;
|
||||
|
||||
const dashboard = {
|
||||
id: DASHBOARD_ID,
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
describe('PublicDashboard - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
// Not published yet — the tab offers Publish.
|
||||
server.use(
|
||||
rest.get(PUBLIC_URL, (_req, res, ctx) =>
|
||||
res(ctx.status(404), ctx.json({ status: 'error', error: {} })),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// The backend gates the public-config writes on dashboard:update, so a
|
||||
// licensed editor can publish — this used to be admin-only in the UI.
|
||||
it('lets a non-admin holding update publish', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<PublicDashboardSettings dashboard={dashboard} />, undefined, {
|
||||
role: 'EDITOR',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('public-dashboard-publish')).toBeEnabled();
|
||||
});
|
||||
});
|
||||
|
||||
it('disables publishing when update is denied', async () => {
|
||||
server.use(setupAuthzDeny(buildDashboardUpdatePermission(DASHBOARD_ID)));
|
||||
|
||||
render(<PublicDashboardSettings dashboard={dashboard} />, undefined, {
|
||||
role: 'ADMIN',
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('public-dashboard-publish')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import PublicDashboardHint from './PublicDashboardHint/PublicDashboardHint';
|
||||
import PublicDashboardSettingsForm from './PublicDashboardSettingsForm/PublicDashboardSettingsForm';
|
||||
import PublicDashboardStatus from './PublicDashboardStatus/PublicDashboardStatus';
|
||||
import PublicDashboardUrl from './PublicDashboardUrl/PublicDashboardUrl';
|
||||
|
||||
import { usePublicDashboard } from './usePublicDashboard';
|
||||
import styles from './PublicDashboard.module.scss';
|
||||
|
||||
@@ -17,7 +18,8 @@ function PublicDashboardSettings({
|
||||
}: PublicDashboardSettingsProps): JSX.Element {
|
||||
const {
|
||||
isPublic,
|
||||
isAdmin,
|
||||
canManage,
|
||||
publishChecks,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
@@ -34,7 +36,7 @@ function PublicDashboardSettings({
|
||||
onOpenUrl,
|
||||
} = usePublicDashboard(dashboard.id);
|
||||
|
||||
const controlsDisabled = isLoading || !isAdmin;
|
||||
const controlsDisabled = isLoading || !canManage;
|
||||
|
||||
return (
|
||||
<div className={styles.publishTab}>
|
||||
@@ -61,7 +63,8 @@ function PublicDashboardSettings({
|
||||
|
||||
<PublicDashboardActions
|
||||
isPublic={isPublic}
|
||||
disabled={controlsDisabled}
|
||||
checks={publishChecks}
|
||||
isLoading={isLoading}
|
||||
isPublishing={isPublishing}
|
||||
isUpdating={isUpdating}
|
||||
isUnpublishing={isUnpublishing}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { useDashboardPermissions } from 'hooks/dashboards/useDashboardPermissions';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
@@ -11,10 +13,8 @@ import {
|
||||
} from 'api/generated/services/dashboard';
|
||||
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
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 { openInNewTab } from 'utils/navigation';
|
||||
|
||||
@@ -22,7 +22,10 @@ import { usePublicDashboardMeta } from './usePublicDashboardMeta';
|
||||
|
||||
export interface UsePublicDashboardReturn {
|
||||
isPublic: boolean;
|
||||
isAdmin: boolean;
|
||||
/** read + update on this dashboard — publishing is a dashboard update. */
|
||||
canManage: boolean;
|
||||
/** `[read, update]` — publishing changes the dashboard. */
|
||||
publishChecks: BrandedPermission[];
|
||||
isLoading: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
@@ -49,8 +52,10 @@ export function usePublicDashboard(
|
||||
): UsePublicDashboardReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { user } = useAppContext();
|
||||
const isAdmin = user?.role === USER_ROLES.ADMIN;
|
||||
// The backend gates the public-config writes on dashboard:update, not on the
|
||||
// admin role, so a licensed editor can publish.
|
||||
const { canEdit: canManage, editChecks: publishChecks } =
|
||||
useDashboardPermissions(dashboardId);
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [timeRangeEnabled, setTimeRangeEnabled] = useState<boolean>(true);
|
||||
@@ -196,7 +201,8 @@ export function usePublicDashboard(
|
||||
|
||||
return {
|
||||
isPublic,
|
||||
isAdmin,
|
||||
canManage,
|
||||
publishChecks,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
|
||||
@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
|
||||
promql: 'PromQL',
|
||||
clickhouse: 'ClickHouse',
|
||||
variable: 'Variable',
|
||||
text: 'Markdown body',
|
||||
};
|
||||
|
||||
interface VariableImpactDialogProps {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { buildVariableImpactPatch } from '../utils/variableImpactPatch';
|
||||
import type { VariableUsage } from '../utils/variableUsages';
|
||||
|
||||
jest.mock('../variableAdapters', () => ({
|
||||
formModelToDto: (model: unknown): unknown => model,
|
||||
}));
|
||||
|
||||
const dashboard = {
|
||||
spec: {
|
||||
panels: {
|
||||
runbook: {
|
||||
spec: {
|
||||
display: { name: 'Runbook' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: 'env {{svc}}' } },
|
||||
queries: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
variables: [],
|
||||
},
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
const textUsage: VariableUsage = {
|
||||
id: 'panel:runbook:0',
|
||||
sourceType: 'panel',
|
||||
sourceId: 'runbook',
|
||||
sourceLabel: 'Runbook',
|
||||
kind: 'text',
|
||||
envelopeIndex: 0,
|
||||
currentText: 'env {{svc}}',
|
||||
resultingText: 'env {{zone}}',
|
||||
};
|
||||
|
||||
describe('buildVariableImpactPatch — text panel bodies', () => {
|
||||
it('replaces the plugin-spec text, never the (empty) queries', () => {
|
||||
const ops = buildVariableImpactPatch(dashboard, [], [textUsage]);
|
||||
|
||||
const panelOps = ops.filter((op) => op.path.includes('/panels/'));
|
||||
expect(panelOps).toStrictEqual([
|
||||
{
|
||||
op: 'replace',
|
||||
path: '/spec/panels/runbook/spec/plugin/spec/text',
|
||||
value: 'env {{zone}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -45,6 +45,16 @@ function promqlPanel(name: string, query: string): unknown {
|
||||
};
|
||||
}
|
||||
|
||||
function textPanel(name: string, text: string): unknown {
|
||||
return {
|
||||
spec: {
|
||||
display: { name },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text } },
|
||||
queries: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function dashboard(
|
||||
panels: Record<string, unknown>,
|
||||
variables: VariableFormModel[],
|
||||
@@ -99,6 +109,38 @@ describe('findVariableUsages', () => {
|
||||
it('returns nothing for an unreferenced variable', () => {
|
||||
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
|
||||
});
|
||||
|
||||
describe('text panel bodies (TDD D5)', () => {
|
||||
const textDash = dashboard(
|
||||
{
|
||||
runbook: textPanel(
|
||||
'Runbook',
|
||||
'env {{svc}} / {{.svc}} / [[svc]] / $svc / {{svcx}}',
|
||||
),
|
||||
unrelated: textPanel('Plain', 'no tokens here'),
|
||||
},
|
||||
[variable({ name: 'svc', type: 'QUERY' })],
|
||||
);
|
||||
|
||||
it('finds the body usage and skips bodies without the token', () => {
|
||||
const usages = findVariableUsages(textDash, 'svc', 'rename', 'zone');
|
||||
expect(usages.map((u) => u.id)).toStrictEqual(['panel:runbook:0']);
|
||||
expect(usages[0].kind).toBe('text');
|
||||
expect(usages[0].sourceLabel).toBe('Runbook');
|
||||
});
|
||||
|
||||
it('rewrites all four syntaxes on rename, leaving other names alone', () => {
|
||||
const [usage] = findVariableUsages(textDash, 'svc', 'rename', 'zone');
|
||||
expect(usage.resultingText).toBe(
|
||||
'env {{zone}} / {{.zone}} / [[zone]] / $zone / {{svcx}}',
|
||||
);
|
||||
});
|
||||
|
||||
it('leaves the body for review on delete', () => {
|
||||
const [usage] = findVariableUsages(textDash, 'svc', 'delete');
|
||||
expect(usage.resultingText).toBe(usage.currentText);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('findApplyUsages', () => {
|
||||
|
||||
@@ -1,26 +1,28 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
const AddVariableButton = ({
|
||||
isEditable,
|
||||
checks,
|
||||
disabledTooltip,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
checks: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
setIsEditing: (state: { type: 'new' }) => void;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
size="md"
|
||||
onClick={(): void => setIsEditing({ type: 'new' })}
|
||||
testId="add-variable"
|
||||
disabled={!isEditable}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
}): JSX.Element => (
|
||||
<AuthZButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
size="md"
|
||||
onClick={(): void => setIsEditing({ type: 'new' })}
|
||||
testId="add-variable"
|
||||
>
|
||||
Add variable
|
||||
</AuthZButton>
|
||||
);
|
||||
|
||||
export default AddVariableButton;
|
||||
|
||||
@@ -2,12 +2,15 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import AddVariableButton from '../AddVariableButton';
|
||||
import { EditingState } from '../../types';
|
||||
import styles from './NoVariables.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
const NoVariablesCard = ({
|
||||
isEditable,
|
||||
checks,
|
||||
disabledTooltip,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
checks: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
@@ -20,7 +23,11 @@ const NoVariablesCard = ({
|
||||
Create a variable to parameterize your panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<AddVariableButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,13 +20,15 @@ import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
// The drawer destroys on close, so reading this once on mount is enough to
|
||||
// open the add-form when deep-linked (e.g. the bar's "Add variable" button).
|
||||
const openAddOnMount = useDashboardStore(
|
||||
@@ -127,7 +129,11 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.container, settingsStyles.settingsCard)}>
|
||||
{variables.length === 0 ? (
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<NoVariablesCard
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<VariablesList
|
||||
@@ -143,7 +149,11 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
appliedToAllNames={appliedToAllNames}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<AddVariableButton
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { isStaticPanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
|
||||
/**
|
||||
* The markdown body of a static (query-less) panel, or null for query kinds.
|
||||
* One localized cast: the plugin-spec union can't be narrowed by a dynamic kind.
|
||||
*/
|
||||
export function getTextPanelBody(
|
||||
spec: DashboardtypesPanelSpecDTO | undefined,
|
||||
): string | null {
|
||||
if (!spec?.plugin || !isStaticPanelKind(spec.plugin.kind)) {
|
||||
return null;
|
||||
}
|
||||
const { text } = spec.plugin.spec as { text?: string };
|
||||
return typeof text === 'string' ? text : null;
|
||||
}
|
||||
@@ -107,6 +107,18 @@ export function buildVariableImpactPatch(
|
||||
|
||||
byPanel.forEach((list, panelId) => {
|
||||
const panel = panels[panelId];
|
||||
|
||||
// A static kind's usage edits its markdown body, not a query.
|
||||
const textUsage = list.find((usage) => usage.kind === 'text');
|
||||
if (textUsage) {
|
||||
ops.push({
|
||||
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
|
||||
path: `/spec/panels/${panelId}/spec/plugin/spec/text`,
|
||||
value: textUsage.resultingText,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (!panel?.spec?.queries?.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
} from 'lib/dashboardVariables/variableReference';
|
||||
|
||||
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
|
||||
import { getTextPanelBody } from './getTextPanelBody';
|
||||
import { dtoToFormModel } from '../variableAdapters';
|
||||
|
||||
/** The kind of query text a variable is referenced from. */
|
||||
@@ -19,7 +20,8 @@ export type VariableUsageKind =
|
||||
| 'builder'
|
||||
| 'promql'
|
||||
| 'clickhouse'
|
||||
| 'variable';
|
||||
| 'variable'
|
||||
| 'text';
|
||||
|
||||
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
|
||||
|
||||
@@ -81,7 +83,7 @@ function computeResultingText(
|
||||
return rewriteVariableReferences(text, variableName, newName);
|
||||
}
|
||||
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
|
||||
// ClickHouse and variable queries are left for the user to edit.
|
||||
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
|
||||
return kind === 'builder'
|
||||
? removeVariableFromExpression(text, variableName)
|
||||
: text;
|
||||
@@ -106,6 +108,31 @@ export function findVariableUsages(
|
||||
const spec = dashboard.spec;
|
||||
|
||||
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
|
||||
// A static kind references variables from its body, not a query (TDD D5 —
|
||||
// rename must rewrite text bodies too, or it silently orphans the tokens).
|
||||
const textBody = getTextPanelBody(panel?.spec);
|
||||
if (typeof textBody === 'string') {
|
||||
if (textContainsVariableReference(textBody, variableName)) {
|
||||
usages.push({
|
||||
id: `panel:${panelId}:0`,
|
||||
sourceType: 'panel',
|
||||
sourceId: panelId,
|
||||
sourceLabel: panel.spec?.display?.name || panelId,
|
||||
kind: 'text',
|
||||
envelopeIndex: 0,
|
||||
currentText: textBody,
|
||||
resultingText: computeResultingText(
|
||||
'text',
|
||||
textBody,
|
||||
variableName,
|
||||
mode,
|
||||
newName,
|
||||
),
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const queries = panel?.spec?.queries;
|
||||
if (!queries?.length) {
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import {
|
||||
setupAuthzAdmin,
|
||||
setupAuthzDenyAll,
|
||||
} from 'lib/authz/utils/authz-test-utils';
|
||||
|
||||
import DashboardSettings from '../index';
|
||||
|
||||
const DASHBOARD_ID = 'dash-1';
|
||||
|
||||
const dashboard = {
|
||||
id: DASHBOARD_ID,
|
||||
spec: { display: { name: 'D' }, panels: {}, layouts: [], variables: [] },
|
||||
} as unknown as DashboardtypesGettableDashboardV2DTO;
|
||||
|
||||
let isCloudUser = true;
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): {
|
||||
isCloudUser: boolean;
|
||||
isEnterpriseSelfHostedUser: boolean;
|
||||
} => ({ isCloudUser, isEnterpriseSelfHostedUser: false }),
|
||||
}));
|
||||
|
||||
describe('DashboardSettings - AuthZ', () => {
|
||||
beforeEach(() => {
|
||||
isCloudUser = true;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('renders a trigger per tab', async () => {
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<DashboardSettings dashboard={dashboard} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('tab', { name: /Variables/ })).toBeInTheDocument();
|
||||
expect(screen.getByRole('tab', { name: /Publish/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The triggers used to be rendered from the TabKeys enum rather than the
|
||||
// items list, so Publish appeared on OSS and landed on an empty body.
|
||||
it('omits the Publish tab when public dashboards are unavailable', async () => {
|
||||
isCloudUser = false;
|
||||
server.use(setupAuthzAdmin());
|
||||
|
||||
render(<DashboardSettings dashboard={dashboard} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /Overview/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: /Publish/ }),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Reading the publish config only needs read; the writes inside gate on update.
|
||||
it('keeps the Publish tab reachable for a non-admin', async () => {
|
||||
server.use(setupAuthzDenyAll());
|
||||
|
||||
render(<DashboardSettings dashboard={dashboard} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tab', { name: /Publish/ })).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.getByRole('tab', { name: /Publish/ })).toBeEnabled();
|
||||
});
|
||||
});
|
||||
@@ -13,9 +13,7 @@ import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/service
|
||||
import Overview from './Overview';
|
||||
import PublicDashboardSettings from './PublicDashboard';
|
||||
import VariablesSettings from './Variables';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
import { useDashboardStore } from '../store/useDashboardStore';
|
||||
import styles from './DashboardSettings.module.scss';
|
||||
@@ -37,7 +35,6 @@ const prefixIcons: Record<TabKeys, JSX.Element> = {
|
||||
};
|
||||
|
||||
function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
// Opened once per drawer mount (the drawer destroys on close); a deep-link
|
||||
// request lands us on the right tab.
|
||||
@@ -58,27 +55,28 @@ function DashboardSettings({ dashboard }: DashboardSettingsProps): JSX.Element {
|
||||
children: <VariablesSettings dashboard={dashboard} />,
|
||||
prefixIcon: <Braces size={14} />,
|
||||
},
|
||||
// Readable by anyone who can open the dashboard; the controls inside
|
||||
// gate on update.
|
||||
...(enablePublicDashboard
|
||||
? [
|
||||
{
|
||||
key: TabKeys.PUBLISH,
|
||||
label: TabKeys.PUBLISH,
|
||||
children: <PublicDashboardSettings dashboard={dashboard} />,
|
||||
disabled: user?.role !== USER_ROLES.ADMIN,
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
[enablePublicDashboard, dashboard, user?.role],
|
||||
[enablePublicDashboard, dashboard],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
{prefixIcons[key]}
|
||||
{key}
|
||||
{items.map((item) => (
|
||||
<TabsTrigger value={item.key} key={item.key} disabled={item.disabled}>
|
||||
{prefixIcons[item.key as TabKeys]}
|
||||
{item.label}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
@@ -5,10 +5,12 @@ import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
@@ -65,9 +67,16 @@ function ConfigPane({
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
// The header toggle belongs with the title and description it hides, so the kind's
|
||||
// declaration still gates it but it renders above, out of the display options.
|
||||
const headerSection = definition.sections.find(
|
||||
(config) => config.kind === SectionKind.PanelHeader,
|
||||
);
|
||||
const sections = definition.sections.filter(
|
||||
(config) => config.kind !== SectionKind.PanelHeader,
|
||||
);
|
||||
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
|
||||
// Title/description are just a slice of the spec — edit them through the same
|
||||
// onChangeSpec path the sections use, so there's a single editing surface.
|
||||
@@ -104,6 +113,23 @@ function ConfigPane({
|
||||
onChange={(e): void => setDisplayField('description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{headerSection && (
|
||||
<SectionSlot
|
||||
bare
|
||||
config={headerSection}
|
||||
spec={spec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{sections.length > 0 && (
|
||||
|
||||
@@ -5,8 +5,18 @@ import PanelTypeSwitcher from '../PanelTypeSwitcher';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
// Stub the registry so the test doesn't pull in the real renderers and chart libs.
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/NumberPanel', displayName: 'Number' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
{ kind: 'signoz/BarChartPanel', displayName: 'Bar Chart' },
|
||||
{ kind: 'signoz/PieChartPanel', displayName: 'Pie Chart' },
|
||||
{ kind: 'signoz/HistogramPanel', displayName: 'Histogram' },
|
||||
{ kind: 'signoz/ListPanel', displayName: 'List' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
const mockGetPanelDefinition = getPanelDefinition as unknown as jest.Mock;
|
||||
@@ -34,6 +44,7 @@ describe('PanelTypeSwitcher', () => {
|
||||
// List supports only logs/traces; every other kind also supports metrics.
|
||||
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
|
||||
mockGetPanelDefinition.mockImplementation((kind: string) => ({
|
||||
mode: 'query',
|
||||
supportedSignals:
|
||||
kind === 'signoz/ListPanel'
|
||||
? ['logs', 'traces']
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useMemo } from 'react';
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { PANEL_OPTIONS } from '../../../Panels/registry';
|
||||
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';
|
||||
@@ -27,17 +27,17 @@ export function usePanelTypeSelectItems({
|
||||
}: UsePanelTypeSelectItemsArgs): ConfigSelectItem<PanelKind>[] {
|
||||
return useMemo(
|
||||
() =>
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
PANEL_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind: panelKind,
|
||||
kind,
|
||||
queryType,
|
||||
signal,
|
||||
label,
|
||||
label: displayName,
|
||||
});
|
||||
return {
|
||||
value: panelKind,
|
||||
label,
|
||||
value: kind,
|
||||
label: displayName,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
|
||||
@@ -2,6 +2,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isStaticPanelKind,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
} from '../../../Panels/capabilities';
|
||||
@@ -37,6 +38,12 @@ export function getPanelTypeDisabledReason({
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
label: string;
|
||||
}): string | undefined {
|
||||
// A kind that renders without a query pairs with anything — it declares no
|
||||
// query types or signals, and the checks below would read that as "supports
|
||||
// nothing" and disable it everywhere.
|
||||
if (isStaticPanelKind(kind)) {
|
||||
return undefined;
|
||||
}
|
||||
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
|
||||
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ type SectionSlotProps = {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
|
||||
bare?: boolean;
|
||||
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
|
||||
|
||||
// Per-section header content; `trigger` expands the section and runs the editor's handler.
|
||||
@@ -50,6 +52,7 @@ function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
@@ -110,6 +113,28 @@ function SectionSlot({
|
||||
|
||||
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
|
||||
|
||||
const editorElement = (
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
);
|
||||
|
||||
if (bare) {
|
||||
return editorElement;
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={title}
|
||||
@@ -118,21 +143,7 @@ function SectionSlot({
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
panelKind={panelKind}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={queryType}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
registerHeaderAction={registerHeaderAction}
|
||||
/>
|
||||
{editorElement}
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,14 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
function textSpec(): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'Runbook', description: 'steps' },
|
||||
plugin: { kind: 'signoz/TextPanel', spec: { text: '' } },
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function spec(unit?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU', description: 'usage' },
|
||||
@@ -93,6 +101,24 @@ describe('ConfigPane', () => {
|
||||
);
|
||||
});
|
||||
|
||||
// It hides the title strip, so it sits with the title rather than under the
|
||||
// display options — and only a kind whose spec accepts `headerOptions` shows it.
|
||||
it('renders the hide-header toggle among the Panel Details fields', () => {
|
||||
renderConfigPane({ spec: textSpec() });
|
||||
|
||||
const toggle = screen.getByTestId('panel-header-hide');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
expect(screen.getByText('Hide header')).toBeInTheDocument();
|
||||
// No collapsible wrapper of its own.
|
||||
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the hide-header toggle for a kind that has no header options', () => {
|
||||
renderConfigPane();
|
||||
|
||||
expect(screen.queryByTestId('panel-header-hide')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the Formatting section for a kind that declares it', () => {
|
||||
renderConfigPane();
|
||||
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
flex: none;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
// The input carries focus, so the ring is drawn on the swatch around it.
|
||||
&:has(.input:focus-visible) {
|
||||
outline: 2px solid var(--bg-robin-400);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
// The real control, sized to the swatch and invisible over it: clicks and focus
|
||||
// land on the radio, the swatch is what the user sees.
|
||||
.input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.selected {
|
||||
box-shadow: 0 0 0 2px var(--bg-robin-500);
|
||||
}
|
||||
|
||||
// Transparency has no colour to show, so it reads as the conventional checkerboard.
|
||||
.checkerboard {
|
||||
background-color: var(--l2-background);
|
||||
background-image:
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--l2-border) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--l2-border) 75%
|
||||
),
|
||||
linear-gradient(
|
||||
45deg,
|
||||
var(--l2-border) 25%,
|
||||
transparent 25%,
|
||||
transparent 75%,
|
||||
var(--l2-border) 75%
|
||||
);
|
||||
background-size: 8px 8px;
|
||||
background-position:
|
||||
0 0,
|
||||
4px 4px;
|
||||
}
|
||||
|
||||
.defaultSurface {
|
||||
background: var(--l2-background);
|
||||
}
|
||||
|
||||
.divider {
|
||||
flex: none;
|
||||
width: 1px;
|
||||
height: 18px;
|
||||
margin: 0 2px;
|
||||
background: var(--l2-border);
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { Fragment } from 'react';
|
||||
import { Check } from '@signozhq/icons';
|
||||
import { TooltipSimple } from '@signozhq/ui/tooltip';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TEXT_BACKGROUND_PRESETS,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
import type {
|
||||
PanelTheme,
|
||||
TextBackgroundPreset,
|
||||
TextBackgroundSelection,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
import styles from './BackgroundSwatches.module.scss';
|
||||
|
||||
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
|
||||
robin: 'Robin',
|
||||
purple: 'Purple',
|
||||
sakura: 'Sakura',
|
||||
cherry: 'Cherry',
|
||||
amber: 'Amber',
|
||||
forest: 'Forest',
|
||||
sienna: 'Sienna',
|
||||
slate: 'Slate',
|
||||
};
|
||||
|
||||
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
|
||||
|
||||
const BASE_TITLES: Record<BaseSelection, string> = {
|
||||
none: 'Transparent',
|
||||
default: 'Default panel',
|
||||
};
|
||||
|
||||
/** Neither base swatch shows a colour, so its tooltip says what it does. */
|
||||
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
|
||||
none: 'Transparent — no card, border or title bar',
|
||||
default: 'Default panel colour',
|
||||
};
|
||||
|
||||
const OPTIONS: TextBackgroundSelection[] = [
|
||||
TextBackgroundKind.None,
|
||||
TextBackgroundKind.Default,
|
||||
...TEXT_BACKGROUND_PRESETS,
|
||||
];
|
||||
|
||||
const DIVIDER_AFTER = 1;
|
||||
|
||||
interface BackgroundSwatchesProps {
|
||||
testId: string;
|
||||
/** Names the group for assistive tech — the row carries no visible label. */
|
||||
label: string;
|
||||
/** `undefined` while a custom colour is active: no swatch is selected. */
|
||||
value: TextBackgroundSelection | undefined;
|
||||
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
|
||||
theme: PanelTheme;
|
||||
onChange: (value: TextBackgroundSelection) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Text panel's background choices as one radio group. Native radios sharing a
|
||||
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
|
||||
* are the platform's; each input is transparent and fills its swatch.
|
||||
*/
|
||||
function BackgroundSwatches({
|
||||
testId,
|
||||
label,
|
||||
value,
|
||||
theme,
|
||||
onChange,
|
||||
}: BackgroundSwatchesProps): JSX.Element {
|
||||
return (
|
||||
<div
|
||||
className={styles.row}
|
||||
role="radiogroup"
|
||||
aria-label={label}
|
||||
data-testid={testId}
|
||||
>
|
||||
{OPTIONS.map((option, index) => {
|
||||
const isBase =
|
||||
option === TextBackgroundKind.None ||
|
||||
option === TextBackgroundKind.Default;
|
||||
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
|
||||
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
|
||||
|
||||
return (
|
||||
<Fragment key={option}>
|
||||
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
|
||||
<label
|
||||
className={cx(styles.swatch, {
|
||||
[styles.checkerboard]: option === TextBackgroundKind.None,
|
||||
[styles.defaultSurface]: option === TextBackgroundKind.Default,
|
||||
[styles.selected]: option === value,
|
||||
})}
|
||||
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
|
||||
data-testid={`${testId}-${option}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className={styles.input}
|
||||
name={testId}
|
||||
value={option}
|
||||
checked={option === value}
|
||||
aria-label={title}
|
||||
onChange={(): void => onChange(option)}
|
||||
/>
|
||||
{option === value && <Check size={14} />}
|
||||
</label>
|
||||
</TooltipSimple>
|
||||
{index === DIVIDER_AFTER && <span className={styles.divider} />}
|
||||
</Fragment>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default BackgroundSwatches;
|
||||
@@ -0,0 +1,67 @@
|
||||
.row {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.active {
|
||||
border-color: var(--bg-robin-500);
|
||||
}
|
||||
|
||||
.chip {
|
||||
flex: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
// No colour to show yet, so the chip advertises that it opens a picker.
|
||||
.chipEmpty {
|
||||
background: conic-gradient(
|
||||
from 0deg,
|
||||
var(--bg-cherry-400),
|
||||
var(--bg-amber-400),
|
||||
var(--bg-forest-400),
|
||||
var(--bg-robin-400),
|
||||
var(--bg-sakura-400),
|
||||
var(--bg-cherry-400)
|
||||
);
|
||||
}
|
||||
|
||||
.label {
|
||||
flex: 1;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.hex {
|
||||
font-family: var(--font-family-sf-mono);
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
// Appended under the picker's own panel.
|
||||
.contrast {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 8px 4px 2px;
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.warning {
|
||||
color: var(--bg-amber-400);
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { ColorPicker } from 'antd';
|
||||
import cx from 'classnames';
|
||||
import {
|
||||
contrastRatio,
|
||||
inkForSurface,
|
||||
MIN_CONTRAST_RATIO,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
|
||||
|
||||
import styles from './CustomBackgroundRow.module.scss';
|
||||
|
||||
const HEX_PLACEHOLDER = '#______';
|
||||
|
||||
/** What the picker opens on before a colour is chosen. */
|
||||
const INITIAL_COLOR = '#3A2A63';
|
||||
|
||||
interface CustomBackgroundRowProps {
|
||||
testId: string;
|
||||
/** The stored hex while a custom colour is active; `undefined` otherwise. */
|
||||
value: string | undefined;
|
||||
onChange: (hex: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The custom colour, as a row rather than a swatch: it opens a picker instead of
|
||||
* committing a value in one click. The picker warns below the contrast floor but
|
||||
* never blocks the choice.
|
||||
*/
|
||||
function CustomBackgroundRow({
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
}: CustomBackgroundRowProps): JSX.Element {
|
||||
const color = value ?? INITIAL_COLOR;
|
||||
const ratio = contrastRatio(inkForSurface(color), color);
|
||||
const isLegible = ratio >= MIN_CONTRAST_RATIO;
|
||||
|
||||
const contrastMessage = isLegible
|
||||
? `Contrast ${ratio.toFixed(1)}:1`
|
||||
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
|
||||
|
||||
function renderPanel(panel: ReactNode): ReactNode {
|
||||
return (
|
||||
<>
|
||||
{panel}
|
||||
<div
|
||||
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
|
||||
data-testid={`${testId}-contrast`}
|
||||
>
|
||||
{!isLegible && <TriangleAlert size={12} />}
|
||||
<span className="translate-safe">{contrastMessage}</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
value={color}
|
||||
size="small"
|
||||
showText={false}
|
||||
trigger="click"
|
||||
panelRender={renderPanel}
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={cx(styles.row, { [styles.active]: value !== undefined })}
|
||||
data-testid={testId}
|
||||
>
|
||||
<span
|
||||
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
|
||||
style={
|
||||
value ? { background: value, color: inkForSurface(value) } : undefined
|
||||
}
|
||||
>
|
||||
{value !== undefined && <Check size={14} />}
|
||||
</span>
|
||||
<Typography.Text className={styles.label}>Custom</Typography.Text>
|
||||
<span className={cx(styles.hex, 'translate-safe')}>
|
||||
{value ?? HEX_PLACEHOLDER}
|
||||
</span>
|
||||
<ChevronDown size={14} />
|
||||
</button>
|
||||
</ColorPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export default CustomBackgroundRow;
|
||||
@@ -0,0 +1,118 @@
|
||||
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
import {
|
||||
PanelTheme,
|
||||
TextBackgroundKind,
|
||||
TextBackgroundPreset,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
|
||||
import BackgroundSwatches from '../BackgroundSwatches';
|
||||
|
||||
function renderRow(
|
||||
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
|
||||
): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TooltipProvider>
|
||||
<BackgroundSwatches
|
||||
testId="background"
|
||||
label="Panel background"
|
||||
theme={PanelTheme.Dark}
|
||||
value={TextBackgroundKind.Default}
|
||||
onChange={onChange}
|
||||
{...props}
|
||||
/>
|
||||
</TooltipProvider>,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('BackgroundSwatches', () => {
|
||||
it('offers transparent, the default surface and the eight presets in order', () => {
|
||||
renderRow();
|
||||
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole('radio')
|
||||
.map((swatch) => swatch.getAttribute('aria-label')),
|
||||
).toStrictEqual([
|
||||
'Transparent',
|
||||
'Default panel',
|
||||
'Robin',
|
||||
'Purple',
|
||||
'Sakura',
|
||||
'Cherry',
|
||||
'Amber',
|
||||
'Forest',
|
||||
'Sienna',
|
||||
'Slate',
|
||||
]);
|
||||
});
|
||||
|
||||
it('is one labelled group', () => {
|
||||
renderRow();
|
||||
|
||||
expect(
|
||||
screen.getByRole('radiogroup', { name: 'Panel background' }),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
['background-none', 'Transparent — no card, border or title bar'],
|
||||
['background-default', 'Default panel colour'],
|
||||
['background-sakura', 'Sakura'],
|
||||
])('explains %s on hover', async (swatchId, copy) => {
|
||||
renderRow();
|
||||
|
||||
fireEvent.focus(screen.getByTestId(swatchId));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
|
||||
});
|
||||
});
|
||||
|
||||
it('paints each preset in the given theme', () => {
|
||||
renderRow({ theme: PanelTheme.Light });
|
||||
|
||||
expect(screen.getByTestId('background-amber')).toHaveStyle({
|
||||
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
|
||||
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
|
||||
});
|
||||
});
|
||||
|
||||
it('marks only the selected swatch, and checks it', () => {
|
||||
renderRow({ value: TextBackgroundPreset.Forest });
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
|
||||
expect(
|
||||
screen.getByRole('radio', { name: 'Default panel' }),
|
||||
).not.toBeChecked();
|
||||
expect(
|
||||
screen.getByTestId('background-forest').querySelector('svg'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('background-default').querySelector('svg'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports the swatch that was clicked', () => {
|
||||
const onChange = renderRow();
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('sienna');
|
||||
});
|
||||
|
||||
// jsdom does not implement radio arrow navigation, so the shared name — what
|
||||
// makes them one group — is what there is to assert.
|
||||
it('groups every swatch under one radio name', () => {
|
||||
renderRow();
|
||||
|
||||
const names = new Set(
|
||||
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
|
||||
);
|
||||
|
||||
expect(names).toStrictEqual(new Set(['background']));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import CustomBackgroundRow from '../CustomBackgroundRow';
|
||||
|
||||
function renderRow(value?: string): jest.Mock {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
|
||||
);
|
||||
return onChange;
|
||||
}
|
||||
|
||||
describe('CustomBackgroundRow', () => {
|
||||
it('stands in for the hex while no custom colour is set', () => {
|
||||
renderRow();
|
||||
|
||||
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
|
||||
});
|
||||
|
||||
it('shows the stored hex once one is set', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
|
||||
});
|
||||
|
||||
it('checks the chip only while the custom colour is the selection', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('leaves the chip unchecked while no custom colour is set', () => {
|
||||
renderRow();
|
||||
|
||||
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
|
||||
});
|
||||
|
||||
describe('the picker', () => {
|
||||
it('opens on the row', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('reports the contrast the colour achieves', () => {
|
||||
renderRow('#3A2A63');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
// The derived ink is pure white, not purple's paired ink.
|
||||
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
|
||||
'Contrast 12.4:1',
|
||||
);
|
||||
});
|
||||
|
||||
it('warns when no ink clears the floor, without disabling anything', () => {
|
||||
renderRow('#808080');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
|
||||
'below 4.5:1',
|
||||
);
|
||||
expect(screen.getByTestId('custom')).toBeEnabled();
|
||||
});
|
||||
|
||||
it('says nothing about the floor when the colour clears it', () => {
|
||||
renderRow('#111111');
|
||||
|
||||
fireEvent.click(screen.getByTestId('custom'));
|
||||
|
||||
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -5,7 +5,7 @@ import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { PanelKind } from '../../Panels/types/panelKind';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -23,6 +23,8 @@ import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppea
|
||||
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
|
||||
import FormattingSection from './sections/FormattingSection/FormattingSection';
|
||||
import LegendSection from './sections/LegendSection/LegendSection';
|
||||
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
|
||||
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
|
||||
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
|
||||
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
|
||||
|
||||
@@ -117,6 +119,23 @@ export const SECTION_REGISTRY: {
|
||||
update: (spec, buckets): PanelSpec =>
|
||||
updatePluginSlice(spec, 'histogramBuckets', buckets),
|
||||
},
|
||||
[SectionKind.TextLayout]: {
|
||||
Component: TextLayoutSection,
|
||||
get: (spec): SectionSpecMap[SectionKind.TextLayout] | undefined =>
|
||||
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
|
||||
update: (spec, presentation): PanelSpec =>
|
||||
updatePluginSlice(spec, 'presentation', presentation),
|
||||
},
|
||||
[SectionKind.PanelHeader]: {
|
||||
Component: PanelHeaderSection,
|
||||
get: (spec): SectionSpecMap[SectionKind.PanelHeader] | undefined =>
|
||||
getPluginSlice<SectionSpecMap[SectionKind.PanelHeader]>(
|
||||
spec,
|
||||
'headerOptions',
|
||||
),
|
||||
update: (spec, headerOptions): PanelSpec =>
|
||||
updatePluginSlice(spec, 'headerOptions', headerOptions),
|
||||
},
|
||||
[SectionKind.ContextLinks]: {
|
||||
Component: ContextLinksSection,
|
||||
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
|
||||
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
|
||||
function PanelHeaderSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
|
||||
return (
|
||||
<ConfigSwitch
|
||||
testId="panel-header-hide"
|
||||
title="Hide header"
|
||||
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
|
||||
value={value?.hide === true}
|
||||
onChange={(hide): void => onChange({ ...value, hide })}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelHeaderSection;
|
||||
@@ -0,0 +1,29 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import PanelHeaderSection from '../PanelHeaderSection';
|
||||
|
||||
describe('PanelHeaderSection', () => {
|
||||
it('toggles hide on', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<PanelHeaderSection value={undefined} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-header-hide'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ hide: true });
|
||||
});
|
||||
|
||||
it('toggles hide back off', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<PanelHeaderSection value={{ hide: true }} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-header-hide'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ hide: false });
|
||||
});
|
||||
|
||||
it('shows the header by default when the slice is empty', () => {
|
||||
render(<PanelHeaderSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-header-hide')).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import {
|
||||
resolveTextBackground,
|
||||
selectionFromResolved,
|
||||
storedFromSelection,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/resolveTextBackground';
|
||||
import type { TextBackgroundSelection } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import {
|
||||
PanelTheme,
|
||||
TextBackgroundKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
|
||||
import type {
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import BackgroundSwatches from '../../controls/BackgroundSwatches/BackgroundSwatches';
|
||||
import CustomBackgroundRow from '../../controls/BackgroundSwatches/CustomBackgroundRow';
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
import styles from './TextLayoutSection.module.scss';
|
||||
|
||||
const HORIZONTAL_OPTIONS = [
|
||||
{ value: DashboardtypesTextAlignDTO.left, label: 'Left' },
|
||||
{ value: DashboardtypesTextAlignDTO.center, label: 'Center' },
|
||||
{ value: DashboardtypesTextAlignDTO.right, label: 'Right' },
|
||||
];
|
||||
|
||||
const VERTICAL_OPTIONS = [
|
||||
{ value: DashboardtypesVerticalAlignDTO.top, label: 'Top' },
|
||||
{ value: DashboardtypesVerticalAlignDTO.center, label: 'Middle' },
|
||||
{ value: DashboardtypesVerticalAlignDTO.bottom, label: 'Bottom' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the Text panel's `presentation` slice: body alignment and the card
|
||||
* background (TDD D7 — scoped to the text spec, not the panel envelope).
|
||||
*/
|
||||
function TextLayoutSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<SectionKind.TextLayout>): JSX.Element {
|
||||
const theme = useIsDarkMode() ? PanelTheme.Dark : PanelTheme.Light;
|
||||
const background = resolveTextBackground(value?.background, theme);
|
||||
|
||||
return (
|
||||
<div className={styles.section}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Horizontal alignment</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="text-layout-horizontal-align"
|
||||
items={HORIZONTAL_OPTIONS}
|
||||
value={value?.textAlign ?? DashboardtypesTextAlignDTO.left}
|
||||
onChange={(textAlign): void => onChange({ ...value, textAlign })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Vertical alignment</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="text-layout-vertical-align"
|
||||
items={VERTICAL_OPTIONS}
|
||||
value={value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top}
|
||||
onChange={(verticalAlign): void => onChange({ ...value, verticalAlign })}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Background</Typography.Text>
|
||||
<BackgroundSwatches
|
||||
testId="text-layout-background"
|
||||
label="Panel background"
|
||||
theme={theme}
|
||||
value={selectionFromResolved(background)}
|
||||
onChange={(selection: TextBackgroundSelection): void =>
|
||||
onChange({
|
||||
...value,
|
||||
background: storedFromSelection(selection, theme),
|
||||
})
|
||||
}
|
||||
/>
|
||||
<CustomBackgroundRow
|
||||
testId="text-layout-background-custom"
|
||||
value={
|
||||
background.kind === TextBackgroundKind.Custom
|
||||
? background.surface
|
||||
: undefined
|
||||
}
|
||||
onChange={(hex): void => onChange({ ...value, background: hex })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TextLayoutSection;
|
||||
@@ -0,0 +1,144 @@
|
||||
import type { ReactElement } from 'react';
|
||||
import {
|
||||
fireEvent,
|
||||
render as rtlRender,
|
||||
type RenderResult,
|
||||
screen,
|
||||
} from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import {
|
||||
DashboardtypesTextAlignDTO,
|
||||
DashboardtypesVerticalAlignDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
TEXT_BACKGROUND_PAIRS,
|
||||
TRANSPARENT_BACKGROUND,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
|
||||
|
||||
import TextLayoutSection from '../TextLayoutSection';
|
||||
|
||||
const value = {
|
||||
textAlign: DashboardtypesTextAlignDTO.left,
|
||||
verticalAlign: DashboardtypesVerticalAlignDTO.top,
|
||||
};
|
||||
|
||||
// The swatch tooltips need a provider; AppLayout supplies one at runtime.
|
||||
function render(ui: ReactElement): RenderResult {
|
||||
return rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
|
||||
}
|
||||
|
||||
// The theme context defaults to dark, so the swatches paint the dark pairs.
|
||||
describe('TextLayoutSection', () => {
|
||||
it('changes horizontal alignment', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Center'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
textAlign: DashboardtypesTextAlignDTO.center,
|
||||
});
|
||||
});
|
||||
|
||||
it('changes vertical alignment', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByText('Bottom'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
verticalAlign: DashboardtypesVerticalAlignDTO.bottom,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores the surface of the theme a preset was picked in', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Amber' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
background: TEXT_BACKGROUND_PAIRS.amber.dark.surface,
|
||||
});
|
||||
});
|
||||
|
||||
it('stores a zero-alpha colour for transparent', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Transparent' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
background: TRANSPARENT_BACKGROUND,
|
||||
});
|
||||
});
|
||||
|
||||
it('unsets the background for the default panel surface', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<TextLayoutSection
|
||||
value={{ ...value, background: TRANSPARENT_BACKGROUND }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('radio', { name: 'Default panel' }));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ ...value, background: undefined });
|
||||
});
|
||||
|
||||
it('lights up the swatch the stored surface belongs to', () => {
|
||||
render(
|
||||
<TextLayoutSection
|
||||
value={{ ...value, background: TEXT_BACKGROUND_PAIRS.sakura.light.surface }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Sakura' })).toBeChecked();
|
||||
});
|
||||
|
||||
it('stores a custom colour straight from the picker', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<TextLayoutSection value={value} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('text-layout-background-custom'));
|
||||
fireEvent.change(screen.getByRole('textbox'), {
|
||||
target: { value: '3A2A64' },
|
||||
});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
...value,
|
||||
background: '#3a2a64',
|
||||
});
|
||||
});
|
||||
|
||||
it('shows a stored custom colour on the custom row alone', () => {
|
||||
render(
|
||||
<TextLayoutSection
|
||||
value={{ ...value, background: '#3A2A64' }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('text-layout-background-custom')).toHaveTextContent(
|
||||
'#3A2A64',
|
||||
);
|
||||
expect(
|
||||
screen
|
||||
.getAllByRole<HTMLInputElement>('radio')
|
||||
.filter((swatch) => swatch.checked),
|
||||
).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('selects the default surface when nothing is stored', () => {
|
||||
render(<TextLayoutSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByRole('radio', { name: 'Default panel' })).toBeChecked();
|
||||
expect(screen.getByRole('radio', { name: 'Transparent' })).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
@@ -8,9 +8,14 @@ import VisualizationSection from '../VisualizationSection';
|
||||
// the test doesn't pull the whole panel registry (renderers, chart libs).
|
||||
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
|
||||
getPanelDefinition: jest.fn(() => ({
|
||||
mode: 'query',
|
||||
supportedSignals: ['metrics', 'logs', 'traces'],
|
||||
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
|
||||
})),
|
||||
PANEL_OPTIONS: [
|
||||
{ kind: 'signoz/TimeSeriesPanel', displayName: 'Time Series' },
|
||||
{ kind: 'signoz/TablePanel', displayName: 'Table' },
|
||||
].map((option) => ({ ...option, icon: (): null => null })),
|
||||
}));
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
|
||||
@@ -8,10 +8,12 @@ import { Typography } from '@signozhq/ui/typography';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { useConfirmableAction } from 'hooks/useConfirmableAction';
|
||||
|
||||
import { DashboardDetailEvents } from 'pages/DashboardPage/constants/events';
|
||||
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import styles from './Header.module.scss';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
|
||||
interface HeaderProps {
|
||||
/** Unsaved edits exist — shows the "Unsaved Changes" badge and gates the discard confirmation on close (not the Save button). */
|
||||
@@ -20,7 +22,9 @@ interface HeaderProps {
|
||||
showSwitchToView?: boolean;
|
||||
/** Locked/no-permission dashboard — Save is disabled with a reason. */
|
||||
readOnly?: boolean;
|
||||
readOnlyReason?: string;
|
||||
/** Present when saving is unavailable — the Save button explains itself with it. */
|
||||
readOnlyChecks?: BrandedPermission[];
|
||||
readOnlyTooltip?: string;
|
||||
onSave: () => void;
|
||||
onSwitchToView?: () => void;
|
||||
onClose: () => void;
|
||||
@@ -31,7 +35,8 @@ function Header({
|
||||
isSaving,
|
||||
showSwitchToView = false,
|
||||
readOnly = false,
|
||||
readOnlyReason,
|
||||
readOnlyChecks = [],
|
||||
readOnlyTooltip,
|
||||
onSave,
|
||||
onSwitchToView,
|
||||
onClose,
|
||||
@@ -90,7 +95,10 @@ function Header({
|
||||
Switch to View Mode
|
||||
</Button>
|
||||
)}
|
||||
<DisabledControlTooltip reason={readOnlyReason ?? ''} disabled={readOnly}>
|
||||
<AuthZTooltip
|
||||
checks={readOnlyChecks}
|
||||
disabledTooltip={readOnly ? readOnlyTooltip : undefined}
|
||||
>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -101,7 +109,7 @@ function Header({
|
||||
>
|
||||
Save changes
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
</AuthZTooltip>
|
||||
</div>
|
||||
|
||||
<DialogWrapper
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ComponentProps } from 'react';
|
||||
import { MemoryRouter } from 'react-router-dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
|
||||
|
||||
@@ -82,7 +82,11 @@ describe('PanelEditor Header', () => {
|
||||
it('disables Save only while read-only or saving', () => {
|
||||
mockUseIsAIAssistantEnabled.mockReturnValue(false);
|
||||
|
||||
renderHeader({ isDirty: true, readOnly: true, readOnlyReason: 'Locked' });
|
||||
renderHeader({
|
||||
isDirty: true,
|
||||
readOnly: true,
|
||||
readOnlyTooltip: 'Locked',
|
||||
});
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-save')).toBeDisabled();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import {
|
||||
ResizableHandle,
|
||||
ResizablePanel,
|
||||
ResizablePanelGroup,
|
||||
useDefaultLayout,
|
||||
} from '@signozhq/ui/resizable';
|
||||
|
||||
import layoutStorage from '../layoutStorage';
|
||||
|
||||
import styles from '../PanelEditor.module.scss';
|
||||
|
||||
/** A resizable pane's bounds, in the percentage strings `ResizablePanel` takes. */
|
||||
interface PaneSize {
|
||||
minSize: string;
|
||||
maxSize: string;
|
||||
defaultSize: string;
|
||||
}
|
||||
|
||||
/** How the left column divides between the preview and the editor pane. */
|
||||
export interface PaneSplit {
|
||||
preview: PaneSize;
|
||||
editor: PaneSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vertical split per authoring mode. The query builder is a compact form, so the
|
||||
* preview keeps the room; a static kind's editor pane is the surface being worked
|
||||
* in, so it gets more and can grow further.
|
||||
*/
|
||||
export const PANE_SPLIT = {
|
||||
query: {
|
||||
preview: { minSize: '55%', maxSize: '65%', defaultSize: '60%' },
|
||||
editor: { minSize: '35%', maxSize: '45%', defaultSize: '40%' },
|
||||
},
|
||||
static: {
|
||||
preview: { minSize: '40%', maxSize: '65%', defaultSize: '55%' },
|
||||
editor: { minSize: '35%', maxSize: '60%', defaultSize: '45%' },
|
||||
},
|
||||
} as const satisfies Record<string, PaneSplit>;
|
||||
|
||||
interface PanelEditorLayoutProps {
|
||||
/** Save/close chrome — its affordances differ per authoring mode. */
|
||||
header: ReactNode;
|
||||
/** Upper-left: what the panel will look like once saved. */
|
||||
preview: ReactNode;
|
||||
/** Lower-left: the kind's `EditorPane` — the query builder, or a static kind's own. */
|
||||
editor: ReactNode;
|
||||
/** Right column: the kind's config sections. */
|
||||
config: ReactNode;
|
||||
split: PaneSplit;
|
||||
}
|
||||
|
||||
/**
|
||||
* The panel editor's frame: header, the resizable three-pane arrangement, and the
|
||||
* persistence of what the user drags. Owned once so both authoring modes cannot
|
||||
* drift apart on pane bounds or share a layout id by accident — they differ only in
|
||||
* `split` and in what fills the slots.
|
||||
*/
|
||||
function PanelEditorLayout({
|
||||
header,
|
||||
preview,
|
||||
editor,
|
||||
config,
|
||||
split,
|
||||
}: PanelEditorLayoutProps): JSX.Element {
|
||||
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
|
||||
id: 'panel-editor-v2',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
const {
|
||||
defaultLayout: mainDefaultLayout,
|
||||
onLayoutChanged: onMainLayoutChanged,
|
||||
} = useDefaultLayout({
|
||||
id: 'panel-editor-v2-main',
|
||||
storage: layoutStorage,
|
||||
});
|
||||
|
||||
return (
|
||||
<div className={styles.page} data-testid="panel-editor-v2">
|
||||
{header}
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2"
|
||||
orientation="horizontal"
|
||||
defaultLayout={defaultLayout}
|
||||
onLayoutChanged={onLayoutChanged}
|
||||
>
|
||||
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
|
||||
<div className={styles.left}>
|
||||
<ResizablePanelGroup
|
||||
id="panel-editor-v2-main"
|
||||
orientation="vertical"
|
||||
defaultLayout={mainDefaultLayout}
|
||||
onLayoutChanged={onMainLayoutChanged}
|
||||
>
|
||||
<ResizablePanel {...split.preview}>{preview}</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel {...split.editor}>{editor}</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
</ResizablePanel>
|
||||
<ResizableHandle withHandle className={styles.handle} />
|
||||
<ResizablePanel
|
||||
minSize="20%"
|
||||
maxSize="25%"
|
||||
defaultSize="20%"
|
||||
className={styles.right}
|
||||
>
|
||||
{config}
|
||||
</ResizablePanel>
|
||||
</ResizablePanelGroup>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PanelEditorLayout;
|
||||
@@ -21,20 +21,15 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { toPanelType } from '../../Panels/types/panelKind';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
|
||||
panelKind: PanelKind;
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** The panel's current signal; selects per-signal query-builder field rules. */
|
||||
signal: TelemetrytypesSignalDTO;
|
||||
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
|
||||
@@ -55,7 +50,7 @@ interface PanelEditorQueryBuilderProps {
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelKind,
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
@@ -65,10 +60,10 @@ function PanelEditorQueryBuilder({
|
||||
}: PanelEditorQueryBuilderProps): JSX.Element {
|
||||
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
|
||||
// builder offers for this kind comes from the kind's own declaration.
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const panelType = toPanelType(panelDefinition.kind);
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isListViewPanel = panelKind === 'signoz/ListPanel';
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -99,12 +94,12 @@ function PanelEditorQueryBuilder({
|
||||
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
|
||||
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
|
||||
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
|
||||
() => getHiddenQueryBuilderFields(panelKind, signal),
|
||||
[panelKind, signal],
|
||||
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
|
||||
[panelDefinition.queryBuilderFields, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
@@ -151,7 +146,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { QueryEditorPaneProps } from '../../Panels/types/panelDefinition';
|
||||
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
|
||||
|
||||
/**
|
||||
* The default query-kind editor pane: the query-builder tabs with no extras. A
|
||||
* kind that needs more (e.g. List's columns editor) declares its own wrapper.
|
||||
*/
|
||||
function QueryBuilderEditorPane({
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
onCancelQuery,
|
||||
stickyHeader,
|
||||
}: QueryEditorPaneProps): JSX.Element {
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelDefinition={panelDefinition}
|
||||
signal={signal}
|
||||
isLoadingQueries={isLoadingQueries}
|
||||
onStageRunQuery={onStageRunQuery}
|
||||
onCancelQuery={onCancelQuery}
|
||||
stickyHeader={stickyHeader}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryBuilderEditorPane;
|
||||
@@ -4,6 +4,9 @@ import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
|
||||
|
||||
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
|
||||
@@ -48,7 +51,7 @@ function renderBuilder(
|
||||
): void {
|
||||
render(
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind as never}
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
signal={signal}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
|
||||
@@ -9,6 +9,13 @@
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
// A static pane never scrolls — the panel card clips, and the renderer scrolls its
|
||||
// own body when the content outgrows it, as on the grid.
|
||||
.previewStatic {
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.header {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
@@ -56,6 +63,14 @@
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
// A static kind's card takes its colours from the background the panel declares,
|
||||
// falling back to the same tokens the query surface uses.
|
||||
.surfaceStatic {
|
||||
border-color: var(--text-panel-border, var(--l2-border));
|
||||
background: var(--text-panel-surface, var(--l2-background));
|
||||
color: var(--text-panel-ink, inherit);
|
||||
}
|
||||
|
||||
.state {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
|
||||
@@ -5,10 +5,16 @@ import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
|
||||
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
|
||||
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
|
||||
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
|
||||
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
|
||||
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
|
||||
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type {
|
||||
RenderableQueryPanelDefinition,
|
||||
RenderableStaticPanelDefinition,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
|
||||
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
|
||||
import type {
|
||||
PanelPagination,
|
||||
PanelQueryData,
|
||||
@@ -17,11 +23,17 @@ import type {
|
||||
import PlotTag from './PlotTag';
|
||||
import styles from './PreviewPane.module.scss';
|
||||
|
||||
interface PreviewPaneProps {
|
||||
interface PreviewPaneBaseProps {
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/** Resolved definition for the panel kind; */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
}
|
||||
|
||||
interface QueryPreviewPaneProps extends PreviewPaneBaseProps {
|
||||
mode: 'query';
|
||||
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
data: PanelQueryData;
|
||||
/** Any fetch in flight — drives the header spinner and the body's loading state. */
|
||||
isFetching: boolean;
|
||||
@@ -34,8 +46,6 @@ interface PreviewPaneProps {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
|
||||
pagination?: PanelPagination;
|
||||
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
|
||||
panelMode?: PanelMode;
|
||||
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
|
||||
hideHeader?: boolean;
|
||||
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
|
||||
@@ -48,41 +58,43 @@ interface PreviewPaneProps {
|
||||
enableDrillDown?: boolean;
|
||||
}
|
||||
|
||||
interface StaticPreviewPaneProps extends PreviewPaneBaseProps {
|
||||
mode: 'static';
|
||||
/** The kind's definition, narrowed to the static arm — no query, no Run step. */
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
/** Saves an edit made from the rendered body into the draft; absent = read-only. */
|
||||
onChangeText?: (text: string) => void;
|
||||
}
|
||||
|
||||
type PreviewPaneProps = QueryPreviewPaneProps | StaticPreviewPaneProps;
|
||||
|
||||
/**
|
||||
* Live preview for the panel editor: renders the draft through the same `PanelBody`
|
||||
* the dashboard grid uses (only `panelMode` differs), so the preview is the
|
||||
* production render path. The query result is owned by the editor root.
|
||||
* Live preview for the panel editor and the View modal: the draft rendered through
|
||||
* the same body the dashboard grid uses (only `panelMode` differs), so the preview
|
||||
* is the production render path. A query draft's result is owned by the editor
|
||||
* root; a static draft re-renders straight from the spec on every edit.
|
||||
*/
|
||||
function PreviewPane({
|
||||
panelId,
|
||||
panel,
|
||||
panelDefinition,
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
refetch,
|
||||
onDragSelect,
|
||||
pagination,
|
||||
panelMode = PanelMode.DASHBOARD_EDIT,
|
||||
hideHeader = false,
|
||||
dashboardPreference,
|
||||
onCloseStandaloneView,
|
||||
onClick,
|
||||
enableDrillDown,
|
||||
}: PreviewPaneProps): JSX.Element {
|
||||
const queryType = getPanelQueryType(panel);
|
||||
function PreviewPane(props: PreviewPaneProps): JSX.Element {
|
||||
const { panelId, panel, panelMode = PanelMode.DASHBOARD_EDIT } = props;
|
||||
const query = props.mode === 'query' ? props : null;
|
||||
const staticDraft = props.mode === 'static' ? props : null;
|
||||
const background = useTextBackground(panel.spec);
|
||||
|
||||
// Search term is ephemeral preview state, threaded to header + renderer but
|
||||
// not persisted to the draft spec. Only kinds that declare it render the box.
|
||||
const searchable = !!panelDefinition.actions.search;
|
||||
const searchable = !!query?.panelDefinition.actions.search;
|
||||
const [searchTerm, setSearchTerm] = useState('');
|
||||
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
{!hideHeader && (
|
||||
<div
|
||||
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
|
||||
>
|
||||
{query && !query.hideHeader && (
|
||||
<div className={styles.header}>
|
||||
<PlotTag queryType={queryType} className={styles.queryType} />
|
||||
<PlotTag
|
||||
queryType={getPanelQueryType(panel)}
|
||||
className={styles.queryType}
|
||||
/>
|
||||
<div className={styles.dateTimeSelector}>
|
||||
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
|
||||
</div>
|
||||
@@ -91,39 +103,67 @@ function PreviewPane({
|
||||
<div className={styles.container}>
|
||||
<div
|
||||
className={cx(styles.surface, {
|
||||
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
|
||||
[styles.surfaceStacked]:
|
||||
!!query && panelMode === PanelMode.STANDALONE_VIEW,
|
||||
[styles.surfaceStatic]: !!staticDraft,
|
||||
})}
|
||||
style={background.style}
|
||||
>
|
||||
<PanelHeader
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
error={error}
|
||||
warning={data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
panelDefinition={panelDefinition}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={pagination}
|
||||
onCloseStandaloneView={onCloseStandaloneView}
|
||||
onClick={onClick}
|
||||
enableDrillDown={enableDrillDown}
|
||||
/>
|
||||
{query ? (
|
||||
<>
|
||||
<PanelHeader
|
||||
mode="query"
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={query.data}
|
||||
isFetching={query.isFetching}
|
||||
error={query.error}
|
||||
warning={query.data.response?.data?.warning}
|
||||
searchable={searchable}
|
||||
searchTerm={searchTerm}
|
||||
onSearchChange={setSearchTerm}
|
||||
hideActions
|
||||
/>
|
||||
<PanelBody
|
||||
Renderer={query.panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={query.data}
|
||||
isFetching={query.isFetching}
|
||||
isPreviousData={query.isPreviousData}
|
||||
error={query.error}
|
||||
refetch={query.refetch}
|
||||
onDragSelect={query.onDragSelect}
|
||||
panelMode={panelMode}
|
||||
dashboardPreference={query.dashboardPreference}
|
||||
searchTerm={searchable ? searchTerm : undefined}
|
||||
pagination={query.pagination}
|
||||
onCloseStandaloneView={query.onCloseStandaloneView}
|
||||
onClick={query.onClick}
|
||||
enableDrillDown={query.enableDrillDown}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
staticDraft && (
|
||||
<>
|
||||
{!isPanelHeaderHidden(panel.spec) && (
|
||||
<PanelHeader
|
||||
mode="static"
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
hideActions
|
||||
/>
|
||||
)}
|
||||
<StaticPanelBody
|
||||
Renderer={staticDraft.panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
panelMode={panelMode}
|
||||
onChangeText={staticDraft.onChangeText}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,333 @@
|
||||
import { useCallback, useMemo } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { ConfigProvider } from 'antd';
|
||||
import {
|
||||
type DashboardtypesPanelDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
type SectionConfig,
|
||||
type SectionControls,
|
||||
SectionKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
|
||||
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { getExecStats } from '../queryV5/v5ResponseData';
|
||||
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import PanelEditorLayout, {
|
||||
PANE_SPLIT,
|
||||
} from './PanelEditorLayout/PanelEditorLayout';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import { useLegendSeries } from './hooks/useLegendSeries';
|
||||
import type { PanelEditorDraftApi } from './types';
|
||||
import { usePanelEditSession } from './hooks/usePanelEditSession';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
|
||||
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
|
||||
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
|
||||
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
|
||||
import { useTableColumns } from './hooks/useTableColumns';
|
||||
|
||||
import logEvent from '@/api/common/logEvent';
|
||||
import { DashboardEvents } from '../../constants/events';
|
||||
|
||||
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
|
||||
// popups (group-by, order-by, having, …) clip when they open into the short pane.
|
||||
// Portal them to the document body; the query-builder filters honor this via
|
||||
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
|
||||
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
|
||||
const getBodyPopupContainer = (): HTMLElement => document.body;
|
||||
|
||||
interface QueryEditorBodyProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
/**
|
||||
* The persisted panel the dirty check compares against. Distinct from `panel` (the
|
||||
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
|
||||
*/
|
||||
savedPanel?: DashboardtypesPanelDTO;
|
||||
/** Creating a new panel (seeded default) vs editing an existing one. */
|
||||
isNew?: boolean;
|
||||
/** Target section for a new panel; falls back to the last/new section. */
|
||||
layoutIndex?: number;
|
||||
/** Leave the editor (navigate back to the dashboard) without saving. */
|
||||
onClose: () => void;
|
||||
/** Called after a successful save — navigates back to the dashboard. */
|
||||
onSaved: () => void;
|
||||
/** Draft state, owned by the shell so it survives an authoring-mode switch. */
|
||||
draftApi: PanelEditorDraftApi;
|
||||
/** The draft kind's definition, narrowed by the shell's fork. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The query-kind editor body: a resizable split with the live preview + the
|
||||
* kind's editor pane on the left and the config pane on the right. Draft and
|
||||
* kind-switch state live in the shell; this body owns the query session and the
|
||||
* save round-trip.
|
||||
*/
|
||||
function QueryEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panel,
|
||||
savedPanel,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
onClose,
|
||||
onSaved,
|
||||
draftApi,
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
}: QueryEditorBodyProps): JSX.Element {
|
||||
// Read here rather than taken as props: this renders inside a loaded dashboard
|
||||
// subtree, so it resolves the same context every other consumer does.
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
|
||||
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
|
||||
// panel always serializes its seed query and seeds the builder's default signal.
|
||||
const {
|
||||
draft,
|
||||
spec,
|
||||
setSpec,
|
||||
isSpecDirty,
|
||||
query,
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
} = usePanelEditSession({
|
||||
panel,
|
||||
panelId,
|
||||
savedPanel,
|
||||
alwaysSerializeQuery: isNew,
|
||||
seedQuerySignal: true,
|
||||
draftApi,
|
||||
});
|
||||
const {
|
||||
data,
|
||||
isFetching,
|
||||
isPreviousData,
|
||||
error,
|
||||
cancelQuery,
|
||||
refetch,
|
||||
pagination,
|
||||
} = query;
|
||||
|
||||
// Live query type (the selected tab) — the type switcher disables kinds that can't be
|
||||
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
|
||||
// query until staged, so the spec would lag the tab.
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
|
||||
const panelKind = draft.spec.plugin.kind;
|
||||
// The kind's own lower pane (query builder, plus e.g. List's columns footer).
|
||||
const { EditorPane } = panelDefinition;
|
||||
|
||||
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
|
||||
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
|
||||
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
|
||||
const formattingControls = useMemo(():
|
||||
| SectionControls[SectionKind.Formatting]
|
||||
| undefined => {
|
||||
const section = panelDefinition.sections.find(
|
||||
(
|
||||
candidate,
|
||||
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
|
||||
candidate.kind === SectionKind.Formatting,
|
||||
);
|
||||
return section?.controls;
|
||||
}, [panelDefinition]);
|
||||
|
||||
// Unsaved-edits flag driving the discard confirmation on close (Save is always
|
||||
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
|
||||
// commits the seed into the draft on open, which would falsely dirty an untouched
|
||||
// query-less new panel.
|
||||
const isDirty = useMemo(
|
||||
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
|
||||
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
|
||||
);
|
||||
|
||||
const isListPanel = panelKind === 'signoz/ListPanel';
|
||||
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
|
||||
// values; cast at this boundary (as ConfigPane does) so the columns editor's
|
||||
// field-key lookup is typed.
|
||||
const listSignal =
|
||||
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
|
||||
TelemetrytypesSignalDTO.logs;
|
||||
|
||||
// Swap the List panel's columns to the new signal's defaults on signal change
|
||||
// (V1 had a per-signal field list; V2 has one `selectFields`).
|
||||
useSwitchColumnsOnSignalChange({
|
||||
enabled: isListPanel,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Seed a new List panel's columns from the query's resolved signal (not the kind's
|
||||
// default logs signal) so a traces-List export gets traces columns, not logs.
|
||||
useSeedNewListColumns({
|
||||
enabled: isNew && isListPanel,
|
||||
signal: listSignal,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
|
||||
const { onDragSelect } = usePanelInteractions();
|
||||
const legendSeries = useLegendSeries(draft, data);
|
||||
const tableColumns = useTableColumns(draft, data);
|
||||
|
||||
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
|
||||
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
|
||||
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
|
||||
const { metricUnit } = useSeedMetricUnit({
|
||||
isNewPanel: isNew,
|
||||
formattingControls,
|
||||
columns: tableColumns,
|
||||
spec,
|
||||
onChangeSpec: setSpec,
|
||||
});
|
||||
|
||||
// Smallest query step interval (seconds) — the floor for the span-gaps
|
||||
// threshold. Undefined until results carry step metadata.
|
||||
const stepInterval = useMemo((): number | undefined => {
|
||||
const intervals = getExecStats(data.response)?.stepIntervals;
|
||||
const values = intervals ? Object.values(intervals) : [];
|
||||
return values.length ? Math.min(...values) : undefined;
|
||||
}, [data.response]);
|
||||
|
||||
const onSwitchToView = useSwitchToViewMode({
|
||||
dashboardId,
|
||||
panelId,
|
||||
panelType: toPanelType(panelKind),
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// Bake the live query into the spec so unstaged edits are saved too.
|
||||
const savedPanelId = await save(buildSaveSpec(draft.spec));
|
||||
// Reveal the saved panel once the dashboard re-renders.
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved', {
|
||||
position: 'top-center',
|
||||
});
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
showErrorModal(err);
|
||||
}
|
||||
}, [
|
||||
isEditable,
|
||||
save,
|
||||
buildSaveSpec,
|
||||
draft.spec,
|
||||
setScrollTargetId,
|
||||
onSaved,
|
||||
showErrorModal,
|
||||
]);
|
||||
|
||||
// Leaving an existing panel's editor (without saving) still returns to it, so
|
||||
// the dashboard lands on that panel rather than scrolled to the top. A new,
|
||||
// unsaved panel has no persisted target, so there's nothing to reveal.
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
const switchToViewMode = useCallback((): void => {
|
||||
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
|
||||
panelId: panelId,
|
||||
});
|
||||
onSwitchToView();
|
||||
}, [onSwitchToView]);
|
||||
|
||||
return (
|
||||
<PanelEditorLayout
|
||||
split={PANE_SPLIT.query}
|
||||
header={
|
||||
<Header
|
||||
isDirty={isDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={!isNew}
|
||||
readOnly={!isEditable}
|
||||
readOnlyChecks={editChecks}
|
||||
readOnlyTooltip={editDisabledTooltip}
|
||||
onSave={onSave}
|
||||
onSwitchToView={switchToViewMode}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<PreviewPane
|
||||
mode="query"
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
data={data}
|
||||
isFetching={isFetching}
|
||||
isPreviousData={isPreviousData}
|
||||
error={error}
|
||||
refetch={refetch}
|
||||
onDragSelect={onDragSelect}
|
||||
pagination={pagination}
|
||||
/>
|
||||
}
|
||||
editor={
|
||||
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
|
||||
<EditorPane
|
||||
panelDefinition={panelDefinition}
|
||||
signal={listSignal}
|
||||
isLoadingQueries={isFetching}
|
||||
onStageRunQuery={runQuery}
|
||||
onCancelQuery={cancelQuery}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
/>
|
||||
</ConfigProvider>
|
||||
}
|
||||
config={
|
||||
<ConfigPane
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={currentQuery.queryType}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
stepInterval={stepInterval}
|
||||
metricUnit={metricUnit}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default QueryEditorBody;
|
||||
@@ -0,0 +1,134 @@
|
||||
import { useCallback } from 'react';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import { PanelMode } from 'lib/visualization/panels/types';
|
||||
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
|
||||
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
|
||||
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
|
||||
import ConfigPane from './ConfigPane/ConfigPane';
|
||||
import Header from './Header/Header';
|
||||
import PanelEditorLayout, {
|
||||
PANE_SPLIT,
|
||||
} from './PanelEditorLayout/PanelEditorLayout';
|
||||
import PreviewPane from './PreviewPane/PreviewPane';
|
||||
import type { PanelEditorContainerProps } from './index';
|
||||
import type { PanelEditorDraftApi } from './types';
|
||||
import { withPanelText } from '../Panels/utils/withPanelText';
|
||||
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
|
||||
|
||||
interface StaticEditorBodyProps extends PanelEditorContainerProps {
|
||||
draftApi: PanelEditorDraftApi;
|
||||
panelDefinition: RenderableStaticPanelDefinition;
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Editor body for a kind that renders from its own plugin spec: the kind's
|
||||
* editor pane under a live preview of the draft, the config pane on the right.
|
||||
* No query session, no builder seeding, no staged-run — the preview re-renders
|
||||
* from the draft spec on every edit.
|
||||
*/
|
||||
function StaticEditorBody({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew = false,
|
||||
layoutIndex,
|
||||
onClose,
|
||||
onSaved,
|
||||
draftApi,
|
||||
panelDefinition,
|
||||
onChangePanelKind,
|
||||
}: StaticEditorBodyProps): JSX.Element {
|
||||
// Read here rather than taken as props: this renders inside a loaded dashboard
|
||||
// subtree, so it resolves the same context every other consumer does.
|
||||
const { isEditable, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
|
||||
const { draft, spec, setSpec, isSpecDirty } = draftApi;
|
||||
const { EditorPane } = panelDefinition;
|
||||
|
||||
const { save, isSaving } = usePanelEditorSave({
|
||||
dashboardId,
|
||||
panelId,
|
||||
isNew,
|
||||
layoutIndex,
|
||||
});
|
||||
|
||||
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
|
||||
const { showErrorModal } = useErrorModal();
|
||||
|
||||
const onSave = useCallback(async (): Promise<void> => {
|
||||
if (!isEditable) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
// `queries: []` is the only shape the API accepts for a static kind.
|
||||
const savedPanelId = await save({ ...draft.spec, queries: [] });
|
||||
setScrollTargetId(savedPanelId);
|
||||
toast.success('Panel saved', {
|
||||
position: 'top-center',
|
||||
});
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
showErrorModal(err);
|
||||
}
|
||||
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
|
||||
|
||||
const onChangeText = useCallback(
|
||||
(text: string): void => setSpec(withPanelText(spec, text)),
|
||||
[spec, setSpec],
|
||||
);
|
||||
|
||||
const onCloseEditor = useCallback((): void => {
|
||||
if (!isNew) {
|
||||
setScrollTargetId(panelId);
|
||||
}
|
||||
onClose();
|
||||
}, [isNew, panelId, setScrollTargetId, onClose]);
|
||||
|
||||
return (
|
||||
<PanelEditorLayout
|
||||
split={PANE_SPLIT.static}
|
||||
header={
|
||||
<Header
|
||||
isDirty={isSpecDirty}
|
||||
isSaving={isSaving}
|
||||
showSwitchToView={false}
|
||||
readOnly={!isEditable}
|
||||
readOnlyChecks={editChecks}
|
||||
readOnlyTooltip={editDisabledTooltip}
|
||||
onSave={onSave}
|
||||
onClose={onCloseEditor}
|
||||
/>
|
||||
}
|
||||
preview={
|
||||
<PreviewPane
|
||||
mode="static"
|
||||
panelId={panelId}
|
||||
panel={draft}
|
||||
panelDefinition={panelDefinition}
|
||||
panelMode={PanelMode.DASHBOARD_EDIT}
|
||||
onChangeText={isEditable ? onChangeText : undefined}
|
||||
/>
|
||||
}
|
||||
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
|
||||
config={
|
||||
<ConfigPane
|
||||
panel={draft}
|
||||
panelId={panelId}
|
||||
spec={spec}
|
||||
onChangeSpec={setSpec}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
queryType={EQueryType.QUERY_BUILDER}
|
||||
legendSeries={[]}
|
||||
tableColumns={[]}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default StaticEditorBody;
|
||||
@@ -3,9 +3,30 @@ import userEvent from '@testing-library/user-event';
|
||||
import { toast } from '@signozhq/ui/sonner';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import PanelEditorContainer from '../index';
|
||||
|
||||
// The editor reads its edit context from the loaded dashboard subtree, which
|
||||
// these composition cases don't stand up; the derivation has its own suite.
|
||||
const mockEditContext = {
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
deleteChecks: [],
|
||||
isLocked: false,
|
||||
canEditDashboard: true,
|
||||
canDeleteDashboard: true,
|
||||
editDisabledTooltip: '',
|
||||
deleteDisabledTooltip: '',
|
||||
};
|
||||
jest.mock(
|
||||
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
|
||||
() => ({
|
||||
useDashboardEditContext: (): typeof mockEditContext => mockEditContext,
|
||||
}),
|
||||
);
|
||||
import { useScrollIntoViewStore } from '../../store/useScrollIntoViewStore';
|
||||
|
||||
/**
|
||||
@@ -172,8 +193,6 @@ function makePanel(
|
||||
const baseProps = {
|
||||
dashboardId: 'dash-1',
|
||||
panelId: 'panel-1',
|
||||
isEditable: true,
|
||||
editDisabledReason: '',
|
||||
onClose: jest.fn(),
|
||||
onSaved: jest.fn(),
|
||||
};
|
||||
@@ -232,7 +251,12 @@ describe('PanelEditorContainer composition', () => {
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
expect.objectContaining({
|
||||
panelDefinition: expect.objectContaining({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
mode: 'query',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -256,7 +280,7 @@ describe('PanelEditorContainer composition', () => {
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user