Compare commits

..

236 Commits

Author SHA1 Message Date
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
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
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
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
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
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
219 changed files with 6930 additions and 18129 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)

View File

@@ -213,7 +213,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.3
entrypoint:
- /bin/sh
command:
@@ -241,7 +241,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.3
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -139,7 +139,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.3
entrypoint:
- /bin/sh
command:
@@ -167,7 +167,7 @@ services:
replicas: 3
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.144.4
image: signoz/signoz-otel-collector:v0.144.3
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_CLUSTER=cluster

View File

@@ -204,7 +204,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -229,7 +229,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

View File

@@ -132,7 +132,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-otel-collector
entrypoint:
- /bin/sh
@@ -157,7 +157,7 @@ services:
- "4318:4318" # OTLP HTTP receiver
signoz-telemetrystore-migrator:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.4}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.144.3}
container_name: signoz-telemetrystore-migrator
environment:
- SIGNOZ_OTEL_COLLECTOR_CLICKHOUSE_DSN=tcp://clickhouse:9000

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,68 @@ 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) 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) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.Get(ctx, orgID, id)
}

View File

@@ -114,7 +114,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
// initiate agent config handler
agentConfMgr, err := agentConf.Initiate(&agentConf.ManagerOptions{
Store: signoz.SQLStore,
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController},
AgentFeatures: []agentConf.AgentFeature{logParsingPipelineController, signoz.Modules.LLMPricingRule},
})
if err != nil {
return nil, err

View File

@@ -497,8 +497,7 @@
"overrides": [
{
"files": [
"src/api/generated/**/*.ts",
"src/api/ai-assistant/**/*.ts"
"src/api/generated/**/*.ts"
],
"rules": {
"@typescript-eslint/explicit-function-return-type": "off",

View File

@@ -135,7 +135,6 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"remark-gfm": "^3.0.1",
"rollup-plugin-visualizer": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2",
@@ -174,18 +173,18 @@
"@commitlint/config-conventional": "20.4.4",
"@faker-js/faker": "9.3.0",
"@jest/globals": "30.2.0",
"@jest/types": "30.2.0",
"@testing-library/jest-dom": "5.16.5",
"@testing-library/react": "13.4.0",
"@testing-library/user-event": "14.4.3",
"@types/color": "^3.0.3",
"@types/crypto-js": "4.2.2",
"@types/d3-hierarchy": "1.1.11",
"@types/dompurify": "^2.4.0",
"@types/event-source-polyfill": "^1.0.0",
"@types/d3-hierarchy": "1.1.11",
"@types/fontfaceobserver": "2.1.0",
"@types/history": "4.7.11",
"@types/jest": "30.0.0",
"@jest/types": "30.2.0",
"@types/lodash-es": "^4.17.4",
"@types/mini-css-extract-plugin": "^2.5.1",
"@types/node": "^16.10.3",

View File

@@ -340,9 +340,6 @@ importers:
rehype-raw:
specifier: 7.0.0
version: 7.0.0
remark-gfm:
specifier: ^3.0.1
version: 3.0.1
rollup-plugin-visualizer:
specifier: 7.0.0
version: 7.0.0(rolldown@1.0.0-beta.53)
@@ -1907,89 +1904,105 @@ packages:
resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-arm@1.2.4':
resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-ppc64@1.2.4':
resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-riscv64@1.2.4':
resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-s390x@1.2.4':
resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linux-x64@1.2.4':
resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-libvips-linuxmusl-arm64@1.2.4':
resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-libvips-linuxmusl-x64@1.2.4':
resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-linux-arm64@0.34.5':
resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-arm@0.34.5':
resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm]
os: [linux]
libc: [glibc]
'@img/sharp-linux-ppc64@0.34.5':
resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-riscv64@0.34.5':
resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@img/sharp-linux-s390x@0.34.5':
resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@img/sharp-linux-x64@0.34.5':
resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@img/sharp-linuxmusl-arm64@0.34.5':
resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@img/sharp-linuxmusl-x64@0.34.5':
resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==}
engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@img/sharp-wasm32@0.34.5':
resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==}
@@ -2344,48 +2357,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.47.0':
resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.47.0':
resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.47.0':
resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.47.0':
resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.47.0':
resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.47.0':
resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.47.0':
resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.47.0':
resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==}
@@ -2488,48 +2509,56 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.62.0':
resolution: {integrity: sha512-8eCy3FCDuWUM5hWujAv6heMvfZPbcCOU3SdQUAkixZLu5bSzOkNfirJiLGoQFO943xceOKkiQRMQNzH++jM3WA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.62.0':
resolution: {integrity: sha512-NjQ7K7tpTPDe9J+yq8p/s/J0E7lRCkK2uDBDqvT4XIT6f4Z0tlnr59OBg/WcrmVHER1AbrcfyxhGTXgcG8ytWg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.62.0':
resolution: {integrity: sha512-oKZed9gmSwze29dEt3/Wnsv6l/Ygw/FUst+8Kfpv2SGeS/glEoTGZAMQw37SVyzFV76UTHJN2snGgxK2t2+8ow==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.62.0':
resolution: {integrity: sha512-gBjBxQ+9lGpAYq+ELqw0w8QXsBnkZclFc7GRX2r0LnEVn3ZTEqeIKpKcGjucmp76Q53bvJD0i4qBWBhcfhSfGA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.62.0':
resolution: {integrity: sha512-Ew2Kxs9EQ9/mbAIJ2hvocMC0wsOu6YKzStI2eFBDt+Td5O8seVC/oxgRIHqCcl5sf5ratA1nozQBAuv7tphkHg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.62.0':
resolution: {integrity: sha512-5z25jcAA0gfKyVwz71A0VXgaPlocPoTAxhlv/hgoK6tlCrfoNuw7haWbDHvGMfjXhdic4EqVXGRv5XsTqFnbRQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.62.0':
resolution: {integrity: sha512-IWpHmMB6ZDllPvqWDkG6AmXrN7JF5e/c4g/0PuURsmlK+vHoYZPB70rr4u1bn3I4LsKCSpqqfveyx6UCOC8wdg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.62.0':
resolution: {integrity: sha512-fjlSxxrD5pA594vkyikCS9MnPRjQawW6/BLgyTYkO+73wwPlYjkcZ7LSd974l0Q2zkHQmu4DPvJFLYA7o8xrxQ==}
@@ -2584,36 +2613,42 @@ packages:
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm-musl@2.5.1':
resolution: {integrity: sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==}
engines: {node: '>= 10.0.0'}
cpu: [arm]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-arm64-glibc@2.5.1':
resolution: {integrity: sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-arm64-musl@2.5.1':
resolution: {integrity: sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==}
engines: {node: '>= 10.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
'@parcel/watcher-linux-x64-glibc@2.5.1':
resolution: {integrity: sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
'@parcel/watcher-linux-x64-musl@2.5.1':
resolution: {integrity: sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==}
engines: {node: '>= 10.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
'@parcel/watcher-win32-arm64@2.5.1':
resolution: {integrity: sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==}
@@ -3474,24 +3509,28 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-bGe5EBB8FVjHBR1mOLOPEFg1Lp3//7geqWkU5NIhxe+yH0W8FVrQ6WRYOap4SUTKdklD/dC4qPLREkMMQ855FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-x64-gnu@1.0.0-beta.53':
resolution: {integrity: sha512-qL+63WKVQs1CMvFedlPt0U9PiEKJOAL/bsHMKUDS6Vp2Q+YAv/QLPu8rcvkfIMvQ0FPU2WL0aX4eWwF6e/GAnA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.0.0-beta.53':
resolution: {integrity: sha512-VGl9JIGjoJh3H8Mb+7xnVqODajBmrdOOb9lxWXdcmxyI+zjB2sux69br0hZJDTyLJfvBoYm439zPACYbCjGRmw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.0.0-beta.53':
resolution: {integrity: sha512-B4iIserJXuSnNzA5xBLFUIjTfhNy7d9sq4FUMQY3GhQWGVhS2RWWzzDnkSU6MUt7/aHUrep0CdQfXUJI9D3W7A==}
@@ -4266,41 +4305,49 @@ packages:
resolution: {integrity: sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-arm64-musl@1.11.1':
resolution: {integrity: sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==}
cpu: [arm64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-ppc64-gnu@1.11.1':
resolution: {integrity: sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-gnu@1.11.1':
resolution: {integrity: sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-riscv64-musl@1.11.1':
resolution: {integrity: sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-linux-s390x-gnu@1.11.1':
resolution: {integrity: sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-gnu@1.11.1':
resolution: {integrity: sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==}
cpu: [x64]
os: [linux]
libc: [glibc]
'@unrs/resolver-binding-linux-x64-musl@1.11.1':
resolution: {integrity: sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==}
cpu: [x64]
os: [linux]
libc: [musl]
'@unrs/resolver-binding-wasm32-wasi@1.11.1':
resolution: {integrity: sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==}
@@ -4367,7 +4414,7 @@ packages:
resolution: {integrity: sha512-VIcFLdRi/VYRU8OL/puL7QXMYafHmqOnwTZY50U1JPlCNj30PxCMx65c494b1K9be9hX83KVt0+gTEwTWLqToA==}
engines: {node: ^20.19.0 || >=22.12.0}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
'@webassemblyjs/ast@1.14.1':
resolution: {integrity: sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==}
@@ -7194,24 +7241,28 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.31.1:
resolution: {integrity: sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.31.1:
resolution: {integrity: sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.31.1:
resolution: {integrity: sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.31.1:
resolution: {integrity: sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w==}
@@ -10239,7 +10290,7 @@ packages:
oxlint: '>=1'
stylelint: '>=16'
typescript: '*'
vite: npm:rolldown-vite@7.3.1
vite: '>=5.4.21'
vls: '*'
vti: '*'
vue-tsc: ~2.2.10 || ^3.0.0
@@ -10268,12 +10319,12 @@ packages:
vite-plugin-compression@0.5.1:
resolution: {integrity: sha512-5QJKBDc+gNYVqL/skgFAP81Yuzo9R+EAf19d+EtsMF/i8kFUpNi3J/H01QD3Oo8zBQn+NzoCIFkpPLynoOzaJg==}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: '>=2.0.0'
vite-plugin-html@3.2.2:
resolution: {integrity: sha512-vb9C9kcdzcIo/Oc3CLZVS03dL5pDlOFuhGlZYDCJ840BhWl/0nGeZWf3Qy7NlOayscY4Cm/QRgULCQkEZige5Q==}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: '>=2.0.0'
vite-plugin-image-optimizer@2.0.3:
resolution: {integrity: sha512-1vrFOTcpSvv6DCY7h8UXab4wqMAjTJB/ndOzG/Kmj1oDOuPF6mbjkNQoGzzCEYeWGe7qU93jc8oQqvoJ57al3A==}
@@ -10281,7 +10332,7 @@ packages:
peerDependencies:
sharp: '>=0.34.0'
svgo: '>=4'
vite: npm:rolldown-vite@7.3.1
vite: '>=5'
peerDependenciesMeta:
sharp:
optional: true
@@ -10291,7 +10342,7 @@ packages:
vite-tsconfig-paths@6.1.1:
resolution: {integrity: sha512-2cihq7zliibCCZ8P9cKJrQBkfgdvcFkOOc3Y02o3GWUDLgqjWsZudaoiuOwO/gzTzy17cS5F7ZPo4bsnS4DGkg==}
peerDependencies:
vite: npm:rolldown-vite@7.3.1
vite: '*'
void-elements@3.1.0:
resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==}

View File

@@ -17,12 +17,6 @@ registered_languages=$(grep -oP "registerLanguage\('\K[^']+" "$SYNTAX_HIGHLIGHTE
missing_languages=()
for lang in $md_languages; do
# Skip ai-* block markers — these are custom AI block types rendered by
# RichCodeBlock as React components (e.g. ActionBlock, LineChartBlock),
# not real syntax languages, so they don't need highlighter registration.
if [[ "$lang" == ai-* ]]; then
continue
fi
if ! echo "$registered_languages" | grep -qx "$lang"; then
missing_languages+=("$lang")
fi

View File

@@ -8,7 +8,6 @@ import { LOCALSTORAGE } from 'constants/localStorage';
import { ORG_PREFERENCES } from 'constants/orgPreferences';
import ROUTES from 'constants/routes';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { isEmpty } from 'lodash-es';
import { useAppContext } from 'providers/App/App';
import { LicensePlatform, LicenseState } from 'types/api/licensesV3/getActive';
@@ -41,8 +40,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
} = useAppContext();
const isAdmin = user.role === USER_ROLES.ADMIN;
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const mapRoutes = useMemo(
() =>
new Map(
@@ -102,10 +99,6 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
return <>{children}</>;
}
if (pathname.startsWith('/ai-assistant/') && !isAIAssistantEnabled) {
return <Redirect to={ROUTES.HOME} />;
}
// Check for workspace access restriction (cloud only)
const isCloudPlatform = activeLicense?.platform === LicensePlatform.CLOUD;

View File

@@ -164,17 +164,14 @@ function createMockAppContext(
featureFlags: [],
orgPreferences: createMockOrgPreferences(),
userPreferences: [],
hostsData: null,
isLoggedIn: true,
org: [{ createdAt: 0, id: 'org-id', displayName: 'Test Org' }],
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingHosts: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: null,
activeLicenseFetchError: null,
hostsFetchError: null,
featureFlagsFetchError: null,
orgPreferencesFetchError: null,
changelog: null,

View File

@@ -18,7 +18,6 @@ import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { useIsDarkMode, useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { NotificationProvider } from 'hooks/useNotifications';
@@ -61,21 +60,13 @@ function App(): JSX.Element {
org,
} = useAppContext();
const [routes, setRoutes] = useState<AppRoutes[]>(defaultRoutes);
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const { hostname } = window.location;
const [pathname, setPathname] = useState(history.location.pathname);
const { hostname, pathname } = window.location;
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
useEffect(() => {
return history.listen((location) => {
setPathname(location.pathname);
});
}, []);
const enableAnalytics = useCallback(
(user: IUser): void => {
// wait for the required data to be loaded before doing init for anything!
@@ -221,27 +212,6 @@ function App(): JSX.Element {
activeLicenseFetchError,
]);
useEffect(() => {
if (!isLoggedInState) {
return;
}
setRoutes((prev) => {
const hasAi = prev.some((r) => r.path === ROUTES.AI_ASSISTANT);
if (isAIAssistantEnabled === hasAi) {
return prev;
}
if (isAIAssistantEnabled) {
const aiRoute = defaultRoutes.find((r) => r.path === ROUTES.AI_ASSISTANT);
if (!aiRoute) {
return prev;
}
return [...prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT), aiRoute];
}
return prev.filter((r) => r.path !== ROUTES.AI_ASSISTANT);
});
}, [isLoggedInState, isAIAssistantEnabled]);
const isDarkMode = useIsDarkMode();
useEffect(() => {
@@ -251,8 +221,7 @@ function App(): JSX.Element {
useEffect(() => {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/') ||
pathname.startsWith('/ai-assistant/')
pathname.startsWith('/public/dashboard/')
) {
window.Pylon?.('hideChatBubble');
} else {

View File

@@ -324,10 +324,3 @@ export const MeterExplorerPage = Loadable(
() =>
import(/* webpackChunkName: "Meter Explorer Page" */ 'pages/MeterExplorer'),
);
export const AIAssistantPage = Loadable(
() =>
import(
/* webpackChunkName: "AI Assistant Page" */ 'pages/AIAssistantPage/AIAssistantPage'
),
);

View File

@@ -2,7 +2,6 @@ import { RouteProps } from 'react-router-dom';
import ROUTES from 'constants/routes';
import {
AIAssistantPage,
AlertHistory,
AlertOverview,
AlertTypeSelectionPage,
@@ -508,13 +507,6 @@ const routes: AppRoutes[] = [
key: 'API_MONITORING',
isPrivate: true,
},
{
path: ROUTES.AI_ASSISTANT,
exact: true,
component: AIAssistantPage,
key: 'AI_ASSISTANT',
isPrivate: true,
},
];
export const SUPPORT_ROUTE: AppRoutes = {

View File

@@ -1,80 +0,0 @@
import axios, { InternalAxiosRequestConfig } from 'axios';
import {
interceptorRejected,
interceptorsRequestBasePath,
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
/** Path-only base for the AI Assistant API. */
export const AI_API_PATH = '/api/v1/assistant';
/** Header that tells the AI backend which SigNoz instance to query against. */
export const SIGNOZ_URL_HEADER = 'X-SigNoz-URL';
/**
* Sets `X-SigNoz-URL` on every outgoing AI Assistant request. The backend
* needs the originating SigNoz instance URL for multi-tenant deployments;
* when omitted it falls back to its `SIGNOZ_API_URL` env var.
*/
export const interceptorsRequestSigNozUrl = (
value: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig => {
if (value.headers) {
value.headers[SIGNOZ_URL_HEADER] = getSigNozInstanceUrl();
}
return value;
};
/**
* AI backend URL — sourced from the global config's `ai_assistant_url` field
* at runtime. `useIsAIAssistantEnabled` keeps this in sync via `setAIBackendUrl`
* whenever the config response changes; consumers (the axios instance and the
* SSE fetch path) read it lazily so they always see the current value.
*/
let aiBackendUrl: string | null = null;
export function setAIBackendUrl(url: string | null): void {
if (aiBackendUrl === url) {
return;
}
aiBackendUrl = url;
AIAssistantInstance.defaults.baseURL = url ? `${url}${AI_API_PATH}` : '';
}
/**
* Full base URL for the AI Assistant API (host + path). Throws when the
* config hasn't yet provided a URL — should never happen in practice
* because `useIsAIAssistantEnabled` gates every consumer surface.
*/
export function getAIBaseUrl(): string {
if (!aiBackendUrl) {
throw new Error('AI assistant URL is not configured.');
}
return `${aiBackendUrl}${AI_API_PATH}`;
}
/**
* Dedicated axios instance for the AI Assistant.
*
* Mirrors the request/response interceptor stack of the main SigNoz axios
* instance — most importantly `interceptorRejected`, which transparently
* rotates the access token via `/sessions/rotate` on a 401 and replays the
* original request. That's why we don't need any AI-specific 401 handling
* for REST calls: this instance inherits the same flow as the rest of the
* app for free.
*
* Only the SSE stream (`streamEvents`) still needs raw fetch since axios
* doesn't expose `ReadableStream` — that path keeps its own auth wrapper.
*/
export const AIAssistantInstance = axios.create({});
AIAssistantInstance.interceptors.request.use(interceptorsRequestResponse);
AIAssistantInstance.interceptors.request.use(interceptorsRequestBasePath);
AIAssistantInstance.interceptors.request.use(interceptorsRequestSigNozUrl);
AIAssistantInstance.interceptors.response.use(
interceptorsResponse,
interceptorRejected,
);

View File

@@ -1,543 +0,0 @@
/**
* AI Assistant API client.
*
* Flow:
* 1. POST /api/v1/assistant/threads → { threadId }
* 2. POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
* 3. GET /api/v1/assistant/executions/{executionId}/events → SSE stream (closes on 'done')
*
* For subsequent messages in the same thread, repeat steps 23.
* Approval/clarification events pause the stream; use approveExecution/clarifyExecution
* to resume, which each return a new executionId to open a fresh SSE stream.
*
* Types in this file re-use the OpenAPI-generated DTOs in
* `src/api/ai-assistant/sigNozAIAssistantAPI.schemas.ts`.
* Local types are defined only when the UI needs a different shape — for
* example, the SSE event union adds a literal `type` discriminator that the
* generated event DTOs leave loose.
*
* REST calls go through `AIAssistantInstance` (an axios instance configured
* with the same interceptor stack as the rest of the app) — that gives them
* automatic 401-then-rotate behaviour for free. Only the SSE call is still
* a raw `fetch` because axios doesn't expose `ReadableStream`; that one
* path gets its own small auth wrapper.
*/
import axios from 'axios';
import getLocalStorageApi from 'api/browser/localstorage/get';
import { Logout } from 'api/utils';
import rotateSession from 'api/v2/sessions/rotate/post';
import afterLogin from 'AppRoutes/utils';
import type {
ActionResultResponseDTO,
ApprovalEventDTO,
ApproveResponseDTO,
CancelResponseDTO,
ClarificationEventDTO,
ClarifyResponseDTO,
ConversationEventDTO,
CreateMessageResponseDTO,
CreateThreadResponseDTO,
DoneEventDTO,
ErrorEventDTO,
ExecutionStateDTO,
FeedbackRatingDTO,
ListThreadsApiV1AssistantThreadsGetArchived,
ListThreadsApiV1AssistantThreadsGetParams,
MessageContextDTO,
MessageContextDTOSource,
MessageContextDTOType,
MessageEventDTO,
MessageSummaryDTO,
RegenerateResponseDTO,
StatusEventDTO,
ThinkingEventDTO,
ThreadDetailResponseDTO,
ThreadListResponseDTO,
ThreadSummaryDTO,
ToolCallEventDTO,
ToolResultEventDTO,
} from './sigNozAIAssistantAPI.schemas';
import { LOCALSTORAGE } from 'constants/localStorage';
import {
AIAssistantInstance,
getAIBaseUrl,
SIGNOZ_URL_HEADER,
} from '../AIAPIInstance';
import { getSigNozInstanceUrl } from 'utils/signozInstanceUrl';
// ---------------------------------------------------------------------------
// SSE-only auth wrapper.
//
// REST calls go through `AIAssistantInstance` (axios) and get refresh-token
// behaviour from the shared `interceptorRejected`. The SSE call has to use
// raw `fetch` (axios can't stream a `ReadableStream`), so it can't ride that
// interceptor — this small wrapper handles 401 at SSE open time by hitting
// the same rotate endpoint and replaying the request once.
//
// In typical use a REST call (e.g. sendMessage / loadThread) precedes every
// stream open, so axios will already have refreshed the token and `fetch`
// just reads the fresh one from localStorage. The wrapper exists for the
// edge case where SSE is the first call to encounter a 401.
// ---------------------------------------------------------------------------
let pendingRotate: Promise<string | null> | null = null;
async function rotateAccessToken(): Promise<string | null> {
if (pendingRotate) {
return pendingRotate;
}
const refreshToken = getLocalStorageApi(LOCALSTORAGE.REFRESH_AUTH_TOKEN) || '';
if (!refreshToken) {
return null;
}
pendingRotate = (async (): Promise<string | null> => {
try {
const response = await rotateSession({ refreshToken });
afterLogin(response.data.accessToken, response.data.refreshToken, true);
return response.data.accessToken;
} catch {
Logout();
return null;
} finally {
pendingRotate = null;
}
})();
return pendingRotate;
}
// Backoff schedule for 429 retries on SSE open. Three attempts is enough to
// absorb the brief window between cancel→send→stream when the backend is
// rate-limiting the burst, without making real "you're saturated" errors
// take forever to surface.
const SSE_429_BACKOFF_MS = [400, 1200, 2500];
function parseRetryAfterMs(value: string | null): number | null {
if (!value) {
return null;
}
const seconds = Number(value);
if (Number.isFinite(seconds)) {
return Math.max(0, seconds * 1000);
}
const date = Date.parse(value);
if (Number.isFinite(date)) {
return Math.max(0, date - Date.now());
}
return null;
}
async function fetchSSEWithAuth(
url: string,
signal?: AbortSignal,
): Promise<Response> {
const send = async (token: string | null): Promise<Response> => {
const headers: Record<string, string> = {
[SIGNOZ_URL_HEADER]: getSigNozInstanceUrl(),
};
if (token) {
headers.Authorization = `Bearer ${token}`;
}
return fetch(url, { headers, signal });
};
const sendWithAuth = async (): Promise<Response> => {
const initialToken = getLocalStorageApi(LOCALSTORAGE.AUTH_TOKEN) || '';
const res = await send(initialToken);
if (res.status !== 401) {
return res;
}
const refreshed = await rotateAccessToken();
if (!refreshed) {
return res;
}
return send(refreshed);
};
let res = await sendWithAuth();
for (const baseDelay of SSE_429_BACKOFF_MS) {
if (res.status !== 429 || signal?.aborted) {
return res;
}
const retryAfter = parseRetryAfterMs(res.headers.get('Retry-After'));
const delay = retryAfter ?? baseDelay;
// eslint-disable-next-line no-await-in-loop
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(resolve, delay);
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer);
reject(new DOMException('SSE 429 backoff aborted', 'AbortError'));
},
{ once: true },
);
});
// eslint-disable-next-line no-await-in-loop
res = await sendWithAuth();
}
return res;
}
// ---------------------------------------------------------------------------
// SSE event types
//
// The generated event DTOs each declare `type?: string` (loose). The UI needs
// a discriminated union, so we intersect each variant with a string-literal
// `type` to enable narrowing via `event.type === 'status'`.
// ---------------------------------------------------------------------------
export type SSEEvent =
| (StatusEventDTO & { type: 'status' })
| (MessageEventDTO & { type: 'message' })
| (ThinkingEventDTO & { type: 'thinking' })
| (ToolCallEventDTO & { type: 'tool_call' })
| (ToolResultEventDTO & { type: 'tool_result' })
| (ApprovalEventDTO & { type: 'approval' })
| (ClarificationEventDTO & { type: 'clarification' })
| (ErrorEventDTO & { type: 'error' })
| (ConversationEventDTO & { type: 'conversation' })
| (DoneEventDTO & { type: 'done' });
/** String-literal view of `ExecutionStateDTO` for ergonomic comparisons. */
export type ExecutionState = `${ExecutionStateDTO}`;
// ---------------------------------------------------------------------------
// Re-exported DTOs — the wire shape, used directly without remapping.
// ---------------------------------------------------------------------------
export type ThreadSummary = ThreadSummaryDTO;
export type ThreadListResponse = ThreadListResponseDTO;
export type ThreadDetailResponse = ThreadDetailResponseDTO;
export type MessageSummary = MessageSummaryDTO;
export type CancelResponse = CancelResponseDTO;
/**
* Construction-friendly view of `MessageContextDTO`: enum fields are widened
* to their string-literal unions so call-sites can pass `'mention'` instead
* of `MessageContextDTOSource.mention`.
*/
export type MessageContext = Omit<MessageContextDTO, 'source' | 'type'> & {
source: `${MessageContextDTOSource}`;
type: `${MessageContextDTOType}`;
};
/** Construction-friendly view of `ListThreadsApiV1AssistantThreadsGetParams`. */
export type ListThreadsOptions = Omit<
ListThreadsApiV1AssistantThreadsGetParams,
'archived'
> & {
archived?: `${ListThreadsApiV1AssistantThreadsGetArchived}`;
};
/** String-literal view of `FeedbackRatingDTO` so call-sites can pass `'positive'`/`'negative'`. */
export type FeedbackRating = `${FeedbackRatingDTO}`;
// ---------------------------------------------------------------------------
// Thread listing & detail
// ---------------------------------------------------------------------------
export async function listThreads(
options: ListThreadsOptions = {},
): Promise<ThreadListResponse> {
const {
archived = 'false',
limit = 20,
cursor = null,
sort = 'updated_desc',
} = options;
const response = await AIAssistantInstance.get<ThreadListResponse>(
'/threads',
{
params: {
archived,
limit,
sort,
...(cursor ? { cursor } : {}),
},
},
);
return response.data;
}
export async function updateThread(
threadId: string,
update: { title?: string | null; archived?: boolean | null },
): Promise<ThreadSummary> {
const response = await AIAssistantInstance.patch<ThreadSummary>(
`/threads/${threadId}`,
update,
);
return response.data;
}
export async function getThreadDetail(
threadId: string,
): Promise<ThreadDetailResponse> {
const response = await AIAssistantInstance.get<ThreadDetailResponse>(
`/threads/${threadId}`,
);
return response.data;
}
// ---------------------------------------------------------------------------
// Step 1 — Create thread
// POST /api/v1/assistant/threads → { threadId }
// ---------------------------------------------------------------------------
export async function createThread(signal?: AbortSignal): Promise<string> {
const response = await AIAssistantInstance.post<CreateThreadResponseDTO>(
'/threads',
{},
{ signal },
);
return response.data.threadId;
}
// ---------------------------------------------------------------------------
// Step 2 — Send message
// POST /api/v1/assistant/threads/{threadId}/messages → { executionId }
// ---------------------------------------------------------------------------
/** Fetches the thread's active executionId for reconnect on thread_busy (409). */
async function getActiveExecutionId(threadId: string): Promise<string | null> {
try {
const response = await AIAssistantInstance.get<ThreadDetailResponseDTO>(
`/threads/${threadId}`,
);
return response.data.activeExecutionId ?? null;
} catch {
return null;
}
}
export async function sendMessage(
threadId: string,
content: string,
contexts?: MessageContext[],
signal?: AbortSignal,
): Promise<string> {
try {
const response = await AIAssistantInstance.post<CreateMessageResponseDTO>(
`/threads/${threadId}/messages`,
{
content,
...(contexts && contexts.length > 0 ? { contexts } : {}),
},
{ signal },
);
return response.data.executionId;
} catch (err) {
// Thread already has an active execution — reconnect to it instead of
// failing the user's send.
if (axios.isAxiosError(err) && err.response?.status === 409) {
const executionId = await getActiveExecutionId(threadId);
if (executionId) {
return executionId;
}
}
throw err;
}
}
// ---------------------------------------------------------------------------
// Step 3 — Stream execution events
// GET /api/v1/assistant/executions/{executionId}/events → SSE
// ---------------------------------------------------------------------------
function parseSSELine(line: string): SSEEvent | null {
if (!line.startsWith('data: ')) {
return null;
}
const json = line.slice('data: '.length).trim();
if (!json || json === '[DONE]') {
return null;
}
try {
return JSON.parse(json) as SSEEvent;
} catch {
return null;
}
}
function parseSSEChunk(chunk: string): SSEEvent[] {
return chunk
.split('\n\n')
.map((part) => part.split('\n').find((l) => l.startsWith('data: ')) ?? '')
.map(parseSSELine)
.filter((e): e is SSEEvent => e !== null);
}
async function* readSSEReader(
reader: ReadableStreamDefaultReader<Uint8Array>,
): AsyncGenerator<SSEEvent> {
const decoder = new TextDecoder();
let lineBuffer = '';
try {
// eslint-disable-next-line no-constant-condition
while (true) {
// eslint-disable-next-line no-await-in-loop
const { done, value } = await reader.read();
if (done) {
break;
}
lineBuffer += decoder.decode(value, { stream: true });
const parts = lineBuffer.split('\n\n');
lineBuffer = parts.pop() ?? '';
yield* parts.flatMap(parseSSEChunk);
}
yield* parseSSEChunk(lineBuffer);
} finally {
reader.releaseLock();
}
}
/**
* Thrown by `streamEvents` when the SSE open returns a non-2xx response.
* Carries the HTTP status so callers can branch on rate-limit vs. other
* failures (e.g. show a "please wait a moment" message on 429).
*/
export class SSEStreamError extends Error {
status: number;
constructor(status: number, statusText: string) {
super(`SSE stream failed: ${status} ${statusText}`);
this.name = 'SSEStreamError';
this.status = status;
}
}
export async function* streamEvents(
executionId: string,
signal?: AbortSignal,
): AsyncGenerator<SSEEvent> {
const res = await fetchSSEWithAuth(
`${getAIBaseUrl()}/executions/${executionId}/events`,
signal,
);
if (!res.ok || !res.body) {
throw new SSEStreamError(res.status, res.statusText);
}
yield* readSSEReader(res.body.getReader());
}
// ---------------------------------------------------------------------------
// Approval / Clarification / Cancel actions
// ---------------------------------------------------------------------------
/** Approve a pending action. Returns a new executionId — open a fresh SSE stream for it. */
export async function approveExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<ApproveResponseDTO>(
'/approve',
{ approvalId },
{ signal },
);
return response.data.executionId;
}
/** Reject a pending action. */
export async function rejectExecution(
approvalId: string,
signal?: AbortSignal,
): Promise<void> {
await AIAssistantInstance.post('/reject', { approvalId }, { signal });
}
/** Submit clarification answers. Returns a new executionId — open a fresh SSE stream for it. */
export async function clarifyExecution(
clarificationId: string,
answers: Record<string, unknown>,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<ClarifyResponseDTO>(
'/clarify',
{ clarificationId, answers },
{ signal },
);
return response.data.executionId;
}
/**
* Clean-slate regeneration of an assistant response. The backend rewinds the
* conversation up to (excluding) the supplied messageId and starts a fresh
* execution. Returns the new executionId — open an SSE stream for it the
* same way `sendMessage` and `approve` do.
*/
export async function regenerateMessage(
messageId: string,
signal?: AbortSignal,
): Promise<string> {
const response = await AIAssistantInstance.post<RegenerateResponseDTO>(
`/messages/${messageId}/regenerate`,
undefined,
{ signal },
);
return response.data.executionId;
}
export async function cancelExecution(
threadId: string,
signal?: AbortSignal,
): Promise<CancelResponse> {
const response = await AIAssistantInstance.post<CancelResponse>(
'/cancel',
{ threadId },
{ signal },
);
return response.data;
}
// ---------------------------------------------------------------------------
// Rollback actions — undo / revert / restore
// All three POST `{ actionMetadataId }` and return `ActionResultResponseDTO`.
// ---------------------------------------------------------------------------
async function postRollback(
endpoint: 'undo' | 'revert' | 'restore',
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> {
const response = await AIAssistantInstance.post<ActionResultResponseDTO>(
`/${endpoint}`,
{ actionMetadataId },
{ signal },
);
return response.data;
}
export const undoExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('undo', actionMetadataId, signal);
export const revertExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('revert', actionMetadataId, signal);
export const restoreExecution = (
actionMetadataId: string,
signal?: AbortSignal,
): Promise<ActionResultResponseDTO> =>
postRollback('restore', actionMetadataId, signal);
// ---------------------------------------------------------------------------
// Feedback
// ---------------------------------------------------------------------------
export async function submitFeedback(
messageId: string,
rating: FeedbackRating,
comment?: string,
): Promise<void> {
await AIAssistantInstance.post(`/messages/${messageId}/feedback`, {
rating,
comment: comment ?? null,
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -18,19 +18,34 @@ import type {
} from 'react-query';
import type {
CreateDashboardV2201,
CreatePublicDashboard201,
CreatePublicDashboardPathParameters,
CreatePublicDashboardV2200,
CreatePublicDashboardV2PathParameters,
DashboardtypesJSONPatchDocumentDTO,
DashboardtypesPostableDashboardV2DTO,
DashboardtypesPostablePublicDashboardDTO,
DashboardtypesUpdatablePublicDashboardDTO,
DeletePublicDashboardPathParameters,
GetDashboardV2200,
GetDashboardV2PathParameters,
GetPublicDashboard200,
GetPublicDashboardData200,
GetPublicDashboardDataPathParameters,
GetPublicDashboardPathParameters,
GetPublicDashboardWidgetQueryRange200,
GetPublicDashboardWidgetQueryRangePathParameters,
LockDashboardV2PathParameters,
PatchDashboardV2200,
PatchDashboardV2PathParameters,
RenderErrorResponseDTO,
UnlockDashboardV2PathParameters,
UpdateDashboardV2200,
UpdateDashboardV2PathParameters,
UpdatePublicDashboardPathParameters,
UpdatePublicDashboardV2200,
UpdatePublicDashboardV2PathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -634,3 +649,739 @@ export const invalidateGetPublicDashboardWidgetQueryRange = async (
return queryClient;
};
/**
* This endpoint creates a v2-shape dashboard with structured metadata, a typed data tree, and resolved tags.
* @summary Create dashboard (v2)
*/
export const createDashboardV2 = (
dashboardtypesPostableDashboardV2DTO: BodyType<DashboardtypesPostableDashboardV2DTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<CreateDashboardV2201>({
url: `/api/v2/dashboards`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
signal,
});
};
export const getCreateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationKey = ['createDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createDashboardV2>>,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> }
> = (props) => {
const { data } = props ?? {};
return createDashboardV2(data);
};
return { mutationFn, ...mutationOptions };
};
export type CreateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof createDashboardV2>>
>;
export type CreateDashboardV2MutationBody =
BodyType<DashboardtypesPostableDashboardV2DTO>;
export type CreateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Create dashboard (v2)
*/
export const useCreateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createDashboardV2>>,
TError,
{ data: BodyType<DashboardtypesPostableDashboardV2DTO> },
TContext
> => {
const mutationOptions = getCreateDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint returns a v2-shape dashboard with its tags and public sharing config (if any).
* @summary Get dashboard (v2)
*/
export const getDashboardV2 = (
{ id }: GetDashboardV2PathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'GET',
signal,
});
};
export const getGetDashboardV2QueryKey = ({
id,
}: GetDashboardV2PathParameters) => {
return [`/api/v2/dashboards/${id}`] as const;
};
export const getGetDashboardV2QueryOptions = <
TData = Awaited<ReturnType<typeof getDashboardV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetDashboardV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetDashboardV2QueryKey({ id });
const queryFn: QueryFunction<Awaited<ReturnType<typeof getDashboardV2>>> = ({
signal,
}) => getDashboardV2({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetDashboardV2QueryResult = NonNullable<
Awaited<ReturnType<typeof getDashboardV2>>
>;
export type GetDashboardV2QueryError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get dashboard (v2)
*/
export function useGetDashboardV2<
TData = Awaited<ReturnType<typeof getDashboardV2>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetDashboardV2PathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getDashboardV2>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetDashboardV2QueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get dashboard (v2)
*/
export const invalidateGetDashboardV2 = async (
queryClient: QueryClient,
{ id }: GetDashboardV2PathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetDashboardV2QueryKey({ id }) },
options,
);
return queryClient;
};
/**
* 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.
* @summary Patch dashboard (v2)
*/
export const patchDashboardV2 = (
{ id }: PatchDashboardV2PathParameters,
dashboardtypesJSONPatchDocumentDTO: BodyType<DashboardtypesJSONPatchDocumentDTO>,
) => {
return GeneratedAPIInstance<PatchDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesJSONPatchDocumentDTO,
});
};
export const getPatchDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
},
TContext
> => {
const mutationKey = ['patchDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof patchDashboardV2>>,
{
pathParams: PatchDashboardV2PathParameters;
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return patchDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type PatchDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof patchDashboardV2>>
>;
export type PatchDashboardV2MutationBody =
BodyType<DashboardtypesJSONPatchDocumentDTO>;
export type PatchDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Patch dashboard (v2)
*/
export const usePatchDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof patchDashboardV2>>,
TError,
{
pathParams: PatchDashboardV2PathParameters;
data: BodyType<DashboardtypesJSONPatchDocumentDTO>;
},
TContext
> => {
const mutationOptions = getPatchDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint updates a v2-shape dashboard's metadata, data, and tag set. Locked dashboards are rejected.
* @summary Update dashboard (v2)
*/
export const updateDashboardV2 = (
{ id }: UpdateDashboardV2PathParameters,
dashboardtypesPostableDashboardV2DTO: BodyType<DashboardtypesPostableDashboardV2DTO>,
) => {
return GeneratedAPIInstance<UpdateDashboardV2200>({
url: `/api/v2/dashboards/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostableDashboardV2DTO,
});
};
export const getUpdateDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
> => {
const mutationKey = ['updateDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateDashboardV2>>,
{
pathParams: UpdateDashboardV2PathParameters;
data: BodyType<DashboardtypesPostableDashboardV2DTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updateDashboardV2>>
>;
export type UpdateDashboardV2MutationBody =
BodyType<DashboardtypesPostableDashboardV2DTO>;
export type UpdateDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update dashboard (v2)
*/
export const useUpdateDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateDashboardV2>>,
TError,
{
pathParams: UpdateDashboardV2PathParameters;
data: BodyType<DashboardtypesPostableDashboardV2DTO>;
},
TContext
> => {
const mutationOptions = getUpdateDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint unlocks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Unlock dashboard (v2)
*/
export const unlockDashboardV2 = ({ id }: UnlockDashboardV2PathParameters) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'DELETE',
});
};
export const getUnlockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['unlockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof unlockDashboardV2>>,
{ pathParams: UnlockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return unlockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type UnlockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof unlockDashboardV2>>
>;
export type UnlockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Unlock dashboard (v2)
*/
export const useUnlockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof unlockDashboardV2>>,
TError,
{ pathParams: UnlockDashboardV2PathParameters },
TContext
> => {
const mutationOptions = getUnlockDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint locks a v2-shape dashboard. Only the dashboard's creator or an org admin may lock or unlock.
* @summary Lock dashboard (v2)
*/
export const lockDashboardV2 = ({ id }: LockDashboardV2PathParameters) => {
return GeneratedAPIInstance<string>({
url: `/api/v2/dashboards/${id}/lock`,
method: 'PUT',
});
};
export const getLockDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationKey = ['lockDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof lockDashboardV2>>,
{ pathParams: LockDashboardV2PathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return lockDashboardV2(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type LockDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof lockDashboardV2>>
>;
export type LockDashboardV2MutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Lock dashboard (v2)
*/
export const useLockDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof lockDashboardV2>>,
TError,
{ pathParams: LockDashboardV2PathParameters },
TContext
> => {
const mutationOptions = getLockDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};
/**
* 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.
* @summary Make a dashboard v2 public
*/
export const createPublicDashboardV2 = (
{ id }: CreatePublicDashboardV2PathParameters,
dashboardtypesPostablePublicDashboardDTO: BodyType<DashboardtypesPostablePublicDashboardDTO>,
) => {
return GeneratedAPIInstance<CreatePublicDashboardV2200>({
url: `/api/v2/dashboards/${id}/public`,
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesPostablePublicDashboardDTO,
});
};
export const getCreatePublicDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createPublicDashboardV2>>,
TError,
{
pathParams: CreatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof createPublicDashboardV2>>,
TError,
{
pathParams: CreatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
},
TContext
> => {
const mutationKey = ['createPublicDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof createPublicDashboardV2>>,
{
pathParams: CreatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return createPublicDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type CreatePublicDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof createPublicDashboardV2>>
>;
export type CreatePublicDashboardV2MutationBody =
BodyType<DashboardtypesPostablePublicDashboardDTO>;
export type CreatePublicDashboardV2MutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Make a dashboard v2 public
*/
export const useCreatePublicDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof createPublicDashboardV2>>,
TError,
{
pathParams: CreatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof createPublicDashboardV2>>,
TError,
{
pathParams: CreatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesPostablePublicDashboardDTO>;
},
TContext
> => {
const mutationOptions = getCreatePublicDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};
/**
* This endpoint updates the public sharing config (time range settings) of an already-public v2 dashboard. Lock state does not gate this endpoint.
* @summary Update public sharing config for a dashboard v2
*/
export const updatePublicDashboardV2 = (
{ id }: UpdatePublicDashboardV2PathParameters,
dashboardtypesUpdatablePublicDashboardDTO: BodyType<DashboardtypesUpdatablePublicDashboardDTO>,
) => {
return GeneratedAPIInstance<UpdatePublicDashboardV2200>({
url: `/api/v2/dashboards/${id}/public`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: dashboardtypesUpdatablePublicDashboardDTO,
});
};
export const getUpdatePublicDashboardV2MutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
TError,
{
pathParams: UpdatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
TError,
{
pathParams: UpdatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
},
TContext
> => {
const mutationKey = ['updatePublicDashboardV2'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
{
pathParams: UpdatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updatePublicDashboardV2(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdatePublicDashboardV2MutationResult = NonNullable<
Awaited<ReturnType<typeof updatePublicDashboardV2>>
>;
export type UpdatePublicDashboardV2MutationBody =
BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
export type UpdatePublicDashboardV2MutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update public sharing config for a dashboard v2
*/
export const useUpdatePublicDashboardV2 = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
TError,
{
pathParams: UpdatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updatePublicDashboardV2>>,
TError,
{
pathParams: UpdatePublicDashboardV2PathParameters;
data: BodyType<DashboardtypesUpdatablePublicDashboardDTO>;
},
TContext
> => {
const mutationOptions = getUpdatePublicDashboardV2MutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -13,14 +13,12 @@ import type {
import type {
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDeploymentsDTO,
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNamespacesDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
InframonitoringtypesPostableVolumesDTO,
ListClusters200,
ListDeployments200,
ListHosts200,
ListNamespaces200,
ListNodes200,
@@ -116,90 +114,6 @@ export const useListClusters = <
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes Deployments with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the deployment, plus average CPU/memory request and limit utilization (deploymentCPURequest, deploymentCPULimit, deploymentMemoryRequest, deploymentMemoryLimit). Each row also reports the latest known desiredPods (k8s.deployment.desired) and availablePods (k8s.deployment.available) replica counts and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each deployment includes metadata attributes (k8s.deployment.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.deployment.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by deployments in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_pods / available_pods, and pagination via offset/limit. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (deploymentCPU, deploymentCPURequest, deploymentCPULimit, deploymentMemory, deploymentMemoryRequest, deploymentMemoryLimit, desiredPods, availablePods) return -1 as a sentinel when no data is available for that field.
* @summary List Deployments for Infra Monitoring
*/
export const listDeployments = (
inframonitoringtypesPostableDeploymentsDTO: BodyType<InframonitoringtypesPostableDeploymentsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDeployments200>({
url: `/api/v2/infra_monitoring/deployments`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableDeploymentsDTO,
signal,
});
};
export const getListDeploymentsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
> => {
const mutationKey = ['listDeployments'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof listDeployments>>,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> }
> = (props) => {
const { data } = props ?? {};
return listDeployments(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListDeploymentsMutationResult = NonNullable<
Awaited<ReturnType<typeof listDeployments>>
>;
export type ListDeploymentsMutationBody =
BodyType<InframonitoringtypesPostableDeploymentsDTO>;
export type ListDeploymentsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Deployments for Infra Monitoring
*/
export const useListDeployments = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listDeployments>>,
TError,
{ data: BodyType<InframonitoringtypesPostableDeploymentsDTO> },
TContext
> => {
const mutationOptions = getListDeploymentsMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of hosts with key infrastructure metrics: CPU usage (%), memory usage (%), I/O wait (%), disk usage (%), and 15-minute load average. Each host includes its current status (active/inactive based on metrics reported in the last 10 minutes) and metadata attributes (e.g., os.type). Supports filtering via a filter expression, filtering by host status, custom groupBy to aggregate hosts by any attribute, ordering by any of the five metrics, and pagination via offset/limit. The response type is 'list' for the default host.name grouping or 'grouped_list' for custom groupBy keys. Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (cpu, memory, wait, load15, diskUsage) return -1 as a sentinel when no data is available for that field.
* @summary List Hosts for Infra Monitoring

File diff suppressed because it is too large Load Diff

View File

@@ -4,46 +4,14 @@ import {
interceptorsRequestResponse,
interceptorsResponse,
} from 'api';
import { ENVIRONMENT } from 'constants/env';
import axios, { AxiosError, AxiosRequestConfig } from 'axios';
import { ENVIRONMENT } from 'constants/env';
// generated API Instance
const generatedAPIAxiosInstance = axios.create({
baseURL: ENVIRONMENT.baseURL,
});
let generatedAPIQueryKeyHeaderContext: Record<string, unknown> | undefined;
export const setGeneratedAPIQueryKeyHeaderContext = <THeaders extends object>(
headers?: THeaders,
): void => {
generatedAPIQueryKeyHeaderContext = headers
? { ...(headers as Record<string, unknown>) }
: undefined;
};
const hashHeaderValue = (value: string): string => {
let hash = 0;
for (let index = 0; index < value.length; index += 1) {
hash = (hash * 31 + value.charCodeAt(index)) >>> 0;
}
return hash.toString(16);
};
const mergeHeaderRecord = (
target: Record<string, unknown>,
source: unknown,
): Record<string, unknown> => {
if (!source || typeof source !== 'object') {
return target;
}
return Object.assign(target, source as Record<string, unknown>);
};
export const GeneratedAPIInstance = <T>(
config: AxiosRequestConfig,
): Promise<T> => {
@@ -58,59 +26,5 @@ generatedAPIAxiosInstance.interceptors.response.use(
interceptorRejected,
);
const getDefaultQueryKeyHeaders = (): Record<string, unknown> => {
const defaults = generatedAPIAxiosInstance.defaults
.headers as unknown as Record<string, unknown>;
const headers: Record<string, unknown> = {};
const methodKeys = new Set([
'common',
'delete',
'get',
'head',
'options',
'patch',
'post',
'put',
]);
mergeHeaderRecord(headers, defaults?.common);
mergeHeaderRecord(headers, defaults?.get);
for (const [key, value] of Object.entries(defaults ?? {})) {
if (!methodKeys.has(key)) {
headers[key] = value;
}
}
return headers;
};
export const getGeneratedAPIQueryKeyHeaders = <THeaders extends object>(
headers?: THeaders,
): [{ headers: Record<string, unknown> }] | [] => {
const mergedHeaders = {
...getDefaultQueryKeyHeaders(),
...generatedAPIQueryKeyHeaderContext,
...(headers as Record<string, unknown> | undefined),
};
const queryKeyHeaders = Object.fromEntries(
Object.entries(mergedHeaders)
.filter(([, value]) => value !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, value]) => {
if (key.toLowerCase() === 'authorization' && typeof value === 'string') {
return [key, hashHeaderValue(value)];
}
return [key, value];
}),
);
return Object.keys(queryKeyHeaders).length
? [{ headers: queryKeyHeaders }]
: [];
};
export type ErrorType<Error> = AxiosError<Error>;
export type BodyType<BodyData> = BodyData;

View File

@@ -103,7 +103,7 @@ function EditMemberDrawer({
const { user: currentUser } = useAppContext();
const [localDisplayName, setLocalDisplayName] = useState('');
const [localRoles, setLocalRoles] = useState<string[]>([]);
const [localRole, setLocalRole] = useState('');
const [isSaving, setIsSaving] = useState(false);
const [saveErrors, setSaveErrors] = useState<SaveError[]>([]);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
@@ -141,7 +141,7 @@ function EditMemberDrawer({
} = useRoles();
const {
currentRoles: currentMemberRoles,
fetchedRoleIds,
isLoading: isMemberRolesLoading,
applyDiff,
} = useMemberRoleManager(member?.id ?? '', open && !!member?.id);
@@ -188,24 +188,16 @@ function EditMemberDrawer({
if (!member?.id) {
roleSessionRef.current = null;
} else if (member.id !== roleSessionRef.current && !isMemberRolesLoading) {
setLocalRoles(
currentMemberRoles.map((r) => r.id).filter(Boolean) as string[],
);
setLocalRole(fetchedRoleIds[0] ?? '');
roleSessionRef.current = member.id;
}
}, [member?.id, currentMemberRoles, isMemberRolesLoading]);
}, [member?.id, fetchedRoleIds, isMemberRolesLoading]);
const isDirty =
member !== null &&
fetchedUser != null &&
(localDisplayName !== fetchedDisplayName ||
JSON.stringify([...localRoles].sort()) !==
JSON.stringify(
currentMemberRoles
.map((r) => r.id)
.filter(Boolean)
.sort(),
));
localRole !== (fetchedRoleIds[0] ?? ''));
const { mutateAsync: updateMyUser } = useUpdateMyUserV2();
const { mutateAsync: updateUser } = useUpdateUser();
@@ -280,14 +272,7 @@ function EditMemberDrawer({
setIsSaving(true);
try {
const nameChanged = localDisplayName !== fetchedDisplayName;
const rolesChanged =
JSON.stringify([...localRoles].sort()) !==
JSON.stringify(
currentMemberRoles
.map((r) => r.id)
.filter(Boolean)
.sort(),
);
const rolesChanged = localRole !== (fetchedRoleIds[0] ?? '');
const namePromise = nameChanged
? isSelf
@@ -301,7 +286,7 @@ function EditMemberDrawer({
const [nameResult, rolesResult] = await Promise.allSettled([
namePromise,
rolesChanged
? applyDiff([...localRoles], availableRoles)
? applyDiff([localRole].filter(Boolean), availableRoles)
: Promise.resolve([]),
]);
@@ -320,7 +305,10 @@ function EditMemberDrawer({
context: 'Roles update',
apiError: toSaveApiError(rolesResult.reason),
onRetry: async (): Promise<void> => {
const failures = await applyDiff([...localRoles], availableRoles);
const failures = await applyDiff(
[localRole].filter(Boolean),
availableRoles,
);
setSaveErrors((prev) => {
const rest = prev.filter((e) => e.context !== 'Roles update');
return [
@@ -365,9 +353,9 @@ function EditMemberDrawer({
isDirty,
isSelf,
localDisplayName,
localRoles,
localRole,
fetchedDisplayName,
currentMemberRoles,
fetchedRoleIds,
updateMyUser,
updateUser,
applyDiff,
@@ -515,15 +503,10 @@ function EditMemberDrawer({
>
<div className="edit-member-drawer__input-wrapper edit-member-drawer__input-wrapper--disabled">
<div className="edit-member-drawer__disabled-roles">
{localRoles.length > 0 ? (
localRoles.map((roleId) => {
const role = availableRoles.find((r) => r.id === roleId);
return (
<Badge key={roleId} color="vanilla">
{role?.name ?? roleId}
</Badge>
);
})
{localRole ? (
<Badge color="vanilla">
{availableRoles.find((r) => r.id === localRole)?.name ?? localRole}
</Badge>
) : (
<span className="edit-member-drawer__email-text"></span>
)}
@@ -534,15 +517,14 @@ function EditMemberDrawer({
) : (
<RolesSelect
id="member-role"
mode="multiple"
roles={availableRoles}
loading={rolesLoading}
isError={rolesError}
error={rolesErrorObj}
onRefetch={refetchRoles}
value={localRoles}
onChange={(roles): void => {
setLocalRoles(roles);
value={localRole}
onChange={(role): void => {
setLocalRole(role ?? '');
setSaveErrors((prev) =>
prev.filter(
(err) =>
@@ -550,7 +532,8 @@ function EditMemberDrawer({
),
);
}}
placeholder="Select roles"
placeholder="Select role"
allowClear={false}
/>
)}
</div>

View File

@@ -5,9 +5,7 @@ import {
useCreateResetPasswordToken,
useDeleteUser,
useGetResetPasswordToken,
useGetRolesByUserID,
useGetUser,
useRemoveUserRoleByUserIDAndRoleID,
useSetRoleByUserID,
useUpdateMyUserV2,
useUpdateUser,
@@ -25,16 +23,11 @@ import EditMemberDrawer, { EditMemberDrawerProps } from '../EditMemberDrawer';
jest.mock('api/generated/services/users', () => ({
useDeleteUser: jest.fn(),
useGetUser: jest.fn(),
useGetRolesByUserID: jest.fn(),
useRemoveUserRoleByUserIDAndRoleID: jest.fn(),
useUpdateUser: jest.fn(),
useUpdateMyUserV2: jest.fn(),
useSetRoleByUserID: jest.fn(),
useGetResetPasswordToken: jest.fn(),
useCreateResetPasswordToken: jest.fn(),
getGetRolesByUserIDQueryKey: ({ id }: { id: string }): string[] => [
`/api/v2/users/${id}/roles`,
],
}));
jest.mock('api/ErrorResponseHandlerForGeneratedAPIs', () => ({
@@ -105,7 +98,6 @@ jest.mock('react-use', () => ({
const ROLES_ENDPOINT = '*/api/v1/roles';
const mockDeleteMutate = jest.fn();
const mockRemoveMutateAsync = jest.fn();
const mockCreateTokenMutateAsync = jest.fn();
const showErrorModal = jest.fn();
@@ -194,14 +186,6 @@ describe('EditMemberDrawer', () => {
isLoading: false,
refetch: jest.fn(),
});
(useGetRolesByUserID as jest.Mock).mockReturnValue({
data: { data: [managedRoles[0]] },
isLoading: false,
});
(useRemoveUserRoleByUserIDAndRoleID as jest.Mock).mockReturnValue({
mutateAsync: mockRemoveMutateAsync.mockResolvedValue({}),
isLoading: false,
});
(useUpdateUser as jest.Mock).mockReturnValue({
mutateAsync: jest.fn().mockResolvedValue({}),
isLoading: false,
@@ -312,7 +296,7 @@ describe('EditMemberDrawer', () => {
expect(onClose).not.toHaveBeenCalled();
});
it('adding a new role calls setRole without removing existing ones', async () => {
it('selecting a different role calls setRole with the new role name', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
@@ -324,7 +308,7 @@ describe('EditMemberDrawer', () => {
renderDrawer({ onComplete });
// signoz-admin is already selected; add signoz-editor on top
// Open the roles dropdown and select signoz-editor
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-editor'));
@@ -337,31 +321,34 @@ describe('EditMemberDrawer', () => {
pathParams: { id: 'user-1' },
data: { name: 'signoz-editor' },
});
expect(mockRemoveMutateAsync).not.toHaveBeenCalled();
expect(onComplete).toHaveBeenCalled();
});
});
it('deselecting a role calls removeRole with the role id', async () => {
it('does not call removeRole when the role is changed', async () => {
const onComplete = jest.fn();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockSet = jest.fn().mockResolvedValue({});
(useSetRoleByUserID as jest.Mock).mockReturnValue({
mutateAsync: mockSet,
isLoading: false,
});
renderDrawer({ onComplete });
// signoz-admin appears as a selected tag — click its remove button to deselect
const adminTag = await screen.findByTitle('signoz-admin');
const removeBtn = adminTag.querySelector(
'.ant-select-selection-item-remove',
) as Element;
await user.click(removeBtn);
// Switch from signoz-admin to signoz-viewer using single-select
await user.click(screen.getByLabelText('Roles'));
await user.click(await screen.findByTitle('signoz-viewer'));
const saveBtn = screen.getByRole('button', { name: /save member details/i });
await waitFor(() => expect(saveBtn).not.toBeDisabled());
await user.click(saveBtn);
await waitFor(() => {
expect(mockRemoveMutateAsync).toHaveBeenCalledWith({
pathParams: { id: 'user-1', roleId: managedRoles[0].id },
expect(mockSet).toHaveBeenCalledWith({
pathParams: { id: 'user-1' },
data: { name: 'signoz-viewer' },
});
expect(onComplete).toHaveBeenCalled();
});

View File

@@ -4,49 +4,6 @@
gap: 8px;
}
.header-ai-assistant-btn-container {
display: flex;
align-items: center;
gap: 4px;
}
.header-ai-assistant-btn__prefix {
display: inline-flex;
align-items: center;
gap: 6px;
}
.header-ai-assistant-btn__badge {
flex-shrink: 0;
display: inline-flex;
line-height: 0;
color: var(--accent-primary);
}
.header-ai-assistant-btn__pulse-dot {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
line-height: 0;
animation: header-ai-assistant-dot-pulse 1.5s ease-in-out infinite;
transform: scale(0.8);
margin-right: -12px;
}
@keyframes header-ai-assistant-dot-pulse {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.35;
transform: scale(0.82);
}
}
.share-modal-content,
.feedback-modal-container {
display: flex;

View File

@@ -1,17 +1,8 @@
import { useCallback, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Dot, Sparkles } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Popover } from 'antd';
import { Button, Popover } from 'antd';
import logEvent from 'api/common/logEvent';
import {
openAIAssistant,
useAIAssistantStore,
} from 'container/AIAssistant/store/useAIAssistantStore';
import { selectPendingUserInputStreamCount } from 'container/AIAssistant/store/pendingInputSelectors';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { useIsAIAssistantEnabled } from 'hooks/useIsAIAssistantEnabled';
import { Globe, Inbox, SquarePen } from '@signozhq/icons';
import AnnouncementsModal from './AnnouncementsModal';
@@ -38,7 +29,6 @@ function HeaderRightSection({
const [openAnnouncementsModal, setOpenAnnouncementsModal] = useState(false);
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const isAIAssistantEnabled = useIsAIAssistantEnabled();
const handleOpenFeedbackModal = useCallback((): void => {
logEvent('Feedback: Clicked', {
@@ -77,46 +67,9 @@ function HeaderRightSection({
};
const isLicenseEnabled = isEnterpriseSelfHostedUser || isCloudUser;
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const pendingUserInputCount: number = useAIAssistantStore(
selectPendingUserInputStreamCount,
);
const showHeaderPendingBadge =
pendingUserInputCount > 0 && !isDrawerOpen && !isModalOpen;
return (
<div className="header-right-section-container">
{isAIAssistantEnabled && !isDrawerOpen && (
<div className="header-ai-assistant-btn-container">
{showHeaderPendingBadge ? (
<span className="header-ai-assistant-btn__badge" aria-hidden>
<span className="header-ai-assistant-btn__pulse-dot">
<Dot size={36} />
</span>
</span>
) : null}
<Tooltip title="AI Assistant">
<Button
variant="solid"
color="secondary"
onClick={openAIAssistant}
aria-label={
showHeaderPendingBadge
? pendingUserInputCount === 1
? 'Open AI Assistant, 1 action needs your response'
: `Open AI Assistant, ${pendingUserInputCount} actions need your response`
: 'Open AI Assistant'
}
prefix={<Sparkles size={14} color="var(--primary)" />}
>
AI Assistant
</Button>
</Tooltip>
</div>
)}
{enableFeedback && isLicenseEnabled && (
<Popover
rootClassName="header-section-popover-root"
@@ -130,13 +83,12 @@ function HeaderRightSection({
onOpenChange={handleOpenFeedbackModalChange}
>
<Button
variant="ghost"
size="icon"
className="share-feedback-btn"
aria-label="Feedback"
prefix={<SquarePen size={14} />}
className="share-feedback-btn periscope-btn ghost"
icon={<SquarePen size={14} />}
onClick={handleOpenFeedbackModal}
/>
>
Feedback
</Button>
</Popover>
)}
@@ -153,10 +105,9 @@ function HeaderRightSection({
onOpenChange={handleOpenAnnouncementsModalChange}
>
<Button
variant="ghost"
size="icon"
aria-label="Announcements"
prefix={<Inbox size={14} />}
icon={<Inbox size={14} />}
className="periscope-btn ghost announcements-btn"
onClick={(): void => {
logEvent('Announcements: Clicked', {
page: location.pathname,
@@ -179,12 +130,12 @@ function HeaderRightSection({
onOpenChange={handleOpenShareURLModalChange}
>
<Button
variant="ghost"
size="icon"
aria-label="Share"
prefix={<Globe size={14} />}
className="share-link-btn periscope-btn ghost"
icon={<Globe size={14} />}
onClick={handleOpenShareURLModal}
/>
>
Share
</Button>
</Popover>
)}
</div>

View File

@@ -46,10 +46,6 @@ jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('hooks/useIsAIAssistantEnabled', () => ({
useIsAIAssistantEnabled: (): boolean => false,
}));
const mockLogEvent = logEvent as jest.Mock;
const mockUseLocation = useLocation as jest.Mock;
const mockUseGetTenantLicense = useGetTenantLicense as jest.Mock;

View File

@@ -12,7 +12,6 @@ import tsx from 'react-syntax-highlighter/dist/esm/languages/prism/tsx';
import typescript from 'react-syntax-highlighter/dist/esm/languages/prism/typescript';
import yaml from 'react-syntax-highlighter/dist/esm/languages/prism/yaml';
import a11yDark from 'react-syntax-highlighter/dist/esm/styles/prism/a11y-dark';
import oneLight from 'react-syntax-highlighter/dist/esm/styles/prism/one-light';
SyntaxHighlighter.registerLanguage('bash', bash);
SyntaxHighlighter.registerLanguage('docker', docker);
@@ -32,4 +31,4 @@ SyntaxHighlighter.registerLanguage('yaml', yaml);
SyntaxHighlighter.registerLanguage('yml', yaml);
export default SyntaxHighlighter;
export { a11yDark, oneLight };
export { a11yDark };

View File

@@ -41,5 +41,4 @@ export enum LOCALSTORAGE {
TRACE_DETAILS_PREFER_OLD_VIEW = 'TRACE_DETAILS_PREFER_OLD_VIEW',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
DASHBOARD_PREFERENCES = 'DASHBOARD_PREFERENCES',
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
}

View File

@@ -88,8 +88,6 @@ const ROUTES = {
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
SERVICE_ACCOUNTS_SETTINGS: '/settings/service-accounts',
AI_ASSISTANT: '/ai-assistant/:conversationId',
AI_ASSISTANT_ICON_PREVIEW: '/ai-assistant-icon-preview',
MCP_SERVER: '/settings/mcp-server',
} as const;

View File

@@ -1,102 +0,0 @@
import { useCallback } from 'react';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Drawer } from 'antd';
import ROUTES from 'constants/routes';
import { Maximize2, MessageSquare, Plus, X } from '@signozhq/icons';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
export default function AIAssistantDrawer(): JSX.Element {
const history = useHistory();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNewConversation = useCallback(() => {
startNewConversation();
}, [startNewConversation]);
return (
<Drawer
open={isDrawerOpen}
onClose={closeDrawer}
placement="right"
width={420}
// Suppress default close button — we render our own header
closeIcon={null}
title={
<div>
<div>
<MessageSquare size={16} />
<span>AI Assistant</span>
</div>
<div>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNewConversation}
aria-label="New conversation"
>
<Plus size={16} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={16} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={closeDrawer}
aria-label="Close drawer"
>
<X size={16} />
</Button>
</Tooltip>
</div>
</div>
}
>
<VariantContext.Provider value="panel">
{activeConversationId ? (
<ConversationView conversationId={activeConversationId} />
) : null}
</VariantContext.Provider>
</Drawer>
);
}

View File

@@ -1,2 +0,0 @@
export * from './AIAssistantDrawer';
export { default } from './AIAssistantDrawer';

View File

@@ -1,98 +0,0 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 1050;
display: flex;
align-items: center;
justify-content: center;
background: rgba(0, 0, 0, 0.45);
backdrop-filter: blur(2px);
animation: backdropIn 0.15s ease;
}
@keyframes backdropIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.modal {
display: flex;
flex-direction: column;
width: 70vw;
height: 80vh;
background: var(--l1-background);
border: 1px solid var(--l1-border);
border-radius: var(--radius-2);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.35);
animation: modalIn 0.18s cubic-bezier(0.16, 1, 0.3, 1);
}
@keyframes modalIn {
from {
opacity: 0;
transform: scale(0.96) translateY(-6px);
}
to {
opacity: 1;
transform: scale(1) translateY(0);
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 12px;
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
background: var(--l1-background);
}
.title {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
font-weight: 600;
color: var(--l1-foreground);
}
.shortcut {
font-size: 10px;
font-family: var(--font-mono, monospace);
font-weight: 500;
color: var(--l3-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 1px 5px;
letter-spacing: 0;
line-height: 1.6;
display: flex;
align-items: center;
gap: 4px;
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}
.body {
flex: 1;
overflow: hidden;
display: flex;
flex-direction: column;
}
.toggleBtnActive {
background: var(--l2-background) !important;
color: var(--accent-primary) !important;
}

View File

@@ -1,209 +0,0 @@
import { useCallback, useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import { useHistory } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Minus, Plus, Sparkles, X } from '@signozhq/icons';
import HistorySidebar from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
import styles from './AIAssistantModal.module.scss';
/**
* Global floating modal for the AI Assistant.
*
* - Triggered by Cmd+J (Mac) / Ctrl+J (Windows/Linux)
* - Escape or the × button fully closes it
* - The (minimize) button collapses to the side panel
* - Mounted once in AppLayout; always in the DOM, conditionally visible
*/
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function AIAssistantModal(): JSX.Element | null {
const history = useHistory();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isModalOpen);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const openModal = useAIAssistantStore((s) => s.openModal);
const closeModal = useAIAssistantStore((s) => s.closeModal);
const minimizeModal = useAIAssistantStore((s) => s.minimizeModal);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent): void => {
// Cmd+J (Mac) / Ctrl+J (Win/Linux) — toggle modal. Opening
// always starts a brand-new conversation; resuming earlier
// threads is done via the in-modal history sidebar.
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'j') {
// Don't intercept Cmd+J inside input/textarea — those are for the user
const tag = (e.target as HTMLElement).tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA') {
return;
}
e.preventDefault();
if (isOpen) {
closeModal();
} else {
startNewConversation();
setShowHistory(false);
openModal();
}
return;
}
// Escape — close modal
if (e.key === 'Escape' && isOpen) {
closeModal();
}
};
window.addEventListener('keydown', handleKeyDown);
return (): void => window.removeEventListener('keydown', handleKeyDown);
}, [isOpen, openModal, closeModal, startNewConversation]);
// ── Handlers ────────────────────────────────────────────────────────────────
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeModal();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeModal, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
const handleMinimize = useCallback(() => {
minimizeModal();
setShowHistory(false);
}, [minimizeModal]);
const handleBackdropClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
// Only close when clicking the backdrop itself, not the modal card
if (e.target === e.currentTarget) {
closeModal();
}
},
[closeModal],
);
if (!isOpen) {
return null;
}
return createPortal(
<VariantContext.Provider value="modal">
<div
className={styles.backdrop}
role="dialog"
aria-modal="true"
aria-label="AI Assistant"
onClick={handleBackdropClick}
>
<div className={styles.modal}>
{/* Header */}
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={16} color="var(--primary)" />
<span>AI Assistant</span>
<kbd className={styles.shortcut}>
<span></span>
<span>J</span>
</kbd>
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
className={showHistory ? styles.toggleBtnActive : ''}
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Minimize to side panel">
<Button
variant="ghost"
size="icon"
onClick={handleMinimize}
aria-label="Minimize to side panel"
>
<Minus size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
onClick={closeModal}
aria-label="Close"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{/* Body */}
<div className={styles.body}>
{showHistory ? (
<HistorySidebar onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</div>
</div>
</VariantContext.Provider>,
document.body,
);
}

View File

@@ -1,2 +0,0 @@
export * from './AIAssistantModal';
export { default } from './AIAssistantModal';

View File

@@ -1,60 +0,0 @@
.panel {
display: flex;
flex-direction: column;
flex-shrink: 0;
height: 100%;
border-left: 1px solid var(--l1-border);
background: var(--l1-background);
overflow: hidden;
position: relative;
}
.resizeHandle {
position: absolute;
top: 0;
left: 0;
width: 4px;
height: 100%;
cursor: col-resize;
z-index: 10;
&::after {
content: '';
position: absolute;
top: 0;
left: 1px;
width: 2px;
height: 100%;
background: transparent;
transition: background 0.15s ease;
}
&:hover::after {
background: var(--accent-primary);
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
border-bottom: 1px solid var(--l1-border);
flex-shrink: 0;
background: var(--l1-background);
}
.title {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: var(--l1-foreground);
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}

View File

@@ -1,189 +0,0 @@
import { useCallback, useLayoutEffect, useRef, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { History, Maximize2, Plus, Sparkles, X } from '@signozhq/icons';
import ConversationsList from '../components/ConversationsList';
import ConversationView from '../ConversationView';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { VariantContext } from '../VariantContext';
import styles from './AIAssistantPanel.module.scss';
const AI_ASSISTANT_PANEL_OPEN_CLASS = 'ai-assistant-panel-open';
const AI_ASSISTANT_PANEL_WIDTH_VAR = '--ai-assistant-panel-width';
export default function AIAssistantPanel(): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const [showHistory, setShowHistory] = useState(false);
const isOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const closeDrawer = useAIAssistantStore((s) => s.closeDrawer);
const startNewConversation = useAIAssistantStore(
(s) => s.startNewConversation,
);
const handleExpand = useCallback(() => {
if (!activeConversationId) {
return;
}
closeDrawer();
history.push(
ROUTES.AI_ASSISTANT.replace(':conversationId', activeConversationId),
);
}, [activeConversationId, closeDrawer, history]);
const handleNew = useCallback(() => {
startNewConversation();
setShowHistory(false);
}, [startNewConversation]);
// When user picks a conversation from the list, close the sidebar
const handleHistorySelect = useCallback(() => {
setShowHistory(false);
}, []);
// ── Resize logic ──────────────────────────────────────────────────────────
const [panelWidth, setPanelWidth] = useState(380);
const dragStartX = useRef(0);
const dragStartWidth = useRef(0);
useLayoutEffect(() => {
const shouldOffsetChatSupport = isOpen && !isFullScreenPage;
document.body.classList.toggle(
AI_ASSISTANT_PANEL_OPEN_CLASS,
shouldOffsetChatSupport,
);
if (shouldOffsetChatSupport) {
document.body.style.setProperty(
AI_ASSISTANT_PANEL_WIDTH_VAR,
`${panelWidth}px`,
);
} else {
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
}
return (): void => {
document.body.classList.remove(AI_ASSISTANT_PANEL_OPEN_CLASS);
document.body.style.removeProperty(AI_ASSISTANT_PANEL_WIDTH_VAR);
};
}, [isFullScreenPage, isOpen, panelWidth]);
const handleResizeMouseDown = useCallback(
(e: React.MouseEvent) => {
e.preventDefault();
dragStartX.current = e.clientX;
dragStartWidth.current = panelWidth;
const onMouseMove = (ev: MouseEvent): void => {
// Panel is on the right; dragging left (lower clientX) increases width
const delta = dragStartX.current - ev.clientX;
const next = Math.min(Math.max(dragStartWidth.current + delta, 380), 800);
setPanelWidth(next);
};
const onMouseUp = (): void => {
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
document.body.style.cursor = '';
document.body.style.userSelect = '';
};
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
},
[panelWidth],
);
if (!isOpen || isFullScreenPage) {
return null;
}
return (
<VariantContext.Provider value="panel">
<div className={styles.panel} style={{ width: panelWidth }}>
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions */}
<div className={styles.resizeHandle} onMouseDown={handleResizeMouseDown} />
<div className={styles.header}>
<div className={styles.title}>
<Sparkles size={18} color="var(--primary)" />
<span>AI Assistant</span>
</div>
<div className={styles.actions}>
<Tooltip title={showHistory ? 'Back to chat' : 'Conversations'}>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void => setShowHistory((v) => !v)}
aria-label="Toggle conversations"
>
<History size={14} />
</Button>
</Tooltip>
<Tooltip title="New conversation">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleNew}
aria-label="New conversation"
>
<Plus size={14} />
</Button>
</Tooltip>
<Tooltip title="Open full screen">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={handleExpand}
disabled={!activeConversationId}
aria-label="Open full screen"
>
<Maximize2 size={14} />
</Button>
</Tooltip>
<Tooltip title="Close">
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={closeDrawer}
aria-label="Close panel"
>
<X size={14} />
</Button>
</Tooltip>
</div>
</div>
{showHistory ? (
<ConversationsList onSelect={handleHistorySelect} />
) : (
activeConversationId && (
<ConversationView conversationId={activeConversationId} />
)
)}
</div>
</VariantContext.Provider>
);
}

View File

@@ -1,2 +0,0 @@
export * from './AIAssistantPanel';
export { default } from './AIAssistantPanel';

View File

@@ -1,32 +0,0 @@
.trigger {
position: absolute;
bottom: 24px;
right: 24px;
z-index: 10;
width: 44px;
height: 44px;
border-radius: 50%;
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
background: var(--accent-primary);
color: var(--accent-primary-foreground);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.25);
transition:
transform 0.15s,
box-shadow 0.15s;
&:hover {
transform: scale(1.08);
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.32);
}
&:active {
transform: scale(0.96);
}
}

View File

@@ -1,45 +0,0 @@
import { matchPath, useLocation } from 'react-router-dom';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import ROUTES from 'constants/routes';
import { Bot } from '@signozhq/icons';
import {
openAIAssistant,
useAIAssistantStore,
} from '../store/useAIAssistantStore';
import styles from './AIAssistantTrigger.module.scss';
/**
* Floating action button anchored to the bottom-right of the content area.
* Hidden when the panel is already open or when on the full-screen AI Assistant page.
*/
export default function AIAssistantTrigger(): JSX.Element | null {
const { pathname } = useLocation();
const isDrawerOpen = useAIAssistantStore((s) => s.isDrawerOpen);
const isModalOpen = useAIAssistantStore((s) => s.isModalOpen);
const isFullScreenPage = !!matchPath(pathname, {
path: ROUTES.AI_ASSISTANT,
exact: true,
});
if (isDrawerOpen || isModalOpen || isFullScreenPage) {
return null;
}
return (
<Tooltip title="AI Assistant">
<Button
variant="solid"
color="primary"
className={styles.trigger}
onClick={openAIAssistant}
aria-label="Open AI Assistant"
>
<Bot size={20} />
</Button>
</Tooltip>
);
}

View File

@@ -1,2 +0,0 @@
export * from './AIAssistantTrigger';
export { default } from './AIAssistantTrigger';

View File

@@ -1,53 +0,0 @@
.conversation {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.loading {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
font-size: 13px;
color: var(--l3-foreground);
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.inputWrapper {
flex-shrink: 0;
padding: 12px;
border-top: 1px solid var(--l1-border);
&.compact {
padding: 8px;
}
}
.disclaimer {
flex-shrink: 0;
padding: 8px 16px;
font-size: 10px;
line-height: 1.4;
margin-top: 4px;
color: var(--l3-foreground);
text-align: center;
&.compact {
padding: 8px 12px;
}
}

View File

@@ -1,155 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import cx from 'classnames';
import ChatInput, { autoContextKey } from '../components/ChatInput';
import ConversationSkeleton from '../components/ConversationSkeleton';
import VirtualizedMessages from '../components/VirtualizedMessages';
import { getAutoContexts } from '../getAutoContexts';
import { useAIAssistantStore } from '../store/useAIAssistantStore';
import { MessageAttachment } from '../types';
import { MessageContext } from '../../../api/ai-assistant/chat';
import { useVariant } from '../VariantContext';
import styles from './ConversationView.module.scss';
interface ConversationViewProps {
conversationId: string;
}
export default function ConversationView({
conversationId,
}: ConversationViewProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const location = useLocation();
const conversation = useAIAssistantStore(
(s) => s.conversations[conversationId],
);
const isStreamingHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const isLoadingThread = useAIAssistantStore((s) => s.isLoadingThread);
const pendingApprovalHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingApproval ?? null,
);
const pendingClarificationHere = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingClarification ?? null,
);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
// Auto-derived contexts come from the route the user is currently looking
// at (dashboard detail, service metrics, an explorer, …). Skip when the
// user is on the standalone AI Assistant page — there's no "underlying"
// page context to attach. ChatInput renders these as chips and merges
// them with the user's `@`-mention picks before invoking onSend.
const allAutoContexts = useMemo(
() =>
variant === 'page'
? []
: getAutoContexts(location.pathname, location.search),
[variant, location.pathname, location.search],
);
// User-dismissed auto-context entries. Reset whenever the URL changes —
// dismissals are scoped to "this page", not the whole conversation.
const [dismissedAutoKeys, setDismissedAutoKeys] = useState<Set<string>>(
() => new Set(),
);
useEffect(() => {
setDismissedAutoKeys(new Set());
}, [location.pathname, location.search]);
const autoContexts = useMemo(
() =>
allAutoContexts.filter((ctx) => !dismissedAutoKeys.has(autoContextKey(ctx))),
[allAutoContexts, dismissedAutoKeys],
);
const handleDismissAutoContext = useCallback((key: string): void => {
setDismissedAutoKeys((prev) => {
const next = new Set(prev);
next.add(key);
return next;
});
}, []);
const handleSend = useCallback(
(
text: string,
attachments?: MessageAttachment[],
contexts?: MessageContext[],
) => {
void sendMessage(text, attachments, contexts);
},
[sendMessage],
);
const handleCancel = useCallback(() => {
cancelStream(conversationId);
}, [cancelStream, conversationId]);
const messages = conversation?.messages ?? [];
const showDisclaimer = messages.length > 0;
const inputDisabled =
isStreamingHere ||
isLoadingThread ||
Boolean(pendingApprovalHere) ||
Boolean(pendingClarificationHere);
const inputWrapperClass = cx(styles.inputWrapper, {
[styles.compact]: isCompact,
});
const disclaimerClass = cx(styles.disclaimer, {
[styles.compact]: isCompact,
});
// Cover the gap between rehydrate (empty primed entry) and the first
// loadThread response. `isHydrating` is set on the rehydrated conversation
// and cleared once fetchThreads resolves; `isLoadingThread` covers the
// per-thread fetch that follows. Together they keep the skeleton visible
// for persisted chats without flashing it on freshly-created ones.
const isHydrating = Boolean(conversation?.isHydrating);
if ((isLoadingThread || isHydrating) && messages.length === 0) {
return (
<div className={styles.conversation}>
<ConversationSkeleton />
<div className={inputWrapperClass}>
<ChatInput
onSend={handleSend}
disabled
autoContexts={autoContexts}
onDismissAutoContext={handleDismissAutoContext}
/>
</div>
</div>
);
}
return (
<div className={styles.conversation}>
<VirtualizedMessages
conversationId={conversationId}
messages={messages}
isStreaming={isStreamingHere}
/>
{showDisclaimer && (
<div className={disclaimerClass} role="note" aria-live="polite">
SigNoz AI can make mistakes. Please double-check responses.
</div>
)}
<div className={inputWrapperClass}>
<ChatInput
onSend={handleSend}
onCancel={handleCancel}
disabled={inputDisabled}
isStreaming={isStreamingHere}
autoContexts={autoContexts}
onDismissAutoContext={handleDismissAutoContext}
/>
</div>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ConversationView';
export { default } from './ConversationView';

View File

@@ -1,8 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
export type AIAssistantVariant = 'panel' | 'page' | 'modal';
export const VariantContext = createContext<AIAssistantVariant>('page');
export const useVariant = (): AIAssistantVariant => useContext(VariantContext);

View File

@@ -1,32 +0,0 @@
@mixin scrollbar($width: 0.3rem) {
scrollbar-width: thin;
scrollbar-color: transparent transparent;
transition: scrollbar-color 0.2s;
&:hover {
scrollbar-color: var(--l3-border) transparent;
}
&::-webkit-scrollbar {
width: $width;
height: $width;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: transparent;
border-radius: 999px;
transition: background 0.2s;
}
&:hover::-webkit-scrollbar-thumb {
background: var(--l3-border);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-foreground);
}
}

View File

@@ -1,116 +0,0 @@
.section {
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
width: 100%;
padding: 8px;
margin-top: 16px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
// Background, padding-x, and rounding are inherited from the parent
// bubble — the section sits inside the assistant bubble as its last
// block, so it matches the bubble's width by definition.
}
.heading {
display: flex;
align-items: center;
gap: 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--l3-foreground);
}
.headingIcon {
color: var(--primary);
flex-shrink: 0;
}
.list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 10px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 163.636% */
letter-spacing: -0.055px;
cursor: pointer;
transition:
background 0.12s ease,
border-color 0.12s ease,
color 0.12s ease;
&:hover:not(:disabled) {
background: var(--l2-background);
border-color: var(--l3-border);
}
&:disabled {
cursor: default;
opacity: 0.55;
}
&.error {
border-color: var(--accent-cherry);
color: var(--accent-cherry);
}
svg {
flex-shrink: 0;
color: var(--l3-foreground);
}
&:hover:not(:disabled) svg {
color: var(--accent-primary);
}
&.error svg {
color: var(--accent-cherry);
}
}
.spin {
color: var(--accent-primary) !important;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
.chipLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 220px;
}
.chipState {
font-size: 10px;
font-weight: 500;
color: var(--l3-foreground);
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 1px 5px;
border: 1px solid var(--l2-border);
border-radius: 999px;
margin-left: 2px;
}

View File

@@ -1,537 +0,0 @@
import { useEffect, useState } from 'react';
import { matchPath, useHistory, useLocation } from 'react-router-dom';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
import {
IBuilderQuery,
Query,
TagFilter,
} from 'types/api/queryBuilder/queryBuilderData';
import cx from 'classnames';
import { v4 as uuidv4 } from 'uuid';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import type { MessageActionDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
ApplyFilterSignalDTO,
MessageActionKindDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
restoreExecution,
revertExecution,
undoExecution,
} from 'api/ai-assistant/chat';
import ROUTES from 'constants/routes';
import { QueryParams } from 'constants/query';
import { openInNewTab } from 'utils/navigation';
import {
ArchiveRestore,
BookOpen,
Check,
ExternalLink,
Eye,
Filter,
LoaderCircle,
MessageCircle,
RotateCcw,
Sparkles,
TriangleAlert,
Undo,
} from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import styles from './ActionsSection.module.scss';
interface ActionsSectionProps {
actions: MessageActionDTO[];
}
type ChipState = 'idle' | 'loading' | 'success' | 'error';
interface ChipResult {
state: ChipState;
error?: string;
}
/** Maps each MessageActionKindDTO to its display icon. */
function ActionIcon({
kind,
size = 12,
}: {
kind: MessageActionDTO['kind'];
size?: number;
}): JSX.Element {
switch (kind) {
case MessageActionKindDTO.undo:
return <Undo size={size} />;
case MessageActionKindDTO.revert:
return <RotateCcw size={size} />;
case MessageActionKindDTO.restore:
return <ArchiveRestore size={size} />;
case MessageActionKindDTO.follow_up:
return <MessageCircle size={size} />;
case MessageActionKindDTO.open_resource:
return <Eye size={size} />;
case MessageActionKindDTO.open_docs:
return <BookOpen size={size} />;
case MessageActionKindDTO.apply_filter:
return <Filter size={size} />;
default:
return <ExternalLink size={size} />;
}
}
/**
* Resolves an `open_resource` action to an in-app route.
* Resource taxonomy mirrors `MessageContextDTOType`: dashboard, alert,
* saved_view, service, and the *_explorer signals.
*/
function resourceRoute(
resourceType: string,
resourceId: string,
): string | null {
switch (resourceType) {
case 'dashboard':
return ROUTES.DASHBOARD.replace(':dashboardId', resourceId);
case 'alert': {
const params = new URLSearchParams({ [QueryParams.ruleId]: resourceId });
return `${ROUTES.EDIT_ALERTS}?${params.toString()}`;
}
case 'service':
return ROUTES.SERVICE_METRICS.replace(':servicename', resourceId);
case 'saved_view':
// No detail route — saved views land on the list page.
// Caller may provide signal-aware metadata in future; default to logs.
return ROUTES.LOGS_SAVE_VIEWS;
case 'logs_explorer':
return ROUTES.LOGS_EXPLORER;
case 'traces_explorer':
return ROUTES.TRACES_EXPLORER;
case 'metrics_explorer':
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
}
}
/**
* The agent emits `action.query` as the SigNoz REST query-range request body:
*
* - V5 (current backend): `{ ..., compositeQuery: { queries: [{ type, spec }] } }`
* — each `spec` already carries `filter.expression` directly.
* - V3 (legacy): `{ ..., compositeQuery: { builderQueries: { A: {...} } } }`
*
* The URL's `compositeQuery` param expects the in-app shape
* (`{ queryType, builder: { queryData: [...], queryFormulas, queryTraceOperator }, ... }`).
* `mapQueryDataFromApi` already handles both API shapes for query-range
* responses, so we delegate to it instead of maintaining a parallel translator.
*
* Defensive: if the agent ever sends the URL shape directly (top-level
* `builder.queryData`), we pass it through unchanged.
*/
function toUrlCompositeQuery(
actionQuery: Record<string, unknown>,
): Record<string, unknown> | null {
// Already in URL shape — use as-is (with envelope defaults filled in).
if (
actionQuery.builder &&
typeof actionQuery.builder === 'object' &&
Array.isArray((actionQuery.builder as Record<string, unknown>).queryData)
) {
return {
queryType: actionQuery.queryType ?? 'builder',
promql: actionQuery.promql ?? [],
clickhouse_sql: actionQuery.clickhouse_sql ?? [],
id: uuidv4(),
unit: actionQuery.unit ?? '',
...actionQuery,
};
}
// API shape: extract the inner compositeQuery and let the shared mapper
// normalise V3/V5 spec → IBuilderQuery for us.
const composite = (actionQuery.compositeQuery ?? actionQuery) as
| Record<string, unknown>
| undefined;
if (!composite) {
return null;
}
try {
const mapped = mapQueryDataFromApi(
composite as unknown as ICompositeMetricQuery,
);
// `mapQueryDataFromApi` falls back to `initialQueryState.builder` when
// neither `queries` nor `builderQueries` is present — detect that and
// signal "unrecognised payload" instead of silently navigating to an
// empty query.
if (mapped.builder.queryData.length === 0) {
return null;
}
return mapped as unknown as Record<string, unknown>;
} catch {
return null;
}
}
/**
* Tracks apply_filter action keys that have already been auto-applied so we
* don't re-fire on re-renders / re-mounts. Module-level (intentionally) — it's
* not state we'd ever want to reset on a component unmount; the action's
* filters are already on the URL after the first auto-apply.
*/
const autoAppliedFilterKeys = new Set<string>();
/**
* True when the user is currently on the explorer that an apply_filter
* action targets — i.e. when auto-applying makes sense (the page is mounted
* and ready to react to a URL change without a route transition).
*/
function signalMatchesPathname(
signal: ApplyFilterSignalDTO,
pathname: string,
): boolean {
switch (signal) {
case ApplyFilterSignalDTO.logs:
return Boolean(
matchPath(pathname, { path: ROUTES.LOGS_EXPLORER, exact: false }),
);
case ApplyFilterSignalDTO.traces:
return Boolean(
matchPath(pathname, { path: ROUTES.TRACES_EXPLORER, exact: false }),
);
case ApplyFilterSignalDTO.metrics:
return Boolean(
matchPath(pathname, {
path: ROUTES.METRICS_EXPLORER_EXPLORER,
exact: false,
}),
);
default:
return false;
}
}
/**
* Stable per-action key used both to dedupe auto-applies and as the React key
* for the chip. Mirrors the same construction we do in the render loop below.
*/
function actionKey(action: MessageActionDTO, index: number): string {
return action.actionMetadataId
? `${action.kind}:${action.actionMetadataId}`
: `${action.kind}:${action.label}:${index}`;
}
/** Maps a signal to its target explorer route. */
function explorerRouteForSignal(signal: ApplyFilterSignalDTO): string | null {
switch (signal) {
case ApplyFilterSignalDTO.logs:
return ROUTES.LOGS_EXPLORER;
case ApplyFilterSignalDTO.traces:
return ROUTES.TRACES_EXPLORER;
case ApplyFilterSignalDTO.metrics:
return ROUTES.METRICS_EXPLORER_EXPLORER;
default:
return null;
}
}
interface ApplyFilterDeps {
history: ReturnType<typeof useHistory>;
pathname: string;
redirectWithQueryBuilderData: ReturnType<
typeof useQueryBuilder
>['redirectWithQueryBuilderData'];
handleSetQueryData: ReturnType<typeof useQueryBuilder>['handleSetQueryData'];
}
/**
* The V5 query-builder UI binds the WHERE clause CodeMirror editor to
* `builder.queryData[i].filter.expression`. The agent normally only sends
* `filters.items`, so we derive the expression per query before pushing
* state. Same recipe as `pages/<X>/aiActions.ts` — keeps the immediate
* UI update consistent with what the URL parser would produce on reload.
*/
function withDerivedFilterExpressions(query: Query): Query {
const queryData = query.builder.queryData.map((q): IBuilderQuery => {
const items = q.filters?.items ?? [];
if (items.length === 0) {
return q;
}
const filters: TagFilter = { items, op: q.filters?.op || 'AND' };
return {
...q,
filters,
filter: convertFiltersToExpression(filters),
};
});
return { ...query, builder: { ...query.builder, queryData } };
}
/**
* Single entry point for an apply_filter action — used by both the auto-apply
* effect (fired once when the user is already on the matching explorer) and
* the manual chip-click handler.
*
* - On-page: push each builder query into the QueryBuilder provider via
* `handleSetQueryData` so the WHERE clause re-renders immediately, then
* `redirectWithQueryBuilderData` to persist it on the URL. Mirrors the
* page-action recipe — calling redirect alone is not sufficient because
* the URL→state effect runs after the next render and the editor binds
* to `filter.expression`, not `filters.items`.
* - Off-page: use `history.push` so the landing explorer initializes from
* the new URL on mount.
*/
function applyFilter(action: MessageActionDTO, deps: ApplyFilterDeps): void {
// eslint-disable-next-line no-console
console.log('[apply_filter] enter', {
signal: action.signal,
query: action.query,
pathname: deps.pathname,
});
if (!action.signal || !action.query) {
// eslint-disable-next-line no-console
console.warn('[apply_filter] bail: missing signal or query', action);
return;
}
const urlQuery = toUrlCompositeQuery(action.query as Record<string, unknown>);
if (!urlQuery) {
// eslint-disable-next-line no-console
console.warn(
'[apply_filter] bail: toUrlCompositeQuery returned null — agent payload shape unrecognized',
action.query,
);
return;
}
const normalized = withDerivedFilterExpressions(urlQuery as unknown as Query);
// eslint-disable-next-line no-console
console.log('[apply_filter] normalized', normalized);
if (signalMatchesPathname(action.signal, deps.pathname)) {
// eslint-disable-next-line no-console
console.log('[apply_filter] on-page → handleSetQueryData + redirect');
normalized.builder.queryData.forEach((q, i) => {
deps.handleSetQueryData(i, q);
});
deps.redirectWithQueryBuilderData(normalized);
return;
}
const base = explorerRouteForSignal(action.signal);
if (!base) {
// eslint-disable-next-line no-console
console.warn('[apply_filter] bail: no route for signal', action.signal);
return;
}
// eslint-disable-next-line no-console
console.log('[apply_filter] off-page → history.push', base);
const encoded = encodeURIComponent(JSON.stringify(normalized));
deps.history.push(`${base}?${QueryParams.compositeQuery}=${encoded}`);
}
/** Picks the right rollback API call for a given action kind. */
function rollbackCall(
kind: MessageActionDTO['kind'],
): ((id: string) => Promise<unknown>) | null {
switch (kind) {
case MessageActionKindDTO.undo:
return undoExecution;
case MessageActionKindDTO.revert:
return revertExecution;
case MessageActionKindDTO.restore:
return restoreExecution;
default:
return null;
}
}
/**
* Renders the actions attached to a single assistant message.
*
* Hidden when the message has no actions. Rendered inside `MessageBubble`
* between the message body and the feedback bar.
*/
export default function ActionsSection({
actions,
}: ActionsSectionProps): JSX.Element | null {
const history = useHistory();
const { pathname } = useLocation();
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const { redirectWithQueryBuilderData, handleSetQueryData } = useQueryBuilder();
// Per-chip click state, keyed by chip key (see `key` below). Persists
// loading/success/error so the chip reflects the rollback outcome until
// the underlying action.state catches up via a fresh thread fetch.
const [results, setResults] = useState<Record<string, ChipResult>>({});
// Auto-apply any apply_filter action whose signal matches the page the
// user is currently on (logs/traces/metrics explorer). Same code path as
// the manual click below — just fired automatically once. The chip stays
// clickable as a fallback for the off-page case. Dedupes via a module-
// level set so re-renders / re-mounts don't re-fire.
useEffect(() => {
actions.forEach((action, i) => {
if (action.kind !== MessageActionKindDTO.apply_filter) {
return;
}
if (!action.signal || !action.query) {
return;
}
if (!signalMatchesPathname(action.signal, pathname)) {
return;
}
const key = actionKey(action, i);
if (autoAppliedFilterKeys.has(key)) {
return;
}
autoAppliedFilterKeys.add(key);
applyFilter(action, {
history,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
});
});
}, [
actions,
pathname,
history,
redirectWithQueryBuilderData,
handleSetQueryData,
]);
if (actions.length === 0) {
return null;
}
const setResult = (key: string, result: ChipResult): void => {
setResults((prev) => ({ ...prev, [key]: result }));
};
const runRollback = async (
key: string,
action: MessageActionDTO,
): Promise<void> => {
const call = rollbackCall(action.kind);
if (!call || !action.actionMetadataId) {
return;
}
setResult(key, { state: 'loading' });
try {
await call(action.actionMetadataId);
setResult(key, { state: 'success' });
} catch (err) {
const message = err instanceof Error ? err.message : 'Failed';
setResult(key, { state: 'error', error: message });
}
};
const handleClick = (key: string, action: MessageActionDTO): void => {
switch (action.kind) {
case MessageActionKindDTO.open_docs: {
if (action.url) {
openInNewTab(action.url);
}
break;
}
case MessageActionKindDTO.follow_up: {
if (action.label) {
void sendMessage(action.label);
}
break;
}
case MessageActionKindDTO.open_resource: {
if (action.resourceType && action.resourceId) {
const path = resourceRoute(action.resourceType, action.resourceId);
if (path) {
history.push(path);
}
}
break;
}
case MessageActionKindDTO.undo:
case MessageActionKindDTO.revert:
case MessageActionKindDTO.restore: {
void runRollback(key, action);
break;
}
case MessageActionKindDTO.apply_filter: {
applyFilter(action, {
history,
pathname,
redirectWithQueryBuilderData,
handleSetQueryData,
});
break;
}
default:
break;
}
};
return (
<div className={styles.section}>
<div className={styles.heading}>
<Sparkles size={12} className={styles.headingIcon} />
<span className={styles.headingText}>Suggested actions</span>
</div>
<div className={styles.list}>
{actions.map((action, i) => {
// Stable per-action key (shared with the auto-apply dedupe set).
// `actionMetadataId` alone isn't unique — the server can attach
// the same id to multiple kinds (e.g. an `undo` and `revert` chip
// for the same operation), so we always include the kind. Falls
// back to label + index when the id is missing (e.g. follow_up /
// open_docs).
const key = actionKey(action, i);
const result = results[key];
const isLoading = result?.state === 'loading';
const isSuccess = result?.state === 'success';
const isError = result?.state === 'error';
// `action.state` is a free-form string from the server (e.g. "active",
// "applied"). Without a documented terminal vocabulary we don't auto-
// disable on it — only the local in-flight click result does. The state
// is still surfaced visually via the suffix pill below.
const isDisabled = isLoading || isSuccess;
const tooltip = isError ? result.error : (action.tooltip ?? undefined);
let icon: JSX.Element;
if (isLoading) {
icon = <LoaderCircle size={12} className={styles.spin} />;
} else if (isSuccess) {
icon = <Check size={12} />;
} else if (isError) {
icon = <TriangleAlert size={12} />;
} else {
icon = <ActionIcon kind={action.kind} />;
}
const chip = (
<Button
variant="outlined"
color="secondary"
size="sm"
className={cx(styles.chip, { [styles.error]: isError })}
onClick={(): void => handleClick(key, action)}
disabled={isDisabled}
aria-label={action.label}
prefix={icon}
>
<span className={styles.chipLabel}>{action.label}</span>
</Button>
);
return tooltip ? (
<Tooltip key={key} title={tooltip}>
{chip}
</Tooltip>
) : (
<span key={key}>{chip}</span>
);
})}
</div>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ActionsSection';
export { default } from './ActionsSection';

View File

@@ -1,282 +0,0 @@
@use '../../_scrollbar' as *;
.card {
border: 1px solid var(--l1-border);
border-radius: var(--radius-2);
padding: 12px;
background: var(--l1-background);
display: flex;
flex-direction: column;
gap: 16px;
width: 100%;
&.decided {
border-color: var(--l2-border);
background: transparent;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 6px 10px;
}
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.shieldIcon {
flex-shrink: 0;
color: var(--primary);
}
.headerLabel {
font-size: 12px;
font-weight: 600;
color: var(--l1-foreground);
}
.resourceBadge {
margin-left: auto;
font-size: 10px;
font-family: var(--font-mono, monospace);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 1px 5px;
color: var(--l2-foreground);
}
.summary {
font-size: 13px;
color: var(--l1-foreground);
margin: 0;
line-height: 1.5;
}
.diffSection {
display: flex;
flex-direction: column;
gap: 6px;
}
.diffHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.diffHeaderLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--l2-foreground);
}
.diff {
display: flex;
gap: 8px;
// Fixed-height dialog (70vh) — let the diff fill the body and the
// JSON panes scroll internally rather than pushing the dialog taller.
&.expanded {
flex: 1;
min-height: 0;
.diffBlock {
min-height: 0;
}
.diffJson {
flex: 1;
max-height: none;
overflow: auto;
font-size: 12px;
}
}
// Unified view: a single column instead of two side-by-side blocks.
// The block-level flex switches to column so the diff pane fills.
&.unified {
flex-direction: column;
}
}
.diffHeaderActions {
display: flex;
align-items: center;
gap: 4px;
}
// Container for line-by-line diff output. Mirrors `.diffJson` for scroll
// + monospace styling but renders an inner stack of `.diffLine` rows
// instead of a single `<pre>` so individual lines can be colored.
.diffPane {
font-family: var(--font-mono, monospace);
font-size: 12px;
background: var(--l2-background);
border-radius: var(--radius-2);
margin: 0;
overflow: auto;
color: var(--l2-foreground);
flex: 1;
min-height: 0;
@include scrollbar(0.4rem);
&.wrapped .diffLineText {
white-space: pre-wrap;
word-break: break-word;
}
}
.diffLine {
display: flex;
align-items: flex-start;
gap: 6px;
padding: 0 8px;
min-height: 18px;
line-height: 1.5;
}
.diffLineAdd {
background: color-mix(in srgb, var(--accent-forest), transparent 88%);
color: var(--l1-foreground);
.diffGutter {
color: var(--accent-forest);
}
}
.diffLineRemove {
background: color-mix(in srgb, var(--accent-cherry), transparent 88%);
color: var(--l1-foreground);
.diffGutter {
color: var(--accent-cherry);
}
}
// Empty filler row in split view to keep before/after columns aligned
// when one side has an added/removed line. Visible as a faint band so
// the eye still tracks the row.
.diffLinePlaceholder {
background: color-mix(in srgb, var(--l3-foreground), transparent 94%);
min-height: 18px;
}
.diffGutter {
flex-shrink: 0;
width: 12px;
text-align: center;
font-weight: 600;
user-select: none;
color: var(--l3-foreground);
}
.diffLineText {
white-space: pre;
flex: 1;
min-width: 0;
}
.diffBlock {
flex: 1;
display: flex;
flex-direction: column;
gap: 3px;
min-width: 0;
&.before .diffLabel {
color: var(--accent-cherry);
}
&.after .diffLabel {
color: var(--accent-forest);
}
}
.diffBlockHeader {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 18px;
}
.diffLabel {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.diffJson {
font-family: var(--font-mono, monospace);
font-size: 11px;
background: var(--l2-background);
border-radius: var(--radius-2);
padding: 5px 7px;
margin: 0;
overflow: auto;
white-space: pre;
max-height: 140px;
color: var(--l2-foreground);
@include scrollbar(0.4rem);
// Wrap long lines instead of horizontal scrolling. Used in the
// expanded modal when the user toggles the "Wrap text" button.
&.wrapped {
white-space: pre-wrap;
word-break: break-word;
overflow-x: hidden;
}
}
.diffModalBody {
display: flex;
flex-direction: column;
gap: 12px;
flex: 1;
min-height: 0;
padding: 16px;
overflow: hidden;
}
.diffToolbarRow {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.diffModalSummary {
font-size: 13px;
color: var(--l2-foreground);
margin: 0;
line-height: 1.5;
flex-shrink: 0;
}
.actions {
display: flex;
gap: 6px;
}
.statusIcon {
flex-shrink: 0;
&.ok {
color: var(--accent-forest);
}
&.no {
color: var(--l3-foreground);
}
}
.statusText {
font-size: 13px;
color: var(--l2-foreground);
}

View File

@@ -1,471 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import {
Dialog,
DialogCloseButton,
DialogContent,
DialogHeader,
DialogSubtitle,
DialogTitle,
} from '@signozhq/ui/dialog';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import type {
ApprovalEventDTO,
ApprovalEventDTODiff,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import {
Check,
Columns2,
Copy,
List,
Maximize2,
Shield,
WrapText,
X,
} from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import styles from './ApprovalCard.module.scss';
interface ApprovalCardProps {
conversationId: string;
approval: ApprovalEventDTO;
}
/**
* Rendered when the agent emits an `approval` SSE event.
* The agent has paused execution; the user must approve or reject
* before the stream resumes on a new execution.
*/
export default function ApprovalCard({
conversationId,
approval,
}: ApprovalCardProps): JSX.Element {
const approveAction = useAIAssistantStore((s) => s.approveAction);
const rejectAction = useAIAssistantStore((s) => s.rejectAction);
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const [decided, setDecided] = useState<'approved' | 'rejected' | null>(null);
const [diffExpanded, setDiffExpanded] = useState(false);
const [wrapText, setWrapText] = useState(false);
const [viewMode, setViewMode] = useState<DiffViewMode>('split');
const handleApprove = async (): Promise<void> => {
setDecided('approved');
await approveAction(conversationId, approval.approvalId);
};
const handleReject = async (): Promise<void> => {
setDecided('rejected');
await rejectAction(conversationId, approval.approvalId);
};
// After decision the card shows a compact confirmation row
if (decided === 'approved') {
return (
<div className={cx(styles.card, styles.decided)}>
<Check size={13} className={cx(styles.statusIcon, styles.ok)} />
<span className={styles.statusText}>Approved resuming</span>
</div>
);
}
if (decided === 'rejected') {
return (
<div className={cx(styles.card, styles.decided)}>
<X size={13} className={cx(styles.statusIcon, styles.no)} />
<span className={styles.statusText}>Rejected.</span>
</div>
);
}
return (
<div className={styles.card}>
<div className={styles.header}>
<Shield size={13} className={styles.shieldIcon} />
<span className={styles.headerLabel}>Action requires approval</span>
<span className={styles.resourceBadge}>
{approval.actionType} · {approval.resourceType}
</span>
</div>
<p className={styles.summary}>{approval.summary}</p>
{approval.diff && (
<div className={styles.diffSection}>
<div className={styles.diffHeader}>
<span className={styles.diffHeaderLabel}>Diff</span>
<Button
variant="link"
size="sm"
color="secondary"
onClick={(): void => setDiffExpanded(true)}
title="Expand diff"
aria-label="Expand diff"
>
<Maximize2 size={12} />
</Button>
</div>
<DiffView diff={approval.diff} />
</div>
)}
<Dialog open={diffExpanded} onOpenChange={setDiffExpanded}>
<DialogContent
className={styles.diffDialog}
style={{ width: '80vw', maxWidth: '80vw', height: '70vh' }}
>
<DialogHeader>
<DialogTitle>Approval diff</DialogTitle>
<DialogSubtitle>
{approval.actionType} · {approval.resourceType}
</DialogSubtitle>
</DialogHeader>
<div className={styles.diffModalBody}>
<p className={styles.diffModalSummary}>{approval.summary}</p>
<div className={styles.diffToolbarRow}>
<ToggleGroup
type="single"
size="sm"
value={viewMode}
onChange={(next): void => {
// Radix `single` group can emit '' when the active item
// is clicked again — preserve the current mode.
if (next === 'split' || next === 'unified') {
setViewMode(next);
}
}}
>
<ToggleGroupItem value="split" aria-label="Split view">
<Columns2 size={12} />
</ToggleGroupItem>
<ToggleGroupItem value="unified" aria-label="Unified view">
<List size={12} />
</ToggleGroupItem>
</ToggleGroup>
<ToggleGroup
type="multiple"
size="sm"
value={wrapText ? ['wrap'] : []}
onChange={(next): void => setWrapText(next.includes('wrap'))}
>
<ToggleGroupItem
value="wrap"
aria-label={wrapText ? 'Disable text wrap' : 'Wrap long lines'}
>
<WrapText size={12} />
</ToggleGroupItem>
</ToggleGroup>
</div>
{approval.diff && (
<DiffView
diff={approval.diff}
expanded
wrapText={wrapText}
viewMode={viewMode}
/>
)}
</div>
<DialogCloseButton onClick={(): void => setDiffExpanded(false)} />
</DialogContent>
</Dialog>
<div className={styles.actions}>
<Button
variant="solid"
size="sm"
onClick={handleApprove}
disabled={isStreaming}
prefix={<Check />}
>
Approve
</Button>
<Button
variant="outlined"
size="sm"
color="secondary"
onClick={handleReject}
disabled={isStreaming}
prefix={<X />}
>
Reject
</Button>
</div>
</div>
);
}
type DiffViewMode = 'split' | 'unified';
interface DiffViewProps {
diff: ApprovalEventDTODiff;
expanded?: boolean;
/** When true, long lines wrap instead of horizontally scrolling. */
wrapText?: boolean;
/** Side-by-side ('split') vs single-column ('unified'). Only honored when expanded. */
viewMode?: DiffViewMode;
}
function DiffView({
diff,
expanded = false,
wrapText = false,
viewMode = 'split',
}: DiffViewProps): JSX.Element {
const beforeText =
diff.before !== undefined ? JSON.stringify(diff.before, null, 2) : '';
const afterText =
diff.after !== undefined ? JSON.stringify(diff.after, null, 2) : '';
// In the inline (collapsed) preview keep the original two-pane layout
// without diff highlighting — diffing is opt-in via the expanded modal.
if (!expanded) {
const jsonClass = cx(styles.diffJson, { [styles.wrapped]: wrapText });
return (
<div className={styles.diff}>
{diff.before !== undefined && (
<div className={cx(styles.diffBlock, styles.before)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>Before</span>
</div>
<pre className={jsonClass}>{beforeText}</pre>
</div>
)}
{diff.after !== undefined && (
<div className={cx(styles.diffBlock, styles.after)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>After</span>
</div>
<pre className={jsonClass}>{afterText}</pre>
</div>
)}
</div>
);
}
const lines = computeLineDiff(beforeText, afterText);
if (viewMode === 'unified') {
// Build the same +/-/space-prefixed text that's on screen so Copy
// gives the user exactly what they see.
const unifiedText = lines
.map((line) => `${prefixFor(line.op)} ${line.text}`)
.join('\n');
return (
<div className={cx(styles.diff, styles.expanded, styles.unified)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>Diff</span>
<div className={styles.diffHeaderActions}>
<CopyButton text={unifiedText} label="diff" />
</div>
</div>
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
{lines.map((line, idx) => (
<DiffLine
// stable enough — input strings are immutable for the view's lifetime
// eslint-disable-next-line react/no-array-index-key
key={idx}
op={line.op}
text={line.text}
prefix={prefixFor(line.op)}
/>
))}
</div>
</div>
);
}
// Split view: align side-by-side using the LCS result. `equal` lines
// appear on both sides; `remove` only on the left, `add` only on the
// right (with an empty placeholder on the missing side so rows stay
// aligned vertically).
return (
<div className={cx(styles.diff, styles.expanded)}>
<div className={cx(styles.diffBlock, styles.before)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>Before</span>
<CopyButton text={beforeText} label="before" />
</div>
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
{lines.map((line, idx) => {
const op = line.op === 'add' ? 'placeholder' : line.op;
const text = line.op === 'add' ? '' : line.text;
// eslint-disable-next-line react/no-array-index-key
return <DiffLine key={idx} op={op} text={text} />;
})}
</div>
</div>
<div className={cx(styles.diffBlock, styles.after)}>
<div className={styles.diffBlockHeader}>
<span className={styles.diffLabel}>After</span>
<CopyButton text={afterText} label="after" />
</div>
<div className={cx(styles.diffPane, { [styles.wrapped]: wrapText })}>
{lines.map((line, idx) => {
const op = line.op === 'remove' ? 'placeholder' : line.op;
const text = line.op === 'remove' ? '' : line.text;
// eslint-disable-next-line react/no-array-index-key
return <DiffLine key={idx} op={op} text={text} />;
})}
</div>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Line diff — small LCS-based implementation. Avoids pulling in `diff`
// since the inputs are JSON.stringify output (line-oriented, typically
// well under a few hundred lines for resource diffs).
// ---------------------------------------------------------------------------
type LineOp = 'equal' | 'add' | 'remove';
type RenderOp = LineOp | 'placeholder';
interface DiffLineEntry {
op: LineOp;
text: string;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
function computeLineDiff(before: string, after: string): DiffLineEntry[] {
if (before === after) {
return splitLines(before).map((text) => ({ op: 'equal', text }));
}
const a = splitLines(before);
const b = splitLines(after);
const m = a.length;
const n = b.length;
// dp[i][j] = length of LCS between a[0..i] and b[0..j]
const dp: number[][] = Array.from({ length: m + 1 }, () =>
new Array<number>(n + 1).fill(0),
);
for (let i = 1; i <= m; i += 1) {
for (let j = 1; j <= n; j += 1) {
if (a[i - 1] === b[j - 1]) {
dp[i][j] = dp[i - 1][j - 1] + 1;
} else {
dp[i][j] = Math.max(dp[i - 1][j], dp[i][j - 1]);
}
}
}
// Backtrack to produce the diff
const result: DiffLineEntry[] = [];
let i = m;
let j = n;
while (i > 0 && j > 0) {
if (a[i - 1] === b[j - 1]) {
result.push({ op: 'equal', text: a[i - 1] });
i -= 1;
j -= 1;
} else if (dp[i - 1][j] >= dp[i][j - 1]) {
result.push({ op: 'remove', text: a[i - 1] });
i -= 1;
} else {
result.push({ op: 'add', text: b[j - 1] });
j -= 1;
}
}
while (i > 0) {
result.push({ op: 'remove', text: a[i - 1] });
i -= 1;
}
while (j > 0) {
result.push({ op: 'add', text: b[j - 1] });
j -= 1;
}
result.reverse();
return result;
}
function splitLines(text: string): string[] {
if (text === '') {
return [];
}
return text.split('\n');
}
function prefixFor(op: LineOp): string {
if (op === 'add') {
return '+';
}
if (op === 'remove') {
return '-';
}
return ' ';
}
interface DiffLineProps {
op: RenderOp;
text: string;
/** Optional gutter prefix used in unified view (`+` / `-` / ` `). */
prefix?: string;
}
function DiffLine({ op, text, prefix }: DiffLineProps): JSX.Element {
const cls = cx(styles.diffLine, {
[styles.diffLineAdd]: op === 'add',
[styles.diffLineRemove]: op === 'remove',
[styles.diffLinePlaceholder]: op === 'placeholder',
});
return (
<div className={cls}>
{prefix !== undefined && (
<span className={styles.diffGutter} aria-hidden="true">
{prefix}
</span>
)}
<span className={styles.diffLineText}>{text || ' '}</span>
</div>
);
}
interface CopyButtonProps {
text: string;
label: string;
}
function CopyButton({ text, label }: CopyButtonProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
// Track the timeout so an unmount mid-flight doesn't try to setState on
// a dead component (and so a rapid re-click resets the 1.5s window).
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(
() => (): void => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
},
[],
);
const handleCopy = (): void => {
copyToClipboard(text);
setCopied(true);
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
timeoutRef.current = setTimeout(() => setCopied(false), 1500);
};
return (
<Button
variant="ghost"
size="sm"
color="secondary"
onClick={handleCopy}
title={copied ? `Copied ${label}` : `Copy ${label}`}
aria-label={copied ? `Copied ${label}` : `Copy ${label}`}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ApprovalCard';
export { default } from './ApprovalCard';

View File

@@ -1,462 +0,0 @@
@use '../../_scrollbar' as *;
.input {
display: flex;
flex-direction: column;
gap: 10px;
background: var(--l1-background);
border-radius: var(--radius-2);
padding: 8px;
border: 1px solid var(--l1-border);
transition: border-color 0.15s;
position: relative;
&:focus-within {
border-color: var(--l1-border);
}
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.contextTags {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.contextTag {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: var(--l2-background);
border: 1px solid var(--l1-border);
border-radius: var(--radius-2);
padding: 4px 6px 4px 8px;
color: var(--l1-foreground);
button {
height: auto !important;
min-height: 0 !important;
width: auto !important;
}
// `auto` chips are derived from the URL (current page) — visually
// distinguished by a dashed border + slightly muted text so the user
// can tell them apart from explicit @-mentions. Tighter padding /
// font-size keeps them visually subordinate to user `@`-picks.
&.auto {
border-style: dashed;
color: var(--l2-foreground);
background: transparent;
font-size: 10px;
padding: 2px 4px 2px 6px;
gap: 3px;
}
}
.contextTagContent {
display: flex;
align-items: center;
gap: 8px;
max-width: 220px;
.contextTagCategory {
flex-shrink: 0;
}
.contextTagLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
.contextTagCategory {
flex-shrink: 0;
}
.contextTagLabel {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contextTagRemove {
flex-shrink: 0;
padding: 0 !important;
height: auto !important;
min-height: 0 !important;
}
.attachmentChip {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 11px;
background: var(--l3-background);
border-radius: var(--radius-2);
padding: 2px 6px 2px 8px;
color: var(--l2-foreground);
max-width: 180px;
}
.attachmentName {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.attachmentRemove {
flex-shrink: 0;
padding: 0 !important;
height: auto !important;
min-height: 0 !important;
}
.row {
display: flex;
align-items: center;
gap: 8px;
}
.composer {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 4px;
}
.footer {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.leftActions,
.rightActions {
display: flex;
align-items: center;
gap: 8px;
}
.attachBtn {
color: var(--l3-foreground);
}
.contextBtn {
flex-shrink: 0;
gap: 6px;
border-style: dashed !important;
padding-inline: 12px !important;
}
.textarea {
display: block;
width: 100%;
background: transparent;
border: none;
outline: none;
resize: none;
color: var(--l1-foreground);
font-size: 12px;
line-height: 1.5;
overflow-y: auto;
font-family: inherit;
@include scrollbar(0.2rem);
&::placeholder {
color: var(--l3-foreground);
opacity: 0.6;
}
&:disabled {
opacity: 0.2;
cursor: not-allowed;
}
}
.charWarning {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 6px 8px;
font-size: 11px;
font-weight: 500;
color: var(--accent-sienna);
background: color-mix(in srgb, var(--accent-sienna), transparent 90%);
border: 1px solid color-mix(in srgb, var(--accent-sienna), transparent 65%);
border-radius: var(--radius-2);
}
.sendBtn {
flex-shrink: 0;
border-radius: var(--radius-2);
&.stop {
background: var(--accent-cherry) !important;
border-color: var(--accent-cherry) !important;
&:hover {
opacity: 0.85;
}
}
}
.contextPopover {
width: 480px !important;
max-width: min(92vw, 480px);
margin-left: 16px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
box-shadow: 0 16px 36px rgba(0, 0, 0, 0.3);
padding: 8px;
// Clip horizontal overflow so long entity titles can't poke past the
// popover's right edge. Vertical overflow is handled inside
// `.contextPopoverEntities`.
overflow-x: hidden;
--popover-padding: 0;
z-index: 1000;
}
.contextPopoverContent {
display: grid;
grid-template-columns: 180px 1fr;
min-height: 250px;
min-width: 0;
}
.contextPopoverCategories {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px;
border-right: 1px solid var(--l2-border);
}
.contextPopoverCategoryItem {
display: flex;
align-items: center;
gap: 8px;
height: 32px;
padding: 0 8px;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
font-size: 12px;
font-weight: 550;
text-align: left;
cursor: pointer;
transition:
background 0.15s ease,
border-color 0.15s ease,
color 0.15s ease;
&:hover {
background: var(--l2-background);
color: var(--l1-foreground);
border-color: var(--l2-border);
}
&.active {
background: color-mix(in srgb, var(--accent-primary), transparent 86%);
color: var(--l1-foreground);
border-color: color-mix(in srgb, var(--accent-primary), transparent 64%);
}
}
.contextPopoverRight {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
// Match the previous fixed entity-list height so the inner search +
// scrolling list have a definite container to size against.
height: 320px;
}
.contextPopoverSearch {
padding: 8px;
flex-shrink: 0;
position: relative;
width: 90%;
}
.contextPopoverSearchInput {
width: 100%;
box-sizing: border-box;
}
.contextPopoverEntities {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 8px;
min-width: 0;
@include scrollbar(0.2rem);
}
.contextPopoverEntityItem {
display: flex;
align-items: center;
gap: 10px;
padding: 8px;
border: 1px solid color-mix(in srgb, var(--l1-foreground), transparent 96%);
border-radius: var(--radius-2);
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 1.35;
text-align: left;
cursor: pointer;
// Required for the inner span's `text-overflow: ellipsis` to engage —
// flex items default to `min-width: auto` (intrinsic width) and would
// otherwise grow past their parent's width to fit long titles.
min-width: 0;
transition:
background 0.15s ease,
border-color 0.15s ease,
transform 0.15s ease;
&:hover {
background: var(--l2-background);
border-color: var(--l2-border);
transform: translateY(-1px);
}
&.selected {
background: color-mix(in srgb, var(--accent-primary), transparent 86%);
border-color: color-mix(in srgb, var(--accent-primary), transparent 64%);
span {
color: var(--l1-foreground);
font-weight: 600;
}
}
}
.contextPopoverEntityItemText {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.contextPopoverEmpty {
font-size: 12px;
color: var(--l3-foreground);
padding: 10px 8px;
}
.micBtn {
flex-shrink: 0;
}
.micRecording {
display: flex;
align-items: center;
background: var(--l2-background);
border-radius: 999px;
padding: 2px;
gap: 0;
flex-shrink: 0;
}
.micDiscard,
.micStop {
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
width: 24px;
height: 24px;
border-radius: 50%;
border: none;
cursor: pointer;
}
.micDiscard {
background: var(--l2-background);
color: var(--l2-foreground);
transition: color 0.12s;
&:hover {
color: var(--l1-foreground);
}
}
.micWaves {
display: flex;
align-items: center;
justify-content: center;
gap: 2px;
height: 20px;
padding: 0 6px;
span {
display: block;
width: 2px;
border-radius: 2px;
background: var(--l1-foreground);
animation: voiceWave 0.9s ease-in-out infinite;
&:nth-child(1) {
animation-delay: 0s;
}
&:nth-child(2) {
animation-delay: 0.1s;
}
&:nth-child(3) {
animation-delay: 0.2s;
}
&:nth-child(4) {
animation-delay: 0.3s;
}
&:nth-child(5) {
animation-delay: 0.4s;
}
&:nth-child(6) {
animation-delay: 0.3s;
}
&:nth-child(7) {
animation-delay: 0.2s;
}
&:nth-child(8) {
animation-delay: 0.1s;
}
}
}
@keyframes voiceWave {
0%,
100% {
height: 2px;
opacity: 0.4;
}
50% {
height: 12px;
opacity: 1;
}
}
.micStop {
background: var(--accent-cherry);
color: var(--accent-cherry-foreground);
transition: opacity 0.12s;
&:hover {
opacity: 0.85;
}
}

View File

@@ -1,944 +0,0 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { useQueryClient } from 'react-query';
import cx from 'classnames';
import { Badge } from '@signozhq/ui/badge';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Tooltip } from '@signozhq/ui/tooltip';
import type { UploadFile } from 'antd';
import {
getListRulesQueryKey,
useListRules,
} from 'api/generated/services/rules';
import type { ListRules200 } from 'api/generated/services/sigNoz.schemas';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useGetAllDashboard } from 'hooks/dashboard/useGetAllDashboard';
import { useQueryService } from 'hooks/useQueryService';
import type { SuccessResponseV2 } from 'types/api';
import type { Dashboard } from 'types/api/dashboard/getAll';
// eslint-disable-next-line
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { useSpeechRecognition } from '../../hooks/useSpeechRecognition';
import { MessageAttachment } from '../../types';
import { MessageContext } from '../../../../api/ai-assistant/chat';
import {
Bell,
LayoutDashboard,
Mic,
Plus,
Search,
Send,
ShieldCheck,
Square,
TriangleAlert,
X,
} from '@signozhq/icons';
import styles from './ChatInput.module.scss';
interface ChatInputProps {
onSend: (
text: string,
attachments?: MessageAttachment[],
contexts?: MessageContext[],
) => void;
onCancel?: () => void;
disabled?: boolean;
isStreaming?: boolean;
/**
* URL-derived `source: 'auto'` contexts representing the page the user is
* currently looking at. Rendered as chips alongside the user's `@`-mention
* picks and merged into the outgoing `contexts` array.
*/
autoContexts?: MessageContext[];
/**
* Called when the user dismisses an auto-context chip. The parent owns
* the dismissed set and is responsible for filtering the next render's
* `autoContexts` to exclude the key.
*/
onDismissAutoContext?: (key: string) => void;
}
/** Stable identity for an auto-context entry — used as React key + dismissal id. */
export function autoContextKey(ctx: MessageContext): string {
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
return `auto:${ctx.type}:${ctx.resourceId ?? ''}:${page ?? ''}`;
}
/**
* Friendly label for an auto-derived context chip. We don't fetch resource
* names from the URL alone, so we lean on the page identity that already
* lives in `metadata.page`, falling back to the resource type.
*/
function autoContextLabel(ctx: MessageContext): string {
const page = (ctx.metadata as { page?: string } | null | undefined)?.page;
switch (page) {
case 'dashboard_detail':
return 'Current dashboard';
case 'panel_edit':
return 'Editing panel';
case 'panel_fullscreen':
return 'Panel (fullscreen)';
case 'dashboard_list':
return 'Dashboards';
case 'alert_edit':
return 'Editing alert';
case 'alert_new':
return 'New alert';
case 'alerts_triggered':
return 'Triggered alerts';
case 'alert_list':
return 'Alerts';
case 'service_detail':
return 'Current service';
case 'services_list':
return 'Services';
case 'logs_explorer':
return 'Logs explorer';
case 'log_detail':
return 'Log details';
case 'traces_explorer':
return 'Traces explorer';
case 'trace_detail':
return 'Trace details';
case 'metrics_explorer':
return 'Metrics explorer';
default:
return ctx.type;
}
}
/** Capitalised category badge text — e.g. "Dashboard", "Logs explorer". */
function autoContextCategory(ctx: MessageContext): string {
switch (ctx.type) {
case 'dashboard':
return 'Dashboard';
case 'alert':
return 'Alert';
case 'service':
return 'Service';
case 'logs_explorer':
return 'Logs';
case 'traces_explorer':
return 'Traces';
case 'metrics_explorer':
return 'Metrics';
case 'saved_view':
return 'Saved view';
default:
return ctx.type;
}
}
const MAX_INPUT_LENGTH = 20000;
const WARNING_THRESHOLD = 15000;
const HOME_SERVICES_INTERVAL = 30 * 60 * 1000;
const CONTEXT_CATEGORIES = ['Dashboards', 'Alerts', 'Services'] as const;
type ContextCategory = (typeof CONTEXT_CATEGORIES)[number];
interface SelectedContextItem {
category: ContextCategory;
entityId: string;
value: string;
}
function toMessageContext(item: SelectedContextItem): MessageContext | null {
switch (item.category) {
case 'Dashboards':
return {
source: 'mention',
type: 'dashboard',
resourceId: item.entityId,
resourceName: item.value,
};
case 'Alerts':
return {
source: 'mention',
type: 'alert',
resourceId: item.entityId,
resourceName: item.value,
};
case 'Services':
return {
source: 'mention',
type: 'service',
resourceId: item.entityId,
resourceName: item.value,
};
default:
return null;
}
}
interface ContextEntityItem {
id: string;
value: string;
}
const CONTEXT_CATEGORY_ICONS = {
Dashboards: LayoutDashboard,
Alerts: Bell,
Services: ShieldCheck,
} satisfies Record<ContextCategory, unknown>;
function fileToDataUrl(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = (): void => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
export default function ChatInput({
onSend,
onCancel,
disabled,
isStreaming = false,
autoContexts,
onDismissAutoContext,
}: ChatInputProps): JSX.Element {
const { selectedTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const [text, setText] = useState('');
const [pendingFiles, setPendingFiles] = useState<UploadFile[]>([]);
const [selectedContexts, setSelectedContexts] = useState<
SelectedContextItem[]
>([]);
const [isContextPickerOpen, setIsContextPickerOpen] = useState(false);
const [activeContextCategory, setActiveContextCategory] =
useState<ContextCategory>('Dashboards');
const [pickerSearchQuery, setPickerSearchQuery] = useState('');
const queryClient = useQueryClient();
// When the picker was opened by typing `@` in the textarea, this holds the
// span of `@<query>` (start / end indices into `text`). Used both for live
// filtering of the entity list and for splicing the trigger out of the
// text once the user picks an item. `null` when the picker is opened via
// the "Add Context" button (no trigger to strip, no query to filter).
const [mentionRange, setMentionRange] = useState<{
start: number;
end: number;
} | null>(null);
const [servicesTimeRange] = useState(() => {
const now = Date.now();
return {
startTime: now - HOME_SERVICES_INTERVAL,
endTime: now,
};
});
// Stores the already-committed final text so interim results don't overwrite it
const committedTextRef = useRef('');
const textareaRef = useRef<HTMLTextAreaElement>(null);
const inputRootRef = useRef<HTMLDivElement>(null);
const capText = useCallback(
(value: string) => value.slice(0, MAX_INPUT_LENGTH),
[],
);
const syncContextPickerFromText = useCallback(
(value: string, caret: number) => {
const beforeCaret = value.slice(0, caret);
const atIndex = beforeCaret.lastIndexOf('@');
if (atIndex < 0) {
setIsContextPickerOpen(false);
setMentionRange(null);
return;
}
const query = beforeCaret.slice(atIndex + 1);
if (/\s/.test(query)) {
setIsContextPickerOpen(false);
setMentionRange(null);
return;
}
setIsContextPickerOpen(true);
setMentionRange({ start: atIndex, end: caret });
},
[],
);
const toggleContextSelection = useCallback(
(category: ContextCategory, entityId: string, contextValue: string) => {
const wasSelected = selectedContexts.some(
(item) => item.category === category && item.entityId === entityId,
);
setSelectedContexts((prev) => {
if (wasSelected) {
return prev.filter(
(item) => !(item.category === category && item.entityId === entityId),
);
}
return [...prev, { category, entityId, value: contextValue }];
});
// When the user picks an item via the `@` trigger, splice the
// `@<query>` span out of the textarea so their prose stays clean.
// Skip on remove (no trigger to strip) and when the picker was
// opened from the "Add Context" button (no mention range tracked).
if (!wasSelected && mentionRange) {
const next =
text.slice(0, mentionRange.start) + text.slice(mentionRange.end);
setText(next);
committedTextRef.current = next;
setMentionRange(null);
}
},
[mentionRange, selectedContexts, text],
);
// Focus the textarea when this component mounts (panel/modal open)
useEffect(() => {
textareaRef.current?.focus();
}, []);
const handleSend = useCallback(async () => {
const trimmed = text.trim();
if (!trimmed && pendingFiles.length === 0) {
return;
}
const attachments: MessageAttachment[] = await Promise.all(
pendingFiles.map(async (f) => {
const dataUrl = f.originFileObj ? await fileToDataUrl(f.originFileObj) : '';
return {
name: f.name,
type: f.type ?? 'application/octet-stream',
dataUrl,
};
}),
);
const userContexts = selectedContexts
.map(toMessageContext)
.filter((context): context is MessageContext => context !== null);
// Auto contexts come first so the agent reads "current page" before
// any explicit @-mentions when both are present.
const contexts = [...(autoContexts ?? []), ...userContexts];
const payload = capText(trimmed);
onSend(
payload,
attachments.length > 0 ? attachments : undefined,
contexts.length > 0 ? contexts : undefined,
);
setText('');
committedTextRef.current = '';
setPendingFiles([]);
setSelectedContexts([]);
textareaRef.current?.focus();
}, [text, pendingFiles, onSend, selectedContexts, autoContexts, capText]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Escape' && isContextPickerOpen) {
setIsContextPickerOpen(false);
return;
}
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
void handleSend();
}
},
[handleSend, isContextPickerOpen],
);
const removeFile = useCallback((uid: string) => {
setPendingFiles((prev) => prev.filter((f) => f.uid !== uid));
}, []);
const removeContext = useCallback(
(category: ContextCategory, entityId: string) => {
setSelectedContexts((prev) =>
prev.filter(
(item) => !(item.category === category && item.entityId === entityId),
),
);
},
[],
);
// ── Voice input ────────────────────────────────────────────────────────────
const {
isListening,
isSupported,
permission: micPermission,
start,
discard,
} = useSpeechRecognition({
onTranscript: (transcriptText, isFinal) => {
if (isFinal) {
// Commit: append to whatever the user has already typed
const separator = committedTextRef.current ? ' ' : '';
const next = capText(committedTextRef.current + separator + transcriptText);
committedTextRef.current = next;
setText(next);
} else {
// Interim: live preview appended to committed text, not yet persisted
const separator = committedTextRef.current ? ' ' : '';
setText(capText(committedTextRef.current + separator + transcriptText));
}
},
});
const showMic = isSupported && micPermission !== 'denied';
// Stop recording and immediately send whatever is in the textarea.
const handleStopAndSend = useCallback(async () => {
// Promote the displayed text (interim included) to committed so handleSend sees it.
committedTextRef.current = capText(text);
// Stop recognition without triggering onTranscript again (would double-append).
discard();
await handleSend();
}, [text, discard, handleSend, capText]);
// Stop recording and revert the textarea to what it was before voice started.
const handleDiscard = useCallback(() => {
discard();
setText(committedTextRef.current);
textareaRef.current?.focus();
}, [discard]);
// ── Push-to-talk (Cmd/Ctrl + Shift + Space) ────────────────────────────────
// Hold the combo to record; release Space to submit. We track which key
// triggered PTT in a ref so a late-released modifier (Cmd/Shift) doesn't
// accidentally stop the session. Auto-repeat is suppressed via a
// "session active" ref so a held key only calls `start()` once.
const pttActiveRef = useRef(false);
useEffect(() => {
if (!isSupported || micPermission === 'denied') {
return undefined;
}
const handleKeyDown = (e: KeyboardEvent): void => {
const isComboKey =
(e.metaKey || e.ctrlKey) &&
e.shiftKey &&
(e.code === 'Space' || e.key === ' ');
if (!isComboKey || disabled || isStreaming) {
return;
}
e.preventDefault();
if (pttActiveRef.current) {
return; // ignore auto-repeat
}
pttActiveRef.current = true;
start();
};
const handleKeyUp = (e: KeyboardEvent): void => {
if (!pttActiveRef.current) {
return;
}
// End on the *first* released key in the combo. macOS browsers
// frequently swallow keyup of regular keys (incl. Space) while
// Cmd is held, so we can't rely on Space-up alone — releasing
// Cmd/Ctrl/Shift must also stop the session.
const isComboKey =
e.code === 'Space' ||
e.key === ' ' ||
e.key === 'Meta' ||
e.key === 'Control' ||
e.key === 'Shift';
if (!isComboKey) {
return;
}
pttActiveRef.current = false;
e.preventDefault();
void handleStopAndSend();
};
window.addEventListener('keydown', handleKeyDown);
window.addEventListener('keyup', handleKeyUp);
return (): void => {
window.removeEventListener('keydown', handleKeyDown);
window.removeEventListener('keyup', handleKeyUp);
};
}, [
isSupported,
micPermission,
disabled,
isStreaming,
start,
handleStopAndSend,
]);
// Each list hook fetches only when its picker tab is actively shown,
// AND treats already-cached data as never stale (`staleTime: Infinity`)
// so an open with a populated cache doesn't trigger a background
// refetch. Net effect: assistant-driven fetches happen exactly once
// per resource list per session, on the first cache miss. Gating on
// `isContextPickerOpen` (not just `activeContextCategory`) is important
// — the latter defaults to 'Dashboards' on every mount, so without the
// picker-open check the dashboards list refetches on every new
// conversation.
const {
data: dashboardsResponse,
isLoading: isDashboardsLoading,
isError: isDashboardsError,
} = useGetAllDashboard({
enabled: isContextPickerOpen && activeContextCategory === 'Dashboards',
staleTime: Infinity,
});
const {
data: alertsResponse,
isLoading: isAlertsLoading,
isError: isAlertsError,
} = useListRules({
query: {
enabled: isContextPickerOpen && activeContextCategory === 'Alerts',
staleTime: Infinity,
},
});
const {
data: servicesResponse,
isLoading: isServicesLoading,
isFetching: isServicesFetching,
isError: isServicesError,
} = useQueryService({
minTime: servicesTimeRange.startTime * 1e6,
maxTime: servicesTimeRange.endTime * 1e6,
selectedTime,
selectedTags: [],
options: {
enabled: isContextPickerOpen && activeContextCategory === 'Services',
staleTime: Infinity,
},
});
/**
* Resolves an auto-context to a human label: dashboard title, alert name,
* service name (the service `resourceId` IS the name), or a generic page
* label as fallback while the lookup data is still loading.
*
* Reads passively from the React Query cache via `getQueryData` —
* never triggers a fetch. If the cache is empty (e.g. assistant opened
* on a page that hasn't loaded the resource list yet), the chip falls
* back to a generic label and resolves once the cache fills via the
* picker or another page.
*/
const resolveAutoContextName = useCallback(
(ctx: MessageContext): string => {
if (ctx.type === 'service' && ctx.resourceId) {
return ctx.resourceId;
}
if (ctx.type === 'dashboard' && ctx.resourceId) {
const cached = queryClient.getQueryData<SuccessResponseV2<Dashboard[]>>(
REACT_QUERY_KEY.GET_ALL_DASHBOARDS,
);
const dash = cached?.data?.find((d) => d.id === ctx.resourceId);
if (dash?.data.title) {
return dash.data.title;
}
}
if (ctx.type === 'alert' && ctx.resourceId) {
const cached = queryClient.getQueryData<ListRules200>(
getListRulesQueryKey(),
);
const rule = cached?.data?.find((r) => r.id === ctx.resourceId);
if (rule?.alert) {
return rule.alert;
}
}
const page = (
ctx.metadata as { page?: string; traceId?: string } | null | undefined
)?.page;
if (page === 'trace_detail') {
const traceId = (ctx.metadata as { traceId?: string } | null | undefined)
?.traceId;
if (traceId) {
return `${traceId.slice(0, 8)}`;
}
}
return autoContextLabel(ctx);
},
[queryClient],
);
const contextEntitiesByCategory: Record<ContextCategory, ContextEntityItem[]> =
{
Dashboards:
dashboardsResponse?.data?.map((dashboard) => ({
id: dashboard.id,
value: dashboard.data.title ?? 'Untitled',
})) ?? [],
Alerts:
alertsResponse?.data
?.filter((alertRule) => Boolean(alertRule.alert))
.map((alertRule) => ({
id: alertRule.id,
value: alertRule.alert,
})) ?? [],
Services:
servicesResponse
?.filter((serviceItem) => Boolean(serviceItem.serviceName))
.map((serviceItem, index) => ({
id: serviceItem.serviceName || `service-${index}`,
value: serviceItem.serviceName,
})) ?? [],
};
const contextCategoryStateByCategory: Record<
ContextCategory,
{ isLoading: boolean; isError: boolean }
> = {
Dashboards: {
isLoading: isDashboardsLoading,
isError: isDashboardsError,
},
Alerts: {
isLoading: isAlertsLoading,
isError: isAlertsError,
},
Services: {
isLoading: isServicesLoading || isServicesFetching,
isError: isServicesError,
},
};
// Type-ahead filter against the `@<query>` typed in the textarea. When
// the picker was opened from the "Add Context" button there's no
// mention query, so fall back to the in-popover search input.
const mentionQuery = mentionRange
? text.slice(mentionRange.start + 1, mentionRange.end).toLowerCase()
: '';
const activeQuery = mentionQuery || pickerSearchQuery.trim().toLowerCase();
const filteredContextOptions = activeQuery
? contextEntitiesByCategory[activeContextCategory].filter((entity) =>
entity.value.toLowerCase().includes(activeQuery),
)
: contextEntitiesByCategory[activeContextCategory];
const { isLoading: isActiveContextLoading, isError: isActiveContextError } =
contextCategoryStateByCategory[activeContextCategory];
const currentLength = text.length;
const showTextWarning = currentLength >= WARNING_THRESHOLD;
return (
<div className={styles.input} ref={inputRootRef}>
{pendingFiles.length > 0 && (
<div className={styles.attachments}>
{pendingFiles.map((f) => (
<div key={f.uid} className={styles.attachmentChip}>
<span className={styles.attachmentName}>{f.name}</span>
<Button
variant="ghost"
size="icon"
className={styles.attachmentRemove}
onClick={(): void => removeFile(f.uid)}
aria-label={`Remove ${f.name}`}
>
<X size={11} />
</Button>
</div>
))}
</div>
)}
{(selectedContexts.length > 0 ||
(autoContexts && autoContexts.length > 0)) && (
<div className={styles.contextTags}>
{autoContexts?.map((ctx) => {
const key = autoContextKey(ctx);
const label = resolveAutoContextName(ctx);
const category = autoContextCategory(ctx);
return (
<div key={key} className={cx(styles.contextTag, styles.auto)}>
<div className={styles.contextTagContent}>
<Badge
color="secondary"
variant="outline"
className={styles.contextTagCategory}
>
{category}
</Badge>
<span className={styles.contextTagLabel}>{label}</span>
</div>
{onDismissAutoContext && (
<Button
variant="link"
size="icon"
color="secondary"
className={styles.contextTagRemove}
onClick={(): void => onDismissAutoContext(key)}
aria-label={`Remove ${category}: ${label} context`}
prefix={<X size={10} />}
></Button>
)}
</div>
);
})}
{selectedContexts.map((contextItem) => (
<div
key={`${contextItem.category}:${contextItem.entityId}`}
className={styles.contextTag}
>
<div className={styles.contextTagContent}>
<Badge
color="primary"
variant="outline"
className={styles.contextTagCategory}
>
{contextItem.category}
</Badge>
<span className={styles.contextTagLabel}>{contextItem.value}</span>
</div>
<Button
variant="link"
size="icon"
color="secondary"
className={styles.contextTagRemove}
onClick={(): void =>
removeContext(contextItem.category, contextItem.entityId)
}
aria-label={`Remove ${contextItem.category}: ${contextItem.value} context`}
prefix={<X size={10} />}
></Button>
</div>
))}
</div>
)}
<div className={styles.composer}>
<textarea
ref={textareaRef}
className={styles.textarea}
placeholder="Ask anything… (Shift+Enter for new line)"
value={text}
onChange={(e): void => {
const next = capText(e.target.value);
setText(next);
// Keep committed text in sync when the user edits manually
committedTextRef.current = next;
syncContextPickerFromText(next, e.target.selectionStart ?? next.length);
}}
onKeyDown={handleKeyDown}
disabled={disabled}
maxLength={MAX_INPUT_LENGTH}
rows={2}
/>
</div>
{showTextWarning && (
<div className={styles.charWarning} role="status">
<TriangleAlert size={12} />
<span>
{currentLength}/{MAX_INPUT_LENGTH} characters. Limit is {MAX_INPUT_LENGTH}
.
</span>
</div>
)}
<div className={styles.footer}>
<div className={styles.leftActions}>
<Popover
open={isContextPickerOpen}
onOpenChange={(open): void => {
setIsContextPickerOpen(open);
if (!open) {
setActiveContextCategory('Dashboards');
setPickerSearchQuery('');
}
}}
>
<PopoverTrigger asChild>
<Button
variant="solid"
color="secondary"
size="sm"
disabled={disabled}
onClick={(): void => {
setActiveContextCategory('Dashboards');
setPickerSearchQuery('');
}}
prefix={<Plus size={10} />}
>
Add Context
</Button>
</PopoverTrigger>
<PopoverContent
className={styles.contextPopover}
side="top"
align="end"
sideOffset={8}
>
<div className={styles.contextPopoverContent}>
<div className={styles.contextPopoverCategories}>
{CONTEXT_CATEGORIES.map((category) => {
const CategoryIcon = CONTEXT_CATEGORY_ICONS[category];
const isActive = activeContextCategory === category;
return (
<div
key={category}
role="tab"
tabIndex={0}
aria-selected={isActive}
className={cx(styles.contextPopoverCategoryItem, {
[styles.active]: isActive,
})}
onClick={(): void => {
setActiveContextCategory(category);
setPickerSearchQuery('');
}}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
setActiveContextCategory(category);
setPickerSearchQuery('');
}
}}
>
<CategoryIcon size={13} />
<span>{category}</span>
</div>
);
})}
</div>
<div className={styles.contextPopoverRight}>
<div className={styles.contextPopoverSearch}>
<Input
type="text"
placeholder={`Search ${activeContextCategory.toLowerCase()}`}
className={styles.contextPopoverSearchInput}
value={pickerSearchQuery}
onChange={(e): void => setPickerSearchQuery(e.target.value)}
prefix={<Search size={12} />}
// Skip the picker's roving keyboard focus — typing here
// shouldn't move category selection.
onKeyDown={(e): void => {
e.stopPropagation();
}}
/>
</div>
<div className={styles.contextPopoverEntities}>
{isActiveContextLoading ? (
<div className={styles.contextPopoverEmpty}>
Loading {activeContextCategory.toLowerCase()}...
</div>
) : isActiveContextError ? (
<div className={styles.contextPopoverEmpty}>
Failed to load {activeContextCategory.toLowerCase()}.
</div>
) : filteredContextOptions.length === 0 ? (
<div className={styles.contextPopoverEmpty}>
No matching entities
</div>
) : (
filteredContextOptions.map((option) => {
const isSelected = selectedContexts.some(
(item) =>
item.category === activeContextCategory &&
item.entityId === option.id,
);
return (
<div
key={option.id}
className={cx(styles.contextPopoverEntityItem, {
[styles.selected]: isSelected,
})}
onClick={(): void =>
toggleContextSelection(
activeContextCategory,
option.id,
option.value,
)
}
>
<span className={styles.contextPopoverEntityItemText}>
{option.value}
</span>
</div>
);
})
)}
</div>
</div>
</div>
</PopoverContent>
</Popover>
</div>
<div className={styles.rightActions}>
{showMic &&
(isListening ? (
<div className={styles.micRecording}>
<div
className={cx(styles.micDiscard, styles.secondary)}
onClick={handleDiscard}
aria-label="Discard recording"
>
<X size={12} />
</div>
<span className={styles.micWaves} aria-hidden="true">
<span />
<span />
<span />
<span />
<span />
<span />
<span />
<span />
</span>
<div
className={cx(styles.micStop, styles.destructive)}
onClick={handleStopAndSend}
aria-label="Stop and send"
>
<Square size={9} fill="currentColor" strokeWidth={0} />
</div>
</div>
) : (
<Tooltip title="Voice input">
<Button
variant="ghost"
size="icon"
onClick={start}
disabled={disabled}
aria-label="Start voice input"
className={styles.micBtn}
>
<Mic size={14} />
</Button>
</Tooltip>
))}
{isStreaming && onCancel ? (
<Tooltip title="Stop generating">
<Button
variant="solid"
size="icon"
color="destructive"
onClick={onCancel}
aria-label="Stop generating"
>
<Square size={10} fill="currentColor" strokeWidth={0} />
</Button>
</Tooltip>
) : (
<Button
variant="solid"
size="icon"
color="primary"
onClick={isListening ? handleStopAndSend : handleSend}
disabled={disabled || (!text.trim() && pendingFiles.length === 0)}
aria-label="Send message"
>
<Send size={14} />
</Button>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ChatInput';
export { default } from './ChatInput';

View File

@@ -1,133 +0,0 @@
.clarification {
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 10px 12px;
background: var(--l2-background);
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
&.submitted {
border-color: var(--l2-border);
background: transparent;
flex-direction: row;
align-items: center;
gap: 6px;
padding: 6px 10px;
}
}
.header {
display: flex;
align-items: center;
gap: 6px;
}
.headerIcon {
flex-shrink: 0;
color: var(--accent-primary);
}
.headerLabel {
font-size: 12px;
font-weight: 600;
color: var(--l1-foreground);
}
.message {
font-size: 13px;
color: var(--l1-foreground);
margin: 0;
line-height: 1.5;
}
.icon {
color: var(--accent-forest);
flex-shrink: 0;
}
.statusText {
font-size: 13px;
color: var(--l2-foreground);
}
.fields {
display: flex;
flex-direction: column;
gap: 8px;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 12px;
font-weight: 500;
color: var(--l2-foreground);
}
.required {
color: var(--accent-cherry);
margin-left: 2px;
}
.input,
.select {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 5px 8px;
font-size: 13px;
color: var(--l1-foreground);
outline: none;
transition: border-color 0.12s;
&:focus {
border-color: var(--accent-primary);
}
}
.select {
cursor: pointer;
}
// Constrain the Radix-based SelectContent popover so it never grows wider
// than the trigger button. `--radix-select-trigger-width` is set by Radix
// at the popper layer when `position: 'popper'` (the default).
.selectContent {
width: var(--radix-select-trigger-width);
min-width: var(--radix-select-trigger-width);
}
.radioGroup,
.checkboxGroup {
display: flex;
flex-direction: column;
gap: 5px;
}
.radioLabel,
.checkboxLabel {
display: flex;
align-items: center;
gap: 7px;
font-size: 13px;
color: var(--l1-foreground);
cursor: pointer;
}
.radio,
.checkbox {
accent-color: var(--accent-primary);
cursor: pointer;
}
.actions {
display: flex;
gap: 6px;
margin-top: 16px;
}

View File

@@ -1,352 +0,0 @@
import { useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Checkbox } from '@signozhq/ui/checkbox';
import { Input } from '@signozhq/ui/input';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from '@signozhq/ui/select';
import { ClarificationFieldTypeDTO } from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import type {
ClarificationEventDTO,
ClarificationFieldEventDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { CircleHelp, Send, X } from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import styles from './ClarificationForm.module.scss';
/** Sentinel emitted by the select dropdown when the user picks the custom slot. */
const CUSTOM_OPTION_SENTINEL = '__signoz_ai_custom__';
/** User-facing label for the synthetic "type your own answer" option. */
const CUSTOM_OPTION_LABEL = 'Other (type your own)';
interface ClarificationFormProps {
conversationId: string;
clarification: ClarificationEventDTO;
}
/**
* Rendered when the agent emits a `clarification` SSE event.
* Dynamically renders form fields based on the `fields` array and
* submits answers to resume the agent on a new execution.
*/
export default function ClarificationForm({
conversationId,
clarification,
}: ClarificationFormProps): JSX.Element {
const submitClarification = useAIAssistantStore((s) => s.submitClarification);
const cancelStream = useAIAssistantStore((s) => s.cancelStream);
const isStreaming = useAIAssistantStore(
(s) => s.streams[conversationId]?.isStreaming ?? false,
);
const fields = clarification.fields ?? [];
const initialAnswers = Object.fromEntries(
fields.map((f) => [f.id, initialAnswerFor(f)]),
);
const [answers, setAnswers] =
useState<Record<string, unknown>>(initialAnswers);
const [submitted, setSubmitted] = useState(false);
const [cancelled, setCancelled] = useState(false);
const setField = (id: string, value: unknown): void => {
setAnswers((prev) => ({ ...prev, [id]: value }));
};
const handleSubmit = async (): Promise<void> => {
setSubmitted(true);
await submitClarification(
conversationId,
clarification.clarificationId,
answers,
);
};
const handleCancel = (): void => {
setCancelled(true);
cancelStream(conversationId);
};
if (submitted) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
<Send size={13} className={styles.icon} />
<span className={styles.statusText}>Answers submitted resuming</span>
</div>
);
}
if (cancelled) {
return (
<div className={cx(styles.clarification, styles.submitted)}>
<X size={13} className={styles.icon} />
<span className={styles.statusText}>Request cancelled.</span>
</div>
);
}
return (
<div className={styles.clarification}>
<div className={styles.header}>
<CircleHelp size={13} className={styles.headerIcon} />
<span className={styles.headerLabel}>A few details needed</span>
</div>
<p className={styles.message}>{clarification.message}</p>
<div className={styles.fields}>
{fields.map((field) => (
<FieldInput
key={field.id}
field={field}
value={answers[field.id]}
onChange={(val): void => setField(field.id, val)}
/>
))}
</div>
<div className={styles.actions}>
<Button
variant="solid"
color="primary"
onClick={handleSubmit}
disabled={isStreaming}
prefix={<Send />}
>
Submit
</Button>
<Button
variant="outlined"
color="secondary"
onClick={handleCancel}
disabled={isStreaming}
prefix={<X />}
>
Cancel request
</Button>
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Field renderer — covers every variant of ClarificationFieldTypeDTO:
// text, number, select, multi_select, boolean.
// ---------------------------------------------------------------------------
/**
* Per-type seed value. The DTO's `default` is `string | string[] | null`,
* which doesn't fit boolean fields cleanly — we coerce 'true'/'false' strings
* for them, fall back to `[]` for multi_select, and the raw string otherwise.
*/
function initialAnswerFor(f: ClarificationFieldEventDTO): unknown {
const raw = f.default;
if (f.type === ClarificationFieldTypeDTO.boolean) {
// `default` is typed string | string[] | null; backend sends
// 'true'/'false' as strings for boolean fields.
return raw === 'true';
}
if (f.type === ClarificationFieldTypeDTO.multi_select) {
return Array.isArray(raw) ? raw : [];
}
return raw ?? '';
}
interface FieldInputProps {
field: ClarificationFieldEventDTO;
value: unknown;
onChange: (value: unknown) => void;
}
function FieldInput({ field, value, onChange }: FieldInputProps): JSX.Element {
const { id, type, label, required, options, allowCustom } = field;
// Local UI state for the synthetic "custom" option on select /
// multi_select fields with `allowCustom`. The free-text input only renders
// when this is true; the typed value is what's actually sent up via
// `onChange` (never the sentinel / "Other" label).
const [isCustom, setIsCustom] = useState(false);
const [customValue, setCustomValue] = useState('');
// Render the select if the field has options OR if the server marked it
// `allowCustom` (in which case the dropdown still appears with just the
// "Other (type your own)" entry — a plain `options: null` would
// otherwise fall through to the bare text-input renderer).
if (type === ClarificationFieldTypeDTO.select && (options || allowCustom)) {
const handleSelectChange = (next: string | string[]): void => {
// `multiple` is off → callback receives a single string. The wider
// `string | string[]` typing comes from the shared Select root.
const picked = Array.isArray(next) ? (next[0] ?? '') : next;
if (picked === CUSTOM_OPTION_SENTINEL) {
setIsCustom(true);
onChange(customValue);
} else {
setIsCustom(false);
onChange(picked);
}
};
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required && <span className={styles.required}>*</span>}
</label>
<Select
value={isCustom ? CUSTOM_OPTION_SENTINEL : String(value ?? '')}
onChange={handleSelectChange}
>
<SelectTrigger id={id} placeholder="Select…" />
{/* Pin the dropdown width to the trigger via Radix's
`--radix-select-trigger-width`; otherwise the popover
sizes to its widest item and looks misaligned. */}
<SelectContent className={styles.selectContent}>
{options?.map((opt) => (
<SelectItem key={opt} value={opt}>
{opt}
</SelectItem>
))}
{allowCustom && (
<SelectItem value={CUSTOM_OPTION_SENTINEL}>
{CUSTOM_OPTION_LABEL}
</SelectItem>
)}
</SelectContent>
</Select>
{isCustom && (
<Input
type="text"
className={styles.input}
placeholder="Enter a custom value"
value={customValue}
onChange={(e): void => {
setCustomValue(e.target.value);
onChange(e.target.value);
}}
/>
)}
</div>
);
}
// Boolean — single yes/no checkbox. The label sits inside the checkbox
// so the click target covers both, matching how multi_select rows render.
if (type === ClarificationFieldTypeDTO.boolean) {
const checked = value === true;
return (
<div className={styles.field}>
<Checkbox
className={styles.checkboxLabel}
value={checked}
onChange={(): void => onChange(!checked)}
>
{label}
{required && <span className={styles.required}>*</span>}
</Checkbox>
</div>
);
}
// Same fallback logic as the select branch — render the checkbox group
// when there are options OR when the field is `allowCustom` only.
if (
type === ClarificationFieldTypeDTO.multi_select &&
(options || allowCustom)
) {
const selected = Array.isArray(value) ? (value as string[]) : [];
// Anything in the value array that isn't one of the predefined options
// is treated as a custom entry — we keep at most one custom entry,
// driven by the local `customValue` + `isCustom` state below.
const regularSelected = selected.filter((v) => options?.includes(v));
const toggleRegular = (opt: string): void => {
const nextRegular = regularSelected.includes(opt)
? regularSelected.filter((v) => v !== opt)
: [...regularSelected, opt];
onChange(
isCustom && customValue ? [...nextRegular, customValue] : nextRegular,
);
};
const toggleCustom = (): void => {
if (isCustom) {
setIsCustom(false);
onChange(regularSelected);
} else {
setIsCustom(true);
onChange(customValue ? [...regularSelected, customValue] : regularSelected);
}
};
const updateCustomValue = (next: string): void => {
setCustomValue(next);
if (isCustom) {
onChange(next ? [...regularSelected, next] : regularSelected);
}
};
return (
<div className={styles.field}>
<span className={styles.label}>
{label}
{required && <span className={styles.required}>*</span>}
</span>
<div className={styles.checkboxGroup}>
{options?.map((opt) => (
<Checkbox
key={opt}
className={styles.checkboxLabel}
value={regularSelected.includes(opt)}
onChange={(): void => toggleRegular(opt)}
>
{opt}
</Checkbox>
))}
{allowCustom && (
<Checkbox
className={styles.checkboxLabel}
value={isCustom}
onChange={toggleCustom}
>
{CUSTOM_OPTION_LABEL}
</Checkbox>
)}
</div>
{isCustom && (
<Input
type="text"
className={styles.input}
placeholder="Enter a custom value"
value={customValue}
onChange={(e): void => updateCustomValue(e.target.value)}
/>
)}
</div>
);
}
// text / number (default)
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label}
{required && <span className={styles.required}>*</span>}
</label>
<Input
id={id}
type={type === 'number' ? 'number' : 'text'}
className={styles.input}
value={String(value ?? '')}
onChange={(e): void =>
onChange(type === 'number' ? Number(e.target.value) : e.target.value)
}
placeholder={label}
/>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ClarificationForm';
export { default } from './ClarificationForm';

View File

@@ -1,145 +0,0 @@
.item {
display: flex;
align-items: center;
gap: 7px;
padding: 6px 8px;
border-radius: var(--radius-2);
cursor: pointer;
min-width: 0;
position: relative;
// Driven below: hover and active reveal the action buttons.
--actions-opacity: 0;
&:hover {
background: var(--l2-background);
--actions-opacity: 1;
}
&.active {
background: var(--l2-background);
--actions-opacity: 1;
.title {
color: var(--l1-foreground);
font-weight: 500;
}
}
&.archived {
opacity: 0.92;
.title {
color: var(--l3-foreground);
}
}
}
.icon {
flex-shrink: 0;
color: var(--l3-foreground);
}
.body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 1px;
}
.title {
font-size: 12px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
line-height: 1.4;
}
.time {
font-size: 10px;
color: var(--l3-foreground);
opacity: 0.7;
}
.input {
width: 100%;
background: transparent;
border: none;
border-bottom: 1px solid var(--accent-primary);
outline: none;
color: var(--l1-foreground);
font-size: 12px;
font-family: inherit;
padding: 1px 0;
}
.actions {
display: flex;
align-items: center;
gap: 1px;
flex-shrink: 0;
opacity: var(--actions-opacity, 0);
transition: opacity 0.12s;
// Float over the right edge of the item so the title can keep using
// the full width while the buttons are hidden — no layout shift +
// no premature truncation. The `background` matches the hover/active
// state so the buttons visually mask any title text underneath.
position: absolute;
top: 50%;
right: 4px;
transform: translateY(-50%);
background: var(--l2-background);
padding: 1px 2px;
border-radius: var(--radius-2);
pointer-events: var(--actions-pointer, none);
}
.item:hover,
.item.active {
--actions-pointer: auto;
}
.btn {
padding: 2px !important;
height: auto !important;
min-height: 0 !important;
&.danger:hover {
color: var(--accent-cherry) !important;
}
}
// Compact menu — narrower than the design-system default so the
// content (Rename / Copy link / Archive) doesn't dwarf the row.
.menu {
min-width: 160px !important;
width: 160px !important;
}
// Shared sizing for every dropdown item so the menu reads compact —
// matches the row's own 12px label scale.
.menuItem {
font-size: 12px !important;
cursor: pointer !important;
}
// Amber treatment for the destructive-but-recoverable Archive action —
// less alarming than red since the conversation can be restored later.
// Targets both the label text and the leading icon (icons inherit color
// via `currentColor`).
.archiveItem {
color: var(--accent-amber) !important;
svg {
color: inherit !important;
}
}
.restoreItem {
color: var(--primary) !important;
svg {
color: inherit !important;
}
}

View File

@@ -1,226 +0,0 @@
import { KeyboardEvent, useCallback, useEffect, useRef, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import cx from 'classnames';
import ROUTES from 'constants/routes';
import { getAbsoluteUrl } from 'utils/basePath';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
import { DropdownMenuSimple } from '@signozhq/ui/dropdown-menu';
import {
Archive,
ArchiveRestore,
EllipsisVertical,
Link,
MessageSquare,
Pencil,
} from '@signozhq/icons';
import { Conversation } from '../../types';
import styles from './ConversationItem.module.scss';
interface ConversationItemProps {
conversation: Conversation;
isActive: boolean;
onSelect: (id: string) => void;
onRename: (id: string, title: string) => void;
onArchive: (id: string) => void;
onRestore: (id: string) => void;
}
function formatRelativeTime(ts: number): string {
if (!Number.isFinite(ts)) {
return '';
}
const diff = Date.now() - ts;
const mins = Math.floor(diff / 60_000);
if (mins < 1) {
return 'just now';
}
if (mins < 60) {
return `${mins}m ago`;
}
const hrs = Math.floor(mins / 60);
if (hrs < 24) {
return `${hrs}h ago`;
}
const days = Math.floor(hrs / 24);
if (days < 7) {
return `${days}d ago`;
}
return new Date(ts).toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
});
}
export default function ConversationItem({
conversation,
isActive,
onSelect,
onRename,
onArchive,
onRestore,
}: ConversationItemProps): JSX.Element {
const [isEditing, setIsEditing] = useState(false);
const [editValue, setEditValue] = useState('');
const inputRef = useRef<HTMLInputElement>(null);
const [, copyToClipboard] = useCopyToClipboard();
const isArchived = Boolean(conversation.archived);
const displayTitle = conversation.title ?? 'New conversation';
const ts = conversation.updatedAt ?? conversation.createdAt;
const handleCopyLink = useCallback((): void => {
// Prefer the server-side `threadId` so the link resolves for anyone
// with access to this conversation. Fall back to the local id for
// drafts that haven't synced yet — useful for the current session
// even if the URL won't reload elsewhere.
const id = conversation.threadId ?? conversation.id;
const path = ROUTES.AI_ASSISTANT.replace(':conversationId', id);
copyToClipboard(getAbsoluteUrl(path));
toast.success('Conversation link copied to clipboard');
}, [conversation.threadId, conversation.id, copyToClipboard]);
const startEditing = useCallback((): void => {
setEditValue(conversation.title ?? '');
setIsEditing(true);
}, [conversation.title]);
useEffect(() => {
if (isEditing) {
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing]);
const commitEdit = useCallback(() => {
onRename(conversation.id, editValue);
setIsEditing(false);
}, [conversation.id, editValue, onRename]);
const handleKeyDown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
commitEdit();
}
if (e.key === 'Escape') {
setIsEditing(false);
}
},
[commitEdit],
);
const itemClass = cx(styles.item, {
[styles.active]: isActive,
[styles.archived]: isArchived,
});
// Dropdown items mirror the previous inline buttons but live in a single
// trigger so the row stays compact. Archive/Restore swap based on the
// archived state — same handler wiring as before.
const baseItems = [
{
key: 'rename',
label: 'Rename',
icon: <Pencil size={12} />,
className: styles.menuItem,
onClick: (): void => startEditing(),
},
{
key: 'copy-link',
label: 'Copy link',
icon: <Link size={12} />,
className: styles.menuItem,
onClick: handleCopyLink,
},
{ type: 'divider' as const, key: 'divider' },
];
const menuItems = isArchived
? [
...baseItems,
{
key: 'restore',
label: 'Restore',
icon: <ArchiveRestore size={12} />,
className: cx(styles.menuItem, styles.restoreItem),
onClick: (): void => onRestore(conversation.id),
},
]
: [
...baseItems,
{
key: 'archive',
label: 'Archive',
icon: <Archive size={12} />,
className: cx(styles.menuItem, styles.archiveItem),
onClick: (): void => onArchive(conversation.id),
},
];
return (
<div
className={itemClass}
onClick={(): void => onSelect(conversation.id)}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
onSelect(conversation.id);
}
}}
>
<MessageSquare size={12} className={styles.icon} />
<div className={styles.body}>
{isEditing ? (
<Input
ref={inputRef}
className={styles.input}
value={editValue}
onChange={(e): void => setEditValue(e.target.value)}
onKeyDown={handleKeyDown}
onBlur={commitEdit}
onClick={(e): void => e.stopPropagation()}
maxLength={80}
/>
) : (
<>
<span className={styles.title} title={displayTitle}>
{displayTitle}
</span>
<span className={styles.time}>{formatRelativeTime(ts)}</span>
</>
)}
</div>
{!isEditing && (
<div
className={styles.actions}
// Stop the row's onSelect from firing when the user opens the
// menu or clicks an item — the menu lives in a portal so its
// own clicks don't bubble, but the trigger button does.
onClick={(e): void => e.stopPropagation()}
>
<DropdownMenuSimple
menu={{ items: menuItems }}
align="end"
sideOffset={4}
className={styles.menu}
>
<Button
variant="link"
size="icon"
color="none"
className={styles.btn}
aria-label="Conversation actions"
prefix={<EllipsisVertical size={12} />}
/>
</DropdownMenuSimple>
</div>
)}
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ConversationItem';
export { default } from './ConversationItem';

View File

@@ -1,84 +0,0 @@
.thread {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
padding: 16px 0;
gap: 14px;
}
.message {
display: flex;
padding: 0 16px;
&.compact {
padding: 0 12px;
}
}
.user {
justify-content: flex-end;
}
.assistant {
justify-content: flex-start;
}
.bubble {
display: flex;
flex-direction: column;
gap: 8px;
// `width: 100%` (capped by per-role max-width below) forces the bubble
// to fill its allotted slot rather than collapsing to the longest line —
// otherwise the lines' percent widths cascade into a tiny bubble.
width: 100%;
border-radius: var(--radius-2);
padding: 12px 14px;
&.user {
// Narrower than the assistant bubble so the alternating chat-thread
// asymmetry is preserved — but wider than the previous 80% so the
// shimmer lines have room to read as a real-looking message.
max-width: 75%;
// Subtle primary tint so the right-side bubble reads as the user's
// message without committing to the full saturated brand color.
background: color-mix(in srgb, var(--accent-primary) 18%, transparent);
border-bottom-right-radius: var(--radius-2);
color: var(--l1-foreground);
}
&.assistant {
max-width: 95%;
background: var(--l2-background);
border-bottom-left-radius: var(--radius-2);
}
}
.line {
height: 9px;
border-radius: 3px;
position: relative;
overflow: hidden;
background: color-mix(in srgb, var(--l1-foreground) 10%, transparent);
// Shimmer sweep — same pattern used by HistorySidebar's skeleton rows.
&::after {
content: '';
position: absolute;
inset: 0;
transform: translateX(-100%);
background: linear-gradient(
90deg,
transparent,
color-mix(in srgb, var(--l1-foreground) 10%, transparent),
transparent
);
animation: shimmer 1.15s ease-in-out infinite;
}
}
@keyframes shimmer {
100% {
transform: translateX(100%);
}
}

View File

@@ -1,53 +0,0 @@
import cx from 'classnames';
import { useVariant } from '../../VariantContext';
import styles from './ConversationSkeleton.module.scss';
/**
* Each entry is one bubble in the placeholder thread:
* role: who "sent" the bubble — drives left/right alignment + colour
* lines: list of widths (as % of the bubble) for the shimmer lines inside
*
* Mixed widths and varying line counts produce something that scans as a real
* back-and-forth conversation rather than a uniform grid.
*/
const ROWS: { role: 'user' | 'assistant'; lines: number[] }[] = [
{ role: 'user', lines: [62] },
{ role: 'assistant', lines: [85, 92, 70] },
{ role: 'user', lines: [55, 40] },
{ role: 'assistant', lines: [90, 78, 95, 60] },
{ role: 'user', lines: [48] },
{ role: 'assistant', lines: [80, 88] },
];
/** Skeleton chat thread shown while a single conversation is being loaded. */
export default function ConversationSkeleton(): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
return (
<div className={styles.thread} aria-busy aria-label="Loading conversation">
{ROWS.map((row, idx) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={idx}
className={cx(styles.message, styles[row.role], {
[styles.compact]: isCompact,
})}
>
<div className={cx(styles.bubble, styles[row.role])}>
{row.lines.map((width, lineIdx) => (
<div
// eslint-disable-next-line react/no-array-index-key
key={lineIdx}
className={styles.line}
style={{ width: `${width}%` }}
/>
))}
</div>
</div>
))}
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ConversationSkeleton';
export { default } from './ConversationSkeleton';

View File

@@ -1,136 +0,0 @@
@use '../../_scrollbar' as *;
.conversationsList {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
// Page variant: fixed-width left column.
&.variantPage {
width: 280px;
flex-shrink: 0;
border-right: 1px solid var(--l1-border);
}
// Panel variant: full-width overlay (replaces conversation view).
&.variantPanel {
width: 100%;
flex: 1;
}
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
flex-shrink: 0;
}
.heading {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--l2-foreground);
// Collapse the line-box to the glyph height so the loading dots
// (centered against the line-box) line up with the cap-height of the
// uppercase text instead of sitting visually low.
line-height: 24px;
}
.searchBar {
padding: 0px 8px 12px;
flex-shrink: 0;
}
.search {
width: 100%;
font-size: 12px;
}
.list {
position: relative;
flex: 1;
overflow-y: auto;
padding: 0 6px 12px;
@include scrollbar(0.25rem);
}
.empty {
margin: 20px 8px 0;
font-size: 12px;
color: var(--l3-foreground);
text-align: center;
}
.srOnly {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.loadingDots {
display: inline-flex;
align-items: center;
gap: 3px;
}
.loadingDot {
width: 4px;
height: 4px;
border-radius: 50%;
background: var(--l3-foreground);
opacity: 0.4;
animation: historyLoadingDot 1.1s ease-in-out infinite;
&:nth-child(2) {
animation-delay: 0.18s;
}
&:nth-child(3) {
animation-delay: 0.36s;
}
}
@keyframes historyLoadingDot {
0%,
100% {
opacity: 0.25;
transform: translateY(0);
}
50% {
opacity: 1;
transform: translateY(-1px);
}
}
.group {
display: flex;
flex-direction: column;
gap: 2px;
margin-bottom: 8px;
&.archived {
margin-top: 4px;
padding-top: 10px;
border-top: 1px solid var(--l2-border);
}
}
.groupLabel {
display: block;
padding: 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--l3-foreground);
}

View File

@@ -1,234 +0,0 @@
import { useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Plus, Search } from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Conversation } from '../../types';
import { useVariant } from '../../VariantContext';
import ConversationItem from '../ConversationItem';
import styles from './ConversationsList.module.scss';
interface ConversationsListProps {
/** Called when a conversation is selected — lets the parent navigate if needed */
onSelect?: (id: string) => void;
onNewConversation?: () => void;
showAddNewConversation?: boolean;
}
function groupByDate(
conversations: Conversation[],
): { label: string; items: Conversation[] }[] {
const now = Date.now();
const DAY = 86_400_000;
const groups: Record<string, Conversation[]> = {
Today: [],
Yesterday: [],
'Last 7 days': [],
'Last 30 days': [],
Older: [],
};
for (const conv of conversations) {
const age = now - (conv.updatedAt ?? conv.createdAt);
if (age < DAY) {
groups.Today.push(conv);
} else if (age < 2 * DAY) {
groups.Yesterday.push(conv);
} else if (age < 7 * DAY) {
groups['Last 7 days'].push(conv);
} else if (age < 30 * DAY) {
groups['Last 30 days'].push(conv);
} else {
groups.Older.push(conv);
}
}
return Object.entries(groups)
.filter(([, items]) => items.length > 0)
.map(([label, items]) => ({ label, items }));
}
/**
* Three-dot loading indicator. Sits inside the sidebar header so the
* conversation list is never bumped down by a skeleton row when threads
* load — visible signal of in-flight work without any layout shift.
*/
function HeaderLoadingDots(): JSX.Element {
return (
<span className={styles.loadingDots} role="status" aria-label="Loading">
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
<span className={styles.loadingDot} />
</span>
);
}
export default function ConversationsList({
onSelect,
onNewConversation,
showAddNewConversation = false,
}: ConversationsListProps): JSX.Element {
const variant = useVariant();
const conversations = useAIAssistantStore((s) => s.conversations);
const activeConversationId = useAIAssistantStore(
(s) => s.activeConversationId,
);
const isLoadingThreads = useAIAssistantStore((s) => s.isLoadingThreads);
const setActiveConversation = useAIAssistantStore(
(s) => s.setActiveConversation,
);
const loadThread = useAIAssistantStore((s) => s.loadThread);
const fetchThreads = useAIAssistantStore((s) => s.fetchThreads);
const archiveConversation = useAIAssistantStore((s) => s.archiveConversation);
const restoreConversation = useAIAssistantStore((s) => s.restoreConversation);
const renameConversation = useAIAssistantStore((s) => s.renameConversation);
const [searchQuery, setSearchQuery] = useState('');
// Fetch threads from backend on mount
useEffect(() => {
void fetchThreads();
}, [fetchThreads]);
// Case-insensitive substring match against the conversation title.
// Untitled conversations match the literal placeholder so users
// searching for "new" can still find them.
const trimmedQuery = searchQuery.trim().toLowerCase();
const matchesQuery = (c: Conversation): boolean => {
if (!trimmedQuery) {
return true;
}
const title = (c.title ?? 'New conversation').toLowerCase();
return title.includes(trimmedQuery);
};
const sortedActive = useMemo(
() =>
Object.values(conversations)
.filter((c) => !c.archived && matchesQuery(c))
.sort(
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[conversations, trimmedQuery],
);
const sortedArchived = useMemo(
() =>
Object.values(conversations)
.filter((c) => Boolean(c.archived) && c.threadId && matchesQuery(c))
.sort(
(a, b) => (b.updatedAt ?? b.createdAt) - (a.updatedAt ?? a.createdAt),
),
// eslint-disable-next-line react-hooks/exhaustive-deps
[conversations, trimmedQuery],
);
const groups = useMemo(() => groupByDate(sortedActive), [sortedActive]);
const hasAnySidebarRows = groups.length > 0 || sortedArchived.length > 0;
const isSearching = trimmedQuery.length > 0;
const handleSelect = (id: string): void => {
const conv = conversations[id];
if (conv?.threadId) {
// Always load from backend — refreshes messages and reconnects
// to active execution if the thread is still busy.
void loadThread(conv.threadId);
} else {
// Local-only conversation (no backend thread yet)
setActiveConversation(id);
}
onSelect?.(id);
};
const variantClass =
variant === 'page' ? styles.variantPage : styles.variantPanel;
return (
<div className={cx(styles.conversationsList, variantClass)}>
<div className={styles.header}>
<span className={styles.heading}>Conversations</span>
{isLoadingThreads && <HeaderLoadingDots />}
{!isLoadingThreads && showAddNewConversation && (
<Tooltip title="New conversation">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={onNewConversation}
aria-label="New conversation"
>
<Plus size={12} />
</Button>
</Tooltip>
)}
</div>
<div className={styles.searchBar}>
<Input
type="text"
value={searchQuery}
onChange={(e): void => setSearchQuery(e.target.value)}
placeholder="Search conversations…"
prefix={<Search size={12} />}
className={styles.search}
/>
</div>
<div className={styles.list} aria-busy={isLoadingThreads}>
{isLoadingThreads && (
<span className={styles.srOnly} role="status">
Loading conversations
</span>
)}
{!isLoadingThreads && !hasAnySidebarRows && (
<p className={styles.empty}>
{isSearching ? 'No matching conversations.' : 'No conversations yet.'}
</p>
)}
{groups.map(({ label, items }) => (
<div key={label} className={styles.group}>
<span className={styles.groupLabel}>{label}</span>
{items.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
onRename={renameConversation}
onArchive={archiveConversation}
onRestore={restoreConversation}
/>
))}
</div>
))}
{sortedArchived.length > 0 && (
<div className={cx(styles.group, styles.archived)}>
<span className={styles.groupLabel}>Archived Conversations</span>
{sortedArchived.map((conv) => (
<ConversationItem
key={conv.id}
conversation={conv}
isActive={conv.id === activeConversationId}
onSelect={handleSelect}
onRename={renameConversation}
onArchive={archiveConversation}
onRestore={restoreConversation}
/>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ConversationsList';
export { default } from './ConversationsList';

View File

@@ -1,327 +0,0 @@
.message {
display: flex;
padding: 8px 16px;
// CSS variable consumed by MessageFeedback to fade in on hover.
--feedback-opacity: 0;
&:hover {
--feedback-opacity: 1;
}
&.compact {
padding: 6px;
}
}
.user {
justify-content: flex-end;
}
.assistant {
justify-content: flex-start;
}
.body {
display: flex;
flex-direction: column;
max-width: 80%;
&.compact {
max-width: 90%;
}
.user & {
align-items: flex-end;
}
.assistant & {
align-items: flex-start;
}
}
.bubble {
border-radius: var(--radius-2);
padding: 8px;
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 153.846% */
letter-spacing: -0.065px;
max-width: 100%;
.user & {
background: var(--accent-primary);
color: var(--primary-foreground);
border-bottom-right-radius: var(--radius-2);
}
.assistant & {
// Flex column for text blocks, tool steps and cards. No parent
// gap — auxiliary blocks (Thinking / ToolCall / actions) stack
// flush, and the prose `.markdown` block adds its own 24px top
// and bottom margins to mark itself as the message's focal point.
display: flex;
flex-direction: column;
align-items: flex-start;
background: var(--l2-background);
color: var(--l1-foreground);
border-bottom-left-radius: var(--radius-2);
}
}
.text {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
// User-bubble row: pencil button sits to the LEFT of the bubble within
// the right-aligned message line, so it visually "ends" at the bubble's
// right edge while keeping the bubble in its original position.
.bubbleRow {
display: flex;
align-items: center;
gap: 2px;
max-width: 100%;
}
.markdown {
width: 100%;
word-break: break-word;
// Anchor the prose block apart from any auxiliary rows (Thinking /
// ToolCall / Suggested actions) above and below it. Reset when this
// is the only / first / last child so the bubble doesn't grow taller
// than its content.
margin: 12px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
p {
margin: 0 0 0.65em;
&:last-child {
margin-bottom: 0;
}
}
ul,
ol {
margin: 0 0 0.65em;
padding-left: 1.5em;
&:last-child {
margin-bottom: 0;
}
}
li {
margin-bottom: 0.3em;
&:last-child {
margin-bottom: 0;
}
ul,
ol {
margin-top: 0.25em;
margin-bottom: 0;
}
}
h1,
h2,
h3,
h4,
h5,
h6 {
font-weight: 600;
line-height: 1.35;
margin: 0.9em 0 0.4em;
color: var(--l1-foreground);
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
h1 {
font-size: 1.15em;
}
h2 {
font-size: 1.08em;
}
h3 {
font-size: 1em;
}
h4,
h5,
h6 {
font-size: 0.95em;
}
strong {
font-weight: 600;
}
em {
font-style: italic;
}
a {
color: var(--accent-primary);
text-decoration: underline;
text-underline-offset: 2px;
text-decoration-thickness: 1px;
&:hover {
opacity: 0.8;
}
}
blockquote {
border-left: 3px solid var(--l2-border);
padding: 0.1em 0 0.1em 0.8em;
color: var(--l2-foreground);
font-style: italic;
margin: 0 0 0.65em;
&:last-child {
margin-bottom: 0;
}
p {
margin-bottom: 0;
}
}
hr {
border: none;
border-top: 1px solid var(--l2-border);
margin: 0.75em 0;
}
code {
font-family: 'Geist Mono', 'Fira Code', monospace;
font-size: 11.5px;
border-radius: var(--radius-2);
padding: 1px 4px;
background: var(--l3-background);
color: var(--l1-foreground);
}
pre {
margin: 0 0 0.65em;
&:last-child {
margin-bottom: 0;
}
}
pre code {
display: block;
padding: 10px;
overflow-x: auto;
border-radius: var(--radius-2);
white-space: pre;
}
table {
border-collapse: collapse;
font-size: 12px;
margin: 0 0 0.65em;
width: 100%;
&:last-child {
margin-bottom: 0;
}
th,
td {
padding: 5px 10px;
border: 1px solid var(--l2-border);
text-align: left;
}
th {
background: var(--l2-background);
color: var(--l1-foreground);
font-weight: 600;
}
td {
color: var(--l1-foreground);
}
}
}
.attachments {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 8px;
}
.attachmentImage {
max-width: 200px;
max-height: 160px;
border-radius: var(--radius-2);
object-fit: cover;
}
.attachmentFile {
font-size: 11px;
padding: 3px 8px;
border-radius: var(--radius-2);
background: var(--l3-background);
color: var(--l2-foreground);
max-width: 180px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.typingIndicator {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 4px 0;
height: 20px;
span {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--l2-foreground);
animation: bounce 1.2s infinite ease-in-out;
&:nth-child(2) {
animation-delay: 0.2s;
}
&:nth-child(3) {
animation-delay: 0.4s;
}
}
}
@keyframes bounce {
0%,
80%,
100% {
transform: scale(0.7);
opacity: 0.5;
}
40% {
transform: scale(1);
opacity: 1;
}
}

View File

@@ -1,164 +0,0 @@
import React from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
// Side-effect: registers all built-in block types into the BlockRegistry
import '../blocks';
import { useVariant } from '../../VariantContext';
import { Message, MessageBlock } from '../../types';
import ActionsSection from '../ActionsSection';
import { RichCodeBlock } from '../blocks';
import { MessageContext } from '../MessageContext';
import MessageFeedback from '../MessageFeedback';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import UserMessageActions from '../UserMessageActions';
import styles from './MessageBubble.module.scss';
/**
* react-markdown renders fenced code blocks as <pre><code>...</code></pre>.
* When RichCodeBlock replaces <code> with a custom AI block component, the
* block ends up wrapped in <pre> which forces monospace font and white-space:pre.
* This renderer detects that case and unwraps the <pre>.
*/
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const childArr = React.Children.toArray(children);
if (childArr.length === 1) {
const child = childArr[0];
// If the code component returned something other than a <code> element
// (i.e. a custom AI block), render without the <pre> wrapper.
if (React.isValidElement(child) && child.type !== 'code') {
return <>{child}</>;
}
}
return <pre>{children}</pre>;
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Renders a single MessageBlock by type. */
function renderBlock(block: MessageBlock, index: number): JSX.Element {
switch (block.type) {
case 'thinking':
return <ThinkingStep key={index} content={block.content} />;
case 'tool_call':
// Blocks in a persisted message are always complete — done is always true.
return (
<ToolCallStep
key={index}
toolCall={{
toolName: block.toolName,
input: block.toolInput,
result: block.result,
done: true,
displayText: block.displayText,
}}
/>
);
case 'text':
default:
return (
<ReactMarkdown
key={index}
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{block.content}
</ReactMarkdown>
);
}
}
interface MessageBubbleProps {
message: Message;
onRegenerate?: () => void;
isLastAssistant?: boolean;
}
export default function MessageBubble({
message,
onRegenerate,
isLastAssistant = false,
}: MessageBubbleProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const isUser = message.role === 'user';
const hasBlocks = !isUser && message.blocks && message.blocks.length > 0;
const messageClass = cx(
styles.message,
isUser ? styles.user : styles.assistant,
{
[styles.compact]: isCompact,
},
);
const bodyClass = cx(styles.body, { [styles.compact]: isCompact });
return (
<div className={messageClass} data-testid={`ai-message-${message.id}`}>
<div className={bodyClass}>
<div className={styles.bubbleRow}>
<div className={styles.bubble}>
{message.attachments && message.attachments.length > 0 && (
<div className={styles.attachments}>
{message.attachments.map((att) => {
const isImage = att.type.startsWith('image/');
return isImage ? (
<img
key={att.name}
src={att.dataUrl}
alt={att.name}
className={styles.attachmentImage}
/>
) : (
<div key={att.name} className={styles.attachmentFile}>
{att.name}
</div>
);
})}
</div>
)}
{isUser ? (
<p className={styles.text}>{message.content}</p>
) : hasBlocks ? (
<MessageContext.Provider value={{ messageId: message.id }}>
{/* eslint-disable-next-line react/no-array-index-key */}
{message.blocks!.map((block, i) => renderBlock(block, i))}
</MessageContext.Provider>
) : (
<MessageContext.Provider value={{ messageId: message.id }}>
<ReactMarkdown
className={styles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{message.content}
</ReactMarkdown>
</MessageContext.Provider>
)}
{!isUser && message.actions && message.actions.length > 0 && (
<ActionsSection actions={message.actions} />
)}
</div>
</div>
{!isUser && (
<MessageFeedback
message={message}
onRegenerate={onRegenerate}
isLastAssistant={isLastAssistant}
/>
)}
{isUser && <UserMessageActions message={message} />}
</div>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './MessageBubble';
export { default } from './MessageBubble';

View File

@@ -1,13 +0,0 @@
// eslint-disable-next-line no-restricted-imports
import { createContext, useContext } from 'react';
interface MessageContextValue {
messageId: string;
}
export const MessageContext = createContext<MessageContextValue>({
messageId: '',
});
export const useMessageContext = (): MessageContextValue =>
useContext(MessageContext);

View File

@@ -1,89 +0,0 @@
.feedback {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 2px 0;
// Driven by MessageBubble's --feedback-opacity (0 normally, 1 on hover/visible).
opacity: var(--feedback-opacity, 0);
transition: opacity 0.15s ease;
&.visible {
--feedback-opacity: 1;
opacity: 1;
}
}
.actions {
display: flex;
align-items: center;
gap: 2px;
}
.btn {
width: 24px !important;
height: 24px !important;
min-height: 0 !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: var(--l3-foreground) !important;
&:hover {
color: var(--l1-foreground) !important;
}
&.active {
color: var(--accent-forest) !important;
}
&.votedUp {
color: var(--accent-primary) !important;
}
&.votedDown {
color: var(--accent-cherry) !important;
}
}
.time {
font-size: 10px;
color: var(--l3-foreground);
white-space: nowrap;
padding-left: 2px;
border-left: 1px solid var(--l2-border);
}
.feedbackTextarea {
width: 100%;
min-height: 96px;
padding: 10px 12px;
resize: vertical;
font: inherit;
font-size: 13px;
line-height: 1.5;
color: var(--l1-foreground);
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: 6px;
outline: none;
transition:
border-color 0.15s ease,
box-shadow 0.15s ease;
box-sizing: border-box;
&::placeholder {
color: var(--l3-foreground);
}
&:focus {
border-color: var(--accent-primary);
box-shadow: 0 0 0 1px var(--accent-primary);
}
}
.feedbackDialogFooter {
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -1,220 +0,0 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import cx from 'classnames';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { Tooltip } from '@signozhq/ui/tooltip';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { Check, Copy, RefreshCw, ThumbsDown, ThumbsUp } from '@signozhq/icons';
import { useTimezone } from 'providers/Timezone';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { FeedbackRating, Message } from '../../types';
import styles from './MessageFeedback.module.scss';
interface MessageFeedbackProps {
message: Message;
onRegenerate?: () => void;
isLastAssistant?: boolean;
}
function formatRelativeTime(timestamp: number): string {
const diffMs = Date.now() - timestamp;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 10) {
return 'just now';
}
if (diffSec < 60) {
return `${diffSec}s ago`;
}
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) {
return `${diffMin} min${diffMin === 1 ? '' : 's'} ago`;
}
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) {
return `${diffHr} hr${diffHr === 1 ? '' : 's'} ago`;
}
const diffDay = Math.floor(diffHr / 24);
return `${diffDay} day${diffDay === 1 ? '' : 's'} ago`;
}
export default function MessageFeedback({
message,
onRegenerate,
isLastAssistant = false,
}: MessageFeedbackProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const submitMessageFeedback = useAIAssistantStore(
(s) => s.submitMessageFeedback,
);
const { formatTimezoneAdjustedTimestamp } = useTimezone();
// Local vote state — initialised from persisted feedbackRating, updated
// immediately on click so the UI responds without waiting for the API.
const [vote, setVote] = useState<FeedbackRating | null>(
message.feedbackRating ?? null,
);
// Negative-feedback dialog: collects an optional comment from the user.
// Positive feedback is one-click; negative requires explicit Submit so
// users can describe what was wrong.
const [isNegativeDialogOpen, setIsNegativeDialogOpen] = useState(false);
const [negativeComment, setNegativeComment] = useState('');
const [relativeTime, setRelativeTime] = useState(() =>
formatRelativeTime(message.createdAt),
);
const absoluteTime = useMemo(
() =>
formatTimezoneAdjustedTimestamp(
message.createdAt,
DATE_TIME_FORMATS.DD_MMM_YYYY_HH_MM_SS,
),
[message.createdAt, formatTimezoneAdjustedTimestamp],
);
// Tick relative time every 30 s
useEffect(() => {
const id = setInterval(() => {
setRelativeTime(formatRelativeTime(message.createdAt));
}, 30_000);
return (): void => clearInterval(id);
}, [message.createdAt]);
const handleCopy = useCallback((): void => {
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content]);
const handleVote = useCallback(
(rating: FeedbackRating): void => {
if (vote === rating) {
return;
}
if (rating === 'negative') {
setNegativeComment('');
setIsNegativeDialogOpen(true);
return;
}
setVote(rating);
submitMessageFeedback(message.id, rating);
},
[vote, message.id, submitMessageFeedback],
);
const handleSubmitNegative = useCallback((): void => {
setVote('negative');
setIsNegativeDialogOpen(false);
submitMessageFeedback(
message.id,
'negative',
negativeComment.trim() || undefined,
);
}, [message.id, negativeComment, submitMessageFeedback]);
return (
<>
<div className={cx(styles.feedback, { [styles.visible]: isLastAssistant })}>
<div className={styles.actions}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
variant="ghost"
onClick={handleCopy}
color="secondary"
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</Tooltip>
<Tooltip title="Good response">
<Button
className={cx(styles.btn, { [styles.votedUp]: vote === 'positive' })}
size="icon"
variant="ghost"
color="secondary"
onClick={(): void => handleVote('positive')}
>
<ThumbsUp size={12} />
</Button>
</Tooltip>
<Tooltip title="Bad response">
<Button
className={cx(styles.btn, {
[styles.votedDown]: vote === 'negative',
})}
size="icon"
variant="ghost"
color="secondary"
onClick={(): void => handleVote('negative')}
>
<ThumbsDown size={12} />
</Button>
</Tooltip>
{onRegenerate && (
<Tooltip title="Regenerate">
<Button
className={styles.btn}
size="icon"
variant="ghost"
color="secondary"
onClick={onRegenerate}
>
<RefreshCw size={12} />
</Button>
</Tooltip>
)}
</div>
<span className={styles.time}>
{relativeTime} · {absoluteTime}
</span>
</div>
<DialogWrapper
open={isNegativeDialogOpen}
onOpenChange={setIsNegativeDialogOpen}
title="What went wrong?"
subTitle="Your feedback helps us improve the assistant. Comments are optional."
width="base"
footer={
<div className={styles.feedbackDialogFooter}>
<Button
variant="solid"
color="secondary"
onClick={(): void => setIsNegativeDialogOpen(false)}
>
Cancel
</Button>
<Button variant="solid" color="primary" onClick={handleSubmitNegative}>
Send feedback
</Button>
</div>
}
>
<textarea
className={styles.feedbackTextarea}
placeholder="Tell us what was unhelpful, inaccurate, or unsafe…"
value={negativeComment}
onChange={(e): void => setNegativeComment(e.target.value)}
rows={5}
autoFocus
maxLength={2000}
/>
</DialogWrapper>
</>
);
}

View File

@@ -1,2 +0,0 @@
export * from './MessageFeedback';
export { default } from './MessageFeedback';

View File

@@ -1,9 +0,0 @@
.streamingStatus {
display: inline-flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-style: italic;
color: var(--l3-foreground);
margin-bottom: 6px;
}

View File

@@ -1,132 +0,0 @@
import React from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import type {
ApprovalEventDTO,
ClarificationEventDTO,
} from 'api/ai-assistant/sigNozAIAssistantAPI.schemas';
import { useVariant } from '../../VariantContext';
import { StreamingEventItem } from '../../types';
import ApprovalCard from '../ApprovalCard';
import { RichCodeBlock } from '../blocks';
import ClarificationForm from '../ClarificationForm';
import ThinkingStep from '../ThinkingStep';
import ToolCallStep from '../ToolCallStep';
import messageStyles from '../MessageBubble/MessageBubble.module.scss';
import styles from './StreamingMessage.module.scss';
function SmartPre({ children }: { children?: React.ReactNode }): JSX.Element {
const childArr = React.Children.toArray(children);
if (childArr.length === 1) {
const child = childArr[0];
if (React.isValidElement(child) && child.type !== 'code') {
return <>{child}</>;
}
}
return <pre>{children}</pre>;
}
const MD_PLUGINS = [remarkGfm];
const MD_COMPONENTS = { code: RichCodeBlock, pre: SmartPre };
/** Human-readable labels for execution status codes shown before any events arrive. */
const STATUS_LABEL: Record<string, string> = {
queued: 'Queued…',
running: 'Thinking…',
awaiting_approval: 'Waiting for your approval…',
awaiting_clarification: 'Waiting for your input…',
resumed: 'Resumed…',
};
function TypingDots(): JSX.Element {
return (
<span className={messageStyles.typingIndicator}>
<span />
<span />
<span />
</span>
);
}
interface StreamingMessageProps {
conversationId: string;
/** Ordered timeline of text and tool-call events in arrival order. */
events: StreamingEventItem[];
status?: string;
pendingApproval?: ApprovalEventDTO | null;
pendingClarification?: ClarificationEventDTO | null;
}
export default function StreamingMessage({
conversationId,
events,
status = '',
pendingApproval = null,
pendingClarification = null,
}: StreamingMessageProps): JSX.Element {
const variant = useVariant();
const isCompact = variant === 'panel';
const statusLabel = STATUS_LABEL[status] ?? '';
const isEmpty =
events.length === 0 && !pendingApproval && !pendingClarification;
const isWaitingOnUser = Boolean(pendingApproval || pendingClarification);
const messageClass = cx(messageStyles.message, messageStyles.assistant, {
[messageStyles.compact]: isCompact,
});
return (
<div className={messageClass}>
<div className={messageStyles.bubble}>
{/* Pre-output indicator — only before any events arrive. */}
{isEmpty && statusLabel && (
<span className={styles.streamingStatus}>{statusLabel}</span>
)}
{isEmpty && !statusLabel && <TypingDots />}
{/* eslint-disable react/no-array-index-key */}
{/* Events rendered in arrival order: text, thinking, and tool calls interleaved */}
{events.map((event, i) => {
if (event.kind === 'tool') {
return <ToolCallStep key={i} toolCall={event.toolCall} />;
}
if (event.kind === 'thinking') {
return <ThinkingStep key={i} content={event.content} />;
}
return (
<ReactMarkdown
key={i}
className={messageStyles.markdown}
remarkPlugins={MD_PLUGINS}
components={MD_COMPONENTS}
>
{event.content}
</ReactMarkdown>
);
})}
{/* eslint-enable react/no-array-index-key */}
{/* While events are still streaming, append the typing dots so the
user has a clear "more is coming" signal. Hidden when the agent
is waiting on the user's input (an approval or clarification
card already conveys that state). */}
{!isEmpty && !isWaitingOnUser && <TypingDots />}
{/* Approval / clarification cards appended after any streamed text */}
{pendingApproval && (
<ApprovalCard conversationId={conversationId} approval={pendingApproval} />
)}
{pendingClarification && (
<ClarificationForm
conversationId={conversationId}
clarification={pendingClarification}
/>
)}
</div>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './StreamingMessage';
export { default } from './StreamingMessage';

View File

@@ -1,45 +0,0 @@
// Minimal expandable row — chevron + label, no icon, no left rail.
// Matches the tool-call row treatment so consecutive thinking + tool
// activity reads as one quiet "what the agent did" log.
.row {
width: 100%;
font-size: 12px;
}
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--l3-foreground);
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
}
}
.chevron {
flex-shrink: 0;
color: inherit;
}
.label {
font-weight: 400;
}
.body {
padding: 4px 0 4px 22px;
}
.content {
font-size: 12px;
color: var(--l3-foreground);
font-style: italic;
margin: 0;
white-space: pre-wrap;
line-height: 1.5;
}

View File

@@ -1,36 +0,0 @@
import { useState } from 'react';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import styles from './ThinkingStep.module.scss';
interface ThinkingStepProps {
content: string;
}
/** Collapsible thinking row — chevron + label, content in the expanded body. */
export default function ThinkingStep({
content,
}: ThinkingStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const toggle = (): void => setExpanded((v) => !v);
return (
<div className={styles.row}>
<div className={styles.header} onClick={toggle}>
{expanded ? (
<ChevronDown size={12} className={styles.chevron} />
) : (
<ChevronRight size={12} className={styles.chevron} />
)}
<span className={styles.label}>Thinking</span>
</div>
{expanded && (
<div className={styles.body}>
<p className={styles.content}>{content}</p>
</div>
)}
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ThinkingStep';
export { default } from './ThinkingStep';

View File

@@ -1,99 +0,0 @@
// Minimal expandable row — chevron + label, no icon, no left rail.
// While the tool is running we swap the chevron for a spinner in the
// same slot so the row alignment doesn't shift when it completes.
.row {
width: 100%;
font-size: 12px;
&.running {
opacity: 0.85;
}
}
.header {
display: flex;
align-items: center;
gap: 10px;
padding: 4px 0;
background: transparent;
border: none;
cursor: pointer;
color: var(--l3-foreground);
user-select: none;
transition: color 0.12s ease;
&:hover {
color: var(--l1-foreground);
}
}
.chevron {
flex-shrink: 0;
color: inherit;
&.spin {
color: var(--accent-primary);
animation: spin 1s linear infinite;
}
}
@keyframes spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.label {
font-weight: 400;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.body {
padding: 4px 0 4px 22px;
display: flex;
flex-direction: column;
gap: 6px;
}
.section {
display: flex;
flex-direction: column;
gap: 3px;
}
.sectionLabel {
font-size: 9px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--l3-foreground);
}
.toolName {
font-family: var(--font-mono, monospace);
font-size: 10px;
color: var(--l2-foreground);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.json {
font-family: var(--font-mono, monospace);
font-size: 11px;
color: var(--l2-foreground);
background: var(--l1-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 5px 7px;
margin: 0;
overflow-x: auto;
white-space: pre;
max-height: 160px;
}

View File

@@ -1,69 +0,0 @@
import { useState } from 'react';
import cx from 'classnames';
import { ChevronDown, ChevronRight, LoaderCircle } from '@signozhq/icons';
import { StreamingToolCall } from '../../types';
import styles from './ToolCallStep.module.scss';
interface ToolCallStepProps {
toolCall: StreamingToolCall;
}
/** Collapsible tool-call row — chevron + label, in/out detail in the body. */
export default function ToolCallStep({
toolCall,
}: ToolCallStepProps): JSX.Element {
const [expanded, setExpanded] = useState(false);
const { toolName, input, result, done, displayText } = toolCall;
// Prefer the server-supplied `displayText` from `ToolCallEventDTO` —
// it's the human-friendly title the backend wants surfaced. Fall back
// to a derived label ("signoz_get_dashboard" → "Get Dashboard") when
// the field is empty / null / missing.
const label =
displayText && displayText.trim().length > 0
? displayText
: toolName
.replace(/^[a-z]+_/, '') // strip prefix like "signoz_"
.replace(/_/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
const toggle = (): void => setExpanded((v) => !v);
return (
<div className={cx(styles.row, { [styles.running]: !done })}>
<div className={styles.header} onClick={toggle}>
{!done ? (
<LoaderCircle size={12} className={cx(styles.chevron, styles.spin)} />
) : expanded ? (
<ChevronDown size={12} className={styles.chevron} />
) : (
<ChevronRight size={12} className={styles.chevron} />
)}
<span className={styles.label}>{label}</span>
</div>
{expanded && (
<div className={styles.body}>
<div className={styles.section}>
<span className={styles.sectionLabel}>Tool</span>
<span className={styles.toolName}>{toolName}</span>
</div>
<div className={styles.section}>
<span className={styles.sectionLabel}>Input</span>
<pre className={styles.json}>{JSON.stringify(input, null, 2)}</pre>
</div>
{done && result !== undefined && (
<div className={styles.section}>
<span className={styles.sectionLabel}>Output</span>
<pre className={styles.json}>
{typeof result === 'string' ? result : JSON.stringify(result, null, 2)}
</pre>
</div>
)}
</div>
)}
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ToolCallStep';
export { default } from './ToolCallStep';

View File

@@ -1,31 +0,0 @@
.actions {
display: flex;
align-items: center;
gap: 2px;
padding: 4px 2px 0;
// User bubbles are right-aligned; mirror the alignment so the chips
// hug the bubble's right edge.
align-self: flex-end;
// Driven by MessageBubble's --feedback-opacity (0 normally, 1 on hover).
opacity: var(--feedback-opacity, 0);
transition: opacity 0.15s ease;
}
.btn {
width: 24px !important;
height: 24px !important;
min-height: 0 !important;
padding: 0 !important;
display: inline-flex !important;
align-items: center !important;
justify-content: center !important;
color: var(--l3-foreground) !important;
&:hover {
color: var(--l1-foreground) !important;
}
&.active {
color: var(--accent-forest) !important;
}
}

View File

@@ -1,48 +0,0 @@
import { useCallback, useState } from 'react';
import { useCopyToClipboard } from 'react-use';
import { Button } from '@signozhq/ui/button';
import { Tooltip } from '@signozhq/ui/tooltip';
import { Check, Copy } from '@signozhq/icons';
import { Message } from '../../types';
import styles from './UserMessageActions.module.scss';
interface UserMessageActionsProps {
message: Message;
}
/**
* Action row rendered under user message bubbles. Mirrors the assistant
* feedback strip's hover-reveal treatment via the bubble's
* `--feedback-opacity` CSS variable; intentionally minimal for now —
* additional actions (edit, share, …) can slot in alongside the copy chip.
*/
export default function UserMessageActions({
message,
}: UserMessageActionsProps): JSX.Element {
const [copied, setCopied] = useState(false);
const [, copyToClipboard] = useCopyToClipboard();
const handleCopy = useCallback((): void => {
copyToClipboard(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
}, [copyToClipboard, message.content]);
return (
<div className={styles.actions}>
<Tooltip title={copied ? 'Copied!' : 'Copy'}>
<Button
className={styles.btn}
size="icon"
variant="ghost"
color="secondary"
onClick={handleCopy}
>
{copied ? <Check size={12} /> : <Copy size={12} />}
</Button>
</Tooltip>
</div>
);
}

View File

@@ -1 +0,0 @@
export { default } from './UserMessageActions';

View File

@@ -1,87 +0,0 @@
@use '../../_scrollbar' as *;
.messages {
flex: 1;
overflow: auto;
@include scrollbar;
// 64px bottom padding leaves breathing room between the last bubble and
// the scroll viewport's edge so the bubble doesn't sit flush against the
// disclaimer / input bar. The scroll-to-bottom effect uses the scroller
// ref to scroll past this padding (Virtuoso's `align: 'end'` would only
// reach the last item's bottom and leave the padding hidden below).
& > div {
padding: 16px 0 64px;
}
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 32px 24px;
gap: 8px;
text-align: center;
}
.emptyIcon {
margin-bottom: 4px;
opacity: 0.85;
}
.emptyTitle {
font-size: 16px;
font-weight: 600;
color: var(--l1-foreground);
margin: 0;
}
.emptySubtitle {
font-size: 13px;
color: var(--l3-foreground);
margin: 0 0 12px;
max-width: 320px;
line-height: 1.45;
}
.emptySuggestions {
display: flex;
flex-direction: column;
gap: 6px;
width: 100%;
max-width: 360px;
}
.emptyChip {
display: flex;
align-items: center;
justify-content: flex-start !important;
gap: 8px;
padding: 10px 14px;
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
background: var(--l1-background);
color: var(--l2-foreground);
font-size: 12.5px;
text-align: left;
cursor: pointer;
transition: all 0.15s ease;
line-height: 1.35;
&:hover {
background: var(--l2-background);
border-color: var(--l3-border);
color: var(--l1-foreground);
}
svg {
flex-shrink: 0;
color: var(--l3-foreground);
}
&:hover svg {
color: var(--accent-primary);
}
}

View File

@@ -1,203 +0,0 @@
import { useCallback, useEffect, useRef } from 'react';
import { Button } from '@signozhq/ui/button';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import {
Activity,
TriangleAlert,
ChartBar,
Search,
Zap,
Sparkles,
} from '@signozhq/icons';
import { useAIAssistantStore } from '../../store/useAIAssistantStore';
import { Message, StreamingEventItem } from '../../types';
import MessageBubble from '../MessageBubble';
import StreamingMessage from '../StreamingMessage';
import styles from './VirtualizedMessages.module.scss';
const SUGGESTIONS = [
{
icon: TriangleAlert,
text: 'Show me the top errors in the last hour',
},
{
icon: Activity,
text: 'What services have the highest latency?',
},
{
icon: ChartBar,
text: 'Give me an overview of system health',
},
{
icon: Search,
text: 'Find slow database queries',
},
{
icon: Zap,
text: 'Which endpoints have the most 5xx errors?',
},
];
const EMPTY_EVENTS: StreamingEventItem[] = [];
interface VirtualizedMessagesProps {
conversationId: string;
messages: Message[];
isStreaming: boolean;
}
export default function VirtualizedMessages({
conversationId,
messages,
isStreaming,
}: VirtualizedMessagesProps): JSX.Element {
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
const regenerateAssistantMessage = useAIAssistantStore(
(s) => s.regenerateAssistantMessage,
);
const streamingStatus = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingStatus ?? '',
);
const streamingEvents = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingEvents ?? EMPTY_EVENTS,
);
// Text deltas append into the last `streamingEvents` entry rather than
// pushing a new one, so `streamingEvents.length` doesn't grow as the
// assistant streams text. Tracking the content length gives us a per-chunk
// scroll trigger.
const streamingContentLength = useAIAssistantStore(
(s) => s.streams[conversationId]?.streamingContent.length ?? 0,
);
const pendingApproval = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingApproval ?? null,
);
const pendingClarification = useAIAssistantStore(
(s) => s.streams[conversationId]?.pendingClarification ?? null,
);
const virtuosoRef = useRef<VirtuosoHandle>(null);
const scrollerRef = useRef<HTMLElement | Window | null>(null);
const handleRegenerate = useCallback(
(messageId: string): void => {
if (isStreaming) {
return;
}
void regenerateAssistantMessage(conversationId, messageId);
},
[conversationId, isStreaming, regenerateAssistantMessage],
);
// Scroll all the way to the actual bottom — including the 64px of bottom
// padding on the scroller — so the last bubble has visible breathing room
// above the disclaimer / input bar. Virtuoso's `scrollToIndex(LAST,
// align: 'end')` would only reach the last item's bottom and leave the
// padding hidden below the fold. Use `auto` while streaming so the bottom
// stays glued as text deltas arrive; `smooth` lags when triggered every
// few ms.
useEffect(() => {
const scroller = scrollerRef.current;
if (!(scroller instanceof HTMLElement)) {
return;
}
scroller.scrollTo({
top: scroller.scrollHeight,
behavior: isStreaming ? 'auto' : 'smooth',
});
}, [
messages.length,
streamingEvents.length,
streamingContentLength,
isStreaming,
pendingApproval,
pendingClarification,
]);
const followOutput = useCallback(
(atBottom: boolean): false | 'auto' | 'smooth' => {
if (isStreaming) {
return 'auto';
}
return atBottom ? 'smooth' : false;
},
[isStreaming],
);
const showStreamingSlot =
isStreaming || Boolean(pendingApproval) || Boolean(pendingClarification);
if (messages.length === 0 && !showStreamingSlot) {
return (
<div className={styles.empty}>
<div className={styles.emptyIcon}>
<Sparkles size={24} color="var(--primary)" />
</div>
<h3 className={styles.emptyTitle}>SigNoz AI Assistant</h3>
<p className={styles.emptySubtitle}>
Ask questions about your traces, logs, metrics, and infrastructure.
</p>
<div className={styles.emptySuggestions}>
{SUGGESTIONS.map((s) => (
<Button
key={s.text}
variant="outlined"
color="secondary"
className={styles.emptyChip}
onClick={(): void => {
sendMessage(s.text);
}}
prefix={<s.icon size={14} />}
>
{s.text}
</Button>
))}
</div>
</div>
);
}
const totalCount = messages.length + (showStreamingSlot ? 1 : 0);
return (
<Virtuoso
ref={virtuosoRef}
scrollerRef={(ref): void => {
scrollerRef.current = ref;
}}
className={styles.messages}
totalCount={totalCount}
followOutput={followOutput}
initialTopMostItemIndex={Math.max(0, totalCount - 1)}
itemContent={(index): JSX.Element => {
if (index < messages.length) {
const msg = messages[index];
const isLastAssistant =
msg.role === 'assistant' &&
messages.slice(index + 1).every((m) => m.role !== 'assistant');
return (
<MessageBubble
message={msg}
onRegenerate={
isLastAssistant && !showStreamingSlot
? (): void => handleRegenerate(msg.id)
: undefined
}
isLastAssistant={isLastAssistant}
/>
);
}
return (
<StreamingMessage
conversationId={conversationId}
events={streamingEvents}
status={streamingStatus}
pendingApproval={pendingApproval}
pendingClarification={pendingClarification}
/>
);
}}
/>
);
}

View File

@@ -1,2 +0,0 @@
export * from './VirtualizedMessages';
export { default } from './VirtualizedMessages';

View File

@@ -1,104 +0,0 @@
.header {
display: flex;
align-items: center;
gap: 5px;
margin-bottom: 6px;
}
.zapIcon {
color: var(--accent-amber);
flex-shrink: 0;
}
.headerLabel {
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--l2-foreground);
}
.description {
margin: 0 0 10px;
font-size: 13px;
color: var(--l1-foreground);
line-height: 1.5;
}
.params {
list-style: none;
margin: 0 0 10px;
padding: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.param {
display: flex;
gap: 6px;
font-size: 12px;
line-height: 1.4;
}
.paramKey {
color: var(--l3-foreground);
flex-shrink: 0;
&::after {
content: ':';
}
}
.paramVal {
color: var(--l1-foreground);
font-family: var(--font-mono, monospace);
word-break: break-all;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.spinner {
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
// Answered / terminal state container layout (composed with .block from Block.module.scss).
.applied,
.dismissed,
.loading,
.error {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
opacity: 0.8;
}
.statusIcon {
flex-shrink: 0;
&.ok {
color: var(--accent-forest);
}
&.no {
color: var(--l3-foreground);
}
&.err {
color: var(--accent-cherry);
}
}
.statusText {
font-size: 13px;
color: var(--l1-foreground);
}

View File

@@ -1,203 +0,0 @@
import { useEffect, useRef, useState } from 'react';
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Check, LoaderCircle, TriangleAlert, X, Zap } from '@signozhq/icons';
import { PageActionRegistry } from '../../../pageActions/PageActionRegistry';
import { AIActionBlock } from '../../../pageActions/types';
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
import { useMessageContext } from '../../MessageContext';
import blockStyles from '../Block.module.scss';
import styles from './ActionBlock.module.scss';
type BlockState = 'pending' | 'loading' | 'applied' | 'dismissed' | 'error';
/**
* Renders an AI-suggested page action.
*
* Two modes based on the registered PageAction.autoApply flag:
*
* autoApply = false (default)
* Shows a confirmation card with Accept / Dismiss. The user must
* explicitly approve before execute() is called. Use for destructive or
* hard-to-reverse actions (create dashboard, delete alert, etc.).
*
* autoApply = true
* Executes immediately on mount — no card shown, just the result summary.
* Use for low-risk, reversible actions where the user explicitly asked for
* the change (e.g. "filter logs for errors"). Avoids unnecessary friction.
*
* Persists answered state via answeredBlocks so re-mounts don't reset UI.
*/
export default function ActionBlock({
data,
}: {
data: AIActionBlock;
}): JSX.Element {
const { messageId } = useMessageContext();
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const [localState, setLocalState] = useState<BlockState>(() => {
if (!messageId) {
return 'pending';
}
const saved = answeredBlocks[messageId];
if (!saved) {
return 'pending';
}
if (saved === 'dismissed') {
return 'dismissed';
}
if (saved.startsWith('error:')) {
return 'error';
}
return 'applied';
});
const [resultSummary, setResultSummary] = useState<string>('');
const [errorMessage, setErrorMessage] = useState<string>('');
const { actionId, description, parameters } = data;
// ── Shared execute logic ─────────────────────────────────────────────────────
const execute = async (): Promise<void> => {
const action = PageActionRegistry.get(actionId);
if (!action) {
const msg = `Action "${actionId}" is not available on the current page.`;
setErrorMessage(msg);
setLocalState('error');
if (messageId) {
markBlockAnswered(messageId, `error:${msg}`);
}
return;
}
setLocalState('loading');
try {
const result = await action.execute(parameters as never);
setResultSummary(result.summary);
setLocalState('applied');
if (messageId) {
markBlockAnswered(messageId, `applied:${result.summary}`);
}
} catch (err) {
const msg = err instanceof Error ? err.message : 'Unknown error';
setErrorMessage(msg);
setLocalState('error');
if (messageId) {
markBlockAnswered(messageId, `error:${msg}`);
}
}
};
// ── Auto-apply: fire immediately on mount if the action opts in ──────────────
const autoApplyFired = useRef(false);
useEffect(() => {
// Only auto-apply once, and only when the block hasn't been answered yet
// (i.e. this is a fresh render, not a remount of an already-answered block).
if (autoApplyFired.current || localState !== 'pending') {
return;
}
const action = PageActionRegistry.get(actionId);
if (!action?.autoApply) {
return;
}
autoApplyFired.current = true;
execute();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleDismiss = (): void => {
setLocalState('dismissed');
if (messageId) {
markBlockAnswered(messageId, 'dismissed');
}
};
// ── Terminal states ──────────────────────────────────────────────────────────
if (localState === 'applied') {
return (
<div className={cx(blockStyles.block, styles.applied)}>
<Check size={13} className={cx(styles.statusIcon, styles.ok)} />
<span className={styles.statusText}>{resultSummary || 'Applied.'}</span>
</div>
);
}
if (localState === 'dismissed') {
return (
<div className={cx(blockStyles.block, styles.dismissed)}>
<X size={13} className={cx(styles.statusIcon, styles.no)} />
<span className={styles.statusText}>Dismissed.</span>
</div>
);
}
if (localState === 'error') {
return (
<div className={cx(blockStyles.block, styles.error)}>
<TriangleAlert size={13} className={cx(styles.statusIcon, styles.err)} />
<span className={styles.statusText}>{errorMessage}</span>
</div>
);
}
// ── Loading (autoApply in progress) ─────────────────────────────────────────
if (localState === 'loading') {
return (
<div className={cx(blockStyles.block, styles.loading)}>
<LoaderCircle size={13} className={cx(styles.spinner, styles.statusIcon)} />
<span className={styles.statusText}>{description}</span>
</div>
);
}
// ── Pending: manual confirmation card ────────────────────────────────────────
const paramEntries = Object.entries(parameters ?? {});
return (
<div className={blockStyles.block}>
<div className={styles.header}>
<Zap size={13} className={styles.zapIcon} />
<span className={styles.headerLabel}>Suggested Action</span>
</div>
<p className={styles.description}>{description}</p>
{paramEntries.length > 0 && (
<ul className={styles.params}>
{paramEntries.map(([key, val]) => (
<li key={key} className={styles.param}>
<span className={styles.paramKey}>{key}</span>
<span className={styles.paramVal}>
{typeof val === 'object' ? JSON.stringify(val) : String(val)}
</span>
</li>
))}
</ul>
)}
<div className={styles.actions}>
<Button variant="solid" size="sm" onClick={execute}>
<Check size={12} />
Apply
</Button>
<Button variant="outlined" size="sm" onClick={handleDismiss}>
<X size={12} />
Dismiss
</Button>
</div>
</div>
);
}

View File

@@ -1,2 +0,0 @@
export * from './ActionBlock';
export { default } from './ActionBlock';

View File

@@ -1,35 +0,0 @@
.block {
background: var(--l2-background);
border: 1px solid var(--l2-border);
border-radius: var(--radius-2);
padding: 12px 14px;
margin: 8px 0;
&:first-child {
margin-top: 0;
}
&:last-child {
margin-bottom: 0;
}
}
.title {
margin: 0 0 12px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--l2-foreground);
}
.unit {
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.empty {
margin: 0;
font-size: 12px;
color: var(--l3-foreground);
}

View File

@@ -1,36 +0,0 @@
import React from 'react';
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type BlockComponent<T = any> = React.ComponentType<{ data: T }>;
/**
* Global registry for AI response block renderers.
*
* Any part of the application can register a custom block type:
*
* import { BlockRegistry } from 'container/AIAssistant/components/blocks';
* BlockRegistry.register('my-panel', MyPanelComponent);
*
* The AI can then emit fenced code blocks with the prefix `ai-<type>` and a
* JSON payload, and the registered component will be rendered in-place:
*
* ```ai-my-panel
* { "foo": "bar" }
* ```
*/
const _registry = new Map<string, BlockComponent>();
export const BlockRegistry = {
register<T>(type: string, component: BlockComponent<T>): void {
_registry.set(type, component as BlockComponent);
},
get(type: string): BlockComponent | undefined {
return _registry.get(type);
},
/** Returns all registered type names (useful for debugging). */
types(): string[] {
return Array.from(_registry.keys());
},
};

View File

@@ -1,36 +0,0 @@
.message {
margin: 0 0 10px;
font-size: 13px;
color: var(--l1-foreground);
line-height: 1.5;
}
.actions {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.answered {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 10px;
opacity: 0.7;
}
.icon {
flex-shrink: 0;
&.ok {
color: var(--accent-forest);
}
&.no {
color: var(--accent-cherry);
}
}
.answerText {
font-size: 13px;
color: var(--l1-foreground);
}

View File

@@ -1,89 +0,0 @@
import cx from 'classnames';
import { Button } from '@signozhq/ui/button';
import { Check, X } from '@signozhq/icons';
import { useAIAssistantStore } from '../../../store/useAIAssistantStore';
import { useMessageContext } from '../../MessageContext';
import blockStyles from '../Block.module.scss';
import styles from './ConfirmBlock.module.scss';
export interface ConfirmData {
message?: string;
/** Text sent back when accepted. Defaults to "Yes, proceed." */
acceptText?: string;
/** Text sent back when rejected. Defaults to "No, cancel." */
rejectText?: string;
/** Label shown on Accept button. Defaults to "Accept" */
acceptLabel?: string;
/** Label shown on Reject button. Defaults to "Reject" */
rejectLabel?: string;
}
export default function ConfirmBlock({
data,
}: {
data: ConfirmData;
}): JSX.Element {
const {
message,
acceptText = 'Yes, proceed.',
rejectText = 'No, cancel.',
acceptLabel = 'Accept',
rejectLabel = 'Reject',
} = data;
const { messageId } = useMessageContext();
const answeredBlocks = useAIAssistantStore((s) => s.answeredBlocks);
const markBlockAnswered = useAIAssistantStore((s) => s.markBlockAnswered);
const sendMessage = useAIAssistantStore((s) => s.sendMessage);
// Durable answered state — survives re-renders/remounts
const answeredChoice = messageId ? answeredBlocks[messageId] : undefined;
const isAnswered = answeredChoice !== undefined;
const handle = (choice: 'accepted' | 'rejected'): void => {
const responseText = choice === 'accepted' ? acceptText : rejectText;
if (messageId) {
markBlockAnswered(messageId, choice);
}
sendMessage(responseText);
};
if (isAnswered) {
const wasAccepted = answeredChoice === 'accepted';
const icon = wasAccepted ? (
<Check size={13} className={cx(styles.icon, styles.ok)} />
) : (
<X size={13} className={cx(styles.icon, styles.no)} />
);
return (
<div className={cx(blockStyles.block, styles.answered)}>
{icon}
<span className={styles.answerText}>
{wasAccepted ? acceptText : rejectText}
</span>
</div>
);
}
return (
<div className={blockStyles.block}>
{message && <p className={styles.message}>{message}</p>}
<div className={styles.actions}>
<Button variant="solid" size="sm" onClick={(): void => handle('accepted')}>
<Check size={12} />
{acceptLabel}
</Button>
<Button
variant="outlined"
size="sm"
onClick={(): void => handle('rejected')}
>
<X size={12} />
{rejectLabel}
</Button>
</div>
</div>
);
}

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