Compare commits

...

261 Commits

Author SHA1 Message Date
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
45 changed files with 8963 additions and 21 deletions

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -11,8 +11,10 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/ctxtypes"
@@ -30,9 +32,9 @@ type module struct {
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
func NewModule(store dashboardtypes.Store, sqlstore sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing, tagModule tag.Module) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
pkgDashboardModule := pkgimpldashboard.NewModule(store, sqlstore, settings, analytics, orgGetter, queryParser, tagModule)
return &module{
pkgDashboardModule: pkgDashboardModule,
@@ -197,6 +199,89 @@ func (module *module) Create(ctx context.Context, orgID valuer.UUID, createdBy s
return module.pkgDashboardModule.Create(ctx, orgID, createdBy, creator, data)
}
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.CreateV2(ctx, orgID, createdBy, creator, postable)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.GetV2(ctx, orgID, id)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
return module.pkgDashboardModule.UpdateV2(ctx, orgID, id, updatedBy, updateable)
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
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) CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
if existing.PublicConfig != nil {
return nil, errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", id)
}
publicDashboard := dashboardtypes.NewPublicDashboard(postable.TimeRangeEnabled, postable.DefaultTimeRange, id)
if err := module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard)); err != nil {
return nil, err
}
existing.PublicConfig = publicDashboard
return existing, nil
}
func (module *module) UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
if _, err := module.licensing.GetActive(ctx, orgID); err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existing, err := module.pkgDashboardModule.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
if existing.PublicConfig == nil {
return nil, errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodePublicDashboardNotFound, "dashboard with id %s isn't public", id)
}
existing.PublicConfig.Update(updatable.TimeRangeEnabled, updatable.DefaultTimeRange)
if err := module.store.UpdatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(existing.PublicConfig)); err != nil {
return nil, err
}
return existing, nil
}
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) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

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"->'data'->'display'->>'name' = ?`,
wantArgs: []any{"overview"},
},
{
name: "name CONTAINS — same JSON path, LIKE pattern",
query: `name CONTAINS 'overview'`,
wantSQL: `"dashboard"."data"->'data'->'display'->>'name' LIKE ?`,
wantArgs: []any{"%overview%"},
},
{
name: "name ILIKE — LOWER wraps the JSON path",
query: `name ILIKE 'Prod%'`,
wantSQL: `lower("dashboard"."data"->'data'->'display'->>'name') LIKE LOWER(?)`,
wantArgs: []any{"Prod%"},
},
{
name: "description = follows the same path shape",
query: `description = 'd1'`,
wantSQL: `"dashboard"."data"->'data'->'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)
})
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

1
go.mod
View File

@@ -88,6 +88,7 @@ require (
gonum.org/v1/gonum v0.17.0
google.golang.org/api v0.265.0
google.golang.org/protobuf v1.36.11
gopkg.in/evanphx/json-patch.v4 v4.13.0
gopkg.in/yaml.v2 v2.4.0
gopkg.in/yaml.v3 v3.0.1
k8s.io/apimachinery v0.35.2

View File

@@ -14,6 +14,218 @@ import (
)
func (provider *provider) addDashboardRoutes(router *mux.Router) error {
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`/`title`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`). Pinned dashboards float to the top of each page.",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.ListableDashboardV2),
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", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.CreateV2), handler.OpenAPIDef{
ID: "CreateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Create dashboard (v2)",
Description: "This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.",
Request: new(dashboardtypes.PostableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.ViewAccess(provider.dashboardHandler.GetV2), handler.OpenAPIDef{
ID: "GetDashboardV2",
Tags: []string{"dashboard"},
Summary: "Get dashboard (v2)",
Description: "This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).",
Request: nil,
RequestContentType: "",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodGet).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UpdateV2), handler.OpenAPIDef{
ID: "UpdateDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update dashboard (v2)",
Description: "This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.",
Request: new(dashboardtypes.UpdateableDashboardV2),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.PatchV2), handler.OpenAPIDef{
ID: "PatchDashboardV2",
Tags: []string{"dashboard"},
Summary: "Patch dashboard (v2)",
Description: "This endpoint applies an RFC 6902 JSON Patch to a v2-shape dashboard. The patch is applied against the postable view of the dashboard (metadata, data, tags), so individual panels, queries, variables, layouts, or tags can be updated without re-sending the rest of the dashboard. Locked dashboards are rejected.",
Request: new(dashboardtypes.JSONPatchDocument),
// Strictly per RFC 6902 the content type is `application/json-patch+json`,
// but our OpenAPI generator only reflects schemas for content types it
// understands (application/json, form-urlencoded, multipart) — anything
// else degrades to `type: string`. Declaring application/json here keeps
// the array-of-ops schema visible to spec consumers; the runtime decoder
// parses JSON regardless of the request's actual Content-Type header.
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPatch).GetError(); err != nil {
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{},
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"},
Summary: "Lock dashboard (v2)",
Description: "This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/lock", handler.New(provider.authzMiddleware.EditAccess(provider.dashboardHandler.UnlockV2), handler.OpenAPIDef{
ID: "UnlockDashboardV2",
Tags: []string{"dashboard"},
Summary: "Unlock dashboard (v2)",
Description: "This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.",
Request: nil,
RequestContentType: "",
Response: nil,
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusNoContent,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleEditor),
})).Methods(http.MethodDelete).GetError(); err != nil {
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{},
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{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodDelete).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublicV2), handler.OpenAPIDef{
ID: "CreatePublicDashboardV2",
Tags: []string{"dashboard"},
Summary: "Make a dashboard v2 public",
Description: "This endpoint creates the public sharing config for a v2 dashboard and returns the dashboard with the new public config attached. Lock state does not gate this endpoint.",
Request: new(dashboardtypes.PostablePublicDashboard),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPatch).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v2/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.UpdatePublicV2), handler.OpenAPIDef{
ID: "UpdatePublicDashboardV2",
Tags: []string{"dashboard"},
Summary: "Update public sharing config for a dashboard v2",
Description: "This endpoint updates the public sharing config (time range settings) of an already-public v2 dashboard. Lock state does not gate this endpoint.",
Request: new(dashboardtypes.UpdatablePublicDashboard),
RequestContentType: "application/json",
Response: new(dashboardtypes.GettableDashboardV2),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
})).Methods(http.MethodPut).GetError(); err != nil {
return err
}
if err := router.Handle("/api/v1/dashboards/{id}/public", handler.New(provider.authzMiddleware.AdminAccess(provider.dashboardHandler.CreatePublic), handler.OpenAPIDef{
ID: "CreatePublicDashboard",
Tags: []string{"dashboard"},

View File

@@ -49,6 +49,32 @@ type Module interface {
GetByMetricNames(ctx context.Context, orgID valuer.UUID, metricNames []string) (map[string][]map[string]string, error)
statsreporter.StatsCollector
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error)
GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error)
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, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error)
PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error)
DeleteV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, isAdmin bool, lock bool) error
CreatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, postable dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error)
UpdatePublicV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatable dashboardtypes.UpdatablePublicDashboard) (*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
}
type Handler interface {
@@ -71,4 +97,31 @@ type Handler interface {
LockUnlock(http.ResponseWriter, *http.Request)
Delete(http.ResponseWriter, *http.Request)
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
CreateV2(http.ResponseWriter, *http.Request)
GetV2(http.ResponseWriter, *http.Request)
ListV2(http.ResponseWriter, *http.Request)
UpdateV2(http.ResponseWriter, *http.Request)
PatchV2(http.ResponseWriter, *http.Request)
DeleteV2(http.ResponseWriter, *http.Request)
LockV2(http.ResponseWriter, *http.Request)
UnlockV2(http.ResponseWriter, *http.Request)
CreatePublicV2(http.ResponseWriter, *http.Request)
UpdatePublicV2(http.ResponseWriter, *http.Request)
PinV2(http.ResponseWriter, *http.Request)
UnpinV2(http.ResponseWriter, *http.Request)
}

View File

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

View File

@@ -2,10 +2,14 @@ package impldashboard
import (
"context"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types"
"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 +67,201 @@ func (store *store) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID)
return storableDashboard, nil
}
func (store *store) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.StorableDashboard, *dashboardtypes.StorablePublicDashboard, error) {
type joinedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
PublicID *valuer.UUID `bun:"public_id"`
PublicCreatedAt *time.Time `bun:"public_created_at"`
PublicUpdatedAt *time.Time `bun:"public_updated_at"`
PublicTimeRangeEnabled *bool `bun:"public_time_range_enabled"`
PublicDefaultTimeRange *string `bun:"public_default_time_range"`
}
row := &joinedRow{StorableDashboard: new(dashboardtypes.StorableDashboard)}
err := store.
sqlstore.
BunDB().
NewSelect().
Model(row).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.data, dashboard.locked, dashboard.created_at, dashboard.created_by, dashboard.updated_at, dashboard.updated_by").
ColumnExpr("pd.id AS public_id, pd.created_at AS public_created_at, pd.updated_at AS public_updated_at, pd.time_range_enabled AS public_time_range_enabled, pd.default_time_range AS public_default_time_range").
Join("LEFT JOIN public_dashboard AS pd ON pd.dashboard_id = dashboard.id").
Where("dashboard.id = ?", id).
Where("dashboard.org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, nil, store.sqlstore.WrapNotFoundErrf(err, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
if row.PublicID == nil {
return row.StorableDashboard, nil, nil
}
public := &dashboardtypes.StorablePublicDashboard{
Identifiable: types.Identifiable{ID: *row.PublicID},
TimeAuditable: types.TimeAuditable{CreatedAt: *row.PublicCreatedAt, UpdatedAt: *row.PublicUpdatedAt},
TimeRangeEnabled: *row.PublicTimeRangeEnabled,
DefaultTimeRange: *row.PublicDefaultTimeRange,
DashboardID: row.ID.StringValue(),
}
return row.StorableDashboard, public, nil
}
// ListV2 emits the joined dashboard ⨝ pinned_dashboard ⨝ public_dashboard
// query the spec calls for. Aliases:
//
// dashboard — the visitor expects this
// pinned_dashboard AS pin — only used inside this query
// public_dashboard AS pd — the visitor expects this
//
// Sort is "is_pinned DESC, <sort> <order>" so pinned dashboards float to the
// top inside the requested ordering. Title-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, bool, error) {
compiled, err := listfilter.Compile(params.Query, store.sqlstore.Formatter())
if err != nil {
return nil, false, err
}
type listedRow struct {
*dashboardtypes.StorableDashboard `bun:",extend"`
IsPinned bool `bun:"is_pinned"`
PublicID *valuer.UUID `bun:"public_id"`
PublicCreatedAt *time.Time `bun:"public_created_at"`
PublicUpdatedAt *time.Time `bun:"public_updated_at"`
PublicTimeRangeEnabled *bool `bun:"public_time_range_enabled"`
PublicDefaultTimeRange *string `bun:"public_default_time_range"`
}
rows := make([]*listedRow, 0)
q := store.sqlstore.
BunDB().
NewSelect().
Model(&rows).
ColumnExpr("dashboard.id, dashboard.org_id, dashboard.data, dashboard.locked, 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("pd.id AS public_id, pd.created_at AS public_created_at, pd.updated_at AS public_updated_at, pd.time_range_enabled AS public_time_range_enabled, pd.default_time_range AS public_default_time_range").
Join("LEFT JOIN pinned_dashboard AS pin ON pin.user_id = ? AND pin.dashboard_id = dashboard.id", userID).
Join("LEFT JOIN public_dashboard AS pd ON pd.dashboard_id = dashboard.id").
Where("dashboard.org_id = ?", orgID)
if compiled != nil {
q = q.Where(compiled.SQL, compiled.Args...)
}
sortExpr, err := store.sortExprForListV2(params.Sort)
if err != nil {
return nil, false, err
}
q = q.
OrderExpr("is_pinned DESC").
OrderExpr(sortExpr + " " + strings.ToUpper(string(params.Order))).
Limit(params.Limit + 1).
Offset(params.Offset)
if err := q.Scan(ctx); err != nil {
return nil, false, err
}
hasMore := len(rows) > params.Limit
if hasMore {
rows = rows[:params.Limit]
}
out := make([]*dashboardtypes.DashboardListRow, len(rows))
for i, r := range rows {
row := &dashboardtypes.DashboardListRow{
Dashboard: r.StorableDashboard,
Pinned: r.IsPinned,
}
if r.PublicID != nil {
row.Public = &dashboardtypes.StorablePublicDashboard{
Identifiable: types.Identifiable{ID: *r.PublicID},
TimeAuditable: types.TimeAuditable{CreatedAt: *r.PublicCreatedAt, UpdatedAt: *r.PublicUpdatedAt},
TimeRangeEnabled: *r.PublicTimeRangeEnabled,
DefaultTimeRange: *r.PublicDefaultTimeRange,
DashboardID: r.ID.StringValue(),
}
}
out[i] = row
}
return out, hasMore, 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", "$.data.display.name")), nil
}
return "", errors.Newf(errors.TypeInvalidInput, dashboardtypes.ErrCodeDashboardListInvalid,
"unsupported sort field %q", sort)
}
func (store *store) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data dashboardtypes.StorableDashboardData) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("data = ?", data).
Set("updated_by = ?", updatedBy).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
Where("org_id = ?", orgID).
Exec(ctx)
if err != nil {
return err
}
rows, err := res.RowsAffected()
if err != nil {
return err
}
// Defends against the race where a delete lands between the caller's
// pre-update GetV2 and this update.
if rows == 0 {
return errors.Newf(errors.TypeNotFound, dashboardtypes.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error {
res, err := store.
sqlstore.
BunDBCtx(ctx).
NewUpdate().
Model((*dashboardtypes.StorableDashboard)(nil)).
Set("locked = ?", locked).
Set("updated_by = ?", updatedBy).
Set("updated_at = ?", time.Now()).
Where("id = ?", id).
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.ErrCodeDashboardNotFound, "dashboard with id %s doesn't exist", id)
}
return nil
}
func (store *store) GetPublic(ctx context.Context, dashboardID string) (*dashboardtypes.StorablePublicDashboard, error) {
storable := new(dashboardtypes.StorablePublicDashboard)
err := store.
@@ -217,3 +416,51 @@ 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
}

View File

@@ -0,0 +1,442 @@
package impldashboard
import (
"context"
"encoding/json"
"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/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/gorilla/mux"
)
func (handler *handler) CreateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PostableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreateV2(ctx, orgID, claims.Email, valuer.MustNewUUID(claims.IdentityID()), req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusCreated, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
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, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
userID, err := valuer.NewUUID(claims.IdentityID())
if err != nil {
render.Error(rw, err)
return
}
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()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.GetV2(ctx, orgID, dashboardID)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) LockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, true)
}
func (handler *handler) UnlockV2(rw http.ResponseWriter, r *http.Request) {
handler.lockUnlockV2(rw, r, false)
}
func (handler *handler) lockUnlockV2(rw http.ResponseWriter, r *http.Request, lock 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, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
isAdmin := false
selectors := []coretypes.Selector{
coretypes.TypeRole.MustSelector(authtypes.SigNozAdminRoleName),
}
err = handler.authz.CheckWithTupleCreation(
ctx,
claims,
valuer.MustNewUUID(claims.OrgID),
authtypes.Relation{Verb: coretypes.VerbAssignee},
coretypes.NewResourceRole(),
selectors,
selectors,
)
if err == nil {
isAdmin = true
}
if err := handler.module.LockUnlockV2(ctx, orgID, dashboardID, claims.Email, isAdmin, lock); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) UpdateV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdateableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdateV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
func (handler *handler) PatchV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PatchableDashboardV2{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.PatchV2(ctx, orgID, dashboardID, claims.Email, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
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, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
if err := handler.module.DeleteV2(ctx, orgID, dashboardID); err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusNoContent, nil)
}
func (handler *handler) CreatePublicV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.PostablePublicDashboard{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.CreatePublicV2(ctx, orgID, dashboardID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}
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, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
userID, err := valuer.NewUUID(claims.IdentityID())
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
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) UpdatePublicV2(rw http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
defer cancel()
claims, err := authtypes.ClaimsFromContext(ctx)
if err != nil {
render.Error(rw, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(r)["id"]
if id == "" {
render.Error(rw, errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "id is missing in the path"))
return
}
dashboardID, err := valuer.NewUUID(id)
if err != nil {
render.Error(rw, err)
return
}
req := dashboardtypes.UpdatablePublicDashboard{}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
render.Error(rw, err)
return
}
dashboard, err := handler.module.UpdatePublicV2(ctx, orgID, dashboardID, req)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, dashboardtypes.NewGettableDashboardV2FromDashboardV2(dashboard))
}

View File

@@ -0,0 +1,210 @@
package impldashboard
import (
"context"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
func (module *module) CreateV2(ctx context.Context, orgID valuer.UUID, createdBy string, creator valuer.UUID, postable dashboardtypes.PostableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := postable.Validate(); err != nil {
return nil, err
}
dashboard := dashboardtypes.NewDashboardV2(orgID, createdBy, postable, nil)
var storableDashboard *dashboardtypes.StorableDashboard
err := module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, dashboard.ID, postable.Tags)
if err != nil {
return err
}
dashboard.Info.Tags = resolvedTags
storable, err := dashboard.ToStorableDashboard()
if err != nil {
return err
}
storableDashboard = storable
return module.store.Create(ctx, storable)
})
if err != nil {
return nil, err
}
module.analytics.TrackUser(ctx, orgID.String(), creator.String(), "Dashboard Created", dashboardtypes.NewStatsFromStorableDashboards([]*dashboardtypes.StorableDashboard{storableDashboard}))
return dashboard, nil
}
// ListV2 calls the store for the joined page (the store owns DSL compilation
// and limit+1/hasMore detection), batch-fetches tags for the returned
// dashboard ids, and hands off to the type-side constructor for assembly.
func (module *module) ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *dashboardtypes.ListDashboardsV2Params) (*dashboardtypes.ListableDashboardV2, error) {
rows, hasMore, 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
}
return dashboardtypes.NewListableDashboardV2(rows, tagsByDashboard, hasMore)
}
func (module *module) GetV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.DashboardV2, error) {
storable, public, err := module.store.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
tags, err := module.tagModule.ListForResource(ctx, orgID, coretypes.KindDashboard, id)
if err != nil {
return nil, err
}
return dashboardtypes.NewDashboardV2FromStorable(storable, public, tags)
}
func (module *module) UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, updateable dashboardtypes.UpdateableDashboardV2) (*dashboardtypes.DashboardV2, error) {
if err := updateable.Validate(); err != nil {
return nil, err
}
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
err = module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
if err != nil {
return err
}
err = existing.Update(updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
})
if err != nil {
return nil, err
}
return existing, nil
}
func (module *module) PatchV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, patch dashboardtypes.PatchableDashboardV2) (*dashboardtypes.DashboardV2, error) {
existing, err := module.GetV2(ctx, orgID, id)
if err != nil {
return nil, err
}
// Locked-dashboard / state gate — independent of tags, so run it before the tx.
if err := existing.CanUpdate(); err != nil {
return nil, err
}
updateable, err := patch.Apply(existing)
if err != nil {
return nil, err
}
err = module.sqlstore.RunInTxCtx(ctx, nil, func(ctx context.Context) error {
resolvedTags, err := module.tagModule.SyncTags(ctx, orgID, coretypes.KindDashboard, id, updateable.Tags)
if err != nil {
return err
}
err = existing.Update(*updateable, updatedBy, resolvedTags)
if err != nil {
return err
}
storable, err := existing.ToStorableDashboard()
if err != nil {
return err
}
return module.store.UpdateV2(ctx, orgID, id, updatedBy, storable.Data)
})
if err != nil {
return nil, err
}
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.sqlstore.RunInTxCtx(ctx, nil, 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)
})
}
// CreatePublicV2 is not supported in the community build.
func (module *module) CreatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.PostablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
// UpdatePublicV2 is not supported in the community build.
func (module *module) UpdatePublicV2(_ context.Context, _ valuer.UUID, _ valuer.UUID, _ dashboardtypes.UpdatablePublicDashboard) (*dashboardtypes.DashboardV2, error) {
return nil, errors.Newf(errors.TypeUnsupported, dashboardtypes.ErrCodePublicDashboardUnsupported, "not implemented")
}
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 {
return err
}
if err := existing.LockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
return module.store.LockUnlockV2(ctx, orgID, id, lock, updatedBy)
}
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,76 @@
package impltag
import (
"context"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type module struct {
store tagtypes.Store
}
func NewModule(store tagtypes.Store) tag.Module {
return &module{store: store}
}
func (m *module) SyncTags(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error) {
var tags []*tagtypes.Tag
err := m.store.RunInTx(ctx, func(ctx context.Context) error {
resolved, err := m.createMany(ctx, orgID, kind, postable)
if err != nil {
return err
}
tagIDs := make([]valuer.UUID, len(resolved))
for i, t := range resolved {
tagIDs[i] = t.ID
}
if err := m.syncLinksForResource(ctx, orgID, kind, resourceID, tagIDs); err != nil {
return err
}
tags = resolved
return nil
})
if err != nil {
return nil, err
}
return tags, nil
}
func (m *module) createMany(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, error) {
if len(postable) == 0 {
return []*tagtypes.Tag{}, nil
}
toCreate, matched, err := m.resolve(ctx, orgID, kind, postable)
if err != nil {
return nil, err
}
created, err := m.store.CreateOrGet(ctx, toCreate)
if err != nil {
return nil, err
}
return append(matched, created...), nil
}
func (m *module) syncLinksForResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) error {
return m.store.RunInTx(ctx, func(ctx context.Context) error {
if err := m.store.CreateRelations(ctx, tagtypes.NewTagRelations(kind, resourceID, tagIDs)); err != nil {
return err
}
return m.store.DeleteRelationsExcept(ctx, orgID, kind, resourceID, tagIDs)
})
}
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)
}
func (m *module) ListForResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
return m.store.ListByResources(ctx, orgID, kind, resourceIDs)
}

View File

@@ -0,0 +1,59 @@
package impltag
import (
"context"
"strings"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// resolve canonicalizes a batch of user-supplied (key, value) tag pairs against
// the existing tags for an org. Lookup is case-insensitive on both key and
// value (matching the storage uniqueness rule); when an existing row matches,
// its display casing is reused. Inputs are deduped on (LOWER(key), LOWER(value));
// the first input's casing wins on collisions. Returns:
// - toCreate: new Tag rows the caller should insert (with pre-generated IDs)
// - matched: existing rows the caller's input already pointed to. They
// already carry authoritative IDs from the store.
func (m *module) resolve(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, postable []tagtypes.PostableTag) ([]*tagtypes.Tag, []*tagtypes.Tag, error) {
if len(postable) == 0 {
return nil, nil, nil
}
existing, err := m.store.List(ctx, orgID, kind)
if err != nil {
return nil, nil, err
}
lowercaseTagsMap := make(map[string]*tagtypes.Tag, len(existing))
for _, t := range existing {
mapKey := strings.ToLower(t.Key) + "\x00" + strings.ToLower(t.Value)
lowercaseTagsMap[mapKey] = t
}
seenInRequestAlready := make(map[string]struct{}, len(postable)) // postable can have the same tag multiple times
toCreate := make([]*tagtypes.Tag, 0)
matched := make([]*tagtypes.Tag, 0)
for _, p := range postable {
key, value, err := tagtypes.ValidatePostableTag(p)
if err != nil {
return nil, nil, err
}
lookup := strings.ToLower(key) + "\x00" + strings.ToLower(value)
if _, dup := seenInRequestAlready[lookup]; dup {
continue
}
seenInRequestAlready[lookup] = struct{}{}
if existingTag, ok := lowercaseTagsMap[lookup]; ok {
matched = append(matched, existingTag)
continue
}
toCreate = append(toCreate, tagtypes.NewTag(orgID, kind, key, value))
}
return toCreate, matched, nil
}

View File

@@ -0,0 +1,112 @@
package impltag
import (
"context"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes/tagtypestest"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
var testKind = coretypes.KindDashboard
func TestModule_Resolve(t *testing.T) {
t.Run("empty input does not hit store", func(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, nil)
require.NoError(t, err)
assert.Empty(t, toCreate)
assert.Empty(t, matched)
assert.Zero(t, store.ListCallCount, "should not hit store when input is empty")
})
t.Run("creates missing pairs and reuses existing", func(t *testing.T) {
orgID := valuer.GenerateUUID()
dbTag := tagtypes.NewTag(orgID, testKind, "team", "Pulse")
dbTag2 := tagtypes.NewTag(orgID, testKind, "Database", "redis")
store := tagtypestest.NewStore()
store.Tags = []*tagtypes.Tag{dbTag, dbTag2}
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "events"}, // new
{Key: "DATABASE", Value: "REDIS"}, // case-only conflict
{Key: "Brand", Value: "New"}, // new
})
require.NoError(t, err)
createdLowerKVs := []string{}
for _, tg := range toCreate {
createdLowerKVs = append(createdLowerKVs, strings.ToLower(tg.Key)+"\x00"+strings.ToLower(tg.Value))
}
assert.ElementsMatch(t, []string{"team\x00events", "brand\x00new"}, createdLowerKVs,
"only the two missing pairs should be returned for insertion")
require.Len(t, matched, 1, "DATABASE:REDIS should hit the existing 'Database:redis' tag")
assert.Same(t, dbTag2, matched[0], "matched should return the existing pointer with its authoritative ID")
})
t.Run("dedupes inputs that map to the same lower(key)+lower(value)", func(t *testing.T) {
orgID := valuer.GenerateUUID()
store := tagtypestest.NewStore()
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "Foo", Value: "Bar"},
{Key: "foo", Value: "bar"},
{Key: "FOO", Value: "BAR"},
})
require.NoError(t, err)
require.Empty(t, matched)
require.Len(t, toCreate, 1, "duplicate inputs must collapse into a single insert")
assert.Equal(t, "Foo", toCreate[0].Key, "first input's casing wins")
assert.Equal(t, "Bar", toCreate[0].Value, "first input's casing wins")
})
t.Run("preserves existing casing on case-only match", func(t *testing.T) {
orgID := valuer.GenerateUUID()
dbTag := tagtypes.NewTag(orgID, testKind, "Team", "Pulse")
store := tagtypestest.NewStore()
store.Tags = []*tagtypes.Tag{dbTag}
m := &module{store: store}
toCreate, matched, err := m.resolve(context.Background(), orgID, testKind, []tagtypes.PostableTag{
{Key: "team", Value: "PULSE"},
})
require.NoError(t, err)
assert.Empty(t, toCreate)
require.Len(t, matched, 1)
assert.Equal(t, "Team", matched[0].Key)
assert.Equal(t, "Pulse", matched[0].Value)
})
t.Run("propagates validation error from any input", func(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
_, _, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, []tagtypes.PostableTag{
{Key: "team", Value: "pulse"},
{Key: "", Value: "x"},
})
require.Error(t, err)
})
t.Run("propagates regex validation error", func(t *testing.T) {
store := tagtypestest.NewStore()
m := &module{store: store}
_, _, err := m.resolve(context.Background(), valuer.GenerateUUID(), testKind, []tagtypes.PostableTag{
{Key: "team!eng", Value: "pulse"},
})
require.Error(t, err)
})
}

View File

@@ -0,0 +1,148 @@
package impltag
import (
"context"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
type store struct {
sqlstore sqlstore.SQLStore
}
func NewStore(sqlstore sqlstore.SQLStore) tagtypes.Store {
return &store{sqlstore: sqlstore}
}
func (s *store) List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*tagtypes.Tag, error) {
tags := make([]*tagtypes.Tag, 0)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&tags).
Where("org_id = ?", orgID).
Where("kind = ?", kind).
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) ListByResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*tagtypes.Tag, error) {
tags := make([]*tagtypes.Tag, 0)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&tags).
Join("JOIN tag_relation AS tr ON tr.tag_id = tag.id").
Where("tr.kind = ?", kind).
Where("tr.resource_id = ?", resourceID).
Where("tag.org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) ListByResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
if len(resourceIDs) == 0 {
return map[valuer.UUID][]*tagtypes.Tag{}, nil
}
type joinedRow struct {
tagtypes.Tag `bun:",extend"`
ResourceID valuer.UUID `bun:"resource_id"`
}
rows := make([]*joinedRow, 0)
err := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(&rows).
ColumnExpr("tag.*, tr.resource_id").
Join("JOIN tag_relation AS tr ON tr.tag_id = tag.id").
Where("tr.kind = ?", kind).
Where("tr.resource_id IN (?)", bun.In(resourceIDs)).
Where("tag.org_id = ?", orgID).
Scan(ctx)
if err != nil {
return nil, err
}
out := make(map[valuer.UUID][]*tagtypes.Tag)
for _, r := range rows {
tag := r.Tag
out[r.ResourceID] = append(out[r.ResourceID], &tag)
}
return out, nil
}
func (s *store) CreateOrGet(ctx context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
if len(tags) == 0 {
return tags, nil
}
// DO UPDATE on a self-set is a deliberate no-op write whose only purpose
// is to make RETURNING fire on conflicting rows. Without it, RETURNING is
// silent on the conflict path and we'd have to refetch by (key, value) to
// learn the existing rows' IDs after a concurrent-insert race. Setting
// key = tag.key (the existing row's value) preserves the first writer's
// casing on case-only collisions.
err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&tags).
On("CONFLICT (org_id, kind, (LOWER(key)), (LOWER(value))) DO UPDATE").
Set("key = tag.key").
Returning("*").
Scan(ctx)
if err != nil {
return nil, err
}
return tags, nil
}
func (s *store) CreateRelations(ctx context.Context, relations []*tagtypes.TagRelation) error {
if len(relations) == 0 {
return nil
}
_, err := s.sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(&relations).
On("CONFLICT (kind, resource_id, tag_id) DO NOTHING").
Exec(ctx)
return err
}
func (s *store) DeleteRelationsExcept(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, keepTagIDs []valuer.UUID) error {
// Scope the delete to the caller's org via a subquery on tag — bun's
// DELETE-with-JOIN syntax isn't uniformly portable across Postgres/SQLite.
tagIDsToDelete := s.sqlstore.
BunDBCtx(ctx).
NewSelect().
TableExpr("tag").
Column("id").
Where("org_id = ?", orgID)
if len(keepTagIDs) > 0 {
tagIDsToDelete = tagIDsToDelete.Where("id NOT IN (?)", bun.In(keepTagIDs))
}
_, err := s.sqlstore.
BunDBCtx(ctx).
NewDelete().
Model((*tagtypes.TagRelation)(nil)).
Where("kind = ?", kind).
Where("resource_id = ?", resourceID).
Where("tag_id IN (?)", tagIDsToDelete).
Exec(ctx)
return err
}
func (s *store) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return s.sqlstore.RunInTxCtx(ctx, nil, cb)
}

View File

@@ -0,0 +1,147 @@
package impltag
import (
"context"
"path/filepath"
"strings"
"testing"
"time"
"github.com/SigNoz/signoz/pkg/factory/factorytest"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlitesqlstore"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/uptrace/bun"
)
func newTestStore(t *testing.T) sqlstore.SQLStore {
t.Helper()
dbPath := filepath.Join(t.TempDir(), "test.db")
store, err := sqlitesqlstore.New(context.Background(), factorytest.NewSettings(), sqlstore.Config{
Provider: "sqlite",
Connection: sqlstore.ConnectionConfig{
MaxOpenConns: 1,
MaxConnLifetime: 0,
},
Sqlite: sqlstore.SqliteConfig{
Path: dbPath,
Mode: "wal",
BusyTimeout: 5 * time.Second,
TransactionMode: "deferred",
},
})
require.NoError(t, err)
_, err = store.BunDB().NewCreateTable().
Model((*tagtypes.Tag)(nil)).
IfNotExists().
Exec(context.Background())
require.NoError(t, err)
_, err = store.BunDB().Exec(`CREATE UNIQUE INDEX IF NOT EXISTS uq_tag_org_kind_lower_key_lower_value ON tag (org_id, kind, LOWER(key), LOWER(value))`)
require.NoError(t, err)
return store
}
var dashboardKind = coretypes.KindDashboard
func tagsByLowerKeyValue(t *testing.T, db *bun.DB) map[string]*tagtypes.Tag {
t.Helper()
all := make([]*tagtypes.Tag, 0)
require.NoError(t, db.NewSelect().Model(&all).Scan(context.Background()))
out := map[string]*tagtypes.Tag{}
for _, tag := range all {
out[strings.ToLower(tag.Key)+"\x00"+strings.ToLower(tag.Value)] = tag
}
return out
}
func TestStore_Create_PopulatesIDsOnFreshInsert(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
tagA := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
tagB := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
preIDA := tagA.ID
preIDB := tagB.ID
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{tagA, tagB})
require.NoError(t, err)
require.Len(t, got, 2)
// No race → pre-generated IDs stand. The slice is what we passed in,
// confirming Scan didn't reallocate.
assert.Equal(t, preIDA, got[0].ID)
assert.Equal(t, preIDB, got[1].ID)
// And the rows are in the DB.
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
require.Contains(t, stored, "tag\x00database")
require.Contains(t, stored, "team\x00blr")
assert.Equal(t, preIDA, stored["tag\x00database"].ID)
assert.Equal(t, preIDB, stored["team\x00blr"].ID)
}
func TestStore_Create_ConflictReturnsExistingRowID(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
// Simulate a concurrent insert: someone else has already inserted "tag:Database".
winner := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{winner})
require.NoError(t, err)
winnerID := winner.ID
// Now our request runs with a different pre-generated ID for the same
// (key, value) — case differs but the functional unique index collapses
// them. RETURNING should overwrite our stale ID with winner's ID.
loser := tagtypes.NewTag(orgID, dashboardKind, "TAG", "DATABASE")
loserPreID := loser.ID
require.NotEqual(t, winnerID, loserPreID, "pre-generated IDs must differ for this test to be meaningful")
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{loser})
require.NoError(t, err)
require.Len(t, got, 1)
assert.Equal(t, winnerID, got[0].ID, "returned slice should carry the existing row's ID, not our stale one")
assert.Equal(t, winnerID, loser.ID, "input slice element is mutated in place")
// And the DB still has exactly one row for that (lower(key), lower(value)) — winner's, with winner's casing.
stored := tagsByLowerKeyValue(t, sqlstore.BunDB())
require.Len(t, stored, 1)
assert.Equal(t, winnerID, stored["tag\x00database"].ID)
assert.Equal(t, "tag", stored["tag\x00database"].Key, "winner's casing preserved in key")
assert.Equal(t, "Database", stored["tag\x00database"].Value, "winner's casing preserved in value")
}
func TestStore_Create_MixedFreshAndConflict(t *testing.T) {
ctx := context.Background()
sqlstore := newTestStore(t)
s := NewStore(sqlstore)
orgID := valuer.GenerateUUID()
pre := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
_, err := s.CreateOrGet(ctx, []*tagtypes.Tag{pre})
require.NoError(t, err)
preExistingID := pre.ID
conflict := tagtypes.NewTag(orgID, dashboardKind, "tag", "Database")
fresh := tagtypes.NewTag(orgID, dashboardKind, "team", "BLR")
freshPreID := fresh.ID
got, err := s.CreateOrGet(ctx, []*tagtypes.Tag{conflict, fresh})
require.NoError(t, err)
require.Len(t, got, 2)
assert.Equal(t, preExistingID, got[0].ID, "conflicting row's ID overwritten with the existing row's")
assert.Equal(t, freshPreID, got[1].ID, "fresh row's pre-generated ID is preserved")
}

20
pkg/modules/tag/tag.go Normal file
View File

@@ -0,0 +1,20 @@
package tag
import (
"context"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Module interface {
// SyncTags resolves the given postable tags (creating new rows as needed)
// 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)
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.
ListForResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error)
}

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

@@ -17,6 +17,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization/implorganization"
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -45,7 +46,8 @@ func TestNewHandlers(t *testing.T) {
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), sqlstore, providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
@@ -53,8 +55,9 @@ func TestNewHandlers(t *testing.T) {
userRoleStore := impluser.NewUserRoleStore(sqlstore, providerSettings)
userGetter := impluser.NewGetter(impluser.NewStore(sqlstore, providerSettings), userRoleStore, flagger)
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, nil, nil, retentionGetter, flagger, tagModule)
querierHandler := querier.NewHandler(providerSettings, nil, nil)
registryHandler := factory.NewHandler(nil)

View File

@@ -45,6 +45,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/spanmapper/implspanmapper"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile"
"github.com/SigNoz/signoz/pkg/modules/spanpercentile/implspanpercentile"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracedetail/impltracedetail"
"github.com/SigNoz/signoz/pkg/modules/tracefunnel"
@@ -88,6 +89,7 @@ type Modules struct {
TraceDetail tracedetail.Module
SpanMapper spanmapper.Module
LLMPricingRule llmpricingrule.Module
Tag tag.Module
}
func NewModules(
@@ -113,6 +115,7 @@ func NewModules(
cloudIntegrationModule cloudintegration.Module,
retentionGetter retention.Getter,
fl flagger.Flagger,
tagModule tag.Module,
) Modules {
quickfilter := implquickfilter.NewModule(implquickfilter.NewStore(sqlstore))
orgSetter := implorganization.NewSetter(implorganization.NewStore(sqlstore), alertmanager, quickfilter)
@@ -145,5 +148,6 @@ func NewModules(
TraceDetail: impltracedetail.NewModule(impltracedetail.NewTraceStore(telemetryStore), providerSettings, config.TraceDetail),
SpanMapper: implspanmapper.NewModule(implspanmapper.NewStore(sqlstore)),
LLMPricingRule: impllmpricingrule.NewModule(impllmpricingrule.NewStore(sqlstore)),
Tag: tagModule,
}
}

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/modules/retention/implretention"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/sharder"
@@ -46,7 +47,8 @@ func TestNewModules(t *testing.T) {
emailing := emailingtest.New()
queryParser := queryparser.New(providerSettings)
require.NoError(t, err)
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), providerSettings, nil, orgGetter, queryParser)
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
dashboardModule := impldashboard.NewModule(impldashboard.NewStore(sqlstore), sqlstore, providerSettings, nil, orgGetter, queryParser, tagModule)
flagger, err := flagger.New(context.Background(), instrumentationtest.New().ToProviderSettings(), flagger.Config{}, flagger.MustNewRegistry())
require.NoError(t, err)
@@ -58,7 +60,8 @@ func TestNewModules(t *testing.T) {
serviceAccount := implserviceaccount.NewModule(implserviceaccount.NewStore(sqlstore), nil, nil, nil, providerSettings, serviceaccount.Config{})
retentionGetter := implretention.NewGetter(implretention.NewStore(sqlstore))
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, nil, nil, nil, nil, nil, nil, nil, queryParser, Config{}, dashboardModule, userGetter, userRoleStore, serviceAccount, implcloudintegration.NewModule(), retentionGetter, flagger, tagModule)
reflectVal := reflect.ValueOf(modules)
for i := 0; i < reflectVal.NumField(); i++ {

View File

@@ -200,6 +200,8 @@ func NewSQLMigrationProviderFactories(
sqlmigration.NewAddServiceAccountManagedRoleTransactionsFactory(sqlstore),
sqlmigration.NewAddSpanMapperFactory(sqlstore, sqlschema),
sqlmigration.NewAddLLMPricingRulesFactory(sqlstore, sqlschema),
sqlmigration.NewAddTagsFactory(sqlstore, sqlschema),
sqlmigration.NewAddPinnedDashboardFactory(sqlstore, sqlschema),
)
}

View File

@@ -32,6 +32,8 @@ import (
"github.com/SigNoz/signoz/pkg/modules/rulestatehistory"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount"
"github.com/SigNoz/signoz/pkg/modules/serviceaccount/implserviceaccount"
"github.com/SigNoz/signoz/pkg/modules/tag"
"github.com/SigNoz/signoz/pkg/modules/tag/impltag"
"github.com/SigNoz/signoz/pkg/modules/user/impluser"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/querier"
@@ -105,7 +107,7 @@ func New(
telemetrystoreProviderFactories factory.NamedMap[factory.ProviderFactory[telemetrystore.TelemetryStore, telemetrystore.Config]],
authNsCallback func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error),
authzCallback func(context.Context, sqlstore.SQLStore, authz.Config, licensing.Licensing, []authz.OnBeforeRoleDelete) (factory.ProviderFactory[authz.AuthZ, authz.Config], error),
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing) dashboard.Module,
dashboardModuleCallback func(sqlstore.SQLStore, factory.ProviderSettings, analytics.Analytics, organization.Getter, queryparser.QueryParser, querier.Querier, licensing.Licensing, tag.Module) dashboard.Module,
gatewayProviderFactory func(licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config],
auditorProviderFactories func(licensing.Licensing) factory.NamedMap[factory.ProviderFactory[auditor.Auditor, auditor.Config]],
meterReporterProviderFactories func(context.Context, factory.ProviderSettings, flagger.Flagger, licensing.Licensing, telemetrystore.TelemetryStore, retention.Getter, organization.Getter, zeus.Zeus) (factory.NamedMap[factory.ProviderFactory[meterreporter.Reporter, meterreporter.Config]], string),
@@ -332,8 +334,13 @@ func New(
// Initialize query parser (needed for dashboard module)
queryParser := queryparser.New(providerSettings)
// Initialize dashboard module
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing)
// Initialize tag module — shared across modules that link entities to tags
// (currently dashboard; future: alerts, RBAC). Built once here and injected
// where needed.
tagModule := impltag.NewModule(impltag.NewStore(sqlstore))
// Initialize dashboard module (needed for authz registry)
dashboard := dashboardModuleCallback(sqlstore, providerSettings, analytics, orgGetter, queryParser, querier, licensing, tagModule)
// Initialize user getter
userGetter := impluser.NewGetter(userStore, userRoleStore, flagger)
@@ -455,7 +462,7 @@ func New(
}
// Initialize all modules
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger)
modules := NewModules(sqlstore, tokenizer, emailing, providerSettings, orgGetter, alertmanager, analytics, querier, telemetrystore, telemetryMetadataStore, authNs, authz, cache, queryParser, config, dashboard, userGetter, userRoleStore, serviceAccount, cloudIntegrationModule, retentionGetter, flagger, tagModule)
// Initialize ruler from the variant-specific provider factories
rulerInstance, err := factory.NewProviderFromNamedMap(ctx, providerSettings, config.Ruler, rulerProviderFactories(cache, alertmanager, sqlstore, telemetrystore, telemetryMetadataStore, prometheus, orgGetter, modules.RuleStateHistory, querier, queryParser), "signoz")

View File

@@ -0,0 +1,71 @@
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() }()
// Composite PK on (user_id, dashboard_id) prevents accidental double-pins
// for the same user/dashboard pair. Only org_id carries an FK — user_id and
// dashboard_id mirror tag_relations and skip FKs because cascade deletes
// are disabled at the platform level (see tags spec).
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,114 @@
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 addTags struct {
sqlstore sqlstore.SQLStore
sqlschema sqlschema.SQLSchema
}
func NewAddTagsFactory(sqlstore sqlstore.SQLStore, sqlschema sqlschema.SQLSchema) factory.ProviderFactory[SQLMigration, Config] {
return factory.NewProviderFactory(factory.MustNewName("add_tags"), func(ctx context.Context, ps factory.ProviderSettings, c Config) (SQLMigration, error) {
return &addTags{
sqlstore: sqlstore,
sqlschema: sqlschema,
}, nil
})
}
func (migration *addTags) Register(migrations *migrate.Migrations) error {
return migrations.Register(migration.Up, migration.Down)
}
func (migration *addTags) Up(ctx context.Context, db *bun.DB) error {
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer func() {
_ = tx.Rollback()
}()
sqls := [][]byte{}
tagTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "tag",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "key", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "value", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "org_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "kind", 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"),
},
},
})
sqls = append(sqls, tagTableSQLs...)
// Case-insensitive uniqueness on (org_id, kind, key, value) — both Postgres
// and SQLite (modernc 3.50.x) support expression indexes.
tagUniqueIndexSQLs := migration.sqlschema.Operator().CreateIndex(
(&sqlschema.UniqueIndex{
TableName: "tag",
ColumnNames: []sqlschema.ColumnName{"org_id", "kind", "key", "value"},
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
}).Named("uq_tag_org_kind_lower_key_lower_value"),
)
sqls = append(sqls, tagUniqueIndexSQLs...)
tagRelationsTableSQLs := migration.sqlschema.Operator().CreateTable(&sqlschema.Table{
Name: "tag_relation",
Columns: []*sqlschema.Column{
{Name: "id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "kind", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "resource_id", DataType: sqlschema.DataTypeText, Nullable: false},
{Name: "tag_id", DataType: sqlschema.DataTypeText, Nullable: false},
},
PrimaryKeyConstraint: &sqlschema.PrimaryKeyConstraint{ColumnNames: []sqlschema.ColumnName{"id"}},
ForeignKeyConstraints: []*sqlschema.ForeignKeyConstraint{
{
ReferencingColumnName: sqlschema.ColumnName("tag_id"),
ReferencedTableName: sqlschema.TableName("tag"),
ReferencedColumnName: sqlschema.ColumnName("id"),
},
},
})
sqls = append(sqls, tagRelationsTableSQLs...)
tagRelationUniqueIndexSQLs := migration.sqlschema.Operator().CreateIndex(
&sqlschema.UniqueIndex{
TableName: "tag_relation",
ColumnNames: []sqlschema.ColumnName{"kind", "resource_id", "tag_id"},
},
)
sqls = append(sqls, tagRelationUniqueIndexSQLs...)
for _, sql := range sqls {
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
return err
}
}
return tx.Commit()
}
func (migration *addTags) Down(_ context.Context, _ *bun.DB) error {
return nil
}

View File

@@ -1,6 +1,8 @@
package sqlschema
import (
"fmt"
"hash/fnv"
"slices"
"strings"
@@ -49,9 +51,23 @@ type Index interface {
ToDropSQL(fmter SQLFormatter) []byte
}
// UniqueIndex models a unique index on a table.
//
// In the common case the index keys on plain columns: set only ColumnNames and
// the SQL is emitted with each column identifier-quoted by the formatter
// (`CREATE UNIQUE INDEX uq_t_a_b ON t (a, b)`).
//
// For functional indexes (e.g. case-insensitive uniqueness on `LOWER(col)`),
// set Expressions to the raw SQL parts and use ColumnNames as metadata for
// "which columns does this index touch". When Expressions is non-empty, it
// overrides ColumnNames for SQL emission — each entry is written verbatim, so
// the caller owns well-formedness — and the auto-generated name uses a hash
// suffix instead of a readable column join because expressions aren't valid
// identifier fragments.
type UniqueIndex struct {
TableName TableName
ColumnNames []ColumnName
Expressions []string
name string
}
@@ -71,16 +87,28 @@ func (index *UniqueIndex) Name() string {
}
b.WriteString(string(column))
}
if len(index.Expressions) > 0 {
if len(index.ColumnNames) > 0 {
b.WriteString("_")
}
hasher := fnv.New32a()
_, _ = hasher.Write([]byte(strings.Join(index.Expressions, "\x00")))
fmt.Fprintf(&b, "%08x", hasher.Sum32())
}
return b.String()
}
func (index *UniqueIndex) Named(name string) Index {
copyOfColumnNames := make([]ColumnName, len(index.ColumnNames))
copy(copyOfColumnNames, index.ColumnNames)
copyOfExpressions := make([]string, len(index.Expressions))
copy(copyOfExpressions, index.Expressions)
return &UniqueIndex{
TableName: index.TableName,
ColumnNames: copyOfColumnNames,
Expressions: copyOfExpressions,
name: name,
}
}
@@ -101,7 +129,18 @@ func (index *UniqueIndex) Equals(other Index) bool {
if other.Type() != IndexTypeUnique {
return false
}
otherUnique, ok := other.(*UniqueIndex)
if !ok {
return false
}
// Plain and functional indexes produce different SQL even if their column
// sets overlap; require both shapes to match.
if (len(index.Expressions) == 0) != (len(otherUnique.Expressions) == 0) {
return false
}
if len(index.Expressions) > 0 && !slices.Equal(index.Expressions, otherUnique.Expressions) {
return false
}
return index.Name() == other.Name() && slices.Equal(index.Columns(), other.Columns())
}
@@ -114,12 +153,20 @@ func (index *UniqueIndex) ToCreateSQL(fmter SQLFormatter) []byte {
sql = fmter.AppendIdent(sql, string(index.TableName))
sql = append(sql, " ("...)
for i, column := range index.ColumnNames {
if i > 0 {
sql = append(sql, ", "...)
if len(index.Expressions) > 0 {
for i, expr := range index.Expressions {
if i > 0 {
sql = append(sql, ", "...)
}
sql = append(sql, expr...)
}
} else {
for i, column := range index.ColumnNames {
if i > 0 {
sql = append(sql, ", "...)
}
sql = fmter.AppendIdent(sql, string(column))
}
sql = fmter.AppendIdent(sql, string(column))
}
sql = append(sql, ")"...)

View File

@@ -38,6 +38,43 @@ func TestIndexToCreateSQL(t *testing.T) {
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "my_index" ON "users" ("id", "name", "email")`,
},
{
name: "Unique_Functional_SingleExpression",
index: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Expressions: []string{"LOWER(email)"},
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_email_1e5a87f1" ON "users" (LOWER(email))`,
},
{
name: "Unique_Functional_MixedColumnsAndExpressions",
index: &UniqueIndex{
TableName: "tag",
ColumnNames: []ColumnName{"org_id", "kind", "key", "value"},
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_org_id_kind_key_value_57e8f81f" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
},
{
name: "Unique_Functional_ComplexExpression",
index: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"first_name", "last_name"},
Expressions: []string{"LOWER(TRIM(first_name) || ' ' || TRIM(last_name))"},
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_users_first_name_last_name_adb1ff53" ON "users" (LOWER(TRIM(first_name) || ' ' || TRIM(last_name)))`,
},
{
name: "Unique_Functional_Named",
index: &UniqueIndex{
TableName: "tag",
ColumnNames: []ColumnName{"org_id", "kind", "key", "value"},
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
name: "uq_tag_org_kind_lower_key_lower_value",
},
sql: `CREATE UNIQUE INDEX IF NOT EXISTS "uq_tag_org_kind_lower_key_lower_value" ON "tag" (org_id, kind, LOWER(key), LOWER(value))`,
},
{
name: "PartialUnique_1Column",
index: &PartialUniqueIndex{
@@ -229,6 +266,47 @@ func TestIndexEquals(t *testing.T) {
},
equals: false,
},
{
name: "Unique_Functional_Same",
a: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Expressions: []string{"LOWER(email)"},
},
b: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Expressions: []string{"LOWER(email)"},
},
equals: true,
},
{
name: "Unique_Functional_DifferentExpressions",
a: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Expressions: []string{"LOWER(email)"},
},
b: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Expressions: []string{"UPPER(email)"},
},
equals: false,
},
{
name: "Unique_Functional_NotEqualToPlainSameColumns",
a: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Expressions: []string{"LOWER(email)"},
},
b: &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
},
equals: false,
},
}
for _, testCase := range testCases {
@@ -238,6 +316,75 @@ func TestIndexEquals(t *testing.T) {
}
}
func TestUniqueIndexFunctionalName(t *testing.T) {
t.Run("autogen uses uq_<table>_<hash>", func(t *testing.T) {
idx := &UniqueIndex{
TableName: "tag",
ColumnNames: []ColumnName{"org_id", "kind", "key", "value"},
Expressions: []string{"org_id", "kind", "LOWER(key)", "LOWER(value)"},
}
assert.Equal(t, "uq_tag_org_id_kind_key_value_57e8f81f", idx.Name())
})
t.Run("same expressions produce the same name", func(t *testing.T) {
a := &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
}
b := &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
}
assert.Equal(t, a.Name(), b.Name())
})
t.Run("different expressions produce different names", func(t *testing.T) {
a := &UniqueIndex{
TableName: "users",
Expressions: []string{"LOWER(email)"},
}
b := &UniqueIndex{
TableName: "users",
Expressions: []string{"UPPER(email)"},
}
assert.NotEqual(t, a.Name(), b.Name())
})
t.Run("expressions in different order produce different names", func(t *testing.T) {
a := &UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "LOWER(key)"},
}
b := &UniqueIndex{
TableName: "tag",
Expressions: []string{"LOWER(key)", "org_id"},
}
assert.NotEqual(t, a.Name(), b.Name())
})
t.Run("functional autogen differs from plain autogen for same columns", func(t *testing.T) {
plain := &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
}
functional := &UniqueIndex{
TableName: "users",
ColumnNames: []ColumnName{"email"},
Expressions: []string{"LOWER(email)"},
}
assert.Equal(t, "uq_users_email", plain.Name())
assert.NotEqual(t, plain.Name(), functional.Name())
})
t.Run("Named() override wins over hash", func(t *testing.T) {
idx := (&UniqueIndex{
TableName: "tag",
Expressions: []string{"org_id", "LOWER(key)"},
}).Named("my_functional_index")
assert.Equal(t, "my_functional_index", idx.Name())
})
}
func TestPartialUniqueIndexName(t *testing.T) {
a := &PartialUniqueIndex{
TableName: "users",

View File

@@ -0,0 +1,124 @@
package dashboardtypes
import (
"strings"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
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 string
const (
ListSortUpdatedAt ListSort = "updated_at"
ListSortCreatedAt ListSort = "created_at"
ListSortName ListSort = "name"
)
type ListOrder string
const (
ListOrderAsc ListOrder = "asc"
ListOrderDesc ListOrder = "desc"
)
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. Lowercases sort/order so callers
// can pass them in any case.
func (p *ListDashboardsV2Params) Validate() error {
if p.Sort == "" {
p.Sort = ListSortUpdatedAt
} else {
p.Sort = ListSort(strings.ToLower(string(p.Sort)))
switch p.Sort {
case ListSortUpdatedAt, ListSortCreatedAt, ListSortName:
default:
return errors.NewInvalidInputf(ErrCodeDashboardListInvalid,
"invalid sort %q — expected one of: updated_at, created_at, name", p.Sort)
}
}
if p.Order == "" {
p.Order = ListOrderDesc
} else {
p.Order = ListOrder(strings.ToLower(string(p.Order)))
switch p.Order {
case ListOrderAsc, ListOrderDesc:
default:
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 gettableDashboardWithPin struct {
GettableDashboardV2
Pinned bool `json:"pinned"`
}
type ListableDashboardV2 struct {
Dashboards []*gettableDashboardWithPin `json:"dashboards"`
HasMore bool `json:"hasMore"`
}
// DashboardListRow is the per-row shape Store.ListV2 returns. Bundles the
// joined dashboard / public_dashboard / pinned_dashboard data so the module
// layer can attach tags and assemble the gettable view.
type DashboardListRow struct {
Dashboard *StorableDashboard
Public *StorablePublicDashboard // nil if no public_dashboard row exists
Pinned bool
}
func NewListableDashboardV2(rows []*DashboardListRow, tagsByEntity map[valuer.UUID][]*tagtypes.Tag, hasMore bool) (*ListableDashboardV2, error) {
dashboards := make([]*gettableDashboardWithPin, len(rows))
for i, r := range rows {
v2, err := NewDashboardV2FromStorable(r.Dashboard, r.Public, tagsByEntity[r.Dashboard.ID])
if err != nil {
return nil, err
}
dashboards[i] = &gettableDashboardWithPin{
GettableDashboardV2: *NewGettableDashboardV2FromDashboardV2(v2),
Pinned: r.Pinned,
}
}
return &ListableDashboardV2{
Dashboards: dashboards,
HasMore: hasMore,
}, 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
}
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
}
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", '$.data.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", '$.data.display.name') = ?`,
expectedArgs: []any{"something"},
},
{
subtestName: "name CONTAINS",
dslQueryToCompile: `name CONTAINS 'overview'`,
expectedSQL: `json_extract("dashboard"."data", '$.data.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", '$.data.display.name')) LIKE LOWER(?)`,
expectedArgs: []any{"Prod%"},
},
{
subtestName: "CONTAINS escapes % in user input",
dslQueryToCompile: `name CONTAINS '50%'`,
expectedSQL: `json_extract("dashboard"."data", '$.data.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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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", '$.data.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", '$.data.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", '$.data.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", '$.data.display.name') = ?)))`,
expectedArgs: []any{"foo"},
},
{
subtestName: "NOT on a tag equality",
dslQueryToCompile: `NOT team = 'pulse'`,
expectedSQL: `
NOT (
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
)
AND json_extract("dashboard"."data", '$.data.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", '$.data.display.name') LIKE ?
AND EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value LIKE ?
)
AND dashboard.created_by = ?
AND EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?)
)
AND EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_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_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value IN (?, ?, ?)
)
OR json_extract("dashboard"."data", '$.data.display.name') LIKE ?
)
AND (
EXISTS (
SELECT 1 FROM tag_relations tr
JOIN tag t ON t.id = tr.tag_id
WHERE tr.entity_type = 'dashboard' AND tr.entity_id = dashboard.id
AND LOWER(t.key) = LOWER(?)
AND t.value = ?
)
OR json_extract("dashboard"."data", '$.data.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,590 @@
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, "$.data.display.name")
case dashboardtypes.DSLKeyDescription:
return v.emitJSONStringComparison(ctx, op, "$.data.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")
}
const tagSubqueryPrefix = "SELECT 1 FROM tag_relations tr JOIN tag t ON t.id = tr.tag_id " +
"WHERE tr.entity_type = 'dashboard' AND tr.entity_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

@@ -0,0 +1,356 @@
package dashboardtypes
import (
"bytes"
"encoding/json"
"strings"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/swaggest/jsonschema-go"
jsonpatch "gopkg.in/evanphx/json-patch.v4"
)
const (
SchemaVersion = "v6"
MaxTagsPerDashboard = 5
)
type DSLKey string
const (
DSLKeyName DSLKey = "name"
DSLKeyDescription DSLKey = "description"
DSLKeyCreatedAt DSLKey = "created_at"
DSLKeyUpdatedAt DSLKey = "updated_at"
DSLKeyCreatedBy DSLKey = "created_by"
DSLKeyLocked DSLKey = "locked"
DSLKeyPublic DSLKey = "public"
)
// reservedDSLKeys are dashboard column-level filter names in the list-query DSL.
// A tag whose key collides with one of these would make the DSL ambiguous, so
// they're rejected (case-insensitively) at write time.
var reservedDSLKeys = map[DSLKey]struct{}{
DSLKeyName: {},
DSLKeyDescription: {},
DSLKeyCreatedAt: {},
DSLKeyUpdatedAt: {},
DSLKeyCreatedBy: {},
DSLKeyLocked: {},
DSLKeyPublic: {},
}
type DashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Info DashboardInfo `json:"info"`
PublicConfig *PublicDashboard `json:"publicConfig,omitempty"`
}
// DashboardInfo is the serializable view of a dashboard's contents — what the UI renders as "the dashboard JSON".
type DashboardInfo struct {
StoredDashboardInfo
Tags []*tagtypes.Tag `json:"tags,omitempty"`
}
// StoredDashboardInfo is exactly what serializes into the dashboard.data column.
type StoredDashboardInfo struct {
Metadata DashboardMetadata `json:"metadata"`
Data DashboardData `json:"data"`
}
type DashboardMetadata struct {
SchemaVersion string `json:"schemaVersion"`
Image string `json:"image,omitempty"`
UploadedGrafana bool `json:"uploadedGrafana"`
}
type PostableDashboardV2 struct {
StoredDashboardInfo
Tags []tagtypes.PostableTag `json:"tags,omitempty"`
}
type UpdateableDashboardV2 = PostableDashboardV2
// PatchableDashboardV2 is an RFC 6902 JSON Patch document applied against a
// PostableDashboardV2-shaped view of an existing dashboard. Patch ops can
// target any field — including individual entries inside `data.panels`,
// `data.panels.<id>.spec.queries`, or `tags` — without re-sending the rest of
// the dashboard.
type PatchableDashboardV2 struct {
patch jsonpatch.Patch
}
// JSONPatchDocument is the OpenAPI-facing schema for an RFC 6902 patch body.
// PatchableDashboardV2 has only an internal `jsonpatch.Patch` field, so the
// reflector would emit an empty schema; the handler def points at this type
// instead so consumers see the array-of-ops shape.
type JSONPatchDocument []JSONPatchOperation
// JSONPatchOperation is one RFC 6902 op. Not every field is valid on every
// op kind (e.g. `value` is required for add/replace/test, ignored for remove;
// `from` is required for move/copy) — the JSON Patch RFC governs that.
type JSONPatchOperation struct {
Op string `json:"op" required:"true"`
Path string `json:"path" required:"true" description:"JSON Pointer (RFC 6901) into the dashboard's postable shape — e.g. /data/display/name, /data/panels/<id>, /data/panels/<id>/spec/queries/0, /tags/-."`
Value any `json:"value,omitempty" description:"Value to add/replace/test against. The expected type depends on the path. Common shapes (see referenced schemas for the exact field set): /data/panels/<id> takes a DashboardtypesPanel; /data/panels/<id>/spec/queries/N (or /-) takes a DashboardtypesQuery; /data/variables/N takes a DashboardtypesVariable; /data/layouts/N takes a DashboardtypesLayout; /tags/N (or /-) takes a TagtypesPostableTag; /data/display/name and other leaf string fields take a string. Required for add/replace/test; ignored for remove/move/copy."`
From string `json:"from,omitempty" description:"Source JSON Pointer for move/copy ops; ignored for other ops."`
}
// PrepareJSONSchema constrains the `op` field to the six RFC 6902 verbs.
func (JSONPatchOperation) PrepareJSONSchema(s *jsonschema.Schema) error {
op, ok := s.Properties["op"]
if !ok || op.TypeObject == nil {
return errors.NewInternalf(errors.CodeInternal, "JSONPatchOperation schema missing `op` property")
}
op.TypeObject.WithEnum("add", "remove", "replace", "move", "copy", "test")
s.Properties["op"] = op
return nil
}
func (p *PatchableDashboardV2) UnmarshalJSON(data []byte) error {
patch, err := jsonpatch.DecodePatch(data)
if err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
p.patch = patch
return nil
}
// patchableDashboardV2View is the JSON shape a patch is applied against.
// It mirrors PostableDashboardV2 except `tags` is always emitted (even when
// empty) — RFC 6902 `add /tags/-` requires the array to exist in the target
// document, and PostableDashboardV2's own `omitempty` on tags would drop it.
type patchableDashboardV2View struct {
StoredDashboardInfo
Tags []tagtypes.PostableTag `json:"tags"`
}
// Apply runs the patch against the existing dashboard. The dashboard is
// projected into the postable JSON shape, the patch is applied, and the
// result is decoded back into an UpdateableDashboardV2 — which re-runs
// the full v2 validation chain.
func (p PatchableDashboardV2) Apply(existing *DashboardV2) (*UpdateableDashboardV2, error) {
base := patchableDashboardV2View{
StoredDashboardInfo: existing.Info.StoredDashboardInfo,
Tags: tagtypes.NewPostableTagsFromTags(existing.Info.Tags),
}
raw, err := json.Marshal(base)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal existing dashboard for patch")
}
patched, err := p.patch.Apply(raw)
if err != nil {
return nil, errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
out := &UpdateableDashboardV2{}
if err := json.Unmarshal(patched, out); err != nil {
return nil, err
}
return out, nil
}
func (p *PostableDashboardV2) UnmarshalJSON(data []byte) error {
dec := json.NewDecoder(bytes.NewReader(data))
dec.DisallowUnknownFields()
type alias PostableDashboardV2
var tmp alias
if err := dec.Decode(&tmp); err != nil {
return errors.WrapInvalidInputf(err, ErrCodeDashboardInvalidInput, "%s", err.Error())
}
*p = PostableDashboardV2(tmp)
return p.Validate()
}
func (p *PostableDashboardV2) Validate() error {
if p.Metadata.SchemaVersion != SchemaVersion {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "metadata.schemaVersion must be %q, got %q", SchemaVersion, p.Metadata.SchemaVersion)
}
if p.Data.Display == nil || p.Data.Display.Name == "" {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "data.display.name is required")
}
if err := p.validateTags(); err != nil {
return err
}
return p.Data.Validate()
}
func (p *PostableDashboardV2) validateTags() error {
if len(p.Tags) > MaxTagsPerDashboard {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "a dashboard can have at most %d tags", MaxTagsPerDashboard)
}
for _, tag := range p.Tags {
if _, reserved := reservedDSLKeys[DSLKey(strings.ToLower(strings.TrimSpace(tag.Key)))]; reserved {
return errors.NewInvalidInputf(ErrCodeDashboardInvalidInput, "tag key %q is reserved", tag.Key)
}
}
return nil
}
type GettableDashboardV2 struct {
types.Identifiable
types.TimeAuditable
types.UserAuditable
OrgID valuer.UUID `json:"orgId"`
Locked bool `json:"locked"`
Info GettableDashboardInfo `json:"info"`
PublicConfig *GettablePublicDasbhboard `json:"publicConfig,omitempty"`
}
type GettableDashboardInfo struct {
StoredDashboardInfo
Tags []*tagtypes.GettableTag `json:"tags,omitempty"`
}
func NewGettableDashboardV2FromDashboardV2(dashboard *DashboardV2) *GettableDashboardV2 {
gettable := &GettableDashboardV2{
Identifiable: dashboard.Identifiable,
TimeAuditable: dashboard.TimeAuditable,
UserAuditable: dashboard.UserAuditable,
OrgID: dashboard.OrgID,
Locked: dashboard.Locked,
Info: GettableDashboardInfo{
StoredDashboardInfo: dashboard.Info.StoredDashboardInfo,
Tags: tagtypes.NewGettableTagsFromTags(dashboard.Info.Tags),
},
}
if dashboard.PublicConfig != nil {
gettable.PublicConfig = NewGettablePublicDashboard(dashboard.PublicConfig)
}
return gettable
}
func NewDashboardV2(orgID valuer.UUID, createdBy string, postable PostableDashboardV2, resolvedTags []*tagtypes.Tag) *DashboardV2 {
now := time.Now()
return &DashboardV2{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{CreatedAt: now, UpdatedAt: now},
UserAuditable: types.UserAuditable{CreatedBy: createdBy, UpdatedBy: createdBy},
OrgID: orgID,
Locked: false,
Info: DashboardInfo{
StoredDashboardInfo: StoredDashboardInfo{
Metadata: postable.Metadata,
Data: postable.Data,
},
Tags: resolvedTags,
},
}
}
// rejects rows that don't carry a v2-shape blob — those are pre-migration v1 dashboards that the v2 API can't render.
func NewDashboardV2FromStorable(storable *StorableDashboard, public *StorablePublicDashboard, tags []*tagtypes.Tag) (*DashboardV2, error) {
metadata, _ := storable.Data["metadata"].(map[string]any)
if metadata == nil || metadata["schemaVersion"] != SchemaVersion {
return nil, errors.Newf(errors.TypeUnsupported, ErrCodeDashboardInvalidData, "dashboard %s is not in %s schema", storable.ID, SchemaVersion)
}
raw, err := json.Marshal(storable.Data)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal stored v2 dashboard data")
}
var stored StoredDashboardInfo
if err := json.Unmarshal(raw, &stored); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal stored v2 dashboard data")
}
var publicConfig *PublicDashboard
if public != nil {
publicConfig = NewPublicDashboardFromStorablePublicDashboard(public)
}
return &DashboardV2{
Identifiable: storable.Identifiable,
TimeAuditable: storable.TimeAuditable,
UserAuditable: storable.UserAuditable,
OrgID: storable.OrgID,
Locked: storable.Locked,
Info: DashboardInfo{
StoredDashboardInfo: stored,
Tags: tags,
},
PublicConfig: publicConfig,
}, nil
}
func (d *DashboardV2) CanLockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if d.CreatedBy != updatedBy && !isAdmin {
return errors.Newf(errors.TypeForbidden, errors.CodeForbidden, "you are not authorized to lock/unlock this dashboard")
}
if d.Locked == lock {
if lock {
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already locked")
}
return errors.Newf(errors.TypeAlreadyExists, errors.CodeAlreadyExists, "dashboard is already unlocked")
}
return nil
}
func (d *DashboardV2) LockUnlock(lock bool, isAdmin bool, updatedBy string) error {
if err := d.CanLockUnlock(lock, isAdmin, updatedBy); err != nil {
return err
}
d.Locked = lock
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
func (d *DashboardV2) CanUpdate() error {
if d.Locked {
return errors.Newf(errors.TypeInvalidInput, errors.CodeInvalidInput, "cannot update a locked dashboard, please unlock the dashboard to update")
}
return nil
}
func (d *DashboardV2) Update(updateable UpdateableDashboardV2, updatedBy string, resolvedTags []*tagtypes.Tag) error {
if err := d.CanUpdate(); err != nil {
return err
}
d.Info.Metadata = updateable.Metadata
d.Info.Data = updateable.Data
d.Info.Tags = resolvedTags
d.UpdatedBy = updatedBy
d.UpdatedAt = time.Now()
return nil
}
// ToStorableDashboard packages a Dashboard into the bun row that goes into
// the dashboard table. Tags are intentionally omitted — they live in
// tag_relations and are inserted separately by the caller.
func (d *DashboardV2) ToStorableDashboard() (*StorableDashboard, error) {
data, err := d.Info.toStorableDashboardData()
if err != nil {
return nil, err
}
return &StorableDashboard{
Identifiable: types.Identifiable{ID: d.ID},
TimeAuditable: d.TimeAuditable,
UserAuditable: d.UserAuditable,
OrgID: d.OrgID,
Locked: d.Locked,
Data: data,
}, nil
}
func (s StoredDashboardInfo) toStorableDashboardData() (StorableDashboardData, error) {
raw, err := json.Marshal(s)
if err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "marshal v2 dashboard data")
}
out := StorableDashboardData{}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, errors.WrapInternalf(err, errors.CodeInternal, "unmarshal v2 dashboard data")
}
return out, nil
}

View File

@@ -0,0 +1,552 @@
package dashboardtypes
import (
"encoding/json"
"strings"
"testing"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// basePostableJSON is the postable shape of a small but realistic v2
// dashboard used as the base document for patch tests. Each panel carries
// one builder query in the same shape production dashboards use
// (aggregations, filter, groupBy populated), and the dashboard has one
// variable — the variable is not patched in any test here, that's
// covered in a separate variable-focused suite.
const basePostableJSON = `{
"metadata": {"schemaVersion": "v6"},
"data": {
"display": {"name": "Service overview"},
"variables": [
{
"kind": "ListVariable",
"spec": {
"name": "service",
"allowAllValue": true,
"allowMultiple": false,
"plugin": {
"kind": "signoz/DynamicVariable",
"spec": {"name": "service.name", "signal": "metrics"}
}
}
}
],
"panels": {
"p1": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}],
"filter": {"expression": "service.name IN $service"},
"groupBy": [{"name": "service.name", "fieldDataType": "string", "fieldContext": "tag"}]
}}}
}
]
}
},
"p2": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/NumberPanel", "spec": {}},
"queries": [
{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "X",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_latency_count",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
]
}
}
},
"layouts": [
{
"kind": "Grid",
"spec": {
"display": {"title": "Row 1"},
"items": [
{"x": 0, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p1"}},
{"x": 6, "y": 0, "width": 6, "height": 6, "content": {"$ref": "#/spec/panels/p2"}}
]
}
}
],
"duration": "1h"
},
"tags": [{"key": "team", "value": "alpha"}, {"key": "env", "value": "prod"}]
}`
func TestPatchableDashboardV2_Apply(t *testing.T) {
// Apply doesn't mutate the input *DashboardV2 — it marshals it to
// JSON, applies the patch, and unmarshals the result into a fresh
// struct. Sharing one base across subtests is safe.
var p PostableDashboardV2
require.NoError(t, json.Unmarshal([]byte(basePostableJSON), &p), "base postable JSON must validate")
base := &DashboardV2{
Info: DashboardInfo{
StoredDashboardInfo: p.StoredDashboardInfo,
Tags: []*tagtypes.Tag{
{Key: "team", Value: "alpha"},
{Key: "env", Value: "prod"},
},
},
}
decode := func(t *testing.T, body string) PatchableDashboardV2 {
t.Helper()
var patch PatchableDashboardV2
require.NoError(t, json.Unmarshal([]byte(body), &patch))
return patch
}
// jsonOf marshals the patched dashboard back to JSON so subtests can
// assert on field values without reaching into the typed plugin specs.
jsonOf := func(t *testing.T, out *UpdateableDashboardV2) string {
t.Helper()
raw, err := json.Marshal(out)
require.NoError(t, err)
return string(raw)
}
// ─────────────────────────────────────────────────────────────────
// Successful patches
// ─────────────────────────────────────────────────────────────────
t.Run("no-op preserves all fields", func(t *testing.T) {
out, err := decode(t, `[]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, base.Info.Metadata, out.Metadata)
assert.Equal(t, base.Info.Data.Display.Name, out.Data.Display.Name)
require.Equal(t, len(base.Info.Data.Panels), len(out.Data.Panels))
for k, panel := range base.Info.Data.Panels {
require.Contains(t, out.Data.Panels, k)
assert.Equal(t, panel.Spec.Plugin.Kind, out.Data.Panels[k].Spec.Plugin.Kind)
}
assert.Len(t, out.Tags, len(base.Info.Tags))
assert.Len(t, out.Data.Variables, len(base.Info.Data.Variables))
assert.Len(t, out.Data.Layouts, len(base.Info.Data.Layouts))
})
t.Run("add metadata image", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/metadata/image", "value": "https://example.com/img.png"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "https://example.com/img.png", out.Metadata.Image)
assert.Equal(t, SchemaVersion, out.Metadata.SchemaVersion, "schemaVersion preserved")
})
t.Run("replace display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/display/name", "value": "Renamed"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Renamed", out.Data.Display.Name)
})
// Per RFC 6902 § 4.1, `add` on an existing object member replaces the
// existing value rather than erroring — same effect as `replace`.
t.Run("add overwrites existing display name", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/data/display/name", "value": "Overwritten"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Overwritten", out.Data.Display.Name)
})
t.Run("add data refreshInterval", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/data/refreshInterval", "value": "30s"}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "30s", string(out.Data.RefreshInterval))
})
t.Run("add panel leaves others untouched", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/data/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Data.Panels, 3)
assert.Contains(t, out.Data.Panels, "p1")
assert.Contains(t, out.Data.Panels, "p2")
assert.Contains(t, out.Data.Panels, "p3")
})
t.Run("replace single panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/BarChartPanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}]
}
}
}]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, PanelPluginKind("signoz/BarChartPanel"), out.Data.Panels["p2"].Spec.Plugin.Kind)
assert.Equal(t, PanelPluginKind("signoz/TimeSeriesPanel"), out.Data.Panels["p1"].Spec.Plugin.Kind, "p1 untouched")
})
// Removing a panel realistically also drops its layout item — exercise
// the multi-op shape the UI sends.
t.Run("remove panel and its layout item", func(t *testing.T) {
out, err := decode(t, `[
{"op": "remove", "path": "/data/panels/p2"},
{"op": "remove", "path": "/data/layouts/0/spec/items/1"}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Data.Panels, 1)
assert.Contains(t, out.Data.Panels, "p1")
assert.NotContains(t, out.Data.Panels, "p2")
raw := jsonOf(t, out)
assert.NotContains(t, raw, `"$ref":"#/spec/panels/p2"`)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p1"`)
})
// The headline use case: edit a single field of a single query inside
// one panel without re-sending any other part of the dashboard.
t.Run("rename single query inside panel", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p1/spec/queries/0/spec/plugin/spec/name",
"value": "renamed"
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Data.Panels["p1"].Spec.Queries, 1)
assert.Contains(t, jsonOf(t, out), `"name":"renamed"`)
})
// Replace a query at a specific index — swaps query "A" out for "B"
// without re-sending the rest of the panel.
t.Run("replace query at index", func(t *testing.T) {
out, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p1/spec/queries/0",
"value": {
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B",
"signal": "metrics",
"aggregations": [{
"metricName": "signoz_db_calls_total",
"temporality": "cumulative",
"timeAggregation": "rate",
"spaceAggregation": "sum"
}]
}}}
}
}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Data.Panels["p1"].Spec.Queries, 1)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"name":"B"`)
assert.NotContains(t, raw, `"name":"A"`)
})
// ─────────────────────────────────────────────────────────────────
// Layout edits
// ─────────────────────────────────────────────────────────────────
t.Run("move panel by editing layout x coordinate", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/layouts/0/spec/items/0/x", "value": 6}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
// The first item used to live at x=0, now lives at x=6.
assert.Contains(t, raw, `"x":6,"y":0,"width":6,"height":6,"content":{"$ref":"#/spec/panels/p1"}`)
})
t.Run("resize panel by editing layout width", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/layouts/0/spec/items/0/width", "value": 12}]`).Apply(base)
require.NoError(t, err)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"width":12`)
})
t.Run("rename layout row title", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/data/layouts/0/spec/display/title", "value": "Latency"}]`).Apply(base)
require.NoError(t, err)
assert.Contains(t, jsonOf(t, out), `"title":"Latency"`)
})
t.Run("append layout item", func(t *testing.T) {
out, err := decode(t, `[{
"op": "add",
"path": "/data/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p1"}}
}]`).Apply(base)
require.NoError(t, err)
// Item count went 2 → 3.
raw := jsonOf(t, out)
assert.Equal(t, 3, strings.Count(raw, `"$ref":"#/spec/panels/`))
})
// Composing add-panel + add-layout-item is the realistic shape of the
// "add a new chart to my dashboard" UI flow — exercise it end-to-end.
t.Run("add panel and corresponding layout item", func(t *testing.T) {
out, err := decode(t, `[
{
"op": "add",
"path": "/data/panels/p3",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TablePanel", "spec": {}},
"queries": [{
"kind": "TimeSeriesQuery",
"spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A",
"signal": "logs",
"aggregations": [{"expression": "count()"}]
}}}
}]
}
}
},
{
"op": "add",
"path": "/data/layouts/0/spec/items/-",
"value": {"x": 0, "y": 6, "width": 12, "height": 6, "content": {"$ref": "#/spec/panels/p3"}}
}
]`).Apply(base)
require.NoError(t, err)
assert.Len(t, out.Data.Panels, 3)
raw := jsonOf(t, out)
assert.Contains(t, raw, `"$ref":"#/spec/panels/p3"`)
})
t.Run("append tag", func(t *testing.T) {
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 3)
assert.Equal(t, "env", out.Tags[2].Key)
assert.Equal(t, "staging", out.Tags[2].Value)
})
t.Run("append tag when none exist", func(t *testing.T) {
noTagsBase := &DashboardV2{
Info: DashboardInfo{
StoredDashboardInfo: base.Info.StoredDashboardInfo,
Tags: nil,
},
}
out, err := decode(t, `[{"op": "add", "path": "/tags/-", "value": {"key": "team", "value": "new"}}]`).Apply(noTagsBase)
require.NoError(t, err)
require.Len(t, out.Tags, 1)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "new", out.Tags[0].Value)
})
t.Run("replace tag value", func(t *testing.T) {
out, err := decode(t, `[{"op": "replace", "path": "/tags/0/value", "value": "beta"}]`).Apply(base)
require.NoError(t, err)
require.Len(t, out.Tags, 2)
assert.Equal(t, "team", out.Tags[0].Key)
assert.Equal(t, "beta", out.Tags[0].Value)
assert.Equal(t, "env", out.Tags[1].Key, "tag at index 1 untouched")
assert.Equal(t, "prod", out.Tags[1].Value, "tag at index 1 untouched")
for _, tag := range out.Tags {
assert.NotEqual(t, "alpha", tag.Value, "old tag value must be gone")
}
})
t.Run("multiple ops applied in order", func(t *testing.T) {
out, err := decode(t, `[
{"op": "replace", "path": "/data/display/name", "value": "Multi-step"},
{"op": "remove", "path": "/data/panels/p2"},
{"op": "add", "path": "/tags/-", "value": {"key": "env", "value": "staging"}}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Multi-step", out.Data.Display.Name)
assert.Len(t, out.Data.Panels, 1)
assert.Len(t, out.Tags, 3)
})
// `test` is an RFC 6902 precondition op: aborts the patch if the value
// at the path doesn't equal the supplied value. Used for optimistic
// concurrency. Here it matches, so the subsequent ops apply.
t.Run("test op passes", func(t *testing.T) {
out, err := decode(t, `[
{"op": "test", "path": "/data/display/name", "value": "Service overview"},
{"op": "replace", "path": "/data/display/name", "value": "Confirmed"}
]`).Apply(base)
require.NoError(t, err)
assert.Equal(t, "Confirmed", out.Data.Display.Name)
})
// ─────────────────────────────────────────────────────────────────
// Failure cases
// ─────────────────────────────────────────────────────────────────
t.Run("decode rejects non-array body", func(t *testing.T) {
var patch PatchableDashboardV2
err := json.Unmarshal([]byte(`{"op": "replace"}`), &patch)
require.Error(t, err)
})
t.Run("decode rejects malformed JSON", func(t *testing.T) {
var patch PatchableDashboardV2
// Outer json.Unmarshal rejects non-JSON before PatchableDashboardV2's
// UnmarshalJSON runs, so the error is a stdlib SyntaxError rather
// than the InvalidInput-classified wrap.
err := json.Unmarshal([]byte(`not json`), &patch)
require.Error(t, err)
})
// `test` precondition fails — the whole patch is rejected, including
// the subsequent replace.
t.Run("test op failure rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "test", "path": "/data/display/name", "value": "Wrong"},
{"op": "replace", "path": "/data/display/name", "value": "Should not apply"}
]`).Apply(base)
require.Error(t, err)
})
t.Run("remove at missing path rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/data/panels/does-not-exist"}]`).Apply(base)
require.Error(t, err)
})
t.Run("remove schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "remove", "path": "/metadata/schemaVersion"}]`).Apply(base)
require.Error(t, err)
})
t.Run("wrong schemaVersion rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/metadata/schemaVersion", "value": "v5"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), SchemaVersion)
})
t.Run("empty display name rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "replace", "path": "/data/display/name", "value": ""}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "data.display.name is required")
})
t.Run("unknown top-level field rejected", func(t *testing.T) {
_, err := decode(t, `[{"op": "add", "path": "/bogus", "value": 42}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "bogus")
})
t.Run("invalid panel kind rejected", func(t *testing.T) {
_, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p1",
"value": {
"kind": "Panel",
"spec": {"plugin": {"kind": "signoz/NotAPanel", "spec": {}}}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "NotAPanel")
})
t.Run("query kind incompatible with panel rejected", func(t *testing.T) {
// PromQLQuery is not allowed on ListPanel — verify the cross-check
// in Validate still runs after a patch.
_, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p2",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/ListPanel", "spec": {}},
"queries": [{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/PromQLQuery", "spec": {"name": "A", "query": "up"}}}}]
}
}
}]`).Apply(base)
require.Error(t, err)
})
t.Run("removing the only query rejected", func(t *testing.T) {
// Validate requires exactly one query per panel — leaving zero is rejected.
_, err := decode(t, `[{"op": "remove", "path": "/data/panels/p2/spec/queries/0"}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("two direct queries rejected", func(t *testing.T) {
// Validate requires exactly one query per panel. To display multiple
// data sources in one panel, wrap them in a CompositeQuery (see the
// "replace query with composite" subtest below).
_, err := decode(t, `[{
"op": "replace",
"path": "/data/panels/p1",
"value": {
"kind": "Panel",
"spec": {
"plugin": {"kind": "signoz/TimeSeriesPanel", "spec": {}},
"queries": [
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "A", "signal": "metrics",
"aggregations": [{"metricName": "signoz_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}},
{"kind": "TimeSeriesQuery", "spec": {"plugin": {"kind": "signoz/BuilderQuery", "spec": {
"name": "B", "signal": "metrics",
"aggregations": [{"metricName": "signoz_db_calls_total", "temporality": "cumulative", "timeAggregation": "rate", "spaceAggregation": "sum"}]
}}}}
]
}
}
}]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "panel must have one query")
})
t.Run("too many tags rejected", func(t *testing.T) {
_, err := decode(t, `[
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "1"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "2"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "3"}},
{"op": "add", "path": "/tags/-", "value": {"key": "t", "value": "4"}}
]`).Apply(base)
require.Error(t, err)
require.Contains(t, err.Error(), "at most")
})
}

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,21 @@ type Store interface {
DeletePublic(context.Context, string) error
RunInTx(context.Context, func(context.Context) error) error
// ════════════════════════════════════════════════════════════════════════
// v2 dashboard methods
// ════════════════════════════════════════════════════════════════════════
GetV2(context.Context, valuer.UUID, valuer.UUID) (*StorableDashboard, *StorablePublicDashboard, error)
UpdateV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, updatedBy string, data StorableDashboardData) error
LockUnlockV2(ctx context.Context, orgID valuer.UUID, id valuer.UUID, locked bool, updatedBy string) error
// bool return is hasMore — the store fetches Limit+1 to detect it.
ListV2(ctx context.Context, orgID valuer.UUID, userID valuer.UUID, params *ListDashboardsV2Params) ([]*DashboardListRow, bool, 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
}

View File

@@ -0,0 +1,30 @@
package tagtypes
import (
"context"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Store interface {
List(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind) ([]*Tag, error)
ListByResource(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID) ([]*Tag, error)
ListByResources(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceIDs []valuer.UUID) (map[valuer.UUID][]*Tag, error)
// CreateOrGet upserts the given tags and returns them with authoritative IDs.
// On conflict on (org_id, kind, LOWER(key), LOWER(value)) — which
// happens only when a concurrent insert raced ours, including casing-only
// collisions — the returned entry carries the existing row's ID rather
// than the pre-generated one in the input.
CreateOrGet(ctx context.Context, tags []*Tag) ([]*Tag, error)
// CreateRelations inserts tag-resource relations. Conflicts on the composite primary key are ignored.
CreateRelations(ctx context.Context, relations []*TagRelation) error
DeleteRelationsExcept(ctx context.Context, orgID valuer.UUID, kind coretypes.Kind, resourceID valuer.UUID, keepTagIDs []valuer.UUID) error
RunInTx(ctx context.Context, cb func(ctx context.Context) error) error
}

113
pkg/types/tagtypes/tag.go Normal file
View File

@@ -0,0 +1,113 @@
package tagtypes
import (
"regexp"
"strings"
"time"
"unicode/utf8"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
const (
MAX_LEN_TAG_KEY = 32
MAX_LEN_TAG_VALUE = 32
)
var (
tagKeyRegex = regexp.MustCompile(`^[a-zA-Z$_@{#][a-zA-Z0-9$_@#{}:/-]*$`)
tagValueRegex = regexp.MustCompile(`^[a-zA-Z0-9$_@#{}:.+=/-]*$`)
ErrCodeTagInvalidKey = errors.MustNewCode("tag_invalid_key")
ErrCodeTagInvalidValue = errors.MustNewCode("tag_invalid_value")
ErrCodeTagNotFound = errors.MustNewCode("tag_not_found")
)
type Tag struct {
bun.BaseModel `bun:"table:tag,alias:tag"`
types.Identifiable
types.TimeAuditable
Key string `json:"key" required:"true" bun:"key,type:text,notnull"`
Value string `json:"value" required:"true" bun:"value,type:text,notnull"`
OrgID valuer.UUID `json:"orgId" required:"true" bun:"org_id,type:text,notnull"`
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
}
type PostableTag struct {
Key string `json:"key" required:"true"`
Value string `json:"value" required:"true"`
}
type GettableTag = PostableTag
func NewGettableTagFromTag(tag *Tag) *GettableTag {
return &GettableTag{Key: tag.Key, Value: tag.Value}
}
func NewGettableTagsFromTags(tags []*Tag) []*GettableTag {
out := make([]*GettableTag, len(tags))
for i, t := range tags {
out[i] = NewGettableTagFromTag(t)
}
return out
}
func NewPostableTagFromTag(tag *Tag) PostableTag {
return PostableTag{Key: tag.Key, Value: tag.Value}
}
func NewPostableTagsFromTags(tags []*Tag) []PostableTag {
out := make([]PostableTag, len(tags))
for i, t := range tags {
out[i] = NewPostableTagFromTag(t)
}
return out
}
func NewTag(orgID valuer.UUID, kind coretypes.Kind, key, value string) *Tag {
now := time.Now()
return &Tag{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
TimeAuditable: types.TimeAuditable{
CreatedAt: now,
UpdatedAt: now,
},
Key: key,
Value: value,
OrgID: orgID,
Kind: kind,
}
}
// ValidatePostableTag trims and validates a user-supplied (key, value) pair.
// Returns the cleaned values on success. Entity-specific reserved-key checks
// (e.g. dashboard column names that would collide with the list-query DSL) are
// the caller's responsibility — perform them before calling into the tag module.
func ValidatePostableTag(p PostableTag) (string, string, error) {
key := strings.TrimSpace(p.Key)
value := strings.TrimSpace(p.Value)
if key == "" {
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key cannot be empty")
}
if value == "" {
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value cannot be empty")
}
if !tagKeyRegex.MatchString(key) {
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key %q contains disallowed characters", key)
}
if !tagValueRegex.MatchString(value) {
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value %q contains disallowed characters", value)
}
if utf8.RuneCountInString(key) > MAX_LEN_TAG_KEY {
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidKey, "tag key %q exceeds the %d-character limit", key, MAX_LEN_TAG_KEY)
}
if utf8.RuneCountInString(value) > MAX_LEN_TAG_VALUE {
return "", "", errors.Newf(errors.TypeInvalidInput, ErrCodeTagInvalidValue, "tag value %q exceeds the %d-character limit", value, MAX_LEN_TAG_VALUE)
}
return key, value, nil
}

View File

@@ -0,0 +1,34 @@
package tagtypes
import (
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/uptrace/bun"
)
type TagRelation struct {
bun.BaseModel `bun:"table:tag_relation,alias:tag_relation"`
types.Identifiable
Kind coretypes.Kind `json:"kind" required:"true" bun:"kind,type:text,notnull"`
ResourceID valuer.UUID `json:"resourceId" required:"true" bun:"resource_id,type:text,notnull"`
TagID valuer.UUID `json:"tagId" required:"true" bun:"tag_id,type:text,notnull"`
}
func NewTagRelation(kind coretypes.Kind, resourceID valuer.UUID, tagID valuer.UUID) *TagRelation {
return &TagRelation{
Identifiable: types.Identifiable{ID: valuer.GenerateUUID()},
Kind: kind,
ResourceID: resourceID,
TagID: tagID,
}
}
func NewTagRelations(kind coretypes.Kind, resourceID valuer.UUID, tagIDs []valuer.UUID) []*TagRelation {
relations := make([]*TagRelation, 0, len(tagIDs))
for _, tagID := range tagIDs {
relations = append(relations, NewTagRelation(kind, resourceID, tagID))
}
return relations
}

View File

@@ -0,0 +1,69 @@
package tagtypes
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidatePostableTag(t *testing.T) {
tests := []struct {
name string
input PostableTag
wantKey string
wantValue string
wantError bool
}{
{name: "simple pair", input: PostableTag{Key: "team", Value: "pulse"}, wantKey: "team", wantValue: "pulse"},
{name: "preserves casing", input: PostableTag{Key: "Team", Value: "Pulse"}, wantKey: "Team", wantValue: "Pulse"},
{name: "trims key", input: PostableTag{Key: " team ", Value: "pulse"}, wantKey: "team", wantValue: "pulse"},
{name: "trims value", input: PostableTag{Key: "team", Value: " pulse "}, wantKey: "team", wantValue: "pulse"},
{name: "empty key rejected", input: PostableTag{Key: "", Value: "pulse"}, wantError: true},
{name: "empty value rejected", input: PostableTag{Key: "team", Value: ""}, wantError: true},
{name: "whitespace-only key rejected", input: PostableTag{Key: " ", Value: "pulse"}, wantError: true},
{name: "whitespace-only value rejected", input: PostableTag{Key: "team", Value: " "}, wantError: true},
{name: "slash accepted", input: PostableTag{Key: "team/eng", Value: "pulse/events"}, wantKey: "team/eng", wantValue: "pulse/events"},
{name: "colon accepted", input: PostableTag{Key: "team:eng", Value: "env:prod"}, wantKey: "team:eng", wantValue: "env:prod"},
{name: "extra punctuation accepted in both", input: PostableTag{Key: "a_b-c@d#e$f{g}h", Value: "a_b-c@d#e$f{g}h"}, wantKey: "a_b-c@d#e$f{g}h", wantValue: "a_b-c@d#e$f{g}h"},
// Key is strict; value allows the extra `. + =` plus leading digits.
{name: "dot in key rejected", input: PostableTag{Key: "team.eng", Value: "pulse"}, wantError: true},
{name: "dot in value accepted", input: PostableTag{Key: "team", Value: "pulse.events"}, wantKey: "team", wantValue: "pulse.events"},
{name: "plus in key rejected", input: PostableTag{Key: "team+eng", Value: "pulse"}, wantError: true},
{name: "plus in value accepted", input: PostableTag{Key: "team", Value: "a+b"}, wantKey: "team", wantValue: "a+b"},
{name: "equals in key rejected", input: PostableTag{Key: "team=eng", Value: "pulse"}, wantError: true},
{name: "equals in value accepted", input: PostableTag{Key: "team", Value: "a=b"}, wantKey: "team", wantValue: "a=b"},
{name: "leading digit in key rejected", input: PostableTag{Key: "2024team", Value: "pulse"}, wantError: true},
{name: "leading digit in value accepted", input: PostableTag{Key: "team", Value: "2024_team"}, wantKey: "team", wantValue: "2024_team"},
{name: "unicode letter in key rejected", input: PostableTag{Key: "チーム", Value: "pulse"}, wantError: true},
{name: "unicode letter in value rejected", input: PostableTag{Key: "team", Value: "東京"}, wantError: true},
{name: "internal space in key rejected", input: PostableTag{Key: "team eng", Value: "pulse"}, wantError: true},
{name: "internal space in value rejected", input: PostableTag{Key: "team", Value: "pulse two"}, wantError: true},
{name: "disallowed char in key rejected", input: PostableTag{Key: "team!eng", Value: "pulse"}, wantError: true},
{name: "disallowed char in value rejected", input: PostableTag{Key: "team", Value: "pulse!one"}, wantError: true},
{name: "control char rejected", input: PostableTag{Key: "team\tone", Value: "pulse"}, wantError: true},
{name: "key at the 32-char limit accepted", input: PostableTag{Key: "abcdefghijklmnopabcdefghijklmnop", Value: "pulse"}, wantKey: "abcdefghijklmnopabcdefghijklmnop", wantValue: "pulse"},
{name: "value at the 32-char limit accepted", input: PostableTag{Key: "team", Value: "abcdefghijklmnopabcdefghijklmnop"}, wantKey: "team", wantValue: "abcdefghijklmnopabcdefghijklmnop"},
{name: "key over the 32-char limit rejected", input: PostableTag{Key: "abcdefghijklmnopabcdefghijklmnopq", Value: "pulse"}, wantError: true},
{name: "value over the 32-char limit rejected", input: PostableTag{Key: "team", Value: "abcdefghijklmnopabcdefghijklmnopq"}, wantError: true},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
gotKey, gotValue, err := ValidatePostableTag(tc.input)
if tc.wantError {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.wantKey, gotKey)
assert.Equal(t, tc.wantValue, gotValue)
})
}
}

View File

@@ -0,0 +1,53 @@
package tagtypestest
import (
"context"
"github.com/SigNoz/signoz/pkg/types/coretypes"
"github.com/SigNoz/signoz/pkg/types/tagtypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
// MockStore is an in-memory tagtypes.MockStore implementation for tests. Most methods
// are inert no-ops; List returns the contents of Tags and increments
// ListCallCount so tests can assert on lookup behavior. Set Tags directly to
// preload fixtures.
type MockStore struct {
Tags []*tagtypes.Tag
ListCallCount int
}
func NewStore() *MockStore {
return &MockStore{}
}
func (s *MockStore) List(_ context.Context, _ valuer.UUID, _ coretypes.Kind) ([]*tagtypes.Tag, error) {
s.ListCallCount++
out := make([]*tagtypes.Tag, len(s.Tags))
copy(out, s.Tags)
return out, nil
}
func (s *MockStore) CreateOrGet(_ context.Context, tags []*tagtypes.Tag) ([]*tagtypes.Tag, error) {
return tags, nil
}
func (s *MockStore) CreateRelations(_ context.Context, _ []*tagtypes.TagRelation) error {
return nil
}
func (s *MockStore) ListByResource(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ valuer.UUID) ([]*tagtypes.Tag, error) {
return []*tagtypes.Tag{}, nil
}
func (s *MockStore) ListByResources(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ []valuer.UUID) (map[valuer.UUID][]*tagtypes.Tag, error) {
return map[valuer.UUID][]*tagtypes.Tag{}, nil
}
func (s *MockStore) DeleteRelationsExcept(_ context.Context, _ valuer.UUID, _ coretypes.Kind, _ valuer.UUID, _ []valuer.UUID) error {
return nil
}
func (s *MockStore) RunInTx(ctx context.Context, cb func(ctx context.Context) error) error {
return cb(ctx)
}

View File

@@ -0,0 +1,288 @@
import json
from collections.abc import Callable
from http import HTTPStatus
from pathlib import Path
import pytest
import requests
from fixtures.auth import USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD
from fixtures.types import Operation, SigNoz
_PERSES_FIXTURE = (
Path(__file__).parents[4]
/ "pkg/types/dashboardtypes/dashboardtypesv2/testdata/perses.json"
)
def _post_dashboard(signoz: SigNoz, token: str, body: dict) -> requests.Response:
return requests.post(
signoz.self.host_configs["8080"].get("/api/v2/dashboards"),
json=body,
headers={"Authorization": f"Bearer {token}"},
timeout=2,
)
def test_empty_body_rejected_for_missing_schema_version(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(signoz, admin_token, {})
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'metadata.schemaVersion must be "v6", got ""'
def test_missing_display_name_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(signoz, admin_token, {"metadata": {"schemaVersion": "v6"}})
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == "data.display.name is required"
def test_minimal_valid_body_creates_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "test name"}},
},
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "success"
data = body["data"]
assert data["info"]["data"]["display"]["name"] == "test name"
assert data["info"]["metadata"]["schemaVersion"] == "v6"
def test_unknown_root_field_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "test name"}},
"unknownfieldattheroot": "shouldgiveanerror",
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'json: unknown field "unknownfieldattheroot"'
def test_unknown_nested_field_rejected(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
response = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {
"display": {
"name": "test name",
"unknownfieldinside": "shouldgiveanerror",
},
},
},
)
assert response.status_code == HTTPStatus.BAD_REQUEST
body = response.json()
assert body["status"] == "error"
assert body["error"]["code"] == "dashboard_invalid_input"
assert body["error"]["message"] == 'json: unknown field "unknownfieldinside"'
def test_perses_fixture_creates_dashboard(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""The perses.json fixture is the kitchen-sink dashboard the schema tests
use; round-tripping it through the create API exercises the full plugin
surface end-to-end."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
data = json.loads(_PERSES_FIXTURE.read_text())
response = _post_dashboard(
signoz,
admin_token,
{"metadata": {"schemaVersion": "v6"}, "data": data},
)
assert response.status_code == HTTPStatus.CREATED
body = response.json()
assert body["status"] == "success"
assert body["data"]["info"]["data"]["display"]["name"] == data["display"]["name"]
def test_tag_casing_is_inherited_from_existing_parent(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
):
"""A second dashboard tagged with a sibling under a casing-variant parent
path should adopt the existing parent's casing while keeping the
user-supplied casing for the new leaf segment."""
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
first = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "dac"}},
"tags": [{"name": "engineering/US/NYC"}],
},
)
assert first.status_code == HTTPStatus.CREATED
first_tags = first.json()["data"]["info"]["tags"]
assert first_tags == [{"name": "engineering/US/NYC"}]
second = _post_dashboard(
signoz,
admin_token,
{
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": "dac"}},
"tags": [{"name": "engineering/us/SF"}],
},
)
assert second.status_code == HTTPStatus.CREATED
second_tags = second.json()["data"]["info"]["tags"]
assert second_tags == [{"name": "engineering/US/SF"}]
# ─── list filter DSL ─────────────────────────────────────────────────────────
# All fixtures carry a marker tag so each test query can be ANDed with
# `tag = '__lst_v2_filter_test'` server-side. That guarantees no leakage
# from any other test file/module sharing the session-scoped DB. The marker
# rules out a `tag NOT EXISTS` case (a fixture that's no-tags by design
# can't also carry the marker) — that operator is covered by the visitor
# unit tests in pkg/types/dashboardtypes/listfilter.
_LIST_FIXTURE_MARKER_TAG = "__lst_v2_filter_test"
_LIST_FIXTURE_DASHBOARDS = [
("lst-overview-prod", ["team/pulse", "prod", "team/frontend"]),
("lst-overview-dev", ["team/pulse", "dev"]),
("lst-database-prod", ["team/storage", "prod", "database/postgres"]),
("lst-database-test", ["team/storage", "test", "database/redis"]),
("lst-frontend-team", ["team/pulse", "team/frontend"]),
]
@pytest.fixture(name="list_fixture_dashboards", scope="module")
def list_fixture_dashboards(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
) -> None:
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
for name, tags in _LIST_FIXTURE_DASHBOARDS:
body: dict = {
"metadata": {"schemaVersion": "v6"},
"data": {"display": {"name": name}},
"tags": [{"name": t} for t in [*tags, _LIST_FIXTURE_MARKER_TAG]],
}
response = _post_dashboard(signoz, admin_token, body)
assert response.status_code == HTTPStatus.CREATED, response.text
@pytest.mark.parametrize(
("filter_query", "expected_names"),
[
(
"name = 'lst-overview-prod' AND tag = 'team/frontend'",
{"lst-overview-prod"},
),
(
"name LIKE 'database/%' AND tag = 'team/storage'",
{"lst-database-prod", "lst-database-test"},
),
(
"(name CONTAINS 'overview' OR name CONTAINS 'frontend') AND tag NOT IN ['dev']",
{"lst-overview-prod", "lst-frontend-team"},
),
(
"NOT tag = 'prod' AND name CONTAINS 'lst-'",
{"lst-overview-dev", "lst-database-test", "lst-frontend-team"},
),
(
"tag = 'team/pulse' AND tag != 'dev'",
{"lst-overview-prod", "lst-frontend-team"},
),
(
"tag IN ['dev', 'test'] OR name = 'lst-overview-prod'",
{"lst-overview-dev", "lst-database-test", "lst-overview-prod"},
),
(
"tag = 'team/pulse' AND tag LIKE 'database/%'",
{"lst-overview-dev", "lst-database-test", "lst-overview-prod"},
),
],
)
def test_list_v2_filter_dsl(
signoz: SigNoz,
create_user_admin: Operation, # pylint: disable=unused-argument
get_token: Callable[[str, str], str],
list_fixture_dashboards: None, # pylint: disable=unused-argument
filter_query: str,
expected_names: set[str],
):
admin_token = get_token(USER_ADMIN_EMAIL, USER_ADMIN_PASSWORD)
scoped_query = f"({filter_query}) AND tag = '{_LIST_FIXTURE_MARKER_TAG}'"
response = requests.get(
signoz.self.host_configs["8080"].get("/api/v2/dashboards"),
params={"query": scoped_query, "limit": 200},
headers={"Authorization": f"Bearer {admin_token}"},
timeout=2,
)
assert response.status_code == HTTPStatus.OK, response.text
body = response.json()
assert body["status"] == "success"
returned_names = {
d["info"]["data"]["display"]["name"] for d in body["data"]["dashboards"]
}
assert returned_names == expected_names