Compare commits

..

178 Commits

Author SHA1 Message Date
Naman Verma
69aad53eb4 chore: generate api specs 2026-05-20 23:42:24 +05:30
Naman Verma
7bacb03483 Merge branch 'main' into nv/v2-dashboard-create 2026-05-20 23:41:18 +05:30
Naman Verma
574867bafb chore: update frontend schema 2026-05-18 20:33:38 +05:30
Naman Verma
d87edca9d1 Merge branch 'main' into nv/v2-dashboard-create 2026-05-18 20:32:34 +05:30
Naman Verma
f0ed0a8967 feat: v2 dashboard GET API (#11136)
* feat: v2 dashboard GET API

* Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get

* chore: update api specs

* fix: remove soft delete references

* chore: embed StorableDashboard into joinedRow in store method

* fix: fix build error

* chore: revert all frontend changes

* fix: remove public dashboard from get v2 call
2026-05-15 11:26:02 +05:30
Naman Verma
b47343bc09 Merge branch 'main' into nv/v2-dashboard-create 2026-05-15 10:52:06 +05:30
Naman Verma
bb39c52229 feat: follow the metadata+spec key structure in open api spec 2026-05-15 00:36:49 +05:30
Naman Verma
5fe69473c9 feat: follow the metadata+spec key structure 2026-05-15 00:34:12 +05:30
Naman Verma
9be77ace42 chore: change dashboardData to dashboardSpec 2026-05-14 20:23:54 +05:30
Naman Verma
c804d8f9b6 fix: remove sqlstore passage in ee pkg 2026-05-14 20:21:12 +05:30
Naman Verma
996bd949f2 fix: add ctx needed in sqlstore 2026-05-14 20:20:44 +05:30
Naman Verma
84225023a5 chore: rename module to m 2026-05-14 20:04:25 +05:30
Naman Verma
a5b9dd279c chore: move NewDashboardV2 to NewDashboardV2WithoutTags 2026-05-14 20:03:44 +05:30
Naman Verma
172418a337 fix: use binding package to get request 2026-05-14 20:02:46 +05:30
Naman Verma
d1d5a9fa32 fix: use store.RunInTx instead of taking in sqlstore 2026-05-14 20:01:18 +05:30
Naman Verma
6f81e9f364 chore: revert idx generation to resolve conflicts 2026-05-14 19:50:09 +05:30
Naman Verma
1933bec786 Merge branch 'main' into nv/v2-dashboard-create 2026-05-14 19:48:57 +05:30
Naman Verma
6d3d9bfb49 Merge branch 'main' into nv/v2-dashboard-create 2026-05-14 19:46:00 +05:30
Naman Verma
8b89f4af85 chore: remove uploaded grafana flag from metadata 2026-05-14 17:51:10 +05:30
Naman Verma
66e4132504 Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-14 17:49:37 +05:30
Naman Verma
29e14ce9c6 chore: bump migration number 2026-05-14 17:47:34 +05:30
Naman Verma
d7dc789a58 Merge branch 'main' into nv/tags 2026-05-14 17:45:55 +05:30
Naman Verma
d2d129eea9 chore: comment out unique index test 2026-05-14 16:03:21 +05:30
Naman Verma
9e1704615f feat: add created at to tag relations 2026-05-14 15:58:52 +05:30
Naman Verma
db06557c12 chore: comment out unique index test 2026-05-14 15:51:18 +05:30
Naman Verma
1475e2b53a chore: add a todo comment 2026-05-14 15:37:07 +05:30
Naman Verma
e58119a416 chore: comment out tags unique index for now 2026-05-14 15:27:53 +05:30
Naman Verma
f5935ccaf4 Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-13 22:41:17 +05:30
Naman Verma
d354044fbe Merge branch 'main' into nv/tags 2026-05-13 22:40:29 +05:30
Naman Verma
fe6cbc3c0c test: add unit tests for new idx type 2026-05-13 22:39:01 +05:30
Naman Verma
45fa0c739c chore: move tag resolution to module 2026-05-13 17:35:24 +05:30
Naman Verma
e1527dd148 chore: combine functional unique index with unique index 2026-05-13 17:23:22 +05:30
Naman Verma
5063be6467 chore: use tagtypestest package for mock store 2026-05-13 17:09:09 +05:30
Naman Verma
b453655dea chore: rename create method to createOrGet 2026-05-13 16:54:58 +05:30
Naman Verma
4cb27e330e Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-13 14:22:20 +05:30
Naman Verma
b47bc6bcc4 fix: only ascii in regex 2026-05-13 13:51:02 +05:30
Naman Verma
5321e9ee87 feat: functional unique index in sql schema 2026-05-13 13:27:44 +05:30
Naman Verma
fc4f326953 fix: use sync tags in create api 2026-05-13 12:21:45 +05:30
Naman Verma
2af4cbf0f9 Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-13 12:11:30 +05:30
Naman Verma
e82f568a27 chore: remove methods that shouldn't be exposed 2026-05-13 12:11:09 +05:30
Naman Verma
c2aac3a278 fix: add regex for tags 2026-05-13 12:00:26 +05:30
Naman Verma
ddfec3e5f7 fix: add len check on tags keys and values 2026-05-13 11:32:31 +05:30
Naman Verma
af623f66e8 chore: bump migration number 2026-05-13 11:23:03 +05:30
Naman Verma
268e747f5c fix: fix build error 2026-05-13 10:59:12 +05:30
Naman Verma
3fc72329c9 Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-13 10:56:15 +05:30
Naman Verma
10ecb7524c fix: add ID in tag relation 2026-05-13 10:33:27 +05:30
Naman Verma
8fc21ca6b9 fix: remove user auditable 2026-05-13 10:19:51 +05:30
Naman Verma
422149369d fix: add org id filter in all list and delete queries 2026-05-13 10:11:55 +05:30
Naman Verma
2063697350 chore: change entity id to resource id 2026-05-13 09:41:01 +05:30
Naman Verma
685faa9211 Merge branch 'main' into nv/tags 2026-05-13 09:23:28 +05:30
Naman Verma
de6fcb9fbb chore: bump migration number 2026-05-12 14:19:47 +05:30
Naman Verma
828619a9e6 Merge branch 'main' into nv/tags 2026-05-12 14:19:17 +05:30
Naman Verma
aff2e1be6b fix: fix build errors in dashboard module 2026-05-12 14:13:40 +05:30
Naman Verma
9728d17a0a fix: remove entity type definition 2026-05-12 14:10:29 +05:30
Naman Verma
ffaf334dfd Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-12 14:09:58 +05:30
Naman Verma
d87e7241c0 feat: add SyncTags method that covers creation and linking 2026-05-12 14:09:19 +05:30
Naman Verma
ae184315a9 feat: foreign key on tag id 2026-05-12 13:55:33 +05:30
Naman Verma
29f782a3a0 chore: remove org ID from tag relation 2026-05-12 13:51:14 +05:30
Naman Verma
71d8dafce1 fix: singular table name 2026-05-12 13:45:02 +05:30
Naman Verma
412320d7d9 fix: use coretypes.Kind instead of defining entity type 2026-05-12 13:42:35 +05:30
Naman Verma
9b5d78b5a0 Merge branch 'main' into nv/tags 2026-05-12 13:21:20 +05:30
Naman Verma
444464ae15 fix: created and updated by schema 2026-05-12 13:04:48 +05:30
Naman Verma
d5841f8daa fix: correct pk in bun model for tag relations 2026-05-12 12:29:29 +05:30
Naman Verma
c344cd256f fix: diff error codes for invalid keys and values 2026-05-12 12:27:46 +05:30
Naman Verma
2f6b7b6260 Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-11 23:42:21 +05:30
Naman Verma
5e61be1606 feat: method to build postable tags from tags 2026-05-11 23:42:08 +05:30
Naman Verma
1f7032953c Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-11 22:34:02 +05:30
Naman Verma
173037d3be fix: extend bun in joinedRow 2026-05-11 22:33:37 +05:30
Naman Verma
4713fd4839 chore: generate api spec 2026-05-11 14:47:02 +05:30
Naman Verma
4ad872b722 fix: add back api endpoint 2026-05-11 14:24:53 +05:30
Naman Verma
642fb66831 Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-11 14:11:28 +05:30
Naman Verma
d12c846212 chore: change where tag module is instantiated 2026-05-11 14:10:07 +05:30
Naman Verma
4e5bd7cf6f fix: lint fix regarding nil, nil return in test file 2026-05-11 14:06:08 +05:30
Naman Verma
3982cce603 chore: merge conflicts error fixing pt 1 2026-05-11 13:59:41 +05:30
Naman Verma
1a43c85cb8 Merge branch 'nv/tags' into nv/v2-dashboard-create 2026-05-11 13:53:21 +05:30
Naman Verma
bd11e985e1 feat: new module for tags 2026-05-11 13:45:10 +05:30
Naman Verma
3e849ee2d3 feat: reserved DSL key validation for tags 2026-05-08 18:10:28 +05:30
Naman Verma
f7d9a57637 fix: pass entity type in create many 2026-05-08 17:56:58 +05:30
Naman Verma
fceb770337 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-08 15:48:58 +05:30
Naman Verma
44496d9d8d feat: entity type column in tags 2026-05-08 15:48:51 +05:30
Naman Verma
398943fe41 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-08 13:38:22 +05:30
Naman Verma
a17debc61b feat: move tags to key:value pairs model 2026-05-08 13:38:13 +05:30
Naman Verma
3113b82904 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-07 12:06:33 +05:30
Naman Verma
71c60c3f2a test: fix mock interface in test 2026-05-07 12:06:27 +05:30
Naman Verma
3b824d50a3 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-07 02:25:09 +05:30
Naman Verma
d0a693b034 feat: method to fetch tags for multiple entries at once 2026-05-07 02:24:42 +05:30
Naman Verma
90377f8116 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-07 02:18:54 +05:30
Naman Verma
cabfd7271b Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-07 02:18:50 +05:30
Naman Verma
750d63cf6b test: unit test fixes 2026-05-07 02:16:05 +05:30
Naman Verma
e4c4acb5df Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-06 11:25:36 +05:30
Naman Verma
c9235cd3d2 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-06 11:22:25 +05:30
Naman Verma
ec837c7006 fix: allow only 1 query in a panel 2026-05-06 11:21:49 +05:30
Naman Verma
a1f73655ca Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-05 16:39:35 +05:30
Naman Verma
0d6081d0d0 feat: consolidate tag module and tagtypes changes from downstream branches 2026-05-05 16:39:13 +05:30
Naman Verma
54832cad34 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-05 11:54:38 +05:30
Naman Verma
a45178d709 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-05 11:54:21 +05:30
Naman Verma
c4224ecf72 Merge branch 'main' into nv/dashboardv2 2026-05-05 11:53:56 +05:30
Naman Verma
ff578f7d92 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 19:26:49 +05:30
Naman Verma
cd630b1152 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 19:26:36 +05:30
Naman Verma
bd0842ac17 fix: query-less panels not allowed 2026-05-04 19:25:49 +05:30
Naman Verma
97b85c386a fix: no v2 package and its consequences 2026-05-04 17:27:58 +05:30
Naman Verma
00bdf50c1c fix: no v2 package and its consequences 2026-05-04 17:26:12 +05:30
Naman Verma
5dec4ec580 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 17:18:39 +05:30
Naman Verma
325767c240 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 17:17:32 +05:30
Naman Verma
5fed2a4585 chore: no v2 subpackage 2026-05-04 17:16:39 +05:30
Naman Verma
664337ae0f Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-05-04 16:19:29 +05:30
Naman Verma
a0ea276681 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-05-04 16:18:03 +05:30
Naman Verma
2dc8699f08 fix: wrap errors 2026-05-04 14:55:38 +05:30
Naman Verma
ed81ed8ab5 fix: no need for copying textboxvariablespec 2026-05-04 14:44:42 +05:30
Naman Verma
48c9da19df fix: return 500 err if spec is nil for composite kind w/ code comment 2026-05-04 14:34:16 +05:30
Naman Verma
eb9663d518 fix: remove extra (un)marshal cycle 2026-05-04 14:18:37 +05:30
Naman Verma
a56a862338 fix: add allowed values in err messages 2026-05-04 14:16:22 +05:30
Naman Verma
021f33f65e Merge branch 'main' into nv/dashboardv2 2026-05-04 12:52:31 +05:30
Naman Verma
4d9386f418 fix: merge conflicts 2026-04-29 14:36:39 +05:30
Naman Verma
737473521d Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-29 14:33:25 +05:30
Naman Verma
1863db8ba8 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-29 14:32:14 +05:30
Naman Verma
661af09a13 Merge branch 'main' into nv/dashboardv2 2026-04-29 14:31:59 +05:30
Naman Verma
6024fa2b91 fix: remove extra spec from builder query marshalling 2026-04-29 14:31:16 +05:30
Naman Verma
8996a96387 chore: use existing mapper 2026-04-29 14:09:34 +05:30
Naman Verma
d6db5c2aab test: integration test fixes 2026-04-29 12:56:14 +05:30
Naman Verma
709590ea1b test: integration tests for create API 2026-04-29 12:23:12 +05:30
Naman Verma
1add46b4c5 fix: module should also validate postable dashboard 2026-04-28 20:05:38 +05:30
Naman Verma
8401261e20 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-28 20:04:33 +05:30
Naman Verma
0ff34a7274 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 20:04:08 +05:30
Naman Verma
44e3bd9608 chore: separate method for validation 2026-04-28 20:03:48 +05:30
Naman Verma
c3944d779e fix: more dashboard request validations 2026-04-28 19:59:11 +05:30
Naman Verma
f5ec783a53 fix: go lint fix 2026-04-28 19:33:28 +05:30
Naman Verma
35b729c425 Merge branch 'nv/tags-for-dashboard-create' into nv/v2-dashboard-create 2026-04-28 19:30:42 +05:30
Naman Verma
4f43c3d803 fix: use existing tag's casing if new tag is a prefix of an existing tag 2026-04-28 19:30:07 +05:30
Naman Verma
5dbde6c64d fix: only return name of a tag in dashboard response 2026-04-28 19:13:03 +05:30
Naman Verma
fb6fdd54ec feat: v2 create dashboard API 2026-04-28 15:05:29 +05:30
Naman Verma
64b8ba62da Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 15:04:11 +05:30
Naman Verma
7c66df408b Merge branch 'main' into nv/dashboardv2 2026-04-28 15:04:03 +05:30
Naman Verma
54049de391 chore: follow proper unmarshal json method structure 2026-04-28 15:02:49 +05:30
Naman Verma
a82f4237c8 Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 09:52:23 +05:30
Naman Verma
89606b6238 Merge branch 'main' into nv/dashboardv2 2026-04-28 09:52:13 +05:30
Naman Verma
db5ce958eb Merge branch 'nv/dashboardv2' into nv/tags-for-dashboard-create 2026-04-28 09:49:01 +05:30
Naman Verma
c8d3a9a54b feat: enum for entity type that other modules can register 2026-04-28 09:47:24 +05:30
Naman Verma
637870b1fc feat: define tags module for v2 dashboard creation 2026-04-27 22:14:47 +05:30
Naman Verma
d46a7e24c9 Merge branch 'main' into nv/dashboardv2 2026-04-27 22:12:12 +05:30
Naman Verma
2a451e1c31 test: test for drift detection mechanics 2026-04-27 18:57:41 +05:30
Naman Verma
60b6d1d890 chore: better method name extractKindAndSpec 2026-04-27 18:42:31 +05:30
Naman Verma
36f755b232 chore: cleanup comments 2026-04-27 18:39:41 +05:30
Naman Verma
c1b3e3683a chore: code movement 2026-04-27 18:37:57 +05:30
Naman Verma
4c68544b1a chore: go lint fix (godot) 2026-04-27 18:37:05 +05:30
Naman Verma
90d9ab95f9 chore: code movement 2026-04-27 18:35:18 +05:30
Naman Verma
065e712e0c chore: code movement 2026-04-27 18:33:48 +05:30
Naman Verma
50db309ecd chore: code movement 2026-04-27 18:32:41 +05:30
Naman Verma
261bc552b0 chore: cleanup testing code 2026-04-27 18:24:52 +05:30
Naman Verma
bab720e98b Merge branch 'main' into nv/dashboardv2 2026-04-27 18:21:09 +05:30
Naman Verma
71fef6636b chore: better method name 2026-04-27 18:18:14 +05:30
Naman Verma
fc3cdecbbb chore: cleaner comment 2026-04-27 18:15:21 +05:30
Naman Verma
860fcfa641 chore: cleaner comment 2026-04-27 18:14:27 +05:30
Naman Verma
a090e3a4aa chore: cleaner comment 2026-04-27 18:14:02 +05:30
Naman Verma
6cf73e2ade chore: better comment to explain what restrictKindToLiteral does 2026-04-27 18:13:34 +05:30
Naman Verma
bbcb6a45d6 chore: renames and code rearrangement 2026-04-27 17:53:54 +05:30
Naman Verma
d13934febc fix: remove textbox plugin from openapi spec 2026-04-27 17:29:36 +05:30
Naman Verma
d5a7b7523d fix: strict decode variable spec as well 2026-04-27 17:27:51 +05:30
Naman Verma
5b8984f131 Merge branch 'main' into nv/dashboardv2 2026-04-27 17:18:44 +05:30
Naman Verma
6ddc5f1f12 chore: better error messages 2026-04-27 17:18:11 +05:30
Naman Verma
055968bfad fix: dot at the end of a comment 2026-04-27 17:07:58 +05:30
Naman Verma
1bf0f38ed9 fix: js lint errors 2026-04-27 17:07:38 +05:30
Naman Verma
842125e20a chore: too many comments 2026-04-27 16:50:41 +05:30
Naman Verma
6dab35caf8 chore: better file name 2026-04-27 16:43:42 +05:30
Naman Verma
047e9e2001 chore: better file names 2026-04-27 16:42:31 +05:30
Naman Verma
45eaa7db58 test: add tests for spec wrappers 2026-04-27 16:36:27 +05:30
Naman Verma
8a3d894eba chore: comment cleanup 2026-04-27 16:32:29 +05:30
Naman Verma
5239060b53 chore: move plugin maps to correct file 2026-04-27 16:30:33 +05:30
Naman Verma
42c6f507ac test: more descriptive test file name 2026-04-27 15:42:54 +05:30
Naman Verma
1b695a0b80 chore: separate file for perses replicas 2026-04-27 15:42:21 +05:30
Naman Verma
438cfab155 chore: comment clean up 2026-04-27 15:39:46 +05:30
Naman Verma
69f7617e01 Merge branch 'main' into nv/dashboardv2 2026-04-27 15:36:58 +05:30
Naman Verma
4420a7e1fc test: much bigger json for data column 2026-04-24 22:16:03 +05:30
Naman Verma
b4bc68c5c5 test: data column in perf tests should match real data 2026-04-24 17:17:37 +05:30
Naman Verma
eb9eb317cc test: perf test script for both sql flavours 2026-04-23 17:14:33 +05:30
Naman Verma
0b1eb16a42 test: fixes in dashboard perf testing data generator 2026-04-23 15:42:58 +05:30
Naman Verma
05a4d12183 test: script to generate test dashboard data in a sql db 2026-04-23 14:19:58 +05:30
Naman Verma
bbaf64c4f0 feat: openapi spec generation 2026-04-21 13:41:06 +05:30
55 changed files with 3556 additions and 5105 deletions

View File

@@ -33,6 +33,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
@@ -100,8 +101,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, tagModule)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()

View File

@@ -46,6 +46,7 @@ import (
pkgcloudintegration "github.com/SigNoz/signoz/pkg/modules/cloudintegration/implcloudintegration"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/retention"
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
@@ -133,8 +134,8 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
}
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), openfgaDataStore, licensing, onBeforeRoleDelete, authtypes.NewRegistry()), nil
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), store, settings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,10 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -30,9 +32,9 @@ type module struct {
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser, tagModule)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -225,6 +227,14 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, postable)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -18,11 +18,15 @@ import type {
} from 'react-query';
import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
GetPublicDashboard200,
GetPublicDashboardData200,
GetPublicDashboardDataPathParameters,
@@ -628,3 +632,187 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.
* @summary Create dashboard (v2)
*/
export const createDashboardV2 = (
dashboardtypesPostableDashboardV2DTO?: BodyType<DashboardtypesPostableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardV2201>({
url: `/api/v2/dashboards`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
signal,
});
};
export const getCreateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationKey = ['createDashboardV2'];
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 createDashboardV2>>,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardV2(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardV2>>
>;
export type CreateDashboardV2MutationBody =
| BodyType<DashboardtypesPostableDashboardV2DTO>
| undefined;
export type CreateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard (v2)
*/
export const useCreateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
return useMutation(getCreateDashboardV2MutationOptions(options));
};
/**
* This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).
* @summary Get dashboard (v2)
*/
export const getDashboardV2 = (
{ id }: GetDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'GET',
signal,
});
};
export const getGetDashboardV2QueryKey = ({
id,
}: GetDashboardV2PathParameters) => {
return [`/api/v2/dashboards/${id}`] as const;
};
export const getGetDashboardV2QueryOptions = <
TData = Awaited<ReturnType<typeof getDashboardV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetDashboardV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDashboardV2QueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDashboardV2>>> = ({
signal,
}) => getDashboardV2({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetDashboardV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getDashboardV2>>
>;
export type GetDashboardV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get dashboard (v2)
*/
export function useGetDashboardV2<
TData = Awaited<ReturnType<typeof getDashboardV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetDashboardV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetDashboardV2QueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get dashboard (v2)
*/
export const invalidateGetDashboardV2 = async (
queryClient: QueryClient,
{ id }: GetDashboardV2PathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetDashboardV2QueryKey({ id }) },
options,
);
return queryClient;
};

File diff suppressed because it is too large Load Diff

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M8.932 20.806c-.369 0-.738.007-1.109 0-.35-.007-.587-.206-.623-.5a.587.587 0 0 1 .53-.636c.79-.062 1.582-.063 2.372-.003a.548.548 0 0 1 .522.602c-.024.326-.253.526-.616.54zM1.792 8.345c-.392 0-.782.008-1.173.002-.327-.006-.577-.22-.614-.512-.037-.293.146-.544.499-.615.192-.032.388-.045.583-.039a81.515 81.515 0 0 1 1.597 0c.163 0 .325.019.483.056.288.073.445.318.411.617-.034.298-.214.477-.515.487-.424.014-.848.004-1.272.004zm7.588 8.417H4.292a2.464 2.464 0 0 1-.326-.007c-.294-.04-.48-.209-.508-.506-.029-.298.11-.501.391-.606.179-.065.365-.051.549-.051 3.347 0 6.695.005 10.042-.006 1.174-.004 2.187-.439 2.993-1.3.69-.738 1.053-1.63 1.16-2.635.085-.788-.027-1.513-.516-2.156-.544-.718-1.28-1.078-2.163-1.082-3.163-.013-6.328-.005-9.487-.01-.336 0-.673-.027-1.007-.058-.29-.027-.45-.201-.469-.492-.021-.317.141-.545.429-.6a1.55 1.55 0 0 1 .29-.015h10.177c1.71.004 3.187 1.038 3.726 2.654.383 1.147.246 2.304-.182 3.416-.824 2.135-2.762 3.448-5.055 3.454-1.652.005-3.304 0-4.956 0zm2.906-13.568c1.533 0 3.066-.008 4.598 0 2.935.018 5.629 1.892 6.653 4.626.442 1.181.538 2.403.412 3.657-.185 1.842-.735 3.552-1.776 5.084-1.608 2.365-3.873 3.68-6.679 4.118-.95.148-1.905.13-2.86.13-.397 0-.61-.181-.633-.51-.025-.351.196-.621.587-.645.434-.026.87-.004 1.305-.016 2.641-.072 4.928-.982 6.74-2.935 1.269-1.37 1.912-3.039 2.13-4.878.151-1.275.135-2.544-.37-3.752-.773-1.85-2.159-2.983-4.068-3.509-.74-.204-1.5-.243-2.26-.247-2.837-.017-5.675-.007-8.511-.007-.12 0-.24.004-.359-.006a.57.57 0 0 1-.517-.536.557.557 0 0 1 .456-.557c.13-.018.261-.024.392-.019h4.762Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.6 KiB

View File

@@ -1,7 +0,0 @@
<svg width="456" height="456" viewBox="0 0 456 456" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="456" height="456" rx="50" fill="#512BD4"/>
<path d="M81.2738 291.333C78.0496 291.333 75.309 290.259 73.052 288.11C70.795 285.906 69.6665 283.289 69.6665 280.259C69.6665 277.173 70.795 274.529 73.052 272.325C75.309 270.121 78.0496 269.019 81.2738 269.019C84.5518 269.019 87.3193 270.121 89.5763 272.325C91.887 274.529 93.0424 277.173 93.0424 280.259C93.0424 283.289 91.887 285.906 89.5763 288.11C87.3193 290.259 84.5518 291.333 81.2738 291.333Z" fill="white"/>
<path d="M210.167 289.515H189.209L133.994 202.406C132.597 200.202 131.441 197.915 130.528 195.546H130.044C130.474 198.081 130.689 203.508 130.689 211.827V289.515H112.149V171H134.477L187.839 256.043C190.096 259.57 191.547 261.994 192.192 263.316H192.514C191.977 260.176 191.708 254.859 191.708 247.365V171H210.167V289.515Z" fill="white"/>
<path d="M300.449 289.515H235.561V171H297.87V187.695H254.746V221.249H294.485V237.861H254.746V272.903H300.449V289.515Z" fill="white"/>
<path d="M392.667 187.695H359.457V289.515H340.272V187.695H307.143V171H392.667V187.695Z" fill="white"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -1,15 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 18 18">
<defs>
<linearGradient id="a" x1="9" y1="17" x2="9" y2="1" gradientUnits="userSpaceOnUse">
<stop offset="0" stop-color="#0078d4"/>
<stop offset="1" stop-color="#5ea0ef"/>
</linearGradient>
</defs>
<circle cx="9" cy="9" r="8" fill="url(#a)"/>
<ellipse cx="9" cy="9" rx="3.2" ry="8" fill="none" stroke="#fff" stroke-width=".7"/>
<line x1="1" y1="9" x2="17" y2="9" stroke="#fff" stroke-width=".7"/>
<line x1="2" y1="5.5" x2="16" y2="5.5" stroke="#fff" stroke-width=".5"/>
<line x1="2" y1="12.5" x2="16" y2="12.5" stroke="#fff" stroke-width=".5"/>
<circle cx="9" cy="9" r="8" fill="none" stroke="#fff" stroke-width=".7"/>
<path d="M13.5 10.5l1.5-1.5-1.5-1.5M4.5 10.5L3 9l1.5-1.5" stroke="#50e6ff" stroke-width="1" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

Before

Width:  |  Height:  |  Size: 877 B

View File

@@ -1,15 +0,0 @@
<svg width="142" height="142" viewBox="0 0 142 142" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clipPath="url(#clip0_0_812)" transform="matrix(1.002103,0,0,1.0377318,6.9399999e-7,-2.5317276e-4)">
<path d="m 141.702,68.418 c 0,7.4632 -4.567,14.1123 -6.748,20.8385 -2.263,6.9789 -2.552,15.0285 -6.776,20.8385 -4.267,5.868 -11.856,8.611 -17.719,12.881 -5.805,4.228 -10.7345,10.628 -17.7061,12.895 -6.7286,2.186 -14.4463,-0.021 -21.9018,-0.021 -7.4555,0 -15.1731,2.207 -21.8998,0.021 C 41.9778,133.604 37.048,127.204 31.2428,122.976 25.3799,118.706 17.7913,115.963 13.5247,110.095 9.30055,104.287 9.01135,96.2374 6.74791,89.2565 4.56351,82.5225 0,75.8735 0,68.418 0,60.9624 4.56737,54.3057 6.74791,47.5795 9.01135,40.6005 9.30055,32.5507 13.5247,26.741 17.7913,20.8753 25.3799,18.1297 31.2428,13.8617 37.048,9.63414 41.9778,3.23209 48.9513,0.966872 55.678,-1.21924 63.3956,0.986167 70.8511,0.986167 c 7.4555,0 15.1732,-2.205407 21.8999,-0.019295 6.9735,2.265218 11.903,8.667268 17.708,12.894828 5.863,4.268 13.452,7.0136 17.719,12.8793 4.224,5.8097 4.513,13.8595 6.776,20.8385 2.181,6.7262 6.748,13.3771 6.748,20.8385 z" fill="#326ce5"/>
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
<path d="m 13.5883,60.53 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
<path d="m 13.5883,68.248 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
<path d="m 13.5883,77.5095 c 8.1804,0 8.1804,3.859 16.3589,3.859 8.1784,0 8.1804,-3.859 16.3607,-3.859 8.1804,0 8.1785,3.859 16.3589,3.859 8.1804,0 8.1785,-3.859 16.3589,-3.859 8.1804,0 8.1803,3.859 16.3588,3.859 8.1785,0 8.1805,-3.859 16.3605,-3.859 8.181,0 8.181,3.859 16.361,3.859" stroke="#ffffff" strokeMiterlimit="10"/>
<path d="m 70.8473,8.18683 c -33.2383,0 -60.1837,26.96657 -60.1837,60.23097 0,33.2642 26.9454,60.2312 60.1837,60.2312 33.2387,0 60.1837,-26.959 60.1837,-60.2312 0,-33.2721 -26.945,-60.23097 -60.1837,-60.23097 z M 70.8319,123.408 C 40.4778,123.408 15.9058,98.8053 15.9,68.4274 15.9,38.0167 40.5357,13.3791 70.9109,13.437 c 30.3751,0.0579 54.9111,24.6589 54.8841,55.0329 -0.027,30.374 -24.609,54.9481 -54.9631,54.9381 z" fill="#ffffff"/>
</g>
<defs>
<clipPath id="clip0_0_812">
<rect width="141.702" height="136.837" fill="#ffffff"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,55 +0,0 @@
<svg viewBox="0 0 400 400" xmlns="http://www.w3.org/2000/svg">
<g>
<g>
<g>
<rect x="122" y="-0.4" transform="matrix(-0.866 -0.5 0.5 -0.866 163.3196 363.3136)" fill="#E535AB" width="16.6" height="320.3"/>
</g>
</g>
<g>
<g>
<rect x="39.8" y="272.2" fill="#E535AB" width="320.3" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="37.9" y="312.2" transform="matrix(-0.866 -0.5 0.5 -0.866 83.0693 663.3409)" fill="#E535AB" width="185" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="177.1" y="71.1" transform="matrix(-0.866 -0.5 0.5 -0.866 463.3409 283.0693)" fill="#E535AB" width="185" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="122.1" y="-13" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7903 232.1221)" fill="#E535AB" width="16.6" height="185"/>
</g>
</g>
<g>
<g>
<rect x="109.6" y="151.6" transform="matrix(-0.5 -0.866 0.866 -0.5 266.0828 473.3766)" fill="#E535AB" width="320.3" height="16.6"/>
</g>
</g>
<g>
<g>
<rect x="52.5" y="107.5" fill="#E535AB" width="16.6" height="185"/>
</g>
</g>
<g>
<g>
<rect x="330.9" y="107.5" fill="#E535AB" width="16.6" height="185"/>
</g>
</g>
<g>
<g>
<rect x="262.4" y="240.1" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7953 714.2875)" fill="#E535AB" width="14.5" height="160.9"/>
</g>
</g>
<path fill="#E535AB" d="M369.5,297.9c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C373.5,259.9,379.2,281.2,369.5,297.9"/>
<path fill="#E535AB" d="M90.9,137c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C94.8,99,100.5,120.3,90.9,137"/>
<path fill="#E535AB" d="M30.5,297.9c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C61.4,320.3,40.1,314.6,30.5,297.9"/>
<path fill="#E535AB" d="M309.1,137c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C340.1,159.4,318.7,153.7,309.1,137"/>
<path fill="#E535AB" d="M200,395.8c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,380.1,219.3,395.8,200,395.8"/>
<path fill="#E535AB" d="M200,74c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,58.4,219.3,74,200,74"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.2 KiB

View File

@@ -1,3 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 77.62745 102.5">
<path fill="#516baa" d="m31.05548,54.44523v24.1773c.00065.04512-.03164.084-.07611.09164l-23.27949,3.99047c-.05091.0076-.09834-.02751-.10594-.07841-.00256-.01712-.0003-.03461.00653-.05051L30.87996,30.58635c.02242-.04633.07815-.06572.12449-.04331.0316.01529.05193.04704.05259.08214l-.00156,23.82005Zm3.92367-13.93321v38.21148c.00046.04691.03573.08617.08232.09164l34.87031,3.89415c.0512.00527.09698-.03196.10226-.08316.00167-.01616-.00092-.03247-.00751-.04732L35.15623,4.70041c-.02237-.04636-.07809-.0658-.12444-.04343-.03117.01504-.05144.04612-.05264.08071v35.77433Zm34.68546,45.76213l-38.57341,11.57218c-.02155.00797-.04524.00797-.06679,0l-23.309-11.57217c-.04636-.0203-.06749-.07435-.04719-.12071.01513-.03455.04988-.0563.08757-.05481h61.88241c.0508.00825.08531.05613.07706.10693-.00482.0297-.02369.05525-.05066.06859Z"/>
</svg>

Before

Width:  |  Height:  |  Size: 901 B

View File

@@ -1,3 +0,0 @@
<svg role="img" viewBox="0 0 24 24" xmlns="http://www.w3.org/2000/svg">
<path fill="currentColor" d="M.113 10.27A13.026 13.026 0 000 11.48h18.23c-.064-.125-.15-.237-.235-.347-3.117-4.027-4.793-3.677-7.19-3.78-.8-.034-1.34-.048-4.524-.048-1.704 0-3.555.005-5.358.01-.234.63-.459 1.24-.567 1.737h9.342v1.216H.113v.002zm18.26 2.426H.009c.02.326.05.645.094.961h16.955c.754 0 1.179-.429 1.315-.96zm-17.318 4.28s2.81 6.902 10.93 7.024c4.855 0 9.027-2.883 10.92-7.024H1.056zM11.988 0C7.5 0 3.593 2.466 1.531 6.108l4.75-.005v-.002c3.71 0 3.849.016 4.573.047l.448.016c1.563.052 3.485.22 4.996 1.364.82.621 2.007 1.99 2.712 2.965.654.902.842 1.94.396 2.934-.408.914-1.289 1.458-2.353 1.458H.391s.099.42.249.886h22.748A12.026 12.026 0 0024 12.005C24 5.377 18.621 0 11.988 0z"/>
</svg>

Before

Width:  |  Height:  |  Size: 776 B

View File

@@ -1,13 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 590 270">
<path d="M30.36,109.14v.48h0A3.73,3.73,0,0,1,30.36,109.14Z" fill="currentColor" fill-rule="evenodd"/>
<path d="M138.66,28.78C107.2,37.87,57.29,43,30.4,43h0V94.35a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.4a129,129,0,0,0,17.49-5.81c4.18-1.88,6.88-3.86,6.88-5.92V15.91C164.1,20.79,151.39,25.11,138.66,28.78Z" fill="#de3423" fill-rule="evenodd"/>
<path d="M138.66,95.37c-18.83,5.43-44.24,9.47-67.39,11.83-15.54,1.59-30.06,2.42-40.87,2.42h0v51.31a.8.8,0,0,0,.19.48c18.35,0,75-6,109.18-15.39a130.38,130.38,0,0,0,17.49-5.81c4.18-1.89,6.88-3.86,6.88-5.92V82.5C164.1,87.37,151.39,91.69,138.66,95.37Z" fill="#de3423" fill-rule="evenodd"/>
<path d="M138.66,162c-18.83,5.43-44.24,9.46-67.39,11.83-15.56,1.59-30.1,2.42-40.91,2.42V228c18.16,0,75.1-5.95,109.37-15.39,12.63-3.48,24.37-7.44,24.37-11.74V149.08C164.1,154,151.39,158.28,138.66,162Z" fill="#de3423" fill-rule="evenodd"/>
<path d="M30.55,94.83C32.4,97.38,48,102.19,71.27,107.2c23.27,4.46,47.47,22.07,66.29,16.64,12.73-3.68,26.54-36.47,26.54-41.34V82c0-3.4-2.55-6.13-6.88-8.4-17.75-9.07-21.11-12.41-27.69-10.6C95.37,72.43,35.06,67.61,30.55,94.83Z" fill="currentColor" fill-rule="evenodd"/>
<path d="M30.55,161.41C32.4,164,48,168.77,71.27,173.79c26,4.74,48.61,20.19,67.44,14.75,12.73-3.68,25.39-34.58,25.39-39.46v-.48c0-3.39-2.55-6.13-6.88-8.39-13.54-7.2-31.43-15.13-38-13.32C85,136.3,39.26,138.37,30.55,161.41Z" fill="currentColor" fill-rule="evenodd"/>
<path d="M200.7,142.39c6,11.79,15.6,17.6,29.05,17.6,14.44,0,19.59-7.64,19.59-15.11,0-5.15-1.83-8.63-6.64-11.79-4.82-3.32-8.3-4.81-16.93-8-10.63-4-16.77-7-23.41-12.29-6.64-5.48-9.79-13-9.79-22.74a28.28,28.28,0,0,1,10.29-22.58c7-5.81,15.44-8.63,25.56-8.63,15.77,0,27.72,6.31,35.69,18.76L249.34,87.78c-4.48-6.81-11.29-10.3-20.59-10.3-9.13,0-15.77,5.15-15.77,12.29,0,4.81,2,7.14,4.82,10,1.82,1.33,6.47,3.32,8.63,4.48l6,2.32,6.8,2.66c11,4.48,18.76,9.3,23.57,14.44s7.31,12.12,7.31,20.75c0,20.42-14.11,34.2-40.51,34.2-21.41,0-37.18-10-44.48-26.4Z" fill="currentColor"/>
<path d="M354.25,104.71,342,117.49a28.14,28.14,0,0,0-21.24-9.13,25,25,0,0,0-18.43,7.47,27.76,27.76,0,0,0,0,37.52,25,25,0,0,0,18.43,7.47A28.14,28.14,0,0,0,342,151.69l12.29,12.78c-9,9.63-20.09,14.44-33.53,14.44-12.79,0-23.58-4.15-32.37-12.62s-13.12-19.09-13.12-31.7,4.32-23.08,13.12-31.54,19.58-12.78,32.37-12.78C334.16,90.27,345.28,95.08,354.25,104.71Z" fill="currentColor"/>
<path d="M393.88,125.62C408,124.3,413,122.47,413,116c0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a14.06,14.06,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C357.7,136.41,369.15,127.78,393.88,125.62ZM391.56,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.75,9.75,0,0,1-4.14,2.49c-3.82,1.33-6.31,1.66-14.28,2.49-11.62,1.33-17.43,5-17.43,10.79C377.12,158.33,382.43,162,391.56,162Z" fill="currentColor"/>
<path d="M444.84,60.88h19.92V149.2c0,8.13,2.66,11.62,10,11.62a21.15,21.15,0,0,0,6-.67v17.76a35.56,35.56,0,0,1-9.47,1c-17.59,0-26.39-9-26.39-27.06Z" fill="currentColor"/>
<path d="M521.71,125.62c14.11-1.32,19.09-3.15,19.09-9.62,0-5.15-4.64-9.13-13.94-9.13q-13.44,0-22.41,10.95l-12.28-10.46c8.13-11.45,19.58-17.09,34.36-17.09,20.75,0,33.7,10,33.7,27.05v37c0,5.81,2.15,6.48,7,6.48h.5v15.43c-2,1.17-5.15,1.83-9.3,1.83-4.48,0-8-1.33-10.62-4a13.94,13.94,0,0,1-3-5.48c-5.81,6.8-15.27,10.29-28.39,10.29-18.42,0-30.87-10.13-30.87-25.4C485.53,136.41,497,127.78,521.71,125.62ZM519.39,162c13.28,0,21.41-6,21.41-16.6v-9.3a9.73,9.73,0,0,1-4.15,2.49c-3.81,1.33-6.3,1.66-14.27,2.49-11.62,1.33-17.43,5-17.43,10.79C505,158.33,510.26,162,519.39,162Z" fill="currentColor"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

View File

@@ -1,37 +0,0 @@
<svg viewBox="0 0 254.5 225" xmlns="http://www.w3.org/2000/svg">
<g>
<g>
<g>
<g>
<path fill="#00ACD7" d="M40.2,101.1c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l35.7,0c0.4,0,0.5,0.3,0.3,0.6 l-1.7,2.6c-0.2,0.3-0.7,0.6-1,0.6L40.2,101.1z"/>
</g>
</g>
</g>
<g>
<g>
<g>
<path fill="#00ACD7" d="M25.1,110.3c-0.4,0-0.5-0.2-0.3-0.5l2.1-2.7c0.2-0.3,0.7-0.5,1.1-0.5l45.6,0c0.4,0,0.6,0.3,0.5,0.6 l-0.8,2.4c-0.1,0.4-0.5,0.6-0.9,0.6L25.1,110.3z"/>
</g>
</g>
</g>
<g>
<g>
<g>
<path fill="#00ACD7" d="M49.3,119.5c-0.4,0-0.5-0.3-0.3-0.6l1.4-2.5c0.2-0.3,0.6-0.6,1-0.6l20,0c0.4,0,0.6,0.3,0.6,0.7l-0.2,2.4 c0,0.4-0.4,0.7-0.7,0.7L49.3,119.5z"/>
</g>
</g>
</g>
<g>
<g id="CXHf1q_3_">
<g>
<g>
<path fill="#00ACD7" d="M153.1,99.3c-6.3,1.6-10.6,2.8-16.8,4.4c-1.5,0.4-1.6,0.5-2.9-1c-1.5-1.7-2.6-2.8-4.7-3.8 c-6.3-3.1-12.4-2.2-18.1,1.5c-6.8,4.4-10.3,10.9-10.2,19c0.1,8,5.6,14.6,13.5,15.7c6.8,0.9,12.5-1.5,17-6.6 c0.9-1.1,1.7-2.3,2.7-3.7c-3.6,0-8.1,0-19.3,0c-2.1,0-2.6-1.3-1.9-3c1.3-3.1,3.7-8.3,5.1-10.9c0.3-0.6,1-1.6,2.5-1.6 c5.1,0,23.9,0,36.4,0c-0.2,2.7-0.2,5.4-0.6,8.1c-1.1,7.2-3.8,13.8-8.2,19.6c-7.2,9.5-16.6,15.4-28.5,17 c-9.8,1.3-18.9-0.6-26.9-6.6c-7.4-5.6-11.6-13-12.7-22.2c-1.3-10.9,1.9-20.7,8.5-29.3c7.1-9.3,16.5-15.2,28-17.3 c9.4-1.7,18.4-0.6,26.5,4.9c5.3,3.5,9.1,8.3,11.6,14.1C154.7,98.5,154.3,99,153.1,99.3z"/>
</g>
<g>
<path fill="#00ACD7" d="M186.2,154.6c-9.1-0.2-17.4-2.8-24.4-8.8c-5.9-5.1-9.6-11.6-10.8-19.3c-1.8-11.3,1.3-21.3,8.1-30.2 c7.3-9.6,16.1-14.6,28-16.7c10.2-1.8,19.8-0.8,28.5,5.1c7.9,5.4,12.8,12.7,14.1,22.3c1.7,13.5-2.2,24.5-11.5,33.9 c-6.6,6.7-14.7,10.9-24,12.8C191.5,154.2,188.8,154.3,186.2,154.6z M210,114.2c-0.1-1.3-0.1-2.3-0.3-3.3 c-1.8-9.9-10.9-15.5-20.4-13.3c-9.3,2.1-15.3,8-17.5,17.4c-1.8,7.8,2,15.7,9.2,18.9c5.5,2.4,11,2.1,16.3-0.6 C205.2,129.2,209.5,122.8,210,114.2z"/>
</g>
</g>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -4,15 +4,12 @@ import amazonMskUrl from '@/assets/Logos/amazon-msk.svg';
import androidJavaMonitoringUrl from '@/assets/Logos/android-java-monitoring.svg';
import androidKotlinMonitoringUrl from '@/assets/Logos/android-kotlin-monitoring.svg';
import anthropicApiMonitoringUrl from '@/assets/Logos/anthropic-api-monitoring.svg';
import apacheDruidUrl from '@/assets/Logos/apache-druid.svg';
import apiGatewayUrl from '@/assets/Logos/api-gateway.svg';
import argocdUrl from '@/assets/Logos/argocd.svg';
import aspnetUrl from '@/assets/Logos/aspnet.svg';
import autogenUrl from '@/assets/Logos/autogen.svg';
import awsAlbUrl from '@/assets/Logos/aws-alb.svg';
import azureAppServiceUrl from '@/assets/Logos/azure-app-service.svg';
import azureBlobStorageUrl from '@/assets/Logos/azure-blob-storage.svg';
import azureCdnFrontdoorUrl from '@/assets/Logos/azure-cdn-frontdoor.svg';
import azureContainerAppsUrl from '@/assets/Logos/azure-container-apps.svg';
import azureFunctionsUrl from '@/assets/Logos/azure-functions.svg';
import azureMysqlUrl from '@/assets/Logos/azure-mysql.svg';
@@ -21,7 +18,6 @@ import azureSqlDatabaseMetricsUrl from '@/assets/Logos/azure-sql-database-metric
import azureVmUrl from '@/assets/Logos/azure-vm.svg';
import basetenUrl from '@/assets/Logos/baseten.svg';
import celeryUrl from '@/assets/Logos/celery.svg';
import certManagerUrl from '@/assets/Logos/cert-manager.svg';
import claudeCodeUrl from '@/assets/Logos/claude-code.svg';
import clickhouseUrl from '@/assets/Logos/clickhouse.svg';
import cloudflareUrl from '@/assets/Logos/cloudflare.svg';
@@ -68,7 +64,6 @@ import goUrl from '@/assets/Logos/go.svg';
import googleAdkUrl from '@/assets/Logos/google-adk.svg';
import googleGeminiUrl from '@/assets/Logos/google-gemini.svg';
import grafanaUrl from '@/assets/Logos/grafana.svg';
import graphqlUrl from '@/assets/Logos/graphql.svg';
import grokUrl from '@/assets/Logos/grok.svg';
import groqUrl from '@/assets/Logos/groq.svg';
import hasuraUrl from '@/assets/Logos/hasura.svg';
@@ -80,7 +75,6 @@ import httpUrl from '@/assets/Logos/http.svg';
import httpMonitoringUrl from '@/assets/Logos/http-monitoring.svg';
import huggingfaceUrl from '@/assets/Logos/huggingface.svg';
import inkeepUrl from '@/assets/Logos/inkeep.svg';
import istioUrl from '@/assets/Logos/istio.svg';
import javaUrl from '@/assets/Logos/java.svg';
import javaOthersUrl from '@/assets/Logos/java-others.svg';
import javascriptUrl from '@/assets/Logos/javascript.svg';
@@ -127,7 +121,6 @@ import pythonUrl from '@/assets/Logos/python.svg';
import quarkusUrl from '@/assets/Logos/quarkus.svg';
import quickstartUrl from '@/assets/Logos/quickstart.svg';
import qwenUrl from '@/assets/Logos/qwen.svg';
import railwayUrl from '@/assets/Logos/railway.svg';
import rdsUrl from '@/assets/Logos/rds.svg';
import reactjsUrl from '@/assets/Logos/reactjs.svg';
import redisUrl from '@/assets/Logos/redis.svg';
@@ -135,9 +128,7 @@ import renderUrl from '@/assets/Logos/render.svg';
import rubyOnRailsUrl from '@/assets/Logos/ruby-on-rails.svg';
import rustUrl from '@/assets/Logos/rust.svg';
import s3Url from '@/assets/Logos/s3.svg';
import scalaUrl from '@/assets/Logos/scala.svg';
import signozBrandLogoUrl from '@/assets/Logos/signoz-brand-logo.svg';
import slogUrl from '@/assets/Logos/slog.svg';
import slurmUrl from '@/assets/Logos/slurm.svg';
import snowflakeUrl from '@/assets/Logos/snowflake.svg';
import snsUrl from '@/assets/Logos/sns.svg';
@@ -3011,18 +3002,9 @@ const onboardingConfigWithLinks = [
'tracing',
],
question: {
desc: 'How would you like to set up Azure Blob Storage monitoring?',
desc: 'What telemetry data do you want to visualise ?',
type: 'select',
helpText:
'One Click uses Azure integration for automated setup. Manual setup uses OpenTelemetry for more control.',
options: [
{
key: 'azure-blob-storage-one-click',
label: 'One Click Azure',
imgUrl: azureBlobStorageUrl,
link: '/integrations/azure?service=storageaccountsblob',
internalRedirect: true,
},
{
key: 'logging',
label: 'Logs',
@@ -3038,32 +3020,6 @@ const onboardingConfigWithLinks = [
],
},
},
{
dataSource: 'azure-cdn-frontdoor',
label: 'Azure CDN / Front Door',
imgUrl: azureCdnFrontdoorUrl,
tags: ['Azure'],
module: 'dashboards',
relatedSearchKeywords: [
'azure',
'azure cdn',
'azure cdn frontdoor',
'azure cdn metrics',
'azure cdn monitoring',
'azure front door',
'azure frontdoor',
'cdn',
'cdn monitoring',
'cdn observability',
'content delivery network',
'front door',
'frontdoor',
'one click',
],
id: 'azure-cdn-frontdoor',
link: '/integrations/azure?service=cdnprofile',
internalRedirect: true,
},
{
dataSource: 'azure-mysql-flexible-server',
label: 'Azure MySQL Flexible Server',
@@ -5658,22 +5614,17 @@ const onboardingConfigWithLinks = [
dataSource: 'fly-io',
label: 'Fly.io',
imgUrl: flyIoUrl,
tags: ['infrastructure monitoring', 'metrics', 'logs'],
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'cloud',
'fly',
'fly.io',
'fly.io logs',
'fly.io metrics',
'fly.io monitoring',
'fly.io observability',
'infrastructure',
'logs',
'fly',
'metrics',
'infrastructure',
'cloud',
'monitoring',
],
link: '/docs/integrations/flyio/',
link: '/docs/metrics-management/fly-metrics/',
},
{
dataSource: 'envoy',
@@ -6295,194 +6246,5 @@ const onboardingConfigWithLinks = [
id: 'render-metrics',
link: '/docs/metrics-management/render-metrics/',
},
{
dataSource: 'cert-manager',
label: 'Cert Manager',
imgUrl: certManagerUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'cert manager',
'cert-manager',
'certificate',
'certificate management',
'certificate monitoring',
'infrastructure',
'kubernetes',
'kubernetes certificates',
'metrics',
'monitoring',
'observability',
'ssl',
'tls',
],
id: 'cert-manager',
link: '/docs/infrastructure-monitoring/cert-manager/',
},
{
dataSource: 'graphql',
label: 'GraphQL',
imgUrl: graphqlUrl,
tags: ['apm/traces'],
module: 'apm',
relatedSearchKeywords: [
'api',
'graphql',
'graphql instrumentation',
'graphql monitoring',
'graphql observability',
'graphql tracing',
'javascript',
'monitoring',
'nodejs',
'observability',
'opentelemetry graphql',
'traces',
'tracing',
],
id: 'graphql',
link: '/docs/instrumentation/javascript/opentelemetry-graphql/',
},
{
dataSource: 'railway',
label: 'Railway',
imgUrl: railwayUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'cloud',
'log forwarding',
'logging',
'logs',
'monitoring',
'observability',
'paas',
'railway',
'railway logs',
'railway monitoring',
'railway observability',
],
id: 'railway',
link: '/docs/integrations/outposts/railway/',
},
{
dataSource: 'aspnet-core-metrics',
label: 'ASP.NET Core Metrics',
imgUrl: aspnetUrl,
tags: ['metrics'],
module: 'metrics',
relatedSearchKeywords: [
'.net metrics',
'asp.net',
'asp.net core',
'asp.net core metrics',
'asp.net metrics',
'asp.net monitoring',
'asp.net observability',
'aspnet',
'aspnet core',
'dotnet metrics',
'metrics',
'monitoring',
'observability',
'opentelemetry aspnet',
],
id: 'aspnet-core-metrics',
link:
'/docs/metrics-management/send-metrics/applications/opentelemetry-aspnetcore/',
},
{
dataSource: 'istio-metrics',
label: 'Istio',
imgUrl: istioUrl,
tags: ['infrastructure monitoring', 'metrics'],
module: 'metrics',
relatedSearchKeywords: [
'infrastructure',
'istio',
'istio metrics',
'istio monitoring',
'istio observability',
'kubernetes',
'mesh',
'metrics',
'monitoring',
'observability',
'service mesh',
],
id: 'istio-metrics',
link: '/docs/metrics-management/istio-metrics/',
},
{
dataSource: 'slog',
label: 'log/slog',
imgUrl: slogUrl,
tags: ['logs'],
module: 'logs',
relatedSearchKeywords: [
'go',
'go logging',
'go logs',
'golang',
'golang logging',
'log/slog',
'logging',
'logs',
'monitoring',
'observability',
'slog',
'slog instrumentation',
'slog logging',
'structured logging',
],
id: 'slog',
link: '/docs/logs-management/send-logs/slog-to-signoz/',
},
{
dataSource: 'scala',
label: 'Scala',
imgUrl: scalaUrl,
tags: ['apm/traces'],
module: 'apm',
relatedSearchKeywords: [
'apm',
'instrumentation',
'jvm',
'monitoring',
'observability',
'opentelemetry scala',
'scala',
'scala instrumentation',
'scala monitoring',
'scala observability',
'scala tracing',
'traces',
'tracing',
],
id: 'scala',
link: '/docs/instrumentation/java/opentelemetry-scala/',
},
{
dataSource: 'apache-druid',
label: 'Apache Druid',
imgUrl: apacheDruidUrl,
tags: ['database'],
module: 'apm',
relatedSearchKeywords: [
'analytics',
'apache druid',
'database',
'druid',
'druid instrumentation',
'druid monitoring',
'druid observability',
'monitoring',
'observability',
'olap',
'opentelemetry druid',
],
id: 'apache-druid',
link: '/docs/integrations/opentelemetry-apache-druid/',
},
];
export default onboardingConfigWithLinks;

View File

@@ -14,6 +14,40 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard (v2)",
Description: "This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},
Summary: "Get dashboard (v2)",
Description: "This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -49,6 +49,14 @@ type Module interface {
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error)
statsreporter.StatsCollector
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
}
type Handler interface {
@@ -71,4 +79,11 @@ type Handler interface {
LockUnlock(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -24,9 +25,10 @@ type module struct {
analytics analytics.Analytics
orgGetter organization.Getter
queryParser queryparser.QueryParser
tagModule tag.Module
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard")
return &module{
store: store,
@@ -34,6 +36,7 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
analytics: analytics,
orgGetter: orgGetter,
queryParser: queryParser,
tagModule: tagModule,
}
}

View File

@@ -21,7 +21,7 @@ func NewStore(sqlstore sqlstore.SQLStore) dashboardtypes.Store {
func (store *store) Create(ctx context.Context, storabledashboard *dashboardtypes.StorableDashboard) error {
_, err := store.
sqlstore.
BunDB().
BunDBCtx(ctx).
NewInsert().
Model(storabledashboard).
Exec(ctx)

View File

@@ -0,0 +1,82 @@
package impldashboard
import (
"context"
"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/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
var req dashboardtypes.PostableDashboardV2
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, dashboard.ToGettableDashboardV2())
}
func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.GetV2(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}

View File

@@ -0,0 +1,53 @@
package impldashboard
import (
"context"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
dashboard := postable.NewDashboardV2WithoutTags(orgID, createdBy)
var storableDashboard *dashboardtypes.StorableDashboard
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
resolvedTags, err := m.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Metadata.Tags)
if err != nil {
return err
}
dashboard.Data.Metadata.Tags = resolvedTags
storable, err := dashboard.ToStorableDashboard()
if err != nil {
return err
}
storableDashboard = storable
return m.store.Create(ctx, storable)
})
if err != nil {
return nil, err
}
m.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
return dashboard, nil
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, err := module.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
if err != nil {
return nil, err
}
return storable.ToDashboardV2(tags)
}

View File

@@ -49,7 +49,7 @@ func TestNewHandlers(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)

View File

@@ -50,7 +50,7 @@ func TestNewModules(t *testing.T) {
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)

View File

@@ -33,6 +33,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
@@ -107,7 +108,7 @@ func New(
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, authz.Config, licensing.Licensing, []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
@@ -339,8 +340,8 @@ func New(
// where needed.
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
// Initialize dashboard module
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
// Initialize user getter
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)

View File

@@ -0,0 +1,272 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
const (
SchemaVersion = "v6"
MaxTagsPerDashboard = 5
)
type DSLKey string
const (
DSLKeyName DSLKey = "name"
DSLKeyDescription DSLKey = "description"
DSLKeyCreatedAt DSLKey = "created_at"
DSLKeyUpdatedAt DSLKey = "updated_at"
DSLKeyCreatedBy DSLKey = "created_by"
DSLKeyLocked DSLKey = "locked"
DSLKeyPublic DSLKey = "public"
)
// reservedDSLKeys are dashboard column-level filter names in the list-query DSL.
// A tag whose key collides with one of these would make the DSL ambiguous, so
// they're rejected (case-insensitively) at write time.
var reservedDSLKeys = map[DSLKey]struct{}{
DSLKeyName: {},
DSLKeyDescription: {},
DSLKeyCreatedAt: {},
DSLKeyUpdatedAt: {},
DSLKeyCreatedBy: {},
DSLKeyLocked: {},
DSLKeyPublic: {},
}
type DashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Data DashboardV2Data `json:"data"`
}
type DashboardV2Data struct {
Metadata DashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
type DashboardV2Metadata struct {
DashboardV2MetadataBase
Tags []*tagtypes.Tag `json:"tags"`
}
type DashboardV2MetadataBase struct {
SchemaVersion string `json:"schemaVersion"`
Image string `json:"image,omitempty"`
}
// ════════════════════════════════════════════════════════════════════════
// Postable
// ════════════════════════════════════════════════════════════════════════
type PostableDashboardV2 struct {
Metadata PostableDashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
func (postable PostableDashboardV2) NewDashboardV2WithoutTags(orgID valuer.UUID, createdBy string) *DashboardV2 {
now := time.Now()
return &DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: createdBy},
OrgID: orgID,
Locked: false,
Data: DashboardV2Data{
Metadata: postable.Metadata.toDashboardV2Metadata(orgID),
Spec: postable.Spec,
},
}
}
type PostableDashboardV2Metadata struct {
DashboardV2MetadataBase
Tags []tagtypes.PostableTag `json:"tags"`
}
func (m PostableDashboardV2Metadata) toDashboardV2Metadata(orgID valuer.UUID) DashboardV2Metadata {
return DashboardV2Metadata{
DashboardV2MetadataBase: m.DashboardV2MetadataBase,
Tags: tagtypes.NewTagsFromPostableTags(orgID, coretypes.KindDashboard, m.Tags),
}
}
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboardV2(tmp)
return p.Validate()
}
func (p *PostableDashboardV2) Validate() error {
if p.Metadata.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "metadata.schemaVersion must be %q, got %q", SchemaVersion, p.Metadata.SchemaVersion)
}
if p.Spec.Display == nil || p.Spec.Display.Name == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.display.name is required")
}
if err := p.validateTags(); err != nil {
return err
}
return p.Spec.Validate()
}
func (p *PostableDashboardV2) validateTags() error {
if len(p.Metadata.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
for _, tag := range p.Metadata.Tags {
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
}
}
return nil
}
// ════════════════════════════════════════════════════════════════════════
// Gettable
// ════════════════════════════════════════════════════════════════════════
type GettableDashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Data GettableDashboardV2Data `json:"data"`
}
type GettableDashboardV2Data struct {
Metadata GettableDashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
type GettableDashboardV2Metadata struct {
DashboardV2MetadataBase
Tags []*tagtypes.GettableTag `json:"tags"`
}
func (d DashboardV2) ToGettableDashboardV2() GettableDashboardV2 {
return GettableDashboardV2{
Identifiable: d.Identifiable,
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Data: d.Data.toGettableDashboardData(),
}
}
func (d DashboardV2Data) toGettableDashboardData() GettableDashboardV2Data {
return GettableDashboardV2Data{
Metadata: GettableDashboardV2Metadata{
DashboardV2MetadataBase: d.Metadata.DashboardV2MetadataBase,
Tags: tagtypes.NewGettableTagsFromTags(d.Metadata.Tags),
},
Spec: d.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Storable
// ════════════════════════════════════════════════════════════════════════
// StorableDashboardV2Data is exactly what serializes into the dashboard.data column.
type StorableDashboardV2Data struct {
Metadata StorableDashboardV2Metadata `json:"metadata"`
Spec DashboardSpec `json:"spec"`
}
func (s StorableDashboardV2Data) toStorableDashboardData() (StorableDashboardData, error) {
raw, err := json.Marshal(s)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v2 dashboard data")
}
out := StorableDashboardData{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal v2 dashboard data")
}
return out, nil
}
type StorableDashboardV2Metadata = DashboardV2MetadataBase
func (stored StorableDashboardV2Data) toDashboardV2Data(tags []*tagtypes.Tag) DashboardV2Data {
return DashboardV2Data{
Metadata: DashboardV2Metadata{
DashboardV2MetadataBase: stored.Metadata,
Tags: tags,
},
Spec: stored.Spec,
}
}
// ════════════════════════════════════════════════════════════════════════
// Convertors
// ════════════════════════════════════════════════════════════════════════
func (d *DashboardV2) ToStorableDashboard() (*StorableDashboard, error) {
storableDashboardV2Data := StorableDashboardV2Data{
Metadata: StorableDashboardV2Metadata{
SchemaVersion: d.Data.Metadata.SchemaVersion,
Image: d.Data.Metadata.Image,
},
Spec: d.Data.Spec,
}
data, err := storableDashboardV2Data.toStorableDashboardData()
if err != nil {
return nil, err
}
return &StorableDashboard{
Identifiable: types.Identifiable{ID: d.ID},
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Data: data,
}, nil
}
func (storable StorableDashboard) ToDashboardV2(tags []*tagtypes.Tag) (*DashboardV2, error) {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil || metadata["schemaVersion"] != SchemaVersion {
return nil, errors.Newf(errors.TypeUnsupported, ErrCodeDashboardInvalidData, "dashboard %s is not in %s schema", storable.ID, SchemaVersion)
}
raw, err := json.Marshal(storable.Data)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal stored v2 dashboard data")
}
var stored StorableDashboardV2Data
if err := json.Unmarshal(raw, &stored); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal stored v2 dashboard data")
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Data: stored.toDashboardV2Data(tags),
}, nil
}

View File

@@ -12,11 +12,11 @@ import (
"github.com/perses/perses/pkg/model/api/v1/common"
)
// DashboardData is the SigNoz dashboard v2 spec shape. It mirrors
// DashboardSpec is the SigNoz dashboard v2 spec shape. It mirrors
// v1.DashboardSpec (Perses) field-for-field, except every common.Plugin
// occurrence is replaced with a typed SigNoz plugin whose OpenAPI schema is a
// per-site discriminated oneOf.
type DashboardData struct {
type DashboardSpec struct {
Display *common.Display `json:"display,omitempty"`
Datasources map[string]*DatasourceSpec `json:"datasources,omitempty"`
Variables []Variable `json:"variables,omitempty"`
@@ -31,15 +31,15 @@ type DashboardData struct {
// Unmarshal + validate entry point
// ══════════════════════════════════════════════
func (d *DashboardData) UnmarshalJSON(data []byte) error {
func (d *DashboardSpec) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias DashboardData
type alias DashboardSpec
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "invalid dashboard spec")
}
*d = DashboardData(tmp)
*d = DashboardSpec(tmp)
return d.Validate()
}
@@ -47,7 +47,7 @@ func (d *DashboardData) UnmarshalJSON(data []byte) error {
// Cross-field validation
// ══════════════════════════════════════════════
func (d *DashboardData) Validate() error {
func (d *DashboardSpec) Validate() error {
for key, panel := range d.Panels {
if panel == nil {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "spec.panels.%s: panel must not be null", key)

View File

@@ -12,8 +12,8 @@ import (
"github.com/stretchr/testify/require"
)
func unmarshalDashboard(data []byte) (*DashboardData, error) {
var d DashboardData
func unmarshalDashboard(data []byte) (*DashboardSpec, error) {
var d DashboardSpec
if err := json.Unmarshal(data, &d); err != nil {
return nil, err
}
@@ -40,7 +40,7 @@ func TestInvalidateNotAJSON(t *testing.T) {
}
// TestUnmarshalErrorPreservesNestedMessage guards the wrap on dec.Decode in
// DashboardData.UnmarshalJSON. The wrap stamps a consistent type/code on
// DashboardSpec.UnmarshalJSON. The wrap stamps a consistent type/code on
// decode failures, but must not smother the rich messages produced by nested
// UnmarshalJSON methods (panel/query/variable/datasource plugin envelopes).
func TestUnmarshalErrorPreservesNestedMessage(t *testing.T) {
@@ -820,7 +820,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
raw, err := os.ReadFile("testdata/perses.json")
require.NoError(t, err)
var data DashboardData
var data DashboardSpec
require.NoError(t, json.Unmarshal(raw, &data), "initial unmarshal")
marshaled, err := json.Marshal(data)
@@ -832,7 +832,7 @@ func TestPersesFixtureStorageRoundTrip(t *testing.T) {
remarshaled, err := json.Marshal(asMap)
require.NoError(t, err, "map → JSON (read-back shape)")
var roundtripped DashboardData
var roundtripped DashboardSpec
require.NoError(t, json.Unmarshal(remarshaled, &roundtripped), "JSON → typed (the failure mode)")
}

View File

@@ -1,6 +1,6 @@
package dashboardtypes
// TestDashboardDataMatchesPerses asserts that DashboardData
// TestDashboardSpecMatchesPerses asserts that DashboardData
// and every nested SigNoz-owned type cover the JSON field set of their Perses
// counterpart.
@@ -16,13 +16,13 @@ import (
"github.com/stretchr/testify/assert"
)
func TestDashboardDataMatchesPerses(t *testing.T) {
func TestDashboardSpecMatchesPerses(t *testing.T) {
cases := []struct {
name string
ours reflect.Type
perses reflect.Type
}{
{"DashboardSpec", typeOf[DashboardData](), typeOf[v1.DashboardSpec]()},
{"DashboardSpec", typeOf[DashboardSpec](), typeOf[v1.DashboardSpec]()},
{"Panel", typeOf[Panel](), typeOf[v1.Panel]()},
{"PanelSpec", typeOf[PanelSpec](), typeOf[v1.PanelSpec]()},
{"Query", typeOf[Query](), typeOf[v1.Query]()},
@@ -38,10 +38,10 @@ func TestDashboardDataMatchesPerses(t *testing.T) {
missing, extra := drift(c.ours, c.perses)
assert.Empty(t, missing,
"DashboardData (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
"DashboardSpec (%s) is missing json fields present on Perses %s — upstream likely added or renamed a field",
c.ours.Name(), c.perses.Name())
assert.Empty(t, extra,
"DashboardData (%s) has json fields absent on Perses %s — upstream likely removed a field or we added one without the counterpart",
"DashboardSpec (%s) has json fields absent on Perses %s — upstream likely removed a field or we added one without the counterpart",
c.ours.Name(), c.perses.Name())
})
}

View File

@@ -10,7 +10,7 @@ import (
type Clusters struct {
Type ResponseType `json:"type" required:"true"`
Records []ClusterRecord `json:"records" required:"true" nullable:"false"`
Records []ClusterRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type DaemonSets struct {
Type ResponseType `json:"type" required:"true"`
Records []DaemonSetRecord `json:"records" required:"true" nullable:"false"`
Records []DaemonSetRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type Deployments struct {
Type ResponseType `json:"type" required:"true"`
Records []DeploymentRecord `json:"records" required:"true" nullable:"false"`
Records []DeploymentRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type Hosts struct {
Type ResponseType `json:"type" required:"true"`
Records []HostRecord `json:"records" required:"true" nullable:"false"`
Records []HostRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type Jobs struct {
Type ResponseType `json:"type" required:"true"`
Records []JobRecord `json:"records" required:"true" nullable:"false"`
Records []JobRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type Namespaces struct {
Type ResponseType `json:"type" required:"true"`
Records []NamespaceRecord `json:"records" required:"true" nullable:"false"`
Records []NamespaceRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type Nodes struct {
Type ResponseType `json:"type" required:"true"`
Records []NodeRecord `json:"records" required:"true" nullable:"false"`
Records []NodeRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type Pods struct {
Type ResponseType `json:"type" required:"true"`
Records []PodRecord `json:"records" required:"true" nullable:"false"`
Records []PodRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type StatefulSets struct {
Type ResponseType `json:"type" required:"true"`
Records []StatefulSetRecord `json:"records" required:"true" nullable:"false"`
Records []StatefulSetRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -10,7 +10,7 @@ import (
type Volumes struct {
Type ResponseType `json:"type" required:"true"`
Records []VolumeRecord `json:"records" required:"true" nullable:"false"`
Records []VolumeRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`

View File

@@ -69,6 +69,14 @@ func NewPostableTagsFromTags(tags []*Tag) []PostableTag {
return out
}
func NewTagsFromPostableTags(orgID valuer.UUID, kind coretypes.Kind, tags []PostableTag) []*Tag {
out := make([]*Tag, len(tags))
for i, t := range tags {
out[i] = NewTag(orgID, kind, t.Key, t.Value)
}
return out
}
func NewTag(orgID valuer.UUID, kind coretypes.Kind, key, value string) *Tag {
now := time.Now()
return &Tag{

View File

@@ -79,17 +79,6 @@ export const test = base.extend<{
const storageState = await storageFor(browser, user);
const ctx = await browser.newContext({ storageState });
const page = await ctx.newPage();
// Opt-in CPU throttling to reproduce GitHub-Linux-runner conditions on
// developer machines. Set `STRESS=1` (typically with `CI=1` to also get
// 2 workers + 2 retries) before running the suite — see CI-HARDENING.md.
// The rate is the CPU slowdown multiplier; 4× matches the 2 vCPU runner.
const throttleRate = Number(process.env.STRESS_CPU_RATE ?? '4');
if (process.env.STRESS === '1') {
const client = await ctx.newCDPSession(page);
await client.send('Emulation.setCPUThrottlingRate', {
rate: throttleRate,
});
}
await use(page);
await ctx.close();
},

View File

@@ -1,15 +1,8 @@
import path from 'path';
import {
expect,
type APIRequestContext,
type Locator,
type Page,
} from '@playwright/test';
import type { APIRequestContext, Locator, Page } from '@playwright/test';
import apmMetricsTemplate from '../testdata/apm-metrics.json';
import chartDataTemplate from '../testdata/chart-data-dashboard.json';
import variablesTemplate from '../testdata/variables-dashboard.json';
// ─── Constants ───────────────────────────────────────────────────────────
//
@@ -90,209 +83,6 @@ export async function createDashboardViaApi(
return postDashboard(page, { title, uploadedGrafana: false });
}
/**
* Generic helper: POST a dashboard with the given title, then PUT the full
* `data` payload (variables / widgets / layout / version) at
* `/dashboards/<id>`. The two-step dance is required because POST silently
* drops everything except `{title, uploadedGrafana, version}` — the SigNoz UI
* itself uses the same pattern.
*/
async function loadDashboardFromTemplate(
page: Page,
title: string,
template: Record<string, unknown>,
): Promise<string> {
const id = await postDashboard(page, { title, uploadedGrafana: false });
const token = await authToken(page);
const putRes = await page.request.put(`/api/v1/dashboards/${id}`, {
data: { ...template, title },
headers: { Authorization: `Bearer ${token}` },
});
if (!putRes.ok()) {
throw new Error(
`PUT /dashboards/${id} ${putRes.status()}: ${await putRes.text()}`,
);
}
return id;
}
/**
* Seed a dashboard exercising every variable type (TEXTBOX × 2, CUSTOM × 3,
* QUERY × 2, DYNAMIC × 1) via the JSON fixture under
* `tests/e2e/testdata/variables-dashboard.json`. Used by Group 3
* (detail-variables) and Group 9 (detail-configure "lists existing
* variables") tests. URL state keys variables by `name`, not `id`, so the
* assertions look up `tb_env` / `cu_env_all` / etc. directly.
*/
export async function createVariablesDashboardViaApi(
page: Page,
title: string,
): Promise<string> {
return loadDashboardFromTemplate(
page,
title,
variablesTemplate as Record<string, unknown>,
);
}
/**
* Seed APM Metrics directly via the API — much faster than driving the
* Import-JSON UI flow. Use this for any test that just needs APM Metrics on
* the canvas; reserve `importApmMetricsDashboardViaUI` for tests that
* actually exercise the import flow itself.
*/
export async function createApmMetricsDashboardViaApi(
page: Page,
): Promise<string> {
return loadDashboardFromTemplate(
page,
APM_METRICS_TITLE,
apmMetricsTemplate as Record<string, unknown>,
);
}
/**
* Seed a single-panel "E2E Metric RPS" dashboard that queries the
* `signoz_e2e_metric` counter without any variable substitution. Pair with
* `seedMetricsViaSeeder` to populate the metric, then assert chart-data
* rendering. Title is fixed by the JSON fixture.
*/
export async function createChartDataDashboardViaApi(
page: Page,
): Promise<string> {
return loadDashboardFromTemplate(
page,
(chartDataTemplate as { title: string }).title,
chartDataTemplate as Record<string, unknown>,
);
}
// ─── Seeder API ───────────────────────────────────────────────────────────
//
// The pytest harness brings up an HTTP seeder container exposing
// POST/DELETE on /telemetry/{traces,logs,metrics}. Its URL is written to
// `tests/e2e/.env.local` as `SIGNOZ_E2E_SEEDER_URL` and read here from the
// process environment.
/** Minimal shape the seeder accepts for a single metric sample. */
export interface SeederMetric {
metric_name: string;
labels: Record<string, string>;
timestamp: string;
value: number;
temporality?: 'Cumulative' | 'Delta' | 'Unspecified';
type_?: 'Sum' | 'Gauge' | 'Histogram' | 'Summary';
is_monotonic?: boolean;
description?: string;
unit?: string;
}
function seederUrl(): string {
const url = process.env.SIGNOZ_E2E_SEEDER_URL;
if (!url) {
throw new Error(
'SIGNOZ_E2E_SEEDER_URL not set — pytest test_setup must be running.',
);
}
return url;
}
/**
* POST a batch of metrics into the seeder. The seeder writes them directly
* into ClickHouse, bypassing the OTLP collector. Use this for tests that need
* panel queries to return non-empty results.
*/
export async function seedMetricsViaSeeder(
page: Page,
metrics: SeederMetric[],
): Promise<void> {
const res = await page.request.post(`${seederUrl()}/telemetry/metrics`, {
data: metrics,
headers: { 'Content-Type': 'application/json' },
});
if (!res.ok()) {
throw new Error(
`seeder POST /telemetry/metrics ${res.status()}: ${await res.text()}`,
);
}
}
/**
* Truncate the metrics tables in ClickHouse via the seeder. Use in
* `afterAll` for tests that mutate global telemetry state — the bootstrap
* stack is shared across specs, so leftover seeded rows could affect
* neighbouring suites.
*/
export async function clearMetricsViaSeeder(page: Page): Promise<void> {
await page.request.delete(`${seederUrl()}/telemetry/metrics`);
}
/**
* Wait for every variable in the persisted dashboard JSON to have a
* "resolved" state — `selectedValue` populated, or `allSelected: true` for
* showALLOption variables. This is the seam tests should cross before
* acting: if a variable has a default in the seed, it's resolved immediately;
* if it has no default (QUERY / DYNAMIC depending on backend resolution), the
* UI's variable-select widget queries the backend, then writes the resolved
* value back into the dashboard's variables map. Tests that share a dashboard
* via `mode: 'serial'` must call this between tests so they don't race
* against an in-flight resolve.
*
* Variables listed in `skipNames` are exempt — typically those that depend on
* seeded telemetry the bootstrap stack does not produce (Dynamic; cascading
* Query against an unresolved parent). Pass them so the wait does not block
* indefinitely on values that can never appear.
*/
export async function awaitVariablesResolved(
page: Page,
dashboardId: string,
options?: { skipNames?: string[]; timeout?: number },
): Promise<void> {
const skip = new Set(options?.skipNames ?? []);
const timeout = options?.timeout ?? 15_000;
const token = await authToken(page);
const isResolved = (v: Record<string, unknown>): boolean => {
if (skip.has(String(v.name))) {
return true;
}
if (v.allSelected === true) {
return true;
}
const sv = v.selectedValue;
if (sv === undefined || sv === null) {
return false;
}
if (Array.isArray(sv)) {
return sv.length > 0;
}
return typeof sv === 'string' ? sv.length > 0 : sv !== null;
};
await expect
.poll(
async () => {
const res = await page.request.get(`/api/v1/dashboards/${dashboardId}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok()) {
return false;
}
const body = (await res.json()) as {
data?: { data?: { variables?: Record<string, Record<string, unknown>> } };
};
const vars = body?.data?.data?.variables ?? {};
return Object.values(vars).every(isResolved);
},
{
timeout,
message:
'awaitVariablesResolved: dashboard.variables[*].selectedValue did not stabilise — pass `skipNames` for variables that require seeded telemetry',
},
)
.toBe(true);
}
/**
* Seed the APM Metrics dashboard by driving the real "Import JSON" UI flow:
* opens the New-dashboard dropdown, picks Import JSON, uploads the fixture

View File

@@ -1,84 +0,0 @@
{
"title": "detail-chart-data-suite",
"description": "Single Time Series panel querying `signoz_calls_total` (in the bootstrap golden seed) with no variable substitution. Used by chart-data assertion tests to verify the panel renders data without inline seeding.",
"tags": [],
"widgets": [
{
"bucketCount": 30,
"bucketWidth": 0,
"columnUnits": {},
"description": "",
"fillSpans": false,
"id": "11111111-1111-4111-8111-111111111111",
"isStacked": false,
"mergeAllActiveQueries": false,
"nullZeroValues": "zero",
"opacity": "1",
"panelTypes": "graph",
"query": {
"builder": {
"queryData": [
{
"aggregateAttribute": {
"dataType": "float64",
"id": "signoz_calls_total--float64--Sum--true",
"isColumn": true,
"isJSON": false,
"key": "signoz_calls_total",
"type": "Sum"
},
"aggregateOperator": "sum_rate",
"dataSource": "metrics",
"disabled": false,
"expression": "A",
"filters": { "items": [], "op": "AND" },
"functions": [],
"groupBy": [],
"having": [],
"legend": "rps",
"limit": null,
"orderBy": [],
"queryName": "A",
"reduceTo": "avg",
"spaceAggregation": "sum",
"stepInterval": 60,
"timeAggregation": "rate"
}
],
"queryFormulas": []
},
"clickhouse_sql": [
{ "disabled": false, "legend": "", "name": "A", "query": "" }
],
"id": "22222222-2222-4222-8222-222222222222",
"promql": [
{ "disabled": false, "legend": "", "name": "A", "query": "" }
],
"queryType": "builder"
},
"selectedLogFields": [
{ "dataType": "string", "name": "body", "type": "" },
{ "dataType": "string", "name": "timestamp", "type": "" }
],
"selectedTracesFields": [],
"softMax": null,
"softMin": null,
"stackedBarChart": false,
"thresholds": [],
"timePreferance": "GLOBAL_TIME",
"title": "E2E Metric RPS",
"yAxisUnit": "none"
}
],
"layout": [
{
"i": "11111111-1111-4111-8111-111111111111",
"x": 0,
"y": 0,
"w": 12,
"h": 6
}
],
"variables": {},
"version": "v4"
}

View File

@@ -1,136 +0,0 @@
{
"title": "detail-variables-suite",
"description": "Seed dashboard exercising every variable type — used by detail-variables and detail-configure specs.",
"tags": [],
"layout": [],
"widgets": [],
"version": "v4",
"variables": {
"00000000-0000-4000-8000-000000000001": {
"id": "00000000-0000-4000-8000-000000000001",
"name": "tb_env",
"order": 0,
"type": "TEXTBOX",
"description": "",
"textboxValue": "otel-demo",
"selectedValue": "otel-demo",
"customValue": "",
"queryValue": "",
"multiSelect": false,
"showALLOption": false,
"allSelected": false,
"sort": "DISABLED",
"modificationUUID": "00000000-0000-4000-8000-000000000101"
},
"00000000-0000-4000-8000-000000000002": {
"id": "00000000-0000-4000-8000-000000000002",
"name": "tb_service",
"order": 1,
"type": "TEXTBOX",
"description": "",
"textboxValue": "frontend",
"selectedValue": "frontend",
"customValue": "",
"queryValue": "",
"multiSelect": false,
"showALLOption": false,
"allSelected": false,
"sort": "DISABLED",
"modificationUUID": "00000000-0000-4000-8000-000000000102"
},
"00000000-0000-4000-8000-000000000003": {
"id": "00000000-0000-4000-8000-000000000003",
"name": "cu_single",
"order": 2,
"type": "CUSTOM",
"description": "",
"textboxValue": "",
"selectedValue": "otel-demo",
"customValue": "otel-demo,mq-kafka,production",
"queryValue": "",
"multiSelect": false,
"showALLOption": false,
"allSelected": false,
"sort": "DISABLED",
"modificationUUID": "00000000-0000-4000-8000-000000000103"
},
"00000000-0000-4000-8000-000000000004": {
"id": "00000000-0000-4000-8000-000000000004",
"name": "cu_env_all",
"order": 3,
"type": "CUSTOM",
"description": "",
"textboxValue": "",
"customValue": "otel-demo,mq-kafka,production",
"queryValue": "",
"multiSelect": true,
"showALLOption": true,
"allSelected": true,
"sort": "DISABLED",
"modificationUUID": "00000000-0000-4000-8000-000000000104"
},
"00000000-0000-4000-8000-000000000005": {
"id": "00000000-0000-4000-8000-000000000005",
"name": "cu_services",
"order": 4,
"type": "CUSTOM",
"description": "",
"textboxValue": "",
"selectedValue": ["adservice", "cartservice"],
"customValue": "adservice,cartservice,frontend",
"queryValue": "",
"multiSelect": true,
"showALLOption": false,
"allSelected": false,
"sort": "DISABLED",
"modificationUUID": "00000000-0000-4000-8000-000000000105"
},
"00000000-0000-4000-8000-000000000006": {
"id": "00000000-0000-4000-8000-000000000006",
"name": "q_env",
"order": 5,
"type": "QUERY",
"description": "",
"textboxValue": "",
"customValue": "",
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'deployment.environment') AS `deployment.environment` FROM signoz_metrics.time_series_v4_1day GROUP BY `deployment.environment`",
"multiSelect": false,
"showALLOption": false,
"allSelected": false,
"sort": "DISABLED",
"modificationUUID": "00000000-0000-4000-8000-000000000106"
},
"00000000-0000-4000-8000-000000000007": {
"id": "00000000-0000-4000-8000-000000000007",
"name": "q_service",
"order": 6,
"type": "QUERY",
"description": "",
"textboxValue": "",
"customValue": "",
"queryValue": "SELECT DISTINCT JSONExtractString(labels, 'service.name') AS `service.name` FROM signoz_metrics.time_series_v4_1day WHERE deployment_environment = $q_env GROUP BY `service.name`",
"multiSelect": true,
"showALLOption": false,
"allSelected": false,
"sort": "DISABLED",
"modificationUUID": "00000000-0000-4000-8000-000000000107"
},
"00000000-0000-4000-8000-000000000008": {
"id": "00000000-0000-4000-8000-000000000008",
"name": "d_namespace",
"order": 7,
"type": "DYNAMIC",
"description": "",
"textboxValue": "",
"customValue": "",
"queryValue": "",
"multiSelect": false,
"showALLOption": false,
"allSelected": false,
"sort": "DISABLED",
"dynamicVariablesAttribute": "k8s.namespace.name",
"dynamicVariablesSource": "metrics",
"modificationUUID": "00000000-0000-4000-8000-000000000108"
}
}
}

View File

@@ -1,206 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
createApmMetricsDashboardViaApi,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
const BASE_TITLE = 'detail-viewing-base';
let baseDashboardId = '';
let apmDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
baseDashboardId = await createDashboardViaApi(page, BASE_TITLE);
seedIds.add(baseDashboardId);
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoDetail(page: Page, id: string): Promise<void> {
await page.goto(`/dashboard/${id}`);
}
test.describe('Dashboard Detail Page — Viewing', () => {
test('TC-01 page chrome — breadcrumb, title, toolbar buttons render', async ({
authedPage: page,
}) => {
// Use the APM dashboard rather than the empty base — empty dashboards
// render an onboarding canvas with its own Configure / New Panel
// buttons, which duplicate the toolbar testids.
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(page).toHaveTitle(new RegExp(APM_METRICS_TITLE));
await expect(
page.getByRole('textbox', { name: /Last \d+/ }).first(),
).toBeVisible();
await expect(page.locator('.refresh-btn button')).toBeVisible();
await expect(
page.getByRole('button', { name: 'Set auto refresh' }),
).toBeVisible();
await expect(page.getByTestId('options')).toBeVisible();
await expect(page.getByTestId('show-drawer')).toBeVisible();
await expect(page.getByTestId('add-panel-header')).toBeVisible();
await expect(page.getByRole('button', { name: 'Feedback' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Share' })).toBeVisible();
});
test('TC-02 breadcrumb returns to /dashboard', async ({
authedPage: page,
}) => {
await gotoDetail(page, baseDashboardId);
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
// events at the breadcrumb's center, defeating even `force: true`.
// Dispatching the click directly on the DOM node bypasses hit testing.
await page
.getByRole('button', { name: 'Dashboard /' })
.dispatchEvent('click');
await expect(page).toHaveURL(/\/dashboard$/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
test('TC-03 tags bar renders for an imported dashboard', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
// `exact: true` is load-bearing — `apm` is a substring of the
// breadcrumb title `APM Metrics`, so a loose match would collide.
for (const tag of ['apm', 'latency', 'error rate', 'throughput']) {
await expect(page.getByText(tag, { exact: true })).toBeVisible();
}
});
test('TC-04 section row headers render for APM Metrics', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
// known behaviour: APM Metrics fixture has two sections both named
// "Overview" — `.first()` deliberately matches whichever renders first.
await expect(
page.getByText('Overview', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
});
test('TC-05 at least one panel container renders', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
});
test('TC-06 no JS pageerror during initial load', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
expect(errors).toHaveLength(0);
});
// ─── Cross-spec: connection with the dashboards-list page ────────────────
test('TC-07 navigating from the dashboards list lands on the detail page', async ({
authedPage: page,
}) => {
await page.goto('/dashboard');
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
await page
.getByPlaceholder('Search by name, description, or tags...')
.fill(APM_METRICS_TITLE);
await expect(page.getByText(APM_METRICS_TITLE).first()).toBeVisible();
const actionIcon = page.getByTestId('dashboard-action-icon').first();
await actionIcon.scrollIntoViewIfNeeded();
await actionIcon.click();
await page.getByRole('tooltip').getByRole('button', { name: 'View' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+/);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
});
});

View File

@@ -1,519 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// ─── Per-test seed lifecycle ────────────────────────────────────────────
//
// Each test gets its own freshly-seeded APM Metrics dashboard (4 sections,
// 16 panels — including the duplicate-named "Overview" sections, which the
// fixture intentionally ships). Per-test seeding eliminates the "previous
// test left the dashboard in a collapsed/renamed state" class of CI flakes
// that bit us repeatedly with `beforeAll`-shared seed: it is no longer
// possible for one test's restore PUT to race the next test's GET, because
// the next test does not see the previous test's dashboard at all.
//
// `serial` mode is no longer required for correctness (tests are hermetic)
// but we keep parallel runs intra-file because seed creation is the
// per-test cost — running them concurrently inside the worker would just
// pile up more concurrent dashboards without helping.
let apmDashboardId: string;
test.beforeEach(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
} finally {
await ctx.close();
}
});
test.afterEach(async ({ browser }) => {
if (!apmDashboardId) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
await deleteDashboardViaApi(ctx.request, apmDashboardId, token);
} catch {
// Best-effort cleanup — a failing delete should not mask test
// failures the user actually needs to see.
} finally {
apmDashboardId = '';
await ctx.close();
}
});
/**
* Resolve the `.row-panel` container for a section by traversing up from its
* title text. The fixture ships two sections both literally named "Overview"
* — pass `index` to disambiguate. Two `..` hops reach `.row-panel`, which
* holds both the chevron and the settings-icon for that row.
*/
function sectionRow(
page: Page,
name: string | RegExp,
index = 0,
): ReturnType<Page['locator']> {
return page
.getByText(name, { exact: typeof name === 'string' })
.nth(index)
.locator('..')
.locator('..');
}
async function gotoApmDashboard(page: Page): Promise<void> {
await page.goto(`/dashboard/${apmDashboardId}`);
await page
.getByRole('button', { name: /dashboard-icon APM Metrics/ })
.waitFor({ state: 'visible' });
// `GridCardLayout`'s auto-save `useEffect` (line 226 of the source) is
// gated on `!isDashboardFetching` but `isDashboardFetching` is NOT in the
// effect's dep array. Concretely: if a chevron is clicked while any
// `[REACT_QUERY_KEY.DASHBOARD_BY_ID]` query is in flight, the effect runs
// once for the new `dashboardLayout`, sees `isDashboardFetching=true`, and
// returns early — and never re-runs when the GET later completes, because
// `dashboardLayout` didn't change again. The PUT is *never* fired and
// `toggleSectionAndWaitForPut` blocks until the 30 s test timeout.
//
// Wait until the in-flight dashboard GETs settle so the effect's gate
// evaluates to `false` on the next click. We assert this two ways: a panel
// from each visible section must render (proves data is hydrated), and
// `Latency` (the first panel of the first Overview section) must paint.
await expect(page.getByText('Latency', { exact: true }).first()).toBeVisible({
timeout: 20_000,
});
}
/**
* Click `.row-icon` (chevron) on a section row. The collapse/expand state is
* driven by React local state — `setDashboardLayout` updates synchronously
* and the (suffixed / unsuffixed) title appears on the next render. We do
* NOT wait for the auto-save PUT here: it's gated on `!isDashboardFetching`
* in `GridCardLayout.tsx` and can be skipped entirely under CI load.
* Persistence does not matter because each test seeds a fresh dashboard.
*
* `dispatchEvent('click')` — under CI viewport the expanded sidenav's
* `nav-item-data` subtree intercepts pointer events at the chevron's
* position (verified in CI run #26162502354). `.click({ force: true })`
* still lands the event at the visual centre and is swallowed by the
* overlay; dispatching the click directly on the SVG node bypasses hit
* testing entirely and triggers React's `onClick` handler.
*/
async function toggleSection(row: ReturnType<Page['locator']>): Promise<void> {
const chevron = row.locator('.row-icon');
await chevron.scrollIntoViewIfNeeded();
await expect(chevron).toBeVisible();
const page = chevron.page();
// Register a PUT listener BEFORE the click. The auto-save effect in
// `GridCardLayout` fires a PUT when `!isDashboardFetching` — if the PUT
// arrives, its `onSuccess` triggers a brief loading-state re-render that
// unmounts every `.row-panel`. The next toggle's chevron lookup either
// misses (locator times out) or grabs a transient node that detaches
// during scroll. Sequencing: dispatch click → await PUT (3 s short
// timeout in case auto-save was gated) → wait for the loading spinner
// to be absent.
const putSettled = page
.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
{ timeout: 3_000 },
)
.catch(() => null);
await chevron.dispatchEvent('click');
await putSettled;
await expect(page.getByAltText('loading')).toHaveCount(0, {
timeout: 20_000,
});
}
/**
* Click the settings (⋮) icon on a section header, bypassing the sidenav's
* pointer-event interception via `dispatchEvent('click')` (same root cause
* as `toggleSectionAndWaitForPut`). The settings popover (Rename / New Panel
* / Remove Section) lives on the LEFT of the row at the same x-coordinate
* as the chevron, so it suffers the same overlap.
*/
async function clickSectionSettings(
row: ReturnType<Page['locator']>,
): Promise<void> {
const icon = row.locator('.settings-icon');
await icon.scrollIntoViewIfNeeded();
await expect(icon).toBeVisible();
await icon.dispatchEvent('click');
}
test.describe('Dashboard Detail — Sections', () => {
// ─── Collapse / expand chevron and widget-count suffix ───────────────────
// TODO(e2e): re-enable once CI consistently passes. Passes locally
// (including `STRESS=1 CI=1`) but flakes on GitHub Linux runner — the
// chevron click intermittently fails to land its auto-save PUT despite
// `dispatchEvent('click')` + `Latency` panel hydration gate. Suspect
// remaining race lives in `GridCardLayout`'s auto-save `useEffect` not
// listing `isDashboardFetching` in its deps. See CI-HARDENING.md item 5.
test.skip('TC-01 collapsing a section hides panels and shows widget count', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// "DB Metrics" is the third section in the APM fixture and lives below
// the fold on the 1280×720 CI viewport. Scroll its title into view and
// wait for visibility so the 14×14 chevron is actionable.
const dbMetricsTitle = page.getByText('DB Metrics', { exact: true }).first();
await dbMetricsTitle.scrollIntoViewIfNeeded();
await expect(dbMetricsTitle).toBeVisible();
await toggleSection(sectionRow(page, 'DB Metrics'));
// After collapse the section title is rewritten to include the count
// suffix; assert with a regex so the test is robust to widget-count
// drift in the fixture.
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Restore: chevron-down is the row-icon variant rendered for collapsed
// sections. Re-resolve via the new (suffixed) title.
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
});
test('TC-02 widget count matches number of panels visible before collapse', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// The first Overview section in the APM fixture holds these four
// panels — they're our ground truth for the count assertion below.
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Request rate', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Error percentage', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('Top operations', { exact: true }).first(),
).toBeVisible();
await toggleSection(sectionRow(page, 'Overview', 0));
await expect(
page.getByText('Overview (4 widgets)', { exact: true }).first(),
).toBeVisible();
// Restore.
await toggleSection(sectionRow(page, 'Overview (4 widgets)'));
await expect(
page.getByText('Overview (4 widgets)', { exact: true }),
).toHaveCount(0);
});
test('TC-03 expanding restores panels', async ({ authedPage: page }) => {
await gotoApmDashboard(page);
// Collapse "DB Metrics" instead of the first Overview — its widgets
// have unique titles ("DB Calls RPS" / "Database Calls Avg Duration")
// so collapse/expand transitions can be asserted without colliding
// with the duplicate-titled panels in the two Overview sections.
// "DB Metrics" lives further down the canvas; scroll into view first
// so the panels actually mount (the canvas virtualises off-screen).
const dbCalls = page.getByText('DB Calls RPS', { exact: true }).first();
await dbCalls.scrollIntoViewIfNeeded();
await expect(dbCalls).toBeVisible({ timeout: 15_000 });
await toggleSection(sectionRow(page, 'DB Metrics'));
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// While collapsed, "DB Calls RPS" should fully unmount.
await expect(page.getByText('DB Calls RPS', { exact: true })).toHaveCount(0);
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(
page.getByText('DB Calls RPS', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
});
// ─── Section options menu (Rename / New Panel / Remove Section) ──────────
test('TC-04 section options menu shows Rename / New Panel / Remove Section', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// Use DB Metrics — its settings popover is guaranteed to render all
// three buttons when the section is expanded. WidgetRow.tsx hides
// "Remove Section" while a section is collapsed.
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
const tooltip = page.getByRole('tooltip');
await expect(tooltip).toBeVisible();
await expect(tooltip.getByRole('button', { name: 'Rename' })).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'New Panel', exact: true }),
).toBeVisible();
await expect(
tooltip.getByRole('button', { name: 'Remove Section' }),
).toBeVisible();
await page.keyboard.press('Escape');
});
test('TC-05 rename a section, restore original name', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const renamed = `Renamed Section ${Date.now()}`;
// DB Metrics has a unique name, avoiding the duplicate-Overview snag.
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const renameDialog = page.getByRole('dialog', { name: 'Rename Section' });
await expect(renameDialog).toBeVisible();
const nameInput = renameDialog.getByPlaceholder('Enter row name here...');
await nameInput.click();
await nameInput.fill(renamed);
await renameDialog.getByRole('button', { name: 'Apply Changes' }).click();
await expect(renameDialog).not.toBeVisible();
await expect(page.getByText(renamed, { exact: true }).first()).toBeVisible();
// Restore.
await clickSectionSettings(sectionRow(page, renamed));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const restoreDialog = page.getByRole('dialog', { name: 'Rename Section' });
const restoreInput = restoreDialog.getByPlaceholder('Enter row name here...');
await restoreInput.click();
await restoreInput.fill('DB Metrics');
await restoreDialog.getByRole('button', { name: 'Apply Changes' }).click();
await expect(restoreDialog).not.toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText(renamed, { exact: true })).toHaveCount(0);
});
test('TC-06 cancel section rename leaves name unchanged', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await clickSectionSettings(sectionRow(page, 'External calls'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Rename' })
.click();
const dialog = page.getByRole('dialog', { name: 'Rename Section' });
await expect(dialog).toBeVisible();
const input = dialog.getByPlaceholder('Enter row name here...');
await input.click();
await input.fill('Should Not Be Applied');
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
await expect(page.getByText('Should Not Be Applied')).toHaveCount(0);
});
// TODO(e2e): re-enable once CI consistently passes. Flaky because of hover interaction on menu, will be changing with new implementation with perses.
test.skip('TC-07 add a new panel to a section, then delete it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const panelName = `Test Panel ${Date.now()}`;
await clickSectionSettings(sectionRow(page, 'DB Metrics'));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'New Panel', exact: true })
.click();
const panelTypeDialog = page.getByRole('dialog', { name: 'New Panel' });
await expect(panelTypeDialog).toBeVisible();
await panelTypeDialog.getByTestId('panel-type-graph').click();
// We're now in the panel editor at /dashboard/:id/new?widgetId=…
await page.waitForURL(/\/new/);
await page.getByTestId('panel-name-input').fill(panelName);
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
// a disabled variant when `isSaveDisabled` is true and an enabled
// variant when it is false. Under CI load the editor mounts with the
// disabled variant first; without `toBeEnabled` the click can hit the
// disabled button and the Save dialog never opens.
const saveBtn = page.getByTestId('new-widget-save');
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
// `dispatchEvent('click')` — sidenav overlap risk on CI; see the same
// rationale on `toggleSectionAndWaitForPut` above.
await saveBtn.dispatchEvent('click');
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
await expect(saveDialog).toBeVisible();
// PUT confirms the panel persisted server-side — more reliable than
// waiting on redux state to propagate before navigating back.
const putResponse = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await saveDialog.getByRole('button', { name: 'OK' }).click();
await putResponse;
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
// The panel ⋮ menu opens on HOVER (not click) — see
// `openPanelMoreMenu` in 21-panel-actions.spec.ts. Clicking the kebab
// can momentarily toggle the menu and immediately re-close it, racing
// the menuitem click on the next line. Use hover and wait for the
// menu role to be visible before clicking Delete.
const panelTitle = page.getByText(panelName, { exact: true }).first();
await panelTitle.hover();
const panelContainer = panelTitle.locator('../..');
await panelContainer.scrollIntoViewIfNeeded();
await panelContainer.hover();
await panelContainer.getByTestId('widget-header-options').hover();
const menu = page.getByRole('menu');
await menu.waitFor({ state: 'visible' });
await menu.getByRole('menuitem', { name: 'Delete', exact: true }).click();
const deleteDialog = page.getByRole('dialog', { name: 'Delete' });
await expect(deleteDialog).toBeVisible();
const deletePut = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await deleteDialog.getByRole('button', { name: 'OK' }).click();
await deletePut;
await expect(deleteDialog).not.toBeVisible();
await expect(page.getByText(panelName, { exact: true })).toHaveCount(0);
});
// ─── New section in edit mode ────────────────────────────────────────────
test('TC-08 add a new section via edit mode, then remove it', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
const sectionName = `Temp Section ${Date.now()}`;
await page.getByTestId('options').click();
await page.getByRole('button', { name: 'New section' }).click();
const newSectionDialog = page.getByRole('dialog', { name: 'New Section' });
await expect(newSectionDialog).toBeVisible();
await newSectionDialog.getByTestId('section-name').fill(sectionName);
await newSectionDialog
.getByRole('button', { name: 'Create Section' })
.click();
await expect(newSectionDialog).not.toBeVisible();
await expect(
page.getByText(sectionName, { exact: true }).first(),
).toBeVisible();
await clickSectionSettings(sectionRow(page, sectionName));
await page
.getByRole('tooltip')
.getByRole('button', { name: 'Remove Section' })
.click();
const deleteRowDialog = page.getByRole('dialog', { name: 'Delete Row' });
await expect(deleteRowDialog).toBeVisible();
await deleteRowDialog.getByRole('button', { name: 'OK' }).click();
await expect(deleteRowDialog).not.toBeVisible();
await expect(page.getByText(sectionName, { exact: true })).toHaveCount(0);
// Original sections are untouched.
await expect(
page.getByText('Overview', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('DB Metrics', { exact: true }).first(),
).toBeVisible();
await expect(
page.getByText('External calls', { exact: true }).first(),
).toBeVisible();
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-09 collapsing two sections in sequence shows both as collapsed', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
await toggleSection(sectionRow(page, 'DB Metrics'));
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
await toggleSection(sectionRow(page, 'External calls'));
await expect(
page.getByText(/^External calls \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Restore both so the test leaves no state behind.
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await toggleSection(sectionRow(page, /^External calls \(\d+ widgets?\)$/));
await expect(page.getByText(/^DB Metrics \(\d+ widgets?\)$/)).toHaveCount(0);
await expect(page.getByText(/^External calls \(\d+ widgets?\)$/)).toHaveCount(
0,
);
});
test('TC-10 panels inside a collapsed section are not in the DOM', async ({
authedPage: page,
}) => {
await gotoApmDashboard(page);
// "DB Calls RPS" is a unique panel inside the "DB Metrics" section.
const dbPanel = page.getByText('DB Calls RPS', { exact: true });
await dbPanel.first().scrollIntoViewIfNeeded();
await expect(dbPanel.first()).toBeVisible();
await toggleSection(sectionRow(page, 'DB Metrics'));
await expect(
page.getByText(/^DB Metrics \(\d+ widgets?\)$/).first(),
).toBeVisible();
// Panels inside the collapsed section unmount, not just hidden.
await expect(dbPanel).toHaveCount(0);
// Restore.
await toggleSection(sectionRow(page, /^DB Metrics \(\d+ widgets?\)$/));
await expect(dbPanel.first()).toBeVisible();
});
});

View File

@@ -1,634 +0,0 @@
import type { Locator, Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
createChartDataDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// Tests in this file mutate the same dashboard (clone / delete panels). Run
// them serially within the worker so state from one test does not leak into
// another's assertions.
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
let apmDashboardId = '';
const TIME_SERIES_PANEL = 'Latency';
const TABLE_PANEL = 'Top operations';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function gotoDetail(page: Page, id: string): Promise<void> {
await page.goto(`/dashboard/${id}`);
// `GridCardLayout`'s auto-save effect fires a PUT on initial load when the
// local `dashboardLayout` state diverges from the server `layouts`. Under
// CI load this PUT can still be in-flight when a test registers its own
// `waitForResponse(PUT)` or triggers a mutation (clone / delete), causing
// the wrong PUT to be captured or concurrent writes to corrupt layout state.
// Drain the in-flight PUT now so every test in this file starts clean.
// The try/catch handles dashboards whose layout is already in sync (no PUT).
try {
await page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
{ timeout: 5_000 },
);
} catch {
// No initial-load PUT within 5 s — layout was already synchronised.
}
}
/**
* Click NewWidget's Save button after waiting for it to become enabled.
*
* Why this helper exists: `container/NewWidget/index.tsx` renders TWO buttons
* with `data-testid="new-widget-save"` — a disabled variant when
* `isSaveDisabled` is true and an enabled variant when it is false. Under CI
* load the editor mounts with the disabled variant first; without
* `toBeEnabled` the click can hit the disabled button and the Save dialog
* never opens, failing the next assertion with "dialog not found".
*/
async function clickNewWidgetSave(page: Page): Promise<void> {
const saveBtn = page.getByTestId('new-widget-save');
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
// `dispatchEvent('click')` — under CI viewport the editor's right-header
// can be partially covered by the sidenav's secondary nav panel, and
// `.click()` retries are then swallowed by the overlay. The synthetic
// click bypasses hit testing and triggers React's `onClick` directly.
await saveBtn.dispatchEvent('click');
}
/**
* Locate the panel container (`.widget-graph-component-container`) for the
* panel with the given title. The title is exposed via `data-testid={title}`
* on the inner `Typography.Text` — traverse upward to the container so we
* can scope the ⋮ icon, search icon, etc. to this panel only.
*
* Multiple panels with the same title (e.g. cloned `Latency` panels) are
* disambiguated by `index`, defaulting to the first match in DOM order.
*/
function panelContainer(page: Page, title: string, index = 0): Locator {
return page
.getByTestId(title)
.nth(index)
.locator(
'xpath=ancestor::div[contains(@class, "widget-graph-component-container")][1]',
);
}
/**
* Hover the panel header (the ⋮ icon is CSS-hidden until the row is hovered)
* and open the action dropdown. Returns the opened menu locator.
*
* The antd `<Dropdown>` wrapping the ⋮ icon uses `trigger={['hover']}` (see
* `WidgetHeader/index.tsx`), so the menu opens on hover, not click —
* dispatching a click is a no-op. We hover the container first to reveal the
* icon (it's CSS-hidden until then) and then hover the icon itself to fire
* the antd Dropdown's mouseenter handler.
*/
async function openPanelMoreMenu(
page: Page,
title: string,
index = 0,
): Promise<Locator> {
const container = panelContainer(page, title, index);
await container.scrollIntoViewIfNeeded();
await container.hover();
const moreOptions = container.getByTestId('widget-header-options');
await moreOptions.hover();
const menu = page.getByRole('menu');
await menu.waitFor({ state: 'visible' });
return menu;
}
test.describe('Dashboard Detail Page — Panel Actions', () => {
// ─── ⋮ menu contents ─────────────────────────────────────────────────────
test('TC-01 panel ⋮ menu shows the 5 actions for a Time Series panel', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
// Time Series headerMenuList = ViewMenuAction + EditMenuAction
// = [View, Clone, Delete, Edit, CreateAlerts]. Download is hidden
// because panelTypes !== TABLE.
await expect(menu.getByRole('menuitem')).toHaveCount(5);
await expect(
menu.getByRole('menuitem', { name: 'View', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'Edit', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'Clone', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: 'Delete', exact: true }),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: /Create Alerts/ }),
).toBeVisible();
await page.keyboard.press('Escape');
});
test('TC-02 Table panel ⋮ menu replaces Create Alerts with Download as CSV', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TABLE_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
// Table panels filter CreateAlerts out of the menu (see GridCard
// `menuList`) and the Download item turns visible because
// panelTypes === TABLE.
await expect(
menu.getByRole('menuitem', {
name: 'Download as CSV',
exact: true,
}),
).toBeVisible();
await expect(
menu.getByRole('menuitem', { name: /Create Alerts/ }),
).toHaveCount(0);
await page.keyboard.press('Escape');
});
// ─── View / Fullscreen ───────────────────────────────────────────────────
test('TC-03 View action opens fullscreen with `expandedWidgetId` URL param', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
// The View menuitem is `disabled: queryResponse.isFetching` — wait
// for it to become enabled before clicking, otherwise the click is a
// no-op and the dialog never opens.
await expect(viewItem).toBeEnabled();
await viewItem.click();
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
await expect(dialog).toBeVisible();
await expect(page).toHaveURL(/expandedWidgetId=/);
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).not.toBeVisible();
await expect(page).not.toHaveURL(/expandedWidgetId=/);
});
test('TC-04 fullscreen panel renders chart canvas or "No Data"', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
await expect(viewItem).toBeEnabled();
await viewItem.click();
const dialog = page.getByRole('dialog', { name: TIME_SERIES_PANEL });
await expect(dialog).toBeVisible();
// known behaviour: the bootstrap stack ingests no telemetry, so a
// fully-rendered chart and a "No Data" empty state are both valid
// terminal states. Both can also coexist (the chart canvas mounts
// before the empty-state overlay paints), so assert that at least
// one of the two is reachable rather than using `.or().toBeVisible()`
// — that combination triggers strict-mode violations when both
// matches resolve.
const canvas = dialog.locator('canvas');
const noData = dialog.getByText(/no data/i);
await expect
.poll(async () => (await canvas.count()) + (await noData.count()), {
timeout: 30_000,
})
.toBeGreaterThan(0);
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).not.toBeVisible();
});
// ─── Table search ────────────────────────────────────────────────────────
test('TC-05 Table panel search icon reveals search input', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TABLE_PANEL, { exact: true }).first(),
).toBeVisible();
const container = panelContainer(page, TABLE_PANEL);
await container.scrollIntoViewIfNeeded();
await container.hover();
// The search icon is hover-revealed; click it to swap the title row
// out for the search input.
const searchIcon = container.getByTestId('widget-header-search');
await searchIcon.click();
// When `showGlobalSearch` is true, the WidgetHeader unmounts the
// Typography.Text that carries the title's `data-testid`, so the
// `panelContainer` ancestor chain no longer resolves. Look up the
// search input by its testid directly — only one search input is
// ever open at a time on a dashboard.
const searchInput = page.getByTestId('widget-header-search-input');
await expect(searchInput).toBeVisible();
await searchInput.fill('test');
await expect(searchInput).toHaveValue('test');
});
// ─── Download as CSV ─────────────────────────────────────────────────────
test('TC-06 Download as CSV triggers a file download', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TABLE_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TABLE_PANEL);
// known behaviour: with no telemetry, the CSV may contain only the
// header row — asserting on `suggestedFilename()` is the resilient
// cross-environment signal that the download actually fired.
const [download] = await Promise.all([
page.waitForEvent('download'),
menu
.getByRole('menuitem', {
name: 'Download as CSV',
exact: true,
})
.click(),
]);
expect(download.suggestedFilename().length).toBeGreaterThan(0);
});
// ─── Clone / Delete ──────────────────────────────────────────────────────
//
// Clone unconditionally navigates to the panel editor (`/new`) — see
// `onCloneHandler` in WidgetGraphComponent. Saving from the editor
// returns to the dashboard with the duplicated panel persisted.
// TODO(e2e): re-enable once CI consistently passes. The Save dialog
// intermittently fails to appear on the GitHub Linux runner after
// clicking `new-widget-save` — `clickNewWidgetSave` gates on
// `toBeEnabled` + `dispatchEvent('click')` and passes locally (incl.
// `STRESS=1 CI=1`) but greens out under CI's slower scheduler in a way
// I haven't been able to reproduce. See CI-HARDENING.md.
test.skip('TC-07 Clone a panel creates a duplicate', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
await expect(titleLocator.first()).toBeVisible();
const beforeCount = await titleLocator.count();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const cloneItem = menu.getByRole('menuitem', { name: 'Clone', exact: true });
await expect(cloneItem).toBeEnabled();
await cloneItem.click();
// The clone handler PUTs the new layout, then redirects to /new.
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
// The Save dialog title varies — "Save Widget" if the query is
// untouched (the case here, since clone preserves the original
// query) or "Unsaved Changes" otherwise. Match either by clicking
// OK in whichever dialog appears.
const saveDialog = page.getByRole('dialog');
await expect(saveDialog).toBeVisible();
await saveDialog.getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Cleanup the cloned panel — its index is `beforeCount` (the last match).
const cleanupMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await cleanupMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
await expect(confirmDialog).toBeVisible();
await confirmDialog.getByRole('button', { name: 'OK' }).click();
await expect(titleLocator).toHaveCount(beforeCount);
});
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
// Will work automatically when that test is re-enabled
test.skip('TC-08 Delete confirm dialog removes a cloned panel', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
await expect(titleLocator.first()).toBeVisible();
const beforeCount = await titleLocator.count();
// Clone a disposable panel — never mutate the seed's original
// `Latency` panel because sibling specs depend on it.
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Delete the clone — last `Latency` in DOM order.
const deleteMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await deleteMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const dialog = page.getByRole('dialog', { name: 'Delete' });
await expect(dialog).toBeVisible();
await expect(dialog).toContainText(/are you sure/i);
await dialog.getByRole('button', { name: 'OK' }).click();
await expect(dialog).not.toBeVisible();
await expect(titleLocator).toHaveCount(beforeCount);
});
// TODO(e2e): re-enable once CI consistently passes. Tied to above test.
// Will work automatically when that test is re-enabled
test.skip('TC-09 Cancel delete keeps the panel', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
await expect(titleLocator.first()).toBeVisible();
const beforeCount = await titleLocator.count();
// Clone a disposable panel to operate on.
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
const deleteMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await deleteMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const dialog = page.getByRole('dialog', { name: 'Delete' });
await expect(dialog).toBeVisible();
await dialog.getByRole('button', { name: 'Cancel' }).click();
await expect(dialog).not.toBeVisible();
// Cancel keeps the clone in place — count unchanged from the
// post-clone state.
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Per-test cleanup: actually delete the clone we just kept so
// subsequent tests start from the seeded count.
const cleanupMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await cleanupMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
const confirmDialog = page.getByRole('dialog', { name: 'Delete' });
await confirmDialog.getByRole('button', { name: 'OK' }).click();
await expect(titleLocator).toHaveCount(beforeCount);
});
// ─── Create Alerts ───────────────────────────────────────────────────────
test('TC-10 Create Alerts menuitem on a Time Series panel navigates to the alerts editor', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByText(TIME_SERIES_PANEL, { exact: true }).first(),
).toBeVisible();
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const createAlerts = menu.getByRole('menuitem', {
name: /Create Alerts/,
});
await expect(createAlerts).toBeEnabled();
// known behaviour: `useCreateAlerts` opens the alerts editor in a
// new tab via `window.open(...)` — the current page's URL does not
// change. Wait for the new browser tab on the context, not the
// existing page.
const [newPage] = await Promise.all([
page.context().waitForEvent('page'),
createAlerts.click(),
]);
await newPage.waitForLoadState();
await expect(newPage).toHaveURL(/\/alerts\/new/);
await newPage.close();
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-11 fullscreen URL deep-link opens the panel modal directly', async ({
authedPage: page,
}) => {
// First navigate normally and capture the panel's widgetId from the
// View action's URL transition — we cannot hard-code a uuid.
await gotoDetail(page, apmDashboardId);
const menu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
const viewItem = menu.getByRole('menuitem', { name: 'View', exact: true });
await expect(viewItem).toBeEnabled();
await viewItem.click();
await expect(page).toHaveURL(/expandedWidgetId=/);
const expandedUrl = page.url();
await page
.getByRole('dialog', { name: TIME_SERIES_PANEL })
.getByRole('button', { name: 'Close' })
.click();
await expect(page).not.toHaveURL(/expandedWidgetId=/);
// Now hard-navigate to the captured deep-link in a fresh page state.
await page.goto(expandedUrl);
await expect(
page.getByRole('dialog', { name: TIME_SERIES_PANEL }),
).toBeVisible();
await expect(page).toHaveURL(/expandedWidgetId=/);
});
test('TC-12 Table panel search filters rows in real time', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
const tableTitle = page.getByText(TABLE_PANEL, { exact: true }).first();
await expect(tableTitle).toBeVisible();
const container = panelContainer(page, TABLE_PANEL);
await container.scrollIntoViewIfNeeded();
await container.hover();
await container.getByTestId('widget-header-search').click();
const searchInput = page.getByTestId('widget-header-search-input');
await expect(searchInput).toBeVisible();
// known behaviour: the bootstrap stack ingests no telemetry, so the
// table body may be empty. The contract this TC guards is "typing in
// the search updates the input value live and does not throw" — a
// rendered row count check only fires when telemetry happens to seed
// rows. We log no console errors during the search keystrokes either.
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await searchInput.fill('foo');
await expect(searchInput).toHaveValue('foo');
await searchInput.fill('');
await expect(searchInput).toHaveValue('');
await searchInput.fill('bar-baz');
await expect(searchInput).toHaveValue('bar-baz');
expect(errors).toHaveLength(0);
});
test('TC-13 panel renders chart data from the bootstrap golden seed', async ({
authedPage: page,
}) => {
const chartId = await createChartDataDashboardViaApi(page);
seedIds.add(chartId);
await page.goto(`/dashboard/${chartId}`);
await expect(
page.getByRole('button', {
name: /dashboard-icon detail-chart-data-suite/,
}),
).toBeVisible();
const panel = page
.getByText('E2E Metric RPS', { exact: true })
.first()
.locator(
'xpath=ancestor::div[contains(@class,"widget-graph-component-container")][1]',
);
await expect(panel).toBeVisible();
await expect(panel.locator('canvas').first()).toBeVisible({
timeout: 30_000,
});
const dimensions = await panel
.locator('canvas')
.first()
.evaluate((el) => {
const c = el as HTMLCanvasElement;
return { w: c.width, h: c.height };
});
expect(dimensions.w).toBeGreaterThan(0);
expect(dimensions.h).toBeGreaterThan(0);
// Empty-state must NOT render — proves the golden seed landed and
// the panel query found rows.
await expect(panel.getByText(/no data/i)).toHaveCount(0);
});
// TODO(e2e): re-enable once CI consistently passes. Same panel
// clone-then-delete flake family as TC-07/TC-08/TC-09 above — the
// Save dialog and / or the delete confirmation intermittently fail
// on CI's slower scheduler.
test.skip('TC-14 Delete only removes the targeted panel — siblings remain', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
// "DB Calls RPS" is a stable sibling we check survives the round-trip.
const sibling = page.getByText('DB Calls RPS', { exact: true }).first();
await sibling.scrollIntoViewIfNeeded();
await expect(sibling).toBeVisible();
const titleLocator = page.getByText(TIME_SERIES_PANEL, { exact: true });
const beforeCount = await titleLocator.count();
// Clone first so the test is read-only at the seed level.
const cloneMenu = await openPanelMoreMenu(page, TIME_SERIES_PANEL);
await cloneMenu.getByRole('menuitem', { name: 'Clone', exact: true }).click();
await page.waitForURL(/\/new/);
await clickNewWidgetSave(page);
await page.getByRole('dialog').getByRole('button', { name: 'OK' }).click();
await page.waitForURL((url) => !url.pathname.includes('/new'));
await expect(titleLocator).toHaveCount(beforeCount + 1);
// Delete the clone (last in DOM order).
const deleteMenu = await openPanelMoreMenu(
page,
TIME_SERIES_PANEL,
beforeCount,
);
await deleteMenu
.getByRole('menuitem', { name: 'Delete', exact: true })
.click();
await page
.getByRole('dialog', { name: 'Delete' })
.getByRole('button', { name: 'OK' })
.click();
// Originals + siblings still present.
await expect(titleLocator).toHaveCount(beforeCount);
await expect(sibling).toBeVisible();
});
});

View File

@@ -1,146 +0,0 @@
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// Scope: dashboard-side seams only —
// 1. The toolbar "New Panel" button opens a dialog listing every panel type
// the app supports (the dashboard's responsibility).
// 2. A panel created from the dialog actually lands on the canvas and
// survives a hard reload (the dashboard's persistence contract).
//
// Editor-internal behaviour (Query Builder vs ClickHouse tab, Panel Settings,
// y-axis units, panel-type changes, etc.) belongs in a separate panel-editor
// spec — do NOT add those here.
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Detail — Add Panel (entry-point + persistence)', () => {
test('TC-01 New Panel toolbar button opens a dialog listing all 7 panel types', async ({
authedPage: page,
}) => {
const ts = Date.now();
const id = await createDashboardViaApi(page, `add-panel-dialog-${ts}`);
seedIds.add(id);
await page.goto(`/dashboard/${id}`);
// Empty dashboards render an onboarding canvas with a duplicate
// `add-panel-header` CTA. Scope to the toolbar (`.right-section`).
await page
.locator('.dashboard-details .right-section')
.getByTestId('add-panel-header')
.click();
const dialog = page.getByRole('dialog', { name: 'New Panel' });
await expect(dialog).toBeVisible();
for (const tile of [
'panel-type-graph',
'panel-type-value',
'panel-type-table',
'panel-type-list',
'panel-type-bar',
'panel-type-pie',
'panel-type-histogram',
]) {
await expect(dialog.getByTestId(tile)).toBeVisible();
}
// Dialog dismisses via the Close (×) button — confirms the user can
// back out without entering the editor (no /new navigation happens
// until a tile is picked).
await dialog.getByRole('button', { name: 'Close' }).click();
await expect(dialog).toBeHidden();
await expect(page).not.toHaveURL(/\/new/);
});
// TODO(e2e): re-enable once CI consistently passes. Same flake family
// as `21-panel-actions.spec.ts` TC-07 — Save dialog intermittently
// fails to appear on CI after `new-widget-save` despite the
// `toBeEnabled` gate and `dispatchEvent('click')`. Passes locally
// (incl. `STRESS=1 CI=1`). See CI-HARDENING.md.
test.skip('TC-02 saving a new panel persists it on the canvas across reload', async ({
authedPage: page,
}) => {
const ts = Date.now();
const id = await createDashboardViaApi(page, `add-panel-persist-${ts}`);
seedIds.add(id);
const panelName = `e2e-panel-${ts}`;
await page.goto(`/dashboard/${id}`);
await page
.locator('.dashboard-details .right-section')
.getByTestId('add-panel-header')
.click();
await page
.getByRole('dialog', { name: 'New Panel' })
.getByTestId('panel-type-graph')
.click();
// We're now on the editor; minimal interaction — set the name and save.
// Anything else (queries, panel-type changes, units) is editor-internal
// and belongs in a panel-editor spec.
await page.getByTestId('panel-name-input').fill(panelName);
// NewWidget renders TWO buttons with `data-testid="new-widget-save"` —
// a disabled variant when `isSaveDisabled` is true and an enabled
// variant when it is false (see container/NewWidget/index.tsx). Under
// CI load the editor mounts with the disabled variant first; clicking
// before `toBeEnabled` resolves means the click hits the disabled
// button and the Save dialog never opens.
const saveBtn = page.getByTestId('new-widget-save');
await expect(saveBtn).toBeVisible();
await expect(saveBtn).toBeEnabled({ timeout: 20_000 });
// `dispatchEvent('click')` — under CI viewport the editor's right-header
// can be partially covered by the sidenav's secondary nav panel. Bypass
// hit testing via a synthetic click. Also wait for the dialog before
// registering the PUT listener so we capture the save mutation rather
// than any unrelated background request.
await saveBtn.dispatchEvent('click');
const saveDialog = page.getByRole('dialog', { name: 'Save Widget' });
await expect(saveDialog).toBeVisible();
const savePut = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await saveDialog.getByRole('button', { name: 'OK' }).click();
const putResp = await savePut;
expect(putResp.ok()).toBeTruthy();
// The editor navigates back to the dashboard inside the PUT onSuccess
// handler — wait for the URL to update before asserting on the canvas.
await page.waitForURL((url) => !url.pathname.includes('/new'));
// Back on the dashboard — the new panel must render with the typed name.
await expect(page).not.toHaveURL(/\/new/);
await expect(
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
// Persistence — hard reload, panel still there.
await page.reload();
await expect(
page.getByText(panelName, { exact: true }).first(),
).toBeVisible();
});
});

View File

@@ -1,78 +0,0 @@
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
// This file's scope is intentionally narrow: prove that the detail page's
// "Edit panel" entry-point lands the user in the panel editor at
// `/dashboard/:id/new?widgetId=…`. Editor-internal behaviour (Query Builder
// pre-population, ClickHouse tab, Panel Settings rename, query-edit + revert,
// y-axis units, panel-type changes, etc.) is the responsibility of a separate
// panel-editor spec — keep this file as the dashboard-side seam only.
const seedIds = new Set<string>();
let apmDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
test.describe('Dashboard Detail — Edit Panel (entry-point only)', () => {
test('TC-01 Edit menu item on a panel navigates to the panel editor', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmDashboardId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
// "DB Calls RPS" is the only single-instance panel name in the APM
// Metrics fixture (other titles like "Latency" repeat across sections),
// so it round-trips uniquely without `.first()` gymnastics.
const panelTitle = page.getByText('DB Calls RPS', { exact: true }).first();
await panelTitle.scrollIntoViewIfNeeded();
// Walk up to the widget-graph container. Its `:hover` flips the ⋮ icon
// from `visibility: hidden` to visible (see GridCardLayout.styles.scss
// rule on `.widget-graph-component-container:hover .options-action`).
const container = panelTitle.locator(
'xpath=ancestor::*[contains(@class,"widget-graph-component-container")][1]',
);
await container.hover();
const options = container.getByTestId('widget-header-options');
// The ⋮ uses an antd `Dropdown` with `trigger=['hover']`; firing a real
// hover (not `dispatchEvent('click')`) is what opens the menu.
await options.hover({ force: true });
await page.getByRole('menuitem', { name: 'Edit' }).click();
await expect(page).toHaveURL(/\/dashboard\/[0-9a-f-]+\/new\?.*widgetId=/);
await expect(page.getByTestId('new-widget-save')).toBeVisible();
});
});

View File

@@ -1,235 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createApmMetricsDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
let apmId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function openTimePicker(page: Page): Promise<void> {
await page
.getByRole('textbox', { name: /Last \d+/ })
.first()
.click();
}
test.describe('Dashboard Detail — Time Range', () => {
test('TC-01 selecting a preset updates the textbox label and URL', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
await page.getByRole('button', { name: 'Last 1 hour 1h' }).click();
const response = await refetch;
await expect(
page.getByRole('textbox', { name: 'Last 1 hour' }),
).toBeVisible();
await expect(page).toHaveURL(/relativeTime=1h/);
// Without seeded telemetry the backend may return 4xx for query_range
// (panels render "No Data" — a known harness limitation, not a test
// bug). Cancelled in-flight responses also surface here as non-ok.
// Only 5xx is a real failure; the URL + textbox label assertions
// above already prove the preset click took effect.
expect(response.status()).toBeLessThan(500);
});
test('TC-02 switching presets twice updates the label both times', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
await page.getByRole('button', { name: 'Last 6 hours 6h' }).click();
await expect(
page.getByRole('textbox', { name: 'Last 6 hours' }),
).toBeVisible();
await expect(page).toHaveURL(/relativeTime=6h/);
await openTimePicker(page);
await page.getByRole('button', { name: 'Last 1 day 1d' }).click();
await expect(page.getByRole('textbox', { name: 'Last 1 day' })).toBeVisible();
await expect(page).toHaveURL(/relativeTime=1d/);
await expect(page).not.toHaveURL(/relativeTime=6h/);
});
test('TC-03 custom date range picker reflects selected dates and switches URL to absolute timestamps', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
await page.getByRole('button', { name: 'Custom Date Range' }).click();
const prevMonth = page.getByRole('button', {
name: 'Go to the Previous Month',
});
for (let i = 0; i < 2; i += 1) {
await prevMonth.click();
}
// Calendar day buttons have accessible names like "Saturday, March
// 14th, 2026" (the rendered label is "14" but a11y appends the suffix
// + month + year). Pick a known day by its long-form name regex
// against the gridcell — `\b14th\b` is unambiguous and avoids
// matching siblings like "14" inside "2014".
await page
.getByRole('gridcell', { name: /\b14th\b/ })
.first()
.click();
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
await page.getByRole('button', { name: 'Apply' }).click();
const response = await refetch;
await expect(
page.getByRole('textbox', { name: /\d{2}\/\d{2}\/\d{4}/ }).first(),
).toBeVisible();
await expect(page).toHaveURL(/startTime=\d+/);
await expect(page).toHaveURL(/endTime=\d+/);
// As TC-01: backend 4xx (no telemetry) is acceptable; only 5xx is
// failure. Apply triggered the refetch, which is what we verify.
expect(response.status()).toBeLessThan(500);
});
test('TC-04 timezone change updates the toolbar timezone label', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await openTimePicker(page);
await page.getByRole('button', { name: 'Change Timezone' }).click();
await expect(
page.getByRole('textbox', { name: 'Search timezones...' }),
).toBeVisible();
await page
.getByRole('button', { name: /Coordinated Universal Time —/ })
.click();
await page.keyboard.press('Escape');
await expect(page.getByText('UTC', { exact: true }).first()).toBeVisible();
});
test('TC-05 refresh-interval popup contents', async ({ authedPage: page }) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Set auto refresh' }).click();
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
await expect(autoRefresh).toBeVisible();
await expect(autoRefresh).not.toBeChecked();
// Labels match the live build (no `15 minutes` / `12 hours` — the
// test plan's enumeration was approximate).
for (const label of [
'5 seconds',
'10 seconds',
'30 seconds',
'1 minute',
'5 minutes',
'10 minutes',
'30 minutes',
'1 hour',
'2 hours',
'1 day',
]) {
await expect(
page.getByRole('button', { name: label, exact: true }),
).toBeVisible();
}
});
test('TC-06 toggling auto-refresh on then changing the interval', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await page.getByRole('button', { name: 'Set auto refresh' }).click();
const autoRefresh = page.getByRole('checkbox', { name: 'Auto Refresh' });
await autoRefresh.click();
await expect(autoRefresh).toBeChecked();
await page.getByRole('button', { name: '1 minute', exact: true }).click();
await page.getByRole('button', { name: '5 minutes', exact: true }).click();
await expect(autoRefresh).toBeChecked();
await autoRefresh.click();
await expect(autoRefresh).not.toBeChecked();
});
test('TC-07 manual sync triggers a query_range refetch', async ({
authedPage: page,
}) => {
await page.goto(`/dashboard/${apmId}`);
await expect(
page.getByRole('button', { name: /dashboard-icon APM Metrics/ }),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
const refetch = page.waitForResponse((r) => r.url().includes('/query_range'));
await page.locator('.refresh-btn button').click();
const response = await refetch;
// 4xx is expected without seeded telemetry; only 5xx is a failure.
// The sync click successfully triggering a query_range fetch is the
// behaviour under test.
expect(response.status()).toBeLessThan(500);
});
});

View File

@@ -1,483 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
awaitVariablesResolved,
createVariablesDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
import variablesTemplate from '../../../testdata/variables-dashboard.json';
// Variables that depend on backend resolution against seeded telemetry the
// bootstrap stack does not produce. Skip them so `awaitVariablesResolved`
// does not block on values that can never appear.
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
let varDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
varDashboardId = await createVariablesDashboardViaApi(
page,
'detail-variables-suite',
);
seedIds.add(varDashboardId);
// Per the framework contract: every variable with a default has its
// `selectedValue` set in the seed JSON; backend-resolved variables
// (Query / Dynamic) cannot resolve without seeded telemetry, so we
// list them in `skipNames`. Tests must not race ahead of seed
// materialisation — this gate ensures the persisted dashboard is in
// a known state before any test runs.
await awaitVariablesResolved(page, varDashboardId, {
skipNames: TELEMETRY_DEPENDENT_VARS,
});
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
function variablesQueryParam(state: Record<string, unknown>): string {
return encodeURIComponent(encodeURIComponent(JSON.stringify(state)));
}
async function gotoVariablesDashboard(
page: Page,
urlState?: Record<string, unknown>,
): Promise<void> {
const url = urlState
? `/dashboard/${varDashboardId}?variables=${variablesQueryParam(urlState)}`
: `/dashboard/${varDashboardId}`;
await page.goto(url);
await expect(
page.getByRole('button', {
name: /dashboard-icon detail-variables-suite/,
}),
).toBeVisible();
}
test.describe('Dashboard Detail — Variables', () => {
test('TC-01 variables bar renders all four types', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
for (const name of [
'$tb_env',
'$tb_service',
'$cu_single',
'$cu_env_all',
'$cu_services',
'$q_env',
'$q_service',
'$d_namespace',
]) {
await expect(page.getByText(name, { exact: true })).toBeVisible();
}
// Textbox variables expose their current value via `value` and `title`
// attributes (the antd Input has no accessible name matching the value),
// so we match on input[value="..."] rather than getByRole+name.
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
await expect(page.locator('input[value="frontend"]')).toBeVisible();
await expect(page.getByTestId('variable-select')).toHaveCount(6);
});
test('TC-02 selecting a value in a single-value Custom variable updates URL and aria-selected', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// $cu_single (nth(0)) — single-select Custom with three static
// options. Driving Custom rather than Query keeps the test
// deterministic regardless of seeded telemetry.
const dropdown = page.getByTestId('variable-select').nth(0);
await dropdown.click();
await page.getByRole('option', { name: 'mq-kafka' }).click();
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
await expect(page).toHaveURL(/variables=.*mq-kafka/);
await dropdown.click();
await expect(page.getByRole('option', { name: 'mq-kafka' })).toHaveAttribute(
'aria-selected',
'true',
);
await page.keyboard.press('Escape');
});
test('TC-03 multi-select renders chips and URL encodes array', async ({
authedPage: page,
}) => {
// URL state seeds adservice + cartservice as initial selection; this also
// guarantees the URL contains the encoded array so we can assert on it
// without relying on the seeded server-side selection rendering identically
// across reloads.
await gotoVariablesDashboard(page, {
cu_services: ['adservice', 'cartservice'],
});
await expect(
page.getByRole('button', { name: 'Remove tag adservice' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Remove tag cartservice' }),
).toBeVisible();
await expect(page).toHaveURL(/adservice/);
await expect(page).toHaveURL(/cartservice/);
});
test('TC-04 removing a chip updates URL', async ({ authedPage: page }) => {
await gotoVariablesDashboard(page, {
cu_services: ['adservice', 'cartservice'],
});
await expect(
page.getByRole('button', { name: 'Remove tag adservice' }),
).toBeVisible();
await page.getByRole('button', { name: 'Remove tag adservice' }).click();
// Removing a chip on a multi-select expands the dropdown; URL state
// only commits when the dropdown closes (onDropdownVisibleChange =>
// false). The CustomMultiSelect swallows Escape, so click outside the
// dropdown to dismiss it.
await page.locator('img[alt="dashboard-img"]').click();
await expect(page.getByRole('listbox')).toBeHidden();
await expect(
page.getByRole('button', { name: 'Remove tag adservice' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Remove tag cartservice' }),
).toBeVisible();
await expect(page).toHaveURL(/variables=/);
await expect(page).not.toHaveURL(/adservice/);
});
test('TC-05 ALL option on a Custom variable', async ({ authedPage: page }) => {
await gotoVariablesDashboard(page, { cu_env_all: 'otel-demo' });
// $cu_env_all (nth(1)) — multi-select Custom with showALLOption: true,
// so the dropdown exposes an "ALL" toggle alongside the static options.
const dropdown = page.getByTestId('variable-select').nth(1);
await expect(
dropdown.locator('.ant-select-selection-item', {
hasText: 'otel-demo',
}),
).toBeVisible();
await dropdown.click();
await page.getByRole('option', { name: 'ALL' }).click();
// When ALL is selected, the multi-select renders an "ALL" badge in a
// custom container (not the standard .ant-select-selection-item), so
// match on the option's checked state inside the dropdown listbox
// rather than on the closed-state chip.
await expect(page.getByRole('option', { name: 'ALL' })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(page).toHaveURL(/variables=/);
});
test('TC-06 textbox variable update propagates to URL', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// Locate by the testid wrapping a stable id, since `input[value="..."]`
// becomes stale the moment we fill('') the field.
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
const tb = page.getByPlaceholder('Enter value').first();
await tb.click();
await tb.fill('');
await tb.fill('production');
await tb.press('Enter');
await expect(page.locator('input[value="production"]')).toBeVisible();
await expect(page).toHaveURL(/variables=.*production/);
});
test('TC-07 cascading: child variable listbox opens after parent change', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page, { q_env: 'otel-demo' });
// q_service (nth(4)) is cascaded from q_env (nth(3)).
const child = page.getByTestId('variable-select').nth(4);
await child.click();
// known behaviour: the child's option list requires seeded telemetry —
// the bootstrap stack has none, so we only assert that the listbox
// renders without crashing rather than checking specific options.
await expect(page.getByRole('listbox').first()).toBeVisible();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/otel-demo/);
});
test('TC-08 URL deep-link restores variable state on hard reload', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page, { cu_env_all: 'mq-kafka' });
const dropdown = page.getByTestId('variable-select').nth(1);
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(
page.getByRole('button', {
name: /dashboard-icon detail-variables-suite/,
}),
).toBeVisible();
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
await expect(page).toHaveURL(/variables=%257B/);
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-09 ALL → specific value → ALL round-trip preserves URL state', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
const dropdown = page.getByTestId('variable-select').nth(1); // cu_env_all
// Seed defaults to ALL — open, pick a specific value, assert URL.
await dropdown.click();
await page.getByRole('option', { name: 'mq-kafka' }).click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/mq-kafka/);
// Re-open, switch back to ALL — URL must update again.
await dropdown.click();
const allOption = page.getByRole('option', { name: 'ALL' });
await allOption.click();
await expect(allOption).toHaveAttribute('aria-selected', 'true');
await page.keyboard.press('Escape');
// `mq-kafka` should no longer appear in the URL after reverting to ALL.
await expect(page).not.toHaveURL(/mq-kafka/);
});
test('TC-10 two variables changed in sequence both encode in URL', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// cu_single — pick `production`.
const single = page.getByTestId('variable-select').nth(0);
await single.click();
await page.getByRole('option', { name: 'production' }).click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/production/);
// q_service — open the multi-select, dismiss without picking. The URL
// should still contain the previous selection.
const cuServices = page.getByTestId('variable-select').nth(2);
await cuServices.click();
await page.keyboard.press('Escape');
await expect(page).toHaveURL(/production/);
await expect(page).toHaveURL(/cu_single/);
});
test('TC-11 navigating away and back preserves the URL-encoded state', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page, { cu_single: 'mq-kafka' });
const dropdown = page.getByTestId('variable-select').nth(0);
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
const stateUrl = page.url();
// Leave to the list, come back via browser back — URL is restored.
// `dispatchEvent('click')` — the expanded sidenav intercepts pointer
// events at the breadcrumb's center, defeating even `force: true`.
// Dispatching the click directly on the DOM node bypasses hit testing.
await page
.getByRole('button', { name: 'Dashboard /' })
.dispatchEvent('click');
await expect(page).toHaveURL(/\/dashboard$/);
await page.goBack();
await expect(page).toHaveURL(stateUrl);
await expect(
dropdown.locator('.ant-select-selection-item', { hasText: 'mq-kafka' }),
).toBeVisible();
});
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
//
// Each `test.skip` below marks a behaviour the spec does NOT yet exercise.
// They are intentional gaps, not bugs — when the feature ships or the seed
// gains telemetry, replace `test.skip` with `test`, drop the comment, and
// implement.
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-12 Custom variable without a default prompts user to select a value', async () => {
// Requires extending variables-dashboard.json with a Custom variable
// that has no `selectedValue` and no `allSelected`. The UI should
// render the dropdown empty/"Select value" until a user picks.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-13 Query variable with pre-seeded selectedValue renders without backend resolution', async () => {
// Requires extending variables-dashboard.json with a Query variable
// that ships with `selectedValue` already populated — the UI should
// trust the seed and not block on a query.
});
test('TC-14 multi-select Query variable without telemetry shows an empty option list', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// q_service is the only multi-select Query in the seed (nth(4) in
// the dropdown order). Without telemetry the option list is empty —
// assert the empty-state explicitly.
const child = page.getByTestId('variable-select').nth(4);
await child.click();
const listbox = page.getByRole('listbox').first();
await expect(listbox).toBeVisible();
await expect(listbox.getByRole('option')).toHaveCount(0);
await page.keyboard.press('Escape');
});
test('TC-15 Dynamic variable resolves a seeded namespace value', async ({
authedPage: page,
}) => {
// d_namespace's `dynamicVariablesAttribute` is `k8s.namespace.name`
// over the `metrics` source. The bootstrap OTel collector ingests
// the golden dataset which tags every resource with
// `k8s.namespace.name=signoz-<service>` for 8 distinct services.
// SigNoz's `signoz_metrics.distributed_metadata` table is populated
// naturally by the collector's signozclickhousemetrics exporter, and
// `/api/v1/fields/values?signal=metrics&name=k8s.namespace.name`
// surfaces the values so the Dynamic variable auto-resolves.
await gotoVariablesDashboard(page);
// d_namespace is the 6th dropdown variable in DOM order. The
// closed-state of the combobox renders the auto-resolved value
// inline next to the variable name. Match any of the 8 seeded
// namespaces — ordering depends on the backend sort, so we accept
// whichever it returns first.
const dynamic = page.getByTestId('variable-select').nth(5);
await expect(dynamic).toContainText(/signoz-\w+/, { timeout: 15_000 });
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-16 changing a variable referenced in a panel query refetches the panel data', async () => {
// $service.name and $deployment.environment are referenced by APM
// panel queries. Asserting that a variable change triggers a
// query_range refetch with the new substitution requires either
// seeded telemetry or a network-request listener that confirms the
// outbound query body contains the new value. Defer until the
// chart-data assertion path is in place.
});
test('TC-17 variable bar order matches the `order` field in dashboard JSON', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// Expected order matches the `order` field in variables-dashboard.json.
const expected = [
'$tb_env',
'$tb_service',
'$cu_single',
'$cu_env_all',
'$cu_services',
'$q_env',
'$q_service',
'$d_namespace',
];
const allText = await page.locator('text=/^\\$\\w+$/').allInnerTexts();
const actual = allText.filter((t) => /^\$\w+$/.test(t));
expect(actual.slice(0, expected.length)).toEqual(expected);
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-18 reordering variables via drag persists to the dashboard JSON', async () => {
// The Configure → Variables tab supports drag handles. After a
// reorder, the persisted `order` fields should update and the
// variables bar should re-render in the new order.
});
test('TC-19 variable removed via Configure disappears from the variables bar', async ({
authedPage: page,
}) => {
await gotoVariablesDashboard(page);
// `tb_service` (textbox, no dependents) — easiest to remove cleanly.
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
await page
.locator('.dashboard-details .right-section')
.getByTestId('show-drawer')
.click();
const dialog = page.getByRole('dialog');
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_service', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.delete-variable-button')
.first()
.dispatchEvent('click');
const confirm = page
.getByRole('dialog')
.filter({ hasText: /delete variable/i })
.last();
await confirm.getByRole('button', { name: 'OK' }).click();
await expect(tabpanel.getByText('tb_service', { exact: true })).toHaveCount(
0,
);
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText('$tb_service', { exact: true })).toHaveCount(0);
// Restore the persisted variable so subsequent serial-mode tests still pass.
const token = await authToken(page);
await page.request.put(`/api/v1/dashboards/${varDashboardId}`, {
data: { ...variablesTemplate, title: 'detail-variables-suite' },
headers: { Authorization: `Bearer ${token}` },
});
await page.reload();
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
});
});

View File

@@ -1,332 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function openEditMode(page: Page): Promise<void> {
await page.getByTestId('options').click();
}
async function closeEditModeIfOpen(page: Page): Promise<void> {
const lockBtn = page.getByRole('button', { name: 'Lock Dashboard' });
if (await lockBtn.isVisible().catch(() => false)) {
await lockBtn.click({ force: true });
}
}
test.describe('Dashboard Detail — Edit Mode', () => {
test('TC-01 edit-mode popup contains all six action buttons', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-popup');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeVisible();
await expect(
page.getByRole('button', { name: 'Rename', exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'Full screen' })).toBeVisible();
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeVisible();
await expect(
page.getByRole('button', { name: 'Copy as JSON' }),
).toBeVisible();
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeHidden();
});
test('TC-02 Lock Dashboard exits edit mode', async ({ authedPage: page }) => {
const id = await seed(page, 'edit-mode-lock');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeHidden();
await expect(
page.getByRole('button', { name: 'Rename', exact: true }),
).toBeHidden();
await expect(page.getByRole('button', { name: 'New section' })).toBeHidden();
await expect(page.getByRole('button', { name: 'Export JSON' })).toBeHidden();
});
test('TC-03 rename dashboard — breadcrumb updates, then restore', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `original-${ts}`;
const renamed = `Renamed-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const renameDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
await expect(renameDialog).toBeVisible();
const nameInput = renameDialog.getByTestId('dashboard-name');
await nameInput.fill('');
await nameInput.fill(renamed);
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${renamed}`),
}),
).toBeVisible();
// Restore.
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const restoreDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
const restoreInput = restoreDialog.getByTestId('dashboard-name');
await restoreInput.fill('');
await restoreInput.fill(original);
await restoreDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${original}`),
}),
).toBeVisible();
});
test('TC-04 cancel rename leaves name unchanged', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `cancel-rename-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const renameDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
const nameInput = renameDialog.getByTestId('dashboard-name');
await nameInput.fill('');
await nameInput.fill('Should Not Be Saved');
await renameDialog.getByRole('button', { name: 'Cancel' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${original}`),
}),
).toBeVisible();
await expect(page.getByText('Should Not Be Saved')).toBeHidden();
});
test('TC-05 add a new section via edit mode, then remove it', async ({
authedPage: page,
}) => {
const ts = Date.now();
const id = await seed(page, `edit-mode-section-${ts}`);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'New section' }).click();
const sectionDialog = page.getByRole('dialog', { name: 'New Section' });
const sectionName = `e2e-section-${ts}`;
await sectionDialog.getByTestId('section-name').fill(sectionName);
await sectionDialog.getByRole('button', { name: 'Create Section' }).click();
const sectionTitle = page
.locator('.section-title')
.filter({ hasText: sectionName });
await expect(sectionTitle).toBeVisible();
// Cleanup — remove the section. The ellipsis trigger sits on the
// `.row-panel` container alongside the section title; the popover it
// opens has rootClassName="row-settings" and renders at body level.
const sectionRow = sectionTitle.locator(
'xpath=ancestor::*[contains(@class, "row-panel")]',
);
await sectionRow.hover();
await sectionRow.locator('.settings-icon').click();
const rowSettingsPopover = page.locator('.row-settings');
await expect(rowSettingsPopover).toBeVisible();
await rowSettingsPopover
.getByRole('button', { name: 'Remove Section' })
.click();
const deleteDialog = page.getByRole('dialog', { name: 'Delete Row' });
await expect(deleteDialog).toBeVisible();
await deleteDialog.getByRole('button', { name: 'OK' }).click();
await expect(sectionTitle).toBeHidden();
});
test('TC-06 Export JSON triggers a .json download', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-export');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
const downloadPromise = page.waitForEvent('download');
await page.getByRole('button', { name: 'Export JSON' }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.json$/);
await closeEditModeIfOpen(page);
});
test('TC-07 Copy as JSON puts dashboard JSON on the clipboard', async ({
authedPage: page,
}) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
const ts = Date.now();
const title = `edit-mode-copy-${ts}`;
const id = await seed(page, title);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Copy as JSON' }).click();
const clipboardText = await page.evaluate(() =>
navigator.clipboard.readText(),
);
const parsed = JSON.parse(clipboardText) as { title?: string };
expect(parsed.title ?? '').toContain(title);
await closeEditModeIfOpen(page);
});
// known behaviour: headless Chromium does not honour the Fullscreen API,
// so we cannot assert `document.fullscreenElement`. Verifying that the
// click is benign (breadcrumb still rendered) is the strongest cross-env
// check available.
test('TC-08 Full screen — clicking does not crash the dashboard', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-fullscreen');
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Full screen' }).click();
await expect(
page.getByRole('button', { name: /Dashboard \// }),
).toBeVisible();
await page.keyboard.press('Escape');
await closeEditModeIfOpen(page);
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-09 lock → unlock round-trip restores edit-mode controls', async ({
authedPage: page,
}) => {
const id = await seed(page, 'edit-mode-lock-roundtrip');
await page.goto(`/dashboard/${id}`);
// Lock the dashboard.
await openEditMode(page);
await page.getByRole('button', { name: 'Lock Dashboard' }).click();
await expect(
page.getByRole('button', { name: 'Lock Dashboard' }),
).toBeHidden();
// Re-opening the popup after a lock shows the Unlock label instead of
// Lock. The button label flips based on `isDashboardLocked`.
await openEditMode(page);
const unlockBtn = page.getByRole('button', { name: 'Unlock Dashboard' });
await expect(unlockBtn).toBeVisible();
await unlockBtn.click();
// After unlock, the popup should re-expose the original action buttons.
await openEditMode(page);
await expect(
page.getByRole('button', { name: 'Rename', exact: true }),
).toBeVisible();
await expect(page.getByRole('button', { name: 'New section' })).toBeVisible();
await closeEditModeIfOpen(page);
});
test('TC-10 rename persists across hard reload', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `rename-persist-${ts}`;
const renamed = `Renamed-Persist-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
await openEditMode(page);
await page.getByRole('button', { name: 'Rename', exact: true }).click();
const renameDialog = page.getByRole('dialog', {
name: 'Rename Dashboard',
});
const nameInput = renameDialog.getByTestId('dashboard-name');
await nameInput.fill('');
await nameInput.fill(renamed);
await renameDialog.getByRole('button', { name: 'Rename Dashboard' }).click();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${renamed}`),
}),
).toBeVisible();
// Hard reload — name must still be the renamed one.
await page.reload();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${renamed}`),
}),
).toBeVisible();
await expect(page).toHaveTitle(new RegExp(renamed));
});
});

View File

@@ -1,687 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
authToken,
awaitVariablesResolved,
createDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
const TELEMETRY_DEPENDENT_VARS = ['q_env', 'q_service', 'd_namespace'];
// `createVariablesDashboardViaApi` is added by the group-3 spec. Import lazily
// so this file still compiles while it is missing — tests that need it skip
// at runtime.
// eslint-disable-next-line @typescript-eslint/no-var-requires
const dashboardsHelpers = require('../../../helpers/dashboards') as {
createVariablesDashboardViaApi?: (
page: Page,
title: string,
) => Promise<string>;
};
const hasVariablesHelper =
typeof dashboardsHelpers.createVariablesDashboardViaApi === 'function';
test.describe.configure({ mode: 'serial' });
const seedIds = new Set<string>();
async function seed(page: Page, title: string): Promise<string> {
const id = await createDashboardViaApi(page, title);
seedIds.add(id);
return id;
}
async function seedVariablesDashboard(
page: Page,
title: string,
): Promise<string> {
if (!dashboardsHelpers.createVariablesDashboardViaApi) {
throw new Error('createVariablesDashboardViaApi helper is not available');
}
const id = await dashboardsHelpers.createVariablesDashboardViaApi(page, title);
seedIds.add(id);
// Wait for the seeded dashboard's variables to fully resolve before any
// caller test acts on them. Variables with defaults already have
// `selectedValue` set; Query/Dynamic variables can't resolve without
// telemetry and are skipped.
await awaitVariablesResolved(page, id, {
skipNames: TELEMETRY_DEPENDENT_VARS,
});
return id;
}
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) return;
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
async function openConfigureDrawer(page: Page) {
// An empty dashboard renders an onboarding canvas with a duplicate
// `data-testid="show-drawer"` Configure CTA alongside the toolbar one.
// Scope to the toolbar (`.dashboard-details .right-section`) to avoid the
// strict-mode collision.
await page
.locator('.dashboard-details .right-section')
.getByTestId('show-drawer')
.click();
const dialog = page.getByRole('dialog');
await expect(dialog).toBeVisible();
return dialog;
}
async function deleteVariableByName(page: Page, varName: string) {
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText(varName, { exact: true }).first();
await nameCell.hover();
// Walk up to the surrounding row container to scope the delete-button
// search; `.variable-item` (or the variable row container) wraps the
// hover-revealed delete button.
await nameCell
.locator('xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]')
.locator('.delete-variable-button')
.first()
.dispatchEvent('click');
const confirm = page
.getByRole('dialog')
.filter({ hasText: /delete variable/i })
.last();
await confirm.getByRole('button', { name: 'OK' }).click();
await expect(tabpanel.getByText(varName, { exact: true })).toHaveCount(0);
await dialog.getByRole('button', { name: /close/i }).first().click();
}
test.describe('Dashboard Detail — Configure drawer', () => {
test('TC-01 Configure drawer opens with three tabs and Overview is active', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-drawer-chrome');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await expect(dialog.getByText('Dashboard Configuration')).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Overview' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Variables' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Publish' })).toBeVisible();
await expect(dialog.getByRole('tab', { name: 'Overview' })).toHaveAttribute(
'aria-selected',
'true',
);
await expect(
dialog.getByRole('tabpanel', { name: 'Overview' }),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(dialog).not.toBeVisible();
});
test('TC-02 update name, description, and tag — persists across reload', async ({
authedPage: page,
}) => {
const ts = Date.now();
const original = `cfg-overview-save-${ts}`;
const updated = `Configured-${ts}`;
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
const nameInput = dialog.getByTestId('dashboard-name');
await nameInput.click();
await nameInput.fill('');
await nameInput.fill(updated);
await dialog.getByTestId('dashboard-desc').fill('Automated test description');
const tagInput = dialog.getByPlaceholder('Start typing your tag name');
await tagInput.fill(`e2e-tag-${ts}`);
await tagInput.press('Enter');
const saveBtn = dialog.getByRole('button', { name: 'Save' });
await saveBtn.scrollIntoViewIfNeeded();
const [putResp] = await Promise.all([
page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
),
saveBtn.click({ force: true }),
]);
expect(putResp.ok()).toBeTruthy();
await dialog.getByRole('button', { name: /close/i }).first().click();
await page.reload();
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${updated}`),
}),
).toBeVisible();
});
test('TC-03 Discard reverts unsaved Overview changes', async ({
authedPage: page,
}) => {
const original = 'cfg-overview-discard';
const id = await seed(page, original);
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
const nameInput = dialog.getByTestId('dashboard-name');
await expect(nameInput).toHaveValue(original);
await nameInput.fill('Temp Modified Name');
const discard = dialog.getByRole('button', { name: 'Discard' });
await expect(discard).toBeVisible();
await discard.click();
await expect(nameInput).toHaveValue(original);
await expect(dialog.getByRole('button', { name: 'Save' })).not.toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-04 Variables tab lists existing variables', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-list');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(tabpanel).toBeVisible();
for (const varName of [
'tb_env',
'tb_service',
'cu_env_all',
'cu_services',
'q_env',
'q_service',
'd_namespace',
]) {
// Variable rows render as plain text inside the Variables tab
// (not a true Antd `Table` with role="row"). Locate via text.
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
}
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-05 add a Textbox variable — appears in the variables bar and is interactive', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-textbox');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `tb_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: 'Textbox' }).click();
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
await expect(saveBtn).toBeEnabled();
await saveBtn.click({ force: true });
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(dialog).not.toBeVisible();
await expect(page.getByText(`$${varName}`)).toBeVisible();
const newTextbox = page.locator('input[placeholder="Enter value"]').last();
await newTextbox.fill('test-value');
await newTextbox.press('Enter');
await expect(page).toHaveURL(/test-value/);
await deleteVariableByName(page, varName);
});
test('TC-06 add a Custom variable — appears in the list', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-custom');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `custom_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: 'Custom' }).click();
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
await expect(
tabpanel.getByText(varName, { exact: true }).first(),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
await deleteVariableByName(page, varName);
});
// known limitation: TC-07 (add a Dynamic (Beta) variable) is intentionally
// not implemented. Dynamic variables source from the SigNoz attribute
// index — the bootstrap stack ingests no telemetry, so the field selector
// renders an empty option list and Save Variable can never be enabled.
// Re-add once the bootstrap seeds telemetry attributes.
test('TC-08 selecting Query type renders the query editor', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not yet available (lands with group 3)',
);
const id = await seedVariablesDashboard(page, 'cfg-variables-add-query');
await page.goto(`/dashboard/${id}`);
const ts = Date.now();
const varName = `query_var_${ts}`;
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill(varName);
await dialog.getByRole('button', { name: /Query/ }).click();
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
// arrive under parallel-worker CI load, far longer than the default 5 s
// locator timeout. 20 s is comfortable headroom without masking real
// regressions.
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
timeout: 20_000,
});
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-09 Save Variable disabled when name is empty', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-variables-empty-name');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
const nameField = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameField).toHaveValue('');
await expect(
dialog.getByRole('button', { name: 'Save Variable' }),
).toBeDisabled();
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-10 Publish tab shows private message and Publish button', async ({
authedPage: page,
}) => {
const id = await seed(page, 'cfg-publish');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Publish' }).click();
await expect(dialog.getByRole('tabpanel', { name: 'Publish' })).toBeVisible();
await expect(
dialog.getByText(
'This dashboard is private. Publish it to make it accessible to anyone with the link.',
),
).toBeVisible();
await expect(
dialog.getByRole('checkbox', { name: 'Enable time range' }),
).toBeVisible();
await expect(
dialog.getByText("Dashboard variables won't work in public dashboards"),
).toBeVisible();
await expect(
dialog.getByRole('button', { name: 'Publish dashboard' }),
).toBeVisible();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
// ─── TBD coverage — placeholders to fill in when each feature lands ──────
//
// `test.skip` placeholders for behaviours not yet covered. Replace with
// `test` and implement when the corresponding feature ships or the seed
// gains the necessary state.
test('TC-11 edit existing variable — rename', async ({ authedPage: page }) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-rename-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
// Hover the row to reveal the edit button (Pylon overlay can intercept,
// so dispatchEvent fires the click directly on the React onClick).
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Editor form mounts; rename and save.
const renamed = `tb_env_renamed_${Date.now()}`;
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameInput).toHaveValue('tb_env');
await nameInput.fill(renamed);
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
// Variables bar reflects the rename; the original label is gone.
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText(`$${renamed}`, { exact: true })).toBeVisible();
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
});
test('TC-12 edit existing variable — change type (CUSTOM → QUERY)', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-change-type');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('cu_single', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Change type from Custom to Query and verify the form swaps to the
// Query editor (Monaco SQL editor mounts where the comma-separated
// values input used to live).
await dialog.getByRole('button', { name: /Query/ }).click();
// Monaco is lazy-loaded — its bundle chunk can take several seconds to
// arrive under parallel-worker CI load, far longer than the default 5 s
// locator timeout. 20 s is comfortable headroom without masking real
// regressions.
await expect(dialog.locator('.monaco-editor').first()).toBeVisible({
timeout: 20_000,
});
// The previous Custom-specific fields must no longer be visible.
await expect(dialog.getByPlaceholder(/Comma separated values/i)).toHaveCount(
0,
);
// Discard rather than save — saving without filling the new query
// would leave a half-configured Query variable. The contract this TC
// guards is "type switching swaps the form correctly", which the
// assertions above already prove.
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
test('TC-13 edit existing variable — change default textbox value persists across reload', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-change-default');
await page.goto(`/dashboard/${id}`);
await expect(page.locator('input[value="otel-demo"]')).toBeVisible();
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
// Update the default textbox value. The Default Value input is the
// second/third field (Name first); locate it via its placeholder.
const defaultInput = dialog
.getByPlaceholder(/Enter default value|Default value/i)
.first();
await defaultInput.fill('new-default');
// PUT confirms the variable persisted server-side before we close +
// reload. Without this wait the reload races the save and the old
// "otel-demo" default renders, producing the observed flake.
const putResponse = page.waitForResponse(
(r) => r.request().method() === 'PUT' && /\/dashboards\//.test(r.url()),
);
await dialog
.getByRole('button', { name: 'Save Variable' })
.click({ force: true });
await putResponse;
// Reload — the new default renders without URL state because it's
// now the persisted seed value.
await dialog.getByRole('button', { name: /close/i }).first().click();
await page.reload();
await expect(page.locator('input[value="new-default"]')).toBeVisible();
});
test('TC-14 delete variable — removed from variables bar', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-delete-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
// Reuse the existing helper and assert the variables bar reflects
// the deletion — `deleteVariableByName` covers the Configure-side
// removal; the bar update is the new contract this TC adds.
await deleteVariableByName(page, 'tb_env');
await expect(page.getByText('$tb_env', { exact: true })).toHaveCount(0);
// Sibling textbox is unaffected.
await expect(page.getByText('$tb_service', { exact: true })).toBeVisible();
});
test('TC-15 variable name validation — duplicate name keeps Save disabled', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-validate-duplicate');
await page.goto(`/dashboard/${id}`);
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
await dialog.getByTestId('add-new-variable').click();
await dialog.getByPlaceholder('Unique name of the variable').fill('tb_env');
await dialog.getByRole('button', { name: 'Textbox' }).click();
// Save Variable should refuse to enable while the name collides with
// an existing variable. Assert the button stays disabled, OR a
// validation message surfaces — UI may pick either signal.
const saveBtn = dialog.getByRole('button', { name: 'Save Variable' });
const errorMsg = dialog.getByText(/already exists|duplicate|in use/i);
// Either Save is disabled, or an explicit error is shown — both are
// valid contracts. `Promise.race` between the two assertions tolerates
// whichever the UI provides.
await expect
.poll(async () => {
const disabled = await saveBtn.isDisabled().catch(() => false);
const err = await errorMsg.isVisible().catch(() => false);
return disabled || err;
})
.toBeTruthy();
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-16 variable name validation — invalid characters / whitespace', async () => {
// Names containing spaces, $-prefix, dots, etc. should be rejected
// by the validator. Confirm Save Variable stays disabled with an
// inline error message.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-17 reorder variables via drag persists `order` in JSON', async () => {
// The Variables tab supports drag handles. After a reorder, the
// persisted `data.variables[*].order` reflects the new sequence and
// the variables bar re-renders accordingly.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-18 add a Dynamic (Beta) variable via Configure → pick seeded attribute', async () => {
// Dynamic-variable resolution itself is covered by
// `67-variables` TC-15 (seed metric → Dynamic dropdown lists the
// namespace → URL state updates). What this TC adds is the Configure
// drawer's *Add Variable → Dynamic* form, whose attribute-picker
// uses a combobox whose stable locator hasn't been pinned in this
// suite yet — leave skipped pending a snapshot pass.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-19 Variable description renders in tooltip / inline metadata', async () => {
// `description` field on each variable should be surfaced in the
// variables bar tooltip and in the Variables tab's row.
});
// eslint-disable-next-line playwright/expect-expect
test.skip('TC-20 Save Variable disabled while query is in flight', async () => {
// For a Query variable mid-resolution, Save Variable should be
// disabled until the query returns options. Otherwise we'd save
// a variable with an empty option list.
});
test('TC-21 cancel-mid-edit variable changes are not persisted', async ({
authedPage: page,
}) => {
test.skip(
!hasVariablesHelper,
'createVariablesDashboardViaApi helper not available',
);
const id = await seedVariablesDashboard(page, 'cfg-cancel-edit-variable');
await page.goto(`/dashboard/${id}`);
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
// Open the editor for tb_env and dirty the Name field.
const dialog = await openConfigureDrawer(page);
await dialog.getByRole('tab', { name: 'Variables' }).click();
const tabpanel = dialog.getByRole('tabpanel', { name: 'Variables' });
const nameCell = tabpanel.getByText('tb_env', { exact: true }).first();
await nameCell.hover();
await nameCell
.locator(
'xpath=ancestor::*[contains(@class,"variable-item") or self::tr][1]',
)
.locator('.edit-variable-button')
.first()
.dispatchEvent('click');
const nameInput = dialog.getByPlaceholder('Unique name of the variable');
await expect(nameInput).toHaveValue('tb_env');
await nameInput.fill('SHOULD_NOT_PERSIST');
// Discard, then re-open the same row. The Name must still be the
// original — abandoned edits never reach the persisted JSON.
await dialog.getByRole('button', { name: 'Discard' }).click();
await dialog.getByRole('button', { name: /close/i }).first().click();
await expect(page.getByText('$tb_env', { exact: true })).toBeVisible();
await expect(
page.getByText('$SHOULD_NOT_PERSIST', { exact: true }),
).toHaveCount(0);
// Reopen Configure → tb_env still has the original name.
const dialog2 = await openConfigureDrawer(page);
await dialog2.getByRole('tab', { name: 'Variables' }).click();
await expect(
dialog2
.getByRole('tabpanel', { name: 'Variables' })
.getByText('tb_env', { exact: true })
.first(),
).toBeVisible();
});
});

View File

@@ -1,243 +0,0 @@
import type { Page } from '@playwright/test';
import { expect, test } from '../../../fixtures/auth';
import { newAdminContext } from '../../../helpers/auth';
import {
APM_METRICS_TITLE,
authToken,
createApmMetricsDashboardViaApi,
createDashboardViaApi,
createVariablesDashboardViaApi,
deleteDashboardViaApi,
} from '../../../helpers/dashboards';
const seedIds = new Set<string>();
const VARIABLES_TITLE = 'detail-edge-cases-variables';
let apmDashboardId = '';
let variablesDashboardId = '';
test.beforeAll(async ({ browser }) => {
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
apmDashboardId = await createApmMetricsDashboardViaApi(page);
seedIds.add(apmDashboardId);
variablesDashboardId = await createVariablesDashboardViaApi(
page,
VARIABLES_TITLE,
);
seedIds.add(variablesDashboardId);
} finally {
await ctx.close();
}
});
test.afterAll(async ({ browser }) => {
if (seedIds.size === 0) {
return;
}
const ctx = await newAdminContext(browser);
const page = await ctx.newPage();
try {
const token = await authToken(page);
for (const id of seedIds) {
await deleteDashboardViaApi(ctx.request, id, token);
seedIds.delete(id);
}
} finally {
await ctx.close();
}
});
function encodeVariables(payload: Record<string, unknown>): string {
return encodeURIComponent(encodeURIComponent(JSON.stringify(payload)));
}
async function gotoDetail(page: Page, id: string, query = ''): Promise<void> {
await page.goto(`/dashboard/${id}${query}`);
}
test.describe('Dashboard Detail Page — Edge Cases', () => {
test('TC-01 panels show "No Data" for a far-past time range without pageerror', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
await gotoDetail(
page,
apmDashboardId,
'?startTime=1672531200000&endTime=1672531260000',
);
// The dashboard chrome must render with the far-past range applied:
// breadcrumb resolves the dashboard title, panel headers render, and the
// time-range textbox reflects the URL.
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
// known behaviour: with no variable values resolvable in the far-past
// window, APM panels stay in a waiting-on-variable state and never
// render the uplot "No Data" overlay. The contract this TC really
// guards is that the page does not throw — assert no client-side
// pageerror was raised.
expect(errors).toHaveLength(0);
});
test('TC-02 nonexistent dashboard ID handled gracefully', async ({
authedPage: page,
}) => {
await page.goto('/dashboard/nonexistent-id-99999');
// The chrome (sidebar logo) must always render, regardless of whether
// the app redirects to /dashboard or shows an in-place error shell.
// The bogus-id breadcrumb must never resolve.
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
await expect(
page.getByRole('button', {
name: /dashboard-icon nonexistent-id-99999/,
}),
).toBeHidden();
});
test('TC-03 sidebar nav still works after hitting a nonexistent dashboard URL', async ({
authedPage: page,
}) => {
await page.goto('/dashboard/nonexistent-id-99999');
await expect(page.getByRole('img', { name: 'SigNoz' })).toBeVisible();
await page
.locator('.nav-item')
.filter({ hasText: /^Dashboards$/ })
.click();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
test('TC-04 variable URL deep-link survives hard reload', async ({
authedPage: page,
}) => {
const deepLink = `?variables=${encodeVariables({ q_env: 'otel-demo' })}`;
await gotoDetail(page, variablesDashboardId, deepLink);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${VARIABLES_TITLE}`),
}),
).toBeVisible();
await expect(page.getByText('$q_env', { exact: true })).toBeVisible();
await expect(page).toHaveURL(/variables=%257B/);
await expect(page).toHaveURL(/otel-demo/);
// Dropdown index — selects (in DOM order): 0=cu_single, 1=cu_env_all,
// 2=cu_services, 3=q_env, 4=q_service, 5=d_namespace.
const qEnv = page.getByTestId('variable-select').nth(3);
await expect(
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
).toBeVisible();
await page.reload({ waitUntil: 'domcontentloaded' });
await expect(page).toHaveURL(/variables=%257B/);
await expect(page).toHaveURL(/otel-demo/);
await expect(
qEnv.locator('.ant-select-selection-item', { hasText: 'otel-demo' }),
).toBeVisible();
});
test('TC-05 a single broken time range does not crash the dashboard canvas', async ({
authedPage: page,
}) => {
const errors: Error[] = [];
page.on('pageerror', (err) => errors.push(err));
// known behaviour: the app may either reject a swapped range
// client-side or render error states per-panel — either way, the
// dashboard chrome and at least one panel header must still render.
await gotoDetail(page, apmDashboardId, '?startTime=999999&endTime=999998');
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await expect(
page.getByText('Latency', { exact: true }).first(),
).toBeVisible();
expect(errors).toHaveLength(0);
});
test('TC-06 sidebar Dashboards link from detail page navigates to /dashboard', async ({
authedPage: page,
}) => {
await gotoDetail(page, apmDashboardId);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${APM_METRICS_TITLE}`),
}),
).toBeVisible();
await page
.locator('.nav-item')
.filter({ hasText: /^Dashboards$/ })
.click();
await expect(page).toHaveURL(/\/dashboard($|\?)/);
await expect(
page.getByRole('heading', { name: 'Dashboards', level: 1 }),
).toBeVisible();
});
// ─── Deep coverage ───────────────────────────────────────────────────────
test('TC-07 a 200-character dashboard name renders without breaking layout', async ({
authedPage: page,
}) => {
const longName = `LongName-${'x'.repeat(190)}`;
const id = await createDashboardViaApi(page, longName);
seedIds.add(id);
await gotoDetail(page, id);
await expect(
page.getByRole('button', {
name: new RegExp(`dashboard-icon ${longName.slice(0, 30)}`),
}),
).toBeVisible();
// The toolbar must still render — long titles cannot push the toolbar
// off-screen or unmount it. Scope to `.right-section` because empty
// dashboards render an onboarding canvas with duplicate testids.
const toolbar = page.locator('.dashboard-details .right-section');
await expect(toolbar.getByTestId('show-drawer')).toBeVisible();
await expect(toolbar.getByTestId('add-panel-header')).toBeVisible();
});
test('TC-08 special characters in the dashboard name round-trip via URL and breadcrumb', async ({
authedPage: page,
}) => {
const trickyName = `Spec & Chars / "${Date.now()}" — émoji 🎯`;
const id = await createDashboardViaApi(page, trickyName);
seedIds.add(id);
await gotoDetail(page, id);
// The full title must round-trip through the breadcrumb without HTML
// entity mangling (`&amp;`, `&quot;` are bugs we'd want to catch).
await expect(
page.getByText(trickyName, { exact: true }).first(),
).toBeVisible();
// document.title is set from the dashboard name — confirm it is intact.
await expect(page).toHaveTitle(new RegExp('Spec & Chars'));
});
});

View File

@@ -0,0 +1,191 @@
import json
from collections.abc import Callable
from http import HTTPStatus
from pathlib import Path
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
_PERSES_FIXTURE = (
Path(__file__).parents[4]
/ "pkg/types/dashboardtypes/dashboardtypesv2/testdata/perses.json"
)
def _post_dashboard(signoz: SigNoz, token: str, body: dict) -> requests.Response:
return requests.post(
signoz.self.host_configs["8080"].get("/api/v2/dashboards"),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
def test_empty_body_rejected_for_missing_schema_version(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(signoz, admin_token, {})
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'metadata.schemaVersion must be "v6", got ""'
def test_missing_display_name_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(signoz, admin_token, {"metadata": {"schemaVersion": "v6"}})
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == "data.display.name is required"
def test_minimal_valid_body_creates_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "test name"}},
},
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "success"
data = body["data"]
assert data["info"]["data"]["display"]["name"] == "test name"
assert data["info"]["metadata"]["schemaVersion"] == "v6"
def test_unknown_root_field_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "test name"}},
"unknownfieldattheroot": "shouldgiveanerror",
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'json: unknown field "unknownfieldattheroot"'
def test_unknown_nested_field_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {
"display": {
"name": "test name",
"unknownfieldinside": "shouldgiveanerror",
},
},
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'json: unknown field "unknownfieldinside"'
def test_perses_fixture_creates_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""The perses.json fixture is the kitchen-sink dashboard the schema tests
use; round-tripping it through the create API exercises the full plugin
surface end-to-end."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
data = json.loads(_PERSES_FIXTURE.read_text())
response = _post_dashboard(
signoz,
admin_token,
{"metadata": {"schemaVersion": "v6"}, "data": data},
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "success"
assert body["data"]["info"]["data"]["display"]["name"] == data["display"]["name"]
def test_tag_casing_is_inherited_from_existing_parent(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""A second dashboard tagged with a sibling under a casing-variant parent
path should adopt the existing parent's casing while keeping the
user-supplied casing for the new leaf segment."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
first = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "dac"}},
"tags": [{"name": "engineering/US/NYC"}],
},
)
assert first.status_code == HTTPStatus.CREATED
first_tags = first.json()["data"]["info"]["tags"]
assert first_tags == [{"name": "engineering/US/NYC"}]
second = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "dac"}},
"tags": [{"name": "engineering/us/SF"}],
},
)
assert second.status_code == HTTPStatus.CREATED
second_tags = second.json()["data"]["info"]["tags"]
assert second_tags == [{"name": "engineering/US/SF"}]