Compare commits

..

174 Commits

Author SHA1 Message Date
Naman Verma
f0ed0a8967 feat: v2 dashboard GET API (#11136)
* feat: v2 dashboard GET API

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

* chore: update api specs

* fix: remove soft delete references

* chore: embed StorableDashboard into joinedRow in store method

* fix: fix build error

* chore: revert all frontend changes

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

View File

@@ -5,7 +5,6 @@ import (
"context"
"os"
"sort"
"strings"
"text/template"
"github.com/SigNoz/signoz/pkg/types/coretypes"
@@ -24,7 +23,6 @@ export default {
{
kind: '{{ .Kind }}',
type: '{{ .Type }}',
{{ .FormattedAllowedVerbs }}
},
{{- end }}
],
@@ -43,9 +41,8 @@ type permissionsTypeRelation struct {
}
type permissionsTypeResource struct {
Kind string
Type string
FormattedAllowedVerbs string
Kind string
Type string
}
type permissionsTypeData struct {
@@ -53,30 +50,6 @@ type permissionsTypeData struct {
Relations []permissionsTypeRelation
}
// formatAllowedVerbs returns a prettier-compatible formatted allowedVerbs line.
// indentLevel is the number of tabs for the property (matching kind/type indent).
// printWidth is prettier's printWidth; tabWidth is assumed to be 1 (each \t = 1 char).
func formatAllowedVerbs(verbs []string, indentLevel int, printWidth int) string {
quoted := make([]string, len(verbs))
for i, v := range verbs {
quoted[i] = "'" + v + "'"
}
indent := strings.Repeat("\t", indentLevel)
oneLine := indent + "allowedVerbs: [" + strings.Join(quoted, ", ") + "],"
if len(oneLine) <= printWidth {
return oneLine
}
var b strings.Builder
b.WriteString(indent + "allowedVerbs: [\n")
for _, q := range quoted {
b.WriteString(indent + "\t" + q + ",\n")
}
b.WriteString(indent + "],")
return b.String()
}
func registerGenerateAuthz(parentCmd *cobra.Command) {
authzCmd := &cobra.Command{
Use: "authz",
@@ -93,8 +66,8 @@ func runGenerateAuthz(_ context.Context) error {
registry := coretypes.NewRegistry()
allowedResources := map[string]bool{
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceServiceAccount).String(): true,
coretypes.NewResourceRef(coretypes.ResourceRole).String(): true,
coretypes.NewResourceRef(coretypes.ResourceMetaResourceFactorAPIKey).String(): true,
}
@@ -107,23 +80,9 @@ func runGenerateAuthz(_ context.Context) error {
continue
}
allowedTypes[ref.Type.StringValue()] = true
resource, err := coretypes.NewResourceFromTypeAndKind(ref.Type, ref.Kind)
if err != nil {
return err
}
verbs := resource.AllowedVerbs()
allowedVerbStrings := make([]string, 0, len(verbs))
for _, verb := range verbs {
allowedVerbStrings = append(allowedVerbStrings, verb.StringValue())
}
sort.Strings(allowedVerbStrings)
resources = append(resources, permissionsTypeResource{
Kind: ref.Kind.String(),
Type: ref.Type.StringValue(),
FormattedAllowedVerbs: formatAllowedVerbs(allowedVerbStrings, 4, 80),
Kind: ref.Kind.String(),
Type: ref.Type.StringValue(),
})
}

View File

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

View File

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

View File

@@ -190,7 +190,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.124.0
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.124.0
image: signoz/signoz:v0.122.0
ports:
- "8080:8080" # signoz port
volumes:

View File

@@ -181,7 +181,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.124.0}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

View File

@@ -109,7 +109,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.124.0}
image: signoz/signoz:${VERSION:-v0.122.0}
container_name: signoz
ports:
- "8080:8080" # signoz port

File diff suppressed because it is too large Load Diff

View File

@@ -54,9 +54,6 @@ type metaresource
define update: [user, serviceaccount, role#assignee]
define delete: [user, serviceaccount, role#assignee]
define attach: [user, serviceaccount, role#assignee]
define detach: [user, serviceaccount, role#assignee]
define block: [user, serviceaccount, role#assignee]

View File

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

View File

@@ -1,271 +0,0 @@
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw';
import { MetricRangePayloadV5 } from 'types/api/v5/queryRange';
const QUERY_RANGE_URL = `${ENVIRONMENT.baseURL}/api/v5/query_range`;
export type MockLogsOptions = {
offset?: number;
pageSize?: number;
hasMore?: boolean;
delay?: number;
onReceiveRequest?: (
req: RestRequest,
) =>
| undefined
| void
| Omit<MockLogsOptions, 'onReceiveRequest'>
| Promise<Omit<MockLogsOptions, 'onReceiveRequest'>>
| Promise<void>;
};
const createLogsResponse = ({
offset = 0,
pageSize = 100,
hasMore = true,
}: MockLogsOptions): MetricRangePayloadV5 => {
const itemsForThisPage = hasMore ? pageSize : pageSize / 2;
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
rows: Array.from({ length: itemsForThisPage }, (_, index) => {
const cumulativeIndex = offset + index;
const baseTimestamp = new Date('2024-02-15T21:20:22Z').getTime();
const currentTimestamp = new Date(
baseTimestamp - cumulativeIndex * 1000,
);
const timestampString = currentTimestamp.toISOString();
const id = `log-id-${cumulativeIndex}`;
const logLevel = ['INFO', 'WARN', 'ERROR'][cumulativeIndex % 3];
const service = ['frontend', 'backend', 'database'][cumulativeIndex % 3];
return {
timestamp: timestampString,
data: {
attributes_bool: {},
attributes_float64: {},
attributes_int64: {},
attributes_string: {
host_name: 'test-host',
log_level: logLevel,
service,
},
body: `${timestampString} ${logLevel} ${service} Log message ${cumulativeIndex}`,
id,
resources_string: {
'host.name': 'test-host',
},
severity_number: [9, 13, 17][cumulativeIndex % 3],
severity_text: logLevel,
span_id: `span-${cumulativeIndex}`,
trace_flags: 0,
trace_id: `trace-${cumulativeIndex}`,
},
};
}),
},
],
},
meta: {
bytesScanned: 0,
durationMs: 0,
rowsScanned: 0,
stepIntervals: {},
},
},
};
};
export function mockQueryRangeV5WithLogsResponse({
hasMore = true,
offset = 0,
pageSize = 100,
delay = 0,
onReceiveRequest,
}: MockLogsOptions = {}): void {
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
res(
...(delay ? [ctx.delay(delay)] : []),
ctx.status(200),
ctx.json(
createLogsResponse(
(await onReceiveRequest?.(req)) ?? {
hasMore,
pageSize,
offset,
},
),
),
),
),
);
}
export function mockQueryRangeV5WithError(
error: string,
statusCode = 500,
): void {
server.use(
rest.post(QUERY_RANGE_URL, (_, res, ctx) =>
res(
ctx.status(statusCode),
ctx.json({
error,
}),
),
),
);
}
export type MockEventsOptions = {
offset?: number;
pageSize?: number;
hasMore?: boolean;
delay?: number;
onReceiveRequest?: (
req: RestRequest,
) =>
| undefined
| void
| Omit<MockEventsOptions, 'onReceiveRequest'>
| Promise<Omit<MockEventsOptions, 'onReceiveRequest'>>
| Promise<void>;
};
const createEventsResponse = ({
offset = 0,
pageSize = 10,
hasMore = true,
}: MockEventsOptions): MetricRangePayloadV5 => {
const itemsForThisPage = hasMore ? pageSize : Math.ceil(pageSize / 2);
const eventReasons = [
'BackoffLimitExceeded',
'SuccessfulCreate',
'Pulled',
'Created',
'Started',
'Killing',
];
const severityTexts = ['Warning', 'Normal'];
const severityNumbers = [13, 9];
const objectKinds = ['Job', 'Pod', 'Deployment', 'ReplicaSet'];
const eventBodies = [
'Job has reached the specified backoff limit',
'Created pod: demo-pod',
'Successfully pulled image',
'Created container',
'Started container',
'Stopping container',
];
return {
data: {
type: 'raw',
data: {
results: [
{
queryName: 'A',
nextCursor: hasMore ? 'next-cursor-token' : '',
rows: Array.from({ length: itemsForThisPage }, (_, index) => {
const cumulativeIndex = offset + index;
const baseTimestamp = new Date('2026-04-21T17:54:33Z').getTime();
const currentTimestamp = new Date(
baseTimestamp - cumulativeIndex * 60000,
);
const timestampString = currentTimestamp.toISOString();
const id = `event-id-${cumulativeIndex}`;
const severityIndex = cumulativeIndex % 2;
const reasonIndex = cumulativeIndex % eventReasons.length;
const kindIndex = cumulativeIndex % objectKinds.length;
return {
timestamp: timestampString,
data: {
attributes_bool: {},
attributes_number: {
'k8s.event.count': 1,
},
attributes_string: {
'k8s.event.action': '',
'k8s.event.name': `demo-event-${cumulativeIndex}.${Math.random()
.toString(36)
.substring(7)}`,
'k8s.event.reason': eventReasons[reasonIndex],
'k8s.event.start_time': `${currentTimestamp.toISOString()} +0000 UTC`,
'k8s.event.uid': `uid-${cumulativeIndex}`,
'k8s.namespace.name': 'demo-apps',
},
body: eventBodies[reasonIndex],
id,
resources_string: {
'k8s.cluster.name': 'signoz-test',
'k8s.node.name': '',
'k8s.object.api_version': 'batch/v1',
'k8s.object.fieldpath': '',
'k8s.object.kind': objectKinds[kindIndex],
'k8s.object.name': `demo-object-${cumulativeIndex}`,
'k8s.object.resource_version': `${462900 + cumulativeIndex}`,
'k8s.object.uid': `object-uid-${cumulativeIndex}`,
'signoz.component': 'otel-deployment',
},
scope_name:
'github.com/open-telemetry/opentelemetry-collector-contrib/receiver/k8seventsreceiver',
scope_string: {},
scope_version: '0.139.0',
severity_number: severityNumbers[severityIndex],
severity_text: severityTexts[severityIndex],
span_id: '',
timestamp: currentTimestamp.getTime() * 1000000,
trace_flags: 0,
trace_id: '',
},
};
}),
},
],
},
meta: {
bytesScanned: 9682976,
durationMs: 295,
rowsScanned: 34198,
stepIntervals: {
A: 170,
},
},
},
};
};
export function mockQueryRangeV5WithEventsResponse({
hasMore = true,
offset = 0,
pageSize = 10,
delay = 0,
onReceiveRequest,
}: MockEventsOptions = {}): void {
server.use(
rest.post(QUERY_RANGE_URL, async (req, res, ctx) =>
res(
...(delay ? [ctx.delay(delay)] : []),
ctx.status(200),
ctx.json(
createEventsResponse(
(await onReceiveRequest?.(req)) ?? {
hasMore,
pageSize,
offset,
},
),
),
),
),
);
}

View File

@@ -14,7 +14,6 @@ import type {
import type {
InframonitoringtypesPostableClustersDTO,
InframonitoringtypesPostableDaemonSetsDTO,
InframonitoringtypesPostableDeploymentsDTO,
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableJobsDTO,
@@ -24,7 +23,6 @@ import type {
InframonitoringtypesPostableStatefulSetsDTO,
InframonitoringtypesPostableVolumesDTO,
ListClusters200,
ListDaemonSets200,
ListDeployments200,
ListHosts200,
ListJobs200,
@@ -122,89 +120,6 @@ export const useListClusters = <
> => {
return useMutation(getListClustersMutationOptions(options));
};
/**
* Returns a paginated list of Kubernetes DaemonSets with key aggregated pod metrics: CPU usage and memory working set summed across pods owned by the daemonset, plus average CPU/memory request and limit utilization (daemonSetCPURequest, daemonSetCPULimit, daemonSetMemoryRequest, daemonSetMemoryLimit). Each row also reports the latest known node-level counters from kube-state-metrics: desiredNodes (k8s.daemonset.desired_scheduled_nodes, the number of nodes the daemonset wants to run on) and currentNodes (k8s.daemonset.current_scheduled_nodes, the number of nodes the daemonset currently runs on) — note these are node counts, not pod counts. It also reports per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } from each pod's latest k8s.pod.phase value). Each daemonset includes metadata attributes (k8s.daemonset.name, k8s.namespace.name, k8s.cluster.name). The response type is 'list' for the default k8s.daemonset.name grouping or 'grouped_list' for custom groupBy keys; in both modes every row aggregates pods owned by daemonsets in the group. Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_request / cpu_limit / memory / memory_request / memory_limit / desired_nodes / current_nodes, 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 (daemonSetCPU, daemonSetCPURequest, daemonSetCPULimit, daemonSetMemory, daemonSetMemoryRequest, daemonSetMemoryLimit, desiredNodes, currentNodes) return -1 as a sentinel when no data is available for that field.
* @summary List DaemonSets for Infra Monitoring
*/
export const listDaemonSets = (
inframonitoringtypesPostableDaemonSetsDTO?: BodyType<InframonitoringtypesPostableDaemonSetsDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListDaemonSets200>({
url: `/api/v2/infra_monitoring/daemonsets`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableDaemonSetsDTO,
signal,
});
};
export const getListDaemonSetsMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listDaemonSets>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableDaemonSetsDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listDaemonSets>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableDaemonSetsDTO> },
TContext
> => {
const mutationKey = ['listDaemonSets'];
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 listDaemonSets>>,
{ data?: BodyType<InframonitoringtypesPostableDaemonSetsDTO> }
> = (props) => {
const { data } = props ?? {};
return listDaemonSets(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListDaemonSetsMutationResult = NonNullable<
Awaited<ReturnType<typeof listDaemonSets>>
>;
export type ListDaemonSetsMutationBody =
| BodyType<InframonitoringtypesPostableDaemonSetsDTO>
| undefined;
export type ListDaemonSetsMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List DaemonSets for Infra Monitoring
*/
export const useListDaemonSets = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listDaemonSets>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableDaemonSetsDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listDaemonSets>>,
TError,
{ data?: BodyType<InframonitoringtypesPostableDaemonSetsDTO> },
TContext
> => {
return useMutation(getListDaemonSetsMutationOptions(options));
};
/**
* 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

View File

@@ -3376,84 +3376,6 @@ export interface InframonitoringtypesClustersDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesDaemonSetRecordDTOMetaAnyOf = {
[key: string]: string;
};
/**
* @nullable
*/
export type InframonitoringtypesDaemonSetRecordDTOMeta =
InframonitoringtypesDaemonSetRecordDTOMetaAnyOf | null;
export interface InframonitoringtypesDaemonSetRecordDTO {
/**
* @type integer
*/
currentNodes: number;
/**
* @type number
* @format double
*/
daemonSetCPU: number;
/**
* @type number
* @format double
*/
daemonSetCPULimit: number;
/**
* @type number
* @format double
*/
daemonSetCPURequest: number;
/**
* @type number
* @format double
*/
daemonSetMemory: number;
/**
* @type number
* @format double
*/
daemonSetMemoryLimit: number;
/**
* @type number
* @format double
*/
daemonSetMemoryRequest: number;
/**
* @type string
*/
daemonSetName: string;
/**
* @type integer
*/
desiredNodes: number;
/**
* @type object,null
*/
meta: InframonitoringtypesDaemonSetRecordDTOMeta;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
}
export interface InframonitoringtypesDaemonSetsDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array,null
*/
records: InframonitoringtypesDaemonSetRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export type InframonitoringtypesDeploymentRecordDTOMetaAnyOf = {
[key: string]: string;
};
@@ -4004,33 +3926,6 @@ export interface InframonitoringtypesPostableClustersDTO {
start: number;
}
export interface InframonitoringtypesPostableDaemonSetsDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array,null
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostableDeploymentsDTO {
/**
* @type integer
@@ -8535,14 +8430,6 @@ export type ListClusters200 = {
status: string;
};
export type ListDaemonSets200 = {
data: InframonitoringtypesDaemonSetsDTO;
/**
* @type string
*/
status: string;
};
export type ListDeployments200 = {
data: InframonitoringtypesDeploymentsDTO;
/**

View File

@@ -264,7 +264,6 @@ function convertRawData(
date: row.timestamp,
} as any,
})),
nextCursor: rawData.nextCursor,
};
}

View File

@@ -181,12 +181,7 @@ function createBaseSpec(
)
: undefined,
legend: isEmpty(queryData.legend) ? undefined : queryData.legend,
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(queryData.having) || Array.isArray(queryData.having)
? undefined
: (queryData?.having as Having),
having: isEmpty(queryData.having) ? undefined : (queryData?.having as Having),
functions: isEmpty(queryData.functions)
? undefined
: queryData.functions.map((func: QueryFunction): QueryFunction => {
@@ -414,10 +409,7 @@ function createTraceOperatorBaseSpec(
)
: undefined,
legend: isEmpty(legend) ? undefined : legend,
// V4 uses having as array, V5 uses having as object with expression field
// If having is an array (V4 format), treat it as undefined for V5
having:
isEmpty(having) || Array.isArray(having) ? undefined : (having as Having),
having: isEmpty(having) ? undefined : (having as Having),
selectFields: isEmpty(nonEmptySelectColumns)
? undefined
: nonEmptySelectColumns?.map(

View File

@@ -1,14 +0,0 @@
.wrapper {
cursor: not-allowed;
}
.errorContent {
background: var(--callout-error-background) !important;
border-color: var(--callout-error-border) !important;
backdrop-filter: blur(15px);
border-radius: 4px !important;
color: var(--foreground) !important;
font-style: normal;
font-weight: 400;
white-space: nowrap;
}

View File

@@ -1,145 +0,0 @@
import { ReactElement } from 'react';
import { render, screen } from 'tests/test-utils';
import { buildPermission } from 'hooks/useAuthZ/utils';
import type { AuthZObject, BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import AuthZTooltip from './AuthZTooltip';
jest.mock('hooks/useAuthZ/useAuthZ');
const mockUseAuthZ = useAuthZ as jest.MockedFunction<typeof useAuthZ>;
const noPermissions = {
isLoading: false,
isFetching: false,
error: null,
permissions: null,
refetchPermissions: jest.fn(),
};
const TestButton = (
props: React.ButtonHTMLAttributes<HTMLButtonElement>,
): ReactElement => (
<button type="button" {...props}>
Action
</button>
);
const createPerm = buildPermission(
'create',
'serviceaccount:*' as AuthZObject<'create'>,
);
const attachSAPerm = (id: string): BrandedPermission =>
buildPermission('attach', `serviceaccount:${id}` as AuthZObject<'attach'>);
const attachRolePerm = buildPermission(
'attach',
'role:*' as AuthZObject<'attach'>,
);
describe('AuthZTooltip — single check', () => {
it('renders child unchanged when permission is granted', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: true } },
});
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('disables child when permission is denied', () => {
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: { [createPerm]: { isGranted: false } },
});
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('disables child while loading', () => {
mockUseAuthZ.mockReturnValue({ ...noPermissions, isLoading: true });
render(
<AuthZTooltip checks={[createPerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
});
describe('AuthZTooltip — multi-check (checks array)', () => {
it('renders child enabled when all checks are granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: true },
[attachRolePerm]: { isGranted: true },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).not.toBeDisabled();
});
it('disables child when first check is denied, second granted', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: true },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
});
it('disables child when both checks are denied and lists denied permissions in data attr', () => {
const sa = attachSAPerm('sa-1');
mockUseAuthZ.mockReturnValue({
...noPermissions,
permissions: {
[sa]: { isGranted: false },
[attachRolePerm]: { isGranted: false },
},
});
render(
<AuthZTooltip checks={[sa, attachRolePerm]}>
<TestButton />
</AuthZTooltip>,
);
expect(screen.getByRole('button', { name: 'Action' })).toBeDisabled();
const wrapper = screen.getByRole('button', { name: 'Action' }).parentElement;
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(sa);
expect(wrapper?.getAttribute('data-denied-permissions')).toContain(
attachRolePerm,
);
});
});

View File

@@ -1,85 +0,0 @@
import { ReactElement, cloneElement, useMemo } from 'react';
import {
TooltipRoot,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from '@signozhq/ui/tooltip';
import type { BrandedPermission } from 'hooks/useAuthZ/types';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parsePermission } from 'hooks/useAuthZ/utils';
import styles from './AuthZTooltip.module.scss';
interface AuthZTooltipProps {
checks: BrandedPermission[];
children: ReactElement;
enabled?: boolean;
tooltipMessage?: string;
}
function formatDeniedMessage(
denied: BrandedPermission[],
override?: string,
): string {
if (override) {
return override;
}
const labels = denied.map((p) => {
const { relation, object } = parsePermission(p);
const resource = object.split(':')[0];
return `${relation} ${resource}`;
});
return labels.length === 1
? `You don't have ${labels[0]} permission`
: `You don't have ${labels.join(', ')} permissions`;
}
function AuthZTooltip({
checks,
children,
enabled = true,
tooltipMessage,
}: AuthZTooltipProps): JSX.Element {
const shouldCheck = enabled && checks.length > 0;
const { permissions, isLoading } = useAuthZ(checks, { enabled: shouldCheck });
const deniedPermissions = useMemo(() => {
if (!permissions) {
return [];
}
return checks.filter((p) => permissions[p]?.isGranted === false);
}, [checks, permissions]);
if (shouldCheck && isLoading) {
return (
<span className={styles.wrapper}>
{cloneElement(children, { disabled: true })}
</span>
);
}
if (!shouldCheck || deniedPermissions.length === 0) {
return children;
}
return (
<TooltipProvider>
<TooltipRoot>
<TooltipTrigger asChild>
<span
className={styles.wrapper}
data-denied-permissions={deniedPermissions.join(',')}
>
{cloneElement(children, { disabled: true })}
</span>
</TooltipTrigger>
<TooltipContent className={styles.errorContent}>
{formatDeniedMessage(deniedPermissions, tooltipMessage)}
</TooltipContent>
</TooltipRoot>
</TooltipProvider>
);
}
export default AuthZTooltip;

View File

@@ -2,8 +2,6 @@ import { Controller, useForm } from 'react-hook-form';
import { useQueryClient } from 'react-query';
import { X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { SACreatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogFooter, DialogWrapper } from '@signozhq/ui/dialog';
import { Input } from '@signozhq/ui/input';
import { toast } from '@signozhq/ui/sonner';
@@ -134,19 +132,17 @@ function CreateServiceAccountModal(): JSX.Element {
Cancel
</Button>
<AuthZTooltip checks={[SACreatePermission]}>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</AuthZTooltip>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form="create-sa-form"
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Service Account
</Button>
</DialogFooter>
</DialogWrapper>
);

View File

@@ -11,15 +11,6 @@ import {
import CreateServiceAccountModal from '../CreateServiceAccountModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -122,9 +113,7 @@ describe('CreateServiceAccountModal', () => {
getErrorMessage: expect.any(Function),
}),
);
const passedError = showErrorModal.mock.calls[0][0] as {
getErrorMessage: () => string;
};
const passedError = showErrorModal.mock.calls[0][0] as any;
expect(passedError.getErrorMessage()).toBe('Internal Server Error');
});
@@ -143,9 +132,6 @@ describe('CreateServiceAccountModal', () => {
await user.click(screen.getByRole('button', { name: /Cancel/i }));
await waitForElementToBeRemoved(dialog);
expect(
screen.queryByRole('dialog', { name: /New Service Account/i }),
).not.toBeInTheDocument();
});
it('shows "Name is required" after clearing the name field', async () => {
@@ -156,8 +142,6 @@ describe('CreateServiceAccountModal', () => {
await user.type(nameInput, 'Bot');
await user.clear(nameInput);
await expect(
screen.findByText('Name is required'),
).resolves.toBeInTheDocument();
await screen.findByText('Name is required');
});
});

View File

@@ -1,13 +1,34 @@
import { ReactElement } from 'react';
import {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { BrandedPermission } from 'hooks/useAuthZ/types';
import { buildPermission } from 'hooks/useAuthZ/utils';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { GuardAuthZ } from './GuardAuthZ';
const BASE_URL = ENVIRONMENT.baseURL || '';
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
describe('GuardAuthZ', () => {
const TestChild = (): ReactElement => <div>Protected Content</div>;
const LoadingFallback = (): ReactElement => <div>Loading...</div>;

View File

@@ -1,4 +0,0 @@
.callout {
box-sizing: border-box;
width: 100%;
}

View File

@@ -1,22 +0,0 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedCallout from './PermissionDeniedCallout';
describe('PermissionDeniedCallout', () => {
it('renders the permission name in the callout message', () => {
render(<PermissionDeniedCallout permissionName="serviceaccount:attach" />);
expect(screen.getByText(/You don't have/)).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:attach/)).toBeInTheDocument();
expect(screen.getByText(/permission/)).toBeInTheDocument();
});
it('accepts an optional className', () => {
const { container } = render(
<PermissionDeniedCallout
permissionName="serviceaccount:read"
className="custom-class"
/>,
);
expect(container.firstChild).toHaveClass('custom-class');
});
});

View File

@@ -1,26 +0,0 @@
import { Callout } from '@signozhq/ui/callout';
import cx from 'classnames';
import styles from './PermissionDeniedCallout.module.scss';
interface PermissionDeniedCalloutProps {
permissionName: string;
className?: string;
}
function PermissionDeniedCallout({
permissionName,
className,
}: PermissionDeniedCalloutProps): JSX.Element {
return (
<Callout
type="error"
showIcon
size="small"
className={cx(styles.callout, className)}
>
{`You don't have ${permissionName} permission`}
</Callout>
);
}
export default PermissionDeniedCallout;

View File

@@ -1,44 +0,0 @@
.container {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
min-height: 50vh;
padding: var(--spacing-10);
}
.content {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: var(--spacing-2);
max-width: 512px;
}
.icon {
margin-bottom: var(--spacing-1);
}
.title {
margin: 0;
font-size: var(--label-base-500-font-size);
font-weight: var(--label-base-500-font-weight);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
color: var(--l1-foreground);
}
.subtitle {
margin: 0;
font-size: var(--label-base-400-font-size);
font-weight: var(--label-base-400-font-weight);
line-height: var(--line-height-18);
letter-spacing: -0.07px;
color: var(--l2-foreground);
}
.permission {
font-family: monospace;
color: var(--l2-foreground);
}

View File

@@ -1,21 +0,0 @@
import { render, screen } from 'tests/test-utils';
import PermissionDeniedFullPage from './PermissionDeniedFullPage';
describe('PermissionDeniedFullPage', () => {
it('renders the title and subtitle with the permissionName interpolated', () => {
render(<PermissionDeniedFullPage permissionName="serviceaccount:list" />);
expect(
screen.getByText("Uh-oh! You don't have permission to view this page."),
).toBeInTheDocument();
expect(screen.getByText(/serviceaccount:list/)).toBeInTheDocument();
expect(
screen.getByText(/Please ask your SigNoz administrator to grant access/),
).toBeInTheDocument();
});
it('renders with a different permissionName', () => {
render(<PermissionDeniedFullPage permissionName="role:read" />);
expect(screen.getByText(/role:read/)).toBeInTheDocument();
});
});

View File

@@ -1,31 +0,0 @@
import { CircleSlash2 } from '@signozhq/icons';
import styles from './PermissionDeniedFullPage.module.scss';
import { Style } from '@signozhq/design-tokens';
interface PermissionDeniedFullPageProps {
permissionName: string;
}
function PermissionDeniedFullPage({
permissionName,
}: PermissionDeniedFullPageProps): JSX.Element {
return (
<div className={styles.container}>
<div className={styles.content}>
<span className={styles.icon}>
<CircleSlash2 color={Style.CALLOUT_WARNING_TITLE} size={14} />
</span>
<p className={styles.title}>
Uh-oh! You don&apos;t have permission to view this page.
</p>
<p className={styles.subtitle}>
You need <code className={styles.permission}>{permissionName}</code> to
view this page. Please ask your SigNoz administrator to grant access.
</p>
</div>
</div>
);
}
export default PermissionDeniedFullPage;

View File

@@ -1,14 +1,14 @@
import { ReactNode, useEffect, useRef } from 'react';
import { ReactNode, useCallback, useEffect, useMemo, useRef } from 'react';
import { parseAsString, useQueryState } from 'nuqs';
import { useStore } from 'zustand';
import { getUserExpressionFromCombined } from '../utils';
import { QuerySearchV2Context } from './context';
import {
createExpressionStore,
QuerySearchV2Store,
} from './QuerySearchV2.store';
import type { StoreApi } from 'zustand';
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import { QuerySearchV2Context } from './context';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
import { createExpressionStore } from './QuerySearchV2.store';
export interface QuerySearchV2ProviderProps {
queryParamKey: string;
@@ -22,7 +22,7 @@ export interface QuerySearchV2ProviderProps {
/**
* Provider component that creates a scoped zustand store and exposes
* the store via context. Handles URL synchronization.
* expression state to children via context.
*/
export function QuerySearchV2Provider({
initialExpression = '',
@@ -30,10 +30,7 @@ export function QuerySearchV2Provider({
queryParamKey,
children,
}: QuerySearchV2ProviderProps): JSX.Element {
const storeRef = useRef<StoreApi<QuerySearchV2Store> | null>(null);
if (!storeRef.current) {
storeRef.current = createExpressionStore();
}
const storeRef = useRef(createExpressionStore());
const store = storeRef.current;
const [urlExpression, setUrlExpression] = useQueryState(
@@ -42,10 +39,10 @@ export function QuerySearchV2Provider({
);
const committedExpression = useStore(store, (s) => s.committedExpression);
useEffect(() => {
store.getState().setInitialExpression(initialExpression);
}, [initialExpression, store]);
const setInputExpression = useStore(store, (s) => s.setInputExpression);
const commitExpression = useStore(store, (s) => s.commitExpression);
const initializeFromUrl = useStore(store, (s) => s.initializeFromUrl);
const resetExpression = useStore(store, (s) => s.resetExpression);
const isInitialized = useRef(false);
useEffect(() => {
@@ -54,10 +51,10 @@ export function QuerySearchV2Provider({
initialExpression,
urlExpression,
);
store.getState().initializeFromUrl(cleanedExpression);
initializeFromUrl(cleanedExpression);
isInitialized.current = true;
}
}, [urlExpression, initialExpression, store]);
}, [urlExpression, initialExpression, initializeFromUrl]);
useEffect(() => {
if (isInitialized.current || !urlExpression) {
@@ -69,13 +66,60 @@ export function QuerySearchV2Provider({
return (): void => {
if (!persistOnUnmount) {
setUrlExpression(null);
store.getState().resetExpression();
resetExpression();
}
};
}, [persistOnUnmount, setUrlExpression, store]);
}, [persistOnUnmount, setUrlExpression, resetExpression]);
const handleChange = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
setInputExpression(userOnly);
},
[initialExpression, setInputExpression],
);
const handleRun = useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
commitExpression(userOnly);
},
[initialExpression, commitExpression],
);
const combinedExpression = useMemo(
() => combineInitialAndUserExpression(initialExpression, committedExpression),
[initialExpression, committedExpression],
);
const contextValue = useMemo<QuerySearchV2ContextValue>(
() => ({
expression: combinedExpression,
userExpression: committedExpression,
initialExpression,
querySearchProps: {
initialExpression: initialExpression.trim() ? initialExpression : undefined,
onChange: handleChange,
onRun: handleRun,
},
}),
[
combinedExpression,
committedExpression,
initialExpression,
handleChange,
handleRun,
],
);
return (
<QuerySearchV2Context.Provider value={store}>
<QuerySearchV2Context.Provider value={contextValue}>
{children}
</QuerySearchV2Context.Provider>
);

View File

@@ -1,10 +1,6 @@
import { createStore, StoreApi } from 'zustand';
export type QuerySearchV2Store = {
/**
* Initial expression (set by provider, used to combine with user expression)
*/
initialExpression: string;
/**
* User-typed expression (local state, updates on typing)
*/
@@ -13,21 +9,32 @@ export type QuerySearchV2Store = {
* Committed expression (synced to URL, updates on submit)
*/
committedExpression: string;
setInitialExpression: (expression: string) => void;
setInputExpression: (expression: string) => void;
commitExpression: (expression: string) => void;
resetExpression: () => void;
initializeFromUrl: (urlExpression: string) => void;
};
export interface QuerySearchProps {
initialExpression: string | undefined;
onChange: (expression: string) => void;
onRun: (expression: string) => void;
}
export interface QuerySearchV2ContextValue {
/**
* Combined expression: "initialExpression AND (userExpression)"
*/
expression: string;
userExpression: string;
initialExpression: string;
querySearchProps: QuerySearchProps;
}
export function createExpressionStore(): StoreApi<QuerySearchV2Store> {
return createStore<QuerySearchV2Store>((set) => ({
initialExpression: '',
inputExpression: '',
committedExpression: '',
setInitialExpression: (expression: string): void => {
set({ initialExpression: expression });
},
setInputExpression: (expression: string): void => {
set({ inputExpression: expression });
},

View File

@@ -1,14 +1,7 @@
import { ReactNode } from 'react';
import { act, renderHook } from '@testing-library/react';
import {
useExpression,
useInitialExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from '../context';
import { useQuerySearchV2Context } from '../context';
import {
QuerySearchV2Provider,
QuerySearchV2ProviderProps,
@@ -34,24 +27,6 @@ function createWrapper(
};
}
function useTestHooks(): {
expression: string;
userExpression: string;
initialExpression: string;
querySearchInitialExpressionProp: string | undefined;
onChange: (expr: string) => void;
onRun: (expr: string) => void;
} {
return {
expression: useExpression(),
userExpression: useUserExpression(),
initialExpression: useInitialExpression(),
querySearchInitialExpressionProp: useQuerySearchInitialExpressionProp(),
onChange: useQuerySearchOnChange(),
onRun: useQuerySearchOnRun(),
};
}
describe('QuerySearchExpressionProvider', () => {
beforeEach(() => {
jest.clearAllMocks();
@@ -59,7 +34,7 @@ describe('QuerySearchExpressionProvider', () => {
});
it('should provide initial context values', () => {
const { result } = renderHook(() => useTestHooks(), {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
@@ -69,7 +44,7 @@ describe('QuerySearchExpressionProvider', () => {
});
it('should combine initialExpression with userExpression', () => {
const { result } = renderHook(() => useTestHooks(), {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'k8s.pod.name = "my-pod"' }),
});
@@ -77,10 +52,10 @@ describe('QuerySearchExpressionProvider', () => {
expect(result.current.initialExpression).toBe('k8s.pod.name = "my-pod"');
act(() => {
result.current.onChange('service = "api"');
result.current.querySearchProps.onChange('service = "api"');
});
act(() => {
result.current.onRun('service = "api"');
result.current.querySearchProps.onRun('service = "api"');
});
expect(result.current.expression).toBe(
@@ -90,19 +65,19 @@ describe('QuerySearchExpressionProvider', () => {
});
it('should provide querySearchProps with correct callbacks', () => {
const { result } = renderHook(() => useTestHooks(), {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper({ initialExpression: 'initial' }),
});
expect(result.current.querySearchInitialExpressionProp).toBe('initial');
expect(typeof result.current.onChange).toBe('function');
expect(typeof result.current.onRun).toBe('function');
expect(result.current.querySearchProps.initialExpression).toBe('initial');
expect(typeof result.current.querySearchProps.onChange).toBe('function');
expect(typeof result.current.querySearchProps.onRun).toBe('function');
});
it('should initialize from URL value on mount', () => {
mockUrlValue = 'status = 500';
const { result } = renderHook(() => useTestHooks(), {
const { result } = renderHook(() => useQuerySearchV2Context(), {
wrapper: createWrapper(),
});
@@ -112,9 +87,9 @@ describe('QuerySearchExpressionProvider', () => {
it('should throw error when used outside provider', () => {
expect(() => {
renderHook(() => useExpression());
renderHook(() => useQuerySearchV2Context());
}).toThrow(
'useQuerySearchV2Store must be used within a QuerySearchV2Provider',
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
});
});

View File

@@ -1,80 +1,17 @@
// eslint-disable-next-line no-restricted-imports -- React Context required for scoped store pattern
import { createContext, useCallback, useContext } from 'react';
import { StoreApi, useStore } from 'zustand';
import { createContext, useContext } from 'react';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from '../utils';
import type { QuerySearchV2Store } from './QuerySearchV2.store';
import type { QuerySearchV2ContextValue } from './QuerySearchV2.store';
export const QuerySearchV2Context =
createContext<StoreApi<QuerySearchV2Store> | null>(null);
createContext<QuerySearchV2ContextValue | null>(null);
function useQuerySearchV2Store(): StoreApi<QuerySearchV2Store> {
const store = useContext(QuerySearchV2Context);
if (!store) {
export function useQuerySearchV2Context(): QuerySearchV2ContextValue {
const context = useContext(QuerySearchV2Context);
if (!context) {
throw new Error(
'useQuerySearchV2Store must be used within a QuerySearchV2Provider',
'useQuerySearchV2Context must be used within a QuerySearchV2Provider',
);
}
return store;
}
export function useInitialExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) => s.initialExpression);
}
export function useInputExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) => s.inputExpression);
}
export function useUserExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) => s.committedExpression);
}
export function useExpression(): string {
const store = useQuerySearchV2Store();
return useStore(store, (s) =>
combineInitialAndUserExpression(s.initialExpression, s.committedExpression),
);
}
export function useQuerySearchOnChange(): (expression: string) => void {
const store = useQuerySearchV2Store();
return useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
store.getState().initialExpression,
expression,
);
store.getState().setInputExpression(userOnly);
},
[store],
);
}
export function useQuerySearchOnRun(): (expression: string) => void {
const store = useQuerySearchV2Store();
const initialExpression = useStore(store, (s) => s.initialExpression);
return useCallback(
(expression: string): void => {
const userOnly = getUserExpressionFromCombined(
initialExpression,
expression,
);
store.getState().commitExpression(userOnly);
},
[store, initialExpression],
);
}
export function useQuerySearchInitialExpressionProp(): string | undefined {
const initialExpression = useInitialExpression();
return initialExpression.trim() ? initialExpression : undefined;
return context;
}

View File

@@ -1,12 +1,8 @@
export {
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from './context';
export { useQuerySearchV2Context } from './context';
export type { QuerySearchV2ProviderProps } from './QuerySearchV2.provider';
export { QuerySearchV2Provider } from './QuerySearchV2.provider';
export type { QuerySearchV2Store } from './QuerySearchV2.store';
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2Store,
} from './QuerySearchV2.store';

View File

@@ -1,16 +1,11 @@
export type {
QuerySearchProps,
QuerySearchV2ContextValue,
QuerySearchV2ProviderProps,
QuerySearchV2Store,
} from './QueryV2/QuerySearch/Provider';
export {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
useQuerySearchV2Context,
} from './QueryV2/QuerySearch/Provider';
export { QueryBuilderV2 } from './QueryBuilderV2';
export {

View File

@@ -32,24 +32,10 @@ import { isKeyMatch } from './utils';
import './Checkbox.styles.scss';
const SELECTED_OPERATORS = [OPERATORS['='], 'in'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in', 'nin'];
const NON_SELECTED_OPERATORS = [OPERATORS['!='], 'not in'];
const SOURCES_WITH_EMPTY_STATE_ENABLED = [QuickFiltersSource.LOGS_EXPLORER];
// Sources that use backend APIs expecting short operator format (e.g., 'nin' instead of 'not in')
const SOURCES_WITH_SHORT_OPERATORS = [QuickFiltersSource.INFRA_MONITORING];
/**
* Returns the correct NOT_IN operator value based on source.
* InfraMonitoring backend expects 'nin', others expect 'not in'.
*/
function getNotInOperator(source: QuickFiltersSource): string {
if (SOURCES_WITH_SHORT_OPERATORS.includes(source)) {
return 'nin';
}
return getOperatorValue('NOT_IN');
}
function setDefaultValues(
values: string[],
trueOrFalse: boolean,
@@ -415,7 +401,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
}
}
break;
case 'nin':
case 'not in':
// if the current running operator is NIN then when unchecking the value it gets
// added to the clause like key NIN [value1 , currentUnselectedValue]
@@ -510,7 +495,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
if (!checked) {
const newFilter = {
...currentFilter,
op: getNotInOperator(source),
op: getOperatorValue('NOT_IN'),
value: [currentFilter.value as string, value],
};
query.filters.items = query.filters.items.map((item) => {
@@ -533,7 +518,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
// case - when there is no filter for the current key that means all are selected right now.
const newFilterItem: TagFilterItem = {
id: uuid(),
op: getNotInOperator(source),
op: getOperatorValue('NOT_IN'),
key: filter.attributeKey,
value,
};

View File

@@ -80,7 +80,6 @@ interface BaseProps {
isError?: boolean;
error?: APIError;
onRefetch?: () => void;
disabled?: boolean;
}
interface SingleProps extends BaseProps {
@@ -124,7 +123,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
isError = internalError,
error = convertToApiError(internalErrorObj),
onRefetch = externalRoles === undefined ? internalRefetch : undefined,
disabled,
} = props;
const notFoundContent = isError ? (
@@ -153,7 +151,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
</Checkbox>
)}
getPopupContainer={getPopupContainer}
disabled={disabled}
/>
);
}
@@ -171,7 +168,6 @@ function RolesSelect(props: RolesSelectProps): JSX.Element {
notFoundContent={notFoundContent}
options={options}
getPopupContainer={getPopupContainer}
disabled={disabled}
/>
);
}

View File

@@ -4,11 +4,6 @@ import { Button } from '@signozhq/ui/button';
import { Input } from '@signozhq/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildSAAttachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate } from '../utils';
@@ -23,7 +18,6 @@ export interface KeyFormPhaseProps {
isValid: boolean;
onSubmit: () => void;
onClose: () => void;
accountId?: string;
}
function KeyFormPhase({
@@ -34,7 +28,6 @@ function KeyFormPhase({
isValid,
onSubmit,
onClose,
accountId,
}: KeyFormPhaseProps): JSX.Element {
return (
<>
@@ -118,25 +111,17 @@ function KeyFormPhase({
<Button variant="solid" color="secondary" onClick={onClose}>
Cancel
</Button>
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(accountId ?? ''),
]}
enabled={!!accountId}
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSubmitting}
disabled={!isValid}
>
Create Key
</Button>
</AuthZTooltip>
Create Key
</Button>
</div>
</div>
</>

View File

@@ -161,7 +161,6 @@ function AddKeyModal(): JSX.Element {
isValid={isValid}
onSubmit={handleSubmit(handleCreate)}
onClose={handleClose}
accountId={accountId ?? undefined}
/>
)}

View File

@@ -1,8 +1,6 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import { buildSADeletePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -67,7 +65,7 @@ function DeleteAccountModal(): JSX.Element {
}
function handleCancel(): void {
void setIsDeleteOpen(null);
setIsDeleteOpen(null);
}
const content = (
@@ -84,20 +82,15 @@ function DeleteAccountModal(): JSX.Element {
<X size={12} />
Cancel
</Button>
<AuthZTooltip
checks={[buildSADeletePermission(accountId ?? '')]}
enabled={!!accountId}
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
>
<Button
variant="solid"
color="destructive"
loading={isDeleting}
onClick={handleConfirm}
>
<Trash2 size={12} />
Delete
</Button>
</AuthZTooltip>
<Trash2 size={12} />
Delete
</Button>
</div>
);

View File

@@ -7,12 +7,6 @@ import { Input } from '@signozhq/ui/input';
import { ToggleGroup, ToggleGroupItem } from '@signozhq/ui/toggle-group';
import { DatePicker } from 'antd';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildAPIKeyUpdatePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { popupContainer } from 'utils/selectPopupContainer';
import { disabledDate, formatLastObservedAt } from '../utils';
@@ -30,8 +24,6 @@ export interface EditKeyFormProps {
onClose: () => void;
onRevokeClick: () => void;
formatTimezoneAdjustedTimestamp: (ts: string, format: string) => string;
canUpdate?: boolean;
accountId?: string;
}
function EditKeyForm({
@@ -45,8 +37,6 @@ function EditKeyForm({
onClose,
onRevokeClick,
formatTimezoneAdjustedTimestamp,
canUpdate = true,
accountId = '',
}: EditKeyFormProps): JSX.Element {
return (
<>
@@ -55,34 +45,12 @@ function EditKeyForm({
<label className="edit-key-modal__label" htmlFor="edit-key-name">
Name
</label>
{!canUpdate ? (
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!keyItem?.id}
>
<div className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.name || '—'}</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
</AuthZTooltip>
) : (
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
)}
</div>
<div className="edit-key-modal__field">
<label className="edit-key-modal__label" htmlFor="edit-key-id">
ID
</label>
<div id="edit-key-id" className="edit-key-modal__key-display">
<span className="edit-key-modal__id-text">{keyItem?.id || '—'}</span>
<LockKeyhole size={12} className="edit-key-modal__lock-icon" />
</div>
<Input
id="edit-key-name"
className="edit-key-modal__input"
placeholder="Enter key name"
{...register('name')}
/>
</div>
<div className="edit-key-modal__field">
@@ -105,22 +73,21 @@ function EditKeyForm({
type="single"
value={field.value}
onChange={(val): void => {
if (val && canUpdate) {
if (val) {
field.onChange(val);
}
}}
size="sm"
className="edit-key-modal__expiry-toggle"
>
<ToggleGroupItem
value={ExpiryMode.NONE}
disabled={!canUpdate}
className="edit-key-modal__expiry-toggle-btn"
>
No Expiration
</ToggleGroupItem>
<ToggleGroupItem
value={ExpiryMode.DATE}
disabled={!canUpdate}
className="edit-key-modal__expiry-toggle-btn"
>
Set Expiration Date
@@ -147,7 +114,6 @@ function EditKeyForm({
popupClassName="edit-key-modal-datepicker-popup"
getPopupContainer={popupContainer}
disabledDate={disabledDate}
disabled={!canUpdate}
/>
)}
/>
@@ -167,39 +133,26 @@ function EditKeyForm({
</form>
<div className="edit-key-modal__footer">
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyItem?.id ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyItem?.id}
>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
<Button variant="link" color="destructive" onClick={onRevokeClick}>
<Trash2 size={12} />
Revoke Key
</Button>
<div className="edit-key-modal__footer-right">
<Button variant="solid" color="secondary" onClick={onClose}>
<X size={12} />
Cancel
</Button>
<AuthZTooltip
checks={[buildAPIKeyUpdatePermission(keyItem?.id ?? '')]}
enabled={!!accountId && !!keyItem?.id}
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
<Button
type="submit"
// @ts-expect-error -- form prop not in @signozhq/ui Button type - TODO: Fix this - @SagarRajput
form={FORM_ID}
variant="solid"
color="primary"
loading={isSaving}
disabled={!isDirty}
>
Save Changes
</Button>
</AuthZTooltip>
Save Changes
</Button>
</div>
</div>
</>

View File

@@ -60,16 +60,6 @@
letter-spacing: 2px;
}
&__id-text {
font-size: 13px;
font-family: monospace;
color: var(--foreground);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
flex: 1;
}
&__lock-icon {
color: var(--foreground);
flex-shrink: 0;

View File

@@ -16,8 +16,6 @@ import type {
import { AxiosError } from 'axios';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import dayjs from 'dayjs';
import { buildAPIKeyUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import { parseAsString, useQueryState } from 'nuqs';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useTimezone } from 'providers/Timezone';
@@ -71,16 +69,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
const expiryMode = watch('expiryMode');
const { permissions: editPermissions, isLoading: isAuthZLoading } = useAuthZ(
editKeyId ? [buildAPIKeyUpdatePermission(editKeyId)] : [],
{ enabled: !!editKeyId },
);
const canUpdate = isAuthZLoading
? false
: (editPermissions?.[buildAPIKeyUpdatePermission(editKeyId ?? '')]
?.isGranted ?? true);
const { mutate: updateKey, isLoading: isSaving } = useUpdateServiceAccountKey({
mutation: {
onSuccess: async () => {
@@ -127,7 +115,7 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
});
function handleClose(): void {
void setEditKeyId(null);
setEditKeyId(null);
setIsRevokeConfirmOpen(false);
}
@@ -181,8 +169,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
isRevoking={isRevoking}
onCancel={(): void => setIsRevokeConfirmOpen(false)}
onConfirm={handleRevoke}
accountId={selectedAccountId ?? undefined}
keyId={keyItem?.id ?? undefined}
/>
) : undefined
}
@@ -204,8 +190,6 @@ function EditKeyModal({ keyItem }: EditKeyModalProps): JSX.Element {
onClose={handleClose}
onRevokeClick={(): void => setIsRevokeConfirmOpen(true)}
formatTimezoneAdjustedTimestamp={formatTimezoneAdjustedTimestamp}
canUpdate={canUpdate}
accountId={selectedAccountId ?? ''}
/>
)}
</DialogWrapper>

View File

@@ -1,16 +1,9 @@
import React, { useCallback, useMemo } from 'react';
import { useCallback, useMemo } from 'react';
import { KeyRound, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Skeleton, Table } from 'antd';
import { Skeleton, Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/es/table/interface';
import type { ServiceaccounttypesGettableFactorAPIKeyDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
APIKeyCreatePermission,
buildAPIKeyDeletePermission,
buildSAAttachPermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import dayjs from 'dayjs';
import { parseAsBoolean, parseAsString, useQueryState } from 'nuqs';
@@ -24,15 +17,12 @@ interface KeysTabProps {
keys: ServiceaccounttypesGettableFactorAPIKeyDTO[];
isLoading: boolean;
isDisabled?: boolean;
canUpdate?: boolean;
accountId?: string;
currentPage: number;
pageSize: number;
}
interface BuildColumnsParams {
isDisabled: boolean;
accountId: string;
onRevokeClick: (keyId: string) => void;
handleformatLastObservedAt: (
lastObservedAt: Date | null | undefined,
@@ -52,7 +42,6 @@ function formatExpiry(expiresAt: number): JSX.Element {
function buildColumns({
isDisabled,
accountId,
onRevokeClick,
handleformatLastObservedAt,
}: BuildColumnsParams): ColumnsType<ServiceaccounttypesGettableFactorAPIKeyDTO> {
@@ -103,34 +92,22 @@ function buildColumns({
key: 'action',
width: 48,
align: 'right' as const,
onCell: (): {
onClick: (e: React.MouseEvent) => void;
style: React.CSSProperties;
} => ({
onClick: (e): void => e.stopPropagation(),
style: { cursor: 'default' },
}),
render: (_, record): JSX.Element => (
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(record.id),
buildSADetachPermission(accountId),
]}
enabled={!isDisabled && !!accountId}
>
<Tooltip title={isDisabled ? 'Service account disabled' : 'Revoke Key'}>
<Button
variant="ghost"
size="sm"
color="destructive"
disabled={isDisabled}
onClick={(): void => {
onClick={(e): void => {
e.stopPropagation();
onRevokeClick(record.id);
}}
className="keys-tab__revoke-btn"
>
<X size={12} />
</Button>
</AuthZTooltip>
</Tooltip>
),
},
];
@@ -140,7 +117,6 @@ function KeysTab({
keys,
isLoading,
isDisabled = false,
accountId = '',
currentPage,
pageSize,
}: KeysTabProps): JSX.Element {
@@ -167,20 +143,14 @@ function KeysTab({
const onRevokeClick = useCallback(
(keyId: string): void => {
void setRevokeKeyId(keyId);
setRevokeKeyId(keyId);
},
[setRevokeKeyId],
);
const columns = useMemo(
() =>
buildColumns({
isDisabled,
accountId,
onRevokeClick,
handleformatLastObservedAt,
}),
[isDisabled, accountId, onRevokeClick, handleformatLastObservedAt],
() => buildColumns({ isDisabled, onRevokeClick, handleformatLastObservedAt }),
[isDisabled, onRevokeClick, handleformatLastObservedAt],
);
if (isLoading) {
@@ -206,21 +176,16 @@ function KeysTab({
Learn more
</a>
</p>
<AuthZTooltip
checks={[APIKeyCreatePermission, buildSAAttachPermission(accountId)]}
enabled={!isDisabled && !!accountId}
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
<Button
variant="link"
color="primary"
onClick={async (): Promise<void> => {
await setIsAddKeyOpen(true);
}}
disabled={isDisabled}
>
+ Add your first key
</Button>
</AuthZTooltip>
+ Add your first key
</Button>
</div>
);
}

View File

@@ -3,11 +3,9 @@ import { LockKeyhole } from '@signozhq/icons';
import { Badge } from '@signozhq/ui/badge';
import { Input } from '@signozhq/ui/input';
import type { AuthtypesRoleDTO } from 'api/generated/services/sigNoz.schemas';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import RolesSelect from 'components/RolesSelect';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { ServiceAccountRow } from 'container/ServiceAccountsSettings/utils';
import { buildSAUpdatePermission } from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useTimezone } from 'providers/Timezone';
import APIError from 'types/api/error';
@@ -21,7 +19,6 @@ interface OverviewTabProps {
localRoles: string[];
onRolesChange: (v: string[]) => void;
isDisabled: boolean;
canUpdate?: boolean;
availableRoles: AuthtypesRoleDTO[];
rolesLoading?: boolean;
rolesError?: boolean;
@@ -37,7 +34,6 @@ function OverviewTab({
localRoles,
onRolesChange,
isDisabled,
canUpdate = true,
availableRoles,
rolesLoading,
rolesError,
@@ -67,16 +63,11 @@ function OverviewTab({
<label className="sa-drawer__label" htmlFor="sa-name">
Name
</label>
{isDisabled || !canUpdate ? (
<AuthZTooltip
checks={[buildSAUpdatePermission(account.id)]}
enabled={!isDisabled && !canUpdate}
>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</AuthZTooltip>
{isDisabled ? (
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{localName || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
) : (
<Input
id="sa-name"
@@ -87,16 +78,6 @@ function OverviewTab({
)}
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-id">
ID
</label>
<div className="sa-drawer__input-wrapper sa-drawer__input-wrapper--disabled">
<span className="sa-drawer__input-text">{account.id || '—'}</span>
<LockKeyhole size={14} className="sa-drawer__lock-icon" />
</div>
</div>
<div className="sa-drawer__field">
<label className="sa-drawer__label" htmlFor="sa-email">
Email Address

View File

@@ -1,11 +1,6 @@
import { useQueryClient } from 'react-query';
import { Trash2, X } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import {
buildAPIKeyDeletePermission,
buildSADetachPermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { DialogWrapper } from '@signozhq/ui/dialog';
import { toast } from '@signozhq/ui/sonner';
import { convertToApiError } from 'api/ErrorResponseHandlerForGeneratedAPIs';
@@ -28,16 +23,12 @@ export interface RevokeKeyFooterProps {
isRevoking: boolean;
onCancel: () => void;
onConfirm: () => void;
accountId?: string;
keyId?: string;
}
export function RevokeKeyFooter({
isRevoking,
onCancel,
onConfirm,
accountId,
keyId,
}: RevokeKeyFooterProps): JSX.Element {
return (
<>
@@ -45,23 +36,15 @@ export function RevokeKeyFooter({
<X size={12} />
Cancel
</Button>
<AuthZTooltip
checks={[
buildAPIKeyDeletePermission(keyId ?? ''),
buildSADetachPermission(accountId ?? ''),
]}
enabled={!!accountId && !!keyId}
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Button
variant="solid"
color="destructive"
loading={isRevoking}
onClick={onConfirm}
>
<Trash2 size={12} />
Revoke Key
</Button>
</AuthZTooltip>
<Trash2 size={12} />
Revoke Key
</Button>
</>
);
}
@@ -132,8 +115,6 @@ function RevokeKeyModal(): JSX.Element {
isRevoking={isRevoking}
onCancel={handleCancel}
onConfirm={handleConfirm}
accountId={accountId ?? undefined}
keyId={revokeKeyId || undefined}
/>
}
>

View File

@@ -16,8 +16,6 @@ import {
import type { RenderErrorResponseDTO } from 'api/generated/services/sigNoz.schemas';
import { AxiosError } from 'axios';
import ErrorInPlace from 'components/ErrorInPlace/ErrorInPlace';
import { GuardAuthZ } from 'components/GuardAuthZ/GuardAuthZ';
import PermissionDeniedCallout from 'components/PermissionDeniedCallout/PermissionDeniedCallout';
import { useRoles } from 'components/RolesSelect';
import { SA_QUERY_PARAMS } from 'container/ServiceAccountsSettings/constants';
import {
@@ -29,15 +27,6 @@ import {
RoleUpdateFailure,
useServiceAccountRoleManager,
} from 'hooks/serviceAccount/useServiceAccountRoleManager';
import {
APIKeyCreatePermission,
APIKeyListPermission,
buildSAAttachPermission,
buildSADeletePermission,
buildSAReadPermission,
buildSAUpdatePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import { useAuthZ } from 'hooks/useAuthZ/useAuthZ';
import {
parseAsBoolean,
parseAsInteger,
@@ -48,7 +37,6 @@ import {
import APIError from 'types/api/error';
import { toAPIError } from 'utils/errorUtils';
import AuthZTooltip from 'components/AuthZTooltip/AuthZTooltip';
import AddKeyModal from './AddKeyModal';
import DeleteAccountModal from './DeleteAccountModal';
import KeysTab from './KeysTab';
@@ -108,22 +96,6 @@ function ServiceAccountDrawer({
const queryClient = useQueryClient();
const { permissions: drawerPermissions, isLoading: isAuthZLoading } = useAuthZ(
selectedAccountId
? [
buildSAReadPermission(selectedAccountId),
buildSAUpdatePermission(selectedAccountId),
buildSADeletePermission(selectedAccountId),
APIKeyListPermission,
]
: [],
{ enabled: !!selectedAccountId },
);
const canRead =
drawerPermissions?.[buildSAReadPermission(selectedAccountId ?? '')]
?.isGranted ?? false;
const {
data: accountData,
isLoading: isAccountLoading,
@@ -132,7 +104,7 @@ function ServiceAccountDrawer({
refetch: refetchAccount,
} = useGetServiceAccount(
{ id: selectedAccountId ?? '' },
{ query: { enabled: canRead && !!selectedAccountId } },
{ query: { enabled: !!selectedAccountId } },
);
const account = useMemo(
@@ -145,9 +117,7 @@ function ServiceAccountDrawer({
currentRoles,
isLoading: isRolesLoading,
applyDiff,
} = useServiceAccountRoleManager(selectedAccountId ?? '', {
enabled: canRead && !!selectedAccountId,
});
} = useServiceAccountRoleManager(selectedAccountId ?? '');
const roleSessionRef = useRef<string | null>(null);
@@ -195,16 +165,9 @@ function ServiceAccountDrawer({
refetch: refetchRoles,
} = useRoles();
const canListKeys =
drawerPermissions?.[APIKeyListPermission]?.isGranted ?? false;
const canUpdate =
drawerPermissions?.[buildSAUpdatePermission(selectedAccountId ?? '')]
?.isGranted ?? true;
const { data: keysData, isLoading: keysLoading } = useListServiceAccountKeys(
{ id: selectedAccountId ?? '' },
{ query: { enabled: !!selectedAccountId && canListKeys } },
{ query: { enabled: !!selectedAccountId } },
);
const keys = keysData?.data ?? [];
@@ -429,26 +392,18 @@ function ServiceAccountDrawer({
</ToggleGroupItem>
</ToggleGroup>
{activeTab === ServiceAccountDrawerTab.Keys && (
<AuthZTooltip
checks={[
APIKeyCreatePermission,
buildSAAttachPermission(selectedAccountId ?? ''),
]}
enabled={!isDeleted && !!selectedAccountId}
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Button
variant="outlined"
size="sm"
color="secondary"
disabled={isDeleted}
onClick={(): void => {
void setIsAddKeyOpen(true);
}}
>
<Plus size={12} />
Add Key
</Button>
</AuthZTooltip>
<Plus size={12} />
Add Key
</Button>
)}
</div>
@@ -457,9 +412,7 @@ function ServiceAccountDrawer({
activeTab === ServiceAccountDrawerTab.Keys ? ' sa-drawer__body--keys' : ''
}`}
>
{(isAuthZLoading || isAccountLoading) && (
<Skeleton active paragraph={{ rows: 6 }} />
)}
{isAccountLoading && <Skeleton active paragraph={{ rows: 6 }} />}
{isAccountError && (
<ErrorInPlace
error={toAPIError(
@@ -468,55 +421,38 @@ function ServiceAccountDrawer({
)}
/>
)}
{!isAuthZLoading &&
!isAccountLoading &&
!isAccountError &&
selectedAccountId && (
<GuardAuthZ
relation="read"
object={`serviceaccount:${selectedAccountId}`}
fallbackOnNoPermissions={(): JSX.Element => (
<PermissionDeniedCallout permissionName="serviceaccount:read" />
)}
>
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
canUpdate={canUpdate}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys &&
(canListKeys ? (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
canUpdate={canUpdate}
accountId={selectedAccountId}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
) : (
<PermissionDeniedCallout permissionName="factor-api-key:list" />
))}
</>
</GuardAuthZ>
)}
{!isAccountLoading && !isAccountError && (
<>
{activeTab === ServiceAccountDrawerTab.Overview && account && (
<OverviewTab
account={account}
localName={localName}
onNameChange={handleNameChange}
localRoles={localRoles}
onRolesChange={(roles): void => {
setLocalRoles(roles);
clearRoleErrors();
}}
isDisabled={isDeleted}
availableRoles={availableRoles}
rolesLoading={rolesLoading}
rolesError={rolesError}
rolesErrorObj={rolesErrorObj}
onRefetchRoles={refetchRoles}
saveErrors={saveErrors}
/>
)}
{activeTab === ServiceAccountDrawerTab.Keys && (
<KeysTab
keys={keys}
isLoading={keysLoading}
isDisabled={isDeleted}
currentPage={keysPage}
pageSize={PAGE_SIZE}
/>
)}
</>
)}
</div>
</div>
);
@@ -546,21 +482,16 @@ function ServiceAccountDrawer({
) : (
<>
{!isDeleted && (
<AuthZTooltip
checks={[buildSADeletePermission(selectedAccountId ?? '')]}
enabled={!!selectedAccountId}
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Button
variant="link"
color="destructive"
onClick={(): void => {
void setIsDeleteOpen(true);
}}
>
<Trash2 size={12} />
Delete Service Account
</Button>
</AuthZTooltip>
<Trash2 size={12} />
Delete Service Account
</Button>
)}
{!isDeleted && (
<div className="sa-drawer__footer-right">

View File

@@ -6,15 +6,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import EditKeyModal from '../EditKeyModal';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -28,7 +19,7 @@ const mockKey: ServiceaccounttypesGettableFactorAPIKeyDTO = {
id: 'key-1',
name: 'Original Key Name',
expiresAt: 0,
lastObservedAt: null as unknown as Date,
lastObservedAt: null as any,
serviceAccountId: 'sa-1',
};

View File

@@ -6,15 +6,6 @@ import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import KeysTab from '../KeysTab';
jest.mock('components/AuthZTooltip/AuthZTooltip', () => ({
__esModule: true,
default: ({
children,
}: {
children: React.ReactElement;
}): React.ReactElement => children,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
@@ -29,7 +20,7 @@ const keys: ServiceaccounttypesGettableFactorAPIKeyDTO[] = [
id: 'key-1',
name: 'Production Key',
expiresAt: 0,
lastObservedAt: null as unknown as Date,
lastObservedAt: null as any,
serviceAccountId: 'sa-1',
},
{

View File

@@ -1,158 +0,0 @@
import type { ReactNode } from 'react';
import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
setupAuthzAdmin,
setupAuthzDeny,
setupAuthzDenyAll,
} from 'tests/authz-test-utils';
import {
APIKeyListPermission,
buildSADeletePermission,
} from 'hooks/useAuthZ/permissions/service-account.permissions';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
const ROLES_ENDPOINT = '*/api/v1/roles';
const SA_KEYS_ENDPOINT = '*/api/v1/service_accounts/:id/keys';
const SA_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_DELETE_ENDPOINT = '*/api/v1/service_accounts/sa-1';
const SA_ROLES_ENDPOINT = '*/api/v1/service_accounts/:id/roles';
const SA_ROLE_DELETE_ENDPOINT = '*/api/v1/service_accounts/:id/roles/:rid';
const activeAccountResponse = {
id: 'sa-1',
name: 'CI Bot',
email: 'ci-bot@signoz.io',
roles: ['signoz-admin'],
status: 'ACTIVE',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-02T00:00:00Z',
};
jest.mock('@signozhq/ui/drawer', () => ({
...jest.requireActual('@signozhq/ui/drawer'),
DrawerWrapper: ({
children,
footer,
open,
}: {
children?: ReactNode;
footer?: ReactNode;
open: boolean;
}): JSX.Element | null =>
open ? (
<div>
{children}
{footer}
</div>
) : null,
}));
jest.mock('@signozhq/ui/sonner', () => ({
...jest.requireActual('@signozhq/ui/sonner'),
toast: { success: jest.fn(), error: jest.fn() },
}));
function renderDrawer(
searchParams: Record<string, string> = { account: 'sa-1' },
): ReturnType<typeof render> {
return render(
<NuqsTestingAdapter searchParams={searchParams} hasMemory>
<ServiceAccountDrawer onSuccess={jest.fn()} />
</NuqsTestingAdapter>,
);
}
function setupBaseHandlers(): void {
server.use(
rest.get(ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json(listRolesSuccessResponse)),
),
rest.get(SA_KEYS_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: [] })),
),
rest.get(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: activeAccountResponse })),
),
rest.put(SA_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.get(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({
data: listRolesSuccessResponse.data.filter(
(r) => r.name === 'signoz-admin',
),
}),
),
),
rest.post(SA_ROLES_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
);
}
describe('ServiceAccountDrawer — permissions', () => {
beforeEach(() => {
jest.clearAllMocks();
setupBaseHandlers();
});
afterEach(() => {
server.resetHandlers();
});
it('shows PermissionDeniedCallout inside drawer when read permission is denied', async () => {
server.use(setupAuthzDenyAll());
renderDrawer();
await waitFor(() => {
expect(screen.getByText(/serviceaccount:read/)).toBeInTheDocument();
});
});
it('shows drawer content when read permission is granted', async () => {
server.use(setupAuthzAdmin());
renderDrawer();
await screen.findByDisplayValue('CI Bot');
expect(screen.queryByText(/serviceaccount:read/)).not.toBeInTheDocument();
});
it('shows PermissionDeniedCallout in Keys tab when list-keys permission is denied', async () => {
server.use(setupAuthzDeny(APIKeyListPermission));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
fireEvent.click(screen.getByRole('radio', { name: /keys/i }));
await waitFor(() => {
expect(screen.getByText(/factor-api-key:list/)).toBeInTheDocument();
});
});
it('disables Delete button when delete permission is denied', async () => {
server.use(setupAuthzDeny(buildSADeletePermission('sa-1')));
renderDrawer();
await screen.findByDisplayValue('CI Bot');
const deleteBtn = screen.getByRole('button', {
name: /Delete Service Account/i,
});
await waitFor(() => expect(deleteBtn).toBeDisabled());
});
});

View File

@@ -3,7 +3,6 @@ import { listRolesSuccessResponse } from 'mocks-server/__mockdata__/roles';
import { rest, server } from 'mocks-server/server';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { setupAuthzAdmin } from 'tests/authz-test-utils';
import ServiceAccountDrawer from '../ServiceAccountDrawer';
@@ -99,7 +98,6 @@ describe('ServiceAccountDrawer', () => {
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});
@@ -302,6 +300,13 @@ describe('ServiceAccountDrawer', () => {
await screen.findByText(/No keys/i);
});
it('shows skeleton while loading account data', () => {
renderDrawer();
// Skeleton renders while the fetch is in-flight
expect(document.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('shows error state when account fetch fails', async () => {
server.use(
rest.get(SA_ENDPOINT, (_, res, ctx) =>
@@ -354,7 +359,6 @@ describe('ServiceAccountDrawer save-error UX', () => {
rest.delete(SA_ROLE_DELETE_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ status: 'success', data: {} })),
),
setupAuthzAdmin(),
);
});

View File

@@ -39,7 +39,6 @@ import {
} from './TanStackTableStateContext';
import {
FlatItem,
SortState,
TableRowContext,
TanStackTableHandle,
TanStackTableProps,
@@ -89,7 +88,6 @@ function TanStackTableInner<TData>(
enableQueryParams,
pagination,
paginationClassname,
onSort,
onEndReached,
getRowKey,
getItemKey,
@@ -132,7 +130,7 @@ function TanStackTableInner<TData>(
setPage,
setLimit,
orderBy,
setOrderBy: internalSetOrderBy,
setOrderBy,
expanded,
setExpanded,
} = useTableParams(enableQueryParams, {
@@ -140,14 +138,6 @@ function TanStackTableInner<TData>(
limit: pagination?.defaultLimit,
});
const setOrderBy = useCallback(
(sort: SortState | null) => {
internalSetOrderBy(sort);
onSort?.(sort);
},
[internalSetOrderBy, onSort],
);
const isGrouped = (groupBy?.length ?? 0) > 0;
const {
@@ -615,22 +605,16 @@ function TanStackTableInner<TData>(
total={effectiveTotalCount}
onPageChange={(p): void => {
setPage(p);
pagination.onPageChange?.(p);
}}
/>
{pagination.showPageSize !== false && (
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => {
setLimit(+value);
pagination.onLimitChange?.(+value);
}}
items={paginationPageSizeItems}
/>
</div>
)}
<div className={viewStyles.paginationPageSize}>
<ComboboxSimple
value={limit?.toString()}
defaultValue="10"
onChange={(value): void => setLimit(+value)}
items={paginationPageSizeItems}
/>
</div>
{suffixPaginationContent}
</div>
)}

View File

@@ -23,13 +23,6 @@ jest.mock('../TanStackTable.module.scss', () => ({
},
}));
// Mock ResizeObserver for combobox tests
global.ResizeObserver = class ResizeObserver {
observe(): void {}
unobserve(): void {}
disconnect(): void {}
};
describe('TanStackTableView Integration', () => {
describe('rendering', () => {
it('renders all data rows', async () => {
@@ -276,131 +269,6 @@ describe('TanStackTableView Integration', () => {
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
it('shows page size selector by default (showPageSize undefined)', async () => {
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10 },
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
// Page size combobox trigger should be visible by default (button with aria-haspopup)
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
);
expect(comboboxTrigger).toBeInTheDocument();
});
it('shows page size selector when showPageSize is true', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showPageSize: true,
},
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
);
expect(comboboxTrigger).toBeInTheDocument();
});
it('hides page size selector when showPageSize is false', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showPageSize: false,
},
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
);
expect(comboboxTrigger).not.toBeInTheDocument();
});
it('calls onPageChange callback when page changes', async () => {
const user = userEvent.setup();
const onPageChange = jest.fn();
renderTanStackTable({
props: {
pagination: { total: 100, defaultPage: 1, defaultLimit: 10, onPageChange },
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
const nav = screen.getByRole('navigation');
const page2 = Array.from(nav.querySelectorAll('button')).find(
(btn) => btn.textContent?.trim() === '2',
);
if (!page2) {
throw new Error('Page 2 button not found in pagination');
}
await user.click(page2);
await waitFor(() => {
expect(onPageChange).toHaveBeenCalledWith(2);
});
});
it('calls onLimitChange callback when limit changes', async () => {
const user = userEvent.setup();
const onLimitChange = jest.fn();
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
onLimitChange,
},
},
});
await waitFor(() => {
expect(
document.querySelector('button[aria-haspopup="dialog"]'),
).toBeInTheDocument();
});
const comboboxTrigger = document.querySelector(
'button[aria-haspopup="dialog"]',
) as HTMLElement;
await user.click(comboboxTrigger);
// Select a different page size option
const option20 = await screen.findByRole('option', { name: '20' });
await user.click(option20);
await waitFor(() => {
expect(onLimitChange).toHaveBeenCalledWith(20);
});
});
});
describe('sorting', () => {
@@ -464,55 +332,6 @@ describe('TanStackTableView Integration', () => {
}
});
});
it('calls onSort callback when sorting', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
renderTanStackTable({
props: { onSort },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
const sortButton = screen.getByTitle('ID');
await user.click(sortButton);
await waitFor(() => {
expect(onSort).toHaveBeenCalledWith(
expect.objectContaining({ columnName: 'id', order: 'asc' }),
);
});
});
it('calls onSort with null when sort is cleared', async () => {
const user = userEvent.setup();
const onSort = jest.fn();
renderTanStackTable({
props: { onSort },
});
await waitFor(() => {
expect(screen.getByText('Item 1')).toBeInTheDocument();
});
const sortButton = screen.getByTitle('ID');
// First click - asc
await user.click(sortButton);
// Second click - desc
await user.click(sortButton);
// Third click - clear
await user.click(sortButton);
await waitFor(() => {
const lastCall = onSort.mock.calls[onSort.mock.calls.length - 1];
expect(lastCall[0]).toBeNull();
});
});
});
describe('row selection', () => {

View File

@@ -115,12 +115,6 @@ export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
/**
* @default true
*/
showPageSize?: boolean;
onPageChange?: (page: number) => void;
onLimitChange?: (limit: number) => void;
showTotalCount?: boolean;
totalCountLabel?: string;
};
@@ -148,8 +142,6 @@ export type TanStackTableProps<TData> = {
enableQueryParams?: boolean | string | TanstackTableQueryParamsConfig;
pagination?: PaginationProps;
paginationClassname?: string;
/** Callback when sort changes. */
onSort?: (sort: SortState | null) => void;
onEndReached?: (index: number) => void;
/** Function to get the unique key for a row (before duplicate handling).
* When set, enables automatic duplicate key detection and group-aware key composition. */

View File

@@ -1,16 +1,33 @@
import { ReactElement } from 'react';
import type { RouteComponentProps } from 'react-router-dom';
import type {
import {
AuthtypesGettableTransactionDTO,
AuthtypesTransactionDTO,
} from 'api/generated/services/sigNoz.schemas';
import { ENVIRONMENT } from 'constants/env';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { render, screen, waitFor } from 'tests/test-utils';
import { AUTHZ_CHECK_URL, authzMockResponse } from 'tests/authz-test-utils';
import { createGuardedRoute } from './createGuardedRoute';
const BASE_URL = ENVIRONMENT.baseURL || '';
const AUTHZ_CHECK_URL = `${BASE_URL}/api/v1/authz/check`;
function authzMockResponse(
payload: AuthtypesTransactionDTO[],
authorizedByIndex: boolean[],
): { data: AuthtypesGettableTransactionDTO[]; status: string } {
return {
data: payload.map((txn, i) => ({
relation: txn.relation,
object: txn.object,
authorized: authorizedByIndex[i] ?? false,
})),
status: 'success',
};
}
describe('createGuardedRoute', () => {
const TestComponent = ({ testProp }: { testProp: string }): ReactElement => (
<div>Test Component: {testProp}</div>

View File

@@ -34,7 +34,7 @@ function OnNoPermissionsFallback(response: {
<br />
Object: <span>{object}</span>
<br />
Please ask your SigNoz administrator to grant access.
Ask your SigNoz administrator to grant access.
</p>
</div>
</div>

View File

@@ -431,31 +431,6 @@ export const OPERATORS = {
NOTILIKE: 'NOT_ILIKE',
};
/**
* Maps short-form InfraMonitoring operators to long-form display labels.
* InfraMonitoring backend uses short forms (NIN), UI displays long forms (NOT_IN).
*/
export const INFRA_SHORT_TO_LONG_OPERATOR_MAP: Record<string, string> = {
NIN: 'NOT_IN',
NLIKE: 'NOT_LIKE',
NOTILIKE: 'NOT_ILIKE',
NREGEX: 'NOT_REGEX',
NEXISTS: 'NOT_EXISTS',
NCONTAINS: 'NOT_CONTAINS',
};
/**
* Maps long-form operators to short-form for InfraMonitoring API.
*/
export const INFRA_LONG_TO_SHORT_OPERATOR_MAP: Record<string, string> = {
NOT_IN: 'NIN',
NOT_LIKE: 'NLIKE',
NOT_ILIKE: 'NOTILIKE',
NOT_REGEX: 'NREGEX',
NOT_EXISTS: 'NEXISTS',
NOT_CONTAINS: 'NCONTAINS',
};
export const QUERY_BUILDER_OPERATORS_BY_TYPES = {
string: [
OPERATORS['='],

View File

@@ -12,8 +12,6 @@ import { Button, Divider, Drawer, Radio, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import type { RadioChangeEvent } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { combineInitialAndUserExpression } from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
@@ -27,6 +25,7 @@ import {
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import GetMinMax from 'lib/getMinMax';
import {
@@ -42,6 +41,7 @@ import { isCustomTimeRange, useGlobalTimeStore } from 'store/globalTime';
import { NANO_SECOND_MULTIPLIER } from 'store/globalTime/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
@@ -52,15 +52,15 @@ import {
import { openInNewTab } from 'utils/navigation';
import { v4 as uuidv4 } from 'uuid';
import { InfraMonitoringEntity, VIEW_TYPES } from '../constants';
import { filterDuplicateFilters } from '../commonUtils';
import { InfraMonitoringEntity, VIEW_TYPES, VIEWS } from '../constants';
import EntityContainers from '../EntityDetailsUtils/EntityContainers';
import EntityEvents from '../EntityDetailsUtils/EntityEvents';
import EntityLogs from '../EntityDetailsUtils/EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityLogs/hooks';
import EntityMetrics from '../EntityDetailsUtils/EntityMetrics';
import EntityProcesses from '../EntityDetailsUtils/EntityProcesses';
import EntityTraces from '../EntityDetailsUtils/EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../EntityDetailsUtils/EntityTraces/hooks';
import { QUERY_KEYS } from '../EntityDetailsUtils/utils';
import {
useInfraMonitoringEventsFilters,
useInfraMonitoringLogFilters,
@@ -71,7 +71,6 @@ import {
import LoadingContainer from '../LoadingContainer';
import '../EntityDetailsUtils/entityDetails.styles.scss';
import { parseAsString, useQueryState } from 'nuqs';
const TimeRangeOffset = 1000000000;
@@ -100,9 +99,6 @@ export interface K8sBaseDetailsProps<T> {
getEntityName: (entity: T) => string;
getInitialLogTracesFilters: (entity: T) => TagFilterItem[];
getInitialEventsFilters: (entity: T) => TagFilterItem[];
/**
* @deprecated It's not needed anymore, remove in the next PR
*/
primaryFilterKeys: string[];
metadataConfig: K8sDetailsMetadataConfig<T>[];
entityWidgetInfo: {
@@ -161,7 +157,7 @@ export function createFilterItem(
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export default function K8sBaseDetails<T>({
function K8sBaseDetails<T>({
category,
eventCategory,
getSelectedItemFilters,
@@ -169,6 +165,7 @@ export default function K8sBaseDetails<T>({
getEntityName,
getInitialLogTracesFilters,
getInitialEventsFilters,
primaryFilterKeys,
metadataConfig,
entityWidgetInfo,
getEntityQueryPayload,
@@ -177,85 +174,6 @@ export default function K8sBaseDetails<T>({
tabsConfig,
customTabs,
}: K8sBaseDetailsProps<T>): JSX.Element {
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[queryKeyPrefix, selectedItem, selectedTime, getAutoRefreshQueryKey],
);
const {
data: entityResponse,
isLoading: isEntityLoading,
isError: isEntityError,
error: entityError,
} = useQuery({
queryKey: entityQueryKey,
queryFn: ({ signal }) => {
if (!selectedItem) {
return { data: null };
}
const filters = getSelectedItemFilters(selectedItem);
const { minTime, maxTime } = getMinMaxTime();
return fetchEntityData(
{
filters,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
},
signal,
);
},
enabled: !!selectedItem,
});
const entity = entityResponse?.data ?? null;
const hasResponseError = !!entityResponse?.error;
const logsAndTracesInitialExpression = useMemo(() => {
if (!entity) {
return '';
}
const primaryFiltersOnly = {
op: 'AND' as const,
items: getInitialLogTracesFilters(entity),
};
return convertFiltersToExpression(primaryFiltersOnly).expression;
}, [entity, getInitialLogTracesFilters]);
const eventsInitialExpression = useMemo(() => {
if (!entity) {
return '';
}
const primaryFiltersOnly = {
op: 'AND' as const,
items: getInitialEventsFilters(entity),
};
return convertFiltersToExpression(primaryFiltersOnly).expression;
}, [entity, getInitialEventsFilters]);
const handleClose = useCallback((): void => {
setSelectedItem(null);
}, [setSelectedItem]);
const entityName = entity ? getEntityName(entity) : '';
// Content state (previously in K8sBaseDetailsContent)
const tabVisibility = useMemo(
() => ({
showMetrics: true,
@@ -269,6 +187,13 @@ export default function K8sBaseDetails<T>({
[tabsConfig],
);
const selectedTime = useGlobalTimeStore((s) => s.selectedTime);
const lastComputedMinMax = useGlobalTimeStore((s) => s.lastComputedMinMax);
const getMinMaxTime = useGlobalTimeStore((s) => s.getMinMaxTime);
const getAutoRefreshQueryKey = useGlobalTimeStore(
(s) => s.getAutoRefreshQueryKey,
);
const { startMs, endMs } = useMemo(
() => ({
startMs: Math.floor(lastComputedMinMax.minTime / NANO_SECOND_MULTIPLIER),
@@ -295,18 +220,103 @@ export default function K8sBaseDetails<T>({
const [selectedView, setSelectedView] = useInfraMonitoringView();
const effectiveView = hideDetailViewTabs ? VIEW_TYPES.METRICS : selectedView;
const [, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [, setTracesFiltersParam] = useInfraMonitoringTracesFilters();
const [, setEventsFiltersParam] = useInfraMonitoringEventsFilters();
const [userLogsExpression] = useQueryState(
K8S_ENTITY_LOGS_EXPRESSION_KEY,
parseAsString,
);
const [userTracesExpression] = useQueryState(
K8S_ENTITY_TRACES_EXPRESSION_KEY,
parseAsString,
const [logFiltersParam, setLogFiltersParam] = useInfraMonitoringLogFilters();
const [tracesFiltersParam, setTracesFiltersParam] =
useInfraMonitoringTracesFilters();
const [eventsFiltersParam, setEventsFiltersParam] =
useInfraMonitoringEventsFilters();
const isDarkMode = useIsDarkMode();
const [selectedItem, setSelectedItem] = useInfraMonitoringSelectedItem();
const urlQuery = useUrlQuery();
useEffect(() => {
if (
hideDetailViewTabs &&
selectedItem &&
selectedView !== VIEW_TYPES.METRICS
) {
setSelectedView(VIEW_TYPES.METRICS);
}
}, [hideDetailViewTabs, selectedItem, selectedView, setSelectedView]);
const entityQueryKey = useMemo(
() =>
getAutoRefreshQueryKey(
selectedTime,
`${queryKeyPrefix}EntityDetails`,
selectedItem,
),
[getAutoRefreshQueryKey, queryKeyPrefix, selectedItem, selectedTime],
);
const {
data: entityResponse,
isLoading: isEntityLoading,
isError: isEntityError,
} = useQuery({
queryKey: entityQueryKey,
queryFn: ({ signal }) => {
if (!selectedItem) {
return { data: null };
}
const filters = getSelectedItemFilters(selectedItem);
const { minTime, maxTime } = getMinMaxTime();
return fetchEntityData(
{
filters,
start: Math.floor(minTime / NANO_SECOND_MULTIPLIER),
end: Math.floor(maxTime / NANO_SECOND_MULTIPLIER),
},
signal,
);
},
enabled: !!selectedItem,
});
const entity = entityResponse?.data ?? null;
const initialFilters = useMemo(() => {
const filters =
effectiveView === VIEW_TYPES.LOGS ? logFiltersParam : tracesFiltersParam;
if (filters) {
return filters;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
items: getInitialLogTracesFilters(entity),
};
}, [
entity,
effectiveView,
logFiltersParam,
tracesFiltersParam,
getInitialLogTracesFilters,
]);
const initialEventsFilters = useMemo(() => {
if (eventsFiltersParam) {
return eventsFiltersParam;
}
if (!entity) {
return { op: 'AND', items: [] };
}
return {
op: 'AND',
items: getInitialEventsFilters(entity),
};
}, [entity, eventsFiltersParam, getInitialEventsFilters]);
const [logsAndTracesFilters, setLogsAndTracesFilters] =
useState<IBuilderQuery['filters']>(initialFilters);
const [eventsFilters, setEventsFilters] =
useState<IBuilderQuery['filters']>(initialEventsFilters);
useEffect(() => {
if (entity) {
logEvent(InfraMonitoringEvents.PageVisited, {
@@ -317,6 +327,11 @@ export default function K8sBaseDetails<T>({
}
}, [entity, eventCategory]);
useEffect(() => {
setLogsAndTracesFilters(initialFilters);
setEventsFilters(initialEventsFilters);
}, [initialFilters, initialEventsFilters]);
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
if (!isCustomTimeRange(currentSelectedInterval)) {
@@ -373,9 +388,143 @@ export default function K8sBaseDetails<T>({
[eventCategory, effectiveView],
);
const handleExplorePagesRedirect = (): void => {
const urlQuery = new URLSearchParams();
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
primaryFilterKeys.includes(item.key?.key ?? ''),
);
const paginationFilter = value?.items?.find(
(item) => item.key?.key === 'id',
);
const newFilters = value?.items?.filter(
(item) =>
item.key?.key !== 'id' &&
!primaryFilterKeys.includes(item.key?.key ?? ''),
);
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
...(primaryFilters || []),
...(newFilters || []),
...(paginationFilter ? [paginationFilter] : []),
].filter((item): item is TagFilterItem => item !== undefined),
),
};
setLogFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
[
setLogFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
);
const handleChangeTracesFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogsAndTracesFilters((prevFilters) => {
const primaryFilters = prevFilters?.items?.filter((item) =>
primaryFilterKeys.includes(item.key?.key ?? ''),
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
...(primaryFilters || []),
...(value?.items?.filter(
(item) => !primaryFilterKeys.includes(item.key?.key ?? ''),
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
};
setTracesFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
[
setTracesFiltersParam,
setSelectedView,
primaryFilterKeys,
eventCategory,
selectedView,
],
);
const handleChangeEventsFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setEventsFilters((prevFilters) => {
const kindFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_KIND,
);
const nameFilter = prevFilters?.items?.find(
(item) => item.key?.key === QUERY_KEYS.K8S_OBJECT_NAME,
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category: eventCategory,
view: selectedView,
});
}
const updatedFilters = {
op: 'AND',
items: filterDuplicateFilters(
[
kindFilter,
nameFilter,
...(value?.items?.filter(
(item) =>
item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND &&
item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME,
) || []),
].filter((item): item is TagFilterItem => item !== undefined),
),
};
setEventsFiltersParam(updatedFilters);
setSelectedView(view);
return updatedFilters;
});
},
[eventCategory, selectedView, setEventsFiltersParam, setSelectedView],
);
const handleExplorePagesRedirect = (): void => {
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
@@ -392,10 +541,12 @@ export default function K8sBaseDetails<T>({
});
if (selectedView === VIEW_TYPES.LOGS) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userLogsExpression || '',
);
const filtersWithoutPagination = {
...logsAndTracesFilters,
items:
logsAndTracesFilters?.items?.filter((item) => item.key?.key !== 'id') ||
[],
};
const compositeQuery = {
...initialQueryState,
@@ -406,8 +557,7 @@ export default function K8sBaseDetails<T>({
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
filters: filtersWithoutPagination,
},
],
},
@@ -417,11 +567,6 @@ export default function K8sBaseDetails<T>({
openInNewTab(`${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`);
} else if (selectedView === VIEW_TYPES.TRACES) {
const fullExpression = combineInitialAndUserExpression(
logsAndTracesInitialExpression,
userTracesExpression || '',
);
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
@@ -431,8 +576,7 @@ export default function K8sBaseDetails<T>({
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
expression: fullExpression,
filter: { expression: fullExpression },
filters: logsAndTracesFilters,
},
],
},
@@ -444,19 +588,25 @@ export default function K8sBaseDetails<T>({
}
};
const handleClose = (): void => {
lastSelectedInterval.current = null;
setSelectedItem(null);
setSelectedView(null);
setTracesFiltersParam(null);
setEventsFiltersParam(null);
setLogFiltersParam(null);
};
const entityName = entity ? getEntityName(entity) : '';
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">
{entityName ||
((isEntityError || hasResponseError) &&
'Failed to load entity details') ||
(isEntityLoading && 'Loading...') ||
'-'}
</Typography.Text>
<Typography.Text className="title">{entityName}</Typography.Text>
</>
}
placement="right"
@@ -471,17 +621,12 @@ export default function K8sBaseDetails<T>({
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{isEntityLoading && <LoadingContainer />}
{(isEntityError || hasResponseError) && (
<div className="entity-error-container">
<Typography.Text color="danger">
{entityResponse?.error ||
(entityError instanceof Error
? entityError.message
: 'Failed to load entity details')}
</Typography.Text>
</div>
{isEntityError && (
<Typography.Text color="danger">
{entityResponse?.error || 'Failed to load entity details'}
</Typography.Text>
)}
{entity && !isEntityLoading && !hasResponseError && (
{entity && !isEntityLoading && (
<>
<div className="entity-detail-drawer__entity">
<div className="entity-details-grid">
@@ -617,23 +762,13 @@ export default function K8sBaseDetails<T>({
))}
</Radio.Group>
{selectedView === VIEW_TYPES.LOGS && (
<Tooltip title="Go to Logs Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
)}
{selectedView === VIEW_TYPES.TRACES && (
<Tooltip title="Go to Traces Explorer" placement="left">
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
</Tooltip>
{(selectedView === VIEW_TYPES.LOGS ||
selectedView === VIEW_TYPES.TRACES) && (
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
)}
</div>
)}
@@ -656,10 +791,12 @@ export default function K8sBaseDetails<T>({
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKeyFilters={primaryFilterKeys}
queryKey={`${queryKeyPrefix}Logs`}
category={category}
initialExpression={logsAndTracesInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.TRACES && (
@@ -667,10 +804,12 @@ export default function K8sBaseDetails<T>({
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
handleChangeTracesFilters={handleChangeTracesFilters}
tracesFilters={logsAndTracesFilters}
selectedInterval={selectedInterval}
queryKey={`${queryKeyPrefix}Traces`}
category={category}
initialExpression={logsAndTracesInitialExpression}
category={eventCategory}
queryKeyFilters={primaryFilterKeys}
/>
)}
{effectiveView === VIEW_TYPES.EVENTS && tabVisibility.showEvents && (
@@ -678,10 +817,11 @@ export default function K8sBaseDetails<T>({
timeRange={modalTimeRange}
isModalTimeSelection
handleTimeChange={handleTimeChange}
handleChangeEventFilters={handleChangeEventsFilters}
filters={eventsFilters}
selectedInterval={selectedInterval}
category={category}
queryKey={`${queryKeyPrefix}Events`}
initialExpression={eventsInitialExpression}
/>
)}
{effectiveView === VIEW_TYPES.CONTAINERS &&
@@ -706,3 +846,5 @@ export default function K8sBaseDetails<T>({
</Drawer>
);
}
export default K8sBaseDetails;

View File

@@ -1,32 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.subtitle {
font-weight: 400;
color: var(--muted-foreground);
}

View File

@@ -1,32 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './EntityEmptyState.module.scss';
interface EntityEmptyStateProps {
hasFilters: boolean;
}
export default function EntityEmptyState({
hasFilters,
}: EntityEmptyStateProps): JSX.Element {
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={emptyStateUrl} alt="empty-state" className={styles.icon} />
{hasFilters ? (
<Typography.Text>
<span className={styles.title}>This query had no results. </span>
Edit your query and try again!
</Typography.Text>
) : (
<Typography.Text>
<span className={styles.title}>No data yet. </span>
When we receive data, it will show up here.
</Typography.Text>
)}
</div>
</div>
);
}

View File

@@ -1,40 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.contactSupport {
display: flex;
align-items: center;
margin-top: 8px;
gap: 4px;
cursor: pointer;
}
.contactSupportText {
color: var(--text-robin-400);
font-weight: 500;
}

View File

@@ -1,50 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import history from 'lib/history';
import { ArrowRight } from '@signozhq/icons';
import { openInNewTab } from 'utils/navigation';
import awwSnapUrl from '@/assets/Icons/awwSnap.svg';
import styles from './EntityError.module.scss';
export default function EntityError(): JSX.Element {
const { isCloudUser: isCloudUserVal } = useGetTenantLicense();
const handleContactSupport = (): void => {
if (isCloudUserVal) {
history.push('/support');
} else {
openInNewTab('https://signoz.io/slack');
}
};
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={awwSnapUrl} alt="error" className={styles.icon} />
<Typography.Text>
<span className={styles.title}>Aw snap :/ </span>
Something went wrong. Please try again or contact support.
</Typography.Text>
<div
className={styles.contactSupport}
onClick={handleContactSupport}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
handleContactSupport();
}
}}
>
<Typography.Link className={styles.contactSupportText}>
Contact Support
</Typography.Link>
<ArrowRight size={14} />
</div>
</div>
</div>
);
}

View File

@@ -1,101 +0,0 @@
.container {
margin-top: 1rem;
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.controls {
width: 100%;
display: flex;
justify-content: flex-end;
}
.eventsTable {
margin-top: var(--spacing-8);
:global(.ant-table) {
:global(.ant-table-thead) > tr > th {
padding: 12px;
font-weight: 500;
font-size: 11px;
line-height: 18px;
background: var(--card);
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-style: normal;
font-weight: 600;
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
:global(.ant-table-cell) {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--card);
border-bottom: none;
}
:global(.ant-table-tbody) > tr:hover > td {
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
}
:global(.ant-table-tbody) > tr > td {
border-bottom: none;
}
:global(.ant-table-thead)
> tr
> th:not(:last-child):not(:global(.ant-table-selection-column)):not(
:global(.ant-table-row-expand-icon-cell)
):not([colspan])::before {
background-color: transparent;
}
:global(.ant-empty-normal) {
visibility: hidden;
}
}
}
.expandIcon {
cursor: pointer;
}

View File

@@ -1,184 +1,226 @@
import { useCallback, useEffect, useMemo } from 'react';
import { Table, TableColumnsType } from 'antd';
import logEvent from 'api/common/logEvent';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { Button, Table, TableColumnsType } from 'antd';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { EventContents } from 'container/InfraMonitoringK8s/commonUtils';
import { VIEWS } from 'container/InfraMonitoringK8s/constants';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import LoadingContainer from 'container/InfraMonitoringK8s/LoadingContainer';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { INITIAL_PAGE_SIZE } from 'container/LogsContextList/configs';
import LogsError from 'container/LogsError/LogsError';
import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/config';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { useQueryState } from 'nuqs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { isArray } from 'lodash-es';
import {
ChevronDown,
ChevronLeft,
ChevronRight,
LoaderCircle,
} from '@signozhq/icons';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { EventContents } from './EventsContent';
import EventsNotConfigured from './EventsNotConfigured';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY, useEntityEvents } from './hooks';
import { getEntityEventsQueryPayload, isEventsKeyNotFoundError } from './utils';
import {
EntityDetailsEmptyContainer,
getEntityEventsOrLogsQueryPayload,
QUERY_KEYS,
} from '../utils';
import styles from './EntityEvents.module.scss';
import './entityEvents.styles.scss';
interface EventDataType {
key: string;
timestamp: string;
body: string;
id: string;
severity: string;
attributes_bool?: Record<string, boolean>;
attributes_number?: Record<string, number>;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;
scope_name?: string;
scope_string?: Record<string, string>;
scope_version?: string;
severity_number?: number;
severity_text?: string;
span_id?: string;
trace_flags?: number;
trace_id?: string;
severity?: string;
}
interface Props {
interface IEntityEventsProps {
timeRange: {
startTime: number;
endTime: number;
};
handleChangeEventFilters: (
filters: IBuilderQuery['filters'],
view: VIEWS,
) => void;
filters: IBuilderQuery['filters'];
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
queryKey: string;
}
const PAGE_SIZE_OPTIONS = [10, 20, 50];
const EventsPageSize = 10;
const handleExpandRow = (record: EventDataType): JSX.Element => (
<EventContents
data={{ ...record.attributes_string, ...record.resources_string }}
/>
);
function EntityEventsContent({
export default function Events({
timeRange,
handleChangeEventFilters,
filters,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
queryKey,
}: IEntityEventsProps): JSX.Element {
const { currentQuery } = useQueryBuilder();
const [pagination, setPagination] = useQueryState(
'eventsPagination',
parseAsJsonNoValidate<{ offset: number; limit: number }>(),
const [formattedEntityEvents, setFormattedEntityEvents] = useState<
EventDataType[]
>([]);
const [hasReachedEndOfEvents, setHasReachedEndOfEvents] = useState(false);
const [page, setPage] = useState(1);
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.LOGS,
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters: {
items: filters?.items?.filter(
(item) =>
item.key?.key !== QUERY_KEYS.K8S_OBJECT_KIND &&
item.key?.key !== QUERY_KEYS.K8S_OBJECT_NAME,
),
op: 'AND',
},
},
],
},
}),
[currentQuery, filters],
);
const pageSize = pagination?.limit || PAGE_SIZE_OPTIONS[0];
const offset = pagination?.offset || 0;
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const queryPayload = useMemo(() => {
const basePayload = getEntityEventsOrLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
filters,
);
basePayload.query.builder.queryData[0].pageSize = INITIAL_PAGE_SIZE;
basePayload.query.builder.queryData[0].offset =
(page - 1) * INITIAL_PAGE_SIZE;
basePayload.query.builder.queryData[0].orderBy = [
{ columnName: 'timestamp', order: ORDERBY_FILTERS.DESC },
{ columnName: 'id', order: ORDERBY_FILTERS.DESC },
];
return basePayload;
}, [timeRange.startTime, timeRange.endTime, filters, page]);
const {
events,
data: eventsData,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
} = useEntityEvents({
queryKey,
timeRange,
expression,
offset,
pageSize,
} = useQuery({
queryKey: [queryKey, timeRange.startTime, timeRange.endTime, filters, page],
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
enabled: !!queryPayload,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.EventsView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityEventsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression || '',
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const formattedEvents = useMemo<EventDataType[]>(
() =>
events.map((event) => ({
key: event.data.id,
id: event.data.id,
timestamp: event.timestamp,
body: event.data.body,
severity: event.data.severity_text,
attributes_string: event.data.attributes_string,
resources_string: event.data.resources_string,
})),
[events],
);
const columns: TableColumnsType<EventDataType> = [
{ title: 'Severity', dataIndex: 'severity', key: 'severity', width: 100 },
{
title: 'Timestamp',
dataIndex: 'timestamp',
width: 240,
width: 200,
ellipsis: true,
key: 'timestamp',
},
{ title: 'Body', dataIndex: 'body', key: 'body' },
];
useEffect(() => {
if (eventsData?.payload?.data?.newResult?.data?.result) {
const responsePayload =
eventsData?.payload.data.newResult.data.result[0].list || [];
const formattedData = responsePayload?.map(
(event): EventDataType => ({
timestamp: event.timestamp,
severity: event.data.severity_text,
body: event.data.body,
id: event.data.id,
key: event.data.id,
resources_string: event.data.resources_string,
attributes_string: event.data.attributes_string,
}),
);
setFormattedEntityEvents(formattedData);
if (
!responsePayload ||
(responsePayload &&
isArray(responsePayload) &&
responsePayload.length < EventsPageSize)
) {
setHasReachedEndOfEvents(true);
} else {
setHasReachedEndOfEvents(false);
}
}
}, [eventsData]);
const handleExpandRow = (record: EventDataType): JSX.Element => (
<EventContents
data={{ ...record.attributes_string, ...record.resources_string }}
/>
);
const handlePrev = (): void => {
if (!formattedEntityEvents.length) {
return;
}
setPage(page - 1);
};
const handleNext = (): void => {
if (!formattedEntityEvents.length) {
return;
}
setPage(page + 1);
};
const handleExpandRowIcon = ({
expanded,
onExpand,
@@ -190,36 +232,39 @@ function EntityEventsContent({
e: React.MouseEvent<HTMLElement, MouseEvent>,
) => void;
record: EventDataType;
}): JSX.Element => {
const handleClick = (e: React.MouseEvent<SVGSVGElement>): void => {
onExpand(record, e as unknown as React.MouseEvent<HTMLElement, MouseEvent>);
};
return expanded ? (
<ChevronDown className={styles.expandIcon} size={14} onClick={handleClick} />
}): JSX.Element =>
expanded ? (
<ChevronDown
className="periscope-btn-icon"
size={14}
onClick={(e): void =>
onExpand(record, e as unknown as React.MouseEvent<HTMLElement, MouseEvent>)
}
/>
) : (
<ChevronRight
className={styles.expandIcon}
className="periscope-btn-icon"
size={14}
onClick={handleClick}
// eslint-disable-next-line sonarjs/no-identical-functions
onClick={(e): void =>
onExpand(record, e as unknown as React.MouseEvent<HTMLElement, MouseEvent>)
}
/>
);
};
useEffect(() => {
return (): void => {
void setPagination(null);
};
}, [setPagination]);
const isDataEmpty =
!isLoading && !isFetching && !isError && formattedEvents.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<div className="entity-events-container">
<div className="entity-events-header">
<div className="filter-section">
{query && (
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={(value): void => handleChangeEventFilters(value, VIEWS.EVENTS)}
disableNavigationShortcuts
/>
)}
</div>
<div className="datetime-section">
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
@@ -231,95 +276,67 @@ function EntityEventsContent({
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
{isLoading && formattedEvents.length === 0 && <LoadingContainer />}
{isLoading && formattedEntityEvents.length === 0 && <LoadingContainer />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isLoading && isEventsKeyNotFoundError(error) && (
<EventsNotConfigured />
{!isLoading && !isError && formattedEntityEvents.length === 0 && (
<EntityDetailsEmptyContainer category={category} view="events" />
)}
{isError && !isLoading && !isEventsKeyNotFoundError(error) && (
<EntityError />
)}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && formattedEvents.length > 0 && (
<div className={styles.eventsTable}>
<div className={styles.controls}>
<Controls
totalCount={hasMore ? currentCount + 1 : currentCount}
countPerPage={pageSize}
offset={offset}
perPageOptions={PAGE_SIZE_OPTIONS}
isLoading={isFetching}
handleNavigatePrevious={(): void => {
void setPagination({
offset: Math.max(0, offset - pageSize),
limit: pageSize,
});
}}
handleNavigateNext={(): void => {
void setPagination({
offset: offset + pageSize,
limit: pageSize,
});
}}
handleCountItemsPerPageChange={(value): void => {
void setPagination({
offset: 0,
limit: value,
});
{!isLoading && !isError && formattedEntityEvents.length > 0 && (
<div className="entity-events-list-container">
<div className="entity-events-list-card">
<Table<EventDataType>
loading={isLoading && page > 1}
columns={columns}
expandable={{
expandedRowRender: handleExpandRow,
rowExpandable: (record): boolean => record.body !== 'Not Expandable',
expandIcon: handleExpandRowIcon,
}}
dataSource={formattedEntityEvents}
pagination={false}
rowKey={(record): string => record.id}
/>
</div>
</div>
)}
<Table<EventDataType>
loading={isFetching && formattedEvents.length === 0}
columns={columns}
expandable={{
expandedRowRender: handleExpandRow,
rowExpandable: (record): boolean => record.body !== 'Not Expandable',
expandIcon: handleExpandRowIcon,
}}
dataSource={formattedEvents}
pagination={false}
rowKey={(record): string => record.id}
/>
{!isError && formattedEntityEvents.length > 0 && (
<div className="entity-events-footer">
<Button
className="entity-events-footer-button periscope-btn ghost"
type="link"
onClick={handlePrev}
disabled={page === 1 || isFetching || isLoading}
>
{!isFetching && <ChevronLeft size={14} />}
Prev
</Button>
<Button
className="entity-events-footer-button periscope-btn ghost"
type="link"
onClick={handleNext}
disabled={hasReachedEndOfEvents || isFetching || isLoading}
>
Next
{!isFetching && <ChevronRight size={14} />}
</Button>
{(isFetching || isLoading) && (
<LoaderCircle
className="animate-spin"
size={16}
color={Color.BG_ROBIN_500}
/>
)}
</div>
)}
</div>
);
}
function EntityEvents({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_EVENTS_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityEventsContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityEvents;

View File

@@ -1,52 +0,0 @@
import { useMemo } from 'react';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import styles from './EventsContent.module.scss';
export function EventContents({
data,
}: {
data: Record<string, string> | undefined;
}): JSX.Element {
const tableData = useMemo(
() =>
data ? Object.keys(data).map((key) => ({ key, value: data[key] })) : [],
[data],
);
const columns: ColumnsType<DataType> = [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: 50,
align: 'left',
className: 'attribute-pin value-field-container',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'attribute-name',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
];
return (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className={styles.eventContentContainer}
/>
);
}

View File

@@ -1,40 +0,0 @@
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
height: 240px;
}
.content {
display: flex;
flex-direction: column;
gap: 4px;
color: var(--muted-foreground);
font-size: 14px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.icon {
height: 32px;
width: 32px;
}
.title {
font-weight: 600;
}
.learnMore {
display: flex;
align-items: center;
margin-top: 8px;
gap: 4px;
cursor: pointer;
}
.learnMoreText {
color: var(--text-robin-400);
font-weight: 500;
}

View File

@@ -1,46 +0,0 @@
import { Typography } from '@signozhq/ui/typography';
import { ArrowRight } from '@signozhq/icons';
import { openInNewTab } from 'utils/navigation';
import emptyStateUrl from '@/assets/Icons/emptyState.svg';
import styles from './EventsNotConfigured.module.scss';
const K8S_EVENTS_DOCS_URL =
'https://signoz.io/docs/infrastructure-monitoring/k8s-metrics/';
export default function EventsNotConfigured(): JSX.Element {
const handleLearnMore = (): void => {
openInNewTab(K8S_EVENTS_DOCS_URL);
};
return (
<div className={styles.container}>
<div className={styles.content}>
<img src={emptyStateUrl} alt="not-configured" className={styles.icon} />
<Typography.Text>
<span className={styles.title}>No Kubernetes events received yet. </span>
To view events, enable the k8s events receiver in your OpenTelemetry
Collector.
</Typography.Text>
<div
className={styles.learnMore}
onClick={handleLearnMore}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
handleLearnMore();
}
}}
>
<Typography.Link className={styles.learnMoreText}>
Learn how to configure
</Typography.Link>
<ArrowRight size={14} />
</div>
</div>
</div>
);
}

View File

@@ -1,100 +1,348 @@
import { mockQueryRangeV5WithEventsResponse } from '__tests__/query_range_v5.util';
import { fireEvent, render, screen } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, screen, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import EntityEvents from '../EntityEvents';
import { K8S_ENTITY_EVENTS_EXPRESSION_KEY } from '../hooks';
function verifyEntityEventsV5Request({
payload,
expectedOffset,
initialTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset: number;
initialTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
expect(spec.offset).toBe(expectedOffset);
if (initialTimeRange) {
expect(payload.start).toBe(initialTimeRange.start);
expect(payload.end).toBe(initialTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
describe('EntityEvents', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQuery: (queryKey: any, queryFn: any, options: any): any =>
mockUseQuery(queryKey, queryFn, options),
}));
beforeEach(() => {
capturedQueryRangePayloads = [];
mockQueryRangeV5WithEventsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
return {};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.INFRASTRUCTURE_MONITORING_KUBERNETES}/`,
}),
}));
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
} as any);
const mockUseQueryBuilderData = {
handleRunQuery: jest.fn(),
stagedQuery: initialQueriesMap[DataSource.METRICS],
updateAllQueriesOperators: jest.fn(),
currentQuery: initialQueriesMap[DataSource.METRICS],
resetQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(),
handleSetQueryData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
isDefaultQuery: jest.fn(),
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
const timeRange = {
startTime: 1718236800,
endTime: 1718236800,
};
const mockHandleChangeEventFilters = jest.fn();
const mockFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'pod-name',
key: {
id: 'pod-name',
dataType: DataTypes.String,
key: 'pod-name',
type: 'tag',
isIndexed: false,
},
op: '=',
value: 'pod-1',
},
],
op: 'and',
};
const isModalTimeSelection = false;
const mockHandleTimeChange = jest.fn();
const selectedInterval: Time = '1m';
const category = InfraMonitoringEntity.PODS;
const queryKey = 'pod-events';
const mockEventsData = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [
{
timestamp: '2024-01-15T10:00:00Z',
data: {
id: 'event-1',
severity_text: 'INFO',
body: 'Test event 1',
resources_string: { 'pod.name': 'test-pod-1' },
attributes_string: { service: 'test-service' },
},
},
{
timestamp: '2024-01-15T10:01:00Z',
data: {
id: 'event-2',
severity_text: 'WARN',
body: 'Test event 2',
resources_string: { 'pod.name': 'test-pod-2' },
attributes_string: { service: 'test-service' },
},
},
],
},
],
},
},
},
},
};
const mockEmptyEventsData = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [],
},
],
},
},
},
},
};
const createMockEvent = (
id: string,
severity: string,
body: string,
podName: string,
): any => ({
timestamp: `2024-01-15T10:${id.padStart(2, '0')}:00Z`,
data: {
id: `event-${id}`,
severity_text: severity,
body,
resources_string: { 'pod.name': podName },
attributes_string: { service: 'test-service' },
},
});
const createMockMoreEventsData = (): any => ({
payload: {
data: {
newResult: {
data: {
result: [
{
list: Array.from({ length: 11 }, (_, i) =>
createMockEvent(
String(i + 1),
['INFO', 'WARN', 'ERROR', 'DEBUG'][i % 4],
`Test event ${i + 1}`,
`test-pod-${i + 1}`,
),
),
},
],
},
},
},
},
});
const renderEntityEvents = (overrides = {}): any => {
const defaultProps = {
timeRange,
handleChangeEventFilters: mockHandleChangeEventFilters,
filters: mockFilters,
isModalTimeSelection,
handleTimeChange: mockHandleTimeChange,
selectedInterval,
category,
queryKey,
...overrides,
};
return render(
<EntityEvents
timeRange={defaultProps.timeRange}
handleChangeEventFilters={defaultProps.handleChangeEventFilters}
filters={defaultProps.filters}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
selectedInterval={defaultProps.selectedInterval}
category={defaultProps.category}
queryKey={defaultProps.queryKey}
/>,
);
};
describe('EntityEvents', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQuery.mockReturnValue({
data: mockEventsData,
isLoading: false,
isError: false,
isFetching: false,
});
});
it('should use V5 API for fetching events', async () => {
act(() => {
render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_EVENTS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityEvents
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'
/>
</NuqsTestingAdapter>,
);
it('should render events list with data', () => {
renderEntityEvents();
expect(screen.getByText('Prev')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(screen.getByText('Test event 1')).toBeInTheDocument();
expect(screen.getByText('Test event 2')).toBeInTheDocument();
expect(screen.getByText('INFO')).toBeInTheDocument();
expect(screen.getByText('WARN')).toBeInTheDocument();
});
it('renders empty state when no events are found', () => {
mockUseQuery.mockReturnValue({
data: mockEmptyEventsData,
isLoading: false,
isError: false,
isFetching: false,
});
await waitFor(() => {
expect(
screen.queryByText('pending_data_placeholder'),
).not.toBeInTheDocument();
renderEntityEvents();
expect(screen.getByText(/No events found for this pods/)).toBeInTheDocument();
});
it('renders loader when fetching events', () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
isFetching: true,
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(1);
renderEntityEvents();
expect(screen.getByTestId('loader')).toBeInTheDocument();
});
it('shows pagination controls when events are present', () => {
renderEntityEvents();
expect(screen.getByText('Prev')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
});
it('disables Prev button on first page', () => {
renderEntityEvents();
const prevButton = screen.getByText('Prev').closest('button');
expect(prevButton).toBeDisabled();
});
it('enables Next button when more events are available', () => {
mockUseQuery.mockReturnValue({
data: createMockMoreEventsData(),
isLoading: false,
isError: false,
isFetching: false,
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityEventsV5Request({
payload: firstPayload,
expectedOffset: 0,
renderEntityEvents();
const nextButton = screen.getByText('Next').closest('button');
expect(nextButton).not.toBeDisabled();
});
it('navigates to next page when Next button is clicked', () => {
mockUseQuery.mockReturnValue({
data: createMockMoreEventsData(),
isLoading: false,
isError: false,
isFetching: false,
});
renderEntityEvents();
const nextButton = screen.getByText('Next').closest('button');
expect(nextButton).not.toBeNull();
fireEvent.click(nextButton as Element);
const { calls } = mockUseQuery.mock;
const hasPage2Call = calls.some((call) => {
const { queryKey: callQueryKey } = call[0] || {};
return Array.isArray(callQueryKey) && callQueryKey.includes(2);
});
expect(hasPage2Call).toBe(true);
});
it('navigates to previous page when Prev button is clicked', () => {
mockUseQuery.mockReturnValue({
data: createMockMoreEventsData(),
isLoading: false,
isError: false,
isFetching: false,
});
renderEntityEvents();
const nextButton = screen.getByText('Next').closest('button');
expect(nextButton).not.toBeNull();
fireEvent.click(nextButton as Element);
const prevButton = screen.getByText('Prev').closest('button');
expect(prevButton).not.toBeNull();
fireEvent.click(prevButton as Element);
const { calls } = mockUseQuery.mock;
const hasPage1Call = calls.some((call) => {
const { queryKey: callQueryKey } = call[0] || {};
return Array.isArray(callQueryKey) && callQueryKey.includes(1);
});
expect(hasPage1Call).toBe(true);
});
});

View File

@@ -0,0 +1,314 @@
// Events
.entity-events-container {
margin-top: 1rem;
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
.ant-tag .ant-typography {
font-size: 12px;
}
}
}
.entity-events-header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entity-events {
margin-top: 1rem;
.virtuoso-list {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-background);
}
.ant-row {
width: fit-content;
}
}
.skeleton-container {
height: 100%;
padding: 16px;
}
}
.ant-table {
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
background: var(--card);
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
.ant-table-thead > tr > th:has(.entityname-column-header) {
background: var(--l2-background);
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: var(--card);
border-bottom: none;
}
.ant-table-cell:has(.entityname-column-value) {
background: var(--l2-background);
}
.entityname-column-value {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.status-cell {
.active-tag {
color: var(--bg-forest-500);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
}
.progress-container {
.ant-progress-bg {
height: 8px !important;
border-radius: 4px;
}
}
.ant-table-tbody > tr:hover > td {
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
}
.ant-table-cell:first-child {
text-align: justify;
}
.ant-table-cell:nth-child(2) {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.column-header-right {
text-align: right;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(
.ant-table-row-expand-icon-cell
):not([colspan])::before {
background-color: transparent;
}
.ant-empty-normal {
visibility: hidden;
}
}
.ant-pagination {
position: fixed;
bottom: 0;
width: calc(100% - 54px);
background: var(--card);
padding: 16px;
margin: 0;
// this is to offset chat support icon till we improve the design
padding-right: 72px;
.ant-pagination-item {
border-radius: 4px;
&-active {
background: var(--primary-background);
border-color: var(--primary-background);
a {
color: var(--l1-foreground) !important;
}
}
}
}
}
.entity-events-list-container {
flex: 1;
height: calc(100vh - 300px) !important;
display: flex;
height: 100%;
.raw-log-content {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.entity-events-list-card {
width: 100%;
margin-top: 12px;
.ant-table-wrapper {
height: 100%;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-background);
}
.ant-row {
width: fit-content;
}
}
.ant-card-body {
padding: 0;
height: 100%;
width: 100%;
}
}
.logs-loading-skeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
.ant-skeleton-input-sm {
height: 18px;
}
}
.no-logs-found {
height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
box-sizing: border-box;
.ant-typography {
display: flex;
align-items: center;
gap: 16px;
}
}
.entity-events-footer {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
padding: 8px 16px 8px 16px;
position: absolute;
bottom: 0;
right: 0;
width: 100%;
border-radius: 0px 0px 3px 3px;
border-top: 1px solid var(--l1-border);
background: var(--l3-background);
box-sizing: border-box;
.ant-btn {
color: var(--l1-foreground);
font-size: 12px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.periscope-btn {
&.gentity {
border: none;
background: transparent;
}
}
}
.periscope-btn-icon {
cursor: pointer;
}

View File

@@ -1,122 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getEntityEventsQueryPayload } from './utils';
export const K8S_ENTITY_EVENTS_EXPRESSION_KEY = 'k8sEntityEventsExpression';
export interface EventRowData {
id: string;
body: string;
severity_text: string;
attributes_string?: Record<string, string>;
resources_string?: Record<string, string>;
}
export interface EventRow {
timestamp: string;
data: EventRowData;
}
export function useEntityEvents({
queryKey,
timeRange,
expression,
offset = 0,
pageSize = 10,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
offset?: number;
pageSize?: number;
}): {
events: EventRow[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: unknown;
currentCount: number;
hasMore: boolean;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
[
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
);
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: async ({ signal }) => {
const { query } = getEntityEventsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset,
pageSize,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
enabled: !!expression?.trim(),
});
const result = data?.payload?.data?.newResult?.data?.result?.[0];
const events = useMemo<EventRow[]>(() => {
const list = result?.list;
if (!list) {
return [];
}
return list.map((item) => ({
data: item.data as EventRowData,
timestamp: item.timestamp,
}));
}, [result?.list]);
const currentCount = result?.list?.length || 0;
const hasMore = !!result?.nextCursor || currentCount >= pageSize;
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
events,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
reactQueryKey,
};
}

View File

@@ -1,130 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
ReduceOperators,
} from 'types/common/queryBuilder';
import APIError from 'types/api/error';
import { v4 as uuidv4 } from 'uuid';
const K8S_EVENT_KEYS = ['k8s.object.kind', 'k8s.object.name'];
export function isEventsKeyNotFoundError(error: unknown): boolean {
if (!(error instanceof APIError)) {
return false;
}
const errorDetails = error.getErrorDetails();
if (errorDetails.error.code !== 'invalid_input') {
return false;
}
const errors = errorDetails.error.errors || [];
return errors.some((err) =>
K8S_EVENT_KEYS.some((key) =>
err.message?.includes(`key \`${key}\` not found`),
),
);
}
export interface EntityEventsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityEventsQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: LogsAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
{
columnName: 'id',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
const DEFAULT_PAGE_SIZE = 10;
export function getEntityEventsQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PAGE_SIZE,
}: EntityEventsQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityEventsQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,109 +0,0 @@
.container {
margin-top: 1rem;
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.logs {
border: 1px solid var(--border);
margin-top: 1rem;
:global(.virtuoso-list) {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--primary);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--primary);
}
:global(.ant-row) {
width: fit-content;
}
}
.skeletonContainer {
height: 100%;
padding: 16px;
}
}
.listContainer {
flex: 1;
height: calc(100vh - 312px) !important;
display: flex;
height: 100%;
:global(.raw-log-content) {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.listCard {
width: 100%;
margin-top: 12px;
:global(.ant-card-body) {
padding: 0;
height: 100%;
width: 100%;
}
}
.logsLoadingSkeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
:global(.ant-skeleton-input-sm) {
height: 18px;
}
}

View File

@@ -1,162 +1,78 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import logEvent from 'api/common/logEvent';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { InfraMonitoringEvents } from 'constants/events';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { getOldLogsOperatorFromNew } from 'hooks/logs/useActiveLog';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { generateFilterQuery } from 'lib/logs/generateFilterQuery';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { DataSource } from 'types/common/queryBuilder';
import { validateQuery } from 'utils/queryValidationUtils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { isKeyNotFoundError } from '../utils';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY, useInfiniteEntityLogs } from './hooks';
import { getEntityLogsQueryPayload } from './utils';
import {
EntityDetailsEmptyContainer,
getEntityEventsOrLogsQueryPayload,
} from '../utils';
import styles from './EntityLogs.module.scss';
import './entityLogs.styles.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
filters: IBuilderQuery['filters'];
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
queryKeyFilters: Array<string>;
}
function EntityLogsContent({
function EntityLogs({
timeRange,
isModalTimeSelection,
handleTimeChange,
selectedInterval,
filters,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
queryKeyFilters,
}: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
const { activeLog, selectedTab, handleSetActiveLog, handleCloseLogDetail } =
useLogDetailHandlers();
const onAddToQuery = useCallback(
(fieldKey: string, fieldValue: string, operator: string): void => {
handleCloseLogDetail();
const partExpression = generateFilterQuery({
fieldKey,
fieldValue,
type: getOldLogsOperatorFromNew(operator),
});
const currentUser = userExpression;
const newUser = currentUser.trim()
? `${currentUser} AND ${partExpression}`
: partExpression;
querySearchOnRun(newUser);
},
[userExpression, querySearchOnRun, handleCloseLogDetail],
const basePayload = getEntityEventsOrLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
filters,
);
const {
logs,
hasReachedEndOfLogs,
isPaginating,
currentPage,
setIsPaginating,
handleNewData,
loadMoreLogs,
hasNextPage,
isFetchingNextPage,
isLoading,
isFetching,
isError,
error,
refetch,
cancel,
} = useInfiniteEntityLogs({
queryKey,
queryPayload,
} = useHandleLogsPagination({
timeRange,
expression,
filters,
queryKeyFilters,
basePayload,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression);
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.LogsView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
);
const queryData = useMemo(
() =>
getEntityLogsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression,
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
@@ -193,24 +109,48 @@ function EntityLogsContent({
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: [
queryKey,
timeRange.startTime,
timeRange.endTime,
filters,
currentPage,
],
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
enabled: !!queryPayload,
keepPreviousData: isPaginating,
});
useEffect(() => {
if (data?.payload?.data?.newResult?.data?.result) {
handleNewData(data.payload.data.newResult.data.result);
}
}, [data, handleNewData]);
useEffect(() => {
setIsPaginating(false);
}, [data, setIsPaginating]);
const renderFooter = useCallback(
(): JSX.Element | null => (
<>
{isFetchingNextPage ? (
<div className={styles.logsLoadingSkeleton}> Loading more logs ... </div>
) : !hasNextPage && logs.length > 0 ? (
<div className={styles.logsLoadingSkeleton}> *** End *** </div>
{isFetching ? (
<div className="logs-loading-skeleton"> Loading more logs ... </div>
) : hasReachedEndOfLogs ? (
<div className="logs-loading-skeleton"> *** End *** </div>
) : null}
</>
),
[isFetchingNextPage, hasNextPage, logs.length],
[isFetching, hasReachedEndOfLogs],
);
const renderContent = useMemo(
() => (
<Card bordered={false} className={styles.listCard}>
<Card bordered={false} className="entity-logs-list-card">
<OverlayScrollbar isVirtuoso>
<Virtuoso
className="entity-logs-virtuoso"
key="entity-logs-virtuoso"
ref={virtuosoRef}
data={logs}
@@ -228,82 +168,32 @@ function EntityLogsContent({
[logs, loadMoreLogs, getItemContent, renderFooter],
);
const showInitialLoading = isLoading || (isFetching && logs.length === 0);
const isKeyNotFound = isKeyNotFoundError(error);
const isDataEmpty =
!showInitialLoading && (!isError || isKeyNotFound) && logs.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
<div className="entity-logs">
{isLoading && <LogsLoading />}
{!isLoading && !isError && logs.length === 0 && (
<EntityDetailsEmptyContainer category={category} view="logs" />
)}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div className="entity-logs-list-container" data-log-detail-ignore="true">
{renderContent}
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.LOGS}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
<div className={styles.logs}>
{showInitialLoading && <LogsLoading />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isError && !isKeyNotFound && !showInitialLoading && <EntityError />}
{!showInitialLoading && (!isError || isKeyNotFound) && logs.length > 0 && (
<div className={styles.listContainer} data-log-detail-ignore="true">
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
);
}
function EntityLogs({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_LOGS_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityLogsContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityLogs;

View File

@@ -0,0 +1,112 @@
import { useMemo } from 'react';
import { VIEWS } from 'container/InfraMonitoringK8s/constants';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { filterOutPrimaryFilters } from '../utils';
import EntityLogs from './EntityLogs';
import './entityLogs.styles.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
handleChangeLogFilters: (value: IBuilderQuery['filters'], view: VIEWS) => void;
logFilters: IBuilderQuery['filters'];
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
queryKeyFilters: Array<string>;
}
function EntityLogsDetailedView({
timeRange,
isModalTimeSelection,
handleTimeChange,
handleChangeLogFilters,
logFilters,
selectedInterval,
queryKey,
category,
queryKeyFilters,
}: Props): JSX.Element {
const { currentQuery } = useQueryBuilder();
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.LOGS,
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters: {
items: filterOutPrimaryFilters(logFilters?.items || [], queryKeyFilters),
op: 'AND',
},
},
],
},
}),
[currentQuery, logFilters?.items, queryKeyFilters],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
return (
<div className="entity-logs-container">
<div className="entity-logs-header">
<div className="filter-section">
{query && (
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={(value): void => handleChangeLogFilters(value, VIEWS.LOGS)}
disableNavigationShortcuts
/>
)}
</div>
<div className="datetime-section">
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
</div>
<EntityLogs
timeRange={timeRange}
filters={logFilters}
queryKey={queryKey}
category={category}
queryKeyFilters={queryKeyFilters}
/>
</div>
);
}
export default EntityLogsDetailedView;

View File

@@ -1,7 +1,10 @@
import { VirtuosoMockContext } from 'react-virtuoso';
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { ENVIRONMENT } from 'constants/env';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { verifyFiltersAndOrderBy } from 'container/LogsExplorerViews/tests/verifyFiltersAndOrderBy';
import { logsPaginationQueryRangeSuccessResponse } from 'mocks-server/__mockdata__/logs_query_range';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
act,
fireEvent,
@@ -10,33 +13,36 @@ import {
screen,
waitFor,
} from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { QueryRangePayload } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import EntityLogs from '../EntityLogs';
import { K8S_ENTITY_LOGS_EXPRESSION_KEY } from '../hooks';
function verifyEntityLogsV5Request({
// Custom verifyPayload function for EntityLogs that works with the correct payload structure
const verifyEntityLogsPayload = ({
payload,
expectedOffset,
initialTimeRange,
}: {
payload: QueryRangePayloadV5;
payload: QueryRangePayload;
expectedOffset: number;
initialTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
expect(spec.offset).toBe(expectedOffset);
}): IBuilderQuery => {
// Extract the builder query data from the correct path
const queryData = payload?.compositeQuery?.builderQueries?.A as IBuilderQuery;
expect(queryData).toBeDefined();
// Assert that the offset in the payload matches the expected offset
expect(queryData.offset).toBe(expectedOffset);
// If initial time range is provided, assert that the payload start and end match
if (initialTimeRange) {
expect(payload.start).toBe(initialTimeRange.start);
expect(payload.end).toBe(initialTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
expect(orderKeys).toContain('id');
}
return queryData;
};
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
@@ -50,38 +56,32 @@ jest.mock(
},
);
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
),
}));
describe('EntityLogs', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
let capturedQueryRangePayloads: QueryRangePayload[] = [];
const itemHeight = 100;
beforeEach(() => {
server.use(
rest.post(
`${ENVIRONMENT.baseURL}/api/v3/query_range`,
async (req, res, ctx) => {
capturedQueryRangePayloads.push(await req.json());
const lastPayload =
capturedQueryRangePayloads[capturedQueryRangePayloads.length - 1];
const queryData = (lastPayload as any)?.compositeQuery?.builderQueries
?.A as IBuilderQuery;
const offset = queryData?.offset ?? 0;
return res(
ctx.status(200),
ctx.json(logsPaginationQueryRangeSuccessResponse({ offset })),
);
},
),
);
capturedQueryRangePayloads = [];
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
return {};
},
});
});
it('should check if k8s logs pagination flows work properly', async () => {
let renderResult: RenderResult;
@@ -89,21 +89,15 @@ describe('EntityLogs', () => {
act(() => {
renderResult = render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_LOGS_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
<EntityLogs
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'
/>
</VirtuosoMockContext.Provider>
</NuqsTestingAdapter>,
<VirtuosoMockContext.Provider value={{ viewportHeight: 500, itemHeight }}>
<EntityLogs
timeRange={{ startTime: 1, endTime: 2 }}
filters={{ items: [], op: 'AND' }}
queryKey="test"
category={InfraMonitoringEntity.PODS}
queryKeyFilters={[]}
/>
</VirtuosoMockContext.Provider>,
);
});
@@ -118,13 +112,16 @@ describe('EntityLogs', () => {
});
await waitFor(async () => {
// Find the Virtuoso scroller element by its data-test-id
scrollableElement = renderResult.container.querySelector(
'[data-test-id="virtuoso-scroller"]',
) as HTMLElement;
// Ensure the element exists
expect(scrollableElement).not.toBeNull();
if (scrollableElement) {
// Set the scrollTop property to simulate scrolling to the calculated end position
scrollableElement.scrollTop = 99 * itemHeight;
act(() => {
@@ -138,31 +135,36 @@ describe('EntityLogs', () => {
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityLogsV5Request({
verifyEntityLogsPayload({
payload: firstPayload,
expectedOffset: 0,
});
// Store the time range from the first payload, which should be consistent in subsequent requests
const initialTimeRange = {
start: firstPayload.start,
end: firstPayload.end,
};
const secondPayload = capturedQueryRangePayloads[1];
verifyEntityLogsV5Request({
const secondQueryData = verifyEntityLogsPayload({
payload: secondPayload,
expectedOffset: 100,
initialTimeRange,
});
verifyFiltersAndOrderBy(secondQueryData);
await waitFor(async () => {
// Find the Virtuoso scroller element by its data-test-id
scrollableElement = renderResult.container.querySelector(
'[data-test-id="virtuoso-scroller"]',
) as HTMLElement;
// Ensure the element exists
expect(scrollableElement).not.toBeNull();
if (scrollableElement) {
// Set the scrollTop property to simulate scrolling to the calculated end position
scrollableElement.scrollTop = 199 * itemHeight;
act(() => {
@@ -176,10 +178,11 @@ describe('EntityLogs', () => {
});
const thirdPayload = capturedQueryRangePayloads[2];
verifyEntityLogsV5Request({
const thirdQueryData = verifyEntityLogsPayload({
payload: thirdPayload,
expectedOffset: 200,
initialTimeRange,
});
verifyFiltersAndOrderBy(thirdQueryData);
});
});

View File

@@ -1,186 +0,0 @@
import { QueryClient, QueryClientProvider } from 'react-query';
import { act, renderHook, waitFor } from '@testing-library/react';
import {
mockQueryRangeV5WithError,
mockQueryRangeV5WithLogsResponse,
} from '../../../../../__tests__/query_range_v5.util';
import { useInfiniteEntityLogs } from '../hooks';
const createWrapper = (): React.FC<{ children: React.ReactNode }> => {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return function Wrapper({
children,
}: {
children: React.ReactNode;
}): JSX.Element {
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
};
};
describe('useInfiniteEntityLogs', () => {
const defaultParams = {
queryKey: 'entityLogsTest',
timeRange: { startTime: 1708000000, endTime: 1708003600 },
expression: 'k8s.pod.name = "test"',
};
describe('initial state', () => {
it('should return initial loading state', () => {
mockQueryRangeV5WithLogsResponse({
delay: 100,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
expect(result.current.isLoading).toBe(true);
expect(result.current.logs).toStrictEqual([]);
});
});
describe('successful data fetching', () => {
it('should return logs after successful fetch', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 5,
hasMore: true,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.error).toBeFalsy();
expect(result.current.logs).toHaveLength(5);
expect(result.current.isError).toBe(false);
});
it('should set hasNextPage based on response size', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
hasMore: true,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(true);
});
it('should not have next page when response is smaller than page size', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
hasMore: false,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.hasNextPage).toBe(false);
});
});
describe('empty state', () => {
it('should return empty logs array when no data', async () => {
mockQueryRangeV5WithLogsResponse({
pageSize: 0,
hasMore: false,
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs).toStrictEqual([]);
expect(result.current.hasNextPage).toBe(false);
});
});
describe('error handling', () => {
it('should set isError on API failure', async () => {
mockQueryRangeV5WithError('Internal Server Error');
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isError).toBe(true);
});
expect(result.current.logs).toStrictEqual([]);
});
});
describe('load more functionality', () => {
it('should fetch next page when loadMoreLogs is called', async () => {
const requestCount = { count: 0 };
mockQueryRangeV5WithLogsResponse({
pageSize: 100,
offset: 0,
hasMore: true,
onReceiveRequest: () => {
requestCount.count += 1;
if (requestCount.count > 1) {
return { offset: 100, pageSize: 100, hasMore: false };
}
return undefined;
},
});
const { result } = renderHook(() => useInfiniteEntityLogs(defaultParams), {
wrapper: createWrapper(),
});
await waitFor(() => {
expect(result.current.isLoading).toBe(false);
});
expect(result.current.logs).toHaveLength(100);
expect(result.current.hasNextPage).toBe(true);
expect(requestCount.count).toBe(1);
act(() => {
result.current.loadMoreLogs();
});
await waitFor(() => {
expect(result.current.logs).toHaveLength(150);
});
expect(result.current.hasNextPage).toBe(false);
expect(requestCount.count).toBe(2);
});
});
});

View File

@@ -0,0 +1,120 @@
.entity-logs-container {
margin-top: 1rem;
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
.ant-tag .ant-typography {
font-size: 12px;
}
}
}
.entity-logs-header {
display: flex;
justify-content: space-between;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entity-logs {
margin-top: 1rem;
.virtuoso-list {
overflow-y: hidden !important;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--l3-background);
}
.ant-row {
width: fit-content;
}
}
.skeleton-container {
height: 100%;
padding: 16px;
}
}
}
.entity-logs-list-container {
flex: 1;
height: calc(100vh - 272px) !important;
display: flex;
height: 100%;
.raw-log-content {
width: 100%;
text-wrap: inherit;
word-wrap: break-word;
}
}
.entity-logs-list-card {
width: 100%;
margin-top: 12px;
.ant-card-body {
padding: 0;
height: 100%;
width: 100%;
}
}
.logs-loading-skeleton {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 8px 0;
.ant-skeleton-input-sm {
height: 18px;
}
}
.no-logs-found {
height: 50vh;
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 24px;
box-sizing: border-box;
.ant-typography {
display: flex;
align-items: center;
gap: 16px;
}
}

View File

@@ -1,126 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useInfiniteQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { getEntityLogsQueryPayload } from './utils';
export const K8S_ENTITY_LOGS_EXPRESSION_KEY = 'k8sEntityLogsExpression';
export function useInfiniteEntityLogs({
queryKey,
timeRange,
expression,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
}): {
logs: ILog[];
isLoading: boolean;
isFetching: boolean;
isFetchingNextPage: boolean;
isError: boolean;
error?: unknown;
hasNextPage: boolean;
loadMoreLogs: () => void;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
],
[queryKey, timeRange.startTime, timeRange.endTime, expression],
);
const {
data,
isLoading,
isFetching,
isFetchingNextPage,
isError,
error,
hasNextPage,
fetchNextPage,
refetch,
} = useInfiniteQuery({
queryKey: reactQueryKey,
queryFn: async ({ pageParam = 0, signal }) => {
const { query } = getEntityLogsQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset: pageParam as number,
pageSize: DEFAULT_PER_PAGE_VALUE,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
getNextPageParam: (lastPage, allPages) => {
const list = lastPage?.payload?.data?.newResult?.data?.result?.[0]?.list;
if (!list || list.length < DEFAULT_PER_PAGE_VALUE) {
return;
}
return allPages.length * DEFAULT_PER_PAGE_VALUE;
},
enabled: !!expression?.trim(),
});
const logs = useMemo<ILog[]>(() => {
if (!data?.pages) {
return [];
}
return data.pages.flatMap((page) => {
const list = page.payload.data.newResult.data.result?.[0]?.list;
if (!list) {
return [];
}
return list.map(
(item) =>
({
...item.data,
timestamp: item.timestamp,
}) as ILog,
);
});
}, [data?.pages]);
const loadMoreLogs = useCallback(() => {
if (hasNextPage && !isFetchingNextPage) {
void fetchNextPage();
}
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
logs,
isLoading,
isFetching,
isFetchingNextPage,
isError,
error,
hasNextPage: !!hasNextPage,
loadMoreLogs,
refetch,
cancel,
reactQueryKey,
};
}

View File

@@ -1,3 +1,3 @@
import EntityLogs from './EntityLogs';
import EntityLogs from './EntityLogsDetailedView';
export default EntityLogs;

View File

@@ -1,108 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
LogsAggregatorOperator,
ReduceOperators,
} from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
export interface EntityLogsQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityLogsQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.logs,
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: LogsAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
{
columnName: 'id',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
export function getEntityLogsQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PER_PAGE_VALUE,
}: EntityLogsQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityLogsQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,52 +0,0 @@
.entityMetricsContainer {
display: flex;
flex-wrap: wrap;
margin-top: 1rem;
margin-left: -12px;
margin-right: -12px;
}
.entityMetricsCol {
flex: 0 0 50%;
max-width: 50%;
padding-left: 12px;
padding-right: 12px;
box-sizing: border-box;
}
.entityMetricsTitle {
font-size: var(--periscope-font-size-base);
color: var(--l2-foreground);
}
.metricsHeader {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entityMetricsCard {
position: relative;
margin: 8px 0 1rem 0;
height: 300px;
padding: 10px;
border: 1px solid var(--l1-border);
border-radius: 3px;
.chartContainer {
width: 100%;
height: 100%;
}
.noDataContainer {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View File

@@ -1,9 +1,15 @@
import { useCallback, useMemo, useRef } from 'react';
import { UseQueryResult } from 'react-query';
import { Skeleton } from 'antd';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import {
getMetricsTableData,
MetricsTable,
} from 'container/InfraMonitoringK8s/commonUtils';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
@@ -13,20 +19,21 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { AlignedData, Options } from 'uplot';
import { Options } from 'uplot';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { FeatureKeys } from '../../../../constants/features';
import { useMultiIntersectionObserver } from '../../../../hooks/useMultiIntersectionObserver';
import { useAppContext } from '../../../../providers/App/App';
import { useEntityMetrics } from './hooks';
import { isKeyNotFoundError } from '../utils';
import styles from './EntityMetrics.module.scss';
import { MetricsTable } from './MetricsTable';
import { DEFAULT_TIME_RANGE } from 'container/TopNav/DateTimeSelectionV2/constants';
import './entityMetrics.styles.scss';
interface EntityMetricsProps<T> {
timeRange: {
@@ -65,19 +72,45 @@ function EntityMetrics<T>({
queryKey,
category,
}: EntityMetricsProps<T>): JSX.Element {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const { visibilities, setElement } = useMultiIntersectionObserver(
entityWidgetInfo.length,
{ threshold: 0.1 },
);
const { queries, chartData, queryPayloads } = useEntityMetrics({
queryKey,
timeRange,
entity,
getEntityQueryPayload,
visibilities,
category,
});
const queryPayloads = useMemo(
() =>
getEntityQueryPayload(
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
),
[
getEntityQueryPayload,
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: [queryKey, payload, ENTITY_VERSION_V4, category],
queryFn: ({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),
);
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
@@ -91,20 +124,60 @@ function EntityMetrics<T>({
scrollLeft: 0,
});
const chartData = useMemo(
() =>
queries.map(({ data }) => {
const panelType = (data?.params as any)?.compositeQuery?.panelType;
return panelType === PANEL_TYPES.TABLE
? getMetricsTableData(data)
: getUPlotChartData(data?.payload);
}),
[queries],
);
const [graphTimeIntervals, setGraphTimeIntervals] = useState<
{
start: number;
end: number;
}[]
>(
new Array(queries.length).fill({
start: timeRange.startTime,
end: timeRange.endTime,
}),
);
useEffect(() => {
setGraphTimeIntervals(
new Array(queries.length).fill({
start: timeRange.startTime,
end: timeRange.endTime,
}),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange]);
const onDragSelect = useCallback(
(start: number, end: number): void => {
(start: number, end: number, graphIndex: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
handleTimeChange('custom', [startTimestamp, endTimestamp]);
setGraphTimeIntervals((prev) => {
const newIntervals = [...prev];
newIntervals[graphIndex] = {
start: Math.floor(startTimestamp / 1000),
end: Math.floor(endTimestamp / 1000),
};
return newIntervals;
});
},
[handleTimeChange],
[],
);
const options = useMemo(
() =>
queries.map(({ data }, idx) => {
const panelType = queryPayloads[idx]?.graphType;
const panelType = (data?.params as any)?.compositeQuery?.panelType;
if (panelType === PANEL_TYPES.TABLE) {
return null;
}
@@ -115,27 +188,25 @@ function EntityMetrics<T>({
yAxisUnit: entityWidgetInfo[idx].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: timeRange.startTime,
maxTimeScale: timeRange.endTime,
onDragSelect,
minTimeScale: graphTimeIntervals[idx].start,
maxTimeScale: graphTimeIntervals[idx].end,
onDragSelect: (start, end) => onDragSelect(start, end, idx),
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}): void => {
}) => {
legendScrollPositionRef.current = position;
},
});
}),
[
queries,
queryPayloads,
isDarkMode,
dimensions,
entityWidgetInfo,
timeRange.startTime,
timeRange.endTime,
graphTimeIntervals,
onDragSelect,
currentQuery,
],
@@ -145,39 +216,32 @@ function EntityMetrics<T>({
query: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>,
idx: number,
): JSX.Element => {
if (
(!query.data && query.isLoading) ||
query.isFetching ||
!visibilities[idx]
) {
if ((!query.data && query.isLoading) || !visibilities[idx]) {
return <Skeleton />;
}
if (query.error && !isKeyNotFoundError(query.error)) {
if (query.error) {
const errorMessage =
(query.error as Error)?.message || 'Something went wrong';
return <div>{errorMessage}</div>;
}
const panelType = queryPayloads[idx]?.graphType;
const panelType = (query.data?.params as any)?.compositeQuery?.panelType;
return (
<div
className={cx(styles.chartContainer, {
[styles.noDataContainer]:
className={cx('chart-container', {
'no-data-container':
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
{panelType === PANEL_TYPES.TABLE ? (
<MetricsTable
rows={chartData[idx]?.[0]?.rows ?? []}
columns={chartData[idx]?.[0]?.columns ?? []}
rows={chartData[idx][0].rows}
columns={chartData[idx][0].columns}
/>
) : (
<Uplot
options={options[idx] as Options}
data={chartData[idx] as AlignedData}
/>
<Uplot options={options[idx] as Options} data={chartData[idx]} />
)}
</div>
);
@@ -185,35 +249,31 @@ function EntityMetrics<T>({
return (
<>
<div className={styles.metricsHeader}>
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime={DEFAULT_TIME_RANGE}
isModalTimeSelection={isModalTimeSelection}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<div className="metrics-header">
<div className="metrics-datetime-section">
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
isModalTimeSelection={isModalTimeSelection}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
</div>
<div className={styles.entityMetricsContainer}>
<Row gutter={24} className="entity-metrics-container">
{queries.map((query, idx) => (
<div
ref={setElement(idx)}
key={entityWidgetInfo[idx].title}
className={styles.entityMetricsCol}
>
<span className={styles.entityMetricsTitle}>
{entityWidgetInfo[idx].title}
</span>
<div className={styles.entityMetricsCard} ref={graphRef}>
<Col ref={setElement(idx)} span={12} key={entityWidgetInfo[idx].title}>
<Typography.Text>{entityWidgetInfo[idx].title}</Typography.Text>
<Card bordered className="entity-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</div>
</div>
</Card>
</Col>
))}
</div>
</Row>
</>
);
}

View File

@@ -1,7 +0,0 @@
.table {
height: 100%;
}
.paginationContainer {
margin: 0;
}

View File

@@ -1,99 +0,0 @@
import { useCallback, useMemo, useState } from 'react';
import TanStackTable, { TableColumnDef } from 'components/TanStackTableView';
import { SortState } from 'components/TanStackTableView/types';
import styles from './MetricsTable.module.scss';
import { MetricsColumn } from './utils';
interface MetricsTableProps {
rows: Record<string, string>[];
columns: MetricsColumn[];
}
const DEFAULT_PAGE = 1;
const DEFAULT_LIMIT = 7; // the sweetspot for the amount of items without showing the scrollbar
export function MetricsTable({
rows,
columns,
}: MetricsTableProps): JSX.Element {
const [page, setPage] = useState(DEFAULT_PAGE);
const [limit, setLimit] = useState(DEFAULT_LIMIT);
const [orderBy, setOrderBy] = useState<SortState | null>(null);
const sortedRows = useMemo(() => {
if (!orderBy) {
return rows;
}
const { columnName, order } = orderBy;
return [...rows].sort((a, b) => {
const aVal = parseFloat(a[columnName]) || 0;
const bVal = parseFloat(b[columnName]) || 0;
return order === 'asc' ? aVal - bVal : bVal - aVal;
});
}, [rows, orderBy]);
const paginatedRows = useMemo(() => {
const startIndex = (page - 1) * limit;
return sortedRows.slice(startIndex, startIndex + limit);
}, [sortedRows, page, limit]);
const handlePageChange = useCallback((newPage: number) => {
setPage(newPage);
}, []);
const handleLimitChange = useCallback((newLimit: number) => {
setLimit(newLimit);
setPage(DEFAULT_PAGE);
}, []);
const columnDefs = useMemo(
() =>
columns.map(
(col, index) =>
({
id: col.key,
accessorKey: col.key as keyof Record<string, string>,
header: (): JSX.Element => (
<TanStackTable.Text title={col.label}>{col.label}</TanStackTable.Text>
),
cell: ({ value }): JSX.Element => {
const displayValue = String(value ?? '');
return (
<TanStackTable.Text title={displayValue}>
{displayValue}
</TanStackTable.Text>
);
},
enableMove: false,
enableResize: false,
enableRemove: false,
enableSort: col.isValueColumn,
width: {
min: index === 0 ? 220 : Math.max(col.label?.length * 12, 80) || 100,
},
}) satisfies TableColumnDef<Record<string, string>>,
),
[columns],
);
return (
<TanStackTable<Record<string, string>>
className={styles.table}
data={paginatedRows}
columns={columnDefs}
onSort={setOrderBy}
pagination={{
total: rows.length,
defaultPage: page,
defaultLimit: limit,
onPageChange: handlePageChange,
onLimitChange: handleLimitChange,
showPageSize: false,
}}
paginationClassname={styles.paginationContainer}
getRowKey={(row) => row.key}
getItemKey={(row) => row.key}
/>
);
}

View File

@@ -5,15 +5,6 @@ import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import EntityMetrics from '../EntityMetrics';
import { useEntityMetrics } from '../hooks';
jest.mock('../hooks', () => ({
useEntityMetrics: jest.fn(),
}));
const mockUseEntityMetrics = useEntityMetrics as jest.MockedFunction<
typeof useEntityMetrics
>;
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: jest.fn().mockReturnValue({}),
@@ -35,8 +26,20 @@ jest.mock('components/Uplot', () => ({
default: (): JSX.Element => <div data-testid="uplot-chart">Uplot Chart</div>,
}));
jest.mock('../MetricsTable', () => ({
jest.mock('container/InfraMonitoringK8s/commonUtils', () => ({
__esModule: true,
getMetricsTableData: jest.fn().mockReturnValue([
{
rows: [
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '42.5' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '43.2' } },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
]),
MetricsTable: jest
.fn()
.mockImplementation(
@@ -44,9 +47,11 @@ jest.mock('../MetricsTable', () => ({
),
}));
const mockUseQueries = jest.fn();
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: (queryConfigs: any[]): any[] => mockUseQueries(queryConfigs),
useQuery: (config: any): any => mockUseQuery(config),
}));
@@ -294,35 +299,10 @@ const renderEntityMetrics = (overrides = {}): any => {
);
};
const mockChartData = [
[], // time_series chart data (uplot handles empty array)
[
{
rows: [
{ data: { timestamp: '2024-01-15T10:00:00Z', value: '1024' } },
{ data: { timestamp: '2024-01-15T10:01:00Z', value: '1028' } },
],
columns: [
{ key: 'timestamp', label: 'Timestamp', isValueColumn: false },
{ key: 'value', label: 'Value', isValueColumn: true },
],
},
], // table chart data
];
const mockQueryPayloads = [
{ graphType: 'graph' }, // time_series
{ graphType: 'table' }, // table
];
describe('EntityMetrics', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseEntityMetrics.mockReturnValue({
queries: mockQueries as any,
chartData: mockChartData,
queryPayloads: mockQueryPayloads as any,
});
mockUseQueries.mockReturnValue(mockQueries);
mockUseQuery.mockReturnValue({
data: {
data: {
@@ -349,41 +329,21 @@ describe('EntityMetrics', () => {
});
it('renders loading state when fetching metrics', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockLoadingQueries as any,
chartData: [[], []],
queryPayloads: mockQueryPayloads as any,
});
mockUseQueries.mockReturnValue(mockLoadingQueries);
renderEntityMetrics();
expect(screen.getAllByText('CPU Usage')).toHaveLength(1);
expect(screen.getAllByText('Memory Usage')).toHaveLength(1);
});
it('renders error state when query fails', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockErrorQueries as any,
chartData: [[], []],
queryPayloads: mockQueryPayloads as any,
});
mockUseQueries.mockReturnValue(mockErrorQueries);
renderEntityMetrics();
expect(screen.getByText('API Error')).toBeInTheDocument();
expect(screen.getByText('Network Error')).toBeInTheDocument();
});
it('renders empty state when no metrics data', () => {
mockUseEntityMetrics.mockReturnValue({
queries: mockEmptyQueries as any,
chartData: [
[],
[
{
rows: [],
columns: [],
},
],
],
queryPayloads: mockQueryPayloads as any,
});
mockUseQueries.mockReturnValue(mockEmptyQueries);
renderEntityMetrics();
expect(screen.getByTestId('uplot-chart')).toBeInTheDocument();
expect(screen.getByTestId('metrics-table')).toBeInTheDocument();
@@ -408,22 +368,22 @@ describe('EntityMetrics', () => {
it('applies intersection observer for visibility', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
visibilities: [true, true],
}),
expect(mockUseQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
enabled: true,
}),
]),
);
});
it('passes correct parameters to useEntityMetrics hook', () => {
it('generates correct query payloads', () => {
renderEntityMetrics();
expect(mockUseEntityMetrics).toHaveBeenCalledWith(
expect.objectContaining({
queryKey: 'test-query-key',
timeRange: mockTimeRange,
entity: mockEntity,
category: InfraMonitoringEntity.PODS,
}),
expect(mockGetEntityQueryPayload).toHaveBeenCalledWith(
mockEntity,
mockTimeRange.startTime,
mockTimeRange.endTime,
false,
);
});
});

View File

@@ -0,0 +1,39 @@
// Metrics
.entity-metrics-container {
margin-top: 1rem;
}
.metrics-header {
display: flex;
justify-content: flex-end;
margin-top: 1rem;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
}
.entity-metrics-card {
margin: 8px 0 1rem 0;
height: 300px;
padding: 10px;
border: 1px solid var(--l1-border);
.ant-card-body {
padding: 0;
}
.chart-container {
width: 100%;
height: 100%;
}
.no-data-container {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}

View File

@@ -1,108 +0,0 @@
import { useMemo } from 'react';
import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import {
GetMetricQueryRange,
GetQueryResultsProps,
} from 'lib/dashboard/getQueryResults';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { FeatureKeys } from '../../../../constants/features';
import { useAppContext } from '../../../../providers/App/App';
import { getMetricsTableData } from './utils';
export interface UseEntityMetricsParams<T> {
queryKey: string;
timeRange: { startTime: number; endTime: number };
entity: T;
getEntityQueryPayload: (
entity: T,
start: number,
end: number,
dotMetricsEnabled: boolean,
) => GetQueryResultsProps[];
visibilities: boolean[];
category: InfraMonitoringEntity;
}
export interface UseEntityMetricsResult {
queries: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>[];
chartData: (
| ReturnType<typeof getUPlotChartData>
| ReturnType<typeof getMetricsTableData>
)[];
queryPayloads: GetQueryResultsProps[];
}
export function useEntityMetrics<T>({
queryKey,
timeRange,
entity,
getEntityQueryPayload,
visibilities,
category,
}: UseEntityMetricsParams<T>): UseEntityMetricsResult {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const queryPayloads = useMemo(
() =>
getEntityQueryPayload(
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
),
[
getEntityQueryPayload,
entity,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
queryKey: [
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
payload,
ENTITY_VERSION_V5,
category,
],
queryFn: ({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V5, undefined, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),
);
const chartData = useMemo(
() =>
queries.map(({ data }, index) => {
const panelType = queryPayloads[index]?.graphType;
return panelType === PANEL_TYPES.TABLE
? getMetricsTableData(data)
: getUPlotChartData(data?.payload);
}),
[queries, queryPayloads],
);
return {
queries,
chartData,
queryPayloads,
};
}

View File

@@ -1,61 +0,0 @@
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
export interface MetricsColumn {
key: string;
label: string;
isValueColumn: boolean;
id?: string;
}
export interface MetricsTableData {
rows: Record<string, string>[];
columns: MetricsColumn[];
}
export const getMetricsTableData = (
data: SuccessResponse<MetricRangePayloadProps> | undefined,
): MetricsTableData[] => {
if (data?.payload?.data?.result?.length) {
const rowsData = (data?.payload.data.result[0] as any).table?.rows;
const columnsData = (data?.payload.data.result[0] as any).table?.columns;
if (!rowsData || !columnsData) {
return [{ rows: [], columns: [] }];
}
// V4 uses builderQueries, V5 already includes legend in column name
const builderQueries = (data.params as any)?.compositeQuery?.builderQueries;
const columns = columnsData.map((columnData: any) => {
if (columnData.isValueColumn) {
// V5: column name already includes legend from convertV5ResponseToLegacy
// V4: need to get legend from builderQueries
const label = builderQueries?.[columnData.name]?.legend || columnData.name;
return {
id: columnData.id || columnData.name,
key: columnData.id || columnData.name,
label,
isValueColumn: true,
};
}
return {
key: columnData.id || columnData.name,
label: columnData.name,
isValueColumn: false,
};
});
if (columns.length === 0) {
return [{ rows: [], columns: [] }];
}
const firstColumnId = columns[0].id || columns[0].key;
const rows = rowsData.map((rowData: any) => ({
...rowData.data,
key: rowData[firstColumnId],
}));
return [{ rows, columns }];
}
return [{ rows: [], columns: [] }];
};

View File

@@ -1,49 +0,0 @@
.container {
margin-top: var(--spacing-8);
}
.filterContainer {
display: flex;
flex-direction: column;
gap: 8px;
padding: var(--spacing-6);
border-radius: var(--radius);
border: 1px solid var(--l1-border);
:global(.ant-select-selector) {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
:global(.ant-tag .ant-typography) {
font-size: 12px;
}
}
}
.filterContainerTime {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
.filterQuerySearch {
flex: 1;
}
.entityTracesTable {
margin-top: var(--spacing-8);
--typography-color: var(--l2-foreground);
}
.controls {
margin-bottom: 1rem;
width: 100%;
display: flex;
justify-content: flex-end;
}

View File

@@ -1,46 +1,38 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import logEvent from 'api/common/logEvent';
import {
QuerySearchV2Provider,
useExpression,
useInitialExpression,
useInputExpression,
useQuerySearchInitialExpressionProp,
useQuerySearchOnChange,
useQuerySearchOnRun,
useUserExpression,
} from 'components/QueryBuilderV2';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
import {
combineInitialAndUserExpression,
getUserExpressionFromCombined,
} from 'components/QueryBuilderV2/QueryV2/QuerySearch/utils';
import { ResizeTable } from 'components/ResizeTable';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { InfraMonitoringEvents } from 'constants/events';
import Controls from 'container/Controls';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import RunQueryBtn from 'container/QueryBuilder/components/RunQueryBtn/RunQueryBtn';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import { VIEWS } from 'container/InfraMonitoringK8s/constants';
import NoLogs from 'container/NoLogs/NoLogs';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { ErrorText } from 'container/TimeSeriesView/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { useQueryState } from 'nuqs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { parseAsJsonNoValidate } from 'utils/nuqsParsers';
import { validateQuery } from 'utils/queryValidationUtils';
import EntityEmptyState from '../EntityEmptyState/EntityEmptyState';
import EntityError from '../EntityError/EntityError';
import { selectedEntityTracesColumns } from '../utils';
import { isKeyNotFoundError } from '../utils';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY, useEntityTraces } from './hooks';
import {
filterOutPrimaryFilters,
getEntityTracesQueryPayload,
selectedEntityTracesColumns,
} from '../utils';
import { getTraceListColumns } from './traceListColumns';
import { getEntityTracesQueryPayload } from './utils';
import styles from './EntityTraces.module.scss';
import './entityTraces.styles.scss';
interface Props {
timeRange: {
@@ -52,99 +44,118 @@ interface Props {
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
handleChangeTracesFilters: (
value: IBuilderQuery['filters'],
view: VIEWS,
) => void;
tracesFilters: IBuilderQuery['filters'];
selectedInterval: Time;
queryKey: string;
category: InfraMonitoringEntity;
initialExpression: string;
category: string;
queryKeyFilters: string[];
}
function EntityTracesContent({
function EntityTraces({
timeRange,
isModalTimeSelection,
handleTimeChange,
handleChangeTracesFilters,
tracesFilters,
selectedInterval,
queryKey,
category,
}: Omit<Props, 'initialExpression'>): JSX.Element {
const expression = useExpression();
const inputExpression = useInputExpression();
const userExpression = useUserExpression();
const initialExpression = useInitialExpression();
const querySearchOnChange = useQuerySearchOnChange();
const querySearchOnRun = useQuerySearchOnRun();
const querySearchInitialExpressionProp = useQuerySearchInitialExpressionProp();
queryKeyFilters,
}: Props): JSX.Element {
const [traces, setTraces] = useState<any[]>([]);
const [offset] = useState<number>(0);
const [pagination, setPagination] = useQueryState(
'pagination',
parseAsJsonNoValidate<{ offset: number; limit: number }>(),
const { currentQuery } = useQueryBuilder();
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters: {
items: filterOutPrimaryFilters(
tracesFilters?.items || [],
queryKeyFilters,
),
op: 'AND',
},
},
],
},
}),
[currentQuery, queryKeyFilters, tracesFilters?.items],
);
const pageSize = pagination?.limit || PER_PAGE_OPTIONS[0];
const offset = pagination?.offset || 0;
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const {
traces,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
} = useEntityTraces({
queryKey,
timeRange,
expression,
offset,
pageSize,
});
const handleRunQuery = useCallback(
(updatedExpression?: string): void => {
const newUserExpression = updatedExpression
? getUserExpressionFromCombined(initialExpression, updatedExpression)
: inputExpression;
const validation = validateQuery(
initialExpression
? combineInitialAndUserExpression(initialExpression, newUserExpression)
: newUserExpression || '',
);
if (validation.isValid) {
querySearchOnRun(newUserExpression || '');
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.K8sEntity,
page: InfraMonitoringEvents.DetailedPage,
category,
view: InfraMonitoringEvents.TracesView,
});
refetch();
}
},
[inputExpression, initialExpression, refetch, querySearchOnRun, category],
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const queryData = useMemo(
const queryPayload = useMemo(
() =>
getEntityTracesQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression: userExpression || '',
}).queryData,
[timeRange.startTime, timeRange.endTime, userExpression],
getEntityTracesQueryPayload(
timeRange.startTime,
timeRange.endTime,
paginationQueryData?.offset || offset,
tracesFilters,
),
[
timeRange.startTime,
timeRange.endTime,
offset,
tracesFilters,
paginationQueryData,
],
);
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: [
queryKey,
timeRange.startTime,
timeRange.endTime,
offset,
tracesFilters,
DEFAULT_ENTITY_VERSION,
paginationQueryData,
],
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
enabled: !!queryPayload,
});
const traceListColumns = getTraceListColumns(selectedEntityTracesColumns);
const isKeyNotFound = isKeyNotFoundError(error);
useEffect(() => {
if (data?.payload?.data?.newResult?.data?.result) {
const currentData = data.payload.data.newResult.data.result;
if (currentData.length > 0 && currentData[0].list) {
if (offset === 0) {
setTraces(currentData[0].list ?? []);
} else {
setTraces((prev) => [...prev, ...(currentData[0].list ?? [])]);
}
}
}
}, [data, offset]);
const isDataEmpty =
!isLoading &&
!isFetching &&
(!isError || isKeyNotFound) &&
traces.length === 0;
const hasAdditionalFilters = !!userExpression?.trim();
!isLoading && !isFetching && !isError && traces.length === 0;
const hasAdditionalFilters =
tracesFilters?.items && tracesFilters?.items?.length > 1;
const totalCount =
data?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0;
const handleRowClick = useCallback(() => {
logEvent(InfraMonitoringEvents.ItemClicked, {
@@ -154,16 +165,21 @@ function EntityTracesContent({
});
}, [category]);
useEffect(() => {
return (): void => {
void setPagination(null);
};
}, [setPagination]);
return (
<div className={styles.container}>
<div className={styles.filterContainer}>
<div className={styles.filterContainerTime}>
<div className="entity-metric-traces">
<div className="entity-metric-traces-header">
<div className="filter-section">
{query && (
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={(value): void =>
handleChangeTracesFilters(value, VIEWS.TRACES)
}
disableNavigationShortcuts
/>
)}
</div>
<div className="datetime-section">
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
@@ -175,61 +191,29 @@ function EntityTracesContent({
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
<RunQueryBtn
isLoadingQueries={isFetching}
onStageRunQuery={(): void => handleRunQuery()}
handleCancelQuery={cancel}
/>
</div>
<div className={styles.filterQuerySearch}>
<QuerySearch
onChange={querySearchOnChange}
queryData={queryData}
dataSource={DataSource.TRACES}
onRun={handleRunQuery}
initialExpression={querySearchInitialExpressionProp}
/>
</div>
</div>
{isError && <ErrorText>{data?.error || 'Something went wrong'}</ErrorText>}
{isLoading && traces.length === 0 && <TracesLoading />}
{isDataEmpty && <EntityEmptyState hasFilters={hasAdditionalFilters} />}
{isDataEmpty && !hasAdditionalFilters && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isError && !isKeyNotFound && !isLoading && <EntityError />}
{(!isError || isKeyNotFound) && traces.length > 0 && (
<div className={styles.entityTracesTable}>
<div className={styles.controls}>
<Controls
totalCount={hasMore ? currentCount + 1 : currentCount}
countPerPage={pageSize}
offset={offset}
perPageOptions={PER_PAGE_OPTIONS}
isLoading={false}
handleNavigatePrevious={(): void => {
void setPagination({
offset: Math.max(0, offset - pageSize),
limit: pageSize,
});
}}
handleNavigateNext={(): void => {
void setPagination({
offset: offset + pageSize,
limit: pageSize,
});
}}
handleCountItemsPerPageChange={(value): void => {
void setPagination({
offset: 0,
limit: value,
});
}}
/>
</div>
{isDataEmpty && hasAdditionalFilters && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
)}
{!isError && traces.length > 0 && (
<div className="entity-traces-table">
<TraceExplorerControls
isLoading={isFetching && traces.length === 0}
totalCount={totalCount}
perPageOptions={PER_PAGE_OPTIONS}
showSizeChanger={false}
/>
<ResizeTable
tableLayout="fixed"
pagination={false}
@@ -247,16 +231,4 @@ function EntityTracesContent({
);
}
function EntityTraces({ initialExpression, ...rest }: Props): JSX.Element {
return (
<QuerySearchV2Provider
queryParamKey={K8S_ENTITY_TRACES_EXPRESSION_KEY}
initialExpression={initialExpression}
persistOnUnmount
>
<EntityTracesContent {...rest} />
</QuerySearchV2Provider>
);
}
export default EntityTraces;

View File

@@ -1,100 +1,285 @@
import { mockQueryRangeV5WithLogsResponse } from '__tests__/query_range_v5.util';
import { render, screen } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { NuqsTestingAdapter } from 'nuqs/adapters/testing';
import { act, render, screen, waitFor } from 'tests/test-utils';
import { QueryRangePayloadV5 } from 'types/api/v5/queryRange';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import EntityTraces from '../EntityTraces';
import { K8S_ENTITY_TRACES_EXPRESSION_KEY } from '../hooks';
function verifyEntityTracesV5Request({
payload,
expectedOffset,
initialTimeRange,
}: {
payload: QueryRangePayloadV5;
expectedOffset: number;
initialTimeRange?: { start: number; end: number };
}): void {
const spec = payload.compositeQuery.queries[0]?.spec as {
offset?: number;
order?: Array<{ key: { name: string }; direction: string }>;
};
expect(spec.offset).toBe(expectedOffset);
if (initialTimeRange) {
expect(payload.start).toBe(initialTimeRange.start);
expect(payload.end).toBe(initialTimeRange.end);
}
const orderKeys = spec.order?.map((o) => o.key.name) ?? [];
expect(orderKeys).toContain('timestamp');
}
jest.mock('container/TopNav/DateTimeSelectionV2/index.tsx', () => ({
jest.mock('container/TopNav/DateTimeSelectionV2', () => ({
__esModule: true,
default: ({
onTimeChange,
}: {
onTimeChange?: (interval: string, dateTimeRange?: [number, number]) => void;
}): JSX.Element => (
<button
type="button"
data-testid="mock-datetime-selection"
onClick={(): void => {
onTimeChange?.('5m');
}}
>
Select Time
</button>
default: (): JSX.Element => (
<div data-testid="date-time-selection">Date Time</div>
),
}));
describe('EntityTraces', () => {
let capturedQueryRangePayloads: QueryRangePayloadV5[] = [];
const mockUseQuery = jest.fn();
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQuery: (queryKey: any, queryFn: any, options: any): any =>
mockUseQuery(queryKey, queryFn, options),
}));
beforeEach(() => {
capturedQueryRangePayloads = [];
mockQueryRangeV5WithLogsResponse({
onReceiveRequest: async (req) => {
const body = (await req.json()) as QueryRangePayloadV5;
capturedQueryRangePayloads.push(body);
return {};
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: '/test-path',
}),
useNavigate: (): jest.Mock => jest.fn(),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.Mock } => ({
safeNavigate: jest.fn(),
}),
}));
jest.spyOn(appContextHooks, 'useAppContext').mockReturnValue({
user: {
role: 'admin',
},
activeLicenseV3: {
event_queue: {
created_at: '0',
event: LicenseEvent.NO_EVENT,
scheduled_at: '0',
status: '',
updated_at: '0',
},
license: {
license_key: 'test-license-key',
license_type: 'trial',
org_id: 'test-org-id',
plan_id: 'test-plan-id',
plan_name: 'test-plan-name',
plan_type: 'trial',
plan_version: 'test-plan-version',
},
},
} as any);
const mockUseQueryBuilderData = {
handleRunQuery: jest.fn(),
stagedQuery: initialQueriesMap[DataSource.METRICS],
updateAllQueriesOperators: jest.fn(),
currentQuery: initialQueriesMap[DataSource.METRICS],
resetQuery: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(),
handleSetQueryData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
isDefaultQuery: jest.fn(),
};
jest.spyOn(useQueryBuilderHooks, 'useQueryBuilder').mockReturnValue({
...mockUseQueryBuilderData,
} as any);
const timeRange = {
startTime: 1718236800,
endTime: 1718236800,
};
const mockHandleChangeTracesFilters = jest.fn();
const mockTracesFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'service-name',
key: {
id: 'service-name',
dataType: DataTypes.String,
key: 'service.name',
type: 'tag',
isIndexed: false,
},
op: '=',
value: 'test-service',
},
],
op: 'and',
};
const isModalTimeSelection = false;
const mockHandleTimeChange = jest.fn();
const selectedInterval: Time = '5m';
const category = InfraMonitoringEntity.PODS;
const queryKey = 'pod-traces';
const queryKeyFilters = ['service.name'];
const mockTracesData = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [
{
timestamp: '2024-01-15T10:00:00Z',
data: {
trace_id: 'trace-1',
span_id: 'span-1',
service_name: 'test-service-1',
operation_name: 'test-operation-1',
duration: 100,
status_code: 200,
},
},
{
timestamp: '2024-01-15T10:01:00Z',
data: {
trace_id: 'trace-2',
span_id: 'span-2',
service_name: 'test-service-2',
operation_name: 'test-operation-2',
duration: 150,
status_code: 500,
},
},
],
},
],
},
},
},
},
};
const mockEmptyTracesData = {
payload: {
data: {
newResult: {
data: {
result: [
{
list: [],
},
],
},
},
},
},
};
const renderEntityTraces = (overrides = {}): any => {
const defaultProps = {
timeRange,
isModalTimeSelection,
handleTimeChange: mockHandleTimeChange,
handleChangeTracesFilters: mockHandleChangeTracesFilters,
tracesFilters: mockTracesFilters,
selectedInterval,
queryKey,
category,
queryKeyFilters,
...overrides,
};
return render(
<EntityTraces
timeRange={defaultProps.timeRange}
isModalTimeSelection={defaultProps.isModalTimeSelection}
handleTimeChange={defaultProps.handleTimeChange}
handleChangeTracesFilters={defaultProps.handleChangeTracesFilters}
tracesFilters={defaultProps.tracesFilters}
selectedInterval={defaultProps.selectedInterval}
queryKey={defaultProps.queryKey}
category={defaultProps.category}
queryKeyFilters={defaultProps.queryKeyFilters}
/>,
);
};
describe('EntityTraces', () => {
beforeEach(() => {
jest.clearAllMocks();
mockUseQuery.mockReturnValue({
data: mockTracesData,
isLoading: false,
isError: false,
isFetching: false,
});
});
it('should use V5 API for fetching traces', async () => {
act(() => {
render(
<NuqsTestingAdapter
searchParams={`${K8S_ENTITY_TRACES_EXPRESSION_KEY}=k8s.pod.name+%3D+%22x%22`}
>
<EntityTraces
timeRange={{ startTime: 1, endTime: 2 }}
isModalTimeSelection={false}
handleTimeChange={jest.fn()}
selectedInterval="5m"
queryKey="test"
category={InfraMonitoringEntity.PODS}
initialExpression='k8s.pod.name = "x"'
/>
</NuqsTestingAdapter>,
);
it('should render traces list with data', () => {
renderEntityTraces();
expect(screen.getByText('Previous')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
expect(
screen.getByText(/Search Filter : select options from suggested values/),
).toBeInTheDocument();
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
});
it('renders empty state when no traces are found', () => {
mockUseQuery.mockReturnValue({
data: mockEmptyTracesData,
isLoading: false,
isError: false,
isFetching: false,
});
await waitFor(() => {
expect(
screen.queryByText('pending_data_placeholder'),
).not.toBeInTheDocument();
renderEntityTraces();
expect(screen.getByText(/No traces yet./)).toBeInTheDocument();
});
it('renders loader when fetching traces', () => {
mockUseQuery.mockReturnValue({
data: undefined,
isLoading: true,
isError: false,
isFetching: true,
});
await waitFor(() => {
expect(capturedQueryRangePayloads).toHaveLength(1);
renderEntityTraces();
expect(screen.getByText('pending_data_placeholder')).toBeInTheDocument();
});
it('shows error state when query fails', () => {
mockUseQuery.mockReturnValue({
data: { error: 'API Error' },
isLoading: false,
isError: true,
isFetching: false,
});
const firstPayload = capturedQueryRangePayloads[0];
verifyEntityTracesV5Request({
payload: firstPayload,
expectedOffset: 0,
});
renderEntityTraces();
expect(screen.getByText('API Error')).toBeInTheDocument();
});
it('calls handleChangeTracesFilters when query builder search changes', () => {
renderEntityTraces();
expect(
screen.getByText(/Search Filter : select options from suggested values/),
).toBeInTheDocument();
});
it('calls handleTimeChange when datetime selection changes', () => {
renderEntityTraces();
expect(screen.getByTestId('date-time-selection')).toBeInTheDocument();
});
it('shows pagination controls when traces are present', () => {
renderEntityTraces();
expect(screen.getByText('Previous')).toBeInTheDocument();
expect(screen.getByText('Next')).toBeInTheDocument();
});
it('disables pagination buttons when no more data', () => {
renderEntityTraces();
const prevButton = screen.getByText('Previous').closest('button');
const nextButton = screen.getByText('Next').closest('button');
expect(prevButton).toBeDisabled();
expect(nextButton).toBeDisabled();
});
});

View File

@@ -0,0 +1,150 @@
// Traces
.entity-metric-traces {
margin-top: 1rem;
.entity-metric-traces-header {
display: flex;
justify-content: space-between;
margin-bottom: 1rem;
gap: 8px;
padding: 12px;
border-radius: 3px;
border: 1px solid var(--l1-border);
.filter-section {
flex: 1;
.ant-select-selector {
border-radius: 2px;
border: 1px solid var(--l1-border) !important;
background-color: var(--l3-background) !important;
input {
font-size: 12px;
}
.ant-tag .ant-typography {
font-size: 12px;
}
}
}
}
.entity-metric-traces-table {
.ant-table-content {
overflow: hidden !important;
}
.ant-table {
border-radius: 3px;
border: 1px solid var(--l1-border);
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
border-bottom: none;
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
&::before {
background-color: transparent;
}
}
.ant-table-thead > tr > th:has(.entityname-column-header) {
background: var(--l2-background);
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--l1-foreground);
background: color-mix(in srgb, var(--bg-robin-200) 1%, transparent);
}
.ant-table-cell:has(.entityname-column-value) {
background: var(--l2-background);
}
.entityname-column-value {
color: var(--l1-foreground);
font-family: 'Geist Mono';
font-style: normal;
font-weight: 600;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.status-cell {
.active-tag {
color: var(--bg-forest-500);
padding: 4px 8px;
border-radius: 4px;
font-size: 12px;
font-weight: 500;
}
}
.progress-container {
.ant-progress-bg {
height: 8px !important;
border-radius: 4px;
}
}
.ant-table-tbody > tr:hover > td {
background: color-mix(in srgb, var(--l1-foreground) 4%, transparent);
}
.ant-table-cell:first-child {
text-align: justify;
}
.ant-table-cell:nth-child(2) {
padding-left: 16px;
padding-right: 16px;
}
.ant-table-cell:nth-child(n + 3) {
padding-right: 24px;
}
.column-header-right {
text-align: right;
}
.ant-table-tbody > tr > td {
border-bottom: none;
}
.ant-table-thead
> tr
> th:not(:last-child):not(.ant-table-selection-column):not(
.ant-table-row-expand-icon-cell
):not([colspan])::before {
background-color: transparent;
}
.ant-empty-normal {
visibility: hidden;
}
}
.ant-table-container::after {
content: none;
}
}
}

View File

@@ -1,115 +0,0 @@
import { useCallback, useMemo } from 'react';
import { useQuery, useQueryClient } from 'react-query';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getEntityTracesQueryPayload } from './utils';
export const K8S_ENTITY_TRACES_EXPRESSION_KEY = 'k8sEntityTracesExpression';
export interface TraceRow {
timestamp: string;
data: Record<string, unknown>;
}
export function useEntityTraces({
queryKey,
timeRange,
expression,
offset = 0,
pageSize = 10,
}: {
queryKey: string;
timeRange: { startTime: number; endTime: number };
expression: string;
offset?: number;
pageSize?: number;
}): {
traces: TraceRow[];
isLoading: boolean;
isFetching: boolean;
isError: boolean;
error?: unknown;
currentCount: number;
hasMore: boolean;
refetch: () => void;
cancel: () => void;
reactQueryKey: unknown[];
} {
const reactQueryKey = useMemo(
() => [
// TODO: remove AUTO_REFRESH_QUERY when migrating to date time selection v3
REACT_QUERY_KEY.AUTO_REFRESH_QUERY,
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
[
queryKey,
timeRange.startTime,
timeRange.endTime,
expression,
offset,
pageSize,
],
);
const { data, isLoading, isFetching, isError, error, refetch } = useQuery({
queryKey: reactQueryKey,
queryFn: async ({ signal }) => {
const { query } = getEntityTracesQueryPayload({
start: timeRange.startTime,
end: timeRange.endTime,
expression,
offset,
pageSize,
});
return GetMetricQueryRange(query, ENTITY_VERSION_V5, undefined, signal);
},
enabled: !!expression?.trim(),
});
const result = data?.payload?.data?.newResult?.data?.result?.[0];
const traces = useMemo<TraceRow[]>(() => {
const list = result?.list;
if (!list) {
return [];
}
return list.map((item) => ({
data: item.data,
timestamp: item.timestamp,
}));
}, [result?.list]);
const currentCount = result?.list?.length || 0;
// Has more if nextCursor exists or if we got a full page of results
const hasMore = !!result?.nextCursor || currentCount >= pageSize;
const queryClient = useQueryClient();
const cancel = useCallback(() => {
void queryClient.cancelQueries({
queryKey: reactQueryKey,
});
}, [queryClient, reactQueryKey]);
return {
traces,
isLoading,
isFetching,
isError,
error,
currentCount,
hasMore,
refetch,
cancel,
reactQueryKey,
};
}

View File

@@ -17,43 +17,6 @@ const keyToLabelMap: Record<string, string> = {
durationNano: 'Duration',
httpMethod: 'HTTP Method',
responseStatusCode: 'Status Code',
spanID: 'Span ID',
traceID: 'Trace ID',
};
const keyAliases: Record<string, string[]> = {
serviceName: ['serviceName', 'service.name', 'service_name'],
durationNano: ['durationNano', 'duration.nano', 'duration_nano'],
httpMethod: ['httpMethod', 'http.method', 'http_method'],
responseStatusCode: [
'response_status_code',
'response.status.code',
'responseStatusCode',
],
spanID: ['spanID', 'span.id', 'span_id'],
traceID: ['traceID', 'trace.id', 'trace_id'],
};
const getPrimaryKey = (key: string): string => {
for (const [primaryKey, aliases] of Object.entries(keyAliases)) {
if (aliases.includes(key)) {
return primaryKey;
}
}
return key;
};
const getValueForKey = (data: Record<string, any>, key: string): any => {
const primaryKey = getPrimaryKey(key);
const aliases = keyAliases[primaryKey];
if (aliases) {
for (const alias of aliases) {
if (data[alias] !== undefined) {
return data[alias];
}
}
}
return data[key];
};
export const getTraceListColumns = (
@@ -61,22 +24,21 @@ export const getTraceListColumns = (
): ColumnsType<RowData> => {
const columns: ColumnsType<RowData> =
selectedColumns.map(({ dataType, key, type }) => ({
title: keyToLabelMap[getPrimaryKey(key)],
title: keyToLabelMap[key],
dataIndex: key,
key: `${key}-${dataType}-${type}`,
width: 145,
render: (value, item): JSX.Element => {
const itemData = item.data as any;
const primaryKey = getPrimaryKey(key);
if (primaryKey === 'timestamp') {
if (key === 'timestamp') {
const date =
typeof value === 'string'
? dayjs(value).format(DATE_TIME_FORMATS.ISO_DATETIME_MS)
: dayjs(value / 1e6).format(DATE_TIME_FORMATS.ISO_DATETIME_MS);
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<BlockLink to={getTraceLink(item)} openInNewTab>
<Typography.Text>{date}</Typography.Text>
</BlockLink>
);
@@ -90,21 +52,21 @@ export const getTraceListColumns = (
);
}
if (primaryKey === 'httpMethod' || primaryKey === 'responseStatusCode') {
if (key === 'httpMethod' || key === 'responseStatusCode') {
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Tag data-testid={key} color="magenta">
{getValueForKey(itemData, key)}
{itemData[key]}
</Tag>
</BlockLink>
);
}
if (primaryKey === 'durationNano') {
const durationNano = getValueForKey(itemData, key);
if (key === 'durationNano') {
const durationNano = itemData[key];
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<BlockLink to={getTraceLink(item)} openInNewTab>
<Typography data-testid={key}>{getMs(durationNano)}ms</Typography>
</BlockLink>
);
@@ -112,7 +74,7 @@ export const getTraceListColumns = (
return (
<BlockLink to={getTraceLink(itemData)} openInNewTab>
<Typography data-testid={key}>{getValueForKey(itemData, key)}</Typography>
<Typography data-testid={key}>{itemData[key]}</Typography>
</BlockLink>
);
},

View File

@@ -1,104 +0,0 @@
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { DEFAULT_PER_PAGE_VALUE } from 'container/Controls/config';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import {
DataSource,
ReduceOperators,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
export interface EntityTracesQueryParams {
start: number;
end: number;
expression: string;
offset?: number;
pageSize?: number;
}
function buildEntityTracesQueryData(
expression: string,
{
offset,
pageSize,
}: {
offset: number;
pageSize: number;
},
): IBuilderQuery {
return {
...initialQueryBuilderFormValuesMap.traces,
queryName: 'A',
dataSource: DataSource.TRACES,
aggregateOperator: TracesAggregatorOperator.NOOP,
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
aggregations: [],
filter: { expression },
expression,
having: {
expression: '',
},
disabled: false,
stepInterval: 60,
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset,
pageSize,
};
}
export function getEntityTracesQueryPayload({
start,
end,
expression,
offset = 0,
pageSize = DEFAULT_PER_PAGE_VALUE,
}: EntityTracesQueryParams): {
query: GetQueryResultsProps;
queryData: IBuilderQuery;
} {
const queryData = buildEntityTracesQueryData(expression, { offset, pageSize });
return {
query: {
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
},
queryData,
};
}

View File

@@ -1,5 +1,8 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from '@signozhq/ui/typography';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { Ghost } from '@signozhq/icons';
import {
BaseAutocompleteData,
DataTypes,
@@ -8,25 +11,12 @@ import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import APIError from 'types/api/error';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { nanoToMilli } from 'utils/timeUtils';
import { v4 as uuidv4 } from 'uuid';
export function isKeyNotFoundError(error: unknown): boolean {
if (!(error instanceof APIError)) {
return false;
}
const errorDetails = error.getErrorDetails();
if (errorDetails.error.code !== 'invalid_input') {
return false;
}
const errors = errorDetails.error.errors || [];
return errors.some((err) => err.message?.includes('not found'));
}
import { InfraMonitoringEntity } from '../constants';
export const QUERY_KEYS = {
K8S_OBJECT_KIND: 'k8s.object.kind',
@@ -99,6 +89,29 @@ export const getEntityEventsOrLogsQueryPayload = (
end,
});
/**
* Empty state container for entity details
*/
export function EntityDetailsEmptyContainer({
view,
category,
}: {
view: 'logs' | 'traces' | 'events';
category: InfraMonitoringEntity;
}): React.ReactElement {
const label = category.slice(0, category.length);
return (
<div className="no-logs-found">
<Typography.Text color="muted">
<Ghost size={24} color={Color.BG_AMBER_500} />
{`No ${view} found for this ${label}
in the selected time range.`}
</Typography.Text>
</div>
);
}
export const entityTracesColumns = [
{
dataIndex: 'timestamp',

View File

@@ -160,21 +160,21 @@ export const getNamespaceMetricsQueryPayload = (
'k8s.replicaset.available',
'k8s_replicaset_available',
);
const k8sDaemonsetDesiredScheduledNodesKey = getKey(
'k8s.daemonset.desired_scheduled_nodes',
'k8s_daemonset_desired_scheduled_nodes',
const k8sDaemonsetDesiredScheduledNamespacesKey = getKey(
'k8s.daemonset.desired.scheduled.namespaces',
'k8s_daemonset_desired_scheduled_namespaces',
);
const k8sDaemonsetCurrentScheduledNodesKey = getKey(
'k8s.daemonset.current_scheduled_nodes',
'k8s_daemonset_current_scheduled_nodes',
const k8sDaemonsetCurrentScheduledNamespacesKey = getKey(
'k8s.daemonset.current.scheduled.namespaces',
'k8s_daemonset_current_scheduled_namespaces',
);
const k8sDaemonsetReadyNodesKey = getKey(
'k8s.daemonset.ready_nodes',
'k8s_daemonset_ready_nodes',
const k8sDaemonsetReadyNamespacesKey = getKey(
'k8s.daemonset.ready.namespaces',
'k8s_daemonset_ready_namespaces',
);
const k8sDaemonsetMisscheduledNodesKey = getKey(
'k8s.daemonset.misscheduled_nodes',
'k8s_daemonset_misscheduled_nodes',
const k8sDaemonsetMisscheduledNamespacesKey = getKey(
'k8s.daemonset.misscheduled.namespaces',
'k8s_daemonset_misscheduled_namespaces',
);
const k8sDeploymentDesiredKey = getKey(
'k8s.deployment.desired',
@@ -1310,8 +1310,8 @@ export const getNamespaceMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: k8sDaemonsetDesiredScheduledNodesKey,
key: k8sDaemonsetDesiredScheduledNodesKey,
id: k8sDaemonsetDesiredScheduledNamespacesKey,
key: k8sDaemonsetDesiredScheduledNamespacesKey,
type: 'Gauge',
},
aggregateOperator: 'latest',
@@ -1356,8 +1356,8 @@ export const getNamespaceMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: k8sDaemonsetCurrentScheduledNodesKey,
key: k8sDaemonsetCurrentScheduledNodesKey,
id: k8sDaemonsetCurrentScheduledNamespacesKey,
key: k8sDaemonsetCurrentScheduledNamespacesKey,
type: 'Gauge',
},
aggregateOperator: 'latest',
@@ -1402,8 +1402,8 @@ export const getNamespaceMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: k8sDaemonsetReadyNodesKey,
key: k8sDaemonsetReadyNodesKey,
id: k8sDaemonsetReadyNamespacesKey,
key: k8sDaemonsetReadyNamespacesKey,
type: 'Gauge',
},
aggregateOperator: 'latest',
@@ -1448,8 +1448,8 @@ export const getNamespaceMetricsQueryPayload = (
{
aggregateAttribute: {
dataType: DataTypes.Float64,
id: k8sDaemonsetMisscheduledNodesKey,
key: k8sDaemonsetMisscheduledNodesKey,
id: k8sDaemonsetMisscheduledNamespacesKey,
key: k8sDaemonsetMisscheduledNamespacesKey,
type: 'Gauge',
},
aggregateOperator: 'latest',

View File

@@ -59,6 +59,7 @@ export const k8sPodInitialLogTracesFilter = (
pod: K8sPodsData,
): ReturnType<typeof createFilterItem>[] => [
createFilterItem(QUERY_KEYS.K8S_POD_NAME, pod.meta.k8s_pod_name),
createFilterItem(QUERY_KEYS.K8S_NAMESPACE_NAME, pod.meta.k8s_namespace_name),
];
export const k8sPodGetEntityName = (pod: K8sPodsData): string =>

View File

@@ -0,0 +1,37 @@
import { render, screen } from '@testing-library/react';
import { EntityProgressBar } from '../components';
import { EventContents } from '../commonUtils';
jest.mock('components/ResizeTable', () => ({
ResizeTable: ({ dataSource }: { dataSource: unknown }): JSX.Element => (
<div data-testid="resize-table">{JSON.stringify(dataSource)}</div>
),
}));
jest.mock('container/LogDetailedView/FieldRenderer', () => ({
__esModule: true,
default: ({ field }: { field: string }): JSX.Element => <span>{field}</span>,
}));
describe('commonUtils', () => {
it('renders EntityProgressBar with percentage value', () => {
render(<EntityProgressBar value={0.5} type="request" />);
expect(screen.getByText('50%')).toBeInTheDocument();
});
it('renders EntityProgressBar with dash for NaN value', () => {
render(<EntityProgressBar value={NaN} type="limit" />);
expect(screen.getByText('-')).toBeInTheDocument();
});
it('renders EventContents with data fields', () => {
render(
<EventContents data={{ namespace: 'default', cluster: 'prod-cluster' }} />,
);
const resizeTable = screen.getByTestId('resize-table');
expect(resizeTable).toHaveTextContent('namespace');
expect(resizeTable).toHaveTextContent('prod-cluster');
});
});

View File

@@ -1,6 +1,4 @@
.eventContentContainer {
padding: var(--spacing-6);
:global(.ant-table) {
background: var(--l1-background);
@@ -16,13 +14,20 @@
}
:global(.ant-table-cell) {
border: 1px solid var(--l2-border) !important;
border: 1px solid var(--l2-border);
}
:global(.attribute-name .ant-btn:hover) {
background-color: transparent !important;
}
:global(.attribute-pin) {
cursor: pointer;
padding: 0;
vertical-align: middle;
text-align: center;
}
:global(.attribute-pin .log-attribute-pin) {
padding: 8px;
display: flex;

View File

@@ -1,4 +1,15 @@
/* eslint-disable @typescript-eslint/explicit-function-return-type */
import { useMemo } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Table, Tooltip } from 'antd';
import type { ColumnsType } from 'antd/lib/table';
import { ResizeTable } from 'components/ResizeTable';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
import styles from './commonUtils.module.scss';
/**
* Converts size in bytes to a human-readable string with appropriate units
@@ -60,3 +71,120 @@ export function getStrokeColorForLimitUtilization(value: number): string {
// Red
return Color.BG_SAKURA_500;
}
export function EventContents({
data,
}: {
data: Record<string, string> | undefined;
}): JSX.Element {
const tableData = useMemo(
() =>
data ? Object.keys(data).map((key) => ({ key, value: data[key] })) : [],
[data],
);
const columns: ColumnsType<DataType> = [
{
title: 'Key',
dataIndex: 'key',
key: 'key',
width: 50,
align: 'left',
className: 'attribute-pin value-field-container',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'attribute-name',
render: (field: string): JSX.Element => <FieldRenderer field={field} />,
},
];
return (
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
className={styles.eventContentContainer}
/>
);
}
export const getMetricsTableData = (data: any): any[] => {
if (data?.params && data?.payload?.data?.result?.length) {
const rowsData = (data?.payload.data.result[0] as any).table.rows;
const columnsData = (data?.payload.data.result[0] as any).table.columns;
const builderQueries = data.params?.compositeQuery?.builderQueries;
const columns = columnsData.map((columnData: any) => {
if (columnData.isValueColumn) {
return {
key: columnData.name,
label: builderQueries[columnData.name].legend,
isValueColumn: true,
};
}
return {
key: columnData.name,
label: columnData.name,
isValueColumn: false,
};
});
const rows = rowsData.map((rowData: any) => rowData.data);
return [{ rows, columns }];
}
return [{ rows: [], columns: [] }];
};
export function MetricsTable({
rows,
columns,
}: {
rows: any[];
columns: any[];
}): JSX.Element {
const columnsData = columns.map((col: any) => ({
title: <Tooltip title={col.label}>{col.label}</Tooltip>,
dataIndex: col.key,
key: col.key,
sorter: false,
ellipsis: true,
render: (value: string) => <Tooltip title={value}>{value}</Tooltip>,
}));
return (
<div className="metrics-table">
<Table
dataSource={rows}
columns={columnsData}
tableLayout="fixed"
pagination={{ pageSize: 10, showSizeChanger: false }}
scroll={{ y: 180 }}
sticky
/>
</div>
);
}
export const filterDuplicateFilters = (
filters: TagFilterItem[],
): TagFilterItem[] => {
const uniqueFilters = [];
const seenIds = new Set();
for (const filter of filters) {
if (!seenIds.has(filter.id)) {
seenIds.add(filter.id);
uniqueFilters.push(filter);
}
}
return uniqueFilters;
};

View File

@@ -12,10 +12,7 @@ import { useLocation } from 'react-router-dom';
import { Button, Select, Spin, Tag, Tooltip } from 'antd';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import {
INFRA_LONG_TO_SHORT_OPERATOR_MAP,
OPERATORS,
} from 'constants/queryBuilder';
import { OPERATORS } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { LogsExplorerShortcuts } from 'constants/shortcuts/logsExplorerShortcuts';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
@@ -71,17 +68,6 @@ import {
import './QueryBuilderSearch.styles.scss';
function getOperatorValueForContext(
op: string,
isInfraMonitoring?: boolean,
): string {
const mappedOp =
isInfraMonitoring && INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
? INFRA_LONG_TO_SHORT_OPERATOR_MAP[op]
: op;
return getOperatorValue(mappedOp);
}
function QueryBuilderSearch({
query,
onChange,
@@ -315,7 +301,7 @@ function QueryBuilderSearch({
dataType: fetchValueDataType(computedTagValue, tagOperator),
type: '',
},
op: getOperatorValueForContext(tagOperator, isInfraMonitoring),
op: getOperatorValue(tagOperator),
value: computedTagValue,
};
});

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