mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-14 07:20:41 +01:00
Compare commits
6 Commits
main
...
feat/story
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0aacce75f4 | ||
|
|
075e91cd2e | ||
|
|
b6bd48e20d | ||
|
|
785982e194 | ||
|
|
85681dd316 | ||
|
|
f2a38323b7 |
30
.github/workflows/jsci.yaml
vendored
30
.github/workflows/jsci.yaml
vendored
@@ -79,6 +79,36 @@ jobs:
|
||||
run: |
|
||||
cd frontend && pnpm generate:api
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run pnpm generate:api in frontend/ locally and commit."; exit 1)
|
||||
storybook:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
container:
|
||||
image: mcr.microsoft.com/playwright:v1.57.0-noble
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
shard: [1, 2, 3, 4]
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: install-pnpm
|
||||
uses: pnpm/action-setup@v6
|
||||
with:
|
||||
version: 10
|
||||
- name: node-install
|
||||
uses: actions/setup-node@v5
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: pnpm
|
||||
cache-dependency-path: frontend/pnpm-lock.yaml
|
||||
- name: install-frontend
|
||||
run: cd frontend && pnpm install
|
||||
- name: test-storybook
|
||||
run: cd frontend && pnpm test:storybook --shard=${{ matrix.shard }}/${{ strategy.job-total }}
|
||||
web-settings:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
|
||||
@@ -96,7 +96,6 @@ 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,17 +296,6 @@ components:
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
AlertmanagertypesChannelListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
@@ -587,43 +576,6 @@ 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
|
||||
@@ -990,20 +942,6 @@ 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:
|
||||
@@ -3536,11 +3474,6 @@ components:
|
||||
- tags
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesHeaderOptions:
|
||||
properties:
|
||||
hide:
|
||||
type: boolean
|
||||
type: object
|
||||
DashboardtypesHistogramBuckets:
|
||||
properties:
|
||||
bucketCount:
|
||||
@@ -3898,7 +3831,6 @@ 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:
|
||||
@@ -3909,7 +3841,6 @@ components:
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
|
||||
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
|
||||
type: object
|
||||
DashboardtypesPanelPluginKind:
|
||||
enum:
|
||||
@@ -3920,7 +3851,6 @@ components:
|
||||
- signoz/TablePanel
|
||||
- signoz/HistogramPanel
|
||||
- signoz/ListPanel
|
||||
- signoz/TextPanel
|
||||
type: string
|
||||
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
|
||||
properties:
|
||||
@@ -3994,18 +3924,6 @@ 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:
|
||||
@@ -4297,37 +4215,6 @@ 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:
|
||||
@@ -4537,12 +4424,6 @@ components:
|
||||
- kind
|
||||
- spec
|
||||
type: object
|
||||
DashboardtypesVerticalAlign:
|
||||
enum:
|
||||
- top
|
||||
- center
|
||||
- bottom
|
||||
type: string
|
||||
ErrorsJSON:
|
||||
properties:
|
||||
code:
|
||||
@@ -7514,7 +7395,10 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
meta:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
properties:
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
predictedSeries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
@@ -7529,51 +7413,12 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
Querybuildertypesv5Bucket:
|
||||
properties:
|
||||
buckets:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
unit:
|
||||
type: string
|
||||
step:
|
||||
format: double
|
||||
type: number
|
||||
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:
|
||||
@@ -7754,16 +7599,6 @@ components:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LinearBucketsSpec:
|
||||
properties:
|
||||
maxValue:
|
||||
format: double
|
||||
type: number
|
||||
numBuckets:
|
||||
type: integer
|
||||
required:
|
||||
- maxValue
|
||||
type: object
|
||||
Querybuildertypesv5LogAggregation:
|
||||
properties:
|
||||
alias:
|
||||
@@ -7771,12 +7606,6 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LogBucketsSpec:
|
||||
properties:
|
||||
scale:
|
||||
nullable: true
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -7857,8 +7686,6 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryBuilderFormula:
|
||||
properties:
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
disabled:
|
||||
type: boolean
|
||||
expression:
|
||||
@@ -7889,8 +7716,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7952,8 +7777,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -8015,8 +7838,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -8342,7 +8163,6 @@ components:
|
||||
- raw
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -8417,6 +8237,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5TimeSeriesValue:
|
||||
properties:
|
||||
bucket:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
|
||||
partial:
|
||||
type: boolean
|
||||
timestamp:
|
||||
@@ -8527,8 +8349,6 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8572,8 +8392,6 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
nullable: true
|
||||
type: array
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8679,7 +8497,6 @@ components:
|
||||
- TRACES_BASED_ALERT
|
||||
- LOGS_BASED_ALERT
|
||||
- EXCEPTIONS_BASED_ALERT
|
||||
- AI_TRACES_BASED_ALERT
|
||||
type: string
|
||||
RuletypesBasicRuleThreshold:
|
||||
properties:
|
||||
@@ -19903,86 +19720,6 @@ 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
|
||||
@@ -20045,239 +19782,6 @@ 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
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
<!--
|
||||
`index.html` links Inter from Google Fonts here. The link is parsed before the
|
||||
msw worker has started, so that one request escapes the iframe while every
|
||||
other font request the app makes is answered with an empty stylesheet; the
|
||||
local faces stand in for both.
|
||||
-->
|
||||
<link rel="stylesheet" href="storybook-fonts.css" />
|
||||
|
||||
<link rel="stylesheet" href="css/uPlot.min.css" />
|
||||
|
||||
<script>
|
||||
|
||||
50
frontend/.storybook/public/storybook-fonts.css
Normal file
50
frontend/.storybook/public/storybook-fonts.css
Normal file
@@ -0,0 +1,50 @@
|
||||
/*
|
||||
* The five families the app pulls from Google Fonts, served from the files in
|
||||
* `public/fonts` instead. `msw/appShellHandlers.ts` answers the CDN with an
|
||||
* empty stylesheet so no request leaves the iframe, which without this left
|
||||
* every story on a fallback for the four families `src/styles.scss` imports at
|
||||
* runtime, and on whatever the Inter link in `index.html` happened to fetch
|
||||
* before the worker had started.
|
||||
*/
|
||||
|
||||
@font-face {
|
||||
font-family: 'Inter';
|
||||
/* The comma is part of the filename and the dev server serves it raw, so it
|
||||
stays literal inside the quoted url rather than percent-encoded. */
|
||||
src: url('fonts/Inter-VariableFont_opsz,wght.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Work Sans';
|
||||
src: url('fonts/WorkSans-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Space Mono';
|
||||
src: url('fonts/SpaceMono-Regular.ttf') format('truetype');
|
||||
font-weight: 400;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Fira Code';
|
||||
src: url('fonts/FiraCode-VariableFont_wght.ttf') format('truetype');
|
||||
font-weight: 300 700;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: 'Geist Mono';
|
||||
src: url('fonts/GeistMonoVF.woff2') format('woff2');
|
||||
font-weight: 100 900;
|
||||
font-style: normal;
|
||||
font-display: swap;
|
||||
}
|
||||
85
frontend/.storybook/test-runner.ts
Normal file
85
frontend/.storybook/test-runner.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
import { getStoryContext } from '@storybook/test-runner';
|
||||
import type { TestRunnerConfig } from '@storybook/test-runner';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
const IGNORED_MESSAGES = [
|
||||
// `preview-head.html` swaps a local stylesheet in for Google Fonts, but the
|
||||
// browser still warns on the real cross-origin one it briefly requests
|
||||
// before msw starts (no CORS headers), regardless of story content.
|
||||
/Can't access cssRules/,
|
||||
// Pre-existing dev-server noise, unrelated to any story.
|
||||
/Couldn't load preload assets/,
|
||||
// Fires because a Jest-driven browser sets a global testing flag React
|
||||
// checks for; unrelated to anything a story does.
|
||||
/current testing environment is not configured to support act/,
|
||||
// React and antd route dev-only warnings (missing keys, DOM nesting, API
|
||||
// deprecations) through `console.error` under this prefix; app-wide and
|
||||
// tracked separately from story regressions.
|
||||
/^Warning: /,
|
||||
// msw's own warning when its response listener count grows across many
|
||||
// story visits in one browser session; not a story defect.
|
||||
/MaxListenersExceededWarning/,
|
||||
// `preview-head.html`'s CSP intentionally blocks third-party iframes
|
||||
// (YouTube embeds, the docs pane) so they hit the real network instead of
|
||||
// an unanswered msw request; the block is the point, not a bug.
|
||||
/violates the following Content Security Policy directive/,
|
||||
];
|
||||
|
||||
const messagesByPage = new WeakMap<Page, string[]>();
|
||||
|
||||
/**
|
||||
* Only `console.error` fails a story. `console.warn` is dev-time advice from
|
||||
* app code (e.g. `aggregateData is null`) and from the runner itself; an
|
||||
* unmocked `/api/` call is a `console.error` in `src/storybook/msw/handlers.ts`.
|
||||
*/
|
||||
const config: TestRunnerConfig = {
|
||||
// msw logs every mocked request at `log`; keep it out of the failure dump
|
||||
// unless the job is re-run with debug logging (GitHub sets RUNNER_DEBUG=1).
|
||||
logLevel: process.env.RUNNER_DEBUG === '1' ? 'info' : 'warn',
|
||||
async preVisit(page): Promise<void> {
|
||||
const existing = messagesByPage.get(page);
|
||||
if (existing) {
|
||||
existing.length = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const messages: string[] = [];
|
||||
messagesByPage.set(page, messages);
|
||||
page.on('console', (message) => {
|
||||
if (
|
||||
message.type() === 'error' &&
|
||||
!IGNORED_MESSAGES.some((pattern) => pattern.test(message.text()))
|
||||
) {
|
||||
messages.push(`[error] ${message.text()}`);
|
||||
}
|
||||
});
|
||||
// The console message alone ("Failed to load resource") doesn't name the
|
||||
// URL; pairing it with the response is what makes a missing mock
|
||||
// actionable instead of just a status code.
|
||||
page.on('response', (response) => {
|
||||
if (response.status() >= 400) {
|
||||
messages.push(`[response] ${response.status()} ${response.url()}`);
|
||||
}
|
||||
});
|
||||
},
|
||||
async postVisit(page, context): Promise<void> {
|
||||
const messages = messagesByPage.get(page) ?? [];
|
||||
if (messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// A story that deliberately mocks a failure response (e.g. a 500 to test
|
||||
// an error state) logs the error it's testing for; opt it out per-story
|
||||
// with `parameters: { allowConsoleErrors: true }`.
|
||||
const storyContext = await getStoryContext(page, context);
|
||||
if (storyContext.parameters?.allowConsoleErrors) {
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Story "${context.name}" logged console error/warning:\n${messages.join('\n')}`,
|
||||
);
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -34,9 +34,6 @@ 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
|
||||
@@ -48,20 +45,11 @@ These hold for every page. The per-pattern sections below only add to them.
|
||||
|
||||

|
||||
|
||||
Without `list`, but with any of `read` / `create` / `update`, the table and
|
||||
everything that only feeds it are blocked:
|
||||
Without `list`, but with any of `read` / `create` / `update`, only the table is blocked:
|
||||
|
||||
- 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`.
|
||||
- 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`.
|
||||
|
||||
### Edit page
|
||||
|
||||
@@ -108,11 +96,6 @@ 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|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)/)',
|
||||
'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)/)',
|
||||
// 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|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)',
|
||||
'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)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
"dev": "vite",
|
||||
"storybook": "storybook dev -p 6006",
|
||||
"storybook:build": "storybook build -o storybook-static",
|
||||
"test:storybook": "bash scripts/test-storybook.sh",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"prettify": "oxfmt",
|
||||
@@ -162,6 +163,7 @@
|
||||
"@jest/types": "30.2.0",
|
||||
"@storybook/addon-a11y": "10.5.9",
|
||||
"@storybook/react-vite": "10.5.9",
|
||||
"@storybook/test-runner": "0.24.5",
|
||||
"@testing-library/dom": "8.20.0",
|
||||
"@testing-library/jest-dom": "5.16.5",
|
||||
"@testing-library/react": "13.4.0",
|
||||
@@ -233,7 +235,7 @@
|
||||
},
|
||||
"overrides": {
|
||||
"@babel/core@<=7.29.0": ">=7.29.6 <8",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.2.0 <5",
|
||||
"@istanbuljs/load-nyc-config>js-yaml": ">=4.3.1 <5",
|
||||
"cookie@<0.7.0": ">=0.7.1 <1",
|
||||
"dompurify@<=3.4.10": ">=3.4.11 <4",
|
||||
"esbuild@>=0.27.3 <0.28.1": ">=0.28.1 <0.29.0",
|
||||
@@ -242,6 +244,14 @@
|
||||
"prismjs@<1.30.0": ">=1.30.0 <2",
|
||||
"react-router@>=6.7.0 <6.30.4": ">=6.30.4 <7",
|
||||
"tmp@<0.2.6": ">=0.2.6 <0.3.0",
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2"
|
||||
"yaml@>=1.0.0 <1.10.3": ">=1.10.3 <2",
|
||||
"brace-expansion@<1.1.18": ">=1.1.18 <2",
|
||||
"brace-expansion@>=2.0.0 <2.1.4": ">=2.1.4 <3",
|
||||
"brace-expansion@>=5.0.0 <5.0.9": ">=5.0.9 <6",
|
||||
"fast-uri@<3.1.5": ">=3.1.5 <4",
|
||||
"immutable@<5.1.8": ">=5.1.8 <6",
|
||||
"js-yaml@>=4.0.0 <4.3.1": ">=4.3.1 <5",
|
||||
"less@<4.5.0": ">=4.5.0 <5",
|
||||
"nanoid@<3.3.18": ">=3.3.18 <4"
|
||||
}
|
||||
}
|
||||
|
||||
1076
frontend/pnpm-lock.yaml
generated
1076
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -25,13 +25,11 @@
|
||||
"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",
|
||||
"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"
|
||||
"variable_name_already_exists": "Variable \"{{name}}\" already exists"
|
||||
}
|
||||
|
||||
31
frontend/scripts/test-storybook.sh
Executable file
31
frontend/scripts/test-storybook.sh
Executable file
@@ -0,0 +1,31 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
story_count=$(find src -name '*.stories.tsx' | wc -l)
|
||||
if [ "$story_count" -eq 0 ]; then
|
||||
echo "No *.stories.tsx found under src/" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# jest splits the sorted story files into contiguous shards and exits 1 when a
|
||||
# shard is empty, which happens on every shard above the file count. The runner
|
||||
# rejects jest's own `--passWithNoTests`, so skip those shards here.
|
||||
for arg in "$@"; do
|
||||
if [[ $arg == --shard=* ]]; then
|
||||
shard_index=${arg#--shard=}
|
||||
if [ "${shard_index%%/*}" -gt "$story_count" ]; then
|
||||
echo "Skipping ${arg#--shard=}: only ${story_count} story files"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
|
||||
pnpm storybook --ci --quiet &
|
||||
SB_PID=$!
|
||||
trap 'kill "$SB_PID" 2>/dev/null || true' EXIT
|
||||
|
||||
until curl -sf http://127.0.0.1:6006/index.json >/dev/null 2>&1; do
|
||||
sleep 1
|
||||
done
|
||||
|
||||
pnpm exec test-storybook --ci --maxWorkers=2 "$@"
|
||||
@@ -1558,18 +1558,6 @@ 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 },
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { AxiosError, AxiosResponse } from 'axios';
|
||||
import { AxiosError, AxiosResponse, isCancel } from 'axios';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import { ErrorStatusCode } from 'types/common';
|
||||
|
||||
@@ -42,6 +42,16 @@ export function ErrorResponseHandler(error: AxiosError): ErrorResponse {
|
||||
};
|
||||
}
|
||||
if (request) {
|
||||
// Avoid logging error when the request was just cancelled for whatever reason
|
||||
if (isCancel(error)) {
|
||||
return {
|
||||
statusCode: 500,
|
||||
payload: null,
|
||||
error: 'Something went wrong',
|
||||
message: null,
|
||||
};
|
||||
}
|
||||
|
||||
// client never received a response, or request never left
|
||||
console.error('client never received a response, or request never left');
|
||||
|
||||
|
||||
@@ -21,23 +21,14 @@ 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';
|
||||
@@ -658,105 +649,6 @@ 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
|
||||
@@ -841,370 +733,3 @@ 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,15 +507,6 @@ 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;
|
||||
}
|
||||
@@ -1009,44 +1000,6 @@ 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',
|
||||
@@ -2438,14 +2391,6 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesTestableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4134,53 +4079,6 @@ 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
|
||||
@@ -4374,7 +4272,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4502,7 +4399,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4578,7 +4474,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -5019,57 +4914,6 @@ 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
|
||||
@@ -5077,8 +4921,7 @@ export type DashboardtypesPanelPluginDTO =
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
|
||||
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
|
||||
|
||||
export enum Querybuildertypesv5RequestTypeDTO {
|
||||
scalar = 'scalar',
|
||||
@@ -5086,7 +4929,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
}
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
@@ -5133,7 +4975,6 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -6002,7 +5843,6 @@ export enum DashboardtypesPanelPluginKindDTO {
|
||||
'signoz/TablePanel' = 'signoz/TablePanel',
|
||||
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
|
||||
'signoz/ListPanel' = 'signoz/ListPanel',
|
||||
'signoz/TextPanel' = 'signoz/TextPanel',
|
||||
}
|
||||
/**
|
||||
* @nullable
|
||||
@@ -8702,7 +8542,16 @@ export interface Querybuildertypesv5LabelDTO {
|
||||
value?: Querybuildertypesv5LabelDTOValue;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
bucket?: Querybuildertypesv5BucketDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -9253,16 +9102,12 @@ export interface PromotetypesPromotePathDTO {
|
||||
promote?: boolean;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
buckets?: number[];
|
||||
export type Querybuildertypesv5AggregationBucketDTOMeta = {
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
}
|
||||
};
|
||||
|
||||
export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
/**
|
||||
@@ -9281,7 +9126,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
* @type array
|
||||
*/
|
||||
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9296,10 +9144,6 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketsKindDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9766,10 +9610,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
overallStateChanged: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9818,10 +9658,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
|
||||
* @type array,null
|
||||
*/
|
||||
labels: Querybuildertypesv5LabelDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9917,7 +9753,6 @@ 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',
|
||||
@@ -13327,44 +13162,6 @@ 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;
|
||||
/**
|
||||
@@ -13373,31 +13170,6 @@ 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;
|
||||
/**
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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;
|
||||
@@ -1,93 +0,0 @@
|
||||
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;
|
||||
@@ -1,91 +0,0 @@
|
||||
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;
|
||||
@@ -1,253 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
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;
|
||||
@@ -1,44 +0,0 @@
|
||||
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;
|
||||
@@ -1,270 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,258 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,24 +0,0 @@
|
||||
// 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' },
|
||||
];
|
||||
@@ -1,69 +0,0 @@
|
||||
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 },
|
||||
});
|
||||
}
|
||||
@@ -1,269 +0,0 @@
|
||||
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 },
|
||||
];
|
||||
@@ -1,149 +0,0 @@
|
||||
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];
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
/** 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,8 +31,6 @@ 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,7 +376,6 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
|
||||
@@ -376,7 +376,9 @@ export default function BillingContainer(): JSX.Element {
|
||||
</Typography.Link>
|
||||
</AuthZTooltip>
|
||||
{` if your payment information has changed. Email us at `}
|
||||
<Typography.Text color="muted">cloud-support@signoz.io</Typography.Text>
|
||||
<Typography.Text as="span" color="muted">
|
||||
cloud-support@signoz.io
|
||||
</Typography.Text>
|
||||
{` otherwise. Be sure to provide this information immediately to avoid interruption to your service.`}
|
||||
</Typography>
|
||||
);
|
||||
|
||||
@@ -174,6 +174,7 @@ export default function ServiceTraces({
|
||||
columns={columns}
|
||||
dataSource={top5Services}
|
||||
pagination={false}
|
||||
rowKey="serviceName"
|
||||
className="services-table"
|
||||
onRow={(record: ServicesList): Record<string, unknown> => ({
|
||||
onClick: (event: React.MouseEvent): void => {
|
||||
|
||||
@@ -216,7 +216,7 @@ function K8sOptionsSidePanel<TData>({
|
||||
);
|
||||
return (
|
||||
<div className={styles.columnItem} key={column.id}>
|
||||
<Typography.Text size="sm" className={styles.columnLabel}>
|
||||
<Typography.Text as="span" size="sm" className={styles.columnLabel}>
|
||||
{column.label}
|
||||
</Typography.Text>
|
||||
{column.canBeHidden ? (
|
||||
|
||||
@@ -329,10 +329,9 @@ describe('transformTransactionGroupsToResourcePermissions', () => {
|
||||
it('returns all resources from RESOURCE_ORDER even with empty transaction groups', () => {
|
||||
const result = transformTransactionGroupsToResourcePermissions([]);
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
@@ -421,10 +420,9 @@ describe('createEmptyRolePermissions', () => {
|
||||
it('creates permissions for all resources in RESOURCE_ORDER', () => {
|
||||
const result = createEmptyRolePermissions();
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
expect(result).toHaveLength(9);
|
||||
expect(result.map((r) => r.resourceKind)).toStrictEqual([
|
||||
'factor-api-key',
|
||||
'dashboard',
|
||||
'license',
|
||||
'logs',
|
||||
'meter-metrics',
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
DraftingCompass,
|
||||
FileKey,
|
||||
Gauge,
|
||||
Grid3X3,
|
||||
Key,
|
||||
Logs,
|
||||
Receipt,
|
||||
@@ -42,14 +41,6 @@ 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,6 +29,5 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -14,8 +14,6 @@ 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,
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,31 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
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') : '',
|
||||
};
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
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,6 +53,22 @@ 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,19 +7,14 @@ 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
|
||||
@@ -29,10 +24,8 @@ export type AuthZButtonProps = ButtonProps & {
|
||||
|
||||
function AuthZButton({
|
||||
checks,
|
||||
authZEnabled = true,
|
||||
tooltipMessage,
|
||||
disabledTooltip,
|
||||
side,
|
||||
authZEnabled = true,
|
||||
withPortal,
|
||||
...buttonProps
|
||||
}: AuthZButtonProps): JSX.Element {
|
||||
@@ -41,8 +34,6 @@ function AuthZButton({
|
||||
checks={checks}
|
||||
enabled={authZEnabled}
|
||||
tooltipMessage={tooltipMessage}
|
||||
disabledTooltip={disabledTooltip}
|
||||
side={side}
|
||||
withPortal={withPortal}
|
||||
>
|
||||
<Button {...buttonProps} />
|
||||
|
||||
@@ -4,7 +4,6 @@ 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';
|
||||
@@ -12,26 +11,15 @@ import AuthZTooltip from './AuthZTooltip';
|
||||
jest.mock('lib/authz/hooks/useAuthZ/useAuthZ');
|
||||
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
|
||||
|
||||
// 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 noPermissions = {
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
error: null,
|
||||
permissions: null,
|
||||
allowed: false,
|
||||
deniedPermissions: [] as BrandedPermission[],
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
|
||||
const TestButton = (
|
||||
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
@@ -54,11 +42,10 @@ const attachRolePerm = buildPermission(
|
||||
|
||||
describe('AuthZTooltip — single check', () => {
|
||||
it('renders child unchanged when permission is granted', () => {
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: true } },
|
||||
}),
|
||||
);
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: true } },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -70,11 +57,10 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('disables child when permission is denied', () => {
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
}),
|
||||
);
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -86,11 +72,10 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('shows formatted permission message in tooltip when denied', async () => {
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
}),
|
||||
);
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: { [createPerm]: { isGranted: false } },
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -110,7 +95,7 @@ describe('AuthZTooltip — single check', () => {
|
||||
});
|
||||
|
||||
it('disables child while loading', () => {
|
||||
mockUseAuthZ.mockReturnValue(authZResult({ isLoading: true }));
|
||||
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[createPerm]}>
|
||||
@@ -125,14 +110,13 @@ 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(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: true },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: true },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -145,14 +129,13 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('disables child when first check is denied, second granted', () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: true },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -165,14 +148,13 @@ 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(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
@@ -191,14 +173,13 @@ describe('AuthZTooltip — multi-check (checks array)', () => {
|
||||
|
||||
it('shows multiple formatted permissions in tooltip when both denied', async () => {
|
||||
const sa = attachSAPerm('sa-1');
|
||||
mockUseAuthZ.mockReturnValue(
|
||||
authZResult({
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
}),
|
||||
);
|
||||
mockUseAuthZ.mockReturnValue({
|
||||
...noPermissions,
|
||||
permissions: {
|
||||
[sa]: { isGranted: false },
|
||||
[attachRolePerm]: { isGranted: false },
|
||||
},
|
||||
});
|
||||
|
||||
render(
|
||||
<AuthZTooltip checks={[sa, attachRolePerm]}>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.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,12 +1,4 @@
|
||||
import {
|
||||
cloneElement,
|
||||
CSSProperties,
|
||||
ReactElement,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { cloneElement, CSSProperties, ReactElement, useMemo } from 'react';
|
||||
import {
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
@@ -17,9 +9,7 @@ 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 cx from 'classnames';
|
||||
|
||||
import styles from '../tooltipContent.module.scss';
|
||||
import styles from './AuthZTooltip.module.scss';
|
||||
|
||||
const DISABLED_STYLE: CSSProperties = {
|
||||
pointerEvents: 'all',
|
||||
@@ -32,23 +22,7 @@ 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
|
||||
@@ -73,17 +47,10 @@ function AuthZTooltip({
|
||||
children,
|
||||
enabled = true,
|
||||
tooltipMessage,
|
||||
disabledTooltip,
|
||||
side,
|
||||
withPortal,
|
||||
}: AuthZTooltipProps): JSX.Element {
|
||||
const { user } = useAppContext();
|
||||
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 shouldCheck = enabled && checks.length > 0;
|
||||
|
||||
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
|
||||
|
||||
@@ -94,20 +61,6 @@ 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,
|
||||
@@ -118,7 +71,7 @@ function AuthZTooltip({
|
||||
});
|
||||
}
|
||||
|
||||
if (!isBlocked && (!shouldCheck || deniedPermissions.length === 0)) {
|
||||
if (!shouldCheck || deniedPermissions.length === 0) {
|
||||
return children;
|
||||
}
|
||||
|
||||
@@ -126,7 +79,7 @@ function AuthZTooltip({
|
||||
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<TooltipRoot open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<TooltipRoot>
|
||||
<TooltipTrigger asChild testId={childTestId}>
|
||||
{cloneElement(children, {
|
||||
disabled: true,
|
||||
@@ -134,31 +87,11 @@ function AuthZTooltip({
|
||||
onClick: noOp,
|
||||
onMouseDown: noOp,
|
||||
onPointerDown: noOp,
|
||||
onPointerEnter: (): void => {
|
||||
isPointerOverRef.current = true;
|
||||
},
|
||||
onPointerLeave: (): void => {
|
||||
isPointerOverRef.current = false;
|
||||
},
|
||||
...(isBlocked
|
||||
? {}
|
||||
: { 'data-denied-permissions': deniedPermissions.join(',') }),
|
||||
'data-denied-permissions': deniedPermissions.join(','),
|
||||
})}
|
||||
</TooltipTrigger>
|
||||
<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 className={styles.errorContent} withPortal={withPortal}>
|
||||
{formatDeniedMessage(deniedPermissions, user.id, tooltipMessage)}
|
||||
</TooltipContent>
|
||||
</TooltipRoot>
|
||||
</TooltipProvider>
|
||||
|
||||
@@ -1,42 +0,0 @@
|
||||
// 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,7 +6,6 @@ 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 = {
|
||||
/**
|
||||
@@ -46,18 +45,6 @@ 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'];
|
||||
@@ -99,21 +86,12 @@ export function createAuthZHOC<P extends object>(
|
||||
Component: ComponentType<P>,
|
||||
opts: WithAuthZOptions<P>,
|
||||
): ComponentType<P> {
|
||||
const { checks, preloadChecks, ...guardProps } = opts;
|
||||
const { checks, ...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,11 +3,6 @@ export default {
|
||||
status: 'success',
|
||||
data: {
|
||||
resources: [
|
||||
{
|
||||
kind: 'dashboard',
|
||||
type: 'metaresource',
|
||||
allowedVerbs: ['create', 'delete', 'list', 'read', 'update'],
|
||||
},
|
||||
{
|
||||
kind: 'factor-api-key',
|
||||
type: 'metaresource',
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
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,9 +97,5 @@ 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,12 +240,6 @@ 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 [];
|
||||
@@ -260,7 +254,6 @@ export function useAuthZ(
|
||||
permissions: data ?? null,
|
||||
allowed,
|
||||
deniedPermissions,
|
||||
isGranted,
|
||||
refetchPermissions,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -170,7 +170,6 @@ export function mockUseAuthZGrantAll(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: true,
|
||||
deniedPermissions: [],
|
||||
isGranted: (): boolean => true,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -188,7 +187,6 @@ export function mockUseAuthZDenyAll(
|
||||
) as UseAuthZResult['permissions'],
|
||||
allowed: false,
|
||||
deniedPermissions: permissions,
|
||||
isGranted: (): boolean => false,
|
||||
refetchPermissions: jest.fn(),
|
||||
};
|
||||
}
|
||||
@@ -215,8 +213,6 @@ 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,5 +1,4 @@
|
||||
import {
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
@@ -30,31 +29,29 @@ 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 { useDashboardCollectionPermissions } from 'hooks/dashboards/useDashboardCollectionPermissions';
|
||||
import { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
|
||||
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 DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import DisabledMenuItemLabel from '../../components/DisabledMenuItemLabel/DisabledMenuItemLabel';
|
||||
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;
|
||||
@@ -65,37 +62,19 @@ function DashboardActions({
|
||||
dashboard,
|
||||
handle,
|
||||
isDashboardLocked,
|
||||
isAuthor,
|
||||
onAddPanel,
|
||||
onLockToggle,
|
||||
onOpenRename,
|
||||
}: DashboardActionsProps): JSX.Element {
|
||||
const {
|
||||
isLocked,
|
||||
isEditable,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
canDeleteDashboard,
|
||||
canReadDashboard,
|
||||
} = useDashboardEditContext();
|
||||
const canEditDashboard = useDashboardStore((s) => s.canEditDashboard);
|
||||
const isLocked = useDashboardStore((s) => s.isLocked);
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
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);
|
||||
@@ -178,110 +157,101 @@ function DashboardActions({
|
||||
void handle.enter();
|
||||
}, [dashboard.id, handle]);
|
||||
|
||||
// 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}
|
||||
/>
|
||||
),
|
||||
[],
|
||||
// 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],
|
||||
);
|
||||
|
||||
const menuItems = useMemo<MenuItem[]>(() => {
|
||||
const dashboardGroup: MenuItem[] = [
|
||||
{
|
||||
const dashboardGroup: MenuItem[] = [];
|
||||
if (canEditDashboard) {
|
||||
dashboardGroup.push({
|
||||
key: 'rename',
|
||||
label: row('Rename', <PenLine size={14} />, editChecks, {
|
||||
disabledTooltip: editDisabledTooltip,
|
||||
}),
|
||||
disabled: !isEditable,
|
||||
label: editLabel('Rename'),
|
||||
icon: <PenLine size={14} />,
|
||||
disabled: isLocked,
|
||||
onClick: onOpenRename,
|
||||
},
|
||||
});
|
||||
// Clone creates a new dashboard, so it's not lock-gated.
|
||||
{
|
||||
dashboardGroup.push({
|
||||
key: 'clone',
|
||||
label: row('Clone dashboard', <Copy size={14} />, cloneChecks),
|
||||
disabled: isCloning || cloneDenied,
|
||||
label: 'Clone dashboard',
|
||||
icon: <Copy size={14} />,
|
||||
disabled: isCloning,
|
||||
onClick: (): void => void handleClone(),
|
||||
},
|
||||
{
|
||||
key: 'lock',
|
||||
label: row(
|
||||
isDashboardLocked ? 'Unlock dashboard' : 'Lock dashboard',
|
||||
<LockKeyhole size={14} />,
|
||||
editChecks,
|
||||
{ disabledTooltip: lockDisabledTooltip },
|
||||
),
|
||||
disabled: !canToggleLock,
|
||||
onClick: onLockToggle,
|
||||
},
|
||||
{
|
||||
key: 'fullscreen',
|
||||
label: row('Full screen', <Fullscreen size={14} />, []),
|
||||
onClick: handleEnterFullScreen,
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
return [
|
||||
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',
|
||||
onClick: onLockToggle,
|
||||
});
|
||||
}
|
||||
dashboardGroup.push({
|
||||
key: 'fullscreen',
|
||||
label: 'Full screen',
|
||||
icon: <Fullscreen size={14} />,
|
||||
onClick: handleEnterFullScreen,
|
||||
});
|
||||
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
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: row('New section', <SquareStack size={14} />, editChecks, {
|
||||
disabledTooltip: editDisabledTooltip,
|
||||
}),
|
||||
disabled: !isEditable,
|
||||
label: editLabel('New section'),
|
||||
icon: <SquareStack size={14} />,
|
||||
disabled: isLocked,
|
||||
onClick: (): void => setIsNewSectionOpen(true),
|
||||
},
|
||||
],
|
||||
},
|
||||
{ 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,
|
||||
},
|
||||
];
|
||||
});
|
||||
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;
|
||||
}, [
|
||||
row,
|
||||
isEditable,
|
||||
editLabel,
|
||||
canEditDashboard,
|
||||
isLocked,
|
||||
editChecks,
|
||||
editDisabledTooltip,
|
||||
deleteChecks,
|
||||
deleteDisabledTooltip,
|
||||
canDeleteDashboard,
|
||||
cloneChecks,
|
||||
cloneDenied,
|
||||
isCloning,
|
||||
canToggleLock,
|
||||
lockDisabledTooltip,
|
||||
isAuthor,
|
||||
user.role,
|
||||
isDashboardLocked,
|
||||
dashboard.createdBy,
|
||||
onOpenRename,
|
||||
handleClone,
|
||||
onLockToggle,
|
||||
@@ -291,10 +261,7 @@ function DashboardActions({
|
||||
|
||||
return (
|
||||
<div className={styles.dashboardActionsContainer}>
|
||||
<DropdownMenuSimple
|
||||
menu={{ items: menuItems }}
|
||||
className={menuStyles.menuContent}
|
||||
>
|
||||
<DropdownMenuSimple menu={{ items: menuItems }}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -306,31 +273,38 @@ function DashboardActions({
|
||||
Actions
|
||||
</Button>
|
||||
</DropdownMenuSimple>
|
||||
<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>
|
||||
{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>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
@@ -342,19 +316,24 @@ function DashboardActions({
|
||||
>
|
||||
JSON
|
||||
</Button>
|
||||
<AuthZTooltip checks={editChecks} disabledTooltip={editDisabledTooltip}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={!isEditable}
|
||||
size="md"
|
||||
{canEditDashboard && (
|
||||
<DisabledControlTooltip
|
||||
reason={DASHBOARD_LOCKED_REASON}
|
||||
disabled={isLocked}
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
onClick={onAddPanel}
|
||||
prefix={<Plus size="md" />}
|
||||
testId="add-panel-header"
|
||||
disabled={isLocked}
|
||||
size="md"
|
||||
>
|
||||
New Panel
|
||||
</Button>
|
||||
</DisabledControlTooltip>
|
||||
)}
|
||||
<JsonEditorDrawer
|
||||
dashboard={dashboard}
|
||||
isOpen={isJsonEditorOpen}
|
||||
|
||||
@@ -1,189 +0,0 @@
|
||||
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,7 +25,6 @@ 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.
|
||||
@@ -44,8 +43,6 @@ 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;
|
||||
@@ -64,7 +61,6 @@ function DashboardInfo({
|
||||
isDashboardLocked,
|
||||
showLockToggle,
|
||||
onToggleLock,
|
||||
lockDisabledTooltip,
|
||||
isEditing,
|
||||
draft,
|
||||
onDraftChange,
|
||||
@@ -72,7 +68,7 @@ function DashboardInfo({
|
||||
onCommit,
|
||||
onCancel,
|
||||
}: DashboardInfoProps): JSX.Element {
|
||||
const { isEditable: canEdit } = useDashboardEditContext();
|
||||
const canEdit = useDashboardStore((s) => s.isEditable);
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
|
||||
const hasTags = tags.length > 0;
|
||||
@@ -81,15 +77,11 @@ 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 AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
|
||||
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
|
||||
import DisabledControlTooltip from '../../components/DisabledControlTooltip/DisabledControlTooltip';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
|
||||
interface JsonEditorDrawerProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
@@ -30,13 +30,10 @@ 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;
|
||||
|
||||
@@ -178,10 +175,7 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<AuthZTooltip
|
||||
checks={readOnlyChecks}
|
||||
disabledTooltip={readOnly ? readOnlyTooltip : undefined}
|
||||
>
|
||||
<DisabledControlTooltip reason={readOnlyReason} disabled={readOnly}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
@@ -192,7 +186,7 @@ function JsonEditorDrawer({
|
||||
>
|
||||
Apply changes
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</DisabledControlTooltip>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { fireEvent, render, screen } from 'tests/test-utils';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { TooltipProvider } from '@signozhq/ui/tooltip';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
@@ -10,21 +10,8 @@ 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;
|
||||
editChecks: unknown[];
|
||||
deleteChecks: unknown[];
|
||||
areOtherPermissionsLoading: boolean;
|
||||
editDisabledTooltip: string;
|
||||
}) => unknown,
|
||||
): unknown =>
|
||||
selector({
|
||||
isEditable: true,
|
||||
editChecks: [],
|
||||
areOtherPermissionsLoading: false,
|
||||
deleteChecks: [],
|
||||
editDisabledTooltip: '',
|
||||
}),
|
||||
selector: (s: { isEditable: boolean; editDisabledReason: string }) => unknown,
|
||||
): unknown => selector({ isEditable: true, editDisabledReason: '' }),
|
||||
}));
|
||||
|
||||
jest.mock('@monaco-editor/react', () => ({
|
||||
@@ -78,26 +65,6 @@ 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());
|
||||
|
||||
@@ -128,7 +95,7 @@ describe('JsonEditorDrawer', () => {
|
||||
mockUseJsonEditor.mockReturnValue(
|
||||
hookValue({ danglingPanelIds: ['p1', 'p2'] }),
|
||||
);
|
||||
const { unmount } = render(
|
||||
const { rerender } = render(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
@@ -137,11 +104,8 @@ 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: [] }));
|
||||
render(
|
||||
rerender(
|
||||
<TooltipProvider>
|
||||
<JsonEditorDrawer dashboard={dashboard} isOpen onClose={jest.fn()} />
|
||||
</TooltipProvider>,
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
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 { useDashboardLockPermission } from 'hooks/dashboards/useDashboardLockPermission';
|
||||
import { useToggleDashboardLock } from 'hooks/dashboards/useToggleDashboardLock';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useErrorModal } from 'providers/ErrorModalProvider';
|
||||
import APIError from 'types/api/error';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getAbsoluteUrl } from 'utils/basePath';
|
||||
|
||||
import { useCreatePanel } from '../hooks/useCreatePanel';
|
||||
@@ -35,6 +42,7 @@ 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);
|
||||
@@ -56,6 +64,7 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
[dashboard.tags],
|
||||
);
|
||||
|
||||
const { user } = useAppContext();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
const { patchAsync } = useOptimisticPatch();
|
||||
const {
|
||||
@@ -66,51 +75,59 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
targetLayoutIndex,
|
||||
} = useCreatePanel();
|
||||
|
||||
// dashboard:update, then the backend's source and creator-or-admin rules.
|
||||
const { canToggleLock, disabledTooltip: lockDisabledTooltip } =
|
||||
useDashboardLockPermission({
|
||||
dashboardId: id,
|
||||
source: dashboard.source,
|
||||
});
|
||||
const isAuthor =
|
||||
!!user?.email && !!dashboard.createdBy && dashboard.createdBy === user.email;
|
||||
|
||||
// Author/admin can lock-unlock (mirrors the Actions menu gate); integration-owned
|
||||
// dashboards are never toggleable.
|
||||
const canToggleLock =
|
||||
(isAuthor || user.role === USER_ROLES.ADMIN) &&
|
||||
dashboard.createdBy !== 'integration';
|
||||
|
||||
// Public-sharing meta (deduped react-query read); drives the header globe.
|
||||
const { isPublic, publicMeta } = usePublicDashboardMeta(id);
|
||||
const publicUrl = getAbsoluteUrl(publicMeta?.publicPath ?? '');
|
||||
|
||||
// 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(
|
||||
(source: 'menu' | 'header'): void => {
|
||||
async (source: 'menu' | 'header'): Promise<void> => {
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
lockSource.current = source;
|
||||
const next = !isDashboardLocked;
|
||||
setIsDashboardLocked(next);
|
||||
if (next) {
|
||||
setShowLockToggle(true);
|
||||
}
|
||||
toggleLock();
|
||||
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);
|
||||
}
|
||||
},
|
||||
[id, isDashboardLocked, toggleLock],
|
||||
[id, title, isDashboardLocked, queryClient, showErrorModal],
|
||||
);
|
||||
|
||||
const onNameSave = useCallback(
|
||||
@@ -167,10 +184,9 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
showLockToggle={showLockToggle}
|
||||
onToggleLock={
|
||||
canToggleLock
|
||||
? (): void => handleLockDashboardToggle('header')
|
||||
? (): void => void handleLockDashboardToggle('header')
|
||||
: undefined
|
||||
}
|
||||
lockDisabledTooltip={lockDisabledTooltip}
|
||||
isEditing={isEditing}
|
||||
draft={draft}
|
||||
onDraftChange={setDraft}
|
||||
@@ -183,8 +199,9 @@ function DashboardPageToolbar(props: DashboardPageToolbarProps): JSX.Element {
|
||||
dashboard={dashboard}
|
||||
handle={handle}
|
||||
isDashboardLocked={isDashboardLocked}
|
||||
isAuthor={isAuthor}
|
||||
onAddPanel={onAddPanel}
|
||||
onLockToggle={(): void => handleLockDashboardToggle('menu')}
|
||||
onLockToggle={(): void => void handleLockDashboardToggle('menu')}
|
||||
onOpenRename={startEdit}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -1,19 +1,11 @@
|
||||
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;
|
||||
/**
|
||||
* 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;
|
||||
disabled: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
isUnpublishing: boolean;
|
||||
@@ -24,8 +16,7 @@ interface PublicDashboardActionsProps {
|
||||
|
||||
function PublicDashboardActions({
|
||||
isPublic,
|
||||
checks,
|
||||
isLoading = false,
|
||||
disabled,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
isUnpublishing,
|
||||
@@ -33,53 +24,45 @@ function PublicDashboardActions({
|
||||
onUpdate,
|
||||
onUnpublish,
|
||||
}: PublicDashboardActionsProps): JSX.Element {
|
||||
const disabled = isLoading;
|
||||
|
||||
return (
|
||||
<div className={styles.footer}>
|
||||
{isPublic ? (
|
||||
<>
|
||||
<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="outlined"
|
||||
color="destructive"
|
||||
disabled={disabled}
|
||||
loading={isUnpublishing}
|
||||
prefix={<Trash size={15} />}
|
||||
testId="public-dashboard-unpublish"
|
||||
onClick={onUnpublish}
|
||||
>
|
||||
Unpublish Dashboard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
loading={isUpdating}
|
||||
prefix={<RefreshCw size={15} />}
|
||||
testId="public-dashboard-update"
|
||||
onClick={onUpdate}
|
||||
>
|
||||
Publish Dashboard
|
||||
Update Dashboard
|
||||
</Button>
|
||||
</AuthZTooltip>
|
||||
</>
|
||||
) : (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
disabled={disabled}
|
||||
loading={isPublishing}
|
||||
prefix={<Globe size={15} />}
|
||||
testId="public-dashboard-publish"
|
||||
onClick={onPublish}
|
||||
>
|
||||
Publish Dashboard
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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,7 +5,6 @@ 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';
|
||||
|
||||
@@ -18,8 +17,7 @@ function PublicDashboardSettings({
|
||||
}: PublicDashboardSettingsProps): JSX.Element {
|
||||
const {
|
||||
isPublic,
|
||||
canManage,
|
||||
publishChecks,
|
||||
isAdmin,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
@@ -36,7 +34,7 @@ function PublicDashboardSettings({
|
||||
onOpenUrl,
|
||||
} = usePublicDashboard(dashboard.id);
|
||||
|
||||
const controlsDisabled = isLoading || !canManage;
|
||||
const controlsDisabled = isLoading || !isAdmin;
|
||||
|
||||
return (
|
||||
<div className={styles.publishTab}>
|
||||
@@ -63,8 +61,7 @@ function PublicDashboardSettings({
|
||||
|
||||
<PublicDashboardActions
|
||||
isPublic={isPublic}
|
||||
checks={publishChecks}
|
||||
isLoading={isLoading}
|
||||
disabled={controlsDisabled}
|
||||
isPublishing={isPublishing}
|
||||
isUpdating={isUpdating}
|
||||
isUnpublishing={isUnpublishing}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
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 {
|
||||
@@ -13,8 +11,10 @@ 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,10 +22,7 @@ import { usePublicDashboardMeta } from './usePublicDashboardMeta';
|
||||
|
||||
export interface UsePublicDashboardReturn {
|
||||
isPublic: boolean;
|
||||
/** read + update on this dashboard — publishing is a dashboard update. */
|
||||
canManage: boolean;
|
||||
/** `[read, update]` — publishing changes the dashboard. */
|
||||
publishChecks: BrandedPermission[];
|
||||
isAdmin: boolean;
|
||||
isLoading: boolean;
|
||||
isPublishing: boolean;
|
||||
isUpdating: boolean;
|
||||
@@ -52,10 +49,8 @@ export function usePublicDashboard(
|
||||
): UsePublicDashboardReturn {
|
||||
const queryClient = useQueryClient();
|
||||
const { showErrorModal } = useErrorModal();
|
||||
// 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 { user } = useAppContext();
|
||||
const isAdmin = user?.role === USER_ROLES.ADMIN;
|
||||
const [, copyToClipboard] = useCopyToClipboard();
|
||||
|
||||
const [timeRangeEnabled, setTimeRangeEnabled] = useState<boolean>(true);
|
||||
@@ -201,8 +196,7 @@ export function usePublicDashboard(
|
||||
|
||||
return {
|
||||
isPublic,
|
||||
canManage,
|
||||
publishChecks,
|
||||
isAdmin,
|
||||
isLoading,
|
||||
isPublishing,
|
||||
isUpdating,
|
||||
|
||||
@@ -19,7 +19,6 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
|
||||
promql: 'PromQL',
|
||||
clickhouse: 'ClickHouse',
|
||||
variable: 'Variable',
|
||||
text: 'Markdown body',
|
||||
};
|
||||
|
||||
interface VariableImpactDialogProps {
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
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,16 +45,6 @@ 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[],
|
||||
@@ -109,38 +99,6 @@ 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,28 +1,26 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import AuthZButton from 'lib/authz/components/AuthZButton/AuthZButton';
|
||||
import type { BrandedPermission } from 'lib/authz/hooks/useAuthZ/types';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
const AddVariableButton = ({
|
||||
checks,
|
||||
disabledTooltip,
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
checks: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
isEditable: boolean;
|
||||
setIsEditing: (state: { type: 'new' }) => void;
|
||||
}): 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>
|
||||
);
|
||||
}): 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>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddVariableButton;
|
||||
|
||||
@@ -2,15 +2,12 @@ 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 = ({
|
||||
checks,
|
||||
disabledTooltip,
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
checks: BrandedPermission[];
|
||||
disabledTooltip?: string;
|
||||
isEditable: boolean;
|
||||
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
@@ -23,11 +20,7 @@ const NoVariablesCard = ({
|
||||
Create a variable to parameterize your panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<AddVariableButton
|
||||
checks={checks}
|
||||
disabledTooltip={disabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -20,15 +20,13 @@ 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, editChecks, editDisabledTooltip } =
|
||||
useDashboardEditContext();
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
// 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(
|
||||
@@ -129,11 +127,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.container, settingsStyles.settingsCard)}>
|
||||
{variables.length === 0 ? (
|
||||
<NoVariablesCard
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
) : (
|
||||
<>
|
||||
<VariablesList
|
||||
@@ -149,11 +143,7 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
appliedToAllNames={appliedToAllNames}
|
||||
/>
|
||||
<div className={styles.footer}>
|
||||
<AddVariableButton
|
||||
checks={editChecks}
|
||||
disabledTooltip={editDisabledTooltip}
|
||||
setIsEditing={setIsEditing}
|
||||
/>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
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,18 +107,6 @@ 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,7 +12,6 @@ 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. */
|
||||
@@ -20,8 +19,7 @@ export type VariableUsageKind =
|
||||
| 'builder'
|
||||
| 'promql'
|
||||
| 'clickhouse'
|
||||
| 'variable'
|
||||
| 'text';
|
||||
| 'variable';
|
||||
|
||||
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
|
||||
|
||||
@@ -83,7 +81,7 @@ function computeResultingText(
|
||||
return rewriteVariableReferences(text, variableName, newName);
|
||||
}
|
||||
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
|
||||
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
|
||||
// ClickHouse and variable queries are left for the user to edit.
|
||||
return kind === 'builder'
|
||||
? removeVariableFromExpression(text, variableName)
|
||||
: text;
|
||||
@@ -108,31 +106,6 @@ 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;
|
||||
|
||||
@@ -1,75 +0,0 @@
|
||||
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,7 +13,9 @@ 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';
|
||||
@@ -35,6 +37,7 @@ 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.
|
||||
@@ -55,28 +58,27 @@ 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],
|
||||
[enablePublicDashboard, dashboard, user?.role],
|
||||
);
|
||||
|
||||
return (
|
||||
<TabsRoot defaultValue={settingsRequest?.tab ?? TabKeys.OVERVIEW}>
|
||||
<TabsList variant="primary">
|
||||
{items.map((item) => (
|
||||
<TabsTrigger value={item.key} key={item.key} disabled={item.disabled}>
|
||||
{prefixIcons[item.key as TabKeys]}
|
||||
{item.label}
|
||||
{Object.values(TabKeys).map((key) => (
|
||||
<TabsTrigger value={key} key={key}>
|
||||
{prefixIcons[key]}
|
||||
{key}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
@@ -5,12 +5,10 @@ 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 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import ConfigActions from './ConfigActions/ConfigActions';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
@@ -67,16 +65,9 @@ function ConfigPane({
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const panelKind = spec.plugin.kind;
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
// 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 sections = definition.sections;
|
||||
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[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.
|
||||
@@ -113,23 +104,6 @@ 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,18 +5,8 @@ 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;
|
||||
@@ -44,7 +34,6 @@ 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_OPTIONS.map(({ kind, displayName, icon: Icon }) => {
|
||||
PANEL_TYPES.map(({ panelKind, label, Icon }) => {
|
||||
// One reason drives both the disabled flag and the tooltip, so they can't disagree.
|
||||
const disabledReason = getPanelTypeDisabledReason({
|
||||
kind,
|
||||
kind: panelKind,
|
||||
queryType,
|
||||
signal,
|
||||
label: displayName,
|
||||
label,
|
||||
});
|
||||
return {
|
||||
value: kind,
|
||||
label: displayName,
|
||||
value: panelKind,
|
||||
label,
|
||||
icon: <Icon size={14} />,
|
||||
disabled: !!disabledReason,
|
||||
tooltip: disabledReason,
|
||||
|
||||
@@ -2,7 +2,6 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
isStaticPanelKind,
|
||||
isQueryTypeSupportedByPanelKind,
|
||||
isSignalSupported,
|
||||
} from '../../../Panels/capabilities';
|
||||
@@ -38,12 +37,6 @@ 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,8 +16,6 @@ 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.
|
||||
@@ -52,7 +50,6 @@ function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
bare,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
@@ -113,28 +110,6 @@ 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}
|
||||
@@ -143,7 +118,21 @@ function SectionSlot({
|
||||
onOpenChange={setOpen}
|
||||
headerSlot={headerSlot}
|
||||
>
|
||||
{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}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,14 +23,6 @@ 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' },
|
||||
@@ -101,24 +93,6 @@ 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.
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,119 +0,0 @@
|
||||
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;
|
||||
@@ -1,67 +0,0 @@
|
||||
.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);
|
||||
}
|
||||
@@ -1,91 +0,0 @@
|
||||
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;
|
||||
@@ -1,118 +0,0 @@
|
||||
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']));
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
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 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { LegendSeries } from '../../../utils/legendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { LegendSeries } from '../../../../utils/legendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { LegendSeries } from '../../../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 'pages/DashboardPage/DashboardContainer/Panels/utils/legendSeries';
|
||||
import type { LegendSeries } from '../utils/legendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
|
||||
@@ -23,8 +23,6 @@ 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';
|
||||
|
||||
@@ -119,23 +117,6 @@ 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.
|
||||
|
||||
@@ -1,24 +0,0 @@
|
||||
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;
|
||||
@@ -1,29 +0,0 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -1,11 +0,0 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
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;
|
||||
@@ -1,144 +0,0 @@
|
||||
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,14 +8,9 @@ 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.
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user