Compare commits

..

149 Commits

Author SHA1 Message Date
Naman Verma
9f1b1476f4 Merge branch 'nv/heatmap' into nv/heatmap-dashboard-panel 2026-09-11 13:18:49 +05:30
Naman Verma
0bea671a2d Merge branch 'main' into nv/heatmap 2026-09-11 13:18:39 +05:30
Naman Verma
0b1695c9c2 chore: regenerate openapi spec 2026-09-11 13:16:29 +05:30
Naman Verma
b4f42cf388 feat(dashboards): add the heatmap panel 2026-09-11 13:15:25 +05:30
Naman Verma
65a8a58600 chore: regenerate api specs 2026-09-11 13:14:44 +05:30
Naman Verma
a3c90ab132 chore: move the heatmap dashboard panel to its own branch 2026-09-11 13:14:41 +05:30
Naman Verma
da4c5578d3 test: fix param in unit test 2026-09-11 13:09:23 +05:30
Naman Verma
bf01867c3c test: properly test for error messages 2026-09-11 12:58:26 +05:30
Naman Verma
41187a5fca chore: track full bucket details to be able to report the clashes in err msg 2026-09-11 12:38:14 +05:30
Naman Verma
d04dc6f7db test: add coarser scale in test 2026-09-11 12:15:11 +05:30
Naman Verma
71badcf65c chore: better comment 2026-09-11 12:03:26 +05:30
Naman Verma
68fe15d5fc chore: better comment 2026-09-11 12:02:43 +05:30
Naman Verma
f8aded8c8e test: use histogram sounding metric name for histogram unit tests 2026-09-11 12:00:45 +05:30
Naman Verma
f6a6793e96 test: remove logscale param from linear bucket in test 2026-09-11 11:57:45 +05:30
Naman Verma
452046d70c chore: remove POC 2026-09-11 11:45:43 +05:30
Naman Verma
2e9c9067f3 Merge branch 'main' into nv/heatmap 2026-09-11 11:45:01 +05:30
Naman Verma
2ddd45cb8c test: fix error msg assertion 2026-09-10 16:56:32 +05:30
Naman Verma
8809d8a7ac test: fix error msg assertion 2026-09-10 16:05:33 +05:30
Naman Verma
21edb05f2b fix: move bucket options to each query for dashboards 2026-09-10 16:04:17 +05:30
Naman Verma
f659205866 fix: make consume.go take in both upper and lower bounds 2026-09-10 14:38:15 +05:30
Naman Verma
215f6f8e0f fix: distinguish between no enabled and >1 enabled queries 2026-09-09 22:07:31 +05:30
Naman Verma
802f7e4a3d fix: more concise err message in promql no le case 2026-09-09 22:03:39 +05:30
Naman Verma
81f1105fbc fix: throw error if no le bucket if found in promql returned data 2026-09-09 21:56:43 +05:30
Naman Verma
575aa57426 Merge branch 'main' into nv/heatmap 2026-09-09 21:19:41 +05:30
Naman Verma
f75d3d8724 test: move rejection tests 2026-09-09 21:18:58 +05:30
Naman Verma
43538b92db fix: no value to be sent if values array is present 2026-09-09 19:57:51 +05:30
Naman Verma
ef65c2c08c chore: remove unneeded nil check 2026-09-09 19:46:47 +05:30
Naman Verma
8e41d3d30d Merge branch 'main' into nv/heatmap 2026-09-09 19:34:30 +05:30
Naman Verma
b249ab8275 fix: dont build heatmap bucket options for missing metrics 2026-09-09 12:25:40 +05:30
Naman Verma
61bca035a3 chore: use increase as time aggregation for regular percentiles as well 2026-09-09 11:55:40 +05:30
Naman Verma
9983b3344b Merge branch 'nv/heatmap' of https://github.com/SigNoz/signoz into nv/heatmap 2026-09-09 10:13:05 +05:30
Naman Verma
1313a289b6 Merge branch 'main' into nv/heatmap 2026-09-07 23:01:08 +05:30
Naman Verma
82a789c19e test: fix pylint in integration test 2026-09-07 21:01:03 +05:30
Naman Verma
ec09873573 chore: push temporary poc for testing 2026-09-07 21:01:03 +05:30
Naman Verma
00cc773792 test: add integration tests 2026-09-07 21:01:03 +05:30
Naman Verma
cf5dd79216 fix: return unsupported for logs and traces 2026-09-07 21:01:03 +05:30
Naman Verma
6546a0abc6 test: delete heatmap UTs (to be covered in integration tests) 2026-09-07 21:01:03 +05:30
Naman Verma
3874f3a6ec test: delete heatmap validation UTs (to be covered in integration tests) 2026-09-07 21:01:03 +05:30
Naman Verma
86db0fd26b chore: var renames 2026-09-07 21:01:03 +05:30
Naman Verma
f382a958db chore: code refactoring of bucketFormulaOutputAsHeatmap 2026-09-07 21:01:03 +05:30
Naman Verma
7c3481b230 chore: comments, code structure changes and renamings in resolveHeatmapBucketAxis 2026-09-07 21:01:03 +05:30
Naman Verma
24f3116527 chore: map var renames 2026-09-07 21:01:03 +05:30
Naman Verma
17f0f49398 chore: some code movement and clarity around ReindexValuesToNewUpperBounds 2026-09-07 21:01:03 +05:30
Naman Verma
f681bacdcb chore: method rename 2026-09-07 21:01:03 +05:30
Naman Verma
ded4ed3d76 chore: add comment for logs and traces supporting only 1 aggregation 2026-09-07 21:01:03 +05:30
Naman Verma
1f84f9c166 chore: remove comment 2026-09-07 21:01:03 +05:30
Naman Verma
87127b580c chore: upperbound instead of boundary 2026-09-07 21:01:03 +05:30
Naman Verma
fc4bd13fce test: remove unit tests that can be covered in integration tests 2026-09-07 21:01:03 +05:30
Naman Verma
075a4b164d chore: revert UT change 2026-09-07 21:01:03 +05:30
Naman Verma
2170189d14 chore: shorten comments 2026-09-07 21:01:03 +05:30
Naman Verma
b7720b655e chore: shorten comments 2026-09-07 21:01:03 +05:30
Naman Verma
f34973b897 chore: use the term upper bound instead of boundary 2026-09-07 21:01:03 +05:30
Naman Verma
d9d041a8fa fix: use separate typedef for cumulative column 2026-09-07 21:01:03 +05:30
Naman Verma
a0e66e3592 fix: return empty instead of err if promql resp does not have le 2026-09-07 21:01:03 +05:30
Naman Verma
a0dff26fbc test: remove unit tests that can be covered in integration tests 2026-09-07 21:01:03 +05:30
Naman Verma
db0b1b2b2a chore: remove capacity vars for an easier read 2026-09-07 21:01:03 +05:30
Naman Verma
fd8ed18592 chore: better var name 2026-09-07 21:01:03 +05:30
Naman Verma
392b5826fd chore: better comment 2026-09-07 21:01:03 +05:30
Naman Verma
4b4023d706 chore: move HeatmapBucketColumn 2026-09-07 21:01:03 +05:30
Naman Verma
f8d803d787 fix: allow other panel types in heatmap 2026-09-07 21:01:03 +05:30
Naman Verma
a33320c665 test: add unit tests for cumulative metrics 2026-09-07 21:01:03 +05:30
Naman Verma
695a481989 fix: use heatmapBucketing.LogScale correctly 2026-09-07 21:01:03 +05:30
Naman Verma
3fc763b95b chore: break down method for easier reading 2026-09-07 21:01:03 +05:30
Naman Verma
027b65574d chore: shorten comments 2026-09-07 21:01:03 +05:30
Naman Verma
43ec98b309 chore: rename method name 2026-09-07 21:01:03 +05:30
Naman Verma
a196a436be chore: code movement 2026-09-07 21:01:03 +05:30
Naman Verma
3f456b6602 chore: add separate file for heatmap accumulator 2026-09-07 21:01:03 +05:30
Naman Verma
f777e068c3 chore: reduce comment size 2026-09-07 21:01:03 +05:30
Naman Verma
5a690695e6 chore: split toResult method into two 2026-09-07 21:01:03 +05:30
Naman Verma
bf362d15db chore: shorten comment 2026-09-07 21:01:03 +05:30
Naman Verma
7e0f70ffa7 chore: remove unneeded comment 2026-09-07 21:01:03 +05:30
Naman Verma
590046adf3 fix: add heatmap to hasData check 2026-09-07 21:01:03 +05:30
Naman Verma
106cb9f6c2 chore: shorten comment 2026-09-07 21:01:03 +05:30
Naman Verma
f95699b1f7 chore: method name change 2026-09-07 21:01:03 +05:30
Naman Verma
1e75b66379 chore: method name change 2026-09-07 21:01:03 +05:30
Naman Verma
b80013d075 fix: return 501 for exp histogram instead of 400 2026-09-07 21:01:03 +05:30
Naman Verma
4a6e25708d chore: remove unneeded comments 2026-09-07 21:01:03 +05:30
Naman Verma
3b8f9a7830 chore: move MaxNumBuckets const to where it is actually used 2026-09-07 21:01:03 +05:30
Naman Verma
68bff5b053 chore: remove unneeded comments 2026-09-07 21:01:03 +05:30
Naman Verma
732c8ae18a chore: remove unneeded comments 2026-09-07 21:01:03 +05:30
Naman Verma
6911630c2a fix: dont allow bucket options in non heatmap requests 2026-09-07 21:01:03 +05:30
Naman Verma
2d78d8add9 chore: move valid bucket kinds to additional part of err 2026-09-07 21:01:03 +05:30
Naman Verma
8e47c7d337 chore: move consts to where they are actually used 2026-09-07 21:01:03 +05:30
Naman Verma
991e734c33 chore: minor code movement 2026-09-07 21:01:03 +05:30
Naman Verma
40c881b8f5 chore: remove unneeded comment 2026-09-07 21:01:03 +05:30
Naman Verma
a2c8dd5275 chore: remove unneeded comment 2026-09-07 21:01:03 +05:30
Naman Verma
32ae7a26d5 chore: shorten comment 2026-09-07 21:01:03 +05:30
Naman Verma
4067b04fa2 fix: update dashboard schema to latest spec 2026-09-07 21:01:03 +05:30
Naman Verma
70409f5ee3 fix: make ResolveHeatmapBucketing a method on MetricAggregation 2026-09-07 21:01:03 +05:30
Naman Verma
5d9953bb7e feat: add heatmap support in query and dashboards 2026-09-07 21:01:03 +05:30
Naman Verma
edfff0ee72 test: fix pylint in integration test 2026-09-07 16:42:23 +05:30
Naman Verma
44556060a3 chore: push temporary poc for testing 2026-09-07 15:54:09 +05:30
Naman Verma
545e0342d2 test: add integration tests 2026-09-07 14:57:47 +05:30
Naman Verma
2f559f34d3 fix: return unsupported for logs and traces 2026-09-06 12:14:39 +05:30
Naman Verma
e4d90b91b7 test: delete heatmap UTs (to be covered in integration tests) 2026-09-06 05:07:18 +05:30
Naman Verma
f33616a598 test: delete heatmap validation UTs (to be covered in integration tests) 2026-09-06 04:51:05 +05:30
Naman Verma
2ac94fc2fc chore: var renames 2026-09-06 04:46:39 +05:30
Naman Verma
7292a6946b chore: code refactoring of bucketFormulaOutputAsHeatmap 2026-09-06 04:32:54 +05:30
Naman Verma
dff49a4135 chore: comments, code structure changes and renamings in resolveHeatmapBucketAxis 2026-09-06 04:09:09 +05:30
Naman Verma
690c207519 chore: map var renames 2026-09-06 03:19:59 +05:30
Naman Verma
d9b6d1f425 chore: some code movement and clarity around ReindexValuesToNewUpperBounds 2026-09-06 03:18:01 +05:30
Naman Verma
1eb974d91a chore: method rename 2026-09-06 02:55:54 +05:30
Naman Verma
1141f6bf14 chore: add comment for logs and traces supporting only 1 aggregation 2026-09-06 02:50:12 +05:30
Naman Verma
a3fb926726 chore: remove comment 2026-09-06 02:25:27 +05:30
Naman Verma
fe1e86f385 chore: upperbound instead of boundary 2026-09-06 02:12:39 +05:30
Naman Verma
e30d9a1a14 test: remove unit tests that can be covered in integration tests 2026-09-06 01:51:03 +05:30
Naman Verma
c9a345713c chore: revert UT change 2026-09-06 01:49:52 +05:30
Naman Verma
22f8eba44a chore: shorten comments 2026-09-06 01:41:53 +05:30
Naman Verma
e8ad9044d5 chore: shorten comments 2026-09-06 01:40:08 +05:30
Naman Verma
576cd5052a chore: use the term upper bound instead of boundary 2026-09-06 01:37:33 +05:30
Naman Verma
4214ccf93d fix: use separate typedef for cumulative column 2026-09-06 01:34:44 +05:30
Naman Verma
63f5c17238 fix: return empty instead of err if promql resp does not have le 2026-09-06 01:25:53 +05:30
Naman Verma
fd6abd67dd test: remove unit tests that can be covered in integration tests 2026-09-05 16:25:23 +05:30
Naman Verma
a78c35d61c chore: remove capacity vars for an easier read 2026-09-05 16:19:46 +05:30
Naman Verma
b2c4916a86 chore: better var name 2026-09-05 16:08:02 +05:30
Naman Verma
feff1a0179 chore: better comment 2026-09-05 15:53:47 +05:30
Naman Verma
eafac1be69 chore: move HeatmapBucketColumn 2026-09-05 15:35:14 +05:30
Naman Verma
510d6985d0 fix: allow other panel types in heatmap 2026-09-05 15:23:15 +05:30
Naman Verma
758aacb065 test: add unit tests for cumulative metrics 2026-09-05 15:13:11 +05:30
Naman Verma
5599c352d0 fix: use heatmapBucketing.LogScale correctly 2026-09-05 14:29:47 +05:30
Naman Verma
96d0a2340c chore: break down method for easier reading 2026-09-05 14:19:11 +05:30
Naman Verma
c6b415ad04 chore: shorten comments 2026-09-05 14:14:39 +05:30
Naman Verma
9b922f297f chore: rename method name 2026-09-05 13:53:08 +05:30
Naman Verma
e81a4c4046 chore: code movement 2026-09-05 13:44:11 +05:30
Naman Verma
9ba13848ea chore: add separate file for heatmap accumulator 2026-09-05 13:16:18 +05:30
Naman Verma
2578f25dd4 chore: reduce comment size 2026-09-05 12:46:35 +05:30
Naman Verma
82352973bb chore: split toResult method into two 2026-09-05 12:36:47 +05:30
Naman Verma
3eb55692f1 chore: shorten comment 2026-09-05 04:04:45 +05:30
Naman Verma
61ceb6d300 chore: remove unneeded comment 2026-09-05 04:03:43 +05:30
Naman Verma
e33fc20579 fix: add heatmap to hasData check 2026-09-05 03:50:44 +05:30
Naman Verma
b130a61049 chore: shorten comment 2026-09-05 03:25:14 +05:30
Naman Verma
132e9bb740 chore: method name change 2026-09-05 03:15:13 +05:30
Naman Verma
9d577731e1 chore: method name change 2026-09-05 03:07:14 +05:30
Naman Verma
4d54568fe1 fix: return 501 for exp histogram instead of 400 2026-09-05 03:04:41 +05:30
Naman Verma
6f82624523 chore: remove unneeded comments 2026-09-05 02:53:15 +05:30
Naman Verma
da0bc4f438 chore: move MaxNumBuckets const to where it is actually used 2026-09-05 02:51:29 +05:30
Naman Verma
2dcb764632 chore: remove unneeded comments 2026-09-05 02:50:27 +05:30
Naman Verma
1bc25e16b8 chore: remove unneeded comments 2026-09-05 02:50:03 +05:30
Naman Verma
eebc6b400d fix: dont allow bucket options in non heatmap requests 2026-09-05 02:49:41 +05:30
Naman Verma
30c4508113 chore: move valid bucket kinds to additional part of err 2026-09-05 02:38:00 +05:30
Naman Verma
c4fe619920 chore: move consts to where they are actually used 2026-09-05 02:33:59 +05:30
Naman Verma
d597adddda chore: minor code movement 2026-09-05 02:31:10 +05:30
Naman Verma
9a05f33e7a chore: remove unneeded comment 2026-09-05 02:25:48 +05:30
Naman Verma
3812937c6f chore: remove unneeded comment 2026-09-05 02:25:29 +05:30
Naman Verma
34aa719cba chore: shorten comment 2026-09-05 02:24:15 +05:30
Naman Verma
cdfce4c4ab fix: update dashboard schema to latest spec 2026-09-05 02:12:34 +05:30
Naman Verma
d8ef672f83 Merge branch 'main' into nv/heatmap 2026-09-05 02:00:57 +05:30
Naman Verma
00efffc127 fix: make ResolveHeatmapBucketing a method on MetricAggregation 2026-09-03 13:50:09 +05:30
Naman Verma
dec922a83f feat: add heatmap support in query and dashboards 2026-09-03 12:07:27 +05:30
127 changed files with 1459 additions and 8124 deletions

View File

@@ -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,6 +3474,79 @@ components:
- tags
- spec
type: object
DashboardtypesHeatmapAxes:
properties:
yScale:
$ref: '#/components/schemas/DashboardtypesHeatmapYScale'
type: object
DashboardtypesHeatmapChartAppearance:
properties:
colors:
$ref: '#/components/schemas/DashboardtypesHeatmapColors'
type: object
DashboardtypesHeatmapColorMode:
enum:
- palette
- opacity
type: string
DashboardtypesHeatmapColorScale:
enum:
- log
- sqrt
- linear
type: string
DashboardtypesHeatmapColors:
properties:
fill:
type: string
maxCount:
nullable: true
type: number
minCount:
nullable: true
type: number
mode:
$ref: '#/components/schemas/DashboardtypesHeatmapColorMode'
palette:
$ref: '#/components/schemas/DashboardtypesHeatmapPalette'
scale:
$ref: '#/components/schemas/DashboardtypesHeatmapColorScale'
steps:
type: integer
type: object
DashboardtypesHeatmapPalette:
enum:
- ice
- moss
- rust
- graphite
- ember
- lagoon
- orchid
- verdant
- lava
- beacon
type: string
DashboardtypesHeatmapPanelSpec:
properties:
axes:
$ref: '#/components/schemas/DashboardtypesHeatmapAxes'
chartAppearance:
$ref: '#/components/schemas/DashboardtypesHeatmapChartAppearance'
formatting:
$ref: '#/components/schemas/DashboardtypesPanelFormatting'
legend:
$ref: '#/components/schemas/DashboardtypesLegend'
visualization:
$ref: '#/components/schemas/DashboardtypesBasicVisualization'
type: object
DashboardtypesHeatmapYScale:
enum:
- auto
- linear
- log
- symlog
type: string
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3888,6 +3899,7 @@ components:
discriminator:
mapping:
signoz/BarChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec'
signoz/HeatmapPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
signoz/HistogramPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
signoz/ListPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
@@ -3903,6 +3915,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3913,6 +3926,7 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/HeatmapPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3926,6 +3940,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHeatmapPanelSpec:
properties:
kind:
enum:
- signoz/HeatmapPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesHeatmapPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec:
properties:
kind:
@@ -8470,8 +8496,6 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8515,8 +8539,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5Label'
nullable: true
type: array
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8622,7 +8644,6 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:
@@ -19846,86 +19867,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
@@ -19988,239 +19929,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

View File

@@ -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/'],

View File

@@ -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));
};

View File

@@ -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
@@ -9713,10 +9658,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9765,10 +9706,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
* @type array,null
*/
labels: Querybuildertypesv5LabelDTO[] | null;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9864,7 +9801,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',
@@ -13274,44 +13210,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;
/**
@@ -13320,31 +13218,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;
/**

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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 (16 #)' },
{ 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: '![alt](url)', label: 'Image' },
{ syntax: '`code`', label: 'Inline code' },
{ syntax: '```lang', label: 'Code block' },
{ syntax: '> quote', label: 'Blockquote' },
{ syntax: '| a | b |', label: 'Table' },
];

View File

@@ -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 },
});
}

View File

@@ -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 },
];

View File

@@ -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];
}

View File

@@ -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;
}

View File

@@ -5,7 +5,6 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -68,7 +67,7 @@ function ConfigPane({
const definition = getPanelDefinition(panelKind);
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.

View File

@@ -44,7 +44,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']

View File

@@ -8,7 +8,6 @@ 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'],
})),

View File

@@ -26,16 +26,3 @@
background: var(--l2-border);
}
}
// The static editor's preview: the panel card the grid shows, minus actions.
.staticPreviewSurface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}

View File

@@ -1,116 +0,0 @@
import type { ReactNode } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import layoutStorage from '../layoutStorage';
import styles from '../PanelEditor.module.scss';
/** A resizable pane's bounds, in the percentage strings `ResizablePanel` takes. */
interface PaneSize {
minSize: string;
maxSize: string;
defaultSize: string;
}
/** How the left column divides between the preview and the editor pane. */
export interface PaneSplit {
preview: PaneSize;
editor: PaneSize;
}
/**
* Vertical split per authoring mode. The query builder is a compact form, so the
* preview keeps the room; a static kind's editor pane is the surface being worked
* in, so it gets more and can grow further.
*/
export const PANE_SPLIT = {
query: {
preview: { minSize: '55%', maxSize: '65%', defaultSize: '60%' },
editor: { minSize: '35%', maxSize: '45%', defaultSize: '40%' },
},
static: {
preview: { minSize: '40%', maxSize: '65%', defaultSize: '55%' },
editor: { minSize: '35%', maxSize: '60%', defaultSize: '45%' },
},
} as const satisfies Record<string, PaneSplit>;
interface PanelEditorLayoutProps {
/** Save/close chrome — its affordances differ per authoring mode. */
header: ReactNode;
/** Upper-left: what the panel will look like once saved. */
preview: ReactNode;
/** Lower-left: the kind's `EditorPane` — the query builder, or a static kind's own. */
editor: ReactNode;
/** Right column: the kind's config sections. */
config: ReactNode;
split: PaneSplit;
}
/**
* The panel editor's frame: header, the resizable three-pane arrangement, and the
* persistence of what the user drags. Owned once so both authoring modes cannot
* drift apart on pane bounds or share a layout id by accident — they differ only in
* `split` and in what fills the slots.
*/
function PanelEditorLayout({
header,
preview,
editor,
config,
split,
}: PanelEditorLayoutProps): JSX.Element {
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
return (
<div className={styles.page} data-testid="panel-editor-v2">
{header}
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel {...split.preview}>{preview}</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel {...split.editor}>{editor}</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
{config}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default PanelEditorLayout;

View File

@@ -21,15 +21,20 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { EQueryType } from 'types/common/dashboard';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
import {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types + field visibility. */
panelDefinition: RenderableQueryPanelDefinition;
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
panelKind: PanelKind;
/** The panel's current signal; selects per-signal query-builder field rules. */
signal: TelemetrytypesSignalDTO;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
@@ -50,7 +55,7 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelDefinition,
panelKind,
signal,
isLoadingQueries,
onStageRunQuery,
@@ -60,10 +65,10 @@ function PanelEditorQueryBuilder({
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
const isListViewPanel = panelKind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -94,12 +99,12 @@ function PanelEditorQueryBuilder({
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
[panelDefinition.queryBuilderFields, signal],
() => getHiddenQueryBuilderFields(panelKind, signal),
[panelKind, signal],
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -146,7 +151,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
return (
<div

View File

@@ -1,28 +0,0 @@
import type { QueryEditorPaneProps } from '../../Panels/types/panelDefinition';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
/**
* The default query-kind editor pane: the query-builder tabs with no extras. A
* kind that needs more (e.g. List's columns editor) declares its own wrapper.
*/
function QueryBuilderEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
/>
);
}
export default QueryBuilderEditorPane;

View File

@@ -4,9 +4,6 @@ import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
@@ -51,7 +48,7 @@ function renderBuilder(
): void {
render(
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
panelKind={panelKind as never}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}

View File

@@ -6,7 +6,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import type {
@@ -20,8 +20,8 @@ import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
panelDefinition: RenderableQueryPanelDefinition;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
@@ -107,7 +107,7 @@ function PreviewPane({
hideActions
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}

View File

@@ -1,332 +0,0 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface QueryEditorBodyProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
/** Draft state, owned by the shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** The draft kind's definition, narrowed by the shell's fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind editor body: a resizable split with the live preview + the
* kind's editor pane on the left and the config pane on the right. Draft and
* kind-switch state live in the shell; this body owns the query session and the
* save round-trip.
*/
function QueryEditorBody({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
draftApi,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const panelKind = draft.spec.plugin.kind;
// The kind's own lower pane (query builder, plus e.g. List's columns footer).
const { EditorPane } = panelDefinition;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<PanelEditorLayout
split={PANE_SPLIT.query}
header={
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
}
preview={
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
}
editor={
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<EditorPane
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}
onChangeSpec={setSpec}
/>
</ConfigProvider>
}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
}
/>
);
}
export default QueryEditorBody;

View File

@@ -1,140 +0,0 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { PanelMode } from 'lib/visualization/panels/types';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import type { PanelEditorContainerProps } from './index';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* Editor body for a kind that renders from its own plugin spec: the kind's
* editor pane under a live preview of the draft, the config pane on the right.
* No query session, no builder seeding, no staged-run — the preview re-renders
* from the draft spec on every edit.
*/
function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const { draft, spec, setSpec, isSpecDirty } = draftApi;
const { EditorPane, Renderer } = panelDefinition;
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// `queries: []` is the only shape the API accepts for a static kind.
const savedPanelId = await save({ ...draft.spec, queries: [] });
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
return (
<PanelEditorLayout
split={PANE_SPLIT.static}
header={
<Header
isDirty={isSpecDirty}
isSaving={isSaving}
showSwitchToView={false}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onClose={onCloseEditor}
/>
}
preview={
<div className={styles.staticPreviewSurface}>
<PanelHeader
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
Renderer={Renderer}
panel={draft}
panelId={panelId}
panelMode={PanelMode.DASHBOARD_EDIT}
/>
</div>
}
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}
/>
}
/>
);
}
export default StaticEditorBody;

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
@@ -251,12 +250,7 @@ describe('PanelEditorContainer composition', () => {
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({
panelDefinition: expect.objectContaining({
kind: 'signoz/TimeSeriesPanel',
mode: 'query',
}),
}),
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
@@ -280,7 +274,7 @@ describe('PanelEditorContainer composition', () => {
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(

View File

@@ -18,8 +18,6 @@ import appStore from 'store';
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
@@ -85,7 +83,7 @@ function EditorRoute(): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}

View File

@@ -19,10 +19,6 @@ jest.mock('lib/query/panelQuery', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
.isStaticPanelKind,
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),

View File

@@ -4,19 +4,24 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import type { PANEL_TYPES } from 'constants/queryBuilder';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelEditorDraftApi } from '../types';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
@@ -33,12 +38,6 @@ interface UsePanelEditSessionArgs {
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
/**
* Externally-owned draft. The editor shell hoists it above its mode fork so a
* kind switch across modes survives the branch swap; hosts without a fork (the
* View modal, until it forks) omit it and the session owns the draft.
*/
draftApi?: PanelEditorDraftApi;
}
export interface UsePanelEditSessionReturn {
@@ -51,7 +50,7 @@ export interface UsePanelEditSessionReturn {
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderableQueryPanelDefinition;
panelDefinition: RenderablePanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
@@ -63,6 +62,8 @@ export interface UsePanelEditSessionReturn {
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
@@ -79,17 +80,14 @@ export function usePanelEditSession({
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
draftApi,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
// Called unconditionally (hooks rules); unused when a hoisted draft is passed in.
const internalDraftApi = usePanelEditorDraft(panel, savedPanel);
const { draft, spec, setSpec, isSpecDirty, reset } =
draftApi ?? internalDraftApi;
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
const panelKind = draft.spec.plugin.kind;
// Hosts fork on `definition.mode` before mounting this session (the editor and
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelDefinition = getPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const defaultSignal = panelDefinition.supportedSignals[0];
@@ -111,6 +109,12 @@ export function usePanelEditSession({
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
@@ -124,5 +128,6 @@ export function usePanelEditSession({
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -18,7 +18,7 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import { resolveQueryType } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -128,25 +128,11 @@ export function usePanelTypeSwitch({
queries,
});
// Revisit → restore the stash verbatim (the reversibility path). A static
// kind's stash carries `queries: []` and its builder query is untouched —
// there is no builder to re-seed for it.
// Revisit → restore the stash verbatim (the reversibility path).
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
if (!isStaticPanelKind(newKind)) {
redirectWithQueryBuilderData(cached.builderQuery);
}
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isStaticPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
redirectWithQueryBuilderData(cached.builderQuery);
return;
}

View File

@@ -1,13 +1,57 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
export interface PanelEditorContainerProps {
import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -27,42 +71,297 @@ export interface PanelEditorContainerProps {
}
/**
* V2 panel editor page shell. Owns exactly the state that must survive a switch
* between authoring modes — the draft and the kind-switch cache — and forks on
* the draft kind's `mode`: query kinds get the session-backed body, static kinds
* an editor pane over a live preview with no query machinery at all.
* V2 panel editor page body: a resizable split with the live preview + query
* builder on the left and the config pane on the right. Owns the draft state and
* the save round-trip.
*/
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const { panel, savedPanel } = props;
const draftApi = usePanelEditorDraft(panel, savedPanel);
function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
panelDefinition,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
setSpec: draftApi.setSpec,
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
if (panelDefinition.mode === 'static') {
return (
<StaticEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
const panelKind = draft.spec.plugin.kind;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
}
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<QueryEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
{panelDefinition && (
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
)}
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -11,8 +11,6 @@ import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
@@ -109,7 +107,7 @@ const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
@@ -151,8 +149,7 @@ describe('panel capabilities guard', () => {
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const queryCapabilities =
requireQueryPanelDefinition(unknownKind).queryCapabilities;
const { queryCapabilities } = getPanelDefinition(unknownKind);
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);

View File

@@ -2,11 +2,7 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import {
mergeQueryBuilderFieldRule,
type FilterConfigsPartial,
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { FilterConfigsPartial } from './types/panelCapabilities';
import type { PanelKind } from './types/panelKind';
/**
@@ -17,46 +13,11 @@ import type { PanelKind } from './types/panelKind';
* these functions then cover it automatically. Pure and side-effect free.
*/
/** Renders from its own plugin spec — no query surface at all. */
export function isStaticPanelKind(kind: PanelKind): boolean {
return getPanelDefinition(kind).mode === 'static';
}
/**
* The kind's definition narrowed to the query arm, or null for a static kind.
* The null is what hosts fork on; the accessors below fold it into "supports
* nothing" for the guard questions.
*/
export function getQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition | null {
const definition = getPanelDefinition(kind);
return definition.mode === 'query' ? definition : null;
}
/**
* The query arm, asserted present. For call sites that a host mounts only after
* narrowing `mode === 'query'` but that read the definition by kind rather than
* receiving it as a prop — the throw makes that invariant executable instead of
* silently null-tolerant.
*/
export function requireQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition {
const definition = getQueryPanelDefinition(kind);
if (!definition) {
throw new Error(
`query machinery mounted for query-less panel kind ${kind} — the host must fork on definition.mode before this point`,
);
}
return definition;
}
/** Signals a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
): TelemetrytypesSignalDTO[] {
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
return getPanelDefinition(kind).supportedSignals;
}
export function isSignalSupported(
@@ -68,7 +29,7 @@ export function isSignalSupported(
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
return getPanelDefinition(kind).supportedQueryTypes;
}
export function isQueryTypeSupportedByPanelKind(
@@ -92,10 +53,6 @@ export function isPanelCombinationValid({
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
// A query-less kind ignores the query entirely, so it pairs with anything.
if (isStaticPanelKind(kind)) {
return true;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return false;
}
@@ -116,11 +73,7 @@ export function resolveQueryType(
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
return supported.includes(preferred) ? preferred : supported[0];
}
/**
@@ -132,6 +85,7 @@ export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
return mergeQueryBuilderFieldRule(rule, signal);
const rule = getPanelDefinition(kind).queryBuilderFields;
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}

View File

@@ -1,7 +1,6 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,6 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,38 +0,0 @@
import type { QueryEditorPaneProps } from '../../types/panelDefinition';
import ListColumnsEditor from '../../../PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from '../../../PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
/**
* List's editor pane: the query builder with the columns editor pinned below it.
* Declared here so no editor host carries a List special case.
*/
function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
spec,
onChangeSpec,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
footer={
<ListColumnsEditor
spec={spec}
onChangeSpec={onChangeSpec}
signal={signal}
/>
}
/>
);
}
export default ListEditorPane;

View File

@@ -1,7 +1,6 @@
import { List } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import ListEditorPane from './ListEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -14,10 +13,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
mode: 'query',
icon: List,
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,

View File

@@ -1,7 +1,6 @@
import { Hash } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
mode: 'query',
icon: Hash,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,6 @@
import { ChartPie } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
mode: 'query',
icon: ChartPie,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,6 @@
import { Table } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
mode: 'query',
icon: Table,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,25 +0,0 @@
// Tripled on purpose: the markdown reset (`.content.content *`) weighs (0,2,0),
// and a doubled class only ties it — leaving the winner to stylesheet order,
// which reverted `position: relative` and let the copy button anchor to the
// panel instead of the block. (0,3,0) wins regardless of order.
.codeBlock.codeBlock.codeBlock {
position: relative;
}
// The wrapper is a reset-exempt island (see MarkdownContent.module.scss), so
// plain classes style it; the button inside keeps its design-system look.
.copyButton {
position: absolute;
top: 4px;
right: 4px;
border-radius: 3px;
background: var(--l3-background);
opacity: 0;
transition: opacity 0.15s ease;
}
// GitHub-style reveal: hover anywhere on the block, or keyboard focus.
.codeBlock.codeBlock.codeBlock:hover .copyButton,
.codeBlock.codeBlock.codeBlock:focus-within .copyButton {
opacity: 1;
}

View File

@@ -1,69 +0,0 @@
import type { CodeProps } from 'react-markdown/lib/ast-to-react';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import SyntaxHighlighter, { resolveLanguage } from './syntaxLanguages';
import { usePrismLanguage } from './usePrismLanguage';
import styles from './CodeBlock.module.scss';
const LANGUAGE_PATTERN = /language-(\w+)/;
/**
* Fenced blocks are tokenised by Prism but coloured by the SCSS module —
* `useInlineStyles` off swaps the library's own theme for `token …` class names,
* which keeps the palette on design tokens and themed with the rest of the body.
*/
function CodeBlock({ inline, className, children }: CodeProps): JSX.Element {
const fenced = LANGUAGE_PATTERN.exec(className ?? '')?.[1]?.toLowerCase();
const language = fenced ? resolveLanguage(fenced) : null;
const isReady = usePrismLanguage(language);
if (inline) {
return <code className={className}>{children}</code>;
}
// react-markdown hands the block's text through as string children; anything
// else in there is not source and has no place in the highlighter's input —
// and it is exactly what the copy button puts on the clipboard.
const source = (Array.isArray(children) ? children : [children])
.filter((child): child is string => typeof child === 'string')
.join('')
.replace(/\n$/, '');
// Verbatim while the language chunk is still loading, and permanently for one
// Prism doesn't know. The `pre` is supplied here either way, since
// `MarkdownContent` unwraps react-markdown's own.
const block =
!language || !isReady ? (
<pre>
<code className={className}>{children}</code>
</pre>
) : (
<SyntaxHighlighter
language={language}
useInlineStyles={false}
PreTag="pre"
CodeTag="code"
>
{source}
</SyntaxHighlighter>
);
return (
<div className={styles.codeBlock}>
{block}
{/* data-md-ui: exempts the design-system button from the body's style reset. */}
<span data-md-ui className={styles.copyButton}>
<CopyButton
value={source}
size={13}
ariaLabel="Copy code"
testId="text-panel-copy-code"
/>
</span>
</div>
);
}
export default CodeBlock;

View File

@@ -1,328 +0,0 @@
@use '../../../../../../styles/scrollbar' as *;
// Style isolation: the subtree is rolled back to user-agent styling, so no global
// rule reaches the rendered body and the rules below are the only author styles that
// apply. `all` skips custom properties, so tokens still resolve, and inherited
// properties the caller owns (`text-align`, set by the panel's presentation options)
// still flow in. The class is doubled throughout so a global `.wrapper p` can't tie
// on specificity and win on source order.
// `[data-md-ui]` marks injected UI islands (the code-block copy button) that keep
// their design-system styling: `:where(:not(…))` skips them and their subtrees at
// zero added specificity, so the island escape doesn't out-rank the body rules.
.content.content,
.content.content *:where(:not([data-md-ui], [data-md-ui] *)) {
all: revert;
box-sizing: border-box;
}
.content.content {
--md-foreground: var(--text-vanilla-100);
--md-muted: var(--text-neutral-dark-100);
--md-link: var(--text-robin-400);
--md-border: var(--l1-border);
--md-surface: color-mix(in srgb, var(--l1-foreground) 6%, transparent);
--md-code-comment: var(--text-neutral-dark-200);
--md-code-punctuation: var(--text-neutral-dark-100);
--md-code-keyword: var(--text-sakura-400);
--md-code-string: var(--text-forest-400);
--md-code-number: var(--text-amber-400);
--md-code-function: var(--text-robin-300);
--md-code-property: var(--text-aqua-400);
// Fits a two-digit ordered marker (`10.`). Shared by the task-list offset.
--md-list-indent: 24px;
display: block;
font-family: var(--font-family-inter);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--font-weight-normal);
line-height: var(--paragraph-base-400-line-height);
color: var(--md-foreground);
overflow-wrap: break-word;
}
:global(body.lightMode) .content.content {
--md-foreground: var(--text-ink-400);
--md-muted: var(--text-neutral-light-100);
--md-link: var(--text-robin-500);
--md-code-comment: var(--text-neutral-light-100);
--md-code-punctuation: var(--text-neutral-light-100);
--md-code-keyword: var(--text-sakura-600);
--md-code-string: var(--text-forest-700);
--md-code-number: var(--text-amber-800);
--md-code-function: var(--text-robin-600);
--md-code-property: var(--text-aqua-700);
}
.content.content > :first-child {
margin-top: 0;
}
.content.content > :last-child {
margin-bottom: 0;
}
.content.content p {
margin: 0 0 8px;
}
.content.content h1,
.content.content h2,
.content.content h3,
.content.content h4,
.content.content h5,
.content.content h6 {
margin: 16px 0 8px;
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
color: var(--md-foreground);
}
.content.content h1 {
font-size: var(--font-size-lg);
}
.content.content h2 {
font-size: var(--label-medium-600-font-size);
}
.content.content h3 {
font-size: var(--font-size-sm);
}
.content.content h4,
.content.content h5,
.content.content h6 {
font-size: var(--paragraph-base-600-font-size);
}
.content.content h5,
.content.content h6 {
color: var(--md-muted);
}
.content.content ul,
.content.content ol {
margin: 0 0 8px;
padding-left: var(--md-list-indent);
}
// A nested list belongs to the item above it, so it opens tight.
.content.content li > ul,
.content.content li > ol {
margin: 2px 0 0;
}
// The reset flattens the user-agent's own disc/circle/square progression, so the
// per-depth shapes are restated here.
.content.content ul {
list-style: disc;
}
.content.content ul ul {
list-style: circle;
}
.content.content ul ul ul {
list-style: square;
}
.content.content ol {
list-style: decimal;
}
.content.content ol ol {
list-style: lower-alpha;
}
.content.content ol ol ol {
list-style: lower-roman;
}
.content.content li {
margin: 2px 0;
}
// Markers are structure, not content. Safari below 17 ignores `::marker` colour and
// leaves them in the body colour.
.content.content li::marker {
color: var(--md-muted);
font-variant-numeric: tabular-nums;
}
// Task lists carry their own checkbox, so drop the marker and reclaim the indent.
.content.content li:has(> input[type='checkbox']) {
list-style: none;
margin-left: calc(var(--md-list-indent) * -1);
}
.content.content input[type='checkbox'] {
margin-right: 6px;
accent-color: var(--md-link);
}
.content.content input[type='checkbox']:not(:disabled) {
cursor: pointer;
}
.content.content a {
color: var(--md-link);
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.content.content strong {
font-weight: var(--font-weight-semibold);
color: var(--md-foreground);
}
.content.content em {
font-style: italic;
}
.content.content del {
text-decoration: line-through;
color: var(--md-muted);
}
.content.content code {
padding: 1px 4px;
border-radius: 2px;
background: var(--md-surface);
font-family: var(--font-family-sf-mono);
font-size: var(--code-small-400-font-size);
color: var(--md-foreground);
}
.content.content pre {
margin: 0 0 8px;
padding: 8px 10px;
border-radius: 3px;
background: var(--md-surface);
overflow-x: auto;
@include custom-scrollbar;
code {
padding: 0;
background: none;
font-size: var(--code-small-400-font-size);
line-height: var(--line-height-18);
color: var(--md-foreground);
}
}
.content.content blockquote {
margin: 0 0 8px;
padding: 2px 0 2px 10px;
border-left: 4px solid var(--md-border);
color: var(--md-muted);
}
.content.content hr {
margin: 12px 0;
border: none;
border-top: 1px solid var(--md-border);
}
.content.content img {
max-width: 100%;
height: auto;
border-radius: 3px;
}
.content.content table {
border-collapse: collapse;
width: auto;
}
.content.content th,
.content.content td {
padding: 4px 10px;
border: 1px solid var(--md-border);
text-align: left;
}
.content.content th {
background: var(--md-surface);
font-weight: var(--font-weight-semibold);
}
// Rendered by the `table` component override.
.content.content .tableScroll {
margin: 0 0 8px;
overflow-x: auto;
@include custom-scrollbar;
}
// Prism runs with `useInlineStyles` off, so it emits `token …` class names. They are
// `:global` because CSS Modules would otherwise hash them and match nothing, and the
// palette lives here on design tokens instead of in a theme object.
.content.content :global(.token.comment),
.content.content :global(.token.prolog),
.content.content :global(.token.doctype),
.content.content :global(.token.cdata) {
color: var(--md-code-comment);
font-style: italic;
}
.content.content :global(.token.punctuation),
.content.content :global(.token.operator),
.content.content :global(.token.entity) {
color: var(--md-code-punctuation);
}
.content.content :global(.token.keyword),
.content.content :global(.token.atrule),
.content.content :global(.token.rule),
.content.content :global(.token.important),
.content.content :global(.token.selector) {
color: var(--md-code-keyword);
}
.content.content :global(.token.string),
.content.content :global(.token.char),
.content.content :global(.token.attr-value),
.content.content :global(.token.regex),
.content.content :global(.token.url) {
color: var(--md-code-string);
}
.content.content :global(.token.number),
.content.content :global(.token.boolean),
.content.content :global(.token.constant),
.content.content :global(.token.symbol) {
color: var(--md-code-number);
}
.content.content :global(.token.function),
.content.content :global(.token.class-name),
.content.content :global(.token.builtin) {
color: var(--md-code-function);
}
.content.content :global(.token.property),
.content.content :global(.token.attr-name),
.content.content :global(.token.variable),
.content.content :global(.token.tag) {
color: var(--md-code-property);
}
.content.content :global(.token.deleted) {
color: var(--text-cherry-400);
}
.content.content :global(.token.inserted) {
color: var(--text-forest-400);
}
.content.content :global(.token.bold) {
font-weight: var(--font-weight-semibold);
}
.content.content :global(.token.italic) {
font-style: italic;
}

View File

@@ -1,148 +0,0 @@
import { type ReactNode, useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import {
editRenderedOccurrence,
type EditableConstruct,
} from '../../utils/markdownSource';
import { TASK_LIST } from '../../utils/taskList';
import CodeBlock from './CodeBlock';
import TaskCheckbox from './TaskCheckbox';
import { TaskItemOffsetContext } from './taskItemOffset';
import styles from './MarkdownContent.module.scss';
/**
* SECURITY — never add `rehype-raw` here. Without it react-markdown renders raw HTML
* as plain text, so there is no `dangerouslySetInnerHTML` on the path and nothing to
* sanitise. The body is user-authored and, on a public dashboard, read anonymously;
* the shared `MarkdownRenderer` enables `rehype-raw` and is safe only for the trusted
* content it was built for. `transformLinkUri` is likewise left at its default.
*/
const REMARK_PLUGINS = [remarkGfm];
// What the default transformer substitutes for a rejected scheme. Inert, but it
// would still put `javascript:` in the DOM, so the anchor is dropped instead.
const REJECTED_HREF = `javascript:${'void(0)'}`;
const READ_ONLY_COMPONENTS: Components = {
a: ({ node: _node, children, href, ...props }): JSX.Element => {
if (!href || href === REJECTED_HREF) {
return <span {...props}>{children}</span>;
}
return (
<a {...props} href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
</a>
);
},
// Wide tables scroll inside their own box rather than widening the panel.
table: ({ node: _node, children, ...props }): JSX.Element => (
<div className={styles.tableScroll}>
<table {...props}>{children}</table>
</div>
),
code: CodeBlock,
// `CodeBlock` emits its own `pre`, so this one would nest a second one.
pre: ({ children }): JSX.Element => <>{children}</>,
};
/** Absent on a read-only surface, which is what keeps the public view inert. */
export interface MarkdownInteractive {
/** The body before interpolation — what an edit is applied to. */
source: string;
onChangeSource: (next: string) => void;
}
export interface MarkdownContentProps {
/** Variable interpolation happens upstream, before parsing. */
children: string;
interactive?: MarkdownInteractive;
/** Rendered instead of the body when the source is blank. */
emptyState?: ReactNode;
className?: string;
testId?: string;
}
/** CommonMark + GFM, styled in isolation — see the reset in the SCSS module. */
function MarkdownContent({
children,
interactive,
emptyState = null,
className,
testId = 'markdown-content',
}: MarkdownContentProps): JSX.Element | null {
// Dashboards re-render on every variable tick; parsing is the expensive half.
// Element overrides that write back to the source: one entry per interactive
// construct, pairing an `EditableConstruct` with the element it renders as.
const components = useMemo<Components>(() => {
// A const, so the narrowing survives into the handler's closure.
const capability = interactive;
if (!capability) {
return READ_ONLY_COMPONENTS;
}
const edit = <T,>(
construct: EditableConstruct<T>,
renderedOffset: number,
value: T,
): void => {
const next = editRenderedOccurrence(construct, {
source: capability.source,
rendered: children,
renderedOffset,
value,
});
if (next !== null) {
capability.onChangeSource(next);
}
};
return {
...READ_ONLY_COMPONENTS,
li: ({ node, children: items, ...props }): JSX.Element => (
<li {...props}>
<TaskItemOffsetContext.Provider value={node.position?.start.offset}>
{items}
</TaskItemOffsetContext.Provider>
</li>
),
input: ({ checked, type }): JSX.Element | null => {
if (type !== 'checkbox') {
return null;
}
return (
<TaskCheckbox
checked={checked === true}
onChange={(next, offset): void => edit(TASK_LIST, offset, next)}
/>
);
},
};
}, [interactive, children]);
const body = useMemo(
() =>
children.trim() ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
{children}
</ReactMarkdown>
) : null,
[children, components],
);
if (!body) {
return emptyState ? <>{emptyState}</> : null;
}
return (
<div className={cx(styles.content, className)} data-testid={testId}>
{body}
</div>
);
}
export default MarkdownContent;

View File

@@ -1,32 +0,0 @@
import { useTaskItemOffset } from './taskItemOffset';
interface TaskCheckboxProps {
checked: boolean;
/** `offset` locates the item in the rendered body. */
onChange: (checked: boolean, offset: number) => void;
}
/**
* A GFM task-list checkbox that writes its state back to the panel's markdown.
* Disabled without an offset from its item: nothing would locate its marker.
*/
function TaskCheckbox({ checked, onChange }: TaskCheckboxProps): JSX.Element {
const offset = useTaskItemOffset();
return (
<input
type="checkbox"
checked={checked}
disabled={offset === undefined}
aria-label="Toggle task item"
data-testid="markdown-task-checkbox"
onChange={(event): void => {
if (offset !== undefined) {
onChange(event.target.checked, offset);
}
}}
/>
);
}
export default TaskCheckbox;

View File

@@ -1,313 +0,0 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import MarkdownContent from '../MarkdownContent';
import { loadLanguage } from '../syntaxLanguages';
describe('MarkdownContent', () => {
describe('security', () => {
it('renders a script tag as literal text, never as an element', () => {
const { container } = render(
<MarkdownContent>{'<script>alert(1)</script>'}</MarkdownContent>,
);
expect(container.querySelector('script')).toBeNull();
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'<script>alert(1)</script>',
);
});
it('renders raw HTML as text rather than markup', () => {
const { container } = render(
<MarkdownContent>
{'<b>bold</b> and <img src="x" onerror="alert(1)">'}
</MarkdownContent>,
);
expect(container.querySelector('b')).toBeNull();
expect(container.querySelector('img')).toBeNull();
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'<b>bold</b>',
);
});
it('drops the anchor for a javascript: href, keeping the label as text', () => {
const { container } = render(
<MarkdownContent>{'[x](javascript:alert(1))'}</MarkdownContent>,
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(container.innerHTML).not.toContain('javascript');
expect(screen.getByTestId('markdown-content')).toHaveTextContent('x');
});
it('opens links in a new tab without handing over the opener', () => {
render(<MarkdownContent>{'[docs](https://signoz.io)'}</MarkdownContent>);
const link = screen.getByRole('link', { name: 'docs' });
expect(link).toHaveAttribute('href', 'https://signoz.io');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
});
});
describe('CommonMark and GFM', () => {
it('renders headings, lists and emphasis', () => {
render(
<MarkdownContent>
{'# Runbook\n\n- **owner** payments\n- _rotation_ weekly'}
</MarkdownContent>,
);
expect(
screen.getByRole('heading', { level: 1, name: 'Runbook' }),
).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(2);
expect(screen.getByText('owner').tagName).toBe('STRONG');
expect(screen.getByText('rotation').tagName).toBe('EM');
});
it('renders GFM tables, task lists and strikethrough', () => {
const { container } = render(
<MarkdownContent>
{'| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- [x] done\n\n~~gone~~'}
</MarkdownContent>,
);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('checkbox')).toBeChecked();
expect(container.querySelector('del')).toHaveTextContent('gone');
});
it('renders fenced code as a preformatted block', () => {
const { container } = render(
<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent(
'kubectl get pods',
);
expect(container.querySelectorAll('pre')).toHaveLength(1);
});
it('renders malformed markdown as literal text instead of throwing', () => {
render(<MarkdownContent>{'| broken | table\n**unclosed'}</MarkdownContent>);
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'**unclosed',
);
});
});
describe('syntax highlighting', () => {
it('tokenises a fenced block once its language has loaded', async () => {
const { container } = render(
<MarkdownContent>{'```js\nconst x = 1; // note\n```'}</MarkdownContent>,
);
await waitFor(() => {
expect(container.querySelector('.token.keyword')).toHaveTextContent(
'const',
);
});
expect(container.querySelector('.token.number')).toHaveTextContent('1');
expect(container.querySelector('.token.comment')).toHaveTextContent(
'// note',
);
});
it('shows the source verbatim while the language is still loading', () => {
const { container } = render(
<MarkdownContent>{'```rust\nfn main() {}\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent(
'fn main() {}',
);
expect(container.querySelector('.token')).toBeNull();
});
it('highlights a language already loaded on the first render', async () => {
await loadLanguage('sql');
const { container } = render(
<MarkdownContent>{'```sql\nSELECT 1\n```'}</MarkdownContent>,
);
expect(container.querySelector('.token.keyword')).toHaveTextContent(
'SELECT',
);
});
it('tags the code element with the language', () => {
const { container } = render(
<MarkdownContent>{'```python\nx = 1\n```'}</MarkdownContent>,
);
expect(container.querySelector('code')).toHaveClass('language-python');
});
it('renders an unknown language verbatim', () => {
const { container } = render(
<MarkdownContent>{'```promql\nrate(foo[5m])\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent(
'rate(foo[5m])',
);
expect(container.querySelector('.token')).toBeNull();
});
it('renders a fence with no language verbatim', () => {
const { container } = render(
<MarkdownContent>{'```\nplain text\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent('plain text');
expect(container.querySelector('.token')).toBeNull();
});
it('leaves inline code untokenised', () => {
const { container } = render(
<MarkdownContent>{'use `const` here'}</MarkdownContent>,
);
expect(container.querySelector('pre')).toBeNull();
expect(container.querySelector('.token')).toBeNull();
});
});
describe('empty body', () => {
it('renders nothing when the source is blank', () => {
const { container } = render(<MarkdownContent>{' \n '}</MarkdownContent>);
expect(container).toBeEmptyDOMElement();
});
it('renders the empty state when one is supplied', () => {
render(
<MarkdownContent emptyState={<span>Nothing here yet</span>}>
{''}
</MarkdownContent>,
);
expect(screen.getByText('Nothing here yet')).toBeInTheDocument();
expect(screen.queryByTestId('markdown-content')).not.toBeInTheDocument();
});
});
});
describe('code block copy button', () => {
it('offers the block source, exactly as fenced, to the copy control', () => {
render(<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>);
const button = screen.getByTestId('text-panel-copy-code');
expect(button).toHaveAccessibleName('Copy code');
});
it('renders no copy control on inline code', () => {
render(<MarkdownContent>{'run `npm i` now'}</MarkdownContent>);
expect(screen.queryByTestId('text-panel-copy-code')).not.toBeInTheDocument();
});
});
describe('MarkdownContent — interactive task lists', () => {
const source = ['- [ ] first', '- [x] second'].join('\n');
it('renders task checkboxes disabled without the capability', () => {
render(<MarkdownContent>{source}</MarkdownContent>);
const boxes = screen.getAllByRole('checkbox');
expect(boxes).toHaveLength(2);
boxes.forEach((box) => expect(box).toBeDisabled());
});
it('renders them enabled, and checked to match the source', () => {
render(
<MarkdownContent interactive={{ source, onChangeSource: jest.fn() }}>
{source}
</MarkdownContent>,
);
const [first, second] = screen.getAllByRole('checkbox');
expect(first).toBeEnabled();
expect(first).not.toBeChecked();
expect(second).toBeChecked();
});
it('checking one rewrites its marker in the source', () => {
const onChangeSource = jest.fn();
render(
<MarkdownContent interactive={{ source, onChangeSource }}>
{source}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[0]);
expect(onChangeSource).toHaveBeenCalledWith(
['- [x] first', '- [x] second'].join('\n'),
);
});
it('unchecking one rewrites only that marker', () => {
const onChangeSource = jest.fn();
render(
<MarkdownContent interactive={{ source, onChangeSource }}>
{source}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[1]);
expect(onChangeSource).toHaveBeenCalledWith(
['- [ ] first', '- [ ] second'].join('\n'),
);
});
it('maps a click back through an expanded variable', () => {
const onChangeSource = jest.fn();
const withVariable = ['- [ ] $env first', '- [ ] second'].join('\n');
render(
<MarkdownContent interactive={{ source: withVariable, onChangeSource }}>
{['- [ ] production first', '- [ ] second'].join('\n')}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[1]);
expect(onChangeSource).toHaveBeenCalledWith(
['- [ ] $env first', '- [x] second'].join('\n'),
);
});
it('saves nothing when a variable injected a marker of its own', () => {
const onChangeSource = jest.fn();
render(
<MarkdownContent interactive={{ source: '- [ ] $tasks', onChangeSource }}>
{['- [ ] one', '- [ ] two'].join('\n')}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[0]);
expect(onChangeSource).not.toHaveBeenCalled();
});
it('ignores a task marker inside a fence', () => {
const onChangeSource = jest.fn();
const fenced = ['```', '- [ ] fenced', '```', '', '- [ ] real'].join('\n');
render(
<MarkdownContent interactive={{ source: fenced, onChangeSource }}>
{fenced}
</MarkdownContent>,
);
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
fireEvent.click(screen.getByRole('checkbox'));
expect(onChangeSource).toHaveBeenCalledWith(
fenced.replace('- [ ] real', '- [x] real'),
);
});
});

View File

@@ -1,89 +0,0 @@
import { PrismLight } from 'react-syntax-highlighter';
type PrismLanguage = Parameters<typeof PrismLight.registerLanguage>[1];
type LanguageLoader = () => Promise<{ default: PrismLanguage }>;
// One dynamic import per language, so each becomes its own chunk and a panel pays
// only for the languages its fences actually name.
const LOADERS: Record<string, LanguageLoader> = {
bash: () => import('react-syntax-highlighter/dist/esm/languages/prism/bash'),
css: () => import('react-syntax-highlighter/dist/esm/languages/prism/css'),
diff: () => import('react-syntax-highlighter/dist/esm/languages/prism/diff'),
docker: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/docker'),
go: () => import('react-syntax-highlighter/dist/esm/languages/prism/go'),
java: () => import('react-syntax-highlighter/dist/esm/languages/prism/java'),
javascript: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/javascript'),
json: () => import('react-syntax-highlighter/dist/esm/languages/prism/json'),
markup: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/markup'),
python: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/python'),
rust: () => import('react-syntax-highlighter/dist/esm/languages/prism/rust'),
sql: () => import('react-syntax-highlighter/dist/esm/languages/prism/sql'),
typescript: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/typescript'),
yaml: () => import('react-syntax-highlighter/dist/esm/languages/prism/yaml'),
};
const ALIASES: Record<string, string> = {
dockerfile: 'docker',
html: 'markup',
js: 'javascript',
py: 'python',
sh: 'bash',
shell: 'bash',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
};
const registered = new Set<string>();
const inFlight = new Map<string, Promise<boolean>>();
/** The name Prism knows a fence's language by, or null if it knows none. */
export function resolveLanguage(name: string): string | null {
const canonical = ALIASES[name] ?? name;
return canonical in LOADERS ? canonical : null;
}
export function isLanguageRegistered(name: string): boolean {
return registered.has(name);
}
/**
* Resolves to whether `name` is registered and ready to highlight with. Concurrent
* callers share one import, so a dashboard of same-language fences fetches once.
*/
export function loadLanguage(name: string): Promise<boolean> {
if (registered.has(name)) {
return Promise.resolve(true);
}
const pending = inFlight.get(name);
if (pending) {
return pending;
}
const loader = LOADERS[name];
if (!loader) {
return Promise.resolve(false);
}
const request = loader()
.then((module) => {
PrismLight.registerLanguage(name, module.default);
registered.add(name);
return true;
})
.catch(() => false)
.finally(() => {
inFlight.delete(name);
});
inFlight.set(name, request);
return request;
}
export default PrismLight;

View File

@@ -1,22 +0,0 @@
import {
// eslint-disable-next-line no-restricted-imports
createContext,
// eslint-disable-next-line no-restricted-imports
useContext,
} from 'react';
/**
* Offset of the enclosing list item in the rendered body. The checkbox a task list
* renders is synthesised by the AST transform with no position of its own, so its
* item supplies one — after every earlier marker and before its own, which is all
* the ordinal needs.
*
* Context, not a store: one render pass handing a node's position to its child.
*/
export const TaskItemOffsetContext = createContext<number | undefined>(
undefined,
);
export function useTaskItemOffset(): number | undefined {
return useContext(TaskItemOffsetContext);
}

View File

@@ -1,42 +0,0 @@
import { useEffect, useState } from 'react';
import { isLanguageRegistered, loadLanguage } from './syntaxLanguages';
/**
* Registers `language` with Prism on demand, reporting when it is ready to
* highlight with. Already-loaded languages report ready on the first render, so a
* second fence of the same language never flashes unhighlighted.
*/
export function usePrismLanguage(language: string | null): boolean {
const [isReady, setIsReady] = useState(
() => !!language && isLanguageRegistered(language),
);
useEffect(() => {
if (!language) {
setIsReady(false);
return undefined;
}
if (isLanguageRegistered(language)) {
setIsReady(true);
return undefined;
}
setIsReady(false);
let isStale = false;
// `loadLanguage` resolves false rather than rejecting, so there is no failure
// path here beyond leaving the block unhighlighted.
void loadLanguage(language).then((loaded): boolean => {
if (!isStale && loaded) {
setIsReady(true);
}
return loaded;
});
return (): void => {
isStale = true;
};
}, [language]);
return isReady;
}

View File

@@ -1,7 +1,6 @@
import { ChartLine } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
mode: 'query',
icon: ChartLine,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -5,7 +5,6 @@ import {
NO_PANEL_ACTIONS,
type RenderablePanelDefinition,
} from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
/**
@@ -20,10 +19,8 @@ import Renderer from './Renderer';
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
mode: 'query',
icon: TriangleAlert,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],

View File

@@ -22,15 +22,6 @@ export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/** The kind's `default` rule with its per-signal overrides merged over it (signal wins). */
export function mergeQueryBuilderFieldRule(
rule: QueryBuilderFieldRule,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}
/**
* How a kind's query-range request is shaped. Declared per-kind in
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code

View File

@@ -1,8 +1,5 @@
import type { ComponentType } from 'react';
import {
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { ChartLine } from '@signozhq/icons';
import type { EQueryType } from 'types/common/dashboard';
@@ -13,11 +10,7 @@ import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type {
BaseRendererProps,
PanelRendererProps,
StaticRendererProps,
} from './rendererProps';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
export enum DownloadFormat {
@@ -70,50 +63,12 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
export type PanelIcon = typeof ChartLine;
export interface PanelDefinitionBase<K extends PanelKind = PanelKind> {
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
icon: PanelIcon;
sections: SectionConfig[];
actions: PanelActionCapabilities;
}
/** Props for a static kind's authoring pane — rendered where the query builder sits. */
export interface StaticEditorPaneProps {
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* Props for a query kind's authoring pane (the editor's lower-left slot). Spec
* read/write is included so a kind's pane can edit its own spec slices (the List
* columns editor) without the host carrying per-kind conditionals.
*/
export interface QueryEditorPaneProps {
/** The kind's definition, narrowed by the host's fork. */
panelDefinition: RenderableQueryPanelDefinition;
signal: TelemetrytypesSignalDTO;
isLoadingQueries: boolean;
onStageRunQuery: () => void;
onCancelQuery: () => void;
/** Pin the tabs row to the pane top; the View modal opts out. */
stickyHeader?: boolean;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* A kind that renders from a query. Declares its whole query surface here, so a
* kind without one carries no query declarations at all — no dummy capabilities,
* no empty signal lists standing in for "not applicable".
*/
export interface QueryPanelDefinition<
K extends PanelKind = PanelKind,
> extends PanelDefinitionBase<K> {
mode: 'query';
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
sections: SectionConfig[];
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
@@ -122,39 +77,16 @@ export interface QueryPanelDefinition<
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}
/**
* A kind that renders from its own plugin spec and saves with `queries: []` (the
* API rejects anything else). Its renderer takes no query data, and its editor
* pane replaces the query builder (TDD D8). No query machinery mounts for it
* anywhere — every host forks on `mode` before touching a query hook.
*/
export interface StaticPanelDefinition<
K extends PanelKind = PanelKind,
> extends PanelDefinitionBase<K> {
mode: 'static';
Renderer: ComponentType<StaticRendererProps<K>>;
EditorPane: ComponentType<StaticEditorPaneProps>;
}
export type PanelDefinition<K extends PanelKind = PanelKind> =
| QueryPanelDefinition<K>
| StaticPanelDefinition<K>;
// Every kind must be registered, so getPanelDefinition never returns undefined.
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
// The arms with their Renderer widened to the kind-agnostic prop surface. Declared
// explicitly rather than via `Omit` over the union, which collapses to common keys.
export interface RenderableQueryPanelDefinition extends Omit<
QueryPanelDefinition,
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}
export type RenderableStaticPanelDefinition = StaticPanelDefinition<PanelKind>;
export type RenderablePanelDefinition =
| RenderableQueryPanelDefinition
| RenderableStaticPanelDefinition;

View File

@@ -75,19 +75,6 @@ export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
};
};
/**
* Props a static (query-less) renderer receives: its panel and render context,
* nothing of the fetch lifecycle. `dashboardId` scopes store reads (resolved
* variables); the editor route seeds the same store, so previews of an unsaved
* panel resolve variables the way the grid does.
*/
export interface StaticRendererProps<K extends PanelKind = PanelKind> {
panelId: string;
panel: PanelOfKind<K>;
panelMode: PanelMode;
dashboardId?: string;
}
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
// and only the gestures it supports. The default K = PanelKind is the widest surface.

View File

@@ -1,121 +0,0 @@
import {
editRenderedOccurrence,
maskFencedCode,
matchOffsets,
} from '../markdownSource';
import { TASK_LIST } from '../taskList';
const CHECKLIST = ['- [ ] first', '- [x] second', '- [ ] third'].join('\n');
function toggle(
source: string,
renderedOffset: number,
value: boolean,
rendered = source,
): string | null {
return editRenderedOccurrence(TASK_LIST, {
source,
rendered,
renderedOffset,
value,
});
}
describe('maskFencedCode', () => {
it('blanks a fence without moving anything after it', () => {
const markdown = ['a', '```', '- [ ] not a task', '```', '- [ ] task'].join(
'\n',
);
const masked = maskFencedCode(markdown);
expect(masked).toHaveLength(markdown.length);
expect(masked.indexOf('- [ ] task')).toBe(markdown.indexOf('- [ ] task'));
expect(masked).not.toContain('not a task');
});
it('handles tilde fences and an unterminated one', () => {
expect(maskFencedCode('~~~\n- [ ] x\n~~~')).not.toContain('[ ]');
expect(maskFencedCode('```\n- [ ] x')).not.toContain('[ ]');
});
});
describe('matchOffsets', () => {
it('finds every task marker, in document order', () => {
expect(TASK_LIST.offsets(CHECKLIST)).toStrictEqual([
CHECKLIST.indexOf('[ ]') + 1,
CHECKLIST.indexOf('[x]') + 1,
CHECKLIST.lastIndexOf('[ ]') + 1,
]);
});
it('ignores markers inside a fence', () => {
const markdown = ['- [ ] real', '```', '- [ ] fenced', '```'].join('\n');
expect(TASK_LIST.offsets(markdown)).toHaveLength(1);
});
it('reads ordered lists and the alternate bullets', () => {
const markdown = ['1. [ ] a', '2) [ ] b', '* [ ] c', '+ [ ] d'].join('\n');
expect(TASK_LIST.offsets(markdown)).toHaveLength(4);
});
it('is repeatable', () => {
expect(TASK_LIST.offsets(CHECKLIST)).toStrictEqual(
TASK_LIST.offsets(CHECKLIST),
);
});
it('does not treat a bare bracket as a marker', () => {
expect(TASK_LIST.offsets('- [] no')).toHaveLength(0);
});
it('scans with the pattern it is given', () => {
const offsets = matchOffsets('a1 b2', /\d/g, () => 0);
expect(offsets).toStrictEqual([1, 4]);
});
});
describe('editRenderedOccurrence', () => {
it('checks the marker the clicked checkbox belongs to', () => {
const next = toggle(CHECKLIST, CHECKLIST.lastIndexOf('[ ]'), true);
expect(next).toBe(['- [ ] first', '- [x] second', '- [x] third'].join('\n'));
});
it('unchecks one without touching its neighbours', () => {
const next = toggle(CHECKLIST, CHECKLIST.indexOf('[x]'), false);
expect(next).toBe(['- [ ] first', '- [ ] second', '- [ ] third'].join('\n'));
});
it('leaves the rest of the body byte-identical', () => {
const source = ['# Title', '', '- [ ] one', '', 'Prose after.'].join('\n');
const next = toggle(source, source.indexOf('[ ]'), true);
expect(next).toBe(source.replace('[ ]', '[x]'));
});
it('maps a rendered offset back through an expanded variable', () => {
const source = ['- [ ] $env first', '- [ ] second'].join('\n');
const rendered = ['- [ ] production first', '- [ ] second'].join('\n');
const next = toggle(source, rendered.lastIndexOf('[ ]'), true, rendered);
expect(next).toBe(['- [ ] $env first', '- [x] second'].join('\n'));
});
it('refuses when the two bodies disagree on how many there are', () => {
const source = ['- [ ] $tasks', '- [ ] last'].join('\n');
const rendered = ['- [ ] one', '- [ ] injected', '- [ ] last'].join('\n');
expect(
toggle(source, rendered.lastIndexOf('[ ]'), true, rendered),
).toBeNull();
});
it('refuses when the source has no such occurrence', () => {
expect(toggle('no tasks here', 0, true)).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getQueryPanelDefinition } from '../capabilities';
import { getPanelDefinition } from '../registry';
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
@@ -10,19 +10,10 @@ import { fromPerses } from '../../queryV5/persesQueryAdapters';
* The panel's saved query as a builder `Query` — what the editor route and the View
* modal put in `compositeQuery` when they open. Matches the seed
* `usePanelEditorQuerySync` computes from the panel.
*
* `null` for a kind that renders from its own spec: there is no query to stage, and
* callers skip the `compositeQuery` param entirely rather than asking about the kind.
*/
export function getPanelBuilderQuery(
panel: DashboardtypesPanelDTO,
): Query | null {
export function getPanelBuilderQuery(panel: DashboardtypesPanelDTO): Query {
const kind = panel.spec.plugin.kind;
const definition = getQueryPanelDefinition(kind);
if (!definition) {
return null;
}
const [defaultSignal] = definition.supportedSignals;
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
if (panel.spec.queries.length === 0 && defaultSignal) {

View File

@@ -1,73 +0,0 @@
/** Fenced regions, whose markup is source text rather than a rendered construct. */
const FENCE = /^([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n\1\2[^\n]*|$)/gm;
/**
* Blanks fenced code, preserving length so offsets still line up. A construct
* inside a fence renders as text, so counting it would misalign the rest.
*/
export function maskFencedCode(markdown: string): string {
return markdown.replace(FENCE, (block) => block.replace(/[^\n]/g, ' '));
}
/** Offsets of every match of `pattern` in `markdown`, ignoring fenced code. */
export function matchOffsets(
markdown: string,
pattern: RegExp,
/** Offset within the match of the span the rewrite targets. */
spanOffset: (match: RegExpExecArray) => number,
): number[] {
const masked = maskFencedCode(markdown);
const scan = new RegExp(pattern.source, pattern.flags);
const offsets: number[] = [];
let match = scan.exec(masked);
while (match !== null) {
offsets.push(match.index + spanOffset(match));
match = scan.exec(masked);
}
return offsets;
}
/**
* A markdown construct whose rendered element writes back to the source. Add one
* of these plus an element override in `MarkdownContent` to make another element
* interactive.
*/
export interface EditableConstruct<T> {
/** Offset of each occurrence's editable span, in document order. */
offsets: (markdown: string) => number[];
/** Rewrites the span at `offset` to `value`. */
rewrite: (markdown: string, offset: number, value: T) => string;
}
/**
* Maps a rendered element back to the same ordinal in the source and rewrites it.
*
* Variables are interpolated before parsing, so a rendered offset is not a source
* offset. The ordinal only holds while both bodies carry the same number of the
* construct — a variable value with one of its own shifts everything after it —
* so a mismatch returns `null` rather than editing the wrong occurrence.
*/
export function editRenderedOccurrence<T>(
construct: EditableConstruct<T>,
{
source,
rendered,
renderedOffset,
value,
}: { source: string; rendered: string; renderedOffset: number; value: T },
): string | null {
const sourceOffsets = construct.offsets(source);
const renderedOffsets = construct.offsets(rendered);
if (sourceOffsets.length !== renderedOffsets.length) {
return null;
}
const ordinal = renderedOffsets.filter(
(start) => start < renderedOffset,
).length;
const target = sourceOffsets[ordinal];
if (target === undefined) {
return null;
}
return construct.rewrite(source, target, value);
}

View File

@@ -1,14 +0,0 @@
import { type EditableConstruct, matchOffsets } from './markdownSource';
/** Group 1 runs up to the state character, which is the span a toggle rewrites. */
const TASK_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+\[)[ xX](\])/gm;
/** The checkbox a GFM task list renders. */
export const TASK_LIST: EditableConstruct<boolean> = {
offsets: (markdown) =>
matchOffsets(markdown, TASK_MARKER, (match) => match[1].length),
rewrite: (markdown, offset, checked) =>
markdown.slice(0, offset) +
(checked ? 'x' : ' ') +
markdown.slice(offset + 1),
};

View File

@@ -1,9 +1,22 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import ContextMenu from 'periscope/components/ContextMenu';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
import QueryPanel from './QueryPanel';
import StaticPanel from './StaticPanel';
import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
import styles from './Panel.module.scss';
/**
* Layout context for the panel actions menu — present only in editable mode. No
@@ -24,8 +37,8 @@ interface PanelProps {
}
/**
* A single dashboard panel. Forks on the kind's mode before any query machinery
* exists, so a static kind never mounts a fetch — not even a disabled one.
* A single dashboard panel (header + body). Thin orchestrator: fetching lives in
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
*/
function Panel({
panel,
@@ -33,28 +46,74 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
if (panelDefinition.mode === 'static') {
return (
<StaticPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
);
}
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
return (
<QueryPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
<div
className={styles.panel}
data-panel-visible={isOffScreen ? 'false' : 'true'}
// Stable locator so the "Download as PNG" action can find this node to
// capture, without threading a ref through the header/actions chain.
data-panel-root={panelId}
>
<PanelHeader
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}

View File

@@ -1,14 +1,11 @@
import type { ComponentType } from 'react';
import { RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import PanelLoader from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelLoader/PanelLoader';
import PanelMessage from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type {
BaseRendererProps,
DashboardPreference,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
import { getResponseType } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import type {
@@ -20,8 +17,8 @@ import { panelStatusFromError } from '../PanelStatus/utils';
import styles from './PanelBody.module.scss';
interface PanelBodyProps {
/** The query arm's renderer — hosts narrow `mode === 'query'` before this mounts. */
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
/** Resolved renderer for the panel kind (`Panel` handles the unsupported case). */
panelDefinition: RenderablePanelDefinition;
panel: DashboardtypesPanelDTO;
panelId: string;
data: PanelQueryData;
@@ -54,7 +51,7 @@ interface PanelBodyProps {
* first-load / renderer. The renderer keeps stale data mounted across refetches.
*/
function PanelBody({
Renderer,
panelDefinition,
panel,
panelId,
data,
@@ -119,7 +116,7 @@ function PanelBody({
return (
<div className={styles.panelContainer}>
<Renderer
<panelDefinition.Renderer
panelId={panelId}
panel={panel}
data={data}

View File

@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import PanelBody from '../PanelBody';
@@ -7,6 +8,10 @@ import PanelBody from '../PanelBody';
// Stub the renderer so these tests focus on PanelBody's state machine.
const MockRenderer = (): JSX.Element => <div data-testid="mock-renderer" />;
const panelDefinition = {
Renderer: MockRenderer,
} as unknown as RenderablePanelDefinition;
function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
@@ -19,7 +24,7 @@ function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
}
const baseProps = {
Renderer: MockRenderer,
panelDefinition,
panelId: 'p1',
data: {} as PanelQueryData,
isFetching: false,

View File

@@ -1,112 +0,0 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelActionsConfig } from './Panel';
import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
import styles from './Panel.module.scss';
interface QueryPanelProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The kind's definition, narrowed to the query arm by `Panel`'s fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* A query-backed dashboard panel (header + body). Thin orchestrator: fetching
* lives in `usePanelQuery`, interactions in `usePanelInteractions`, state in
* `PanelBody`.
*/
function QueryPanel({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: QueryPanelProps): JSX.Element {
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
const panelKind = panel.spec.plugin.kind;
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
return (
<div
className={styles.panel}
data-panel-visible={isOffScreen ? 'false' : 'true'}
// Stable locator so the "Download as PNG" action can find this node to
// capture, without threading a ref through the header/actions chain.
data-panel-root={panelId}
>
<PanelHeader
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}
export default QueryPanel;

View File

@@ -1,56 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import type { PanelActionsConfig } from './Panel';
import PanelHeader from './PanelHeader/PanelHeader';
import StaticPanelBody from './StaticPanelBody/StaticPanelBody';
import styles from './Panel.module.scss';
interface StaticPanelProps {
panel: DashboardtypesPanelDTO;
panelId: string;
panelDefinition: RenderableStaticPanelDefinition;
isVisible?: boolean;
panelActions?: PanelActionsConfig;
}
/**
* A dashboard panel that renders from its own plugin spec: chrome plus the static
* body. No fetch, no status indicators, no time preference, no drilldown — none
* of that exists without a query.
*/
function StaticPanel({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: StaticPanelProps): JSX.Element {
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
// Stable locator, as on QueryPanel — actions that capture the panel node
// (and tests) address it the same way for both arms.
data-panel-root={panelId}
>
<PanelHeader
panelId={panelId}
panel={panel}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
timeLabel={null}
panelActions={panelActions}
/>
<StaticPanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
/>
</div>
);
}
export default StaticPanel;

View File

@@ -1,5 +0,0 @@
.body {
height: 100%;
min-height: 0;
overflow: hidden;
}

View File

@@ -1,45 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import styles from './StaticPanelBody.module.scss';
interface StaticPanelBodyProps {
Renderer: RenderableStaticPanelDefinition['Renderer'];
panel: DashboardtypesPanelDTO;
panelId: string;
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
panelMode?: PanelMode;
}
/**
* Body for a kind that renders from its own plugin spec: the static renderer and
* nothing else — no fetch, no loading or error states. Shared by the dashboard
* grid, the public view and the editor preview, which is what keeps the preview
* live while the draft spec changes.
*/
function StaticPanelBody({
Renderer,
panel,
panelId,
panelMode = PanelMode.DASHBOARD_VIEW,
}: StaticPanelBodyProps): JSX.Element {
// From the edit context, not props: the editor route seeds it too, so an
// unsaved panel's preview resolves variables the same way the grid does.
const dashboardId = useDashboardStore((s) => s.dashboardId);
return (
<div className={styles.body} data-testid="static-panel-body">
<Renderer
panelId={panelId}
panel={panel as PanelOfKind}
panelMode={panelMode}
dashboardId={dashboardId || undefined}
/>
</div>
);
}
export default StaticPanelBody;

View File

@@ -1,196 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { useDrilldown } from '../hooks/useDrilldown';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import {
DashboardDetailEvents,
DashboardEvents,
} from 'pages/DashboardPage/constants/events';
interface QueryViewModalBodyProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Close the modal — wired to the graph manager's Save/Cancel. */
onClose: () => void;
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind View modal body: a compact drilldown editor. It renders an
* editable draft of the panel (preview) over a per-view time window plus the
* kind's editor pane, so the user can tweak + Stage & Run without touching the
* dashboard. Edits are temporary.
*/
function QueryViewModalBody({
panel,
panelId,
onClose,
draftApi,
onChangePanelKind,
}: QueryViewModalBodyProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
} = useViewPanelTimeWindow();
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
query,
runQuery,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride, draftApi });
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
pagination,
} = query;
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
// so it reflects in-modal edits (and the click's time range follows the per-view window).
const drilldown = useDrilldown(draft, panelId, {
openDrilldownView: applyDrilldownQuery,
});
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
const handleDragSelect = useCallback(
(start: number, end: number): void => {
if (Math.floor(start) !== Math.floor(end)) {
void logEvent(DashboardDetailEvents.PanelZoomed, {
context: 'viewModal',
panelType: draft.spec.plugin.kind,
panelId,
});
}
onDragSelect(start, end);
},
[onDragSelect, draft.spec.plugin.kind, panelId],
);
// Publish the modal's local extender for the nested no-data state; cleared on close.
const setViewPanelExtendWindow = useViewPanelStore(
(s) => s.setViewPanelExtendWindow,
);
useEffect(() => {
setViewPanelExtendWindow(extendWindow);
return (): void => setViewPanelExtendWindow(null);
}, [extendWindow, setViewPanelExtendWindow]);
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
}
const { EditorPane } = panelDefinition;
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
};
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<EditorPane
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}
spec={draft.spec}
onChangeSpec={setSpec}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={handleDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
hideHeader
/>
</div>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}
export default QueryViewModalBody;

View File

@@ -1,93 +0,0 @@
import { useCallback } from 'react';
import { PenLine } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { PanelMode } from 'lib/visualization/panels/types';
import logEvent from 'api/common/logEvent';
import PanelTypeSwitcher from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/PanelTypeSwitcher';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { DashboardEvents } from 'pages/DashboardPage/constants/events';
import { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
interface StaticViewModalBodyProps {
panelId: string;
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The static-kind View modal body: the panel rendered live over the kind's
* editor pane — the same layout idea as the query body, with the time window,
* query builder and drilldown machinery absent because none of it applies.
* Edits are temporary; "Edit panel" hands them to the full editor.
*/
function StaticViewModalBody({
panelId,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticViewModalBodyProps): JSX.Element {
const { draft, spec, setSpec } = draftApi;
const { EditorPane, Renderer } = panelDefinition;
const openPanelEditor = useOpenPanelEditor();
const onSwitchToEdit = useCallback((): void => {
void logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, { panelId });
// Carry the in-modal edits so the editor opens on them, not the saved panel.
openPanelEditor(panelId, {
handoffState: { editSpec: { ...draft.spec, queries: [] } },
});
}, [openPanelEditor, panelId, draft.spec]);
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<div className={styles.staticToolbar}>
<PanelTypeSwitcher
panelKind={draft.spec.plugin.kind}
queryType={EQueryType.QUERY_BUILDER}
onChange={onChangePanelKind}
/>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
prefix={<PenLine size={14} />}
onClick={onSwitchToEdit}
data-testid="static-view-switch-to-edit"
>
Edit panel
</Button>
</div>
<div className={styles.staticPreview}>
<PanelHeader
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
Renderer={Renderer}
panel={draft}
panelId={panelId}
panelMode={PanelMode.STANDALONE_VIEW}
/>
</div>
<div className={styles.staticEditorPane}>
<EditorPane spec={spec} onChangeSpec={setSpec} />
</div>
</div>
);
}
export default StaticViewModalBody;

View File

@@ -60,29 +60,3 @@
.panelTypeSelector {
width: 240px;
}
// Static-kind modal body: toolbar, live panel, editor pane.
.staticToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.staticPreview {
display: flex;
flex-direction: column;
flex: 1 1 55%;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}
.staticEditorPane {
flex: 1 1 45%;
min-height: 0;
margin-top: 12px;
}

View File

@@ -1,22 +1,26 @@
import { useMemo } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryParams } from 'constants/query';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { useCallback, useEffect, useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import ListColumnsEditor from 'pages/DashboardPage/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import QueryViewModalBody from './QueryViewModalBody';
import StaticViewModalBody from './StaticViewModalBody';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
import { useDrilldown } from '../hooks/useDrilldown';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import {
DashboardDetailEvents,
DashboardEvents,
} from 'pages/DashboardPage/constants/events';
interface ViewPanelModalContentProps {
panel: DashboardtypesPanelDTO;
@@ -26,79 +30,168 @@ interface ViewPanelModalContentProps {
}
/**
* View-modal shell. Owns the draft and the kind-switch cache — the state that
* must survive a switch between authoring modes — and forks on the draft kind's
* `mode`, so a static kind mounts no time window, query session or drilldown.
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
* the panel (preview) over a per-view time window plus the shared query builder, so the
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
*/
function ViewPanelModalContent({
panel,
panelId,
onClose,
}: ViewPanelModalContentProps): JSX.Element {
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the
// query still comes from the URL. Falls back to the saved panel for a plain "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
}: ViewPanelModalContentProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
} = useViewPanelTimeWindow();
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() => {
// A URL query can only seed a kind that takes one.
const isQuerySeeded =
compositeQuery && getPanelDefinition(baseSpec.plugin.kind).mode === 'query';
return isQuerySeeded
? {
...panel,
spec: buildViewPanelSpec({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
}),
}
: { ...panel, spec: baseSpec };
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride });
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
pagination,
} = query;
const draftApi = usePanelEditorDraft(initialPanel);
const draftKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(draftKind);
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[draftKind],
setSpec: draftApi.setSpec,
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
// so it reflects in-modal edits (and the click's time range follows the per-view window).
const drilldown = useDrilldown(draft, panelId, {
openDrilldownView: applyDrilldownQuery,
});
if (panelDefinition.mode === 'static') {
return (
<StaticViewModalBody
panelId={panelId}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
);
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
const handleDragSelect = useCallback(
(start: number, end: number): void => {
if (Math.floor(start) !== Math.floor(end)) {
void logEvent(DashboardDetailEvents.PanelZoomed, {
context: 'viewModal',
panelType: draft.spec.plugin.kind,
panelId,
});
}
onDragSelect(start, end);
},
[onDragSelect, draft.spec.plugin.kind, panelId],
);
// Publish the modal's local extender for the nested no-data state; cleared on close.
const setViewPanelExtendWindow = useViewPanelStore(
(s) => s.setViewPanelExtendWindow,
);
useEffect(() => {
setViewPanelExtendWindow(extendWindow);
return (): void => setViewPanelExtendWindow(null);
}, [extendWindow, setViewPanelExtendWindow]);
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
}
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
};
return (
<QueryViewModalBody
panel={panel}
panelId={panelId}
onClose={onClose}
draftApi={draftApi}
onChangePanelKind={onChangePanelKind}
/>
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<PanelEditorQueryBuilder
panelKind={draft.spec.plugin.kind}
signal={signal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}
footer={
isListPanel ? (
<ListColumnsEditor
spec={draft.spec}
onChangeSpec={setSpec}
signal={signal}
/>
) : undefined
}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={handleDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
hideHeader
/>
</div>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}

View File

@@ -5,12 +5,17 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditSession } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import type { OpenDrilldownView } from 'pages/DashboardPage/DashboardContainer/Panels/types/drilldown';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
@@ -18,15 +23,16 @@ import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { EQueryType } from 'types/common/dashboard';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
time: PanelQueryTimeOverride;
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
}
export interface UseViewPanelModeReturn {
@@ -35,7 +41,7 @@ export interface UseViewPanelModeReturn {
/** Update the draft's spec in place (e.g. the List columns editor). */
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
panelDefinition: RenderableQueryPanelDefinition;
panelDefinition: RenderablePanelDefinition;
/**
* Builder datasource driving the query builder and the panel-type selector's
* disabled rule. Resolved from the query, falling back to the kind's default
@@ -48,6 +54,8 @@ export interface UseViewPanelModeReturn {
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
runQuery: () => void;
/** Switch the draft's visualization kind (temporary; reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the query the view opened with, discarding in-modal edits. */
resetQuery: () => void;
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
@@ -69,20 +77,51 @@ export function useViewPanelMode({
panel,
panelId,
time,
draftApi,
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() =>
compositeQuery
? {
...panel,
spec: buildViewPanelSpec({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
}),
}
: { ...panel, spec: baseSpec },
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);
const {
draft,
panelDefinition,
defaultSignal,
query,
runQuery,
onChangePanelKind,
buildSaveSpec,
reset,
setSpec,
} = usePanelEditSession({ panel, panelId, time, draftApi });
} = usePanelEditSession({ panel: initialPanel, panelId, time });
// The query the view opened with, captured once — the Reset target.
const savedQuery = useMemo(
@@ -139,6 +178,7 @@ export function useViewPanelMode({
queryType: currentQuery.queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,

View File

@@ -1,115 +0,0 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import Panel from '../Panel';
// Real registry by default; the static cases override per render.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
});
jest.mock('pages/DashboardPage/DashboardContainer/hooks/usePanelQuery', () => ({
usePanelQuery: jest.fn(),
}));
// Chrome + query-path collaborators stubbed: this file tests the mode fork, not them.
jest.mock('../PanelHeader/PanelHeader', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="panel-header" />,
}));
jest.mock('../PanelBody/PanelBody', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="query-panel-body" />,
}));
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
onPanelClick: jest.fn(),
enableDrillDown: false,
contextMenuProps: {},
}),
}));
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: undefined,
}),
}));
jest.mock('periscope/components/ContextMenu', () => ({
__esModule: true,
default: (): null => null,
}));
const mockUsePanelQuery = usePanelQuery as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
const panel = {
kind: 'Panel',
spec: {
display: { name: 'P' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
function StaticRenderer(props: { panelMode: string }): JSX.Element {
return <div data-testid="fake-static-renderer" data-mode={props.panelMode} />;
}
const staticDefinition = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Static',
sections: [],
actions: { search: false },
mode: 'static',
Renderer: StaticRenderer,
EditorPane: StaticRenderer,
};
describe('Panel — authoring-mode fork', () => {
beforeEach(() => {
mockUsePanelQuery.mockReset();
mockUsePanelQuery.mockReturnValue({
data: EMPTY_PANEL_QUERY_DATA,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
pagination: undefined,
});
});
it('mounts the query body and fetch for a query kind', () => {
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('query-panel-body')).toBeInTheDocument();
expect(mockUsePanelQuery).toHaveBeenCalledTimes(1);
});
it('mounts the static renderer for a static kind with no query hook at all', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('static-panel-body')).toBeInTheDocument();
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('query-panel-body')).not.toBeInTheDocument();
expect(mockUsePanelQuery).not.toHaveBeenCalled();
});
it('renders the static body in dashboard-view mode with panel chrome', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('fake-static-renderer')).toHaveAttribute(
'data-mode',
'DASHBOARD_VIEW',
);
expect(screen.getByTestId('panel-header')).toBeInTheDocument();
});
});

View File

@@ -1,11 +1,10 @@
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { fireEvent, render, screen } from '@testing-library/react';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { ReactElement } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import ViewPanelModal from '../ViewPanelModal/ViewPanelModal';
// The preview reuses the edit page's PreviewPane (chart + header + heavy render
@@ -22,30 +21,6 @@ jest.mock(
);
// Isolate from the draft/query-builder plumbing (its own suite covers it).
// Real registry by default; the static-fork test overrides one kind.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
});
// The shell reads the URL seed + kind switch itself; stub its collaborators so
// the suite needs no router and keeps asserting through the mocked mode hook.
jest.mock('hooks/queryBuilder/useGetCompositeQueryParam', () => ({
useGetCompositeQueryParam: (): null => null,
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: (): URLSearchParams => new URLSearchParams(),
}));
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch',
() => ({
usePanelTypeSwitch: (): unknown => ({ onChangePanelKind: jest.fn() }),
}),
);
jest.mock('../ViewPanelModal/useViewPanelMode', () => ({
useViewPanelMode: (args: {
panel: { spec: { plugin: { kind: string } } };
@@ -55,14 +30,8 @@ jest.mock('../ViewPanelModal/useViewPanelMode', () => ({
draft: args.panel,
panelDefinition: {
kind,
mode: 'query',
actions: { search: kind === 'signoz/ListPanel' },
Renderer: (): null => null,
// Same testid as the real pane: the suite asserts the modal fills its
// query-builder slot from the definition.
EditorPane: (): JSX.Element => (
<div data-testid="panel-editor-v2-query-builder" />
),
},
query: {
data: { response: undefined, requestPayload: undefined, legendMap: {} },
@@ -192,7 +161,8 @@ describe('ViewPanelModal', () => {
expect(screen.getByTestId('preview-pane')).toBeInTheDocument();
});
it('invokes onClose when the modal is dismissed', () => {
it('invokes onClose when the modal is dismissed', async () => {
const user = userEvent.setup();
const onClose = jest.fn();
renderWithProvider(
<ViewPanelModal
@@ -202,53 +172,10 @@ describe('ViewPanelModal', () => {
onClose={onClose}
/>,
);
// fireEvent: user-event's pointer walk races Radix's layer bookkeeping in
// jsdom, transiently dropping the dialog's pointer-events and failing the
// interaction check. The assertion is only that Close wires to onClose.
fireEvent.click(screen.getByLabelText('Close'));
await user.click(screen.getByLabelText('Close'));
expect(onClose).toHaveBeenCalled();
});
it('mounts the static body — editor pane, no query builder slot — for a static kind', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
const staticDefinition = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Static',
sections: [],
actions: {},
mode: 'static',
Renderer: (): JSX.Element => <div data-testid="fake-static-renderer" />,
EditorPane: (): JSX.Element => <div data-testid="static-editor-pane" />,
};
(getPanelDefinition as jest.Mock).mockImplementation((kind: string) =>
kind === 'signoz/TimeSeriesPanel'
? staticDefinition
: actual.getPanelDefinition(kind),
);
renderWithProvider(
<ViewPanelModal
panel={makePanel('signoz/TimeSeriesPanel')}
panelId="p1"
open
onClose={jest.fn()}
/>,
);
expect(screen.getByTestId('static-editor-pane')).toBeInTheDocument();
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-query-builder'),
).not.toBeInTheDocument();
expect(mockPreviewPaneRender).not.toHaveBeenCalled();
(getPanelDefinition as jest.Mock).mockImplementation(
actual.getPanelDefinition,
);
});
// Charts share one global cursor-sync key and uPlot replays drag across the
// group; the modal must opt out so a drag here can't move the dashboard's time.
it('opts the chart out of the dashboard cursor-sync group', () => {

View File

@@ -108,7 +108,6 @@ jest.mock(
// importing the whole renderer registry into the test.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: (kind: string): unknown => ({
mode: 'query',
actions: { drilldown: kind !== 'signoz/ListPanel' },
}),
}));

View File

@@ -10,8 +10,6 @@ import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/perse
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
import { useViewPanelMode } from '../ViewPanelModal/useViewPanelMode';
import { useViewPanel } from '../hooks/useViewPanel';
@@ -91,13 +89,10 @@ const stagedIds: (string | undefined)[] = [];
function ModalBody({ panelId }: { panelId: string }): JSX.Element {
const { currentQuery } = useQueryBuilder();
// The modal shell owns the draft in production; mirrored here.
const draftApi = usePanelEditorDraft(PANELS[panelId]);
const { draft } = useViewPanelMode({
panel: PANELS[panelId],
panelId,
time: { startMs: 0, endMs: 1000 },
draftApi,
});
renders.push({
current: panelOf(JSON.stringify(currentQuery)),

View File

@@ -15,7 +15,7 @@ import { buildQueryRangeRequest } from 'pages/DashboardPage/DashboardContainer/q
import { envelopesToQuery } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
@@ -70,8 +70,7 @@ export function useCreateAlertFromPanel(): (
// Redux global time is nanoseconds; the request DTO takes epoch ms.
const request = buildQueryRangeRequest({
queries: panel.spec.queries,
// Reached only through the menu item `actions.createAlert` gates.
queryCapabilities: requireQueryPanelDefinition(panelKind).queryCapabilities,
queryCapabilities: getPanelDefinition(panelKind).queryCapabilities,
startMs: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
endMs: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
variables,

View File

@@ -14,7 +14,6 @@ import type {
OpenDrilldownView,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/drilldown';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { buildAggregateData } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildAggregateData';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
@@ -181,7 +180,7 @@ export function useDrilldown(
const { resolvedQuery, isResolving } = useResolvedDrilldownQuery({
queries,
panelKind: kind,
queryCapabilities: requireQueryPanelDefinition(kind).queryCapabilities,
queryCapabilities: getPanelDefinition(kind).queryCapabilities,
v1Query,
enabled: showAggregateMenu,
});

View File

@@ -52,18 +52,15 @@ export function useViewPanel(): UseViewPanelApi {
// Only a drilldown retargets the panel type.
next.delete(QueryParams.graphType);
clearViewPanelHandoff();
// `null` for a static kind — no query state to stage or persist.
const query = getPanelBuilderQuery(panel);
if (query) {
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
// The provider applies the URL in an effect, a tick after the builder's fields have
// mounted and read the query they keep. `resetQuery` — not `initQueryBuilderData`:
// swapping one staged id for another re-anchors global time and refetches the grid.
resetQuery(query);
}
next.set(
QueryParams.compositeQuery,
encodeURIComponent(JSON.stringify(query)),
);
// The provider applies the URL in an effect, a tick after the builder's fields have
// mounted and read the query they keep. `resetQuery` — not `initQueryBuilderData`:
// swapping one staged id for another re-anchors global time and refetches the grid.
resetQuery(query);
void logEvent(DashboardDetailEvents.PanelViewed, { panelId });
safeNavigate(`${pathname}?${next.toString()}`);
},

View File

@@ -46,9 +46,8 @@ export function useOpenPanelEditor(): (
new URLSearchParams(timeSearch).forEach((value, key) => {
params.set(key, value);
});
// `null` for a static kind — no query state to stage or persist.
const query = options?.panel && getPanelBuilderQuery(options.panel);
if (query) {
if (options?.panel) {
const query = getPanelBuilderQuery(options.panel);
// Single-encoded: `useGetCompositeQueryParam` decodes once on top of the decode
// `URLSearchParams` already does.
params.set(

View File

@@ -17,16 +17,6 @@ export interface PanelQueryData {
legendMap: Record<string, string>;
}
/**
* The no-fetch value of `PanelQueryData`, for chrome shared with static panels —
* the header renders status/download surfaces off it and shows neither.
*/
export const EMPTY_PANEL_QUERY_DATA: PanelQueryData = {
response: undefined,
requestPayload: undefined,
legendMap: {},
};
/** One data point. `timestamp` is epoch milliseconds (V5 wire native). */
export interface PanelSeriesPoint {
timestamp: number;

View File

@@ -4,11 +4,9 @@ import { noop } from 'lodash-es';
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { usePublicPanelQuery } from '../hooks/usePublicPanelQuery';
import StaticPublicPanel from './StaticPublicPanel';
import styles from './PublicPanel.module.scss';
interface PublicPanelProps {
@@ -28,40 +26,17 @@ const PUBLIC_DASHBOARD_PREFERENCE: DashboardPreference = {
syncMode: DashboardCursorSync.None,
};
/**
* Read-only v2 public panel. Forks on the kind's mode before any query machinery
* exists; the static arm renders nothing until a static kind registers.
*/
function PublicPanel(props: PublicPanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(props.panel.spec.plugin.kind);
if (panelDefinition.mode === 'static') {
return (
<StaticPublicPanel
panel={props.panel}
panelKey={props.panelKey}
panelDefinition={panelDefinition}
/>
);
}
return <QueryPublicPanel {...props} panelDefinition={panelDefinition} />;
}
interface QueryPublicPanelProps extends PublicPanelProps {
panelDefinition: RenderableQueryPanelDefinition;
}
// Reuses the V2 header/body renderers with interactions disabled.
function QueryPublicPanel({
// Read-only v2 public panel: reuses the V2 header/body renderers with interactions disabled.
function PublicPanel({
panel,
panelKey,
publicDashboardId,
startMs,
endMs,
isVisible,
panelDefinition,
}: QueryPublicPanelProps): JSX.Element {
}: PublicPanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
const { data, isFetching, isPreviousData, error, refetch } =
usePublicPanelQuery({
panel,
@@ -85,7 +60,7 @@ function QueryPublicPanel({
hideActions
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panelDefinition={panelDefinition}
panel={panel}
panelId={panelKey}
data={data}

View File

@@ -1,45 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import styles from './PublicPanel.module.scss';
interface StaticPublicPanelProps {
panel: DashboardtypesPanelDTO;
panelKey: string;
panelDefinition: RenderableStaticPanelDefinition;
}
/**
* Read-only public rendering of a kind that renders from its own plugin spec. The
* body is authored content meant to be read, so it is not redacted — and there is
* no query to issue. Public dashboards carry no variable runtime, so variable
* tokens render literally, as an undefined variable does anywhere else.
*/
function StaticPublicPanel({
panel,
panelKey,
panelDefinition,
}: StaticPublicPanelProps): JSX.Element {
return (
<div className={styles.panel} data-panel-root={panelKey}>
<PanelHeader
panelId={panelKey}
panel={panel}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelKey}
/>
</div>
);
}
export default StaticPublicPanel;

View File

@@ -1,8 +1,6 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { usePublicPanelQuery } from '../../hooks/usePublicPanelQuery';
import PublicPanel from '../PublicPanel';
@@ -10,14 +8,6 @@ jest.mock('../../hooks/usePublicPanelQuery', () => ({
usePublicPanelQuery: jest.fn(),
}));
// Real registry by default; individual tests override to a static definition.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
});
// Stub the reused V2 renderers so the test targets PublicPanel's own wiring, not uPlot/timezone.
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
@@ -107,30 +97,6 @@ describe('PublicPanel', () => {
);
});
it('renders a static kind with no fetch at all', () => {
const StaticRenderer = (): JSX.Element => (
<div data-testid="fake-static-renderer" />
);
(getPanelDefinition as jest.Mock).mockReturnValueOnce({
kind: 'signoz/TimeSeriesPanel',
displayName: 'Static',
sections: [],
actions: {},
mode: 'static',
Renderer: StaticRenderer,
EditorPane: StaticRenderer,
});
render(<PublicPanel panel={timeseriesPanel} {...commonProps} />);
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(screen.getByTestId('panel-header')).toHaveAttribute(
'data-hide-actions',
'true',
);
expect(mockQuery).not.toHaveBeenCalled();
});
it('gates the fetch when off screen', () => {
render(
<PublicPanel panel={timeseriesPanel} {...commonProps} isVisible={false} />,

View File

@@ -37,9 +37,6 @@ type Alertmanager interface {
// ListAllChannels lists all channels for all organizations. It is used by the legacy alertmanager only.
ListAllChannels(context.Context) ([]*alertmanagertypes.Channel, error)
// Nil params lists every channel, unordered and unpaged.
ListNotificationChannels(context.Context, string, *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error)
// GetChannelByID gets a channel for the organization.
GetChannelByID(context.Context, string, valuer.UUID) (*alertmanagertypes.Channel, error)
@@ -51,12 +48,7 @@ type Alertmanager interface {
// CreateNotificationChannel takes the postable rather than a receiver, because
// a receiver carries only the display name.
CreateNotificationChannel(context.Context, string, alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)
UpdateNotificationChannel(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error)
// TestNotificationChannel sends a test notification for a channel that need not exist.
TestNotificationChannel(context.Context, string, alertmanagertypes.TestableNotificationChannel) error
CreateNotificationChannel(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)
// DeleteChannelByID deletes a channel for the organization.
DeleteChannelByID(context.Context, string, valuer.UUID) error

View File

@@ -3,7 +3,6 @@ package sqlalertmanagerstore
import (
"context"
"database/sql"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
@@ -139,88 +138,21 @@ func (store *config) DeleteChannelByID(ctx context.Context, orgID string, id val
}, opts...)
}
func (store *config) ListChannels(ctx context.Context, orgID string, params *alertmanagertypes.ListChannelsParams) ([]*alertmanagertypes.Channel, int64, error) {
// Nil params means no pagination, so every match is on the only page.
if params == nil {
var channels []*alertmanagertypes.Channel
func (store *config) ListChannels(ctx context.Context, orgID string) ([]*alertmanagertypes.Channel, error) {
var channels []*alertmanagertypes.Channel
if err := store.
sqlstore.
BunDB().
NewSelect().
Model(&channels).
Where("org_id = ?", orgID).
Scan(ctx); err != nil {
return nil, 0, err
}
return channels, int64(len(channels)), nil
}
type listedChannel struct {
*alertmanagertypes.Channel `bun:",extend"`
Total int64 `bun:"total"`
}
rows := make([]*listedChannel, 0)
q := store.
err := store.
sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("*").
ColumnExpr("COUNT(*) OVER () AS total").
Where("org_id = ?", orgID)
formatter := store.sqlstore.Formatter()
if params.Query != "" {
// LOWER(col) LIKE LOWER(?) behaves the same on SQLite (no ILIKE) and
// Postgres. The value's % and _ are escaped to match literally, with
// ESCAPE pinning backslash as the escape character.
q = q.Where(
string(formatter.LowerExpression("display_name"))+" LIKE LOWER(?) ESCAPE '\\'",
"%"+formatter.EscapeLikePattern(params.Query)+"%",
)
Model(&channels).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, err
}
if !params.Kind.IsZero() {
q = q.Where("type = ?", params.Kind.ToStoredType())
}
q = q.
OrderExpr(params.Sort.ToColumn() + " " + strings.ToUpper(params.Order.StringValue())).
Limit(params.Limit).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, 0, err
}
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the full
// filter total.
var total int64
if len(rows) > 0 {
total = rows[0].Total
} else if params.Offset > 0 {
// An empty page carries no total. At offset zero that means nothing
// matched, but past the last match it hides the rows the offset skipped.
matches, err := q.Count(ctx)
if err != nil {
return nil, 0, err
}
total = int64(matches)
}
channels := make([]*alertmanagertypes.Channel, len(rows))
for index, row := range rows {
channels[index] = row.Channel
}
return channels, total, nil
return channels, nil
}
func (store *config) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.Channel, error) {

View File

@@ -292,7 +292,7 @@ func (_c *MockAlertmanager_CreateInhibitRules_Call) RunAndReturn(run func(ctx co
}
// CreateNotificationChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Context, s string, postableNotificationChannel alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, postableNotificationChannel)
if len(ret) == 0 {
@@ -301,17 +301,17 @@ func (_mock *MockAlertmanager) CreateNotificationChannel(context1 context.Contex
var r0 *alertmanagertypes.Channel
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
return returnFunc(context1, s, postableNotificationChannel)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.PostableNotificationChannel) *alertmanagertypes.Channel); ok {
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) *alertmanagertypes.Channel); ok {
r0 = returnFunc(context1, s, postableNotificationChannel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.Channel)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, alertmanagertypes.PostableNotificationChannel) error); ok {
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.PostableNotificationChannel) error); ok {
r1 = returnFunc(context1, s, postableNotificationChannel)
} else {
r1 = ret.Error(1)
@@ -327,12 +327,12 @@ type MockAlertmanager_CreateNotificationChannel_Call struct {
// CreateNotificationChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - postableNotificationChannel alertmanagertypes.PostableNotificationChannel
// - postableNotificationChannel *alertmanagertypes.PostableNotificationChannel
func (_e *MockAlertmanager_Expecter) CreateNotificationChannel(context1 interface{}, s interface{}, postableNotificationChannel interface{}) *MockAlertmanager_CreateNotificationChannel_Call {
return &MockAlertmanager_CreateNotificationChannel_Call{Call: _e.mock.On("CreateNotificationChannel", context1, s, postableNotificationChannel)}
}
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1 context.Context, s string, postableNotificationChannel alertmanagertypes.PostableNotificationChannel)) *MockAlertmanager_CreateNotificationChannel_Call {
func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel)) *MockAlertmanager_CreateNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
@@ -342,9 +342,9 @@ func (_c *MockAlertmanager_CreateNotificationChannel_Call) Run(run func(context1
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.PostableNotificationChannel
var arg2 *alertmanagertypes.PostableNotificationChannel
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.PostableNotificationChannel)
arg2 = args[2].(*alertmanagertypes.PostableNotificationChannel)
}
run(
arg0,
@@ -360,7 +360,7 @@ func (_c *MockAlertmanager_CreateNotificationChannel_Call) Return(channel *alert
return _c
}
func (_c *MockAlertmanager_CreateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, postableNotificationChannel alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateNotificationChannel_Call {
func (_c *MockAlertmanager_CreateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, postableNotificationChannel *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_CreateNotificationChannel_Call {
_c.Call.Return(run)
return _c
}
@@ -1280,80 +1280,6 @@ func (_c *MockAlertmanager_ListChannels_Call) RunAndReturn(run func(context1 con
return _c
}
// ListNotificationChannels provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) ListNotificationChannels(context1 context.Context, s string, listChannelsParams *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error) {
ret := _mock.Called(context1, s, listChannelsParams)
if len(ret) == 0 {
panic("no return value specified for ListNotificationChannels")
}
var r0 *alertmanagertypes.ListableNotificationChannel
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error)); ok {
return returnFunc(context1, s, listChannelsParams)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *alertmanagertypes.ListChannelsParams) *alertmanagertypes.ListableNotificationChannel); ok {
r0 = returnFunc(context1, s, listChannelsParams)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.ListableNotificationChannel)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *alertmanagertypes.ListChannelsParams) error); ok {
r1 = returnFunc(context1, s, listChannelsParams)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAlertmanager_ListNotificationChannels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNotificationChannels'
type MockAlertmanager_ListNotificationChannels_Call struct {
*mock.Call
}
// ListNotificationChannels is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - listChannelsParams *alertmanagertypes.ListChannelsParams
func (_e *MockAlertmanager_Expecter) ListNotificationChannels(context1 interface{}, s interface{}, listChannelsParams interface{}) *MockAlertmanager_ListNotificationChannels_Call {
return &MockAlertmanager_ListNotificationChannels_Call{Call: _e.mock.On("ListNotificationChannels", context1, s, listChannelsParams)}
}
func (_c *MockAlertmanager_ListNotificationChannels_Call) Run(run func(context1 context.Context, s string, listChannelsParams *alertmanagertypes.ListChannelsParams)) *MockAlertmanager_ListNotificationChannels_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 *alertmanagertypes.ListChannelsParams
if args[2] != nil {
arg2 = args[2].(*alertmanagertypes.ListChannelsParams)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *MockAlertmanager_ListNotificationChannels_Call) Return(listableNotificationChannel *alertmanagertypes.ListableNotificationChannel, err error) *MockAlertmanager_ListNotificationChannels_Call {
_c.Call.Return(listableNotificationChannel, err)
return _c
}
func (_c *MockAlertmanager_ListNotificationChannels_Call) RunAndReturn(run func(context1 context.Context, s string, listChannelsParams *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error)) *MockAlertmanager_ListNotificationChannels_Call {
_c.Call.Return(run)
return _c
}
// PutAlerts provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) PutAlerts(context1 context.Context, s string, v alertmanagertypes.PostableAlerts) error {
ret := _mock.Called(context1, s, v)
@@ -1771,69 +1697,6 @@ func (_c *MockAlertmanager_TestAlert_Call) RunAndReturn(run func(ctx context.Con
return _c
}
// TestNotificationChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) TestNotificationChannel(context1 context.Context, s string, testableNotificationChannel alertmanagertypes.TestableNotificationChannel) error {
ret := _mock.Called(context1, s, testableNotificationChannel)
if len(ret) == 0 {
panic("no return value specified for TestNotificationChannel")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, alertmanagertypes.TestableNotificationChannel) error); ok {
r0 = returnFunc(context1, s, testableNotificationChannel)
} else {
r0 = ret.Error(0)
}
return r0
}
// MockAlertmanager_TestNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TestNotificationChannel'
type MockAlertmanager_TestNotificationChannel_Call struct {
*mock.Call
}
// TestNotificationChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - testableNotificationChannel alertmanagertypes.TestableNotificationChannel
func (_e *MockAlertmanager_Expecter) TestNotificationChannel(context1 interface{}, s interface{}, testableNotificationChannel interface{}) *MockAlertmanager_TestNotificationChannel_Call {
return &MockAlertmanager_TestNotificationChannel_Call{Call: _e.mock.On("TestNotificationChannel", context1, s, testableNotificationChannel)}
}
func (_c *MockAlertmanager_TestNotificationChannel_Call) Run(run func(context1 context.Context, s string, testableNotificationChannel alertmanagertypes.TestableNotificationChannel)) *MockAlertmanager_TestNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 alertmanagertypes.TestableNotificationChannel
if args[2] != nil {
arg2 = args[2].(alertmanagertypes.TestableNotificationChannel)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *MockAlertmanager_TestNotificationChannel_Call) Return(err error) *MockAlertmanager_TestNotificationChannel_Call {
_c.Call.Return(err)
return _c
}
func (_c *MockAlertmanager_TestNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, testableNotificationChannel alertmanagertypes.TestableNotificationChannel) error) *MockAlertmanager_TestNotificationChannel_Call {
_c.Call.Return(run)
return _c
}
// TestReceiver provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) TestReceiver(context1 context.Context, s string, receiver *alertmanagertypes.Receiver) error {
ret := _mock.Called(context1, s, receiver)
@@ -2029,86 +1892,6 @@ func (_c *MockAlertmanager_UpdateChannelByReceiverAndID_Call) RunAndReturn(run f
return _c
}
// UpdateNotificationChannel provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) UpdateNotificationChannel(context1 context.Context, s string, uUID valuer.UUID, updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error) {
ret := _mock.Called(context1, s, uUID, updatableNotificationChannel)
if len(ret) == 0 {
panic("no return value specified for UpdateNotificationChannel")
}
var r0 *alertmanagertypes.Channel
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error)); ok {
return returnFunc(context1, s, uUID, updatableNotificationChannel)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) *alertmanagertypes.Channel); ok {
r0 = returnFunc(context1, s, uUID, updatableNotificationChannel)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*alertmanagertypes.Channel)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, valuer.UUID, alertmanagertypes.UpdatableNotificationChannel) error); ok {
r1 = returnFunc(context1, s, uUID, updatableNotificationChannel)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockAlertmanager_UpdateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateNotificationChannel'
type MockAlertmanager_UpdateNotificationChannel_Call struct {
*mock.Call
}
// UpdateNotificationChannel is a helper method to define mock.On call
// - context1 context.Context
// - s string
// - uUID valuer.UUID
// - updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel
func (_e *MockAlertmanager_Expecter) UpdateNotificationChannel(context1 interface{}, s interface{}, uUID interface{}, updatableNotificationChannel interface{}) *MockAlertmanager_UpdateNotificationChannel_Call {
return &MockAlertmanager_UpdateNotificationChannel_Call{Call: _e.mock.On("UpdateNotificationChannel", context1, s, uUID, updatableNotificationChannel)}
}
func (_c *MockAlertmanager_UpdateNotificationChannel_Call) Run(run func(context1 context.Context, s string, uUID valuer.UUID, updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel)) *MockAlertmanager_UpdateNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 valuer.UUID
if args[2] != nil {
arg2 = args[2].(valuer.UUID)
}
var arg3 alertmanagertypes.UpdatableNotificationChannel
if args[3] != nil {
arg3 = args[3].(alertmanagertypes.UpdatableNotificationChannel)
}
run(
arg0,
arg1,
arg2,
arg3,
)
})
return _c
}
func (_c *MockAlertmanager_UpdateNotificationChannel_Call) Return(channel *alertmanagertypes.Channel, err error) *MockAlertmanager_UpdateNotificationChannel_Call {
_c.Call.Return(channel, err)
return _c
}
func (_c *MockAlertmanager_UpdateNotificationChannel_Call) RunAndReturn(run func(context1 context.Context, s string, uUID valuer.UUID, updatableNotificationChannel alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error)) *MockAlertmanager_UpdateNotificationChannel_Call {
_c.Call.Return(run)
return _c
}
// UpdateRoutePolicyByID provides a mock function for the type MockAlertmanager
func (_mock *MockAlertmanager) UpdateRoutePolicyByID(ctx context.Context, routeID string, route *alertmanagertypes.PostableRoutePolicy) (*alertmanagertypes.GettableRoutePolicy, error) {
ret := _mock.Called(ctx, routeID, route)
@@ -2394,52 +2177,6 @@ func (_c *MockHandler_DeleteChannelByID_Call) RunAndReturn(run func(responseWrit
return _c
}
// DeleteNotificationChannel provides a mock function for the type MockHandler
func (_mock *MockHandler) DeleteNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
return
}
// MockHandler_DeleteNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DeleteNotificationChannel'
type MockHandler_DeleteNotificationChannel_Call struct {
*mock.Call
}
// DeleteNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) DeleteNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_DeleteNotificationChannel_Call {
return &MockHandler_DeleteNotificationChannel_Call{Call: _e.mock.On("DeleteNotificationChannel", responseWriter, request)}
}
func (_c *MockHandler_DeleteNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_DeleteNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 http.ResponseWriter
if args[0] != nil {
arg0 = args[0].(http.ResponseWriter)
}
var arg1 *http.Request
if args[1] != nil {
arg1 = args[1].(*http.Request)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockHandler_DeleteNotificationChannel_Call) Return() *MockHandler_DeleteNotificationChannel_Call {
_c.Call.Return()
return _c
}
func (_c *MockHandler_DeleteNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_DeleteNotificationChannel_Call {
_c.Run(run)
return _c
}
// DeleteRoutePolicyByID provides a mock function for the type MockHandler
func (_mock *MockHandler) DeleteRoutePolicyByID(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
@@ -2624,52 +2361,6 @@ func (_c *MockHandler_GetChannelByID_Call) RunAndReturn(run func(responseWriter
return _c
}
// GetNotificationChannel provides a mock function for the type MockHandler
func (_mock *MockHandler) GetNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
return
}
// MockHandler_GetNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetNotificationChannel'
type MockHandler_GetNotificationChannel_Call struct {
*mock.Call
}
// GetNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) GetNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_GetNotificationChannel_Call {
return &MockHandler_GetNotificationChannel_Call{Call: _e.mock.On("GetNotificationChannel", responseWriter, request)}
}
func (_c *MockHandler_GetNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_GetNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 http.ResponseWriter
if args[0] != nil {
arg0 = args[0].(http.ResponseWriter)
}
var arg1 *http.Request
if args[1] != nil {
arg1 = args[1].(*http.Request)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockHandler_GetNotificationChannel_Call) Return() *MockHandler_GetNotificationChannel_Call {
_c.Call.Return()
return _c
}
func (_c *MockHandler_GetNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_GetNotificationChannel_Call {
_c.Run(run)
return _c
}
// GetRoutePolicyByID provides a mock function for the type MockHandler
func (_mock *MockHandler) GetRoutePolicyByID(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
@@ -2808,98 +2499,6 @@ func (_c *MockHandler_ListChannels_Call) RunAndReturn(run func(responseWriter ht
return _c
}
// ListNotificationChannels provides a mock function for the type MockHandler
func (_mock *MockHandler) ListNotificationChannels(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
return
}
// MockHandler_ListNotificationChannels_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ListNotificationChannels'
type MockHandler_ListNotificationChannels_Call struct {
*mock.Call
}
// ListNotificationChannels is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) ListNotificationChannels(responseWriter interface{}, request interface{}) *MockHandler_ListNotificationChannels_Call {
return &MockHandler_ListNotificationChannels_Call{Call: _e.mock.On("ListNotificationChannels", responseWriter, request)}
}
func (_c *MockHandler_ListNotificationChannels_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_ListNotificationChannels_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 http.ResponseWriter
if args[0] != nil {
arg0 = args[0].(http.ResponseWriter)
}
var arg1 *http.Request
if args[1] != nil {
arg1 = args[1].(*http.Request)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockHandler_ListNotificationChannels_Call) Return() *MockHandler_ListNotificationChannels_Call {
_c.Call.Return()
return _c
}
func (_c *MockHandler_ListNotificationChannels_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_ListNotificationChannels_Call {
_c.Run(run)
return _c
}
// TestNotificationChannel provides a mock function for the type MockHandler
func (_mock *MockHandler) TestNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
return
}
// MockHandler_TestNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'TestNotificationChannel'
type MockHandler_TestNotificationChannel_Call struct {
*mock.Call
}
// TestNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) TestNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_TestNotificationChannel_Call {
return &MockHandler_TestNotificationChannel_Call{Call: _e.mock.On("TestNotificationChannel", responseWriter, request)}
}
func (_c *MockHandler_TestNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_TestNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 http.ResponseWriter
if args[0] != nil {
arg0 = args[0].(http.ResponseWriter)
}
var arg1 *http.Request
if args[1] != nil {
arg1 = args[1].(*http.Request)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockHandler_TestNotificationChannel_Call) Return() *MockHandler_TestNotificationChannel_Call {
_c.Call.Return()
return _c
}
func (_c *MockHandler_TestNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_TestNotificationChannel_Call {
_c.Run(run)
return _c
}
// TestReceiver provides a mock function for the type MockHandler
func (_mock *MockHandler) TestReceiver(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
@@ -2992,52 +2591,6 @@ func (_c *MockHandler_UpdateChannelByID_Call) RunAndReturn(run func(responseWrit
return _c
}
// UpdateNotificationChannel provides a mock function for the type MockHandler
func (_mock *MockHandler) UpdateNotificationChannel(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)
return
}
// MockHandler_UpdateNotificationChannel_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'UpdateNotificationChannel'
type MockHandler_UpdateNotificationChannel_Call struct {
*mock.Call
}
// UpdateNotificationChannel is a helper method to define mock.On call
// - responseWriter http.ResponseWriter
// - request *http.Request
func (_e *MockHandler_Expecter) UpdateNotificationChannel(responseWriter interface{}, request interface{}) *MockHandler_UpdateNotificationChannel_Call {
return &MockHandler_UpdateNotificationChannel_Call{Call: _e.mock.On("UpdateNotificationChannel", responseWriter, request)}
}
func (_c *MockHandler_UpdateNotificationChannel_Call) Run(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_UpdateNotificationChannel_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 http.ResponseWriter
if args[0] != nil {
arg0 = args[0].(http.ResponseWriter)
}
var arg1 *http.Request
if args[1] != nil {
arg1 = args[1].(*http.Request)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockHandler_UpdateNotificationChannel_Call) Return() *MockHandler_UpdateNotificationChannel_Call {
_c.Call.Return()
return _c
}
func (_c *MockHandler_UpdateNotificationChannel_Call) RunAndReturn(run func(responseWriter http.ResponseWriter, request *http.Request)) *MockHandler_UpdateNotificationChannel_Call {
_c.Run(run)
return _c
}
// UpdateRoutePolicy provides a mock function for the type MockHandler
func (_mock *MockHandler) UpdateRoutePolicy(responseWriter http.ResponseWriter, request *http.Request) {
_mock.Called(responseWriter, request)

View File

@@ -19,18 +19,8 @@ type Handler interface {
DeleteChannelByID(http.ResponseWriter, *http.Request)
ListNotificationChannels(http.ResponseWriter, *http.Request)
GetNotificationChannel(http.ResponseWriter, *http.Request)
CreateNotificationChannel(http.ResponseWriter, *http.Request)
UpdateNotificationChannel(http.ResponseWriter, *http.Request)
DeleteNotificationChannel(http.ResponseWriter, *http.Request)
TestNotificationChannel(http.ResponseWriter, *http.Request)
GetAllRoutePolicies(http.ResponseWriter, *http.Request)
GetRoutePolicyByID(http.ResponseWriter, *http.Request)

View File

@@ -240,7 +240,7 @@ func (service *Service) getConfig(ctx context.Context, orgID string) (*alertmana
}
func (service *Service) compareAndSelectConfig(ctx context.Context, incomingConfig *alertmanagertypes.Config) (*alertmanagertypes.Config, error) {
channels, _, err := service.configStore.ListChannels(ctx, incomingConfig.StoreableConfig().OrgID, nil)
channels, err := service.configStore.ListChannels(ctx, incomingConfig.StoreableConfig().OrgID)
if err != nil {
return nil, err
}

View File

@@ -5,13 +5,10 @@ import (
"net/http"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/binding"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/alertmanagertypes"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *http.Request) {
@@ -24,8 +21,8 @@ func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *h
return
}
postable := alertmanagertypes.PostableNotificationChannel{}
if err := binding.JSON.BindBody(req.Body, &postable); err != nil {
postable := new(alertmanagertypes.PostableNotificationChannel)
if err := binding.JSON.BindBody(req.Body, postable); err != nil {
render.Error(rw, err)
return
}
@@ -44,150 +41,3 @@ func (handler *handler) CreateNotificationChannel(rw http.ResponseWriter, req *h
render.Success(rw, http.StatusCreated, gettable)
}
func (handler *handler) ListNotificationChannels(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
params := new(alertmanagertypes.ListChannelsParams)
if err := binding.Query.BindQuery(req.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
// todo(@namanv3): use valuer.MustNewUUID(claims.OrgID) from here till the store method.
listed, err := handler.alertmanager.ListNotificationChannels(ctx, claims.OrgID, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, listed)
}
func (handler *handler) GetNotificationChannel(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
channel, err := handler.alertmanager.GetChannelByID(ctx, claims.OrgID, id)
if err != nil {
render.Error(rw, err)
return
}
gettable, err := channel.ToGettableNotificationChannel()
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, gettable)
}
func (handler *handler) UpdateNotificationChannel(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
updatable := alertmanagertypes.UpdatableNotificationChannel{}
if err := binding.JSON.BindBody(req.Body, &updatable); err != nil {
render.Error(rw, err)
return
}
channel, err := handler.alertmanager.UpdateNotificationChannel(ctx, claims.OrgID, id, updatable)
if err != nil {
render.Error(rw, err)
return
}
gettable, err := channel.ToGettableNotificationChannel()
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, gettable)
}
func (handler *handler) DeleteNotificationChannel(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
render.Error(rw, errors.NewInvalidInputf(errors.CodeInvalidInput, "id is not a valid uuid-v7"))
return
}
if err := handler.alertmanager.DeleteChannelByID(ctx, claims.OrgID, id); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) TestNotificationChannel(rw http.ResponseWriter, req *http.Request) {
ctx, cancel := context.WithTimeout(req.Context(), 30*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
testable := alertmanagertypes.TestableNotificationChannel{}
if err := binding.JSON.BindBody(req.Body, &testable); err != nil {
render.Error(rw, err)
return
}
if err := handler.alertmanager.TestNotificationChannel(ctx, claims.OrgID, testable); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -141,23 +141,7 @@ func (provider *provider) TestAlert(ctx context.Context, orgID string, ruleID st
}
func (provider *provider) ListChannels(ctx context.Context, orgID string) ([]*alertmanagertypes.Channel, error) {
channels, _, err := provider.configStore.ListChannels(ctx, orgID, nil)
return channels, err
}
func (provider *provider) ListNotificationChannels(ctx context.Context, orgID string, params *alertmanagertypes.ListChannelsParams) (*alertmanagertypes.ListableNotificationChannel, error) {
channels, total, err := provider.configStore.ListChannels(ctx, orgID, params)
if err != nil {
return nil, err
}
listed := make([]*alertmanagertypes.ListedNotificationChannel, 0, len(channels))
for _, channel := range channels {
listed = append(listed, channel.ToListedNotificationChannel())
}
return &alertmanagertypes.ListableNotificationChannel{Channels: listed, Total: total}, nil
return provider.configStore.ListChannels(ctx, orgID)
}
func (provider *provider) ListAllChannels(ctx context.Context) ([]*alertmanagertypes.Channel, error) {
@@ -260,7 +244,7 @@ func (provider *provider) CreateChannel(ctx context.Context, orgID string, recei
return channel, nil
}
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID string, postable *alertmanagertypes.PostableNotificationChannel) (*alertmanagertypes.Channel, error) {
receiver, err := postable.ToReceiver()
if err != nil {
return nil, err
@@ -294,55 +278,6 @@ func (provider *provider) CreateNotificationChannel(ctx context.Context, orgID s
return channel, nil
}
// UpdateNotificationChannel replaces the channel's configuration. The display
// name is not in the updatable body because it cannot change, so it is read off
// the stored channel and fed back into the receiver.
func (provider *provider) UpdateNotificationChannel(ctx context.Context, orgID string, id valuer.UUID, updatable alertmanagertypes.UpdatableNotificationChannel) (*alertmanagertypes.Channel, error) {
channel, err := provider.configStore.GetChannelByID(ctx, orgID, id)
if err != nil {
return nil, err
}
receiver, err := updatable.ToReceiver(channel.DisplayName)
if err != nil {
return nil, err
}
if err := channel.Update(receiver); err != nil {
return nil, err
}
config, err := provider.configStore.Get(ctx, orgID)
if err != nil {
return nil, err
}
if err := config.SetGlobalConfig(provider.config.Signoz.Global); err != nil {
return nil, err
}
if err := config.UpdateReceiver(receiver); err != nil {
return nil, err
}
if err := provider.configStore.UpdateChannel(ctx, orgID, channel, alertmanagertypes.WithCb(func(ctx context.Context) error {
return provider.configStore.Set(ctx, config)
})); err != nil {
return nil, err
}
return channel, nil
}
func (provider *provider) TestNotificationChannel(ctx context.Context, orgID string, testable alertmanagertypes.TestableNotificationChannel) error {
receiver, err := testable.ToReceiver()
if err != nil {
return err
}
return provider.service.TestReceiver(ctx, orgID, receiver)
}
func (provider *provider) Config() alertmanagerserver.Config {
return provider.config.Signoz.Config
}
@@ -365,7 +300,7 @@ func (provider *provider) SetDefaultConfig(ctx context.Context, orgID string) er
}
func (provider *provider) Collect(ctx context.Context, orgID valuer.UUID) (map[string]any, error) {
channels, _, err := provider.configStore.ListChannels(ctx, orgID.String(), nil)
channels, err := provider.configStore.ListChannels(ctx, orgID.String())
if err != nil {
return nil, err
}

View File

@@ -158,140 +158,6 @@ func (provider *provider) addAlertmanagerRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/notification_channels", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.ListNotificationChannels, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "ListNotificationChannels",
Tags: []string{"channels"},
Summary: "List notification channels",
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`).",
Request: nil,
RequestQuery: new(alertmanagertypes.ListChannelsParams),
RequestContentType: "",
Response: new(alertmanagertypes.ListableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbList)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel,
Verb: coretypes.VerbList,
Category: coretypes.ActionCategoryDataAccess,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/notification_channels/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.GetNotificationChannel, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName, authtypes.SigNozViewerRoleName),
handler.OpenAPIDef{
ID: "GetNotificationChannel",
Tags: []string{"channels"},
Summary: "Get notification channel by ID",
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.",
Request: nil,
RequestContentType: "",
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbRead)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel,
Verb: coretypes.VerbRead,
Category: coretypes.ActionCategoryDataAccess,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/notification_channels/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.UpdateNotificationChannel, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "UpdateNotificationChannel",
Tags: []string{"channels"},
Summary: "Update notification channel",
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.",
Request: new(alertmanagertypes.UpdatableNotificationChannel),
RequestContentType: "application/json",
Response: new(alertmanagertypes.GettableNotificationChannel),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbUpdate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel,
Verb: coretypes.VerbUpdate,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/notification_channels/{id}", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.DeleteNotificationChannel, authtypes.SigNozAdminRoleName),
handler.OpenAPIDef{
ID: "DeleteNotificationChannel",
Tags: []string{"channels"},
Summary: "Delete notification channel",
Description: "This endpoint deletes a notification channel by ID",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbDelete)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel,
Verb: coretypes.VerbDelete,
Category: coretypes.ActionCategoryConfigurationChange,
ID: coretypes.PathParam("id"),
Selector: coretypes.IDSelector,
}),
)).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/notification_channels/test", handler.New(
provider.authzMiddleware.CheckResources(provider.alertmanagerHandler.TestNotificationChannel, authtypes.SigNozAdminRoleName, authtypes.SigNozEditorRoleName),
handler.OpenAPIDef{
ID: "TestNotificationChannel",
Tags: []string{"channels"},
Summary: "Test notification channel",
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.",
Request: new(alertmanagertypes.TestableNotificationChannel),
RequestContentType: "application/json",
Response: nil,
ResponseContentType: "",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newScopedSecuritySchemes([]string{coretypes.ResourceMetaResourceNotificationChannel.Scope(coretypes.VerbCreate)}),
},
handler.WithResourceDefs(handler.BasicResourceDef{
Resource: coretypes.ResourceMetaResourceNotificationChannel,
Verb: coretypes.VerbCreate,
Category: coretypes.ActionCategoryConfigurationChange,
Selector: coretypes.WildcardSelector,
}),
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/route_policies", handler.New(provider.authzMiddleware.ViewAccess(provider.alertmanagerHandler.GetAllRoutePolicies), handler.OpenAPIDef{
ID: "GetAllRoutePolicies",
Tags: []string{"routepolicies"},

View File

@@ -12,32 +12,25 @@ import (
// PrepareParamsForTracesV5 returns the traces explorer query params for the
// given range and filter; the traces explorer writes its time params in
// nanoseconds. queryType is builder_ai_query for the AI observability explorer.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string, queryType qbtypes.QueryType) url.Values {
return prepareExplorerParams("traces", queryType, start.UnixNano(), end.UnixNano(), whereClause)
// nanoseconds.
func PrepareParamsForTracesV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("traces", start.UnixNano(), end.UnixNano(), whereClause)
}
// PrepareParamsForLogsV5 returns the logs explorer query params for the given
// range and filter; the logs explorer writes its time params in milliseconds.
func PrepareParamsForLogsV5(start, end time.Time, whereClause string) url.Values {
return prepareExplorerParams("logs", qbtypes.QueryTypeBuilder, start.UnixMilli(), end.UnixMilli(), whereClause)
return prepareExplorerParams("logs", start.UnixMilli(), end.UnixMilli(), whereClause)
}
// The end link is double encoded because otherwise a filter expression with `%` somewhere in it breaks.
func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start, end int64, whereClause string) url.Values {
// builder_query is the explorer default, so it is left out to keep existing links unchanged
builderQueryType := ""
if queryType != qbtypes.QueryTypeBuilder {
builderQueryType = queryType.StringValue()
}
func prepareExplorerParams(dataSource string, start, end int64, whereClause string) url.Values {
urlData := URLShareableCompositeQuery{
QueryType: "builder",
Builder: URLShareableBuilderQuery{
QueryData: []LinkQuery{{
DataSource: dataSource,
BuilderQueryType: builderQueryType,
Filter: &FilterExpression{Expression: whereClause},
DataSource: dataSource,
Filter: &FilterExpression{Expression: whereClause},
}},
QueryFormulas: make([]string, 0),
},
@@ -54,8 +47,7 @@ func prepareExplorerParams(dataSource string, queryType qbtypes.QueryType, start
// BuilderQueryForSignal returns the filter expression and group-by keys of the
// builder query for the given signal, or found=false when the composite query
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts). AI trace
// queries (builder_ai_query) count as trace builder queries.
// has no builder query for it (e.g. PromQL or ClickHouse SQL alerts).
// TODO(srikanthccv): re-visit this and support multiple queries.
func BuilderQueryForSignal(queries []qbtypes.QueryEnvelope, signal telemetrytypes.Signal) (string, []qbtypes.GroupByKey, bool) {
switch signal {
@@ -71,7 +63,7 @@ func builderQueryForSignal[T any](queries []qbtypes.QueryEnvelope, signal teleme
var q qbtypes.QueryBuilderQuery[T]
found := false
for _, query := range queries {
if query.Type != qbtypes.QueryTypeBuilder && query.Type != qbtypes.QueryTypeBuilderAI {
if query.Type != qbtypes.QueryTypeBuilder {
continue
}
if spec, ok := query.Spec.(qbtypes.QueryBuilderQuery[T]); ok {

View File

@@ -30,15 +30,6 @@ func TestBuilderQueryForSignal(t *testing.T) {
Type: qbtypes.QueryTypePromQL,
Spec: qbtypes.PromQuery{Name: "C"},
}
aiTraceQuery := qbtypes.QueryEnvelope{
Type: qbtypes.QueryTypeBuilderAI,
Spec: qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]{
Name: "D",
Signal: telemetrytypes.SignalTraces,
Filter: &qbtypes.Filter{Expression: "trace.input_tokens > 1000"},
GroupBy: []qbtypes.GroupByKey{{TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{Name: "session.id"}}},
},
}
t.Run("logs query among mixed queries", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{promQuery, logQuery, traceQuery}, telemetrytypes.SignalLogs)
@@ -55,14 +46,6 @@ func TestBuilderQueryForSignal(t *testing.T) {
assert.Empty(t, groupBy)
})
t.Run("ai trace query counts as traces", func(t *testing.T) {
filterExpr, groupBy, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{logQuery, aiTraceQuery}, telemetrytypes.SignalTraces)
require.True(t, found)
assert.Equal(t, "trace.input_tokens > 1000", filterExpr)
require.Len(t, groupBy, 1)
assert.Equal(t, "session.id", groupBy[0].Name)
})
t.Run("no builder query for signal", func(t *testing.T) {
_, _, found := BuilderQueryForSignal([]qbtypes.QueryEnvelope{traceQuery}, telemetrytypes.SignalLogs)
assert.False(t, found)

View File

@@ -13,9 +13,8 @@ type FilterExpression struct {
// LinkQuery carries the only fields the explorer pages read from a shared
// link; the frontend fills in the rest of the query shape with defaults.
type LinkQuery struct {
DataSource string `json:"dataSource"`
BuilderQueryType string `json:"builderQueryType,omitempty"`
Filter *FilterExpression `json:"filter,omitempty"`
DataSource string `json:"dataSource"`
Filter *FilterExpression `json:"filter,omitempty"`
}
type URLShareableBuilderQuery struct {

View File

@@ -114,7 +114,6 @@ func (h *handler) GetRuleHistoryTimeline(w http.ResponseWriter, r *http.Request)
Fingerprint: item.Fingerprint,
Value: item.Value,
RelatedTracesLink: item.RelatedTracesLink,
RelatedAITracesLink: item.RelatedAITracesLink,
RelatedLogsLink: item.RelatedLogsLink,
})
}
@@ -152,12 +151,11 @@ func (h *handler) GetRuleHistoryContributors(w http.ResponseWriter, r *http.Requ
converted := make([]rulestatehistorytypes.GettableRuleStateHistoryContributor, 0, len(res))
for _, item := range res {
converted = append(converted, rulestatehistorytypes.GettableRuleStateHistoryContributor{
Fingerprint: item.Fingerprint,
Labels: item.Labels.ToQBLabels(),
Count: item.Count,
RelatedTracesLink: item.RelatedTracesLink,
RelatedAITracesLink: item.RelatedAITracesLink,
RelatedLogsLink: item.RelatedLogsLink,
Fingerprint: item.Fingerprint,
Labels: item.Labels.ToQBLabels(),
Count: item.Count,
RelatedTracesLink: item.RelatedTracesLink,
RelatedLogsLink: item.RelatedLogsLink,
})
}
render.Success(w, http.StatusOK, converted)

View File

@@ -41,8 +41,7 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
return nil
}
signal, ok := relatedLinkSignal(rule.AlertType)
if !ok {
if rule.AlertType != ruletypes.AlertTypeLogs && rule.AlertType != ruletypes.AlertTypeTraces {
return nil
}
if rule.RuleCondition == nil || rule.RuleCondition.CompositeQuery == nil {
@@ -63,6 +62,10 @@ func (m *module) relatedLinkBuilderForRule(ctx context.Context, orgID valuer.UUI
builder.evaluation = ruletypes.RollingWindow{EvalWindow: evalWindow}
}
signal := telemetrytypes.SignalLogs
if rule.AlertType == ruletypes.AlertTypeTraces {
signal = telemetrytypes.SignalTraces
}
// links are still built from the labels alone when the rule has no builder
// query for the signal (e.g. ClickHouse SQL alerts)
builder.filterExpr, builder.groupBy, _ = contextlinks.BuilderQueryForSignal(rule.RuleCondition.CompositeQuery.Queries, signal)
@@ -81,35 +84,21 @@ func (b *relatedLinkBuilder) queryWindow(unixMilli int64) (time.Time, time.Time)
return start.Add(-3 * time.Minute), end
}
// links returns the explorer query params for the given entry labels and time
// range.
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) rulestatehistorytypes.RelatedLinks {
// links returns the encoded logs and traces explorer query params for the
// given entry labels and time range; at most one of the two is non-empty.
func (b *relatedLinkBuilder) links(labels rulestatehistorytypes.LabelsString, start, end time.Time) (string, string) {
lbls := map[string]string{}
if err := json.Unmarshal([]byte(labels), &lbls); err != nil {
return rulestatehistorytypes.RelatedLinks{}
return "", ""
}
whereClause := contextlinks.PrepareFilterExpression(lbls, b.filterExpr, b.groupBy)
switch b.alertType {
case ruletypes.AlertTypeLogs:
return rulestatehistorytypes.RelatedLinks{RelatedLogsLink: contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()}
return contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode(), ""
case ruletypes.AlertTypeTraces:
return rulestatehistorytypes.RelatedLinks{RelatedTracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilder).Encode()}
case ruletypes.AlertTypeAITraces:
return rulestatehistorytypes.RelatedLinks{RelatedAITracesLink: contextlinks.PrepareParamsForTracesV5(start, end, whereClause, qbtypes.QueryTypeBuilderAI).Encode()}
return "", contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
}
return rulestatehistorytypes.RelatedLinks{}
}
// relatedLinkSignal returns the explorer signal that related links open for
// the alert type, or ok=false when the alert type has none (e.g. metrics).
func relatedLinkSignal(alertType ruletypes.AlertType) (telemetrytypes.Signal, bool) {
switch alertType {
case ruletypes.AlertTypeLogs:
return telemetrytypes.SignalLogs, true
case ruletypes.AlertTypeTraces, ruletypes.AlertTypeAITraces:
return telemetrytypes.SignalTraces, true
}
return telemetrytypes.SignalUnspecified, false
return "", ""
}

View File

@@ -34,7 +34,7 @@ func (m *module) GetHistoryTimeline(ctx context.Context, orgID valuer.UUID, rule
if builder := m.relatedLinkBuilderForRule(ctx, orgID, ruleID); builder != nil {
for idx := range items {
start, end := builder.queryWindow(items[idx].UnixMilli)
items[idx].RelatedLinks = builder.links(items[idx].Labels, start, end)
items[idx].RelatedLogsLink, items[idx].RelatedTracesLink = builder.links(items[idx].Labels, start, end)
}
}
@@ -60,7 +60,7 @@ func (m *module) GetHistoryContributors(ctx context.Context, orgID valuer.UUID,
// span it too instead of a single evaluation window
start, end := time.UnixMilli(query.Start), time.UnixMilli(query.End)
for idx := range contributors {
contributors[idx].RelatedLinks = builder.links(contributors[idx].Labels, start, end)
contributors[idx].RelatedLogsLink, contributors[idx].RelatedTracesLink = builder.links(contributors[idx].Labels, start, end)
}
}

View File

@@ -830,12 +830,12 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedLogsLink = contextlinks.PrepareParamsForLogsV5(start, end, whereClause).Encode()
} else if rule.AlertType == ruletypes.AlertTypeTraces || rule.AlertType == ruletypes.AlertTypeAITraces {
} else if rule.AlertType == ruletypes.AlertTypeTraces {
// TODO(srikanthccv): re-visit this and support multiple queries
var q qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]
for _, query := range rule.RuleCondition.CompositeQuery.Queries {
if query.Type == qbtypes.QueryTypeBuilder || query.Type == qbtypes.QueryTypeBuilderAI {
if query.Type == qbtypes.QueryTypeBuilder {
switch spec := query.Spec.(type) {
case qbtypes.QueryBuilderQuery[qbtypes.TraceAggregation]:
q = spec
@@ -849,7 +849,7 @@ func (aH *APIHandler) getRuleStateHistory(w http.ResponseWriter, r *http.Request
}
whereClause := contextlinks.PrepareFilterExpression(lbls, filterExpr, q.GroupBy)
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause, rule.AlertType.BuilderQueryType()).Encode()
res.Items[idx].RelatedTracesLink = contextlinks.PrepareParamsForTracesV5(start, end, whereClause).Encode()
}
}
}

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