Compare commits

..

428 Commits

Author SHA1 Message Date
Naman Verma
4fe9222a5c Merge branch 'nv/v2-list-dashboard' into nv/dashboard-views 2026-06-05 02:43:45 +05:30
Naman Verma
7d9d4b923d chore: regenerate api specs 2026-06-05 02:43:25 +05:30
Naman Verma
67ed248fef chore: regenerate api specs 2026-06-05 02:41:57 +05:30
Naman Verma
77cb60c106 Merge branch 'nv/v2-list-dashboard' into nv/dashboard-views 2026-06-05 01:01:22 +05:30
Naman Verma
1fbe05f450 Merge branch 'main' into nv/v2-list-dashboard 2026-06-05 01:00:34 +05:30
Naman Verma
43d588a870 fix: add missing image field in list response 2026-06-05 00:58:27 +05:30
Naman Verma
6309e27168 chore: add nolint comment 2026-06-04 18:27:21 +05:30
Naman Verma
564e79137e fix: use must method for user id as well 2026-06-04 18:03:31 +05:30
Naman Verma
92ef8b73a9 fix: remove wrong api description msg 2026-06-04 17:56:06 +05:30
Naman Verma
98df6ead80 Merge branch 'main' into nv/v2-list-dashboard 2026-06-04 17:50:01 +05:30
Naman Verma
7c7329cfd7 Merge branch 'nv/v2-list-dashboard' into nv/dashboard-views 2026-06-04 17:28:10 +05:30
Naman Verma
00a99999ac feat: include list of all dashboard tags in list api response 2026-06-04 17:27:46 +05:30
Naman Verma
fd1a94ca41 fix: use must new org id 2026-06-04 17:18:26 +05:30
Naman Verma
29e8f651f5 Merge branch 'nv/v2-list-dashboard' into nv/dashboard-views 2026-06-04 17:16:56 +05:30
Naman Verma
3809dc1d31 fix: use must new org id 2026-06-04 17:16:15 +05:30
Naman Verma
9a5a24392f Merge branch 'nv/v2-list-dashboard' into nv/dashboard-views 2026-06-04 17:13:18 +05:30
Naman Verma
de42bc9f73 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-06-04 17:12:56 +05:30
Naman Verma
0ddadb051d chore: rename updateable to updatable 2026-06-04 17:10:12 +05:30
Naman Verma
9e8da30c3b fix: proper error passage 2026-06-04 17:04:54 +05:30
Naman Verma
97de2551eb fix: use must new org id 2026-06-04 17:04:37 +05:30
Naman Verma
67affa0fd5 chore: rearrange 2026-06-04 16:58:26 +05:30
Naman Verma
226d924489 chore: add all err codes in api spec 2026-06-04 16:45:32 +05:30
Naman Verma
643770d790 fix: return better err 2026-06-04 16:44:35 +05:30
Naman Verma
f92473230a chore: rearrange 2026-06-04 16:34:51 +05:30
Naman Verma
93452ebde9 chore: regenerate api specs 2026-06-04 16:31:11 +05:30
Naman Verma
d9b316fc0a chore: dont pass email to create and update methods 2026-06-04 16:29:35 +05:30
Naman Verma
c5c86c1bbf chore: dont pass email to create and update methods 2026-06-04 16:27:18 +05:30
Naman Verma
19f68b1ac1 fix: remove user auditable 2026-06-04 16:25:28 +05:30
Naman Verma
932080a83c chore: bump migration number 2026-06-04 16:23:12 +05:30
Naman Verma
9135664188 fix: remove omitempty 2026-06-04 16:22:26 +05:30
Naman Verma
2d061f3a70 fix: fix post merge build and schema errors 2026-06-04 16:21:10 +05:30
Naman Verma
a08bd8d126 Merge branch 'nv/v2-list-dashboard' into nv/dashboard-views 2026-06-04 16:17:49 +05:30
Naman Verma
6a3d1b54eb Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-06-04 16:13:42 +05:30
Naman Verma
4c10fb0df1 Merge branch 'main' into nv/v2-dashboard-update 2026-06-04 16:13:18 +05:30
Naman Verma
deea23d441 fix: remove extra decodePatch calls 2026-06-03 20:50:16 +05:30
Naman Verma
3b2586f386 chore: better error passage 2026-06-03 20:04:31 +05:30
Naman Verma
cd44095dc7 chore: separate file for patch types 2026-06-03 19:50:42 +05:30
Naman Verma
fb2834f812 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-06-03 17:04:06 +05:30
Naman Verma
66534bc7c6 Merge branch 'main' into nv/v2-dashboard-update 2026-06-03 17:01:10 +05:30
Naman Verma
e8214739b8 fix: make remove idempotent in patch 2026-06-03 16:59:37 +05:30
Naman Verma
0a2fe1c8f6 chore: remove JSONPatchDocument and use patchable everywhere 2026-06-03 16:49:44 +05:30
Naman Verma
d40d2ddf60 chore: use same jsonpatch package as done in zeus 2026-06-03 16:10:10 +05:30
Naman Verma
879258ec28 test: integration test and fixes found through it 2026-06-03 16:05:52 +05:30
Naman Verma
a36c7fbde1 fix: use valuer string for list order and sort 2026-06-03 14:38:49 +05:30
Naman Verma
005718cc3b fix: remove join to public dashboard table in list call 2026-06-03 14:21:06 +05:30
Naman Verma
bc0623d07a test: change data to spec in unit tests 2026-06-03 14:09:24 +05:30
Naman Verma
7019df2470 fix: add all error codes for new apis 2026-06-03 13:53:35 +05:30
Naman Verma
faf877de65 fix: change title to name in api description 2026-06-03 13:43:35 +05:30
Naman Verma
5e5656881d fix: add missing name fetch in listv2 store method 2026-06-03 13:36:31 +05:30
Naman Verma
5d60c34761 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-06-03 13:30:12 +05:30
Naman Verma
9bf42aa1da Merge branch 'main' into nv/v2-dashboard-update 2026-06-03 13:28:38 +05:30
Naman Verma
75b422d236 chore: generate frontend api spec 2026-06-03 12:20:26 +05:30
Naman Verma
a6e3a6efa2 fix: address review comments 2026-06-03 12:19:22 +05:30
Naman Verma
158a2eac8b feat: delete dashboard v2 API (#11299)
* feat: delete dashboard v2 API

* fix: fix post merge build and spec errors
2026-06-03 11:29:16 +05:30
Naman Verma
c0e67a8ca3 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-06-03 11:23:52 +05:30
Naman Verma
9b7c820f87 Merge branch 'main' into nv/v2-dashboard-update 2026-06-03 11:21:38 +05:30
Srikanth Chekuri
43ff0dc90f Merge branch 'main' into nv/v2-dashboard-update 2026-06-02 15:44:11 +05:30
Naman Verma
c8cbac7725 chore: bump migration number 2026-05-29 08:10:18 +05:30
Naman Verma
5efa8d226b chore: update api spec 2026-05-29 08:09:10 +05:30
Naman Verma
cc1feb43d0 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-29 08:07:19 +05:30
Naman Verma
13d9641f9d chore: add back accidentally removed tests 2026-05-29 07:44:16 +05:30
Naman Verma
9f559d33d7 fix: change data to spec in api param description 2026-05-29 07:43:19 +05:30
Naman Verma
f56f7381a5 fix: use v1 store update method 2026-05-29 07:38:12 +05:30
Naman Verma
3656d117cc Merge branch 'main' into nv/v2-dashboard-update 2026-05-29 07:13:29 +05:30
Naman Verma
8e39f44a9e Merge branch 'main' into nv/v2-dashboard-update 2026-05-27 18:42:47 +05:30
Naman Verma
e92a0e953c fix: remove unused module methods 2026-05-27 17:56:30 +05:30
Naman Verma
7add0b924c fix: remove unused store method 2026-05-27 17:54:38 +05:30
Naman Verma
d2070d4844 Merge branch 'main' into nv/v2-dashboard-update 2026-05-27 17:25:31 +05:30
Naman Verma
e9b0c32f19 fix: fix build error in test after merge conflict 2026-05-27 17:20:53 +05:30
Naman Verma
ae18dbc722 Merge branch 'main' into nv/v2-dashboard-update 2026-05-27 17:19:18 +05:30
Naman Verma
6771e06d15 feat: introduce dashboard views for list dashboards page 2026-05-26 20:22:15 +05:30
Naman Verma
61b8a41c3d Merge branch 'nv/v2-list-dashboard' into nv/v2-dashboard-delete 2026-05-26 12:57:11 +05:30
Naman Verma
9789a5be89 chore: bump migration number 2026-05-26 12:56:59 +05:30
Naman Verma
09bf5267d6 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-26 12:56:42 +05:30
Naman Verma
87ff61e887 fix: build error fix 2026-05-26 12:56:10 +05:30
Naman Verma
3c14d0e53c Merge branch 'nv/v2-list-dashboard' into nv/v2-dashboard-delete 2026-05-26 12:55:27 +05:30
Naman Verma
f57fc261b9 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-26 12:54:24 +05:30
Naman Verma
8afb247046 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-26 12:54:07 +05:30
Naman Verma
3ee706c68e chore: make tags required in postable 2026-05-26 12:52:10 +05:30
Naman Verma
58767e67c0 chore: generate api specs 2026-05-26 12:50:39 +05:30
Naman Verma
ccecc13bb9 feat: add flag to generate unique name in backend 2026-05-26 12:41:04 +05:30
Naman Verma
1c8b2c1d21 Merge branch 'main' into nv/v2-dashboard-create 2026-05-26 12:03:27 +05:30
Naman Verma
8fbec89aab chore: remove enum def of threshold comparison operator 2026-05-26 12:02:18 +05:30
Naman Verma
40da02e5b4 test: add unit tests for type conversions 2026-05-26 12:01:02 +05:30
Naman Verma
6704e15a01 fix: correct convertor method name 2026-05-26 11:29:48 +05:30
Naman Verma
d05697fdba fix: fix post merge build and spec errors 2026-05-26 11:13:07 +05:30
Naman Verma
fd3ddab9fd Merge branch 'nv/v2-list-dashboard' into nv/v2-dashboard-delete 2026-05-26 11:11:36 +05:30
Naman Verma
1b3b891d5c chore: bump migration number 2026-05-26 11:07:51 +05:30
Naman Verma
d903f6cb1b Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-26 11:07:27 +05:30
Naman Verma
fbf30cc192 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-26 11:06:47 +05:30
Naman Verma
ecd9670bf6 Merge branch 'main' into nv/v2-dashboard-create 2026-05-26 11:05:56 +05:30
Naman Verma
b5fb45904c fix: add quotes around tag relation kind 2026-05-25 16:07:25 +05:30
Naman Verma
c24caf0e9a fix: dont include full data in list response 2026-05-25 15:51:25 +05:30
Naman Verma
dc42fae378 fix: fix tests based on name related changes 2026-05-25 15:42:08 +05:30
Naman Verma
94e70179d7 chore: generate api specs 2026-05-25 15:28:21 +05:30
Naman Verma
063c104b80 chore: bump migration number 2026-05-25 15:26:11 +05:30
Naman Verma
937ebfd843 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-25 15:25:47 +05:30
Naman Verma
d8da99fcbb test: fix build errors and tests based on name related changes 2026-05-25 15:24:08 +05:30
Naman Verma
075e8d3029 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-25 14:39:27 +05:30
Naman Verma
f7fc3eade8 feat: add validation on dashboard name 2026-05-25 14:32:45 +05:30
Naman Verma
9415c06166 Merge branch 'main' into nv/v2-dashboard-create 2026-05-25 14:12:27 +05:30
Naman Verma
fa8139b279 chore: remove integration test for now (will add along with list api) 2026-05-25 14:12:05 +05:30
Naman Verma
3271e35eeb fix: set display name in unmarshal json 2026-05-25 14:10:34 +05:30
Naman Verma
a48beef8ec chore: increase MaxTagsPerDashboard to 10 2026-05-25 14:06:15 +05:30
Naman Verma
ad4e3dcf45 fix: remove unneeded comment 2026-05-25 14:05:15 +05:30
Naman Verma
2e8295f0b3 fix: improve api descriptions 2026-05-25 14:03:32 +05:30
Naman Verma
3e6394ba50 fix: remove unused param in constructor 2026-05-25 13:59:53 +05:30
Naman Verma
95da13ecb9 feat: add immutable name in dashboard v2 api specs 2026-05-25 13:58:01 +05:30
Naman Verma
8b38e3969f Merge branch 'main' into nv/v2-dashboard-create 2026-05-25 13:56:54 +05:30
Naman Verma
f3d097a61a feat: add immutable name in dashboard v2 2026-05-25 13:55:18 +05:30
Naman Verma
81249342ff feat: add immutable name in dashboard v2 2026-05-25 13:55:08 +05:30
Naman Verma
5ce097ed2a fix: add some required fields 2026-05-25 12:51:06 +05:30
Naman Verma
ad9b17d9ae fix: remove system dashboards from list v2 response 2026-05-25 11:47:12 +05:30
Naman Verma
6d5eed3782 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-25 11:39:16 +05:30
Naman Verma
3c985af24c Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-25 11:38:56 +05:30
Naman Verma
c3f50b5db8 chore: incorporate source 2026-05-25 11:38:35 +05:30
Naman Verma
000dcc10b2 chore: incorporate source in api spec 2026-05-25 11:33:15 +05:30
Naman Verma
d7ffbe3f9f chore: incorporate source 2026-05-25 11:31:03 +05:30
Naman Verma
72cbc0450b Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-25 11:14:19 +05:30
Naman Verma
7d7fd425e2 fix: add source for v2 dashboards 2026-05-25 11:13:48 +05:30
Naman Verma
e3e2ea61a0 fix: send total count in response + bug fixes 2026-05-25 11:10:03 +05:30
Naman Verma
a870a98a80 chore: bump migration number 2026-05-25 10:23:19 +05:30
Naman Verma
d55846af03 Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-25 10:22:49 +05:30
Naman Verma
55cfe57a9e Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-25 10:22:04 +05:30
Naman Verma
0a4c75d899 Merge branch 'main' into nv/v2-dashboard-create 2026-05-25 10:21:47 +05:30
Naman Verma
44292bdc1d fix: remove hasMore from list response 2026-05-25 10:21:21 +05:30
Naman Verma
5e6b71e56e fix: add missing request struct in list api 2026-05-22 18:57:51 +05:30
Naman Verma
abeab11271 fix: update migration numbering 2026-05-21 17:11:25 +05:30
Naman Verma
26b007cce5 chore: generate api specs 2026-05-21 10:40:25 +05:30
Naman Verma
0d1766b7c2 fix: fix build errors 2026-05-21 10:18:12 +05:30
Naman Verma
61c15c704b Merge branch 'nv/v2-dashboard-update' into nv/v2-list-dashboard 2026-05-21 10:14:38 +05:30
Naman Verma
6702930688 feat: patch dashboard api (#11182)
* feat: lock, unlock, create public, update public v2 dashboard APIs

* feat: delete dashboard v2 API and hard delete cron job

* feat: patch dashboard api

* chore: update api specs

* chore: update api specs

* chore: update api specs

* chore: remove delete related work

* fix: add examples of structs for value param in param description

* test: unit test fixes

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* test: key value tags in test

* fix: build error in patch module method

* fix: build error in Apply method

* fix: use sync tags method in update

* fix: fix build errors

* fix: fix all patch application tests

* chore: add more mapper methods
2026-05-21 10:04:38 +05:30
Naman Verma
4a24ee44e8 chore: generate api specs 2026-05-20 23:44:22 +05:30
Naman Verma
0045ecadab Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-20 23:43:34 +05:30
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
b610056954 chore: update frontend schema 2026-05-18 20:34:45 +05:30
Naman Verma
db77b398e7 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-18 20:34:17 +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
a7debaa6ed feat: lock, unlock, create public, update public v2 dashboard APIs (#11167)
* feat: lock, unlock, create public, update public v2 dashboard APIs

* chore: update api specs

* fix: use new pattern of checking for admin permission

* fix: remove soft delete reference

* chore: revert all frontend changes

* fix: fix build errors and remove v2 create/update public apis

* chore: use v1 methods wherever possible

* fix: use update v2 store method
2026-05-15 13:12:04 +05:30
Naman Verma
85ac805fae fix: fix build errors post merge conflict resolution 2026-05-15 12:21:25 +05:30
Naman Verma
8a0441293a chore: revert all frontend changes 2026-05-15 12:17:44 +05:30
Naman Verma
25ae787ecb Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-update 2026-05-15 12:15:58 +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
4374f75394 feat: delete dashboard v2 API 2026-05-13 23:06:20 +05:30
Naman Verma
937a469e80 Merge branch 'nv/patch-dashboard' into nv/v2-list-dashboard 2026-05-13 22:44:21 +05:30
Naman Verma
b0262a7d89 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-13 22:43:43 +05:30
Naman Verma
18200c049e Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-13 22:43:14 +05:30
Naman Verma
3dc5b53cc3 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-13 22:42:32 +05:30
Naman Verma
60fef93100 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-13 22:41:57 +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
8e4521177d fix: correct the method name being called 2026-05-13 14:39:32 +05:30
Naman Verma
edf2c4493b Merge branch 'nv/patch-dashboard' into nv/v2-list-dashboard 2026-05-13 14:37:21 +05:30
Naman Verma
39174d6040 fix: use sync tags method in update 2026-05-13 14:36:57 +05:30
Naman Verma
2e7500a0b2 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-13 14:32:16 +05:30
Naman Verma
5987228e4a Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-13 14:30:09 +05:30
Naman Verma
08a59145cc fix: use sync tags method in update 2026-05-13 14:29:08 +05:30
Naman Verma
744856680e Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-13 14:23:07 +05:30
Naman Verma
681e205ac1 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-13 14:22:38 +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
dd615869f6 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-13 12:21:59 +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
1cdecceece fix: fix build error 2026-05-13 11:00:52 +05:30
Naman Verma
fb4f0a9c63 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-13 10:59:55 +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
563b289469 fix: visitor should follow new tag struct 2026-05-12 00:21:13 +05:30
Naman Verma
eae3ff7ee6 chore: embed StorableDashboard in listedRow 2026-05-12 00:07:31 +05:30
Naman Verma
f228f2c9bf fix: build error fix 2026-05-12 00:03:50 +05:30
Naman Verma
44afbbe122 fix: remove soft delete references 2026-05-11 23:52:14 +05:30
Naman Verma
771ae521ab chore: remove newline 2026-05-11 23:49:17 +05:30
Naman Verma
7b97979d84 Merge branch 'nv/patch-dashboard' into nv/v2-list-dashboard 2026-05-11 23:47:58 +05:30
Naman Verma
4c604c3079 fix: build error in Apply method 2026-05-11 23:43:58 +05:30
Naman Verma
a11bc8c325 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-11 23:43:30 +05:30
Naman Verma
35930198d0 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-11 23:43:14 +05:30
Naman Verma
48f4838b93 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-11 23:42:55 +05:30
Naman Verma
ce7735d348 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-11 23:42:37 +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
124b529392 fix: build error in patch module method 2026-05-11 23:41:18 +05:30
Naman Verma
27f334dbe0 test: key value tags in test 2026-05-11 23:40:49 +05:30
Naman Verma
b626d4b868 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-11 23:25:59 +05:30
Naman Verma
3019d151ae fix: remove soft delete reference 2026-05-11 23:08:06 +05:30
Naman Verma
7fa00ef30b fix: use new pattern of checking for admin permission 2026-05-11 23:05:27 +05:30
Naman Verma
1abf66f593 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-11 22:51:44 +05:30
Naman Verma
d8f7e62565 fix: remove soft delete references 2026-05-11 22:47:02 +05:30
Naman Verma
9380569223 fix: compile error fix 2026-05-11 22:42:40 +05:30
Naman Verma
1605b1c1ec Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-11 22:36:00 +05:30
Naman Verma
42660ca8a6 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-11 22:34:22 +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
e9aab5a618 chore: embed StorableDashboard into joinedRow in store method 2026-05-11 22:32:35 +05:30
Naman Verma
c0113324ca fix: remove soft delete references 2026-05-11 22:17:11 +05:30
Naman Verma
6d59fa4700 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-11 22:13:40 +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
4f82bae07a Merge branch 'nv/patch-dashboard' into nv/v2-list-dashboard 2026-05-08 18:12:44 +05:30
Naman Verma
aa6066f7a8 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-08 18:12:41 +05:30
Naman Verma
128abf413e Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-08 18:12:39 +05:30
Naman Verma
abd7e41f97 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-08 18:12:33 +05:30
Naman Verma
3ebde75ebd Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-08 18:11:59 +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
629779a666 Merge branch 'nv/patch-dashboard' into nv/v2-list-dashboard 2026-05-08 15:49:12 +05:30
Naman Verma
c864faf01f Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-08 15:49:09 +05:30
Naman Verma
99802daa3d Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-08 15:49:06 +05:30
Naman Verma
7dfa474dc1 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-08 15:49:04 +05:30
Naman Verma
5c223e9b04 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-08 15:49:01 +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
24d3f65200 Merge branch 'nv/patch-dashboard' into nv/v2-list-dashboard 2026-05-08 13:38:36 +05:30
Naman Verma
9ceaaeecf1 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-08 13:38:33 +05:30
Naman Verma
3e9c1fd7c9 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-08 13:38:30 +05:30
Naman Verma
3b0fa192d8 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-08 13:38:28 +05:30
Naman Verma
8c44c42e13 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-08 13:38:25 +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
c13270814a feat: v2 list dashboards api 2026-05-07 19:12:58 +05:30
Naman Verma
7eb6dbe4a6 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-07 12:06:44 +05:30
Naman Verma
ce424b776b Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-07 12:06:41 +05:30
Naman Verma
0fae729715 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-07 12:06:39 +05:30
Naman Verma
6079e9869c Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-07 12:06:36 +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
abfc19e27a Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-07 02:25:20 +05:30
Naman Verma
762a852a4f Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-07 02:25:17 +05:30
Naman Verma
3cc2a689c8 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-07 02:25:14 +05:30
Naman Verma
b74f5854fc Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-07 02:25:11 +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
ee4508cb85 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-07 02:19:04 +05:30
Naman Verma
b2aec2edaf Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-07 02:19:01 +05:30
Naman Verma
cd7899795d Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-07 02:18:59 +05:30
Naman Verma
ad2d1467ec Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-07 02:18:56 +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
d0bfee2645 test: unit test fixes 2026-05-07 02:15:21 +05:30
Naman Verma
8b505c0197 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/patch-dashboard 2026-05-06 11:25:46 +05:30
Naman Verma
431fb7ca62 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-06 11:25:44 +05:30
Naman Verma
44cf8ed8e7 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-06 11:25:41 +05:30
Naman Verma
4d1129c85f Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-06 11:25:39 +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
cbba2e16d8 fix: add examples of structs for value param in param description 2026-05-05 17:58:58 +05:30
Naman Verma
1d0ab788d5 chore: remove delete related work 2026-05-05 17:51:52 +05:30
Naman Verma
4aae71462b chore: update api specs 2026-05-05 16:57:03 +05:30
Naman Verma
bd49d94144 Merge branch 'nv/delete-v2-dashboard' into nv/patch-dashboard 2026-05-05 16:55:47 +05:30
Naman Verma
fa7205a673 chore: update api specs 2026-05-05 16:55:30 +05:30
Naman Verma
13ec049495 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-05 16:54:49 +05:30
Naman Verma
3e8468ab23 chore: update api specs 2026-05-05 16:54:34 +05:30
Naman Verma
e6cb7fabde Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-05 16:53:52 +05:30
Naman Verma
59b8fa0e05 chore: update api specs 2026-05-05 16:53:34 +05:30
Naman Verma
133a3a0057 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-05 16:52:12 +05:30
Naman Verma
b4e524dae0 chore: update api specs 2026-05-05 16:51:56 +05:30
Naman Verma
2aa46f9f86 Merge branch 'nv/delete-v2-dashboard' into nv/patch-dashboard 2026-05-05 16:41:55 +05:30
Naman Verma
73fa15da83 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-05 16:41:43 +05:30
Naman Verma
cd70d0bdeb Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-05 16:41:32 +05:30
Naman Verma
4de0092664 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-05 16:41:18 +05:30
Naman Verma
337d23c91f Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get
# Conflicts:
#	pkg/modules/tag/impltag/module.go
#	pkg/modules/tag/tag.go
#	pkg/types/tagtypes/tag_test.go
2026-05-05 16:41:04 +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
301d0103b0 Merge branch 'nv/delete-v2-dashboard' into nv/patch-dashboard 2026-05-05 11:59:12 +05:30
Naman Verma
dc99772ee4 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-05 11:58:45 +05:30
Naman Verma
80849ebfeb Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-05 11:58:22 +05:30
Naman Verma
2c0c7240a4 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-05 11:56:39 +05:30
Naman Verma
28cb0a8be7 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-05 11:54:54 +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
14927c89d3 feat: patch dashboard api 2026-05-05 09:22:25 +05:30
Naman Verma
55487dde3a Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-04 19:27:40 +05:30
Naman Verma
fc5717af51 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-04 19:27:26 +05:30
Naman Verma
8bf650192e Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-04 19:27:14 +05:30
Naman Verma
f8fb7e5f8d Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 19:27:02 +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
b3e3dd13b4 Merge branch 'nv/other-dashboard-v2-update-methods' into nv/delete-v2-dashboard 2026-05-04 17:48:42 +05:30
Naman Verma
710d5531f3 Merge branch 'nv/v2-dashboard-update' into nv/other-dashboard-v2-update-methods 2026-05-04 17:45:15 +05:30
Naman Verma
e37e427079 fix: merge fixes 2026-05-04 17:40:46 +05:30
Naman Verma
1e99ab4659 Merge branch 'nv/v2-dashboard-get' into nv/v2-dashboard-update 2026-05-04 17:40:26 +05:30
Naman Verma
3353cda021 Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 17:35:33 +05:30
Naman Verma
f5a71037bf Merge branch 'nv/v2-dashboard-create' into nv/v2-dashboard-get 2026-05-04 17:33:03 +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
ca96c71146 feat: delete dashboard v2 API and hard delete cron job 2026-05-03 15:01:43 +05:30
Naman Verma
de2909d1d1 feat: lock, unlock, create public, update public v2 dashboard APIs 2026-05-03 14:38:24 +05:30
Naman Verma
f311fcabf7 feat: v2 dashboard update API 2026-04-29 18:39:48 +05:30
Naman Verma
a37c07f881 feat: v2 dashboard GET API 2026-04-29 15:12:47 +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
58 changed files with 4885 additions and 1095 deletions

View File

@@ -2540,6 +2540,41 @@ components:
$ref: '#/components/schemas/DashboardtypesVariable'
type: array
type: object
DashboardtypesDashboardView:
properties:
createdAt:
format: date-time
type: string
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
id:
type: string
name:
type: string
orgId:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- data
- orgId
type: object
DashboardtypesDashboardViewData:
properties:
order:
$ref: '#/components/schemas/DashboardtypesListOrder'
query:
type: string
sort:
$ref: '#/components/schemas/DashboardtypesListSort'
version:
type: string
required:
- version
type: object
DashboardtypesDatasourcePlugin:
oneOf:
- $ref: '#/components/schemas/DashboardtypesDatasourcePluginVariantStruct'
@@ -2727,6 +2762,11 @@ components:
- solid
- dashed
type: string
DashboardtypesListOrder:
enum:
- asc
- desc
type: string
DashboardtypesListPanelSpec:
properties:
selectFields:
@@ -2734,6 +2774,12 @@ components:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
type: object
DashboardtypesListSort:
enum:
- updated_at
- created_at
- name
type: string
DashboardtypesListVariableSpec:
properties:
allowAllValue:
@@ -2756,6 +2802,83 @@ components:
nullable: true
type: string
type: object
DashboardtypesListableDashboardV2:
properties:
dashboards:
items:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2'
type: array
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
type: array
total:
format: int64
type: integer
required:
- dashboards
- total
- tags
type: object
DashboardtypesListableDashboardView:
properties:
views:
items:
$ref: '#/components/schemas/DashboardtypesDashboardView'
type: array
required:
- views
type: object
DashboardtypesListedDashboardV2:
properties:
createdAt:
format: date-time
type: string
createdBy:
type: string
id:
type: string
image:
type: string
locked:
type: boolean
name:
type: string
orgId:
type: string
pinned:
type: boolean
schemaVersion:
type: string
source:
$ref: '#/components/schemas/DashboardtypesSource'
spec:
$ref: '#/components/schemas/DashboardtypesListedDashboardV2Spec'
tags:
items:
$ref: '#/components/schemas/TagtypesPostableTag'
type: array
updatedAt:
format: date-time
type: string
updatedBy:
type: string
required:
- id
- orgId
- locked
- source
- schemaVersion
- name
- pinned
- tags
- spec
type: object
DashboardtypesListedDashboardV2Spec:
properties:
display:
$ref: '#/components/schemas/CommonDisplay'
type: object
DashboardtypesNumberPanelSpec:
properties:
formatting:
@@ -2945,6 +3068,16 @@ components:
- tags
- spec
type: object
DashboardtypesPostableDashboardView:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardViewData'
name:
type: string
required:
- name
- data
type: object
DashboardtypesPostablePublicDashboard:
properties:
defaultTimeRange:
@@ -12932,6 +13065,80 @@ paths:
tags:
- preferences
/api/v2/dashboards:
get:
deprecated: false
description: Returns a page of v2-shape dashboards for the calling user's org.
Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`),
order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
operationId: ListDashboardsV2
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: sort
schema:
$ref: '#/components/schemas/DashboardtypesListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/DashboardtypesListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardV2'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboards (v2)
tags:
- dashboard
post:
deprecated: false
description: This endpoint creates a dashboard in the v2 format that follows
@@ -12990,6 +13197,62 @@ paths:
tags:
- dashboard
/api/v2/dashboards/{id}:
delete:
deprecated: false
description: This endpoint deletes a v2-shape dashboard along with its tag relations.
Locked dashboards are rejected.
operationId: DeleteDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- EDITOR
- tokenizer:
- EDITOR
summary: Delete dashboard (v2)
tags:
- dashboard
get:
deprecated: false
description: This endpoint returns a v2-shape dashboard.
@@ -13309,6 +13572,351 @@ paths:
summary: Lock dashboard (v2)
tags:
- dashboard
/api/v2/dashboards/{id}/pins/me:
delete:
deprecated: false
description: Removes the pin for the calling user. Idempotent — unpinning a
dashboard that wasn't pinned still returns 204.
operationId: UnpinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Unpin a dashboard for the current user (v2)
tags:
- dashboard
put:
deprecated: false
description: Pins the dashboard for the calling user. A user can pin at most
10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned
dashboard is a no-op success.
operationId: PinDashboardV2
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"409":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Conflict
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Pin a dashboard for the current user (v2)
tags:
- dashboard
/api/v2/dashboards/views:
get:
deprecated: false
description: Returns every saved view in the calling user's org. Saved views
are shared org-wide; any user may read, create, edit, and delete any view.
operationId: ListDashboardViews
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesListableDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List dashboard saved views
tags:
- dashboard
post:
deprecated: false
description: Persists the calling user's dashboard listing state (query, sort,
order) as a named, reusable view shared across the org.
operationId: CreateDashboardView
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: Created
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Create dashboard saved view
tags:
- dashboard
/api/v2/dashboards/views/{id}:
delete:
deprecated: false
description: Removes a saved view. Saved views are shared org-wide; any user
in the org may delete any view. Idempotent — deleting a non-existent view
returns 404.
operationId: DeleteDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
content:
application/json:
schema:
type: string
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Delete dashboard saved view
tags:
- dashboard
put:
deprecated: false
description: Replaces a saved view's name and data. Saved views are shared org-wide;
any user in the org may edit any view.
operationId: UpdateDashboardView
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/DashboardtypesPostableDashboardView'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/DashboardtypesDashboardView'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Update dashboard saved view
tags:
- dashboard
/api/v2/factor_password/forgot:
post:
deprecated: false

View File

@@ -229,10 +229,47 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return module.pkgDashboardModule.PatchV2(ctx, orgID, id, updatedBy, patch)
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.RunInTx(ctx, func(ctx context.Context) error {
if err := module.store.DeletePublic(ctx, id.String()); err != nil && !errors.Ast(err, errors.TypeNotFound) {
return err
}
return module.pkgDashboardModule.DeleteV2(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
return module.pkgDashboardModule.LockUnlockV2(ctx, orgID, id, updatedBy, isAdmin, lock)
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
return module.pkgDashboardModule.ListV2(ctx, orgID, userID, params)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.PinV2(ctx, orgID, userID, id)
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.UnpinV2(ctx, userID, id)
}
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.CreateView(ctx, orgID, postable)
}
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
return module.pkgDashboardModule.ListViews(ctx, orgID)
}
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdateableDashboardView) (*dashboardtypes.DashboardView, error) {
return module.pkgDashboardModule.UpdateView(ctx, orgID, id, updateable)
}
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.pkgDashboardModule.DeleteView(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

@@ -16,10 +16,11 @@ func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
}
func (f *formatter) JSONExtractString(column, path string) []byte {
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgres(path)...)
return sql
ops := f.convertJSONPathToPostgres(path)
if len(ops) == 0 {
return f.bunf.AppendIdent(nil, column)
}
return append(f.TextToJsonColumn(column), ops...)
}
func (f *formatter) JSONType(column, path string) []byte {

View File

@@ -18,19 +18,19 @@ func TestJSONExtractString(t *testing.T) {
name: "simple path",
column: "data",
path: "$.field",
expected: `"data"->>'field'`,
expected: `"data"::jsonb->>'field'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.name",
expected: `"metadata"->'user'->>'name'`,
expected: `"metadata"::jsonb->'user'->>'name'`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.level1.level2.level3",
expected: `"json_col"->'level1'->'level2'->>'level3'`,
expected: `"json_col"::jsonb->'level1'->'level2'->>'level3'`,
},
{
name: "root path",

View File

@@ -0,0 +1,65 @@
package postgressqlstore
// Lives in this package (rather than the listfilter package) so it can use
// the unexported newFormatter constructor without driving a real Postgres
// connection. Covers the only listfilter cases whose emitted SQL differs
// between SQLite and Postgres — the ones that go through JSONExtractString
// (`name`, `description`). All other operators (=, !=, BETWEEN, LIKE, IN,
// EXISTS, lower(...)) emit identical ANSI SQL on both dialects and are
// covered by the SQLite tests in the listfilter package itself.
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uptrace/bun/dialect/pgdialect"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/listfilter"
)
func TestListFilterCompile_Postgres(t *testing.T) {
f := newFormatter(pgdialect.New())
cases := []struct {
name string
query string
wantSQL string
wantArgs []any
}{
{
name: "name = uses Postgres -> / ->> chain",
query: `name = 'overview'`,
wantSQL: `"dashboard"."data"::jsonb->'spec'->'display'->>'name' = ?`,
wantArgs: []any{"overview"},
},
{
name: "name CONTAINS — same JSON path, LIKE pattern",
query: `name CONTAINS 'overview'`,
wantSQL: `"dashboard"."data"::jsonb->'spec'->'display'->>'name' LIKE ?`,
wantArgs: []any{"%overview%"},
},
{
name: "name ILIKE — LOWER wraps the JSON path",
query: `name ILIKE 'Prod%'`,
wantSQL: `lower("dashboard"."data"::jsonb->'spec'->'display'->>'name') LIKE LOWER(?)`,
wantArgs: []any{"Prod%"},
},
{
name: "description = follows the same path shape",
query: `description = 'd1'`,
wantSQL: `"dashboard"."data"::jsonb->'spec'->'display'->>'description' = ?`,
wantArgs: []any{"d1"},
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
out, err := listfilter.Compile(c.query, f)
require.NoError(t, err)
require.NotNil(t, out)
assert.Equal(t, c.wantSQL, out.SQL)
assert.Equal(t, c.wantArgs, out.Args)
})
}
}

View File

@@ -19,13 +19,17 @@ import type {
import type {
CreateDashboardV2201,
CreateDashboardView201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
DashboardtypesPatchableDashboardV2DTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostableDashboardViewDTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatableDashboardV2DTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeleteDashboardV2PathParameters,
DeleteDashboardViewPathParameters,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
@@ -35,13 +39,20 @@ import type {
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
ListDashboardViews200,
ListDashboardsV2200,
ListDashboardsV2Params,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
PinDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UnpinDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdateDashboardView200,
UpdateDashboardViewPathParameters,
UpdatePublicDashboardPathParameters,
} from '../sigNoz.schemas';
@@ -641,6 +652,103 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List dashboards (v2)
*/
export const listDashboardsV2 = (
params?: ListDashboardsV2Params,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDashboardsV2200>({
url: `/api/v2/dashboards`,
method: 'GET',
params,
signal,
});
};
export const getListDashboardsV2QueryKey = (
params?: ListDashboardsV2Params,
) => {
return [`/api/v2/dashboards`, ...(params ? [params] : [])] as const;
};
export const getListDashboardsV2QueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardsV2QueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof listDashboardsV2>>> = ({
signal,
}) => listDashboardsV2(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardsV2QueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardsV2>>
>;
export type ListDashboardsV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboards (v2)
*/
export function useListDashboardsV2<
TData = Awaited<ReturnType<typeof listDashboardsV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListDashboardsV2Params,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardsV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardsV2QueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboards (v2)
*/
export const invalidateListDashboardsV2 = async (
queryClient: QueryClient,
params?: ListDashboardsV2Params,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardsV2QueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a dashboard in the v2 format that follows Perses spec.
* @summary Create dashboard (v2)
@@ -724,6 +832,85 @@ export const useCreateDashboardV2 = <
> => {
return useMutation(getCreateDashboardV2MutationOptions(options));
};
/**
* This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.
* @summary Delete dashboard (v2)
*/
export const deleteDashboardV2 = (
{ id }: DeleteDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardV2'];
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 deleteDashboardV2>>,
{ pathParams: DeleteDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardV2>>
>;
export type DeleteDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard (v2)
*/
export const useDeleteDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardV2>>,
TError,
{ pathParams: DeleteDashboardV2PathParameters },
TContext
> => {
return useMutation(getDeleteDashboardV2MutationOptions(options));
};
/**
* This endpoint returns a v2-shape dashboard.
* @summary Get dashboard (v2)
@@ -1181,3 +1368,509 @@ export const useLockDashboardV2 = <
> => {
return useMutation(getLockDashboardV2MutationOptions(options));
};
/**
* Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.
* @summary Unpin a dashboard for the current user (v2)
*/
export const unpinDashboardV2 = (
{ id }: UnpinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/pins/me`,
method: 'DELETE',
signal,
});
};
export const getUnpinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unpinDashboardV2'];
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 unpinDashboardV2>>,
{ pathParams: UnpinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unpinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnpinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unpinDashboardV2>>
>;
export type UnpinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unpin a dashboard for the current user (v2)
*/
export const useUnpinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unpinDashboardV2>>,
TError,
{ pathParams: UnpinDashboardV2PathParameters },
TContext
> => {
return useMutation(getUnpinDashboardV2MutationOptions(options));
};
/**
* Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.
* @summary Pin a dashboard for the current user (v2)
*/
export const pinDashboardV2 = (
{ id }: PinDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/pins/me`,
method: 'PUT',
signal,
});
};
export const getPinDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['pinDashboardV2'];
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 pinDashboardV2>>,
{ pathParams: PinDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return pinDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type PinDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof pinDashboardV2>>
>;
export type PinDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Pin a dashboard for the current user (v2)
*/
export const usePinDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof pinDashboardV2>>,
TError,
{ pathParams: PinDashboardV2PathParameters },
TContext
> => {
return useMutation(getPinDashboardV2MutationOptions(options));
};
/**
* Returns every saved view in the calling user's org. Saved views are shared org-wide; any user may read, create, edit, and delete any view.
* @summary List dashboard saved views
*/
export const listDashboardViews = (signal?: AbortSignal) => {
return GeneratedAPIInstance<ListDashboardViews200>({
url: `/api/v2/dashboards/views`,
method: 'GET',
signal,
});
};
export const getListDashboardViewsQueryKey = () => {
return [`/api/v2/dashboards/views`] as const;
};
export const getListDashboardViewsQueryOptions = <
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getListDashboardViewsQueryKey();
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listDashboardViews>>
> = ({ signal }) => listDashboardViews(signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListDashboardViewsQueryResult = NonNullable<
Awaited<ReturnType<typeof listDashboardViews>>
>;
export type ListDashboardViewsQueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List dashboard saved views
*/
export function useListDashboardViews<
TData = Awaited<ReturnType<typeof listDashboardViews>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listDashboardViews>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListDashboardViewsQueryOptions(options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List dashboard saved views
*/
export const invalidateListDashboardViews = async (
queryClient: QueryClient,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListDashboardViewsQueryKey() },
options,
);
return queryClient;
};
/**
* Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.
* @summary Create dashboard saved view
*/
export const createDashboardView = (
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardView201>({
url: `/api/v2/dashboards/views`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getCreateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
const mutationKey = ['createDashboardView'];
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 createDashboardView>>,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardView(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardView>>
>;
export type CreateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type CreateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard saved view
*/
export const useCreateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardView>>,
TError,
{ data?: BodyType<DashboardtypesPostableDashboardViewDTO> },
TContext
> => {
return useMutation(getCreateDashboardViewMutationOptions(options));
};
/**
* Removes a saved view. Saved views are shared org-wide; any user in the org may delete any view. Idempotent — deleting a non-existent view returns 404.
* @summary Delete dashboard saved view
*/
export const deleteDashboardView = (
{ id }: DeleteDashboardViewPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/views/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
const mutationKey = ['deleteDashboardView'];
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 deleteDashboardView>>,
{ pathParams: DeleteDashboardViewPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteDashboardView(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteDashboardView>>
>;
export type DeleteDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete dashboard saved view
*/
export const useDeleteDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteDashboardView>>,
TError,
{ pathParams: DeleteDashboardViewPathParameters },
TContext
> => {
return useMutation(getDeleteDashboardViewMutationOptions(options));
};
/**
* Replaces a saved view's name and data. Saved views are shared org-wide; any user in the org may edit any view.
* @summary Update dashboard saved view
*/
export const updateDashboardView = (
{ id }: UpdateDashboardViewPathParameters,
dashboardtypesPostableDashboardViewDTO?: BodyType<DashboardtypesPostableDashboardViewDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateDashboardView200>({
url: `/api/v2/dashboards/views/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardViewDTO,
signal,
});
};
export const getUpdateDashboardViewMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardView'];
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 updateDashboardView>>,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardView(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardViewMutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardView>>
>;
export type UpdateDashboardViewMutationBody =
| BodyType<DashboardtypesPostableDashboardViewDTO>
| undefined;
export type UpdateDashboardViewMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard saved view
*/
export const useUpdateDashboardView = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardView>>,
TError,
{
pathParams: UpdateDashboardViewPathParameters;
data?: BodyType<DashboardtypesPostableDashboardViewDTO>;
},
TContext
> => {
return useMutation(getUpdateDashboardViewMutationOptions(options));
};

View File

@@ -4587,6 +4587,54 @@ export interface DashboardtypesDashboardSpecDTO {
variables?: DashboardtypesVariableDTO[];
}
export enum DashboardtypesListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum DashboardtypesListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface DashboardtypesDashboardViewDataDTO {
order?: DashboardtypesListOrderDTO;
/**
* @type string
*/
query?: string;
sort?: DashboardtypesListSortDTO;
/**
* @type string
*/
version: string;
}
export interface DashboardtypesDashboardViewDTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
id: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type string
* @format date-time
*/
updatedAt?: string;
}
export enum DashboardtypesDatasourcePluginKindDTO {
'signoz/Datasource' = 'signoz/Datasource',
}
@@ -4698,6 +4746,88 @@ export interface DashboardtypesJSONPatchOperationDTO {
value?: unknown;
}
export interface DashboardtypesListedDashboardV2SpecDTO {
display?: CommonDisplayDTO;
}
export interface DashboardtypesListedDashboardV2DTO {
/**
* @type string
* @format date-time
*/
createdAt?: string;
/**
* @type string
*/
createdBy?: string;
/**
* @type string
*/
id: string;
/**
* @type string
*/
image?: string;
/**
* @type boolean
*/
locked: boolean;
/**
* @type string
*/
name: string;
/**
* @type string
*/
orgId: string;
/**
* @type boolean
*/
pinned: boolean;
/**
* @type string
*/
schemaVersion: string;
source: DashboardtypesSourceDTO;
spec: DashboardtypesListedDashboardV2SpecDTO;
/**
* @type array
*/
tags: TagtypesPostableTagDTO[];
/**
* @type string
* @format date-time
*/
updatedAt?: string;
/**
* @type string
*/
updatedBy?: string;
}
export interface DashboardtypesListableDashboardV2DTO {
/**
* @type array
*/
dashboards: DashboardtypesListedDashboardV2DTO[];
/**
* @type array
*/
tags: TagtypesPostableTagDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export interface DashboardtypesListableDashboardViewDTO {
/**
* @type array
*/
views: DashboardtypesDashboardViewDTO[];
}
export enum DashboardtypesPanelPluginKindDTO {
'signoz/TimeSeriesPanel' = 'signoz/TimeSeriesPanel',
'signoz/BarChartPanel' = 'signoz/BarChartPanel',
@@ -4738,6 +4868,14 @@ export interface DashboardtypesPostableDashboardV2DTO {
tags: TagtypesPostableTagDTO[] | null;
}
export interface DashboardtypesPostableDashboardViewDTO {
data: DashboardtypesDashboardViewDataDTO;
/**
* @type string
*/
name: string;
}
export interface DashboardtypesPostablePublicDashboardDTO {
/**
* @type string
@@ -9541,6 +9679,40 @@ export type GetUserPreference200 = {
export type UpdateUserPreferencePathParameters = {
name: string;
};
export type ListDashboardsV2Params = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
sort?: DashboardtypesListSortDTO;
/**
* @description undefined
*/
order?: DashboardtypesListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListDashboardsV2200 = {
data: DashboardtypesListableDashboardV2DTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardV2201 = {
data: DashboardtypesGettableDashboardV2DTO;
/**
@@ -9549,6 +9721,9 @@ export type CreateDashboardV2201 = {
status: string;
};
export type DeleteDashboardV2PathParameters = {
id: string;
};
export type GetDashboardV2PathParameters = {
id: string;
};
@@ -9588,6 +9763,42 @@ export type UnlockDashboardV2PathParameters = {
export type LockDashboardV2PathParameters = {
id: string;
};
export type UnpinDashboardV2PathParameters = {
id: string;
};
export type PinDashboardV2PathParameters = {
id: string;
};
export type ListDashboardViews200 = {
data: DashboardtypesListableDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type CreateDashboardView201 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type DeleteDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardViewPathParameters = {
id: string;
};
export type UpdateDashboardView200 = {
data: DashboardtypesDashboardViewDTO;
/**
* @type string
*/
status: string;
};
export type GetFeatures200 = {
/**
* @type array

View File

@@ -1,14 +1,15 @@
import { getWaterfallV4 } from 'api/generated/services/tracedetail';
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV4PayloadProps,
GetTraceV4SuccessResponse,
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV4 = async (
props: GetTraceV4PayloadProps,
): Promise<SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse> => {
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
@@ -18,37 +19,31 @@ const getTraceV4 = async (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// Backend only uses the uncollapsedSpans list (unlike V2 which also interprets
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const response = await getWaterfallV4(
{ traceID: props.traceId },
{
selectedSpanId: props.selectedSpanId,
uncollapsedSpans,
limit: 10000,
},
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// Generated client unwraps the axios response; .data is the waterfall payload.
// Wire spans carry time_unix; SpanV3's timestamp + 'service.name' are derived below.
type WireSpan = Omit<SpanV3, 'timestamp' | 'service.name'> & {
time_unix: number;
};
const rawPayload = response.data as unknown as Omit<
GetTraceV4SuccessResponse,
'spans'
> & { spans: WireSpan[] | null };
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span) => ({
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
timestamp: span.time_unix,
}));
// API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
// Convert by using the first span's timestamp as the base if there's a mismatch.
let { startTimestampMillis, endTimestampMillis } = rawPayload;
@@ -75,4 +70,4 @@ const getTraceV4 = async (
};
};
export default getTraceV4;
export default getTraceV3;

View File

@@ -33,8 +33,7 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V4_WATERFALL: 'GET_TRACE_V4_WATERFALL',
GET_TRACE_AGGREGATIONS: 'GET_TRACE_AGGREGATIONS',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -1,66 +0,0 @@
import { renderHook, waitFor } from '@testing-library/react';
import { getTraceAggregations } from 'api/generated/services/tracedetail';
import { ReactNode } from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import useGetTraceAggregations from '../useGetTraceAggregations';
jest.mock('api/generated/services/tracedetail', () => ({
__esModule: true,
getTraceAggregations: jest
.fn()
.mockResolvedValue({ status: 'success', data: { aggregations: [] } }),
}));
const mockApi = getTraceAggregations as jest.Mock;
const wrapper = ({ children }: { children: ReactNode }): JSX.Element => {
const client = new QueryClient({
defaultOptions: { queries: { retry: false } },
});
return <QueryClientProvider client={client}>{children}</QueryClientProvider>;
};
const aggregations = [
{ field: { name: 'service.name' }, aggregation: 'execution_time_percentage' },
] as never;
describe('useGetTraceAggregations', () => {
beforeEach(() => mockApi.mockClear());
it('fetches when enabled with a traceId and aggregations', async () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: true }),
{ wrapper },
);
await waitFor(() => expect(mockApi).toHaveBeenCalledTimes(1));
expect(mockApi).toHaveBeenCalledWith({ traceID: 't1' }, { aggregations });
});
it('does not fetch when disabled', () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations, enabled: false }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
it('does not fetch without a traceId', () => {
renderHook(
() => useGetTraceAggregations({ traceId: '', aggregations, enabled: true }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
it('does not fetch with no aggregations requested', () => {
renderHook(
() =>
useGetTraceAggregations({ traceId: 't1', aggregations: [], enabled: true }),
{ wrapper },
);
expect(mockApi).not.toHaveBeenCalled();
});
});

View File

@@ -1,34 +0,0 @@
import { useQuery, UseQueryResult } from 'react-query';
import {
GetTraceAggregations200,
SpantypesSpanAggregationDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getTraceAggregations } from 'api/generated/services/tracedetail';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
interface UseGetTraceAggregationsProps {
traceId: string;
aggregations: SpantypesSpanAggregationDTO[];
enabled: boolean;
}
type UseGetTraceAggregations = UseQueryResult<GetTraceAggregations200>;
/**
* Fetches trace aggregations on demand — gate via `enabled` so the request
* fires only when the Analytics panel is open. The query key includes the
* requested fields, so changing the color-by field refetches.
*/
const useGetTraceAggregations = ({
traceId,
aggregations,
enabled,
}: UseGetTraceAggregationsProps): UseGetTraceAggregations =>
useQuery({
queryFn: () => getTraceAggregations({ traceID: traceId }, { aggregations }),
queryKey: [REACT_QUERY_KEY.GET_TRACE_AGGREGATIONS, traceId, aggregations],
enabled: enabled && !!traceId && aggregations.length > 0,
refetchOnWindowFocus: false,
});
export default useGetTraceAggregations;

View File

@@ -1,29 +1,30 @@
import { useQuery, UseQueryResult } from 'react-query';
import getTraceV4 from 'api/trace/getTraceV4';
import getTraceV3 from 'api/trace/getTraceV3';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV4PayloadProps,
GetTraceV4SuccessResponse,
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
} from 'types/api/trace/getTraceV3';
const useGetTraceV4 = (props: GetTraceV4PayloadProps): UseTraceV4 =>
const useGetTraceV3 = (props: GetTraceV3PayloadProps): UseTraceV3 =>
useQuery({
queryFn: () => getTraceV4(props),
queryFn: () => getTraceV3(props),
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V4_WATERFALL,
REACT_QUERY_KEY.GET_TRACE_V3_WATERFALL,
props.traceId,
props.selectedSpanId,
props.isSelectedSpanIDUnCollapsed,
props.aggregations,
],
enabled: !!props.traceId,
keepPreviousData: true,
refetchOnWindowFocus: false,
});
type UseTraceV4 = UseQueryResult<
SuccessResponse<GetTraceV4SuccessResponse> | ErrorResponse,
type UseTraceV3 = UseQueryResult<
SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse,
unknown
>;
export default useGetTraceV4;
export default useGetTraceV3;

View File

@@ -33,15 +33,6 @@
scrollbar-width: none;
}
.state {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
min-height: 120px;
padding: 24px 12px;
}
.list {
display: grid;
grid-template-columns: auto auto 1fr;

View File

@@ -1,28 +1,21 @@
import { useMemo } from 'react';
import { useParams } from 'react-router-dom';
import {
TabsContent,
TabsList,
TabsRoot,
TabsTrigger,
} from '@signozhq/ui/tabs';
import cx from 'classnames';
import { DetailsHeader } from 'components/DetailsPanel';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
import { generateColorPair } from 'pages/TraceDetailsV3/utils/generateColorPair';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import {
SpantypesSpanAggregationDTO,
TelemetrytypesTelemetryFieldKeyDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import { useTraceStore } from '../../stores/traceStore';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../../utils/aggregations';
import AnalyticsTabContent, { AnalyticsRow } from './AnalyticsTabContent';
import styles from './AnalyticsPanel.module.scss';
@@ -42,31 +35,10 @@ function AnalyticsPanel({
onClose,
onTabChange,
}: AnalyticsPanelProps): JSX.Element | null {
const { id: traceId } = useParams<TraceDetailV3URLProps>();
const colorByField = useTraceStore((s) => s.colorByField);
const colorByFieldName = colorByField.name;
const aggregations = useTraceStore((s) => s.aggregations);
const colorByFieldName = useTraceStore((s) => s.colorByField.name);
const isDarkMode = useIsDarkMode();
// Fetch exec-time % + span count for the current color-by field only, and
// only while the panel is open. Changing the field refetches via the key.
const aggregationsRequest = useMemo<SpantypesSpanAggregationDTO[]>(() => {
// v5 TelemetryFieldKey and the generated DTO are runtime-identical; only
// the literal-union vs enum nominal types differ
const field = colorByField as unknown as TelemetrytypesTelemetryFieldKeyDTO;
return [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
];
}, [colorByField]);
const { data, isLoading, isError } = useGetTraceAggregations({
traceId: traceId || '',
aggregations: aggregationsRequest,
enabled: isOpen,
});
const aggregations = data?.data.aggregations;
const execTimePct = useMemo(
() =>
findAggregationMap(
@@ -83,39 +55,38 @@ function AnalyticsPanel({
[aggregations, colorByFieldName],
);
const execTimeRows = useMemo<AnalyticsRow[]>(() => {
const execTimeRows = useMemo(() => {
if (!execTimePct) {
return [];
}
return Object.entries(execTimePct)
.sort(([, a], [, b]) => b - a)
.map(([group, percentage]) => {
const pair = generateColorPair(group);
return {
group,
percentage,
color: isDarkMode ? pair.color : pair.colorDark,
widthPct: Math.min(percentage, 100),
label: `${percentage.toFixed(2)}%`,
};
});
})
.sort((a, b) => b.percentage - a.percentage);
}, [execTimePct, isDarkMode]);
const spanCountRows = useMemo<AnalyticsRow[]>(() => {
const spanCountRows = useMemo(() => {
if (!spanCounts) {
return [];
}
const max = Math.max(...Object.values(spanCounts), 1);
return Object.entries(spanCounts)
.sort(([, a], [, b]) => b - a)
.map(([group, count]) => {
const pair = generateColorPair(group);
return {
group,
count,
max,
color: isDarkMode ? pair.color : pair.colorDark,
widthPct: (count / max) * 100,
label: String(count),
};
});
})
.sort((a, b) => b.count - a.count);
}, [spanCounts, isDarkMode]);
if (!isOpen) {
@@ -161,23 +132,65 @@ function AnalyticsPanel({
<div className={styles.tabsScroll}>
<TabsContent value="exec-time">
<AnalyticsTabContent
isLoading={isLoading}
isError={isError}
fieldName={colorByFieldName}
rows={execTimeRows}
valueVariant="wide"
/>
<div className={styles.list}>
{execTimeRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${Math.min(row.percentage, 100)}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueWide)}>
{row.percentage.toFixed(2)}%
</span>
</div>
</>
))}
</div>
</TabsContent>
<TabsContent value="spans">
<AnalyticsTabContent
isLoading={isLoading}
isError={isError}
fieldName={colorByFieldName}
rows={spanCountRows}
valueVariant="narrow"
/>
<div className={styles.list}>
{spanCountRows.map((row) => (
<>
<div
key={`${row.group}-dot`}
className={styles.dot}
style={{ backgroundColor: row.color }}
/>
<span key={`${row.group}-name`} className={styles.serviceName}>
{row.group}
</span>
<div key={`${row.group}-bar`} className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${(row.count / row.max) * 100}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className={cx(styles.value, styles.valueNarrow)}>
{row.count}
</span>
</div>
</>
))}
</div>
</TabsContent>
</div>
</TabsRoot>

View File

@@ -1,84 +0,0 @@
import { Fragment } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Spinner from 'components/Spinner';
import styles from './AnalyticsPanel.module.scss';
export interface AnalyticsRow {
group: string;
color: string;
widthPct: number;
label: string;
}
interface AnalyticsTabContentProps {
isLoading: boolean;
isError: boolean;
fieldName: string;
rows: AnalyticsRow[];
valueVariant: 'wide' | 'narrow';
}
// Loading / error / empty render in place of the rows so the tabs stay visible.
function AnalyticsTabContent({
isLoading,
isError,
fieldName,
rows,
valueVariant,
}: AnalyticsTabContentProps): JSX.Element {
if (isLoading) {
return (
<div className={styles.state}>
<Spinner height="auto" />
</div>
);
}
if (isError) {
return (
<div className={styles.state}>
<Typography.Text>Couldn&apos;t load analytics</Typography.Text>
</div>
);
}
if (rows.length === 0) {
return (
<div className={styles.state}>
<Typography.Text>No data for {fieldName}</Typography.Text>
</div>
);
}
return (
<div className={styles.list}>
{rows.map((row) => (
<Fragment key={row.group}>
<div className={styles.dot} style={{ backgroundColor: row.color }} />
<span className={styles.serviceName}>{row.group}</span>
<div className={styles.barCell}>
<div className={styles.bar}>
<div
className={styles.barFill}
style={{
width: `${row.widthPct}%`,
backgroundColor: row.color,
}}
/>
</div>
<span
className={cx(
styles.value,
valueVariant === 'wide' ? styles.valueWide : styles.valueNarrow,
)}
>
{row.label}
</span>
</div>
</Fragment>
))}
</div>
);
}
export default AnalyticsTabContent;

View File

@@ -1,144 +0,0 @@
import { screen } from '@testing-library/react';
import useGetTraceAggregations from 'hooks/trace/useGetTraceAggregations';
import { render } from 'tests/test-utils';
import { DEFAULT_COLOR_BY_FIELD } from '../../../constants';
import { useTraceStore } from '../../../stores/traceStore';
import AnalyticsPanel from '../AnalyticsPanel';
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useParams: (): { id: string } => ({ id: 'trace-123' }),
}));
jest.mock('hooks/trace/useGetTraceAggregations', () => ({
__esModule: true,
default: jest.fn(),
}));
// Isolate the panel's own logic from the floating-panel chrome.
jest.mock('periscope/components/FloatingPanel', () => ({
__esModule: true,
FloatingPanel: ({ children }: { children: React.ReactNode }): JSX.Element => (
<div>{children}</div>
),
}));
jest.mock('components/DetailsPanel', () => ({
__esModule: true,
DetailsHeader: (): JSX.Element => <div data-testid="details-header" />,
}));
jest.mock('components/Spinner', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="spinner" />,
}));
const mockHook = useGetTraceAggregations as jest.Mock;
const noop = (): void => undefined;
const renderPanel = (isOpen = true): ReturnType<typeof render> =>
render(<AnalyticsPanel isOpen={isOpen} onClose={noop} onTabChange={noop} />);
const aggregationsResponse = {
status: 'success',
data: {
aggregations: [
{
field: { name: 'service.name' },
aggregation: 'execution_time_percentage',
value: { api: 80, db: 20 },
},
{
field: { name: 'service.name' },
aggregation: 'span_count',
value: { api: 5, db: 2 },
},
],
},
};
describe('AnalyticsPanel', () => {
beforeEach(() => {
mockHook.mockReset();
useTraceStore.setState({ colorByField: DEFAULT_COLOR_BY_FIELD });
});
it('renders nothing when closed and does not enable the fetch', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: false,
isError: false,
});
const { container } = renderPanel(false);
expect(container).toBeEmptyDOMElement();
expect(mockHook).toHaveBeenCalledWith(
expect.objectContaining({ enabled: false }),
);
});
it('requests both aggregations for the current color-by field when open', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderPanel();
expect(mockHook).toHaveBeenCalledWith(
expect.objectContaining({
traceId: 'trace-123',
enabled: true,
aggregations: [
{
field: DEFAULT_COLOR_BY_FIELD,
aggregation: 'execution_time_percentage',
},
{ field: DEFAULT_COLOR_BY_FIELD, aggregation: 'span_count' },
],
}),
);
});
it('shows the loading state with the tabs still visible', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
});
renderPanel();
expect(screen.getByTestId('spinner')).toBeInTheDocument();
// tabs stay visible while loading
expect(screen.getByText('% exec time')).toBeInTheDocument();
expect(screen.getByText('Spans')).toBeInTheDocument();
});
it('shows an error state when the request fails', () => {
mockHook.mockReturnValue({
data: undefined,
isLoading: false,
isError: true,
});
renderPanel();
expect(screen.getByText(/couldn't load analytics/i)).toBeInTheDocument();
});
it('renders rows for the current field on success', () => {
mockHook.mockReturnValue({
data: aggregationsResponse,
isLoading: false,
isError: false,
});
renderPanel();
expect(screen.getByText('api')).toBeInTheDocument();
expect(screen.getByText('80.00%')).toBeInTheDocument();
});
it('shows an empty state when the field has no data', () => {
mockHook.mockReturnValue({
data: { status: 'success', data: { aggregations: [] } },
isLoading: false,
isError: false,
});
renderPanel();
expect(screen.getByText(/no data for service.name/i)).toBeInTheDocument();
});
});

View File

@@ -1,6 +1,3 @@
// Applied to both the menu content and the submenu content (each renders in
// its own portal with a default z-index of 50) so both stack above
// FloatingPanel (z-index 999).
.traceOptionsDropdown {
z-index: 1100;
}

View File

@@ -1,16 +1,7 @@
import { useMemo } from 'react';
import type { MenuItem } from '@signozhq/ui/dropdown-menu';
import { Button } from '@signozhq/ui/button';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuTrigger,
} from '@signozhq/ui/dropdown-menu';
import { DropdownMenuSimple as Dropdown } from '@signozhq/ui/dropdown-menu';
import { Settings2 } from '@signozhq/icons';
import { useTraceStore } from '../stores/traceStore';
@@ -23,9 +14,6 @@ interface TraceOptionsMenuProps {
onOpenPreviewFields: () => void;
}
// Composed from dropdown-menu primitives (instead of DropdownMenuSimple)
// because the simple preset offers no way to style the submenu content,
// which renders in its own portal and needs a z-index above FloatingPanel.
function TraceOptionsMenu({
showTraceDetails,
onToggleTraceDetails,
@@ -37,52 +25,78 @@ function TraceOptionsMenu({
(s) => s.availableColorByOptions,
);
const handleColorByChange = (name: string): void => {
const next = availableColorByOptions.find((o) => o.field.name === name);
if (next) {
setColorByField(next.field);
const menuItems: MenuItem[] = useMemo(() => {
const items: MenuItem[] = [
{
key: 'toggle-trace-details',
label: showTraceDetails ? 'Hide trace details' : 'Show trace details',
onClick: onToggleTraceDetails,
},
{
key: 'preview-fields',
label: 'Preview fields',
onClick: onOpenPreviewFields,
},
];
// Only show the "Colour by" submenu if there's an actual choice to make.
if (availableColorByOptions.length > 1) {
items.push({
key: 'colour-by',
label: 'Colour by',
children: [
{
type: 'group',
label: 'COLOUR BY',
children: [
{
type: 'radio-group',
value: colorByField.name,
onChange: (name: string): void => {
const next = availableColorByOptions.find(
(o) => o.field.name === name,
);
if (next) {
setColorByField(next.field);
}
},
children: availableColorByOptions.map((opt) => ({
type: 'radio',
key: opt.field.name,
label: opt.label,
value: opt.field.name,
})),
},
],
},
],
});
}
};
return items;
}, [
showTraceDetails,
onToggleTraceDetails,
onOpenPreviewFields,
colorByField.name,
setColorByField,
availableColorByOptions,
]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
/>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" className={styles.traceOptionsDropdown}>
<DropdownMenuItem clickable onSelect={onToggleTraceDetails}>
{showTraceDetails ? 'Hide trace details' : 'Show trace details'}
</DropdownMenuItem>
<DropdownMenuItem clickable onSelect={onOpenPreviewFields}>
Preview fields
</DropdownMenuItem>
{/* Only show the "Colour by" submenu if there's an actual choice to make. */}
{availableColorByOptions.length > 1 && (
<DropdownMenuSub>
<DropdownMenuSubTrigger>Colour by</DropdownMenuSubTrigger>
<DropdownMenuSubContent className={styles.traceOptionsDropdown}>
<DropdownMenuLabel>COLOUR BY</DropdownMenuLabel>
<DropdownMenuRadioGroup
value={colorByField.name}
onValueChange={handleColorByChange}
>
{availableColorByOptions.map((opt) => (
<DropdownMenuRadioItem key={opt.field.name} value={opt.field.name}>
{opt.label}
</DropdownMenuRadioItem>
))}
</DropdownMenuRadioGroup>
</DropdownMenuSubContent>
</DropdownMenuSub>
)}
</DropdownMenuContent>
</DropdownMenu>
<Dropdown
menu={{ items: menuItems }}
align="start"
className={styles.traceOptionsDropdown}
>
<Button
variant="ghost"
size="icon"
color="secondary"
aria-label="Trace options"
prefix={<Settings2 size={14} />}
/>
</Dropdown>
);
}

View File

@@ -1,9 +1,6 @@
import { FlamegraphSpan } from 'types/api/trace/getTraceFlamegraph';
import {
computeVisualLayout,
WIDE_GROUP_THRESHOLD,
} from '../computeVisualLayout';
import { computeVisualLayout } from '../computeVisualLayout';
function makeSpan(
overrides: Partial<FlamegraphSpan> & {
@@ -475,177 +472,4 @@ describe('computeVisualLayout', () => {
expect(aRow).toBeGreaterThan(1); // must NOT be at row 1
expect(aRow).toBe(3); // next free row after B at row 2 (A overlaps B)
});
// --- Wide-group fast path (> WIDE_GROUP_THRESHOLD siblings) ---
// Past the threshold the layout switches to exact overlap-only packing to
// avoid the O(N^2) connector-avoidance spiral. These lock in correctness and
// the no-overlap invariant at scale.
function noRowHasOverlap(
layout: ReturnType<typeof computeVisualLayout>,
): void {
for (const row of layout.visualRows) {
const sorted = [...row].sort((a, b) => a.timestamp - b.timestamp);
for (let i = 1; i < sorted.length; i++) {
const prevEnd = sorted[i - 1].timestamp + sorted[i - 1].durationNano / 1e6;
expect(sorted[i].timestamp).toBeGreaterThanOrEqual(prevEnd);
}
}
}
it('should pack thousands of sequential leaf siblings into 1 row (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 2000 strictly sequential (non-overlapping) children
for (let i = 0; i < 2000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6, // 5ms, ends before next starts
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.spanToVisualRow['root']).toBe(0);
expect(layout.totalVisualRows).toBe(2); // all siblings share row 1
for (const k of kids) {
expect(layout.spanToVisualRow[k.spanId]).toBe(1);
}
noRowHasOverlap(layout);
});
it('should pack thousands of fully-overlapping leaf siblings without violations (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
// 1000 children all spanning the same window → each needs its own row
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: 0,
durationNano: 100e6,
}),
);
}
const layout = computeVisualLayout([[root], kids]);
expect(layout.totalVisualRows).toBe(1001); // root + 1000 stacked rows
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1001);
noRowHasOverlap(layout);
});
it('should keep non-leaf subtrees adjacent within a wide mixed group (wide path)', () => {
const root = makeSpan({ spanId: 'root', timestamp: 0, durationNano: 1e12 });
const kids: FlamegraphSpan[] = [];
for (let i = 0; i < 1000; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * 10,
durationNano: 5e6,
}),
);
}
// One of the wide siblings has a child of its own
const grandchild = makeSpan({
spanId: 'gc',
parentSpanId: 'k500',
timestamp: 5000,
durationNano: 2e6,
});
const layout = computeVisualLayout([[root], kids, [grandchild]]);
const parentRow = layout.spanToVisualRow['k500'];
const gcRow = layout.spanToVisualRow['gc'];
expect(gcRow - parentRow).toBe(1); // subtree adjacency preserved
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(1002);
noRowHasOverlap(layout);
});
// --- Regression guards for the wide-trace layout spiral ---
// The pre-fix algorithm's connector-avoidance checks formed a positive-
// feedback loop on wide SCATTERED groups (short spans spread across the
// parent window with modest concurrency): each pushed-down child stamped
// connector points on intermediate rows, pushing later children even
// higher. Sequential and fully-overlapping groups (tests above) do NOT
// trigger it — these two shapes do, and encode its failure signatures.
function makeScatteredStar(
childCount: number,
spacingMs: number,
durationMs: number,
): FlamegraphSpan[][] {
const root = makeSpan({
spanId: 'root',
timestamp: 0,
durationNano: (childCount * spacingMs + durationMs) * 1e6,
});
const kids: FlamegraphSpan[] = [];
for (let i = 0; i < childCount; i++) {
kids.push(
makeSpan({
spanId: `k${i}`,
parentSpanId: 'root',
timestamp: i * spacingMs,
durationNano: durationMs * 1e6,
}),
);
}
return [[root], kids];
}
it('should not spiral row count on a scattered group just above the threshold', () => {
// Children of 50ms each, starting every 10ms → max temporal concurrency
// is 6 (each span overlaps the next 5). The old algorithm spiraled this
// shape to ~1 row per child; overlap-only packing needs ~7 including the
// root. Sized just above WIDE_GROUP_THRESHOLD so a regression fails fast
// (sub-second) rather than burning CI time.
const count = WIDE_GROUP_THRESHOLD + 88;
const layout = computeVisualLayout(makeScatteredStar(count, 10, 50));
expect(Object.keys(layout.spanToVisualRow)).toHaveLength(count + 1);
// Generous slack over the optimal ~7 — but orders of magnitude below the
// one-row-per-child the spiral produced.
expect(layout.totalVisualRows).toBeLessThan(30);
noRowHasOverlap(layout);
});
it('should be faster through the fast path despite one extra span', () => {
// Identical scattered shape on both sides of the gate: THRESHOLD children
// run the original connector-avoidance path, THRESHOLD + 1 run the
// overlap-only fast path. With one MORE span to place, the fast path can
// only win because the algorithm is cheaper — a self-calibrating
// comparison (same machine, same process) with no absolute time budget.
// On this shape the avoidance path spirals (~tens of ms) while the fast
// path stays ~1ms, so the margin is well past timer noise. Also guards
// the gate itself: if everything routed to one path, one extra span can
// never be faster.
const avoidancePathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD, 10, 50);
const fastPathInput = makeScatteredStar(WIDE_GROUP_THRESHOLD + 1, 10, 50);
// Warm-up runs so JIT compilation doesn't skew either side.
computeVisualLayout(avoidancePathInput);
computeVisualLayout(fastPathInput);
const timeOf = (input: FlamegraphSpan[][]): number => {
const start = performance.now();
computeVisualLayout(input);
return performance.now() - start;
};
// Best-of-3 per side to shave off GC pauses and scheduler noise.
const RUNS = [0, 1, 2];
const avoidanceMs = Math.min(...RUNS.map(() => timeOf(avoidancePathInput)));
const fastMs = Math.min(...RUNS.map(() => timeOf(fastPathInput)));
expect(fastMs).toBeLessThan(avoidanceMs);
});
});

View File

@@ -18,82 +18,6 @@ export interface VisualLayout {
totalVisualRows: number;
}
// Above this many siblings under one parent, the connector-avoidance refinement
// (Checks 2 & 3) is both visually meaningless — the row is already a dense wall —
// and quadratic: every child deposits a connector point on each intermediate row,
// which pushes later children even higher, which deposits more points. That
// feedback loop inflates a layout needing ~50 rows to thousands and never
// finishes on wide traces. Past the threshold we pack by overlap only.
// Exported so the regression tests stay anchored to the real gate value.
export const WIDE_GROUP_THRESHOLD = 512;
/**
* Segment tree over rows that answers "lowest row index >= `from` whose smallest
* span start-time is >= `end`" in O(log rows). Used to place a large group of
* leaf siblings by overlap only: because siblings are processed in descending
* start order, every already-placed span on a row starts at or after the current
* one, so [start, end] overlaps a row iff some span there starts before `end` —
* i.e. the row is free iff its minimum start >= end. Each node stores the max of
* its subtree's per-row minimum starts so a free row can be found by descent.
*/
class LowestFreeRow {
private readonly size: number;
private readonly tree: Float64Array;
constructor(rows: number) {
let size = 1;
while (size < rows) {
size *= 2;
}
this.size = size;
this.tree = new Float64Array(size * 2).fill(Infinity);
}
place(row: number, start: number): void {
let i = row + this.size;
// A row's key is the minimum start among its spans. Children are processed
// in descending start order so a leaf's start is the new minimum, but a
// non-leaf subtree's descendant can land on a row out of order — take min.
if (start >= this.tree[i]) {
return;
}
this.tree[i] = start;
for (i >>= 1; i >= 1; i >>= 1) {
const next = Math.max(this.tree[2 * i], this.tree[2 * i + 1]);
if (this.tree[i] === next) {
break;
}
this.tree[i] = next;
}
}
lowestFrom(from: number, end: number): number {
return this.descend(1, 0, this.size - 1, from, end);
}
private descend(
node: number,
lo: number,
hi: number,
from: number,
end: number,
): number {
if (hi < from || this.tree[node] < end) {
return -1;
}
if (lo === hi) {
return lo;
}
const mid = (lo + hi) >> 1;
const left = this.descend(2 * node, lo, mid, from, end);
if (left !== -1) {
return left;
}
return this.descend(2 * node + 1, mid + 1, hi, from, end);
}
}
/**
* Computes an overlap-safe visual layout for flamegraph spans using DFS ordering.
*
@@ -290,53 +214,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
arr.push(point);
}
// Fast path for a parent with a very large group of children: pack by overlap
// only (descending greedy), skipping the quadratic connector-avoidance that
// spirals at this scale. Leaf children — the bulk of a wide trace — are placed
// in O(log rows) via the segment tree; the rare non-leaf subtree falls back to
// findPlacement against the shared interval map. Both structures are kept in
// sync so each placement sees all prior occupancy. Same ShapeEntry[] contract.
function computeWideShape(
rootSpan: FlamegraphSpan,
children: FlamegraphSpan[],
): ShapeEntry[] {
const shape: ShapeEntry[] = [{ span: rootSpan, relativeRow: 0 }];
const localIntervals = new Map<number, Array<[number, number]>>();
// Children occupy relative rows 1..children.length in the worst case.
const finder = new LowestFreeRow(children.length + 2);
const occupy = (row: number, span: FlamegraphSpan): void => {
const s = span.timestamp;
const e = span.timestamp + span.durationNano / 1e6;
shape.push({ span, relativeRow: row });
addIntervalTo(localIntervals, row, s, e);
finder.place(row, s);
};
for (const child of children) {
if (childrenMap.has(child.spanId)) {
// Non-leaf: place its whole subtree shape as a unit via findPlacement.
const childShape = computeSubtreeShape(child);
const offset = findPlacement(childShape, 1, localIntervals);
for (const entry of childShape) {
occupy(entry.relativeRow + offset, entry.span);
}
} else {
const end = child.timestamp + child.durationNano / 1e6;
occupy(finder.lowestFrom(1, end), child);
}
}
return shape;
}
function computeSubtreeShape(rootSpan: FlamegraphSpan): ShapeEntry[] {
const children = childrenMap.get(rootSpan.spanId);
if (children && children.length > WIDE_GROUP_THRESHOLD) {
return computeWideShape(rootSpan, children);
}
const localIntervals = new Map<number, Array<[number, number]>>();
const localConnectorPoints = new Map<number, number[]>();
const shape: ShapeEntry[] = [];
@@ -347,6 +225,7 @@ export function computeVisualLayout(spans: FlamegraphSpan[][]): VisualLayout {
shape.push({ span: rootSpan, relativeRow: 0 });
addIntervalTo(localIntervals, 0, rootStart, rootEnd);
const children = childrenMap.get(rootSpan.spanId);
if (children) {
for (const child of children) {
const childShape = computeSubtreeShape(child);

View File

@@ -94,7 +94,7 @@ export function useVisualLayoutWorker(spans: FlamegraphSpan[][]): {
cleanup();
};
// Timeout: if worker doesn't respond in 15s, terminate and error
// Timeout: if worker doesn't respond in 30s, terminate and error
const WORKER_TIMEOUT_MS = 15000;
const timeoutId = setTimeout(() => {
if (requestIdRef.current === currentId && isComputingRef.current) {

View File

@@ -3,7 +3,7 @@ import { Skeleton } from 'antd';
import { AxiosError } from 'axios';
import Spinner from 'components/Spinner';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { GetTraceV4SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
import { GetTraceV3SuccessResponse, SpanV3 } from 'types/api/trace/getTraceV3';
import { TraceWaterfallStates } from './constants';
import Error from './TraceWaterfallStates/Error/Error';
@@ -22,7 +22,7 @@ interface ITraceWaterfallProps {
localUncollapsedNodes: Set<string>;
setLocalUncollapsedNodes: Dispatch<SetStateAction<Set<string>>>;
traceData:
| SuccessResponse<GetTraceV4SuccessResponse, unknown>
| SuccessResponse<GetTraceV3SuccessResponse, unknown>
| ErrorResponse
| undefined;
isFetchingTraceData: boolean;

View File

@@ -1,34 +0,0 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { getAvailableColorByFieldNames } from '../utils';
const span = (partial: Partial<SpanV3>): SpanV3 =>
({ level: 1, resource: {}, attributes: {}, ...partial }) as SpanV3;
describe('getAvailableColorByFieldNames', () => {
it('returns [] for an empty span set', () => {
expect(getAvailableColorByFieldNames([])).toStrictEqual([]);
});
it('offers a field if any span carries it, in option order', () => {
const spans = [
span({ resource: { 'service.name': 'api' } }),
// k8s.node.name lives on a non-root span — still offered
span({ resource: { 'k8s.node.name': 'node-1' } }),
];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual([
'service.name',
'k8s.node.name',
]);
});
it('reads from attributes when the key is not on resource', () => {
const spans = [span({ attributes: { 'host.name': 'box-1' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['host.name']);
});
it('does not offer fields no span carries', () => {
const spans = [span({ resource: { 'service.name': 'api' } })];
expect(getAvailableColorByFieldNames(spans)).toStrictEqual(['service.name']);
});
});

View File

@@ -8,17 +8,23 @@ import { Collapse } from 'antd';
import { useDetailsPanel } from 'components/DetailsPanel';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { LOCALSTORAGE } from 'constants/localStorage';
import useGetTraceV4 from 'hooks/trace/useGetTraceV4';
import useGetTraceV3 from 'hooks/trace/useGetTraceV3';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import NoData from 'pages/TraceDetailV2/NoData/NoData';
import { ResizableBox } from 'periscope/components/ResizableBox';
import { SpanV3, TraceDetailV3URLProps } from 'types/api/trace/getTraceV3';
import {
SpanV3,
TraceDetailV3URLProps,
WaterfallAggregationRequest,
} from 'types/api/trace/getTraceV3';
import { COLOR_BY_FIELDS } from './constants';
import { TraceDetailEventKeys, TraceDetailEvents } from './events';
import { useTraceDetailLogEvent } from './hooks/useTraceDetailLogEvent';
import TraceStoreSync from './stores/TraceStoreSync';
import { useTraceStore } from './stores/traceStore';
import { AGGREGATIONS } from './utils/aggregations';
import { SpanDetailVariant } from './SpanDetailsPanel/constants';
import SpanDetailsPanel from './SpanDetailsPanel/SpanDetailsPanel';
import type { TraceMetadataForHeader } from './TraceDetailsHeader/TraceDetailsHeader';
@@ -28,7 +34,6 @@ import TraceFlamegraph from './TraceFlamegraph/TraceFlamegraph';
import TraceWaterfall from './TraceWaterfall/TraceWaterfall';
import { IInterestedSpan } from './TraceWaterfall/types';
import { getAncestorSpanIds } from './TraceWaterfall/utils';
import { getAvailableColorByFieldNames } from './utils';
import cx from 'classnames';
@@ -98,6 +103,17 @@ function TraceDetailsV3(): JSX.Element {
setInterestedSpanId({ spanId, isUncollapsed: true });
}, [urlQuery]);
// Hardcoded for now — fetch aggregations for all 3 candidate color-by fields
// upfront so a future color-by-field switch doesn't need to refetch.
const waterfallAggregationsRequest = useMemo<WaterfallAggregationRequest[]>(
() =>
COLOR_BY_FIELDS.flatMap((field) => [
{ field, aggregation: AGGREGATIONS.EXEC_TIME_PCT },
{ field, aggregation: AGGREGATIONS.SPAN_COUNT },
]),
[],
);
// Once all spans are loaded (frontend mode), freeze query params so
// subsequent interestedSpanId changes don't trigger unnecessary refetches.
const fullDataLoadedRef = useRef(false);
@@ -105,6 +121,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
});
const queryParams = fullDataLoadedRef.current
@@ -113,17 +130,19 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
const {
data: traceData,
isFetching: isFetchingTraceData,
error: errorFetchingTraceData,
} = useGetTraceV4({
} = useGetTraceV3({
traceId,
uncollapsedSpans: queryParams.uncollapsedSpans,
selectedSpanId: queryParams.selectedSpanId,
isSelectedSpanIDUnCollapsed: queryParams.isSelectedSpanIDUnCollapsed,
aggregations: queryParams.aggregations,
});
const allSpans = traceData?.payload?.spans || [];
@@ -131,13 +150,6 @@ function TraceDetailsV3(): JSX.Element {
const isFullDataLoaded =
totalSpansCount > 0 && totalSpansCount <= allSpans.length;
// Color-by options, gated on fields in loaded spans. Resource attrs are
// trace-wide, so any window has the full set — no need to accumulate.
const availableColorByFields = useMemo(() => {
const spans = traceData?.payload?.spans;
return spans?.length ? getAvailableColorByFieldNames(spans) : undefined;
}, [traceData?.payload?.spans]);
// Lock the ref once we confirm all data is loaded
if (isFullDataLoaded && !fullDataLoadedRef.current) {
fullDataLoadedRef.current = true;
@@ -145,6 +157,7 @@ function TraceDetailsV3(): JSX.Element {
selectedSpanId: interestedSpanId.spanId,
isSelectedSpanIDUnCollapsed: interestedSpanId.isUncollapsed,
uncollapsedSpans: uncollapsedNodes,
aggregations: waterfallAggregationsRequest,
};
}
@@ -369,7 +382,7 @@ function TraceDetailsV3(): JSX.Element {
);
return (
<TraceStoreSync availableColorByFields={availableColorByFields}>
<TraceStoreSync aggregations={traceData?.payload?.aggregations}>
<div className={styles.root}>
<TraceDetailsHeader
filterMetadata={filterMetadata}

View File

@@ -2,20 +2,21 @@ import { ReactNode, useEffect } from 'react';
import { useMutation } from 'react-query';
import updateUserPreferenceAPI from 'api/v1/user/preferences/name/update';
import { useAppContext } from 'providers/App/App';
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreAggregations,
setTraceStoreCallbacks,
setTraceStoreUserPreferences,
} from './traceStore';
interface TraceStoreSyncProps {
availableColorByFields: string[] | undefined;
aggregations: WaterfallAggregationResponse[] | undefined;
children: ReactNode;
}
/**
* Bridges React-managed inputs (`availableColorByFields`, `userPreferences`
* Bridges React-managed inputs (the `aggregations` prop, `userPreferences`
* from AppContext, and the user-pref mutation hook) into the Zustand store.
*
* Renders nothing until `userPreferences` resolves so the flamegraph never
@@ -24,15 +25,15 @@ interface TraceStoreSyncProps {
* is logged in, so this gate is usually already settled by mount time.
*/
function TraceStoreSync({
availableColorByFields,
aggregations,
children,
}: TraceStoreSyncProps): JSX.Element | null {
const { userPreferences, updateUserPreferenceInContext } = useAppContext();
const { mutate: mutateUserPreference } = useMutation(updateUserPreferenceAPI);
useEffect(() => {
setTraceStoreAvailableColorByFields(availableColorByFields);
}, [availableColorByFields]);
setTraceStoreAggregations(aggregations);
}, [aggregations]);
useEffect(() => {
setTraceStoreUserPreferences(userPreferences ?? null);

View File

@@ -1,71 +0,0 @@
import { USER_PREFERENCES } from 'constants/userPreferences';
import { UserPreference } from 'types/api/preferences/preference';
import { COLOR_BY_OPTIONS, DEFAULT_COLOR_BY_FIELD } from '../../constants';
import {
setTraceStoreAvailableColorByFields,
setTraceStoreUserPreferences,
useTraceStore,
} from '../traceStore';
const colorByPref = (fieldName: string): UserPreference[] => [
{
name: USER_PREFERENCES.SPAN_DETAILS_COLOR_BY_ATTRIBUTE,
value: fieldName,
} as UserPreference,
];
const optionNames = (): string[] =>
useTraceStore.getState().availableColorByOptions.map((o) => o.field.name);
describe('traceStore color-by gating', () => {
beforeEach(() => {
useTraceStore.setState({
availableColorByFieldNames: undefined,
userPreferences: null,
colorByField: DEFAULT_COLOR_BY_FIELD,
availableColorByOptions: COLOR_BY_OPTIONS.filter(
(o) => o.field.name === DEFAULT_COLOR_BY_FIELD.name,
),
});
});
it('offers only the default field before spans load', () => {
expect(optionNames()).toStrictEqual([DEFAULT_COLOR_BY_FIELD.name]);
expect(useTraceStore.getState().colorByField).toStrictEqual(
DEFAULT_COLOR_BY_FIELD,
);
});
it('offers the default plus any field present on loaded spans', () => {
setTraceStoreAvailableColorByFields(['host.name']);
expect(optionNames()).toStrictEqual([
DEFAULT_COLOR_BY_FIELD.name,
'host.name',
]);
});
it('honors the persisted color-by field when it is available', () => {
setTraceStoreAvailableColorByFields(['host.name']);
setTraceStoreUserPreferences(colorByPref('host.name'));
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
});
it('falls back to the default when the persisted field is not available', () => {
setTraceStoreUserPreferences(colorByPref('host.name'));
setTraceStoreAvailableColorByFields(['k8s.node.name']);
expect(useTraceStore.getState().colorByField.name).toBe(
DEFAULT_COLOR_BY_FIELD.name,
);
expect(optionNames()).toStrictEqual([
DEFAULT_COLOR_BY_FIELD.name,
'k8s.node.name',
]);
});
it('trusts the persisted field while spans are still loading', () => {
// availableColorByFieldNames stays undefined (loading) — do not flip to default
setTraceStoreUserPreferences(colorByPref('host.name'));
expect(useTraceStore.getState().colorByField.name).toBe('host.name');
});
});

View File

@@ -1,6 +1,7 @@
import { USER_PREFERENCES } from 'constants/userPreferences';
import { UserPreference } from 'types/api/preferences/preference';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { WaterfallAggregationResponse } from 'types/api/trace/getTraceV3';
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
import { create } from 'zustand';
@@ -10,6 +11,10 @@ import {
ColorByOption,
DEFAULT_COLOR_BY_FIELD,
} from '../constants';
import {
AGGREGATIONS,
getAggregationMap as findAggregationMap,
} from '../utils/aggregations';
import { toTelemetryFieldKey } from '../utils/previewFields';
interface MutateOptions {
@@ -25,9 +30,7 @@ type MutateUserPreference = (
interface TraceStoreState {
// --- Inputs synced from React layer via TraceStoreSync ---
// Fields present on loaded spans; gates color-by options. `undefined` while
// loading so we keep trusting the persisted field.
availableColorByFieldNames: string[] | undefined;
aggregations: WaterfallAggregationResponse[] | undefined;
userPreferences: UserPreference[] | null;
updateUserPreferenceInContext: UpdateUserPreferenceInContext | null;
mutateUserPreference: MutateUserPreference | null;
@@ -38,7 +41,9 @@ interface TraceStoreState {
previewFields: TelemetryFieldKey[];
// --- Setters used only by TraceStoreSync ---
setAvailableColorByFields: (fieldNames: string[] | undefined) => void;
setAggregations: (
aggregations: WaterfallAggregationResponse[] | undefined,
) => void;
setUserPreferences: (userPreferences: UserPreference[] | null) => void;
setCallbacks: (callbacks: {
updateUserPreferenceInContext: UpdateUserPreferenceInContext;
@@ -66,18 +71,23 @@ function getPersistedColorByField(
/**
* Re-derives `colorByField` + `availableColorByOptions` from the two inputs.
* Preserves the "trust persisted while spans load" rule so the flamegraph
* doesn't repaint when the waterfall response arrives.
* Preserves the "trust persisted while aggregations load" rule so the
* flamegraph doesn't repaint when the aggregations response arrives.
*/
function deriveColorState(
availableColorByFieldNames: string[] | undefined,
aggregations: WaterfallAggregationResponse[] | undefined,
userPreferences: UserPreference[] | null,
): Pick<TraceStoreState, 'colorByField' | 'availableColorByOptions'> {
const isFieldAvailable = (fieldName: string): boolean => {
if (fieldName === DEFAULT_COLOR_BY_FIELD.name) {
return true;
}
return !!availableColorByFieldNames?.includes(fieldName);
const map = findAggregationMap(
aggregations,
AGGREGATIONS.EXEC_TIME_PCT,
fieldName,
);
return !!map && Object.keys(map).length > 0;
};
const availableColorByOptions = COLOR_BY_OPTIONS.filter((opt) =>
@@ -85,10 +95,10 @@ function deriveColorState(
);
const persistedColorByField = getPersistedColorByField(userPreferences);
// While loading, trust persisted — don't flip to default prematurely.
// While aggregations are loading, trust persisted — don't flip to default
// just because we haven't confirmed availability yet.
const colorByField =
availableColorByFieldNames === undefined ||
isFieldAvailable(persistedColorByField.name)
aggregations === undefined || isFieldAvailable(persistedColorByField.name)
? persistedColorByField
: DEFAULT_COLOR_BY_FIELD;
@@ -124,7 +134,7 @@ function derivePreviewFields(
}
export const useTraceStore = create<TraceStoreState>()((set, get) => ({
availableColorByFieldNames: undefined,
aggregations: undefined,
userPreferences: null,
updateUserPreferenceInContext: null,
mutateUserPreference: null,
@@ -135,19 +145,19 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
),
previewFields: [],
setAvailableColorByFields: (availableColorByFieldNames): void => {
setAggregations: (aggregations): void => {
const { userPreferences } = get();
set({
availableColorByFieldNames,
...deriveColorState(availableColorByFieldNames, userPreferences),
aggregations,
...deriveColorState(aggregations, userPreferences),
});
},
setUserPreferences: (userPreferences): void => {
const { availableColorByFieldNames } = get();
const { aggregations } = get();
set({
userPreferences,
...deriveColorState(availableColorByFieldNames, userPreferences),
...deriveColorState(aggregations, userPreferences),
previewFields: derivePreviewFields(userPreferences),
});
},
@@ -225,9 +235,9 @@ export const useTraceStore = create<TraceStoreState>()((set, get) => ({
},
}));
export const setTraceStoreAvailableColorByFields = (
fieldNames: string[] | undefined,
): void => useTraceStore.getState().setAvailableColorByFields(fieldNames);
export const setTraceStoreAggregations = (
aggregations: WaterfallAggregationResponse[] | undefined,
): void => useTraceStore.getState().setAggregations(aggregations);
export const setTraceStoreUserPreferences = (
userPreferences: UserPreference[] | null,

View File

@@ -1,6 +1,5 @@
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { COLOR_BY_OPTIONS } from './constants';
import {
ColorPair,
generateColorPair,
@@ -110,14 +109,3 @@ export function resolveSpanColor(
}
return generateColorPair(getSpanGroupValue(span, colorByFieldName));
}
/**
* Color-by fields present on any of the given spans — replaces the old
* server-side aggregation gating. `service.name` is always offered by the store
* regardless of this list.
*/
export function getAvailableColorByFieldNames(spans: SpanV3[]): string[] {
return COLOR_BY_OPTIONS.filter((opt) =>
spans.some((s) => getSpanAttribute(s, opt.field.name)),
).map((opt) => opt.field.name);
}

View File

@@ -1,19 +1,19 @@
import {
SpantypesSpanAggregationResultDTO,
SpantypesSpanAggregationTypeDTO,
} from 'api/generated/services/sigNoz.schemas';
WaterfallAggregationResponse,
WaterfallAggregationType,
} from 'types/api/trace/getTraceV3';
export const AGGREGATIONS = {
EXEC_TIME_PCT: SpantypesSpanAggregationTypeDTO.execution_time_percentage,
SPAN_COUNT: SpantypesSpanAggregationTypeDTO.span_count,
DURATION: SpantypesSpanAggregationTypeDTO.duration,
} as const;
EXEC_TIME_PCT: 'execution_time_percentage',
SPAN_COUNT: 'span_count',
DURATION: 'duration',
} as const satisfies Record<string, WaterfallAggregationType>;
export function getAggregationMap(
aggregations: SpantypesSpanAggregationResultDTO[] | undefined,
type: SpantypesSpanAggregationTypeDTO,
aggregations: WaterfallAggregationResponse[] | undefined,
type: WaterfallAggregationType,
fieldName: string,
): Record<string, number> | null | undefined {
): Record<string, number> | undefined {
return aggregations?.find(
(a) => a.aggregation === type && a.field.name === fieldName,
)?.value;

View File

@@ -1,9 +1,26 @@
export interface GetTraceV4PayloadProps {
import { TelemetryFieldKey } from 'types/api/v5/queryRange';
export type WaterfallAggregationType =
| 'span_count'
| 'execution_time_percentage'
| 'duration';
export interface WaterfallAggregationRequest {
field: TelemetryFieldKey;
aggregation: WaterfallAggregationType;
}
export interface WaterfallAggregationResponse extends WaterfallAggregationRequest {
value: Record<string, number>;
}
export interface GetTraceV3PayloadProps {
traceId: string;
selectedSpanId: string;
uncollapsedSpans: string[];
isSelectedSpanIDUnCollapsed: boolean;
limit?: number; // Optional limit for number of spans to fetch, default can be set in API
aggregations?: WaterfallAggregationRequest[];
}
export interface TraceDetailV3URLProps {
@@ -71,7 +88,7 @@ export interface SpanV3 {
trace_state: string;
}
export interface GetTraceV4SuccessResponse {
export interface GetTraceV3SuccessResponse {
spans: SpanV3[];
hasMissingSpans: boolean;
uncollapsedSpans: string[];
@@ -81,4 +98,5 @@ export interface GetTraceV4SuccessResponse {
totalErrorSpansCount: number;
rootServiceName: string;
rootServiceEntryPoint: string;
aggregations?: WaterfallAggregationResponse[];
}

View File

@@ -14,6 +14,93 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
// Saved-view routes are registered before `/api/v2/dashboards/{id}` so the
// literal `views` segment isn't swallowed by the `{id}` pattern.
if err := router.Handle("/api/v2/dashboards/views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListViews), handler.OpenAPIDef{
ID: "ListDashboardViews",
Tags: []string{"dashboard"},
Summary: "List dashboard saved views",
Description: "Returns every saved view in the calling user's org. Saved views are shared org-wide; any user may read, create, edit, and delete any view.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardView),
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/v2/dashboards/views", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.CreateView), handler.OpenAPIDef{
ID: "CreateDashboardView",
Tags: []string{"dashboard"},
Summary: "Create dashboard saved view",
Description: "Persists the calling user's dashboard listing state (query, sort, order) as a named, reusable view shared across the org.",
Request: new(dashboardtypes.PostableDashboardView),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UpdateView), handler.OpenAPIDef{
ID: "UpdateDashboardView",
Tags: []string{"dashboard"},
Summary: "Update dashboard saved view",
Description: "Replaces a saved view's name and data. Saved views are shared org-wide; any user in the org may edit any view.",
Request: new(dashboardtypes.UpdateableDashboardView),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardView),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/views/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.DeleteView), handler.OpenAPIDef{
ID: "DeleteDashboardView",
Tags: []string{"dashboard"},
Summary: "Delete dashboard saved view",
Description: "Removes a saved view. Saved views are shared org-wide; any user in the org may delete any view. Idempotent — deleting a non-existent view returns 404.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.ListV2), handler.OpenAPIDef{
ID: "ListDashboardsV2",
Tags: []string{"dashboard"},
Summary: "List dashboards (v2)",
Description: "Returns a page of v2-shape dashboards for the calling user's org. Supports a filter DSL (`query`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).",
Request: new(dashboardtypes.ListDashboardsV2Params),
RequestContentType: "application/json",
Response: new(dashboardtypes.ListableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
@@ -89,6 +176,23 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.DeleteV2), handler.OpenAPIDef{
ID: "DeleteDashboardV2",
Tags: []string{"dashboard"},
Summary: "Delete dashboard (v2)",
Description: "This endpoint deletes a v2-shape dashboard along with its tag relations. Locked dashboards are rejected.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.LockV2), handler.OpenAPIDef{
ID: "LockDashboardV2",
Tags: []string{"dashboard"},
@@ -123,6 +227,42 @@ func (provider *provider) addDashboardRoutes(router *mux.Router) error {
return err
}
// ViewAccess: pinning only mutates the calling user's pin list, not the
// dashboard itself — anyone who can view a dashboard can bookmark it.
if err := router.Handle("/api/v2/dashboards/{id}/pins/me", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.PinV2), handler.OpenAPIDef{
ID: "PinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Pin a dashboard for the current user (v2)",
Description: "Pins the dashboard for the calling user. A user can pin at most 10 dashboards; pinning when at the limit returns 409. Re-pinning an already-pinned dashboard is a no-op success.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusNotFound, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/pins/me", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.UnpinV2), handler.OpenAPIDef{
ID: "UnpinDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unpin a dashboard for the current user (v2)",
Description: "Removes the pin for the calling user. Idempotent — unpinning a dashboard that wasn't pinned still returns 204.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{http.StatusBadRequest},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).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

@@ -61,11 +61,27 @@ type Module interface {
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updatable dashboardtypes.UpdatableDashboardV2) (*dashboardtypes.DashboardV2, error)
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error
UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error)
ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error)
UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdateableDashboardView) (*dashboardtypes.DashboardView, error)
DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
}
type Handler interface {
@@ -96,6 +112,8 @@ type Handler interface {
GetV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
@@ -103,4 +121,18 @@ type Handler interface {
UnlockV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
PinV2(http.ResponseWriter, *http.Request)
UnpinV2(http.ResponseWriter, *http.Request)
DeleteV2(http.ResponseWriter, *http.Request)
CreateView(http.ResponseWriter, *http.Request)
ListViews(http.ResponseWriter, *http.Request)
UpdateView(http.ResponseWriter, *http.Request)
DeleteView(http.ResponseWriter, *http.Request)
}

View File

@@ -2,10 +2,12 @@ package impldashboard
import (
"context"
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes/listfilter"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
@@ -63,6 +65,96 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
// ListV2 emits the joined dashboard ⨝ pinned_dashboard query the spec calls
// for. Aliases:
//
// dashboard — the visitor expects this
// pinned_dashboard AS pin — only used inside this query
//
// Sort is "is_pinned DESC, <sort> <order>" so pinned dashboards float to the
// top inside the requested ordering. Name-sort goes through the same
// JSONExtractString path the visitor uses for name/description filtering.
func (store *store) ListV2(
ctx context.Context,
orgID valuer.UUID,
userID valuer.UUID,
params *dashboardtypes.ListDashboardsV2Params,
) ([]*dashboardtypes.DashboardListRow, int64, error) {
compiled, err := listfilter.Compile(params.Query, store.sqlstore.Formatter())
if err != nil {
return nil, 0, err
}
type listedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
IsPinned bool `bun:"is_pinned"`
Total int64 `bun:"total"`
}
rows := make([]*listedRow, 0)
q := store.sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.name, dashboard.data, dashboard.locked, dashboard.source, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("CASE WHEN pin.user_id IS NOT NULL THEN 1 ELSE 0 END AS is_pinned").
ColumnExpr("COUNT(*) OVER () AS total").
Join("LEFT JOIN pinned_dashboard AS pin ON pin.user_id = ? AND pin.dashboard_id = dashboard.id", userID).
Where("dashboard.org_id = ?", orgID).
Where("dashboard.source != ?", dashboardtypes.SourceSystem)
if compiled != nil {
q = q.Where(compiled.SQL, compiled.Args...)
}
sortExpr, err := store.sortExprForListV2(params.Sort)
if err != nil {
return nil, 0, err
}
q = q.
OrderExpr("is_pinned DESC").
OrderExpr(sortExpr + " " + strings.ToUpper(params.Order.StringValue())).
Limit(params.Limit).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, 0, err
}
// COUNT(*) OVER () is computed pre-LIMIT, so any returned row carries the
// full filter total. Empty result page => zero matches.
var total int64
if len(rows) > 0 {
total = rows[0].Total
}
out := make([]*dashboardtypes.DashboardListRow, len(rows))
for i, r := range rows {
out[i] = &dashboardtypes.DashboardListRow{
Dashboard: r.StorableDashboard,
Pinned: r.IsPinned,
}
}
return out, total, nil
}
// sortExprForListV2 maps a sort enum to the SQL expression to plug into
// ORDER BY. Title-sort routes through the SQLFormatter so it stays
// dialect-aware (matches what listfilter/visitor does for the name filter).
func (store *store) sortExprForListV2(sort dashboardtypes.ListSort) (string, error) {
switch sort {
case dashboardtypes.ListSortUpdatedAt:
return "dashboard.updated_at", nil
case dashboardtypes.ListSortCreatedAt:
return "dashboard.created_at", nil
case dashboardtypes.ListSortName:
return string(store.sqlstore.Formatter().JSONExtractString("dashboard.data", "$.spec.display.name")), nil
}
return "", errors.Newf(errors.TypeInvalidInput, dashboardtypes.ErrCodeDashboardListInvalid,
"unsupported sort field %q", sort)
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.
@@ -217,3 +309,133 @@ func (store *store) RunInTx(ctx context.Context, cb func(ctx context.Context) er
return cb(ctx)
})
}
// PinForUser combines the count check, the existence check, and the upsert in
// a single statement so the limit gate and the insert can't drift between two
// round-trips.
//
// pin exists? | count < 10? | WHERE passes? | effect | rows
// ------------|-------------|-------------------------|-----------------------------------|-----
// no | yes | yes (count branch) | INSERT new row | 1
// no | no | no | nothing (limit hit) | 0
// yes | yes | yes (count branch) | INSERT → conflict → no-op UPDATE | 1
// yes | no | yes (EXISTS OR branch) | INSERT → conflict → no-op UPDATE | 1
//
// rows = 0 is the only signal of a real limit hit.
func (store *store) PinForUser(ctx context.Context, pd *dashboardtypes.PinnedDashboard) error {
res, err := store.sqlstore.BunDBCtx(ctx).NewRaw(`
INSERT INTO pinned_dashboard (user_id, dashboard_id, org_id, pinned_at)
SELECT ?, ?, ?, ?
WHERE (SELECT COUNT(*) FROM pinned_dashboard WHERE user_id = ?) < ?
OR EXISTS (SELECT 1 FROM pinned_dashboard WHERE user_id = ? AND dashboard_id = ?)
ON CONFLICT (user_id, dashboard_id) DO UPDATE SET user_id = EXCLUDED.user_id
`,
pd.UserID, pd.DashboardID, pd.OrgID, pd.PinnedAt,
pd.UserID, dashboardtypes.MaxPinnedDashboardsPerUser,
pd.UserID, pd.DashboardID,
).Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePinnedDashboardLimitHit,
"cannot pin more than %d dashboards", dashboardtypes.MaxPinnedDashboardsPerUser)
}
return nil
}
func (store *store) UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error {
_, err := store.sqlstore.BunDBCtx(ctx).
NewDelete().
Model((*dashboardtypes.PinnedDashboard)(nil)).
Where("user_id = ?", userID).
Where("dashboard_id = ?", dashboardID).
Exec(ctx)
return err
}
func (store *store) CreateDashboardView(ctx context.Context, view *dashboardtypes.DashboardView) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(view).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapAlreadyExistsErrf(err, errors.CodeAlreadyExists, "dashboard view with id %s already exists", view.ID)
}
return nil
}
func (store *store) GetDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardView, error) {
view := new(dashboardtypes.DashboardView)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(view).
Where("id = ?", id).
Where("org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardViewNotFound, "dashboard view with id %s doesn't exist", id)
}
return view, nil
}
func (store *store) ListDashboardViews(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.DashboardView, error) {
views := make([]*dashboardtypes.DashboardView, 0)
err := store.
sqlstore.
BunDB().
NewSelect().
Model(&views).
Where("org_id = ?", orgID).
OrderExpr("updated_at DESC").
Scan(ctx)
if err != nil {
return nil, err
}
return views, nil
}
func (store *store) UpdateDashboardView(ctx context.Context, view *dashboardtypes.DashboardView) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model(view).
WherePK().
Where("org_id = ?", view.OrgID).
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardViewNotFound, "dashboard view with id %s doesn't exist", view.ID)
}
return nil
}
func (store *store) DeleteDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := store.
sqlstore.
BunDBCtx(ctx).
NewDelete().
Model(new(dashboardtypes.DashboardView)).
Where("id = ?", id).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardViewNotFound, "dashboard view with id %s doesn't exist", id)
}
return nil
}

View File

@@ -42,6 +42,38 @@ func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusCreated, dashboard.ToGettableDashboardV2())
}
func (handler *handler) ListV2(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 := valuer.MustNewUUID(claims.OrgID)
userID := valuer.MustNewUUID(claims.IdentityID())
params := new(dashboardtypes.ListDashboardsV2Params)
if err := binding.Query.BindQuery(r.URL.Query(), params); err != nil {
render.Error(rw, err)
return
}
if err := params.Validate(); err != nil {
render.Error(rw, err)
return
}
out, err := handler.module.ListV2(ctx, orgID, userID, params)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (handler *handler) GetV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
@@ -205,3 +237,79 @@ func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
render.Success(rw, http.StatusOK, dashboard.ToGettableDashboardV2())
}
func (handler *handler) PinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, true)
}
func (handler *handler) UnpinV2(rw http.ResponseWriter, r *http.Request) {
handler.pinUnpinV2(rw, r, false)
}
func (handler *handler) pinUnpinV2(rw http.ResponseWriter, r *http.Request, pin bool) {
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 := valuer.MustNewUUID(claims.OrgID)
userID := valuer.MustNewUUID(claims.IdentityID())
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
}
if pin {
err = handler.module.PinV2(ctx, orgID, userID, dashboardID)
} else {
err = handler.module.UnpinV2(ctx, userID, dashboardID)
}
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) DeleteV2(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 := valuer.MustNewUUID(claims.OrgID)
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
}
if err := handler.module.DeleteV2(ctx, orgID, dashboardID); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -2,6 +2,7 @@ package impldashboard
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -42,6 +43,29 @@ func (m *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy stri
return dashboard, nil
}
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
rows, total, err := module.store.ListV2(ctx, orgID, userID, params)
if err != nil {
return nil, err
}
dashboardIDs := make([]valuer.UUID, len(rows))
for i, r := range rows {
dashboardIDs[i] = r.Dashboard.ID
}
tagsByDashboard, err := module.tagModule.ListForResources(ctx, orgID, coretypes.KindDashboard, dashboardIDs)
if err != nil {
return nil, err
}
allTags, err := module.tagModule.List(ctx, orgID, coretypes.KindDashboard)
if err != nil {
return nil, err
}
return dashboardtypes.NewListableDashboardV2(rows, total, tagsByDashboard, allTags)
}
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 {
@@ -135,6 +159,24 @@ func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.
return existing, nil
}
func (module *module) DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return err
}
if existing.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot delete a locked dashboard, please unlock the dashboard to delete")
}
return module.store.RunInTx(ctx, func(ctx context.Context) error {
// Syncing to an empty tag set drops every tag link for the dashboard.
if _, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, nil); err != nil {
return err
}
return module.store.Delete(ctx, orgID, id)
})
}
func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
@@ -149,3 +191,19 @@ func (module *module) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id va
}
return module.store.Update(ctx, orgID, storable)
}
func (module *module) PinV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, id valuer.UUID) error {
if _, err := module.GetV2(ctx, orgID, id); err != nil {
return err
}
return module.store.PinForUser(ctx, &dashboardtypes.PinnedDashboard{
UserID: userID,
DashboardID: id,
OrgID: orgID,
PinnedAt: time.Now(),
})
}
func (module *module) UnpinV2(ctx context.Context, userID valuer.UUID, id valuer.UUID) error {
return module.store.UnpinForUser(ctx, userID, id)
}

View File

@@ -0,0 +1,132 @@
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) CreateView(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 := valuer.MustNewUUID(claims.OrgID)
var req dashboardtypes.PostableDashboardView
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
view, err := handler.module.CreateView(ctx, orgID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, view)
}
func (handler *handler) ListViews(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 := valuer.MustNewUUID(claims.OrgID)
out, err := handler.module.ListViews(ctx, orgID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, out)
}
func (handler *handler) UpdateView(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 := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
viewID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
var req dashboardtypes.UpdateableDashboardView
if err := binding.JSON.BindBody(r.Body, &req); err != nil {
render.Error(rw, err)
return
}
view, err := handler.module.UpdateView(ctx, orgID, viewID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, view)
}
func (handler *handler) DeleteView(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 := valuer.MustNewUUID(claims.OrgID)
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
viewID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
if err := handler.module.DeleteView(ctx, orgID, viewID); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}

View File

@@ -0,0 +1,46 @@
package impldashboard
import (
"context"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (module *module) CreateView(ctx context.Context, orgID valuer.UUID, postable dashboardtypes.PostableDashboardView) (*dashboardtypes.DashboardView, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
view := postable.NewDashboardView(orgID)
if err := module.store.CreateDashboardView(ctx, view); err != nil {
return nil, err
}
return view, nil
}
func (module *module) ListViews(ctx context.Context, orgID valuer.UUID) (*dashboardtypes.ListableDashboardView, error) {
views, err := module.store.ListDashboardViews(ctx, orgID)
if err != nil {
return nil, err
}
return &dashboardtypes.ListableDashboardView{Views: views}, nil
}
func (module *module) UpdateView(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updateable dashboardtypes.UpdateableDashboardView) (*dashboardtypes.DashboardView, error) {
if err := updateable.Validate(); err != nil {
return nil, err
}
view, err := module.store.GetDashboardView(ctx, orgID, id)
if err != nil {
return nil, err
}
view.Update(updateable)
if err := module.store.UpdateDashboardView(ctx, view); err != nil {
return nil, err
}
return view, nil
}
func (module *module) DeleteView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
return module.store.DeleteDashboardView(ctx, orgID, id)
}

View File

@@ -67,6 +67,10 @@ func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, ki
})
}
func (m *module) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error) {
return m.store.List(ctx, orgID, kind)
}
func (m *module) ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
return m.store.ListByResource(ctx, orgID, kind, resourceID)
}

View File

@@ -13,6 +13,9 @@ type Module interface {
// and reconciles the resource's links to exactly that set, all in one transaction.
SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error)
// List returns every tag of the given kind in the org.
List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error)
ListForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error)
// Resources with no tags are absent from the returned map.

View File

@@ -0,0 +1,33 @@
package filterquery
import (
"fmt"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/antlr4-go/antlr/v4"
)
func Parse(query string) (antlr.ParseTree, *antlr.CommonTokenStream, *ErrorCollector) {
collector := NewErrorCollector()
lexer := grammar.NewFilterQueryLexer(antlr.NewInputStream(query))
lexer.RemoveErrorListeners()
lexer.AddErrorListener(collector)
tokens := antlr.NewCommonTokenStream(lexer, 0)
parser := grammar.NewFilterQueryParser(tokens)
parser.RemoveErrorListeners()
parser.AddErrorListener(collector)
return parser.Query(), tokens, collector
}
type ErrorCollector struct {
*antlr.DefaultErrorListener
Errors []string
}
func NewErrorCollector() *ErrorCollector {
return &ErrorCollector{}
}
func (c *ErrorCollector) SyntaxError(_ antlr.Recognizer, _ any, line, column int, msg string, _ antlr.RecognitionException) {
c.Errors = append(c.Errors, fmt.Sprintf("syntax error at %d:%d — %s", line, column, msg))
}

View File

@@ -86,12 +86,11 @@ func New(
func (q *querier) QueryRange(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest) (*qbtypes.QueryRangeResponse, error) {
// Normalize Start/End to ms. UnmarshalJSON covers HTTP requests; callers
// that build the request programmatically skip it, so this is the catch-all
// (idempotent for the already-normalized path).
if err := req.Normalize(); err != nil {
return nil, err
}
// Coerce the window to epoch milliseconds up front so every downstream
// consumer (TimeRange, narrowWindowByTraceID, step interval, etc.) can
// safely assume ms regardless of the resolution the caller sent.
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
tmplVars := req.Variables
if tmplVars == nil {
@@ -428,12 +427,10 @@ func (q *querier) resolveMetricMetadata(ctx context.Context, queries []qbtypes.Q
func (q *querier) QueryRawStream(ctx context.Context, orgID valuer.UUID, req *qbtypes.QueryRangeRequest, client *qbtypes.RawStream) {
// Catch-all normalization for programmatic callers (see QueryRange). End is
// 0 here for the open-ended stream, which Normalize leaves untouched.
if err := req.Normalize(); err != nil {
client.Error <- err
return
}
// Coerce the window to epoch milliseconds up front (End may be 0 for the
// open-ended stream, which ToMilliSecs leaves untouched).
req.Start = querybuilder.ToMilliSecs(req.Start)
req.End = querybuilder.ToMilliSecs(req.End)
event := &qbtypes.QBEvent{
Version: "v5",

View File

@@ -33,6 +33,28 @@ func ToNanoSecs(epoch uint64) uint64 {
return temp * uint64(math.Pow(10, float64(19-count)))
}
// ToMilliSecs takes an epoch whose resolution is inferred from its magnitude
// (s/ms/µs/ns) and returns it in milliseconds. A millisecond epoch for the
// current era has 13 digits (e.g. ~1.7e12 in 2026), so the value is scaled so
// its digit-width matches: smaller values (seconds) are scaled up, larger ones
// (micro/nanoseconds) are scaled down. Zero is returned unchanged.
func ToMilliSecs(epoch uint64) uint64 {
if epoch == 0 {
return 0
}
temp := epoch
count := 0
for epoch != 0 {
epoch /= 10
count++
}
const msDigits = 13
if count < msDigits {
return temp * uint64(math.Pow(10, float64(msDigits-count)))
}
return temp / uint64(math.Pow(10, float64(count-msDigits)))
}
// TODO(srikanthccv): should these be rounded to nearest multiple of 60 instead of 5 if step > 60?
// That would make graph look nice but "nice" but should be less important than the usefulness.
func RecommendedStepInterval(start, end uint64) uint64 {

View File

@@ -60,3 +60,51 @@ func TestToNanoSecs(t *testing.T) {
})
}
}
func TestToMilliSecs(t *testing.T) {
tests := []struct {
name string
epoch uint64
expected uint64
}{
{
name: "10-digit Unix timestamp (seconds) - 2023-01-01 00:00:00 UTC",
epoch: 1672531200, // seconds
expected: 1672531200000, // * 10^3
},
{
name: "13-digit Unix timestamp (milliseconds) - already ms",
epoch: 1672531200000,
expected: 1672531200000, // unchanged
},
{
name: "16-digit Unix timestamp (microseconds)",
epoch: 1672531200000000, // microseconds
expected: 1672531200000, // / 10^3
},
{
name: "19-digit Unix timestamp (nanoseconds)",
epoch: 1672531200000000000, // nanoseconds
expected: 1672531200000, // / 10^6
},
{
name: "Unix epoch start - zero is unchanged",
epoch: 0,
expected: 0,
},
{
name: "Recent timestamp in seconds - 2024-05-25 12:00:00 UTC",
epoch: 1716638400,
expected: 1716638400000,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := ToMilliSecs(tt.epoch)
if result != tt.expected {
t.Errorf("ToMilliSecs(%d) = %d, want %d", tt.epoch, result, tt.expected)
}
})
}
}

View File

@@ -211,6 +211,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddDashboardNameFactory(sqlstore, sqlschema),
sqlmigration.NewFixChangelogOperationTypeFactory(sqlstore, sqlschema),
sqlmigration.NewCloudIntegrationRemoveCascadeDeleteFactory(sqlschema),
sqlmigration.NewAddPinnedDashboardFactory(sqlstore, sqlschema),
sqlmigration.NewAddDashboardViewFactory(sqlstore, sqlschema),
)
}

View File

@@ -0,0 +1,67 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addPinnedDashboard struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddPinnedDashboardFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_pinned_dashboard"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addPinnedDashboard{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addPinnedDashboard) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addPinnedDashboard) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "pinned_dashboard",
Columns: []*sqlschema.Column{
{Name: "user_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "dashboard_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "pinned_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false, Default: "current_timestamp"},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"user_id", "dashboard_id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addPinnedDashboard) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,69 @@
package sqlmigration
import (
"context"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun"
"github.com/uptrace/bun/migrate"
)
type addDashboardView struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddDashboardViewFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_dashboard_view"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addDashboardView{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addDashboardView) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addDashboardView) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() { _ = tx.Rollback() }()
sqls := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "dashboard_view",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "name", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "data", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "created_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
{Name: "updated_at", DataType: sqlschema.DataTypeTimestamp, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("org_id"),
ReferencedTableName: sqlschema.TableName("organizations"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addDashboardView) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -0,0 +1,146 @@
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/valuer"
"github.com/uptrace/bun"
)
const (
DashboardViewSchemaVersion = "v1"
MaxDashboardViewNameLen = 32
)
var (
ErrCodeDashboardViewInvalidInput = errors.MustNewCode("dashboard_view_invalid_input")
ErrCodeDashboardViewNotFound = errors.MustNewCode("dashboard_view_not_found")
)
type DashboardViewData struct {
Version string `json:"version" required:"true"`
Query string `json:"query"`
Sort ListSort `json:"sort"`
Order ListOrder `json:"order"`
}
func (d *DashboardViewData) Validate() error {
if d.Version != DashboardViewSchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
"version must be %q, got %q", DashboardViewSchemaVersion, d.Version)
}
if !d.Sort.IsZero() {
s := d.Sort
switch s {
case ListSortUpdatedAt, ListSortCreatedAt, ListSortName:
default:
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
"invalid sort %q — expected one of: `updated_at`, `created_at`, `name`", d.Sort)
}
d.Sort = s
}
if !d.Order.IsZero() {
o := d.Order
switch o {
case ListOrderAsc, ListOrderDesc:
default:
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
"invalid order %q — expected `asc` or `desc`", d.Order)
}
d.Order = o
}
return nil
}
type DashboardView struct {
bun.BaseModel `bun:"table:dashboard_view,alias:dashboard_view"`
types.Identifiable
types.TimeAuditable
Name string `bun:"name,type:text,notnull" json:"name" required:"true"`
Data DashboardViewData `bun:"data,type:jsonb,notnull" json:"data" required:"true"`
OrgID valuer.UUID `bun:"org_id,type:text,notnull" json:"orgId" required:"true"`
}
// ════════════════════════════════════════════════════════════════════════
// Postable
// ════════════════════════════════════════════════════════════════════════
type PostableDashboardView struct {
Name string `json:"name" required:"true"`
Data DashboardViewData `json:"data" required:"true"`
}
func (p *PostableDashboardView) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableDashboardView
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardViewInvalidInput, "invalid saved view request body").WithAdditional(err.Error())
}
*p = PostableDashboardView(tmp)
return p.Validate()
}
func (p *PostableDashboardView) Validate() error {
if err := validateDashboardViewName(p.Name); err != nil {
return err
}
return p.Data.Validate()
}
func (p PostableDashboardView) NewDashboardView(orgID valuer.UUID) *DashboardView {
now := time.Now()
return &DashboardView{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
OrgID: orgID,
Name: p.Name,
Data: p.Data,
}
}
// ════════════════════════════════════════════════════════════════════════
// Updateable
// ════════════════════════════════════════════════════════════════════════
type UpdateableDashboardView = PostableDashboardView
func (v *DashboardView) Update(updateable UpdateableDashboardView) {
v.Name = updateable.Name
v.Data = updateable.Data
v.UpdatedAt = time.Now()
}
// ════════════════════════════════════════════════════════════════════════
// Gettable
// ════════════════════════════════════════════════════════════════════════
type GettableDashboardView = DashboardView
type ListableDashboardView struct {
Views []*GettableDashboardView `json:"views" required:"true" nullable:"false"`
}
// ════════════════════════════════════════════════════════════════════════
// Helpers
// ════════════════════════════════════════════════════════════════════════
func validateDashboardViewName(name string) error {
trimmed := strings.TrimSpace(name)
if trimmed == "" {
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput, "name is required")
}
if len(trimmed) > MaxDashboardViewNameLen {
return errors.NewInvalidInputf(ErrCodeDashboardViewInvalidInput,
"name must be at most %d characters, got %d", MaxDashboardViewNameLen, len(trimmed))
}
return nil
}

View File

@@ -0,0 +1,159 @@
package dashboardtypes
import (
"slices"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/perses/perses/pkg/model/api/v1/common"
)
const (
DefaultListLimit = 20
MaxListLimit = 200
)
// ListSort is the sort field for the dashboard list endpoint. The value is a
// stable enum so callers can't ask for arbitrary columns.
type ListSort struct{ valuer.String }
var (
ListSortUpdatedAt = ListSort{valuer.NewString("updated_at")}
ListSortCreatedAt = ListSort{valuer.NewString("created_at")}
ListSortName = ListSort{valuer.NewString("name")}
)
func (ListSort) Enum() []any {
return []any{ListSortUpdatedAt, ListSortCreatedAt, ListSortName}
}
func (s ListSort) IsValid() bool {
return slices.ContainsFunc(s.Enum(), func(v any) bool { return v == s })
}
type ListOrder struct{ valuer.String }
var (
ListOrderAsc = ListOrder{valuer.NewString("asc")}
ListOrderDesc = ListOrder{valuer.NewString("desc")}
)
func (ListOrder) Enum() []any {
return []any{ListOrderAsc, ListOrderDesc}
}
func (o ListOrder) IsValid() bool {
return slices.ContainsFunc(o.Enum(), func(v any) bool { return v == o })
}
var ErrCodeDashboardListInvalid = errors.MustNewCode("dashboard_list_invalid")
type ListDashboardsV2Params struct {
Query string `query:"query"`
Sort ListSort `query:"sort"`
Order ListOrder `query:"order"`
Limit int `query:"limit"`
Offset int `query:"offset"`
}
// Validate fills in defaults (sort=updated_at, order=desc, limit=20) and
// rejects out-of-allowlist sort/order values and bad limit/offset. Limit is
// clamped to MaxListLimit on the high side. Sort/order are case-insensitive —
// valuer.String lowercases them at bind time.
func (p *ListDashboardsV2Params) Validate() error {
if p.Sort.IsZero() {
p.Sort = ListSortUpdatedAt
} else if !p.Sort.IsValid() {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid sort %q — expected one of: `updated_at`, `created_at`, `name`", p.Sort)
}
if p.Order.IsZero() {
p.Order = ListOrderDesc
} else if !p.Order.IsValid() {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid order %q — expected `asc` or `desc`", p.Order)
}
if p.Limit == 0 {
p.Limit = DefaultListLimit
} else if p.Limit < 0 {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid limit %d — must be a positive integer", p.Limit)
} else if p.Limit > MaxListLimit {
p.Limit = MaxListLimit
}
if p.Offset < 0 {
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid offset %d — must be a non-negative integer", p.Offset)
}
return nil
}
type listedDashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId" required:"true"`
Locked bool `json:"locked" required:"true"`
Source Source `json:"source" required:"true"`
SchemaVersion string `json:"schemaVersion" required:"true"`
Name string `json:"name" required:"true"`
Pinned bool `json:"pinned" required:"true"`
Image string `json:"image,omitempty"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
Spec listedDashboardV2Spec `json:"spec" required:"true"`
}
type listedDashboardV2Spec struct {
Display *common.Display `json:"display,omitempty"`
}
type ListableDashboardV2 struct {
Dashboards []*listedDashboardV2 `json:"dashboards" required:"true" nullable:"false"`
Total int64 `json:"total" required:"true"`
Tags []*tagtypes.GettableTag `json:"tags" required:"true" nullable:"false"`
}
// DashboardListRow is the per-row shape Store.ListV2 returns. Bundles the
// joined dashboard / pinned_dashboard data so the module layer can attach
// tags and assemble the gettable view.
type DashboardListRow struct {
Dashboard *StorableDashboard
Pinned bool
}
func NewListableDashboardV2(rows []*DashboardListRow, total int64, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, allTags []*tagtypes.Tag) (*ListableDashboardV2, error) {
dashboards := make([]*listedDashboardV2, len(rows))
for i, r := range rows {
v2, err := r.Dashboard.ToDashboardV2(tagsByEntity[r.Dashboard.ID])
if err != nil {
return nil, err
}
dashboards[i] = &listedDashboardV2{
Identifiable: v2.Identifiable,
TimeAuditable: v2.TimeAuditable,
UserAuditable: v2.UserAuditable,
OrgID: v2.OrgID,
Locked: v2.Locked,
Source: v2.Source,
SchemaVersion: v2.SchemaVersion,
Name: v2.Name,
Pinned: r.Pinned,
Image: v2.Image,
Tags: tagtypes.NewGettableTagsFromTags(v2.Tags),
Spec: listedDashboardV2Spec{Display: v2.Spec.Display},
}
}
return &ListableDashboardV2{
Dashboards: dashboards,
Total: total,
Tags: tagtypes.NewGettableTagsFromTags(allTags),
}, nil
}

View File

@@ -0,0 +1,61 @@
package listfilter
import (
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
var ErrCodeDashboardListFilterInvalid = errors.MustNewCode("dashboard_list_filter_invalid")
// reservedOps lists the operators each reserved (column-level) DSL key accepts.
// Any non-reserved key is treated as a tag key and uses tagKeyOps.
var reservedOps = map[dashboardtypes.DSLKey]map[qbtypesv5.FilterOperator]struct{}{
dashboardtypes.DSLKeyName: stringSearchOps(),
dashboardtypes.DSLKeyDescription: stringSearchOps(),
dashboardtypes.DSLKeyCreatedAt: numericRangeOps(),
dashboardtypes.DSLKeyUpdatedAt: numericRangeOps(),
dashboardtypes.DSLKeyCreatedBy: stringSearchOps(),
dashboardtypes.DSLKeyLocked: opsSet(qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual),
dashboardtypes.DSLKeyPublic: opsSet(qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual),
}
// tagKeyOps applies to every non-reserved DSL key — the operator targets the
// tag's value with an implicit case-insensitive match on the tag's key.
var tagKeyOps = opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
qbtypesv5.FilterOperatorExists, qbtypesv5.FilterOperatorNotExists,
)
func stringSearchOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike,
qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike,
qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains,
qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp,
qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn,
)
}
func numericRangeOps() map[qbtypesv5.FilterOperator]struct{} {
return opsSet(
qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq,
qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween,
)
}
func opsSet(ops ...qbtypesv5.FilterOperator) map[qbtypesv5.FilterOperator]struct{} {
m := make(map[qbtypesv5.FilterOperator]struct{}, len(ops))
for _, op := range ops {
m[op] = struct{}{}
}
return m
}

View File

@@ -0,0 +1,39 @@
package listfilter
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
)
type Compiled struct {
SQL string
Args []any
}
func Compile(query string, formatter sqlstore.SQLFormatter) (*Compiled, error) {
if len(query) == 0 {
return nil, nil //nolint:nilnil
}
queryVisitor := newVisitor(formatter)
frag, syntaxErrs := queryVisitor.compile(query)
if len(syntaxErrs) > 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(syntaxErrs, "; "))
}
if len(queryVisitor.errors) > 0 {
return nil, errors.NewInvalidInputf(ErrCodeDashboardListFilterInvalid,
"invalid filter query: %s", strings.Join(queryVisitor.errors, "; "))
}
if frag == nil || frag.sql == "" {
return nil, nil //nolint:nilnil
}
return &Compiled{
SQL: frag.sql,
Args: frag.args,
}, nil
}

View File

@@ -0,0 +1,507 @@
package listfilter
import (
"strings"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstoretest"
)
type compileCase struct {
subtestName string
dslQueryToCompile string
nilExpected bool
expectedSQL string
expectedArgs []any
expectedErrShouldContain string
}
func runCompileCases(t *testing.T, cases []compileCase) {
t.Helper()
for _, c := range cases {
t.Run(c.subtestName, func(t *testing.T) {
out, err := Compile(c.dslQueryToCompile, formatter(t))
if c.expectedErrShouldContain != "" {
require.Error(t, err)
assert.Contains(t, strings.ToLower(err.Error()), strings.ToLower(c.expectedErrShouldContain))
return
}
require.NoError(t, err)
if c.nilExpected {
assert.Nil(t, out)
return
}
require.NotNil(t, out)
if c.expectedSQL != "" {
assert.Equal(t, normalizeSQL(c.expectedSQL), normalizeSQL(out.SQL))
}
if c.expectedArgs != nil {
require.Len(t, out.Args, len(c.expectedArgs))
for i, want := range c.expectedArgs {
// time.Time values can carry semantically-equal instants
// in different *Location representations (UTC vs Local vs
// FixedZone). Compare via .Equal() instead of DeepEqual.
if wantT, ok := want.(time.Time); ok {
gotT, ok := out.Args[i].(time.Time)
require.True(t, ok, "arg[%d]: want time.Time, got %T", i, out.Args[i])
assert.True(t, wantT.Equal(gotT), "arg[%d]: want %s, got %s", i, wantT, gotT)
continue
}
assert.Equal(t, want, out.Args[i], "arg[%d]", i)
}
}
})
}
}
func TestCompile_Empty(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "empty query yields nil", dslQueryToCompile: "", nilExpected: true},
})
}
func TestCompile_Name(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name =",
dslQueryToCompile: `name = 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
expectedArgs: []any{"overview"},
},
{
// QUOTED_TEXT in the grammar covers both '…' and "…" — visitor
// strips whichever quote pair surrounds the value.
subtestName: "name = with double-quoted value",
dslQueryToCompile: `name = "something"`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') = ?`,
expectedArgs: []any{"something"},
},
{
subtestName: "name CONTAINS",
dslQueryToCompile: `name CONTAINS 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ?`,
expectedArgs: []any{"%overview%"},
},
{
subtestName: "name ILIKE — emitted as LOWER(col) LIKE LOWER(?) for dialect parity",
dslQueryToCompile: `name ILIKE 'Prod%'`,
expectedSQL: `lower(json_extract("dashboard"."data", '$.spec.display.name')) LIKE LOWER(?)`,
expectedArgs: []any{"Prod%"},
},
{
subtestName: "CONTAINS escapes % in user input",
dslQueryToCompile: `name CONTAINS '50%'`,
expectedSQL: `json_extract("dashboard"."data", '$.spec.display.name') LIKE ?`,
expectedArgs: []any{`%50\%%`},
},
})
}
func TestCompile_CreatedByLocked(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "created_by LIKE",
dslQueryToCompile: `created_by LIKE '%@signoz.io'`,
expectedSQL: `dashboard.created_by LIKE ?`,
expectedArgs: []any{"%@signoz.io"},
},
{
subtestName: "locked = true",
dslQueryToCompile: `locked = true`,
expectedSQL: `dashboard.locked = ?`,
expectedArgs: []any{true},
},
})
}
func TestCompile_Public(t *testing.T) {
runCompileCases(t, []compileCase{
{subtestName: "public = true", dslQueryToCompile: `public = true`, expectedSQL: `pd.id IS NOT NULL`},
{subtestName: "public = false", dslQueryToCompile: `public = false`, expectedSQL: `pd.id IS NULL`},
{subtestName: "public != true", dslQueryToCompile: `public != true`, expectedSQL: `pd.id IS NULL`},
})
}
func TestCompile_Timestamps(t *testing.T) {
ist := time.FixedZone("+05:30", 5*60*60+30*60)
runCompileCases(t, []compileCase{
{
subtestName: "created_at >= RFC3339",
dslQueryToCompile: `created_at >= '2026-03-10T00:00:00Z'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC)},
},
{
subtestName: "updated_at BETWEEN",
dslQueryToCompile: `updated_at BETWEEN '2026-03-10T00:00:00Z' AND '2026-03-20T00:00:00Z'`,
expectedSQL: `dashboard.updated_at BETWEEN ? AND ?`,
expectedArgs: []any{
time.Date(2026, 3, 10, 0, 0, 0, 0, time.UTC),
time.Date(2026, 3, 20, 0, 0, 0, 0, time.UTC),
},
},
{
subtestName: "created_at >= IST timestamp",
dslQueryToCompile: `created_at >= '2026-03-10T05:30:00+05:30'`,
expectedSQL: `dashboard.created_at >= ?`,
expectedArgs: []any{time.Date(2026, 3, 10, 5, 30, 0, 0, ist)},
},
})
}
// Tag operators wrap each predicate in EXISTS / NOT EXISTS. Any non-reserved
// key is a tag key — `team = 'pulse'` matches a tag with key=team value=pulse,
// `tag = 'prod'` matches a tag with key=tag value=prod, and so on.
func TestCompile_Tag(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "team = wraps in EXISTS",
dslQueryToCompile: `team = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"team", "pulse"},
},
{
subtestName: "tag = is just a regular tag-key filter",
dslQueryToCompile: `tag = 'database'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"tag", "database"},
},
{
subtestName: "team != wraps in NOT EXISTS with positive inner",
dslQueryToCompile: `team != 'pulse'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"team", "pulse"},
},
{
subtestName: "team IN — inner is single placeholder list on t.value",
dslQueryToCompile: `team IN ['pulse', 'events']`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{"team", "pulse", "events"},
},
{
subtestName: "team NOT IN",
dslQueryToCompile: `team NOT IN ['pulse', 'events']`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)`,
expectedArgs: []any{"team", "pulse", "events"},
},
{
subtestName: "team LIKE — wildcard on value",
dslQueryToCompile: `team LIKE 'pulse%'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ?
)`,
expectedArgs: []any{"team", "pulse%"},
},
{
subtestName: "team NOT LIKE",
dslQueryToCompile: `team NOT LIKE 'staging%'`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ?
)`,
expectedArgs: []any{"team", "staging%"},
},
{
subtestName: "database EXISTS — asserts a tag with key=database is present",
dslQueryToCompile: `database EXISTS`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{"database"},
},
{
subtestName: "database NOT EXISTS",
dslQueryToCompile: `database NOT EXISTS`,
expectedSQL: `
NOT EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{"database"},
},
{
subtestName: "tag-key matching is case-insensitive — TEAM lowercased",
dslQueryToCompile: `TEAM = 'pulse'`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"team", "pulse"},
},
})
}
func TestCompile_BooleanComposition(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "AND chain — flat arg list",
dslQueryToCompile: `locked = true AND public = true`,
expectedSQL: `dashboard.locked = ? AND pd.id IS NOT NULL`,
expectedArgs: []any{true},
},
{
subtestName: "OR chain",
dslQueryToCompile: `locked = true OR public = true`,
expectedSQL: `dashboard.locked = ? OR pd.id IS NOT NULL`,
expectedArgs: []any{true},
},
{
subtestName: "parens preserve precedence",
dslQueryToCompile: `(locked = true OR public = true) AND created_by = 'a@b.com'`,
expectedSQL: `(dashboard.locked = ? OR pd.id IS NOT NULL) AND dashboard.created_by = ?`,
expectedArgs: []any{true, "a@b.com"},
},
})
}
// Distinct from operator-suffix negation (NOT IN / NOT LIKE / NOT EXISTS).
// Driven by the unaryExpression rule (`NOT? primary`), so NOT binds to
// exactly one primary and only widens via parens.
func TestCompile_NOT(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "NOT on a single comparison",
dslQueryToCompile: `NOT name = 'foo'`,
expectedSQL: `NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT binds tightly to its primary in an AND chain",
dslQueryToCompile: `NOT name = 'foo' AND created_by = 'alice'`,
expectedSQL: `NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?) AND dashboard.created_by = ?`,
expectedArgs: []any{"foo", "alice"},
},
{
subtestName: "NOT applied to the second term in an AND chain",
dslQueryToCompile: `locked = true AND NOT name = 'foo'`,
expectedSQL: `dashboard.locked = ? AND NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)`,
expectedArgs: []any{true, "foo"},
},
{
subtestName: "NOT around a parenthesized OR",
dslQueryToCompile: `NOT (locked = true OR public = true)`,
expectedSQL: `NOT ((dashboard.locked = ? OR pd.id IS NOT NULL))`,
expectedArgs: []any{true},
},
{
subtestName: "double NOT via parens",
dslQueryToCompile: `NOT (NOT name = 'foo')`,
expectedSQL: `NOT ((NOT (json_extract("dashboard"."data", '$.spec.display.name') = ?)))`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT on a tag equality",
dslQueryToCompile: `NOT team = 'pulse'`,
expectedSQL: `
NOT (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)`,
expectedArgs: []any{"team", "pulse"},
},
{
subtestName: "NOT team = ... AND name = ...",
dslQueryToCompile: `NOT team = 'pulse' AND name = 'overview'`,
expectedSQL: `
NOT (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)
AND json_extract("dashboard"."data", '$.spec.display.name') = ?`,
expectedArgs: []any{"team", "pulse", "overview"},
},
})
}
func TestCompile_ComplexExamples(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "name CONTAINS + tag LIKE + created_by + database =",
dslQueryToCompile: `name CONTAINS 'overview' AND tag LIKE 'prod%' AND created_by = 'naman.verma@signoz.io' AND database = 'mongo'`,
expectedSQL: `
json_extract("dashboard"."data", '$.spec.display.name') LIKE ?
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ?
)
AND dashboard.created_by = ?
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)`,
expectedArgs: []any{"%overview%", "tag", "prod%", "naman.verma@signoz.io", "database", "mongo"},
},
{
subtestName: "team IN AND database EXISTS",
dslQueryToCompile: `team IN ['pulse', 'events'] AND database EXISTS`,
expectedSQL: `
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)
AND EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
)`,
expectedArgs: []any{"team", "pulse", "events", "database"},
},
{
subtestName: "nested OR / AND with parens",
dslQueryToCompile: `(database IN ['sql', 'redis', 'mongo'] OR name LIKE '%database%') AND (team = 'pulse' OR name LIKE '%pulse%')`,
expectedSQL: `
(
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?, ?)
)
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ?
)
AND (
EXISTS (
SELECT 1 FROM tag_relation tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
OR json_extract("dashboard"."data", '$.spec.display.name') LIKE ?
)`,
expectedArgs: []any{"database", "sql", "redis", "mongo", "%database%", "team", "pulse", "%pulse%"},
},
})
}
func TestCompile_Rejections(t *testing.T) {
runCompileCases(t, []compileCase{
{
subtestName: "rejects op outside per-reserved-key allowlist",
dslQueryToCompile: `name BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects BETWEEN on a tag key",
dslQueryToCompile: `team BETWEEN 'a' AND 'z'`,
expectedErrShouldContain: "operator",
},
{
subtestName: "rejects non-bool on locked",
dslQueryToCompile: `locked = 'yes'`,
expectedErrShouldContain: "boolean",
},
{
subtestName: "rejects non-RFC3339 timestamp",
dslQueryToCompile: `created_at >= 'not-a-date'`,
expectedErrShouldContain: "RFC3339",
},
{
subtestName: "rejects REGEXP — not yet supported",
dslQueryToCompile: `name REGEXP '.*'`,
expectedErrShouldContain: "REGEXP",
},
{
subtestName: "rejects syntax error from grammar",
dslQueryToCompile: `name = `,
expectedErrShouldContain: "syntax",
},
})
}
func formatter(t *testing.T) sqlstore.SQLFormatter {
t.Helper()
p := sqlstoretest.New(sqlstore.Config{Provider: "sqlite"}, sqlmock.QueryMatcherEqual)
return p.Formatter()
}
func normalizeSQL(s string) string {
s = strings.Join(strings.Fields(s), " ")
s = strings.ReplaceAll(s, "( ", "(")
s = strings.ReplaceAll(s, " )", ")")
return s
}

View File

@@ -0,0 +1,592 @@
package listfilter
import (
"fmt"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/parser/filterquery"
grammar "github.com/SigNoz/signoz/pkg/parser/filterquery/grammar"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
qbtypesv5 "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/antlr4-go/antlr/v4"
)
// fragment is one composable WHERE fragment. sql uses `?` placeholders;
// args lines up positionally with the placeholders.
type fragment struct {
sql string
args []any
}
func newFragment(sql string, args ...any) *fragment {
return &fragment{sql: sql, args: args}
}
type visitor struct {
grammar.BaseFilterQueryVisitor
formatter sqlstore.SQLFormatter
errors []string
}
func newVisitor(formatter sqlstore.SQLFormatter) *visitor {
return &visitor{
formatter: formatter,
}
}
// Emitted WHERE fragment uses aliases `dashboard` and `pd` (public_dashboard).
func (v *visitor) compile(query string) (*fragment, []string) {
tree, _, collector := filterquery.Parse(query)
if len(collector.Errors) > 0 {
return nil, collector.Errors
}
frag, _ := v.visit(tree).(*fragment)
return frag, nil
}
func (v *visitor) visit(tree antlr.ParseTree) any {
if tree == nil {
return nil
}
return tree.Accept(v)
}
// ════════════════════════════════════════════════════════════════════════
// methods from grammar.BaseFilterQueryVisitor that are overridden
// ════════════════════════════════════════════════════════════════════════
func (v *visitor) VisitQuery(ctx *grammar.QueryContext) any {
return v.visit(ctx.Expression())
}
func (v *visitor) VisitExpression(ctx *grammar.ExpressionContext) any {
return v.visit(ctx.OrExpression())
}
func (v *visitor) VisitOrExpression(ctx *grammar.OrExpressionContext) any {
parts := ctx.AllAndExpression()
frags := make([]*fragment, 0, len(parts))
for _, p := range parts {
if f, ok := v.visit(p).(*fragment); ok && f != nil {
frags = append(frags, f)
}
}
return joinFragments(frags, "OR")
}
func (v *visitor) VisitAndExpression(ctx *grammar.AndExpressionContext) any {
parts := ctx.AllUnaryExpression()
frags := make([]*fragment, 0, len(parts))
for _, p := range parts {
if f, ok := v.visit(p).(*fragment); ok && f != nil {
frags = append(frags, f)
}
}
return joinFragments(frags, "AND")
}
func (v *visitor) VisitUnaryExpression(ctx *grammar.UnaryExpressionContext) any {
f, _ := v.visit(ctx.Primary()).(*fragment)
if f == nil {
return nil
}
if ctx.NOT() != nil {
return newFragment("NOT ("+f.sql+")", f.args...)
}
return f
}
func (v *visitor) VisitPrimary(ctx *grammar.PrimaryContext) any {
if ctx.OrExpression() != nil {
f, _ := v.visit(ctx.OrExpression()).(*fragment)
if f == nil {
return nil
}
return newFragment("("+f.sql+")", f.args...)
}
if ctx.Comparison() != nil {
return v.visit(ctx.Comparison())
}
// Bare keys, values, full text, and function calls are not part of the
// dashboard list DSL.
v.addErr("unsupported expression %q — every term must be of the form `key OP value`", ctx.GetText())
return nil
}
// VisitComparison dispatches a single `key OP value` term. A key that matches
// a reserved DSL key (name, description, etc.) becomes a column-level
// predicate; any other identifier is treated as a tag key — the operator
// applies to the tag's value, with a case-insensitive match on the tag's key.
func (v *visitor) VisitComparison(ctx *grammar.ComparisonContext) any {
key, ok := v.parseKey(ctx)
if !ok {
return nil
}
op, ok := v.opFromContext(ctx)
if !ok {
return nil
}
if reservedOpSet, isReserved := reservedOps[dashboardtypes.DSLKey(key)]; isReserved {
if _, allowed := reservedOpSet[op]; !allowed {
v.addErr("operator %s is not allowed for key %q", opName(op), key)
return nil
}
switch dashboardtypes.DSLKey(key) {
case dashboardtypes.DSLKeyName:
return v.emitJSONStringComparison(ctx, op, "$.spec.display.name")
case dashboardtypes.DSLKeyDescription:
return v.emitJSONStringComparison(ctx, op, "$.spec.display.description")
case dashboardtypes.DSLKeyCreatedAt:
return v.emitTimestampComparison(ctx, op, "dashboard.created_at")
case dashboardtypes.DSLKeyUpdatedAt:
return v.emitTimestampComparison(ctx, op, "dashboard.updated_at")
case dashboardtypes.DSLKeyCreatedBy:
return v.emitStringComparison(ctx, op, "dashboard.created_by")
case dashboardtypes.DSLKeyLocked:
return v.emitBoolComparison(ctx, op, "dashboard.locked")
case dashboardtypes.DSLKeyPublic:
return v.emitPublicComparison(ctx, op)
}
}
if _, allowed := tagKeyOps[op]; !allowed {
v.addErr("operator %s is not allowed on a tag-key filter", opName(op))
return nil
}
return v.emitTagComparison(ctx, op, key)
}
func (v *visitor) parseKey(ctx *grammar.ComparisonContext) (string, bool) {
keyText := strings.ToLower(strings.TrimSpace(ctx.Key().GetText()))
if keyText == "" {
v.addErr("filter key cannot be empty")
return "", false
}
return keyText, true
}
func (v *visitor) opFromContext(ctx *grammar.ComparisonContext) (qbtypesv5.FilterOperator, bool) {
switch {
case ctx.EQUALS() != nil:
return qbtypesv5.FilterOperatorEqual, true
case ctx.NOT_EQUALS() != nil, ctx.NEQ() != nil:
return qbtypesv5.FilterOperatorNotEqual, true
case ctx.LT() != nil:
return qbtypesv5.FilterOperatorLessThan, true
case ctx.LE() != nil:
return qbtypesv5.FilterOperatorLessThanOrEq, true
case ctx.GT() != nil:
return qbtypesv5.FilterOperatorGreaterThan, true
case ctx.GE() != nil:
return qbtypesv5.FilterOperatorGreaterThanOrEq, true
case ctx.BETWEEN() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotBetween, true
}
return qbtypesv5.FilterOperatorBetween, true
case ctx.LIKE() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotLike, true
}
return qbtypesv5.FilterOperatorLike, true
case ctx.ILIKE() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotILike, true
}
return qbtypesv5.FilterOperatorILike, true
case ctx.CONTAINS() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotContains, true
}
return qbtypesv5.FilterOperatorContains, true
case ctx.REGEXP() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotRegexp, true
}
return qbtypesv5.FilterOperatorRegexp, true
case ctx.InClause() != nil:
return qbtypesv5.FilterOperatorIn, true
case ctx.NotInClause() != nil:
return qbtypesv5.FilterOperatorNotIn, true
case ctx.EXISTS() != nil:
if ctx.NOT() != nil {
return qbtypesv5.FilterOperatorNotExists, true
}
return qbtypesv5.FilterOperatorExists, true
}
v.addErr("could not determine operator in expression %q", ctx.GetText())
return qbtypesv5.FilterOperatorUnknown, false
}
// ─── per-key emitters ────────────────────────────────────────────────────────
func (v *visitor) emitJSONStringComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, jsonPath string) *fragment {
colExpr := string(v.formatter.JSONExtractString("dashboard.data", jsonPath))
return v.emitStringOp(ctx, op, colExpr, string(dashboardtypes.DSLKeyName))
}
func (v *visitor) emitStringComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
return v.emitStringOp(ctx, op, colExpr, string(dashboardtypes.DSLKeyCreatedBy))
}
// emitStringOp covers all the operators the spec allows on text-shaped keys
// (name, description, created_by). Tag uses a separate emitter that wraps each
// produced fragment in an EXISTS subquery.
func (v *visitor) emitStringOp(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr, keyForErr string) *fragment {
switch op {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLike, qbtypesv5.FilterOperatorNotLike:
val, ok := v.singleString(ctx, keyForErr)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ?", val)
case qbtypesv5.FilterOperatorILike, qbtypesv5.FilterOperatorNotILike:
val, ok := v.singleString(ctx, keyForErr)
if !ok {
return nil
}
// SQLite has no ILIKE keyword and Postgres LIKE is case-sensitive — emit
// LOWER(col) LIKE LOWER(?) so behavior is identical on both dialects.
lowerCol := string(v.formatter.LowerExpression(colExpr))
return newFragment(lowerCol+" "+opName(iLikeToLike(op))+" LOWER(?)", val)
case qbtypesv5.FilterOperatorContains, qbtypesv5.FilterOperatorNotContains:
val, ok := v.singleString(ctx, keyForErr)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(containsToLike(op))+" ?", "%"+escapeLike(val)+"%")
case qbtypesv5.FilterOperatorRegexp, qbtypesv5.FilterOperatorNotRegexp:
v.addErr("REGEXP filtering on %q is not yet supported", keyForErr)
return nil
case qbtypesv5.FilterOperatorIn, qbtypesv5.FilterOperatorNotIn:
vals, ok := v.stringList(ctx, keyForErr)
if !ok {
return nil
}
return inFragment(colExpr, op, vals)
}
v.addErr("operator %s on %q is not implemented", opName(op), keyForErr)
return nil
}
func (v *visitor) emitTimestampComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
switch op {
case qbtypesv5.FilterOperatorEqual, qbtypesv5.FilterOperatorNotEqual,
qbtypesv5.FilterOperatorLessThan, qbtypesv5.FilterOperatorLessThanOrEq,
qbtypesv5.FilterOperatorGreaterThan, qbtypesv5.FilterOperatorGreaterThanOrEq:
t, ok := v.singleTimestamp(ctx)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ?", t)
case qbtypesv5.FilterOperatorBetween, qbtypesv5.FilterOperatorNotBetween:
ts, ok := v.twoTimestamps(ctx)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ? AND ?", ts[0], ts[1])
}
v.addErr("operator %s on timestamp is not implemented", opName(op))
return nil
}
func (v *visitor) emitBoolComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, colExpr string) *fragment {
b, ok := v.singleBool(ctx)
if !ok {
return nil
}
return newFragment(colExpr+" "+opName(op)+" ?", b)
}
// emitPublicComparison renders `public = true|false` against the LEFT-joined
// public_dashboard alias `pd`. The spec says public is a virtual column whose
// truthiness is the existence of a row in public_dashboard.
func (v *visitor) emitPublicComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator) *fragment {
b, ok := v.singleBool(ctx)
if !ok {
return nil
}
want := b
if op == qbtypesv5.FilterOperatorNotEqual {
want = !b
}
if want {
return newFragment("pd.id IS NOT NULL")
}
return newFragment("pd.id IS NULL")
}
// TODO: drop the extra quotes once coretypes.Kind stops being double-encoded
// in the tag_relation.kind column.
const tagSubqueryPrefix = "SELECT 1 FROM tag_relation tr JOIN tag t ON t.id = tr.tag_id " +
`WHERE tr.kind = '"dashboard"' AND tr.resource_id = dashboard.id ` +
"AND LOWER(t.key) = LOWER(?)"
// emitTagComparison wraps the inner predicate in EXISTS (or NOT EXISTS for the
// negated operators). The inner predicate matches the tag's key
// case-insensitively and applies the user's operator to the tag's value.
// EXISTS / NOT EXISTS skip the value predicate — they assert the existence
// (or absence) of any tag with the given key.
func (v *visitor) emitTagComparison(ctx *grammar.ComparisonContext, op qbtypesv5.FilterOperator, key string) *fragment {
if op == qbtypesv5.FilterOperatorExists || op == qbtypesv5.FilterOperatorNotExists {
wrapper := "EXISTS"
if op == qbtypesv5.FilterOperatorNotExists {
wrapper = "NOT EXISTS"
}
return newFragment(wrapper+" ("+tagSubqueryPrefix+")", key)
}
// All other tag operators take the positive form of the value predicate
// and toggle the EXISTS wrapper for negation. Inverse() flips Not<X> → <X>.
negated := op.IsNegativeOperator()
posOp := op
if negated {
posOp = op.Inverse()
}
inner := v.emitStringOp(ctx, posOp, "t.value", key)
if inner == nil {
return nil
}
wrapper := "EXISTS"
if negated {
wrapper = "NOT EXISTS"
}
args := append([]any{key}, inner.args...)
return newFragment(wrapper+" ("+tagSubqueryPrefix+" AND "+inner.sql+")", args...)
}
// ─── value extraction helpers ───────────────────────────────────────────────
func (v *visitor) addErr(format string, args ...any) {
v.errors = append(v.errors, fmt.Sprintf(format, args...))
}
func (v *visitor) singleString(ctx *grammar.ComparisonContext, keyForErr string) (string, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addErr("expected exactly one value for %q", keyForErr)
return "", false
}
return v.stringValue(values[0], keyForErr)
}
func (v *visitor) singleBool(ctx *grammar.ComparisonContext) (bool, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addErr("expected a single boolean (true/false)")
return false, false
}
return v.boolValue(values[0])
}
func (v *visitor) singleTimestamp(ctx *grammar.ComparisonContext) (time.Time, bool) {
values := ctx.AllValue()
if len(values) != 1 {
v.addErr("expected a single RFC3339 timestamp")
return time.Time{}, false
}
return v.timestampValue(values[0])
}
func (v *visitor) twoTimestamps(ctx *grammar.ComparisonContext) ([2]time.Time, bool) {
values := ctx.AllValue()
if len(values) != 2 {
v.addErr("BETWEEN expects two RFC3339 timestamps")
return [2]time.Time{}, false
}
a, ok1 := v.timestampValue(values[0])
b, ok2 := v.timestampValue(values[1])
if !ok1 || !ok2 {
return [2]time.Time{}, false
}
return [2]time.Time{a, b}, true
}
func (v *visitor) stringList(ctx *grammar.ComparisonContext, keyForErr string) ([]string, bool) {
var valuesCtx []grammar.IValueContext
switch {
case ctx.InClause() != nil:
ic := ctx.InClause()
if ic.ValueList() != nil {
valuesCtx = ic.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{ic.Value()}
}
case ctx.NotInClause() != nil:
nc := ctx.NotInClause()
if nc.ValueList() != nil {
valuesCtx = nc.ValueList().AllValue()
} else {
valuesCtx = []grammar.IValueContext{nc.Value()}
}
default:
v.addErr("IN clause is missing for %q", keyForErr)
return nil, false
}
if len(valuesCtx) == 0 {
v.addErr("IN list for %q is empty", keyForErr)
return nil, false
}
out := make([]string, 0, len(valuesCtx))
for _, vc := range valuesCtx {
s, ok := v.stringValue(vc, keyForErr)
if !ok {
return nil, false
}
out = append(out, s)
}
return out, true
}
func (v *visitor) stringValue(ctx grammar.IValueContext, keyForErr string) (string, bool) {
if ctx.QUOTED_TEXT() != nil {
return trimQuotes(ctx.QUOTED_TEXT().GetText()), true
}
if ctx.KEY() != nil {
// Bare tokens are accepted as strings, mirroring the FilterQuery lexer's
// treatment of unquoted identifiers on the value side.
return ctx.KEY().GetText(), true
}
v.addErr("expected a string value for %q, got %q", keyForErr, ctx.GetText())
return "", false
}
func (v *visitor) boolValue(ctx grammar.IValueContext) (bool, bool) {
if ctx.BOOL() == nil {
v.addErr("expected a boolean (true/false), got %q", ctx.GetText())
return false, false
}
return strings.EqualFold(ctx.BOOL().GetText(), "true"), true
}
func (v *visitor) timestampValue(ctx grammar.IValueContext) (time.Time, bool) {
if ctx.QUOTED_TEXT() == nil {
v.addErr("expected an RFC3339 timestamp string, got %q", ctx.GetText())
return time.Time{}, false
}
raw := trimQuotes(ctx.QUOTED_TEXT().GetText())
t, err := time.Parse(time.RFC3339, raw)
if err != nil {
v.addErr("invalid RFC3339 timestamp %q: %s", raw, err.Error())
return time.Time{}, false
}
return t, true
}
// ─── fragment helpers ────────────────────────────────────────────────────────
func joinFragments(frags []*fragment, conn string) *fragment {
if len(frags) == 0 {
return nil
}
if len(frags) == 1 {
return frags[0]
}
parts := make([]string, len(frags))
args := make([]any, 0)
for i, f := range frags {
parts[i] = f.sql
args = append(args, f.args...)
}
return newFragment(strings.Join(parts, " "+conn+" "), args...)
}
func inFragment(colExpr string, op qbtypesv5.FilterOperator, vals []string) *fragment {
placeholders := strings.Repeat("?, ", len(vals))
placeholders = placeholders[:len(placeholders)-2]
args := make([]any, len(vals))
for i, s := range vals {
args[i] = s
}
return newFragment(colExpr+" "+opName(op)+" ("+placeholders+")", args...)
}
// opName returns the user-facing spelling of a FilterOperator. For the
// operators we emit directly into SQL (=, !=, <, LIKE, IN, BETWEEN, …) the
// spelling doubles as the SQL keyword. For the operators we don't emit
// directly (ILIKE, CONTAINS, REGEXP, EXISTS, NOT EXISTS) it's only used in
// error messages.
func opName(op qbtypesv5.FilterOperator) string {
switch op {
case qbtypesv5.FilterOperatorEqual:
return "="
case qbtypesv5.FilterOperatorNotEqual:
return "!="
case qbtypesv5.FilterOperatorLessThan:
return "<"
case qbtypesv5.FilterOperatorLessThanOrEq:
return "<="
case qbtypesv5.FilterOperatorGreaterThan:
return ">"
case qbtypesv5.FilterOperatorGreaterThanOrEq:
return ">="
case qbtypesv5.FilterOperatorBetween:
return "BETWEEN"
case qbtypesv5.FilterOperatorNotBetween:
return "NOT BETWEEN"
case qbtypesv5.FilterOperatorLike:
return "LIKE"
case qbtypesv5.FilterOperatorNotLike:
return "NOT LIKE"
case qbtypesv5.FilterOperatorILike:
return "ILIKE"
case qbtypesv5.FilterOperatorNotILike:
return "NOT ILIKE"
case qbtypesv5.FilterOperatorContains:
return "CONTAINS"
case qbtypesv5.FilterOperatorNotContains:
return "NOT CONTAINS"
case qbtypesv5.FilterOperatorRegexp:
return "REGEXP"
case qbtypesv5.FilterOperatorNotRegexp:
return "NOT REGEXP"
case qbtypesv5.FilterOperatorIn:
return "IN"
case qbtypesv5.FilterOperatorNotIn:
return "NOT IN"
case qbtypesv5.FilterOperatorExists:
return "EXISTS"
case qbtypesv5.FilterOperatorNotExists:
return "NOT EXISTS"
}
return "?"
}
// iLikeToLike maps ILIKE → LIKE for the LOWER(col) LIKE LOWER(?) emission.
func iLikeToLike(op qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if op == qbtypesv5.FilterOperatorNotILike {
return qbtypesv5.FilterOperatorNotLike
}
return qbtypesv5.FilterOperatorLike
}
// containsToLike maps CONTAINS → LIKE for the LIKE '%val%' emission.
func containsToLike(op qbtypesv5.FilterOperator) qbtypesv5.FilterOperator {
if op == qbtypesv5.FilterOperatorNotContains {
return qbtypesv5.FilterOperatorNotLike
}
return qbtypesv5.FilterOperatorLike
}
// escapeLike escapes the LIKE meta-characters % and _ in user input so that a
// CONTAINS query of `50%` doesn't match every value containing `50`.
func escapeLike(s string) string {
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
return r.Replace(s)
}
func trimQuotes(s string) string {
if len(s) >= 2 {
if (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'') {
s = s[1 : len(s)-1]
}
}
s = strings.ReplaceAll(s, `\\`, `\`)
s = strings.ReplaceAll(s, `\'`, `'`)
return s
}

View File

@@ -32,6 +32,7 @@ const (
DSLKeyCreatedBy DSLKey = "created_by"
DSLKeyLocked DSLKey = "locked"
DSLKeyPublic DSLKey = "public"
DSLKeySource DSLKey = "source"
)
// reservedDSLKeys are dashboard column-level filter names in the list-query DSL.
@@ -45,6 +46,7 @@ var reservedDSLKeys = map[DSLKey]struct{}{
DSLKeyCreatedBy: {},
DSLKeyLocked: {},
DSLKeyPublic: {},
DSLKeySource: {},
}
type DashboardV2 struct {

View File

@@ -0,0 +1,22 @@
package dashboardtypes
import (
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
const MaxPinnedDashboardsPerUser = 10
var ErrCodePinnedDashboardLimitHit = errors.MustNewCode("pinned_dashboard_limit_hit")
type PinnedDashboard struct {
bun.BaseModel `bun:"table:pinned_dashboard,alias:pinned_dashboard"`
UserID valuer.UUID `bun:"user_id,pk,type:text"`
DashboardID valuer.UUID `bun:"dashboard_id,pk,type:text"`
OrgID valuer.UUID `bun:"org_id,type:text,notnull"`
PinnedAt time.Time `bun:"pinned_at,notnull,default:current_timestamp"`
}

View File

@@ -32,4 +32,29 @@ type Store interface {
DeletePublic(context.Context, string) error
RunInTx(context.Context, func(context.Context) error) error
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
// int64 return is the total row count for the filter (pre-limit/offset),
ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *ListDashboardsV2Params) ([]*DashboardListRow, int64, error)
// Returns ErrCodePinnedDashboardLimitHit when the user is at MaxPinnedDashboardsPerUser.
PinForUser(ctx context.Context, pd *PinnedDashboard) error
UnpinForUser(ctx context.Context, userID valuer.UUID, dashboardID valuer.UUID) error
// ════════════════════════════════════════════════════════════════════════
// Dashboard saved view methods
// ════════════════════════════════════════════════════════════════════════
CreateDashboardView(ctx context.Context, view *DashboardView) error
GetDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*DashboardView, error)
ListDashboardViews(ctx context.Context, orgID valuer.UUID) ([]*DashboardView, error)
UpdateDashboardView(ctx context.Context, view *DashboardView) error
DeleteDashboardView(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
}

View File

@@ -12,13 +12,6 @@ import (
"github.com/swaggest/jsonschema-go"
)
const (
// minEpochMs and maxEpochMs bound a plausible ms timestamp to
// 1990-01-01 .. 2100-01-01, used to reject malformed Start/End values.
minEpochMs uint64 = 631_152_000_000
maxEpochMs uint64 = 4_102_444_800_000
)
type QueryEnvelope struct {
// Type is the type of the query.
Type QueryType `json:"type"` // "builder_query" | "builder_formula" | "builder_sub_query" | "builder_join" | "promql" | "clickhouse_sql"
@@ -556,23 +549,7 @@ func (r *QueryRangeRequest) SkipFillGaps(name string) bool {
return false
}
// Normalize coerces Start and End to epoch milliseconds, inferring the source
// resolution (s/ms/µs/ns) from each value's magnitude, and rejects non-zero
// values outside the plausible 1990-2100 range. Lets downstream consumers
// assume ms regardless of what the caller sent.
func (r *QueryRangeRequest) Normalize() error {
start, err := toMilliSecs(r.Start)
if err != nil {
return err
}
end, err := toMilliSecs(r.End)
if err != nil {
return err
}
r.Start, r.End = start, end
return nil
}
// UnmarshalJSON implements custom JSON unmarshaling to disallow unknown fields.
func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Define a type alias to avoid infinite recursion
type Alias QueryRangeRequest
@@ -632,11 +609,6 @@ func (r *QueryRangeRequest) UnmarshalJSON(data []byte) error {
// Copy the decoded values back to the original struct
*r = QueryRangeRequest(temp)
// Coerce Start/End to ms (and validate) at decode time for HTTP requests.
if err := r.Normalize(); err != nil {
return err
}
return nil
}
@@ -690,24 +662,3 @@ func (r *QueryRangeRequest) GetQueriesSupportingZeroDefault() map[string]bool {
return canDefaultZero
}
// toMilliSecs scales an epoch to milliseconds based on its magnitude: seconds are
// scaled up, micro/nanoseconds down, milliseconds left as-is. Zero is returned
// unchanged. A non-zero result outside 1990-2100 is rejected as malformed.
func toMilliSecs(epoch uint64) (uint64, error) {
var ms uint64
switch {
case epoch < 1e12: // seconds
ms = epoch * 1_000
case epoch < 1e15: // milliseconds
ms = epoch
case epoch < 1e18: // microseconds
ms = epoch / 1_000
default: // nanoseconds
ms = epoch / 1_000_000
}
if epoch != 0 && (ms < minEpochMs || ms > maxEpochMs) {
return 0, errors.NewInvalidInputf(errors.CodeInvalidInput, "timestamp %d is outside the supported range (1990-2100)", epoch)
}
return ms, nil
}

View File

@@ -1903,70 +1903,3 @@ func TestQueryRangeRequest_StepIntervalForQuery(t *testing.T) {
})
}
}
func TestQueryRangeRequest_Normalize(t *testing.T) {
tests := []struct {
name string
start uint64
end uint64
wantStart uint64
wantEnd uint64
wantErr bool
}{
{
name: "seconds are scaled up to ms",
start: 1672531200, // 2023-01-01 in seconds
end: 1716638400, // 2024-05-25 in seconds
wantStart: 1672531200000, // * 10^3
wantEnd: 1716638400000,
},
{
name: "milliseconds pass through unchanged",
start: 1672531200000,
end: 1716638400000,
wantStart: 1672531200000,
wantEnd: 1716638400000,
},
{
name: "microseconds are scaled down to ms",
start: 1672531200000000, // µs
end: 1716638400000000,
wantStart: 1672531200000, // / 10^3
wantEnd: 1716638400000,
},
{
name: "nanoseconds are scaled down to ms",
start: 1672531200000000000, // ns
end: 1716638400000000000,
wantStart: 1672531200000, // / 10^6
wantEnd: 1716638400000,
},
{
name: "zero end (open-ended stream) is left untouched",
start: 1672531200000,
end: 0,
wantStart: 1672531200000,
wantEnd: 0,
},
{
name: "out-of-range timestamp is rejected",
start: 5_000_000_000_000, // ~year 2128 in ms, beyond the 2100 bound
end: 5_000_000_000_000,
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
r := &QueryRangeRequest{Start: tt.start, End: tt.end}
err := r.Normalize()
if tt.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tt.wantStart, r.Start)
assert.Equal(t, tt.wantEnd, r.End)
})
}
}

View File

@@ -0,0 +1,445 @@
import uuid
from collections.abc import Callable
from http import HTTPStatus
import pytest
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
# The v2 dashboard API. Request shape (current):
# {"schemaVersion": "v6", "name": "<dns-1123-label>",
# "spec": {"display": {"name": "<human name>"}},
# "tags": [{"key": "...", "value": "..."}]}
# `name` is a DNS-1123 label identifier and is immutable after create;
# `spec.display.name` is the human-facing title used for name-sort/name-filter.
_BASE = "/api/v2/dashboards"
_TIMEOUT = 5
def _headers(token: str) -> dict:
return {"Authorization": f"Bearer {token}"}
def _url(signoz: SigNoz, path: str = "") -> str:
return signoz.self.host_configs["8080"].get(f"{_BASE}{path}")
def _create(signoz: SigNoz, token: str, body: dict) -> requests.Response:
return requests.post(
_url(signoz), json=body, headers=_headers(token), timeout=_TIMEOUT
)
def _get(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
return requests.get(
_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT
)
def _list(signoz: SigNoz, token: str, **params: object) -> requests.Response:
return requests.get(
_url(signoz),
params={k: v for k, v in params.items() if v is not None},
headers=_headers(token),
timeout=_TIMEOUT,
)
def _update(
signoz: SigNoz, token: str, dashboard_id: str, body: dict
) -> requests.Response:
return requests.put(
_url(signoz, f"/{dashboard_id}"),
json=body,
headers=_headers(token),
timeout=_TIMEOUT,
)
def _delete(signoz: SigNoz, token: str, dashboard_id: str) -> requests.Response:
return requests.delete(
_url(signoz, f"/{dashboard_id}"), headers=_headers(token), timeout=_TIMEOUT
)
def _lock(
signoz: SigNoz, token: str, dashboard_id: str, lock: bool
) -> requests.Response:
method = requests.put if lock else requests.delete
return method(
_url(signoz, f"/{dashboard_id}/lock"),
headers=_headers(token),
timeout=_TIMEOUT,
)
def _pin(signoz: SigNoz, token: str, dashboard_id: str, pin: bool) -> requests.Response:
method = requests.put if pin else requests.delete
return method(
_url(signoz, f"/{dashboard_id}/pins/me"),
headers=_headers(token),
timeout=_TIMEOUT,
)
def _minimal_body(name: str, display: str, tags: list[dict] | None = None) -> dict:
return {
"schemaVersion": "v6",
"name": name,
"spec": {"display": {"name": display}},
"tags": tags or [],
}
# ─── failure cases (create no dashboards) ────────────────────────────────────
def test_create_rejects_wrong_schema_version(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, 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"] == 'schemaVersion must be "v6", got ""'
def test_create_rejects_missing_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(signoz, token, {"schemaVersion": "v6"})
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == "name is required"
def test_create_rejects_non_dns_name(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _create(
signoz, token, _minimal_body(name="Not A Label", display="Not A Label")
)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
def test_create_rejects_unknown_field(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _minimal_body("rejects-unknown", "Rejects Unknown")
body["unknownfield"] = "boom"
response = _create(signoz, token, body)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
assert "unknown field" in response.json()["error"]["message"]
def test_create_rejects_reserved_tag_key(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
body = _minimal_body(
"rejects-reserved", "Rejects Reserved", [{"key": "source", "value": "x"}]
)
response = _create(signoz, token, body)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
def test_create_rejects_too_many_tags(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
tags = [{"key": f"k{i}", "value": "v"} for i in range(11)]
response = _create(signoz, token, _minimal_body("too-many-tags", "Too Many", tags))
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_invalid_input"
@pytest.mark.parametrize(
"params",
[
{"sort": "bogus"},
{"order": "bogus"},
{"limit": -1},
{"offset": -1},
],
)
def test_list_rejects_invalid_params(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
params: dict,
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _list(signoz, token, **params)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert response.json()["error"]["code"] == "dashboard_list_invalid"
def test_get_rejects_malformed_id(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _get(signoz, token, "not-a-uuid")
assert response.status_code == HTTPStatus.BAD_REQUEST
def test_get_missing_dashboard_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _get(signoz, token, str(uuid.uuid4()))
assert response.status_code == HTTPStatus.NOT_FOUND
def test_delete_missing_dashboard_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _delete(signoz, token, str(uuid.uuid4()))
assert response.status_code == HTTPStatus.NOT_FOUND
def test_pin_missing_dashboard_returns_not_found(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _pin(signoz, token, str(uuid.uuid4()), pin=True)
assert response.status_code == HTTPStatus.NOT_FOUND
# ─── lifecycle ───────────────────────────────────────────────────────────────
# A single end-to-end flow through create → get → list/filter/sort → pin →
# update → lock → delete. Every fixture dashboard carries a unique suite marker
# tag so list queries can be scoped server-side, isolating this test from any
# other dashboards sharing the session DB.
_SUITE_TAG = {"key": "suite", "value": "lifecyclev2"}
_SUITE_FILTER = "suite = 'lifecyclev2'"
def _scoped(query: str) -> str:
return f"({query}) AND {_SUITE_FILTER}"
def _display_names(body: dict) -> list[str]:
return [d["spec"]["display"]["name"] for d in body["data"]["dashboards"]]
def test_dashboard_v2_lifecycle( # pylint: disable=too-many-locals,too-many-statements
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
fixtures = [
(
"lc-alpha",
"Alpha Overview",
[{"key": "team", "value": "pulse"}, {"key": "env", "value": "prod"}],
),
(
"lc-beta",
"Beta Overview",
[{"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
),
(
"lc-gamma",
"Gamma Storage",
[{"key": "team", "value": "storage"}, {"key": "env", "value": "prod"}],
),
]
# ── stage 1: create ──────────────────────────────────────────────────────
ids: dict[str, str] = {}
for name, display, tags in fixtures:
response = _create(
signoz, token, _minimal_body(name, display, [_SUITE_TAG, *tags])
)
assert response.status_code == HTTPStatus.CREATED, response.text
ids[name] = response.json()["data"]["id"]
# TODO: re-enable once the dashboard name unique index lands — creating a
# second dashboard with an existing name should conflict (409). Until the
# index exists, duplicate names are silently allowed.
# response = _create(signoz, token, _minimal_body("lc-alpha", "Alpha Dupe"))
# assert response.status_code == HTTPStatus.CONFLICT, response.text
# ── stage 2: get one and verify the round-tripped shape ──────────────────
response = _get(signoz, token, ids["lc-alpha"])
assert response.status_code == HTTPStatus.OK, response.text
alpha = response.json()["data"]
assert alpha["id"] == ids["lc-alpha"]
assert alpha["name"] == "lc-alpha"
assert alpha["spec"]["display"]["name"] == "Alpha Overview"
assert alpha["schemaVersion"] == "v6"
assert alpha["source"] == "user"
assert alpha["locked"] is False
assert {"key": "team", "value": "pulse"} in alpha["tags"]
# ── stage 3: list everything in the suite ────────────────────────────────
response = _list(signoz, token, query=_SUITE_FILTER, limit=200)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["data"]["total"] == 3
assert set(_display_names(body)) == {
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
}
# ── stage 4: filter DSL (tag-key filters + display-name contains) ────────
cases = [
("team = 'pulse'", {"Alpha Overview", "Beta Overview"}),
("env = 'prod'", {"Alpha Overview", "Gamma Storage"}),
("name CONTAINS 'Overview'", {"Alpha Overview", "Beta Overview"}),
("env IN ['dev', 'test']", {"Beta Overview"}),
]
for query, expected in cases:
response = _list(signoz, token, query=_scoped(query), limit=200)
assert response.status_code == HTTPStatus.OK, response.text
assert set(_display_names(response.json())) == expected, query
# ── stage 5: name sort honours order ─────────────────────────────────────
response = _list(
signoz, token, query=_SUITE_FILTER, sort="name", order="asc", limit=200
)
assert _display_names(response.json()) == [
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
]
response = _list(
signoz, token, query=_SUITE_FILTER, sort="name", order="desc", limit=200
)
assert _display_names(response.json()) == [
"Gamma Storage",
"Beta Overview",
"Alpha Overview",
]
# ── stage 6: pinning floats a dashboard to the top of any ordering ───────
assert (
_pin(signoz, token, ids["lc-gamma"], pin=True).status_code
== HTTPStatus.NO_CONTENT
)
response = _list(
signoz, token, query=_SUITE_FILTER, sort="name", order="asc", limit=200
)
dashboards = response.json()["data"]["dashboards"]
assert dashboards[0]["name"] == "lc-gamma"
assert dashboards[0]["pinned"] is True
assert all(d["pinned"] is False for d in dashboards[1:])
# ── stage 7: unpinning restores the natural ordering ─────────────────────
assert (
_pin(signoz, token, ids["lc-gamma"], pin=False).status_code
== HTTPStatus.NO_CONTENT
)
response = _list(
signoz, token, query=_SUITE_FILTER, sort="name", order="asc", limit=200
)
assert _display_names(response.json()) == [
"Alpha Overview",
"Beta Overview",
"Gamma Storage",
]
# ── stage 8: update mutates the spec but keeps the immutable name ────────
update_body = _minimal_body(
"lc-alpha",
"Alpha Overview",
[
_SUITE_TAG,
{"key": "team", "value": "pulse"},
{"key": "env", "value": "prod"},
],
)
update_body["spec"]["display"]["description"] = "now with a description"
response = _update(signoz, token, ids["lc-alpha"], update_body)
assert response.status_code == HTTPStatus.OK, response.text
response = _get(signoz, token, ids["lc-alpha"])
assert (
response.json()["data"]["spec"]["display"]["description"]
== "now with a description"
)
# ── stage 9: a locked dashboard rejects updates until unlocked ───────────
assert (
_lock(signoz, token, ids["lc-beta"], lock=True).status_code
== HTTPStatus.NO_CONTENT
)
beta_body = _minimal_body(
"lc-beta",
"Beta Overview",
[_SUITE_TAG, {"key": "team", "value": "pulse"}, {"key": "env", "value": "dev"}],
)
response = _update(signoz, token, ids["lc-beta"], beta_body)
assert response.status_code == HTTPStatus.BAD_REQUEST
assert (
_lock(signoz, token, ids["lc-beta"], lock=False).status_code
== HTTPStatus.NO_CONTENT
)
assert (
_update(signoz, token, ids["lc-beta"], beta_body).status_code == HTTPStatus.OK
)
# ── stage 10: delete removes the dashboard from get and list ─────────────
assert _delete(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NO_CONTENT
assert _get(signoz, token, ids["lc-gamma"]).status_code == HTTPStatus.NOT_FOUND
response = _list(signoz, token, query=_SUITE_FILTER, limit=200)
assert response.json()["data"]["total"] == 2
assert set(_display_names(response.json())) == {"Alpha Overview", "Beta Overview"}