Compare commits

..

199 Commits

Author SHA1 Message Date
aks07
48f1a4cbf3 Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-05-06 20:57:43 +05:30
aks07
bb499973bf feat: remove antd component usage 2026-05-06 20:57:04 +05:30
aks07
13c087d34d feat: remove common component 2026-05-06 19:54:09 +05:30
aks07
feb9031bcd feat: packages remove 2026-05-06 19:17:51 +05:30
aks07
bc4a6b7ded feat: packages remove 2026-05-06 18:01:18 +05:30
aks07
9216bb5f34 Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-05-06 17:26:48 +05:30
aks07
18d2806f95 feat: minor refactors 2026-05-06 17:03:36 +05:30
aks07
8d666471e1 feat: v3 behind feature flag 2026-05-06 17:00:16 +05:30
aks07
9d022772b7 feat: minor refactors 2026-05-06 16:28:47 +05:30
aks07
648a48cbaa feat: minor refactors 2026-05-06 15:39:44 +05:30
aks07
eb22c57a67 feat: minor change 2026-05-04 17:43:31 +05:30
aks07
896379b680 feat: minor change 2026-05-04 17:34:32 +05:30
aks07
f041b16e4b feat: delete waterfall go 2026-05-04 17:04:39 +05:30
aks07
9ca3a7fd3e Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-05-04 16:57:14 +05:30
aks07
43e122367c Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items
# Conflicts:
#	docs/api/openapi.yml
#	frontend/package.json
#	frontend/src/api/generated/services/sigNoz.schemas.ts
#	frontend/src/api/generated/services/tracedetail/index.ts
#	frontend/src/auto-import-registry.d.ts
#	frontend/src/components/LogDetail/index.tsx
#	frontend/src/hooks/trace/useCopySpanLink.ts
#	frontend/src/styles.scss
#	frontend/yarn.lock
#	pkg/apiserver/signozapiserver/provider.go
#	pkg/apiserver/signozapiserver/tracedetail.go
#	pkg/modules/tracedetail/impltracedetail/handler.go
#	pkg/modules/tracedetail/impltracedetail/module.go
#	pkg/modules/tracedetail/impltracedetail/store.go
#	pkg/modules/tracedetail/impltracedetail/waterfall_test.go
#	pkg/signoz/module.go
#	pkg/signoz/openapi.go
#	pkg/signoz/provider.go
#	pkg/types/tracedetailtypes/store.go
#	pkg/types/tracedetailtypes/waterfall.go
2026-05-04 16:56:32 +05:30
aks07
603077c575 feat: open in new tab 2026-04-21 10:01:34 +05:30
aks07
7e5c4476f7 feat: add service name 2026-04-21 09:22:17 +05:30
aks07
da648ed3f3 feat: style fix 2026-04-21 01:43:12 +05:30
aks07
9fa56aacd1 feat: uncollapse spans on select from flamegraph 2026-04-21 01:37:22 +05:30
aks07
5acd79419c Merge branch 'main' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-21 01:30:25 +05:30
aks07
9b7b0f8862 feat: load all spans in waterfall under limit 2026-04-21 01:23:33 +05:30
aks07
c29e8a0136 feat: add status codes 2026-04-20 21:25:49 +05:30
aks07
ebac945ac2 feat: minor fix 2026-04-20 14:04:05 +05:30
aks07
e787497695 feat: fix colors 2026-04-20 13:46:25 +05:30
aks07
eba6bd5f5b feat: disable crosshair in waterfall 2026-04-20 11:26:35 +05:30
aks07
1aeab2718d feat: fix test 2026-04-20 10:44:21 +05:30
aks07
d879af4fb3 Merge branch 'ns/waterfall-v3-2' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-20 10:19:56 +05:30
Nikhil Soni
ac10be2eb2 Merge remote-tracking branch 'origin/main' into ns/waterfall-v3-2 2026-04-20 10:04:09 +05:30
aks07
113d1544ba feat: add crosshair 2026-04-20 09:46:14 +05:30
aks07
df02da664c feat: use semantic tokens 2026-04-17 21:03:41 +05:30
aks07
d0a491ed8e feat: use semantic tokens 2026-04-17 20:59:46 +05:30
aks07
77c39a9f05 feat: delete unused file 2026-04-17 20:26:50 +05:30
aks07
309a76e5fd feat: rename copy to copy value 2026-04-17 19:13:28 +05:30
aks07
43e80caf09 Merge branch 'feat/filter-search' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-17 10:31:48 +05:30
aks07
a2d853daf5 Merge branch 'main' of github.com:SigNoz/signoz into feat/filter-search 2026-04-17 10:23:53 +05:30
aks07
3970619afa Merge remote-tracking branch 'origin/ns/waterfall-v3-2' into feat/filter-search 2026-04-17 10:11:31 +05:30
aks07
9dc87761c1 feat: minor fix 2026-04-16 20:22:17 +05:30
aks07
86a44fad42 Merge branch 'feat/filter-search' of github.com:SigNoz/signoz into feat/dropdown-items 2026-04-16 20:20:41 +05:30
aks07
91f74144cb feat: old trace btn added 2026-04-16 20:19:27 +05:30
aks07
0863c5170b feat: no data screen 2026-04-16 18:14:18 +05:30
aks07
837cd2a463 feat: fix color duplications 2026-04-16 16:46:37 +05:30
aks07
c88a2d5d90 feat: dropdown added to span details 2026-04-16 15:42:09 +05:30
Nikhil Soni
c9abc2cb30 chore: use sqlbuilder in clickhouse store where possible 2026-04-16 09:39:36 +05:30
Nikhil Soni
01824b0b62 chore: remove unused duration nano field 2026-04-16 09:39:36 +05:30
Nikhil Soni
d1b378992d refactor: use same method for cache key creation 2026-04-16 09:39:36 +05:30
Nikhil Soni
52ca921d2a refactor: return 404 on missing trace and other cleanup 2026-04-16 09:39:36 +05:30
Nikhil Soni
42f12dfef3 refactor: breakdown GetSelectedSpans for readability 2026-04-16 09:39:36 +05:30
Nikhil Soni
f2a694447e refactor: separate out store calls and computations 2026-04-16 09:39:36 +05:30
Nikhil Soni
2e7dfa739f refactor: move error to types as well 2026-04-16 09:39:36 +05:30
Nikhil Soni
0aa73580a3 refactor: extract ClickHouse queries into a store abstraction
Move GetTraceSummary and GetTraceSpans out of module.go into a
traceStore interface backed by clickhouseTraceStore in store.go.
The module struct now holds a traceStore instead of a raw
telemetrystore.TelemetryStore, keeping DB access separate from
business logic.
2026-04-16 09:39:36 +05:30
Nikhil Soni
2ff1a43bf8 refactor: move type creation and constants to types package
- Move DB/table/cache/windowing constants to tracedetailtypes package
- Add NewWaterfallTrace and NewWaterfallResponse constructors in types
- Use constructors in module.go instead of inline struct literals
- Reorder waterfall.go so public functions precede private ones
2026-04-16 09:39:36 +05:30
Nikhil Soni
c1477c78be chore: avoid returning nil, nil 2026-04-16 09:39:36 +05:30
Nikhil Soni
9807dd5295 refactor: break down GetWaterfall method for readability 2026-04-16 09:39:36 +05:30
Nikhil Soni
2c59eeff26 fix: update openapi specs 2026-04-16 09:39:36 +05:30
Nikhil Soni
8ccfb4efef fix: use int16 for status code as per db schema 2026-04-16 09:39:36 +05:30
Nikhil Soni
87d18160e8 fix: remove timeout since waterfall take longer 2026-04-16 09:39:36 +05:30
Nikhil Soni
bfa7ee96da chore: generate openapi spec for v3 waterfall 2026-04-16 09:39:33 +05:30
Nikhil Soni
5e3eb66d3a fix: use typed paramter field in logs 2026-04-16 09:38:12 +05:30
Nikhil Soni
3d8cdf18bd fix: add timeout to module context 2026-04-16 09:38:12 +05:30
Nikhil Soni
cb4e501047 fix: rename timestamp to milli for readability 2026-04-16 09:38:12 +05:30
Nikhil Soni
cb8b2137ba fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-16 09:38:12 +05:30
Nikhil Soni
998315a255 chore: avoid sorting on every traversal 2026-04-16 09:38:12 +05:30
Nikhil Soni
250657e46b chore: add same test cases as for old waterfall api 2026-04-16 09:38:12 +05:30
Nikhil Soni
795ae9ab18 refactor: convert waterfall api to modules format 2026-04-16 09:38:05 +05:30
Nikhil Soni
6a9ea8d9f8 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-16 09:31:48 +05:30
Nikhil Soni
2723e18023 fix: update span.attributes to map of string to any
To support otel format of diffrent types of attributes
2026-04-16 09:31:48 +05:30
Nikhil Soni
6e89d5f6eb chore: add reason for using snake case in response 2026-04-16 09:31:48 +05:30
Nikhil Soni
4c2a815236 refactor: move type conversion logic to types pkg 2026-04-16 09:31:48 +05:30
Nikhil Soni
b1d66b2e5f feat: setup types and interface for waterfall v3
v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service
2026-04-16 09:31:48 +05:30
aks07
ae88edbb5e feat: disable scroll to view for collapse and uncollapse 2026-04-15 22:36:58 +05:30
aks07
7c9484d47b feat: api integration v3 2026-04-15 21:50:02 +05:30
aks07
24128bd394 feat: show caution on sampled flamegraph 2026-04-15 13:35:55 +05:30
aks07
2118916a23 feat: fix flamegraph and waterfall bg color 2026-04-15 00:51:39 +05:30
aks07
52220412a1 fix: align timeline and span length in flamegraph and waterfall 2026-04-15 00:32:35 +05:30
aks07
85abee8476 feat: show child count in waterfall 2026-04-14 21:27:15 +05:30
aks07
650a29d184 feat: set limit to 100k for flamegraph 2026-04-14 21:17:26 +05:30
aks07
d9c7101d22 feat: reduce time 2026-04-14 18:45:14 +05:30
aks07
b1e7c25189 Merge branch 'main' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 15:33:19 +05:30
aks07
e9904a0558 Merge branch 'feat/filter-search' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 15:33:04 +05:30
aks07
5cd199f535 feat: automatically scroll left on vertical scroll 2026-04-14 15:32:23 +05:30
aks07
f6f48ca0bc feat: api integration 2026-04-14 14:46:49 +05:30
Aditya Singh
847f91e22e Merge branch 'main' into feat/filter-search 2026-04-14 13:14:46 +05:30
aks07
29d0abe5a8 Merge branch 'ns/waterfall-v3-2' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 13:11:26 +05:30
Nikhil Soni
c08840a827 fix: update openapi specs 2026-04-14 10:50:36 +05:30
Nikhil Soni
a3e7bb90b0 fix: use int16 for status code as per db schema 2026-04-14 10:50:36 +05:30
Nikhil Soni
8515d2f37c fix: remove timeout since waterfall take longer 2026-04-14 10:50:36 +05:30
Nikhil Soni
07c05ac3a6 chore: generate openapi spec for v3 waterfall 2026-04-14 10:50:36 +05:30
Nikhil Soni
6289f59ba3 fix: use typed paramter field in logs 2026-04-14 10:50:36 +05:30
Nikhil Soni
76371c9fa2 fix: add timeout to module context 2026-04-14 10:50:36 +05:30
Nikhil Soni
f082e396eb fix: rename timestamp to milli for readability 2026-04-14 10:50:36 +05:30
Nikhil Soni
840eb8f228 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-14 10:50:36 +05:30
Nikhil Soni
2911baf6bb chore: avoid sorting on every traversal 2026-04-14 10:50:36 +05:30
Nikhil Soni
fc5be4eeb5 chore: add same test cases as for old waterfall api 2026-04-14 10:50:36 +05:30
Nikhil Soni
a1b92c79a4 refactor: convert waterfall api to modules format 2026-04-14 10:50:31 +05:30
Nikhil Soni
7a0acd5c8b fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-14 10:49:58 +05:30
Nikhil Soni
069cbe2c6f fix: update span.attributes to map of string to any
To support otel format of diffrent types of attributes
2026-04-14 10:49:58 +05:30
Nikhil Soni
4c821f9721 chore: add reason for using snake case in response 2026-04-14 10:49:58 +05:30
Nikhil Soni
4eccea92db refactor: move type conversion logic to types pkg 2026-04-14 10:49:58 +05:30
Nikhil Soni
c8d8966a5d feat: setup types and interface for waterfall v3
v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service
2026-04-14 10:49:58 +05:30
aks07
1e52a5603e Merge branch 'ns/waterfall-v3-2' of github.com:SigNoz/signoz into feat/filter-search 2026-04-14 10:33:46 +05:30
aks07
780ba1a359 feat: new filter ui 2026-04-14 10:27:21 +05:30
aks07
3b71abe820 feat: remove trace header 2026-04-13 18:49:38 +05:30
aks07
70b9d0ff02 feat: filter ui fix 2026-04-13 15:49:43 +05:30
aks07
f4657861e1 feat: make filter and search work with flamegraph 2026-04-13 15:49:09 +05:30
Nikhil Soni
66fe5b5240 fix: update openapi specs 2026-04-13 14:04:45 +05:30
Nikhil Soni
c333cecf43 fix: use int16 for status code as per db schema 2026-04-13 13:23:39 +05:30
Nikhil Soni
276e09853e fix: remove timeout since waterfall take longer 2026-04-13 12:31:08 +05:30
aks07
4defd41504 feat: prevent api call on closing span detail 2026-04-13 12:07:28 +05:30
aks07
ab53b29a14 feat: add span details loader 2026-04-13 10:25:49 +05:30
aks07
b58e82efbf feat: disable anaytics span tab for now 2026-04-12 21:33:07 +05:30
aks07
0a1a676877 feat: show total span count 2026-04-12 21:14:19 +05:30
aks07
bb2aa9f77c feat: auto scroll horizontally to span 2026-04-12 20:08:55 +05:30
aks07
04bef4ac06 feat: sync error and loading state for flamegraph for n/w and computation logic 2026-04-11 23:48:47 +05:30
aks07
3bcb2c2c41 feat: added loading to flamegraph and timeout to webworker 2026-04-11 22:37:10 +05:30
aks07
9e77b76122 feat: add icons 2026-04-11 21:28:00 +05:30
aks07
ff4a41d842 feat: analytics 2026-04-11 14:34:57 +05:30
aks07
387deb779d feat: span details ux 2026-04-10 21:31:40 +05:30
aks07
1ec2663d51 feat: lint fix 2026-04-10 13:40:43 +05:30
aks07
1b17370da0 feat: fix test 2026-04-10 13:10:28 +05:30
aks07
c6484a79e2 feat: fix test 2026-04-10 13:09:08 +05:30
Nikhil Soni
16a2c7a1af chore: generate openapi spec for v3 waterfall 2026-04-10 10:54:36 +05:30
aks07
3c4ac0e85e feat: supress click 2026-04-10 09:08:45 +05:30
aks07
87ba729a00 feat: minor change 2026-04-10 02:46:55 +05:30
aks07
f1ed7145e4 feat: add limit 2026-04-10 02:41:22 +05:30
aks07
bc15495e17 Merge branch 'main' of github.com:SigNoz/signoz into feat/span-details 2026-04-10 02:01:59 +05:30
aks07
f7d3012daf feat: api integration 2026-04-10 00:53:25 +05:30
Nikhil Soni
6ec9a2ec41 fix: use typed paramter field in logs 2026-04-09 21:39:29 +05:30
Nikhil Soni
9c056f809a fix: add timeout to module context 2026-04-09 20:03:47 +05:30
Nikhil Soni
c1d4273416 fix: rename timestamp to milli for readability 2026-04-09 16:54:44 +05:30
Nikhil Soni
618fe891d5 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-09 14:02:44 +05:30
Nikhil Soni
549c7e7034 chore: avoid sorting on every traversal 2026-04-09 11:38:55 +05:30
Nikhil Soni
dd65f83c3d chore: add same test cases as for old waterfall api 2026-04-09 11:38:55 +05:30
Nikhil Soni
8463a131fc refactor: convert waterfall api to modules format 2026-04-09 11:38:55 +05:30
Nikhil Soni
2d42518440 fix: remove unused fields and rename span type
To avoid confusing with otel span
2026-04-09 11:38:55 +05:30
Nikhil Soni
43d75a3853 fix: update span.attributes to map of string to any
To support otel format of diffrent types of attributes
2026-04-09 11:38:55 +05:30
Nikhil Soni
c5bb34e385 chore: add reason for using snake case in response 2026-04-09 11:38:54 +05:30
Nikhil Soni
6fd129991d refactor: move type conversion logic to types pkg 2026-04-09 11:38:54 +05:30
Nikhil Soni
9c5cca426a feat: setup types and interface for waterfall v3
v3 is required for udpating the response json of
the waterfall api. There wont' be any logical change.
Using this requirement as an opportunity to move
waterfall api to provider codebase architecture from
older query-service
2026-04-09 11:38:54 +05:30
aks07
a467efb97d feat: style fixes 2026-04-09 08:31:12 +05:30
aks07
58e2718090 feat: linked spans 2026-04-08 22:04:42 +05:30
aks07
65fee725c9 feat: key value label style fixes 2026-04-08 14:21:54 +05:30
aks07
ea87174088 feat: fix span details headr 2026-04-08 11:39:47 +05:30
aks07
627c483d86 feat: copy link change and url clear on span close 2026-04-07 20:06:53 +05:30
aks07
2533137db4 feat: use resizable for waterfall table as well 2026-04-07 15:55:06 +05:30
aks07
a774f8a4fe feat: add collapsible sections in trace details 2026-04-07 13:28:59 +05:30
aks07
8487f6cf66 feat: add bound to drags while floating 2026-04-01 19:33:53 +05:30
aks07
6ebe51126e feat: fix pinning. fix drag on top 2026-04-01 16:48:59 +05:30
aks07
ed64d5cd9f feat: replace draggable package 2026-03-31 21:33:42 +05:30
aks07
c04076e664 feat: span details folder rename 2026-03-31 17:50:02 +05:30
aks07
3c129e2c7d feat: span details floating drawer added 2026-03-31 17:44:25 +05:30
aks07
0ba51e2058 feat: json viewer with select dropdown added 2026-03-31 15:17:15 +05:30
aks07
cdc2ab134c feat: style fix 2026-03-26 20:27:41 +05:30
aks07
fb0c05b553 feat: refactor 2026-03-26 20:25:25 +05:30
aks07
68e9707e3b feat: search in pretty view 2026-03-26 20:08:25 +05:30
aks07
17ffaf9ccf feat: minor change 2026-03-26 19:42:48 +05:30
aks07
efec669b76 feat: update yarn lock 2026-03-26 19:31:10 +05:30
aks07
17b9e14d34 feat: added pretty view 2026-03-26 19:25:06 +05:30
aks07
2db9f969c3 feat: key attr section added 2026-03-26 15:01:16 +05:30
aks07
9fa466b124 feat: added span percentile 2026-03-26 14:33:06 +05:30
aks07
0c7768ebff feat: details field component 2026-03-25 03:10:50 +05:30
aks07
58dd51e92f feat: span details header 2026-03-25 02:22:11 +05:30
aks07
870c9bf6dc Merge branch 'main' of github.com:SigNoz/signoz into feat/span-details 2026-03-25 01:08:22 +05:30
aks07
7604956bf0 feat: span details init 2026-03-25 00:34:56 +05:30
aks07
66510e4919 feat: waterfall resizable 2026-03-18 20:58:01 +05:30
aks07
a1bf0e67db feat: event dots in trace details 2026-03-18 18:54:19 +05:30
aks07
a06046612a feat: connector line ux 2026-03-17 18:45:11 +05:30
aks07
31c9d4309b feat: move to service worker 2026-03-17 17:06:05 +05:30
aks07
7bef8b86c4 feat: subtree segregated tree 2026-03-17 14:39:57 +05:30
aks07
d26acd36a3 feat: subtree segregated tree 2026-03-17 13:55:58 +05:30
aks07
1cee595135 feat: subtree segregated tree 2026-03-17 13:29:07 +05:30
aks07
dd1868fcbc feat: row based flamegraph 2026-03-16 23:27:31 +05:30
aks07
a20beb8ba2 feat: span hover popover sync 2026-03-16 14:18:12 +05:30
aks07
998d652feb feat: fix hover option overflow 2026-03-16 10:19:42 +05:30
aks07
3695d3c180 feat: match span style 2026-03-13 15:53:46 +05:30
aks07
da175bafbc feat: add TimelineV3 ruler to waterfall header with padding fix
Add the TimelineV3 component to the sticky header of the waterfall's
right panel so timeline tick marks are visible. Add horizontal padding
to both the timeline header and span duration bars to prevent label
overflow/clipping at the edges.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:12:49 +05:30
aks07
021b187cbc feat: decouple waterfall left (span tree) and right (timeline bars) panels
Split the waterfall into two independent panels with a shared virtualizer
so deeply nested span names are visible via horizontal scroll in the left
panel. Left panel uses useReactTable + <table> for future column
extensibility; right panel uses plain divs for timeline bars. A draggable
resize handle separates the two panels.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 16:04:32 +05:30
aks07
f42b468597 feat: waterfall init 2026-03-11 17:15:27 +05:30
aks07
7e2cf57819 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-11 12:24:06 +05:30
aks07
dc9ebc5b26 feat: add test utils 2026-03-11 03:31:10 +05:30
aks07
398ab6e9d9 feat: add test cases for flamegraph 2026-03-11 03:28:35 +05:30
aks07
fec60671d8 feat: minor comment added 2026-03-11 03:03:49 +05:30
aks07
99259cc4e8 feat: remove unnecessary props 2026-03-10 16:01:24 +05:30
aks07
ca311717c2 feat: bg color for selected and hover spans 2026-03-06 23:16:35 +05:30
aks07
a614da2c65 fix: update color 2026-03-06 20:22:23 +05:30
aks07
ce18709002 fix: style fix 2026-03-06 20:03:42 +05:30
aks07
2b6977e891 feat: reduce timeline intervals 2026-03-06 20:01:50 +05:30
aks07
3e6eedbcab feat: fix style 2026-03-06 19:57:19 +05:30
aks07
fd9e3f0411 feat: fix style 2026-03-06 19:26:23 +05:30
aks07
e99465e030 Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-06 18:45:18 +05:30
aks07
9ad2db4b99 feat: scroll to selected span 2026-03-06 16:00:48 +05:30
aks07
07fd5f70ef feat: fix timerange unit selection when zoomed 2026-03-06 12:55:34 +05:30
aks07
ba79121795 feat: temp change 2026-03-06 12:49:06 +05:30
aks07
6e4e419b5e Merge branch 'main' of github.com:SigNoz/signoz into feat/trace-flamegraph 2026-03-06 10:30:41 +05:30
aks07
2f06afaf27 feat: handle click and hover with tooltip 2026-03-05 23:25:10 +05:30
aks07
f77c3cb23c feat: update span colors 2026-03-05 22:40:06 +05:30
aks07
9e3a8efcfc feat: zoom and drag added 2026-03-05 18:22:55 +05:30
aks07
8e325ba8b3 feat: added timeline v3 2026-03-05 12:31:17 +05:30
aks07
884f516766 feat: add text to spans 2026-03-03 12:20:06 +05:30
aks07
4bcbb4ffc3 feat: flamegraph canvas init 2026-03-02 19:21:23 +05:30
42 changed files with 1940 additions and 4036 deletions

View File

@@ -2547,8 +2547,7 @@ components:
format: double
type: number
meta:
additionalProperties:
type: string
additionalProperties: {}
nullable: true
type: object
status:
@@ -2598,103 +2597,6 @@ components:
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesNodeCondition:
enum:
- ready
- not_ready
- no_data
type: string
InframonitoringtypesNodeCountsByReadiness:
properties:
notReady:
type: integer
ready:
type: integer
required:
- ready
- notReady
type: object
InframonitoringtypesNodeRecord:
properties:
condition:
$ref: '#/components/schemas/InframonitoringtypesNodeCondition'
meta:
additionalProperties:
type: string
nullable: true
type: object
nodeCPU:
format: double
type: number
nodeCPUAllocatable:
format: double
type: number
nodeCountsByReadiness:
$ref: '#/components/schemas/InframonitoringtypesNodeCountsByReadiness'
nodeMemory:
format: double
type: number
nodeMemoryAllocatable:
format: double
type: number
nodeName:
type: string
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
required:
- nodeName
- condition
- nodeCountsByReadiness
- podCountsByPhase
- nodeCPU
- nodeCPUAllocatable
- nodeMemory
- nodeMemoryAllocatable
- meta
type: object
InframonitoringtypesNodes:
properties:
endTimeBeforeRetention:
type: boolean
records:
items:
$ref: '#/components/schemas/InframonitoringtypesNodeRecord'
nullable: true
type: array
requiredMetricsCheck:
$ref: '#/components/schemas/InframonitoringtypesRequiredMetricsCheck'
total:
type: integer
type:
$ref: '#/components/schemas/InframonitoringtypesResponseType'
warning:
$ref: '#/components/schemas/Querybuildertypesv5QueryWarnData'
required:
- type
- records
- total
- requiredMetricsCheck
- endTimeBeforeRetention
type: object
InframonitoringtypesPodCountsByPhase:
properties:
failed:
type: integer
pending:
type: integer
running:
type: integer
succeeded:
type: integer
unknown:
type: integer
required:
- pending
- running
- succeeded
- failed
- unknown
type: object
InframonitoringtypesPodPhase:
enum:
- pending
@@ -2702,15 +2604,18 @@ components:
- succeeded
- failed
- unknown
- no_data
- ""
type: string
InframonitoringtypesPodRecord:
properties:
failedPodCount:
type: integer
meta:
additionalProperties:
type: string
additionalProperties: {}
nullable: true
type: object
pendingPodCount:
type: integer
podAge:
format: int64
type: integer
@@ -2723,8 +2628,6 @@ components:
podCPURequest:
format: double
type: number
podCountsByPhase:
$ref: '#/components/schemas/InframonitoringtypesPodCountsByPhase'
podMemory:
format: double
type: number
@@ -2738,6 +2641,12 @@ components:
$ref: '#/components/schemas/InframonitoringtypesPodPhase'
podUID:
type: string
runningPodCount:
type: integer
succeededPodCount:
type: integer
unknownPodCount:
type: integer
required:
- podUID
- podCPU
@@ -2747,7 +2656,11 @@ components:
- podMemoryRequest
- podMemoryLimit
- podPhase
- podCountsByPhase
- pendingPodCount
- runningPodCount
- succeededPodCount
- failedPodCount
- unknownPodCount
- podAge
- meta
type: object
@@ -2801,32 +2714,6 @@ components:
- end
- limit
type: object
InframonitoringtypesPostableNodes:
properties:
end:
format: int64
type: integer
filter:
$ref: '#/components/schemas/Querybuildertypesv5Filter'
groupBy:
items:
$ref: '#/components/schemas/Querybuildertypesv5GroupByKey'
nullable: true
type: array
limit:
type: integer
offset:
type: integer
orderBy:
$ref: '#/components/schemas/Querybuildertypesv5OrderBy'
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
InframonitoringtypesPostablePods:
properties:
end:
@@ -11731,83 +11618,12 @@ paths:
summary: List Hosts for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/nodes:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes nodes with key metrics:
CPU usage, CPU allocatable, memory working set, memory allocatable, per-group
nodeCountsByReadiness ({ ready, notReady } from each node''s latest k8s.node.condition_ready
in the window) and per-group podCountsByPhase ({ pending, running, succeeded,
failed, unknown } for pods scheduled on the listed nodes). Each node includes
metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is
''list'' for the default k8s.node.name grouping (each row is one node with
its current condition string: ready / not_ready / no_data) or ''grouped_list''
for custom groupBy keys (each row aggregates nodes in the group; condition
stays no_data). Supports filtering via a filter expression, custom groupBy,
ordering by cpu / cpu_allocatable / memory / memory_allocatable, 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
(nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1
as a sentinel when no data is available for that field.'
operationId: ListNodes
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/InframonitoringtypesPostableNodes'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/InframonitoringtypesNodes'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: List Nodes for Infra Monitoring
tags:
- inframonitoring
/api/v2/infra_monitoring/pods:
post:
deprecated: false
description: 'Returns a paginated list of Kubernetes pods with key metrics:
CPU usage, CPU request/limit utilization, memory working set, memory request/limit
utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data),
utilization, current pod phase (pending/running/succeeded/failed/unknown),
and pod age (ms since start time). Each pod includes metadata attributes (namespace,
node, workload owner such as deployment/statefulset/daemonset/job/cronjob,
cluster). Supports filtering via a filter expression, custom groupBy to aggregate
@@ -11815,13 +11631,13 @@ paths:
cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit.
The response type is ''list'' for the default k8s.pod.uid grouping (each row
is one pod with its current phase) or ''grouped_list'' for custom groupBy
keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase:
{ pending, running, succeeded, failed, unknown } derived from each pod''s
latest phase in the window). Also reports missing required metrics and whether
the requested time range falls before the data retention boundary. Numeric
metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest,
podMemoryLimit, podAge) return -1 as a sentinel when no data is available
for that field.'
keys (each row aggregates pods in the group with per-phase counts: pendingPodCount,
runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived
from each pod''s latest phase in the window). Also reports missing required
metrics and whether the requested time range falls before the data retention
boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory,
podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no
data is available for that field.'
operationId: ListPods
requestBody:
content:

View File

@@ -3,7 +3,6 @@ import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
maxWorkers: '50%',
silent: true,
clearMocks: true,
coverageDirectory: 'coverage',

View File

@@ -241,12 +241,10 @@
},
"lint-staged": {
"*.(js|jsx|ts|tsx)": [
"oxlint --fix",
"oxfmt --write",
"sh -c tsgo --noEmit"
],
"*.(js|jsx|ts|tsx|scss|css)": [
"oxlint --fix --quiet --no-error-on-unmatched-pattern",
"oxfmt --write"
],
"*.(scss|css)": [
"stylelint"
]

View File

@@ -13,10 +13,8 @@ import type {
import type {
InframonitoringtypesPostableHostsDTO,
InframonitoringtypesPostableNodesDTO,
InframonitoringtypesPostablePodsDTO,
ListHosts200,
ListNodes200,
ListPods200,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
@@ -109,91 +107,7 @@ export const useListHosts = <
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, 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 (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.
* @summary List Nodes for Infra Monitoring
*/
export const listNodes = (
inframonitoringtypesPostableNodesDTO: BodyType<InframonitoringtypesPostableNodesDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListNodes200>({
url: `/api/v2/infra_monitoring/nodes`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: inframonitoringtypesPostableNodesDTO,
signal,
});
};
export const getListNodesMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationKey = ['listNodes'];
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 listNodes>>,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> }
> = (props) => {
const { data } = props ?? {};
return listNodes(data);
};
return { mutationFn, ...mutationOptions };
};
export type ListNodesMutationResult = NonNullable<
Awaited<ReturnType<typeof listNodes>>
>;
export type ListNodesMutationBody =
BodyType<InframonitoringtypesPostableNodesDTO>;
export type ListNodesMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary List Nodes for Infra Monitoring
*/
export const useListNodes = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof listNodes>>,
TError,
{ data: BodyType<InframonitoringtypesPostableNodesDTO> },
TContext
> => {
const mutationOptions = getListNodesMutationOptions(options);
return useMutation(mutationOptions);
};
/**
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.
* @summary List Pods for Infra Monitoring
*/
export const listPods = (

View File

@@ -4579,7 +4579,7 @@ export interface InframonitoringtypesHostFilterDTO {
* @nullable
*/
export type InframonitoringtypesHostRecordDTOMeta = {
[key: string]: string;
[key: string]: unknown;
} | null;
export interface InframonitoringtypesHostRecordDTO {
@@ -4652,127 +4652,35 @@ export interface InframonitoringtypesHostsDTO {
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export enum InframonitoringtypesNodeConditionDTO {
ready = 'ready',
not_ready = 'not_ready',
no_data = 'no_data',
}
export interface InframonitoringtypesNodeCountsByReadinessDTO {
/**
* @type integer
*/
notReady: number;
/**
* @type integer
*/
ready: number;
}
/**
* @nullable
*/
export type InframonitoringtypesNodeRecordDTOMeta = {
[key: string]: string;
} | null;
export interface InframonitoringtypesNodeRecordDTO {
condition: InframonitoringtypesNodeConditionDTO;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesNodeRecordDTOMeta;
/**
* @type number
* @format double
*/
nodeCPU: number;
/**
* @type number
* @format double
*/
nodeCPUAllocatable: number;
nodeCountsByReadiness: InframonitoringtypesNodeCountsByReadinessDTO;
/**
* @type number
* @format double
*/
nodeMemory: number;
/**
* @type number
* @format double
*/
nodeMemoryAllocatable: number;
/**
* @type string
*/
nodeName: string;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
}
export interface InframonitoringtypesNodesDTO {
/**
* @type boolean
*/
endTimeBeforeRetention: boolean;
/**
* @type array
* @nullable true
*/
records: InframonitoringtypesNodeRecordDTO[] | null;
requiredMetricsCheck: InframonitoringtypesRequiredMetricsCheckDTO;
/**
* @type integer
*/
total: number;
type: InframonitoringtypesResponseTypeDTO;
warning?: Querybuildertypesv5QueryWarnDataDTO;
}
export interface InframonitoringtypesPodCountsByPhaseDTO {
/**
* @type integer
*/
failed: number;
/**
* @type integer
*/
pending: number;
/**
* @type integer
*/
running: number;
/**
* @type integer
*/
succeeded: number;
/**
* @type integer
*/
unknown: number;
}
export enum InframonitoringtypesPodPhaseDTO {
pending = 'pending',
running = 'running',
succeeded = 'succeeded',
failed = 'failed',
unknown = 'unknown',
no_data = 'no_data',
'' = '',
}
/**
* @nullable
*/
export type InframonitoringtypesPodRecordDTOMeta = {
[key: string]: string;
[key: string]: unknown;
} | null;
export interface InframonitoringtypesPodRecordDTO {
/**
* @type integer
*/
failedPodCount: number;
/**
* @type object
* @nullable true
*/
meta: InframonitoringtypesPodRecordDTOMeta;
/**
* @type integer
*/
pendingPodCount: number;
/**
* @type integer
* @format int64
@@ -4793,7 +4701,6 @@ export interface InframonitoringtypesPodRecordDTO {
* @format double
*/
podCPURequest: number;
podCountsByPhase: InframonitoringtypesPodCountsByPhaseDTO;
/**
* @type number
* @format double
@@ -4814,6 +4721,18 @@ export interface InframonitoringtypesPodRecordDTO {
* @type string
*/
podUID: string;
/**
* @type integer
*/
runningPodCount: number;
/**
* @type integer
*/
succeededPodCount: number;
/**
* @type integer
*/
unknownPodCount: number;
}
export interface InframonitoringtypesPodsDTO {
@@ -4863,34 +4782,6 @@ export interface InframonitoringtypesPostableHostsDTO {
start: number;
}
export interface InframonitoringtypesPostableNodesDTO {
/**
* @type integer
* @format int64
*/
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type array
* @nullable true
*/
groupBy?: Querybuildertypesv5GroupByKeyDTO[] | null;
/**
* @type integer
*/
limit: number;
/**
* @type integer
*/
offset?: number;
orderBy?: Querybuildertypesv5OrderByDTO;
/**
* @type integer
* @format int64
*/
start: number;
}
export interface InframonitoringtypesPostablePodsDTO {
/**
* @type integer
@@ -9242,14 +9133,6 @@ export type ListHosts200 = {
status: string;
};
export type ListNodes200 = {
data: InframonitoringtypesNodesDTO;
/**
* @type string
*/
status: string;
};
export type ListPods200 = {
data: InframonitoringtypesPodsDTO;
/**

View File

@@ -589,16 +589,6 @@ function TanStackTableInner<TData>(
{showPagination && pagination && (
<div className={cx(viewStyles.paginationContainer, paginationClassname)}>
{prefixPaginationContent}
{pagination.showTotalCount && effectiveTotalCount > 0 && (
<span
className={viewStyles.paginationTotalCount}
data-testid="pagination-total-count"
>
Showing {(page - 1) * limit + 1} -{' '}
{Math.min(page * limit, effectiveTotalCount)} of {effectiveTotalCount}
{pagination.totalCountLabel ? ` ${pagination.totalCountLabel}` : ''}
</span>
)}
<Pagination
current={page}
pageSize={limit}

View File

@@ -117,10 +117,6 @@
justify-content: flex-end;
gap: 12px;
margin-top: 12px;
ul {
padding: 0;
}
}
.paginationPageSize {
@@ -128,11 +124,6 @@
--combobox-trigger-height: 2rem;
}
.paginationTotalCount {
font-size: var(--periscope-font-size-base);
color: var(--l1-foreground);
}
.tanstackLoadingOverlay {
position: absolute;
left: 50%;

View File

@@ -188,87 +188,6 @@ describe('TanStackTableView Integration', () => {
expect(screen.getByTestId('suffix-content')).toBeInTheDocument();
});
});
it('renders total count when showTotalCount is true', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
},
},
});
await waitFor(() => {
const totalCount = screen.getByTestId('pagination-total-count');
expect(totalCount).toBeInTheDocument();
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 100');
});
});
it('renders total count with label when totalCountLabel is provided', async () => {
renderTanStackTable({
props: {
pagination: {
total: 50,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
totalCountLabel: 'Pods',
},
},
});
await waitFor(() => {
const totalCount = screen.getByTestId('pagination-total-count');
expect(totalCount).toBeInTheDocument();
expect(totalCount).toHaveTextContent('Showing 1 - 10 of 50 Pods');
});
});
it('does not render total count when showTotalCount is false', async () => {
renderTanStackTable({
props: {
pagination: {
total: 100,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: false,
},
},
});
await waitFor(() => {
expect(screen.getByRole('navigation')).toBeInTheDocument();
});
expect(
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
it('does not render total count when total is 0', async () => {
renderTanStackTable({
props: {
pagination: {
total: 0,
defaultPage: 1,
defaultLimit: 10,
showTotalCount: true,
},
},
});
await waitFor(() => {
expect(screen.getByRole('table')).toBeInTheDocument();
});
expect(
screen.queryByTestId('pagination-total-count'),
).not.toBeInTheDocument();
});
});
describe('sorting', () => {

View File

@@ -115,8 +115,6 @@ export type PaginationProps = {
total: number;
defaultPage?: number;
defaultLimit?: number;
showTotalCount?: boolean;
totalCountLabel?: string;
};
export type TanstackTableQueryParamsConfig = {

View File

@@ -286,8 +286,6 @@ export function K8sBaseList<T extends K8sEntityData>({
total: totalCount,
defaultLimit: 10,
defaultPage: 1,
showTotalCount: true,
totalCountLabel: entity.charAt(0).toUpperCase() + entity.slice(1),
}}
paginationClassname={styles.paginationContainer}
/>

View File

@@ -1,241 +0,0 @@
/**
* Login - Session Context & Organization Tests
*
* Split from Login.test.tsx for better parallelization.
* Tests session context fetching and organization selection.
*/
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import Login from '../index';
import {
CALLBACK_AUTHN_ORG,
mockMultiOrgMixedAuth,
mockSingleOrgPasswordAuth,
mockVersionSetupCompleted,
PASSWORD_AUTHN_EMAIL,
PASSWORD_AUTHN_ORG,
SESSIONS_CONTEXT_ENDPOINT,
VERSION_ENDPOINT,
} from './Login.test-utils';
// =============================================================================
// MOCKS
// =============================================================================
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
},
},
}));
// =============================================================================
// TESTS
// =============================================================================
describe('Login - Session Context & Organization', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('Session Context Fetching', () => {
it('fetches session context on next button click and enables password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
});
});
it('handles session context API errors', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'internal_server',
message: 'couldnt fetch the sessions context',
url: '',
},
}),
),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
});
});
it('auto-selects organization when only one exists', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
});
});
});
describe('Organization Selection', () => {
it('shows organization dropdown when multiple orgs exist', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
const { getByTestId, getByText } = render(<Login />);
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('Organization Name')).toBeInTheDocument();
});
await screen.findByRole('combobox');
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
});
});
it('updates selected organization on dropdown change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
render(<Login />);
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await screen.findByRole('combobox');
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
await screen.findByRole('button', { name: /sign in with sso/i });
});
});
});

View File

@@ -1,127 +0,0 @@
/**
* Login - Initial Render & Setup Tests
*
* Split from Login.test.tsx for better parallelization.
* Tests initial render, loading states, and setup validation.
*/
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { rest, server } from 'mocks-server/server';
import { render, screen, waitFor } from 'tests/test-utils';
import Login from '../index';
import {
mockVersionSetupCompleted,
mockVersionSetupIncomplete,
VERSION_ENDPOINT,
} from './Login.test-utils';
// =============================================================================
// MOCKS
// =============================================================================
jest.mock('lib/history', () => ({
__esModule: true,
default: {
push: jest.fn(),
location: {
search: '',
},
},
}));
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
// =============================================================================
// TESTS
// =============================================================================
describe('Login - Initial Render & Setup', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
});
afterEach(() => {
server.resetHandlers();
});
describe('Initial Render', () => {
it('renders login form with email input and next button', () => {
const { getByTestId, getByPlaceholderText } = render(<Login />);
expect(
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
).toBeInTheDocument();
expect(getByTestId('email')).toBeInTheDocument();
expect(getByTestId('initiate_login')).toBeInTheDocument();
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
});
it('shows loading state when version data is being fetched', () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
const { getByTestId } = render(<Login />);
expect(getByTestId('initiate_login')).toBeDisabled();
});
});
describe('Setup Check', () => {
it('redirects to signup when setup is not completed', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
});
});
it('stays on login page when setup is completed', async () => {
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
it('handles version API error gracefully', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Server error' })),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
});
});

View File

@@ -1,172 +0,0 @@
/**
* Shared test utilities for Login tests.
* Extract common mocks, data, and setup to avoid duplication across split test files.
*/
import { rest, server } from 'mocks-server/server';
import { Info } from 'types/api/v1/version/get';
import { SessionsContext } from 'types/api/v2/sessions/context/get';
import { Token } from 'types/api/v2/sessions/email_password/post';
import { ErrorV2 } from 'types/api';
// =============================================================================
// CONSTANTS
// =============================================================================
export const VERSION_ENDPOINT = '*/api/v1/version';
export const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
export const EMAIL_PASSWORD_ENDPOINT = '*/api/v2/sessions/email_password';
export const CALLBACK_AUTHN_ORG = 'callback_authn_org';
export const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
export const PASSWORD_AUTHN_ORG = 'password_authn_org';
export const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
// =============================================================================
// MOCK DATA
// =============================================================================
export const mockVersionSetupCompleted: Info = {
setupCompleted: true,
ee: 'Y',
version: '0.25.0',
};
export const mockVersionSetupIncomplete: Info = {
setupCompleted: false,
ee: 'Y',
version: '0.25.0',
};
export const mockSingleOrgPasswordAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
],
};
export const mockSingleOrgCallbackAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
export const mockMultiOrgMixedAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: PASSWORD_AUTHN_ORG,
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: CALLBACK_AUTHN_ORG,
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
export const mockOrgWithWarning: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Warning Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
export const mockMultiOrgWithWarning: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Normal Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: 'Warning Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
export const mockEmailPasswordResponse: Token = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
};
// =============================================================================
// MOCK SETUP HELPERS
// =============================================================================
export function setupVersionEndpoint(
data: Info = mockVersionSetupCompleted,
): void {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data, status: 'success' })),
),
);
}
export function setupSessionContextEndpoint(data: SessionsContext): void {
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data })),
),
);
}
export function setupEmailPasswordEndpoint(
data: Token = mockEmailPasswordResponse,
): void {
server.use(
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data })),
),
);
}

View File

@@ -1,31 +1,20 @@
/**
* Login - Authentication & Form Tests
*
* Split from Login.test.tsx for better parallelization.
* Tests password auth, callback auth, URL params, warnings, form state, and edge cases.
*/
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { rest, server } from 'mocks-server/server';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { ErrorV2 } from 'types/api';
import { Info } from 'types/api/v1/version/get';
import { SessionsContext } from 'types/api/v2/sessions/context/get';
import { Token } from 'types/api/v2/sessions/email_password/post';
import Login from '../index';
import {
CALLBACK_AUTHN_URL,
EMAIL_PASSWORD_ENDPOINT,
mockEmailPasswordResponse,
mockMultiOrgWithWarning,
mockOrgWithWarning,
mockSingleOrgCallbackAuth,
mockSingleOrgPasswordAuth,
mockVersionSetupCompleted,
PASSWORD_AUTHN_EMAIL,
SESSIONS_CONTEXT_ENDPOINT,
VERSION_ENDPOINT,
} from './Login.test-utils';
// =============================================================================
// MOCKS
// =============================================================================
const VERSION_ENDPOINT = '*/api/v1/version';
const SESSIONS_CONTEXT_ENDPOINT = '*/api/v2/sessions/context';
const CALLBACK_AUTHN_ORG = 'callback_authn_org';
const CALLBACK_AUTHN_URL = 'https://sso.example.com/auth';
const PASSWORD_AUTHN_ORG = 'password_authn_org';
const PASSWORD_AUTHN_EMAIL = 'jest.test@signoz.io';
jest.mock('lib/history', () => ({
__esModule: true,
@@ -37,13 +26,102 @@ jest.mock('lib/history', () => ({
},
}));
// =============================================================================
// TESTS
// =============================================================================
const mockHistoryPush = history.push as jest.MockedFunction<
typeof history.push
>;
describe('Login - Authentication & Form', () => {
// Mock data
const mockVersionSetupCompleted: Info = {
setupCompleted: true,
ee: 'Y',
version: '0.25.0',
};
const mockVersionSetupIncomplete: Info = {
setupCompleted: false,
ee: 'Y',
version: '0.25.0',
};
const mockSingleOrgPasswordAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
],
};
const mockSingleOrgCallbackAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Test Organization',
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
const mockMultiOrgMixedAuth: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: PASSWORD_AUTHN_ORG,
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
},
{
id: 'org-2',
name: CALLBACK_AUTHN_ORG,
authNSupport: {
password: [],
callback: [{ provider: 'google', url: CALLBACK_AUTHN_URL }],
},
},
],
};
const mockOrgWithWarning: SessionsContext = {
exists: true,
orgs: [
{
id: 'org-1',
name: 'Warning Organization',
authNSupport: {
password: [{ provider: 'email_password' }],
callback: [],
},
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
const mockEmailPasswordResponse: Token = {
accessToken: 'mock-access-token',
refreshToken: 'mock-refresh-token',
};
describe('Login Component', () => {
beforeEach(() => {
jest.clearAllMocks();
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
@@ -58,6 +136,269 @@ describe('Login - Authentication & Form', () => {
server.resetHandlers();
});
describe('Initial Render', () => {
it('renders login form with email input and next button', () => {
const { getByTestId, getByPlaceholderText } = render(<Login />);
expect(
screen.getByText(/sign in to monitor, trace, and troubleshoot/i),
).toBeInTheDocument();
expect(getByTestId('email')).toBeInTheDocument();
expect(getByTestId('initiate_login')).toBeInTheDocument();
expect(getByPlaceholderText('e.g. john@signoz.io')).toBeInTheDocument();
});
it('shows loading state when version data is being fetched', () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
ctx.json({ data: mockVersionSetupCompleted, status: 'success' }),
),
),
);
const { getByTestId } = render(<Login />);
expect(getByTestId('initiate_login')).toBeDisabled();
});
});
describe('Setup Check', () => {
it('redirects to signup when setup is not completed', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ data: mockVersionSetupIncomplete, status: 'success' }),
),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).toHaveBeenCalledWith(ROUTES.SIGN_UP);
});
});
it('stays on login page when setup is completed', async () => {
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
it('handles version API error gracefully', async () => {
server.use(
rest.get(VERSION_ENDPOINT, (req, res, ctx) =>
res(ctx.status(500), ctx.json({ error: 'Server error' })),
),
);
render(<Login />);
await waitFor(() => {
expect(mockHistoryPush).not.toHaveBeenCalled();
});
});
});
describe('Session Context Fetching', () => {
it('fetches session context on next button click and enables password', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByTestId('password')).toBeInTheDocument();
});
});
it('handles session context API errors', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(
ctx.status(500),
ctx.json({
error: {
code: 'internal_server',
message: 'couldnt fetch the sessions context',
url: '',
},
}),
),
),
);
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('couldnt fetch the sessions context')).toBeInTheDocument();
});
});
it('auto-selects organization when only one exists', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
// Should show password field directly (no org selection needed)
expect(getByTestId('password')).toBeInTheDocument();
expect(screen.queryByText(/organization name/i)).not.toBeInTheDocument();
});
});
});
describe('Organization Selection', () => {
it('shows organization dropdown when multiple orgs exist', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await waitFor(() => {
expect(getByText('Organization Name')).toBeInTheDocument();
});
await screen.findByRole('combobox');
// Click on the dropdown to reveal the options
await user.click(screen.getByRole('combobox'));
await waitFor(() => {
expect(screen.getByText(PASSWORD_AUTHN_ORG)).toBeInTheDocument();
expect(screen.getByText(CALLBACK_AUTHN_ORG)).toBeInTheDocument();
});
});
it('updates selected organization on dropdown change', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgMixedAuth })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
return input;
});
await user.type(emailInput, PASSWORD_AUTHN_EMAIL);
const nextButton = await waitFor(() => {
const button = screen.getByTestId('initiate_login');
expect(button).not.toBeDisabled();
return button;
});
await user.click(nextButton);
await screen.findByRole('combobox');
// Select CALLBACK_AUTHN_ORG
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText(CALLBACK_AUTHN_ORG));
await screen.findByRole('button', { name: /sign in with sso/i });
});
});
describe('Password Authentication', () => {
it('shows password field when password auth is supported', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
@@ -70,6 +411,7 @@ describe('Login - Authentication & Form', () => {
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -106,6 +448,7 @@ describe('Login - Authentication & Form', () => {
initialRoute: '/login?password=Y',
});
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -123,6 +466,7 @@ describe('Login - Authentication & Form', () => {
await user.click(nextButton);
await waitFor(() => {
// Should show password field even for SSO org due to password=Y override
expect(getByTestId('password')).toBeInTheDocument();
});
});
@@ -140,6 +484,7 @@ describe('Login - Authentication & Form', () => {
const { getByTestId, queryByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -165,7 +510,10 @@ describe('Login - Authentication & Form', () => {
it('redirects to callback URL on button click', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const mockLocation = { href: 'http://localhost/' };
// Mock window.location.href
const mockLocation = {
href: 'http://localhost/',
};
Object.defineProperty(window, 'location', {
value: mockLocation,
writable: true,
@@ -179,6 +527,7 @@ describe('Login - Authentication & Form', () => {
const { getByTestId, queryByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -203,6 +552,7 @@ describe('Login - Authentication & Form', () => {
const callbackButton = getByTestId('callback_authn_submit');
await user.click(callbackButton);
// Check that window.location.href was set to the callback URL
await waitFor(() => {
expect(window.location.href).toBe(CALLBACK_AUTHN_URL);
});
@@ -217,7 +567,7 @@ describe('Login - Authentication & Form', () => {
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
rest.post(EMAIL_PASSWORD_ENDPOINT, async (_, res, ctx) =>
rest.post('*/api/v2/sessions/email_password', async (_, res, ctx) =>
res(
ctx.status(200),
ctx.json({ status: 'success', data: mockEmailPasswordResponse }),
@@ -227,6 +577,7 @@ describe('Login - Authentication & Form', () => {
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -253,6 +604,8 @@ describe('Login - Authentication & Form', () => {
await user.type(passwordInput, 'testpassword');
await user.click(loginButton);
// do not test for the request paramters here. Reference: https://mswjs.io/docs/best-practices/avoid-request-assertions
// rather test for the effects of the request
await waitFor(() => {
expect(localStorage.getItem('AUTH_TOKEN')).toBe('mock-access-token');
});
@@ -265,7 +618,7 @@ describe('Login - Authentication & Form', () => {
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
rest.post(EMAIL_PASSWORD_ENDPOINT, (_, res, ctx) =>
rest.post('*/api/v2/sessions/email_password', (_, res, ctx) =>
res(
ctx.status(401),
ctx.json({
@@ -281,6 +634,7 @@ describe('Login - Authentication & Form', () => {
const { getByTestId, getByText } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -354,13 +708,14 @@ describe('Login - Authentication & Form', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockOrgWithWarning })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -387,6 +742,23 @@ describe('Login - Authentication & Form', () => {
it('shows warning modal when a warning org is selected among multiple orgs', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
// Mock multiple orgs including one with a warning
const mockMultiOrgWithWarning = {
orgs: [
{ id: 'org1', name: 'Org 1' },
{
id: 'org2',
name: 'Org 2',
warning: {
code: 'ORG_WARNING',
message: 'Organization has limited access',
url: 'https://example.com/warning',
errors: [{ message: 'Contact admin for full access' }],
} as ErrorV2,
},
],
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockMultiOrgWithWarning })),
@@ -395,6 +767,7 @@ describe('Login - Authentication & Form', () => {
const { getByTestId } = render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = getByTestId('email');
expect(input).not.toBeDisabled();
@@ -413,8 +786,9 @@ describe('Login - Authentication & Form', () => {
await screen.findByRole('combobox');
// Select the organization with a warning
await user.click(screen.getByRole('combobox'));
await user.click(screen.getByText('Warning Organization'));
await user.click(screen.getByText('Org 2'));
await waitFor(() => {
expect(
@@ -429,7 +803,7 @@ describe('Login - Authentication & Form', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(
ctx.delay(100),
ctx.status(200),
@@ -440,6 +814,7 @@ describe('Login - Authentication & Form', () => {
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -456,6 +831,7 @@ describe('Login - Authentication & Form', () => {
await user.click(nextButton);
// Button should be disabled during API call
expect(nextButton).toBeDisabled();
});
@@ -463,15 +839,17 @@ describe('Login - Authentication & Form', () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockSingleOrgPasswordAuth })),
),
);
render(<Login />);
// Initially shows "Next" button
expect(screen.getByTestId('initiate_login')).toBeInTheDocument();
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -489,6 +867,7 @@ describe('Login - Authentication & Form', () => {
await user.click(nextButton);
await waitFor(() => {
// Should show "Sign in with Password" button for password auth
expect(screen.getByTestId('password_authn_submit')).toBeInTheDocument();
expect(screen.queryByTestId('initiate_login')).not.toBeInTheDocument();
});
@@ -505,13 +884,14 @@ describe('Login - Authentication & Form', () => {
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNoOrgs })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -529,6 +909,7 @@ describe('Login - Authentication & Form', () => {
await user.click(nextButton);
await waitFor(() => {
// Should not show any auth method buttons
expect(
screen.queryByTestId('password_authn_submit'),
).not.toBeInTheDocument();
@@ -556,13 +937,14 @@ describe('Login - Authentication & Form', () => {
};
server.use(
rest.get(SESSIONS_CONTEXT_ENDPOINT, (_, res, ctx) =>
rest.get(SESSIONS_CONTEXT_ENDPOINT, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ data: mockNoAuthSupport })),
),
);
render(<Login />);
// Wait for version API to complete (email input becomes enabled)
const emailInput = await waitFor(() => {
const input = screen.getByTestId('email');
expect(input).not.toBeDisabled();
@@ -580,6 +962,7 @@ describe('Login - Authentication & Form', () => {
await user.click(nextButton);
await waitFor(() => {
// Should not show any auth method buttons
expect(
screen.queryByTestId('password_authn_submit'),
).not.toBeInTheDocument();

View File

@@ -1,6 +1,5 @@
import { ApiRoutingPolicy } from 'api/routingPolicies/getRoutingPolicies';
import { IAppContext } from 'providers/App/types';
import { getAppContextMockMinimal } from 'tests/test-utils';
import { IAppContext, IUser } from 'providers/App/types';
import { Channels } from 'types/api/channels/getAll';
import { RoutingPolicy, UseRoutingPoliciesReturn } from '../types';
@@ -79,14 +78,49 @@ export function getUseRoutingPoliciesMockData(
};
}
/**
* @deprecated Use getAppContextMockMinimal from 'tests/test-utils' directly.
* This is a backwards-compatible wrapper that will be removed in a future version.
*/
export function getAppContextMockState(
overrides?: Partial<IAppContext['user']>,
overrides?: Partial<IUser>,
): IAppContext {
return getAppContextMockMinimal(overrides);
return {
user: {
accessJwt: 'some-token',
refreshJwt: 'some-refresh-token',
id: 'some-user-id',
email: 'user@signoz.io',
displayName: 'John Doe',
createdAt: 1732544623,
organization: 'Nightswatch',
orgId: 'does-not-matter-id',
role: 'ADMIN',
...overrides,
},
activeLicense: null,
trialInfo: null,
featureFlags: null,
orgPreferences: null,
userPreferences: null,
isLoggedIn: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
featureFlagsFetchError: undefined,
orgPreferencesFetchError: undefined,
changelog: null,
showChangelogModal: false,
activeLicenseRefetch: jest.fn(),
updateUser: jest.fn(),
updateOrgPreferences: jest.fn(),
updateUserPreferenceInContext: jest.fn(),
updateOrg: jest.fn(),
updateChangelog: jest.fn(),
toggleChangelogModal: jest.fn(),
versionData: null,
hasEditPermission: false,
};
}
export function mockLocation(pathname: string): jest.Mock {

View File

@@ -51,10 +51,7 @@ import { Span } from 'types/api/trace/getTraceV2';
import { formatEpochTimestamp } from 'utils/timeUtils';
import Attributes from './Attributes/Attributes';
import {
RelatedSignalsViews,
SPAN_PERCENTILE_INITIAL_DELAY_MS,
} from './constants';
import { RelatedSignalsViews } from './constants';
import EventAttribute from './Events/components/EventAttribute';
import Events from './Events/Events';
import LinkedSpans from './LinkedSpans/LinkedSpans';
@@ -413,7 +410,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
const timer = setTimeout(() => {
setInitialWaitCompleted(true);
}, SPAN_PERCENTILE_INITIAL_DELAY_MS);
}, 2000); // 2-second delay
return (): void => {
// clean the old state around span percentile data

View File

@@ -9,12 +9,6 @@ import { Span } from 'types/api/trace/getTraceV2';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
// Mock delay constant for faster tests
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
// Mock external dependencies
const mockRedirectWithQueryBuilderData = jest.fn();
const mockNotifications = {

View File

@@ -1,5 +1,3 @@
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import ROUTES from 'constants/routes';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
@@ -8,13 +6,6 @@ import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import {
mockSpanPercentileResponse,
mockUserPreferenceResponse,
} from './SpanDetailsDrawer.test-utils';
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
const mockGetUserPreference = jest.mocked(getUserPreference);
import {
expectedHostOnlyMetadata,
expectedInfraMetadata,
@@ -31,21 +22,6 @@ import {
} from './infraMetricsTestData';
// Mock external dependencies
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
@@ -196,10 +172,6 @@ describe('SpanDetailsDrawer - Infra Metrics', () => {
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup default mocks for percentile APIs to avoid delays
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
// Setup API call tracking for infra metrics
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
apiCallHistory.push(query);

View File

@@ -1,414 +0,0 @@
/**
* SpanDetailsDrawer - Logs Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests logs tab display, API queries, navigation, and highlighting.
*/
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { screen, userEvent, waitFor } from 'tests/test-utils';
import {
expectedAfterFilterExpression,
expectedBeforeFilterExpression,
expectedSpanFilterExpression,
expectedTraceOnlyFilterExpression,
mockAfterLogsResponse,
mockBeforeLogsResponse,
mockSpanLogsResponse,
} from './mockData';
import {
ApiCallHistory,
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
clearAllMocks,
createApiCallHistory,
mockSafeNavigate,
mockSpanPercentileResponse,
mockUpdateAllQueriesOperators,
mockUserPreferenceResponse,
mockWindowOpen,
renderSpanDetailsDrawer,
setupLogsApiMock,
setupSpanDetailsDrawerMocks,
} from './SpanDetailsDrawer.test-utils';
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
const mockGetUserPreference = jest.mocked(getUserPreference);
// =============================================================================
// MOCK SETUP
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: ROUTES.TRACE_DETAIL,
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock(
'components/Logs/RawLogView',
() =>
function MockRawLogView({
data,
onLogClick,
isHighlighted,
helpTooltip,
}: {
data: any;
onLogClick: (data: any, event: React.MouseEvent) => void;
isHighlighted: boolean;
helpTooltip: string;
}): JSX.Element {
return (
<div
data-testid={`raw-log-${data.id}`}
className={isHighlighted ? 'log-highlighted' : 'log-context'}
title={helpTooltip}
onClick={(e): void => onLogClick?.(data, e)}
>
<div>{data.body}</div>
<div>{data.timestamp}</div>
</div>
);
},
);
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Logs', () => {
let apiCallHistory: ApiCallHistory;
beforeEach(() => {
jest.useRealTimers();
clearAllMocks();
setupSpanDetailsDrawerMocks();
// Setup percentile API mocks to avoid delays
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
apiCallHistory = createApiCallHistory();
setupLogsApiMock(
apiCallHistory,
mockSpanLogsResponse,
mockBeforeLogsResponse,
mockAfterLogsResponse,
);
});
afterEach(() => {
server.resetHandlers();
});
it('should display logs tab in right sidebar when span is selected', async () => {
renderSpanDetailsDrawer();
const logsButton = screen.getByRole('button', { name: /logs/i });
expect(logsButton).toBeInTheDocument();
expect(logsButton).toBeVisible();
});
it(
'should open related logs view when logs tab is clicked',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('overlay-scrollbar')).toBeInTheDocument();
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
expect(
screen.getByTestId('raw-log-context-log-before'),
).toBeInTheDocument();
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
});
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should make 3 API queries when logs tab is opened',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
});
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
trace_only_logs: traceOnlyQuery,
} = apiCallHistory;
expect((spanQuery as any).query.builder.queryData[0].filter.expression).toBe(
expectedSpanFilterExpression,
);
expect(
(beforeQuery as any).query.builder.queryData[0].filter.expression,
).toBe(expectedBeforeFilterExpression);
expect(
(afterQuery as any).query.builder.queryData[0].filter.expression,
).toBe(expectedAfterFilterExpression);
if (traceOnlyQuery) {
expect(traceOnlyQuery.query.builder.queryData[0].filter.expression).toBe(
expectedTraceOnlyFilterExpression,
);
}
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should use correct timestamp ordering for different query types',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
});
const {
span_logs: spanQuery,
before_logs: beforeQuery,
after_logs: afterQuery,
} = apiCallHistory;
expect((spanQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
'desc',
);
expect(
(beforeQuery as any).query.builder.queryData[0].orderBy[0].order,
).toBe('desc');
expect((afterQuery as any).query.builder.queryData[0].orderBy[0].order).toBe(
'asc',
);
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should navigate to logs explorer with span filters when span log is clicked',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
});
const spanLog = screen.getByTestId('raw-log-span-log-1');
await user.click(spanLog);
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(ROUTES.LOGS_EXPLORER),
'_blank',
);
});
const navigationCall = mockWindowOpen.mock.calls[0][0];
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
expect(urlParams.get(QueryParams.activeLogId)).toBe('"span-log-1"');
expect(urlParams.get(QueryParams.startTime)).toBe('1640994900000');
expect(urlParams.get(QueryParams.endTime)).toBe('1640995560000');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
"trace_id = 'test-trace-id'",
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should navigate to logs explorer with trace filter when context log is clicked',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(
screen.getByTestId('raw-log-context-log-before'),
).toBeInTheDocument();
});
const contextLog = screen.getByTestId('raw-log-context-log-before');
await user.click(contextLog);
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(ROUTES.LOGS_EXPLORER),
'_blank',
);
});
const navigationCall = mockWindowOpen.mock.calls[0][0];
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
expect(urlParams.get(QueryParams.activeLogId)).toBe('"context-log-before"');
const compositeQuery = JSON.parse(
urlParams.get(QueryParams.compositeQuery) || '{}',
);
expect(compositeQuery.builder.queryData[0].filter.expression).toContain(
"trace_id = 'test-trace-id'",
);
expect(compositeQuery.builder.queryData[0].filter.expression).not.toContain(
'span_id',
);
expect(mockSafeNavigate).not.toHaveBeenCalled();
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should always open logs explorer in new tab regardless of click type',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
});
const spanLog = screen.getByTestId('raw-log-span-log-1');
await user.click(spanLog);
await waitFor(() => {
expect(mockWindowOpen).toHaveBeenCalledWith(
expect.stringContaining(ROUTES.LOGS_EXPLORER),
'_blank',
);
});
expect(mockSafeNavigate).not.toHaveBeenCalled();
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
it(
'should display span logs as highlighted and context logs as regular',
async () => {
renderSpanDetailsDrawer();
const user = userEvent.setup({ pointerEventsCheck: 0 });
const logsButton = screen.getByRole('button', { name: /logs/i });
await user.click(logsButton);
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(3);
});
await waitFor(() => {
expect(screen.getByTestId('raw-log-span-log-1')).toBeInTheDocument();
expect(screen.getByTestId('raw-log-span-log-2')).toBeInTheDocument();
expect(
screen.getByTestId('raw-log-context-log-before'),
).toBeInTheDocument();
expect(screen.getByTestId('raw-log-context-log-after')).toBeInTheDocument();
});
const spanLog1 = screen.getByTestId('raw-log-span-log-1');
const spanLog2 = screen.getByTestId('raw-log-span-log-2');
expect(spanLog1).toHaveClass('log-highlighted');
expect(spanLog2).toHaveClass('log-highlighted');
expect(spanLog1).toHaveAttribute(
'title',
'This log belongs to the current span',
);
const contextLogBefore = screen.getByTestId('raw-log-context-log-before');
const contextLogAfter = screen.getByTestId('raw-log-context-log-after');
expect(contextLogBefore).toHaveClass('log-context');
expect(contextLogAfter).toHaveClass('log-context');
expect(contextLogBefore).not.toHaveAttribute('title');
},
CI_SENSITIVE_LOGS_TEST_TIMEOUT,
);
});

View File

@@ -1,565 +0,0 @@
/**
* SpanDetailsDrawer - Span Percentile Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests percentile display, expansion, time range selection, and resource attributes.
*/
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { SuccessResponseV2 } from 'types/api';
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import { mockEmptyLogsResponse, mockSpan } from './mockData';
// =============================================================================
// TYPED MOCKS (defined before jest.mock for proper hoisting)
// =============================================================================
const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
const mockGetUserPreference = jest.mocked(getUserPreference);
const mockSafeNavigate = jest.fn();
// =============================================================================
// JEST MOCKS
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: '/trace',
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filter: { expression: "trace_id = 'test-trace-id'" },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
});
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// MOCK DATA
// =============================================================================
const mockSpanPercentileResponse = {
httpStatusCode: 200 as const,
data: {
percentiles: {
p50: 500000000,
p90: 1000000000,
p95: 1500000000,
p99: 2000000000,
},
position: {
percentile: 75.5,
description: 'This span is in the 75th percentile',
},
},
};
const mockUserPreferenceResponse = {
statusCode: 200,
httpStatusCode: 200,
error: null,
message: 'Success',
data: {
name: 'span_percentile_resource_attributes',
description: 'Resource attributes for span percentile calculation',
valueType: 'array',
defaultValue: [],
value: ['service.name', 'name', 'http.method'],
allowedValues: [],
allowedScopes: [],
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
},
};
const mockSpanPercentileErrorResponse = {
httpStatusCode: 500,
data: null,
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
// =============================================================================
// CONSTANTS
// =============================================================================
const P75_TEXT = 'p75';
const SPAN_PERCENTILE_TEXT = 'Span Percentile';
const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER = 'Search resource attributes';
// =============================================================================
// RENDER HELPER
// =============================================================================
const mockQueryBuilderContextValue = {
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
stagedQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
panelType: 'list',
redirectWithQuery: jest.fn(),
handleRunQuery: jest.fn(),
handleStageQuery: jest.fn(),
resetQuery: jest.fn(),
};
function renderSpanDetailsDrawer(): void {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpan}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
}
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Span Percentile Functionality', () => {
beforeEach(() => {
jest.useRealTimers();
jest.clearAllMocks();
mockSafeNavigate.mockClear();
mockUpdateAllQueriesOperators.mockClear();
// Setup default mocks
mockGetUserPreference.mockResolvedValue(mockUserPreferenceResponse);
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileResponse);
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
Promise.resolve(mockEmptyLogsResponse),
);
});
afterEach(() => {
server.resetHandlers();
});
it('should display span percentile value after successful API call', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
});
it('should call API with correct parameters', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(mockGetSpanPercentiles).toHaveBeenCalled();
});
expect(mockGetSpanPercentiles).toHaveBeenCalledWith({
start: expect.any(Number),
end: expect.any(Number),
spanDuration: mockSpan.durationNano,
serviceName: mockSpan.serviceName,
name: mockSpan.name,
resourceAttributes: expect.any(Object),
});
});
it('should handle user preference loading', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(mockGetUserPreference).toHaveBeenCalledWith({
name: 'span_percentile_resource_attributes',
});
});
});
it('should show loading spinner while fetching percentile data', async () => {
mockGetSpanPercentiles.mockImplementation(
() =>
new Promise((resolve) => {
setTimeout(() => resolve(mockSpanPercentileResponse), 1000);
}),
);
renderSpanDetailsDrawer();
await waitFor(() => {
const spinnerContainer = document.querySelector(
'.loading-spinner-container',
);
expect(spinnerContainer).toBeInTheDocument();
});
});
it('should handle API error gracefully', async () => {
mockGetSpanPercentiles.mockResolvedValue(mockSpanPercentileErrorResponse);
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
});
});
it('should not display percentile value when API returns non-200 status', async () => {
mockGetSpanPercentiles.mockResolvedValue({
httpStatusCode: 500 as const,
data: null,
} as unknown as Awaited<ReturnType<typeof getSpanPercentiles>>);
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.queryByText(/p\d+/)).not.toBeInTheDocument();
});
});
it('should handle empty percentile data gracefully', async () => {
mockGetSpanPercentiles.mockResolvedValue({
httpStatusCode: 200,
data: {
percentiles: {},
position: {
percentile: 0,
description: '',
},
},
});
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText('p0')).toBeInTheDocument();
});
});
it('should display tooltip with correct content', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.mouseEnter(percentileValue);
await waitFor(() => {
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
expect(screen.getByText(/out of the distribution/)).toBeInTheDocument();
expect(
screen.getByText(/evaluated for 1 hour\(s\) since the span start time/),
).toBeInTheDocument();
expect(screen.getByText('Click to learn more')).toBeInTheDocument();
});
});
it('should expand percentile details when percentile value is clicked', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
expect(screen.getByText(/This span duration is/)).toBeInTheDocument();
expect(
screen.getByText(/out of the distribution for this resource/),
).toBeInTheDocument();
});
});
it('should display percentile table with correct values', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText('Percentile')).toBeInTheDocument();
expect(screen.getByText('Duration')).toBeInTheDocument();
});
expect(screen.getByText('p50')).toBeInTheDocument();
expect(screen.getByText('p90')).toBeInTheDocument();
expect(screen.getByText('p95')).toBeInTheDocument();
expect(screen.getByText('p99')).toBeInTheDocument();
expect(screen.getAllByText(P75_TEXT)).toHaveLength(3);
expect(screen.getAllByText(/this span/i).length).toBeGreaterThan(0);
});
it('should allow time range selection and trigger API call', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const timeRangeSelector = screen.getByRole('combobox');
expect(timeRangeSelector).toBeInTheDocument();
expect(screen.getByText(/1.*hour/i)).toBeInTheDocument();
await waitFor(() => {
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
expect.objectContaining({
start: expect.any(Number),
end: expect.any(Number),
spanDuration: mockSpan.durationNano,
serviceName: mockSpan.serviceName,
name: mockSpan.name,
resourceAttributes: expect.any(Object),
}),
);
});
});
it('should show resource attributes selector when plus icon is clicked', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
fireEvent.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
});
it('should filter resource attributes based on search query', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
fireEvent.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
const searchInput = screen.getByPlaceholderText(
SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER,
);
fireEvent.change(searchInput, { target: { value: 'http' } });
expect(screen.getAllByText('http.method').length).toBeGreaterThan(0);
expect(screen.getAllByText(SPAN_ATTRIBUTES.HTTP_URL).length).toBeGreaterThan(
0,
);
expect(screen.getAllByText('http.status_code').length).toBeGreaterThan(0);
});
it('should handle resource attribute selection and trigger API call', async () => {
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
fireEvent.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
fireEvent.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
const httpMethodCheckbox = screen.getByRole('checkbox', {
name: /http\.method/i,
});
fireEvent.click(httpMethodCheckbox);
await waitFor(() => {
expect(mockGetSpanPercentiles).toHaveBeenCalledWith(
expect.objectContaining({
resourceAttributes: expect.objectContaining({
'http.method': 'GET',
}),
}),
);
});
});
it('should close resource attributes selector when check icon is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderSpanDetailsDrawer();
await waitFor(() => {
expect(screen.getByText(P75_TEXT)).toBeInTheDocument();
});
const percentileValue = screen.getByText(P75_TEXT);
await user.click(percentileValue);
await waitFor(() => {
expect(screen.getByText(SPAN_PERCENTILE_TEXT)).toBeInTheDocument();
});
const plusIcon = screen.getByTestId('plus-icon');
await user.click(plusIcon);
await waitFor(() => {
expect(
screen.getByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).toBeInTheDocument();
});
const checkIcon = screen.getByTestId('check-icon');
await user.click(checkIcon);
await waitFor(() => {
expect(
screen.queryByPlaceholderText(SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER),
).not.toBeInTheDocument();
});
});
});

View File

@@ -1,188 +0,0 @@
/**
* SpanDetailsDrawer - Search Visibility Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests search functionality in the attributes tab.
*/
import { SPAN_ATTRIBUTES } from 'container/ApiMonitoring/Explorer/Domains/DomainDetails/constants';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { fireEvent, screen, userEvent, waitFor } from 'tests/test-utils';
import { mockEmptyLogsResponse } from './mockData';
import {
clearAllMocks,
mockSafeNavigate,
mockUpdateAllQueriesOperators,
renderSpanDetailsDrawer,
SEARCH_PLACEHOLDER,
setupSpanDetailsDrawerMocks,
} from './SpanDetailsDrawer.test-utils';
// =============================================================================
// MOCK SETUP
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
// Mock external dependencies
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: '/trace',
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
// Mock getSpanPercentiles API
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
// Mock getUserPreference API
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
// Mock PreferenceContextProvider
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Search Visibility User Flows', () => {
beforeEach(() => {
jest.useRealTimers();
clearAllMocks();
setupSpanDetailsDrawerMocks();
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
Promise.resolve(mockEmptyLogsResponse),
);
});
afterEach(() => {
server.resetHandlers();
});
// Journey 1: Default Search Visibility
it('should display search visible by default when user opens span details', () => {
renderSpanDetailsDrawer();
// User sees search input in the Attributes tab by default
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
expect(searchInput).toBeInTheDocument();
expect(searchInput).toBeVisible();
});
it('should filter attributes when user types in search', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
renderSpanDetailsDrawer();
// User sees all attributes initially
expect(screen.getByText('http.method')).toBeInTheDocument();
expect(screen.getByText(SPAN_ATTRIBUTES.HTTP_URL)).toBeInTheDocument();
expect(screen.getByText('http.status_code')).toBeInTheDocument();
// User types "method" in search
const searchInput = screen.getByPlaceholderText(SEARCH_PLACEHOLDER);
await user.type(searchInput, 'method');
// User sees only matching attributes
await waitFor(() => {
expect(screen.getByText('http.method')).toBeInTheDocument();
expect(screen.queryByText(SPAN_ATTRIBUTES.HTTP_URL)).not.toBeInTheDocument();
expect(screen.queryByText('http.status_code')).not.toBeInTheDocument();
});
});
// Journey 2: Search Toggle & Focus Management
it('should hide search when user clicks search icon', () => {
renderSpanDetailsDrawer();
// User sees search initially
expect(screen.getByPlaceholderText(SEARCH_PLACEHOLDER)).toBeInTheDocument();
// User clicks search icon to hide search
const tabBar = screen.getByRole('tablist');
const searchIcon = tabBar.querySelector('.search-icon');
if (searchIcon) {
fireEvent.click(searchIcon);
}
// Search is now hidden
expect(
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
).not.toBeInTheDocument();
});
it('should show and focus search when user clicks search icon again', () => {
renderSpanDetailsDrawer();
// User clicks search icon to hide
const tabBar = screen.getByRole('tablist');
const searchIcon = tabBar.querySelector('.search-icon');
if (searchIcon) {
fireEvent.click(searchIcon);
}
// Search is hidden
expect(
screen.queryByPlaceholderText(SEARCH_PLACEHOLDER),
).not.toBeInTheDocument();
// User clicks search icon again to show
if (searchIcon) {
fireEvent.click(searchIcon);
}
// Search appears and receives focus
const searchInput = screen.getByPlaceholderText(
SEARCH_PLACEHOLDER,
) as HTMLInputElement;
expect(searchInput).toBeInTheDocument();
expect(searchInput).toHaveFocus();
});
});

View File

@@ -1,228 +0,0 @@
/**
* SpanDetailsDrawer - Status Message Truncation Tests
*
* Split from SpanDetailsDrawer.test.tsx for better parallelization.
* Tests status message display and expandable popover functionality.
*/
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { server } from 'mocks-server/server';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import {
mockEmptyLogsResponse,
mockSpanWithLongStatusMessage,
mockSpanWithShortStatusMessage,
} from './mockData';
import {
clearAllMocks,
mockQueryBuilderContextValue,
mockSafeNavigate,
mockUpdateAllQueriesOperators,
setupSpanDetailsDrawerMocks,
} from './SpanDetailsDrawer.test-utils';
// =============================================================================
// MOCK SETUP
// =============================================================================
jest.mock('container/SpanDetailsDrawer/constants', () => ({
...jest.requireActual('container/SpanDetailsDrawer/constants'),
SPAN_PERCENTILE_INITIAL_DELAY_MS: 0,
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string; search: string } => ({
pathname: '/trace',
search: 'trace_id=test-trace-id',
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): { safeNavigate: jest.MockedFunction<() => void> } => ({
safeNavigate: mockSafeNavigate,
}),
}));
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: (): any => ({
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
}),
}));
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
jest.mock('api/trace/getSpanPercentiles', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/v1/user/preferences/name/get', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock(
'container/SpanDetailsDrawer/Events/components/AttributeWithExpandablePopover',
() =>
function AttributeWithExpandablePopover({
attributeKey,
attributeValue,
onExpand,
}: {
attributeKey: string;
attributeValue: string;
onExpand: (title: string, content: string) => void;
}): JSX.Element {
return (
<div className="attribute-container" key={attributeKey}>
<div className="attribute-key">{attributeKey}</div>
<div className="wrapper">
<div className="attribute-value">{attributeValue}</div>
<div data-testid="popover-content">
<pre>{attributeValue}</pre>
<button
type="button"
onClick={(): void => onExpand(attributeKey, attributeValue)}
>
Expand
</button>
</div>
</div>
</div>
);
},
);
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
PreferenceContextProvider: ({
children,
}: {
children: React.ReactNode;
}): JSX.Element => <div>{children}</div>,
}));
// =============================================================================
// TESTS
// =============================================================================
describe('SpanDetailsDrawer - Status Message Truncation User Flows', () => {
beforeEach(() => {
jest.useRealTimers();
clearAllMocks();
setupSpanDetailsDrawerMocks();
(GetMetricQueryRange as jest.Mock).mockImplementation(() =>
Promise.resolve(mockEmptyLogsResponse),
);
});
afterEach(() => {
server.resetHandlers();
});
it('should display expandable popover with Expand button for long status message', () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithLongStatusMessage}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// User sees status message label
expect(screen.getByText('status message')).toBeInTheDocument();
// User sees the status message value
const statusMessageElements = screen.getAllByText(
mockSpanWithLongStatusMessage.statusMessage,
);
expect(statusMessageElements.length).toBeGreaterThan(0);
// User sees Expand button in popover
const expandButton = screen.getByRole('button', { name: /expand/i });
expect(expandButton).toBeInTheDocument();
});
it('should open modal with full status message when user clicks Expand button', async () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithLongStatusMessage}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// User clicks the Expand button
const expandButton = screen.getByRole('button', { name: /expand/i });
await fireEvent.click(expandButton);
// User sees modal with the full status message content
await waitFor(() => {
const modalTitle = document.querySelector('.ant-modal-title');
expect(modalTitle).toBeInTheDocument();
expect(modalTitle?.textContent).toBe('status message');
const preElement = document.querySelector(
'.attribute-with-expandable-popover__full-view',
);
expect(preElement).toBeInTheDocument();
expect(preElement?.textContent).toBe(
mockSpanWithLongStatusMessage.statusMessage,
);
});
});
it('should display short status message as simple text without popover', () => {
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={false}
setIsSpanDetailsDocked={jest.fn()}
selectedSpan={mockSpanWithShortStatusMessage}
traceStartTime={1640995200000}
traceEndTime={1640995260000}
/>
</QueryBuilderContext.Provider>,
);
// User sees status message label and value
expect(screen.getByText('status message')).toBeInTheDocument();
expect(
screen.getByText(mockSpanWithShortStatusMessage.statusMessage),
).toBeInTheDocument();
// User hovers over the status message value
const statusMessageValue = screen.getByText(
mockSpanWithShortStatusMessage.statusMessage,
);
fireEvent.mouseEnter(statusMessageValue);
// No Expand button should appear
expect(
screen.queryByRole('button', { name: /expand/i }),
).not.toBeInTheDocument();
});
});

View File

@@ -1,249 +0,0 @@
/**
* Shared test utilities for SpanDetailsDrawer tests.
* Extract common mocks, setup, and render helpers to avoid duplication across split test files.
*/
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
import getUserPreference from 'api/v1/user/preferences/name/get';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import { render } from 'tests/test-utils';
import { SuccessResponseV2 } from 'types/api';
import { GetSpanPercentilesResponseDataProps } from 'types/api/trace/getSpanPercentiles';
import SpanDetailsDrawer from '../SpanDetailsDrawer';
import { mockEmptyLogsResponse, mockSpan } from './mockData';
// =============================================================================
// TYPED MOCKS
// =============================================================================
export const mockGetSpanPercentiles = jest.mocked(getSpanPercentiles);
export const mockGetUserPreference = jest.mocked(getUserPreference);
export const mockSafeNavigate = jest.fn();
export const mockWindowOpen = jest.fn();
// =============================================================================
// MOCK SETUP (call in beforeAll or at module level)
// =============================================================================
export function setupSpanDetailsDrawerMocks(): void {
// Mock window.open
Object.defineProperty(window, 'open', {
writable: true,
value: mockWindowOpen,
});
}
// =============================================================================
// MOCK UPDATE OPERATORS
// =============================================================================
export const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
aggregateOperator: 'noop',
filter: { expression: "trace_id = 'test-trace-id'" },
expression: 'A',
disabled: false,
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
groupBy: [],
limit: null,
having: [],
},
],
queryFormulas: [],
},
queryType: 'builder',
});
// =============================================================================
// QUERY BUILDER CONTEXT MOCK
// =============================================================================
export const mockQueryBuilderContextValue = {
currentQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
stagedQuery: {
builder: {
queryData: [
{
dataSource: 'logs',
queryName: 'A',
filter: { expression: "trace_id = 'test-trace-id'" },
},
],
},
},
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
panelType: 'list',
redirectWithQuery: jest.fn(),
handleRunQuery: jest.fn(),
handleStageQuery: jest.fn(),
resetQuery: jest.fn(),
};
// =============================================================================
// RENDER HELPER
// =============================================================================
interface RenderSpanDetailsDrawerProps {
selectedSpan?: typeof mockSpan;
traceStartTime?: number;
traceEndTime?: number;
isSpanDetailsDocked?: boolean;
setIsSpanDetailsDocked?: jest.Mock;
}
export const renderSpanDetailsDrawer = (
props: RenderSpanDetailsDrawerProps = {},
): void => {
const {
selectedSpan = mockSpan,
traceStartTime = 1640995200000,
traceEndTime = 1640995260000,
isSpanDetailsDocked = false,
setIsSpanDetailsDocked = jest.fn(),
} = props;
render(
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
<SpanDetailsDrawer
isSpanDetailsDocked={isSpanDetailsDocked}
setIsSpanDetailsDocked={setIsSpanDetailsDocked}
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
</QueryBuilderContext.Provider>,
);
};
// =============================================================================
// CONSTANTS
// =============================================================================
export const CI_SENSITIVE_LOGS_TEST_TIMEOUT = 15000;
export const P75_TEXT = 'p75';
export const SPAN_PERCENTILE_TEXT = 'Span Percentile';
export const SEARCH_PLACEHOLDER = 'Search for attribute...';
export const SEARCH_RESOURCE_ATTRIBUTES_PLACEHOLDER =
'Search resource attributes';
// =============================================================================
// MOCK DATA FOR PERCENTILES
// =============================================================================
export const mockSpanPercentileResponse = {
httpStatusCode: 200 as const,
data: {
percentiles: {
p50: 500000000,
p90: 1000000000,
p95: 1500000000,
p99: 2000000000,
},
position: {
percentile: 75.5,
description: 'This span is in the 75th percentile',
},
},
};
export const mockUserPreferenceResponse = {
statusCode: 200,
httpStatusCode: 200,
error: null,
message: 'Success',
data: {
name: 'span_percentile_resource_attributes',
description: 'Resource attributes for span percentile calculation',
valueType: 'array',
defaultValue: [],
value: ['service.name', 'name', 'http.method'],
allowedValues: [],
allowedScopes: [],
createdAt: '2023-01-01T00:00:00Z',
updatedAt: '2023-01-01T00:00:00Z',
},
};
export const mockSpanPercentileErrorResponse = {
httpStatusCode: 500,
data: null,
} as unknown as SuccessResponseV2<GetSpanPercentilesResponseDataProps>;
// =============================================================================
// COMMON BEFOREEACH SETUP
// =============================================================================
export interface ApiCallHistory {
span_logs: any;
before_logs: any;
after_logs: any;
trace_only_logs: any;
}
export function createApiCallHistory(): ApiCallHistory {
return {
span_logs: null,
before_logs: null,
after_logs: null,
trace_only_logs: null,
};
}
export function setupLogsApiMock(
apiCallHistory: ApiCallHistory,
mockSpanLogsResponse: any,
mockBeforeLogsResponse: any,
mockAfterLogsResponse: any,
): void {
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
const filterExpression = (query as any)?.query?.builder?.queryData?.[0]
?.filter?.expression;
if (!filterExpression) {
return Promise.resolve(mockEmptyLogsResponse);
}
if (filterExpression.includes('span_id')) {
apiCallHistory.span_logs = query;
return Promise.resolve(mockSpanLogsResponse);
}
if (filterExpression.includes('id <')) {
apiCallHistory.before_logs = query;
return Promise.resolve(mockBeforeLogsResponse);
}
if (filterExpression.includes('id >')) {
apiCallHistory.after_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
if (filterExpression.includes('trace_id =')) {
apiCallHistory.trace_only_logs = query;
return Promise.resolve(mockAfterLogsResponse);
}
return Promise.resolve(mockEmptyLogsResponse);
});
}
export function clearAllMocks(): void {
jest.clearAllMocks();
mockSafeNavigate.mockClear();
mockWindowOpen.mockClear();
mockUpdateAllQueriesOperators.mockClear();
mockGetSpanPercentiles.mockClear();
mockGetUserPreference.mockClear();
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,9 +9,3 @@ export const RELATED_SIGNALS_VIEW_TYPES = {
// METRICS: RelatedSignalsViews.METRICS,
INFRA: RelatedSignalsViews.INFRA,
};
/**
* Delay in milliseconds before fetching span percentile data on initial load.
* Product requirement to avoid overwhelming API on rapid span selections.
*/
export const SPAN_PERCENTILE_INITIAL_DELAY_MS = 2000;

View File

@@ -8,7 +8,7 @@ import afterLogin from 'AppRoutes/utils';
import AuthError from 'components/AuthError/AuthError';
import AuthPageContainer from 'components/AuthPageContainer';
import { useNotifications } from 'hooks/useNotifications';
import { ArrowRight } from 'lucide-react';
import { ArrowRight, CircleAlert } from 'lucide-react';
import APIError from 'types/api/error';
import tvUrl from '@/assets/svgs/tv.svg';
@@ -28,8 +28,9 @@ type FormValues = {
function SignUp(): JSX.Element {
const [loading, setLoading] = useState(false);
const [confirmPasswordTouched, setConfirmPasswordTouched] = useState(false);
const [confirmPasswordError, setConfirmPasswordError] =
useState<boolean>(false);
const [formError, setFormError] = useState<APIError | null>();
const { notifications } = useNotifications();
@@ -83,10 +84,35 @@ function SignUp(): JSX.Element {
})();
};
const isPasswordMismatch =
Boolean(confirmPassword) && password !== confirmPassword;
const handleValuesChange: (changedValues: Partial<FormValues>) => void = (
changedValues,
) => {
// Clear error if passwords match while typing (but don't set error until blur)
if ('password' in changedValues || 'confirmPassword' in changedValues) {
const { password, confirmPassword } = form.getFieldsValue();
const showPasswordMismatchError = confirmPasswordTouched && isPasswordMismatch;
if (password && confirmPassword && password === confirmPassword) {
setConfirmPasswordError(false);
}
}
};
const handlePasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
// Only validate if confirm password has a value
if (confirmPassword) {
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
};
const handleConfirmPasswordBlur = (): void => {
const { password, confirmPassword } = form.getFieldsValue();
if (password && confirmPassword) {
const isSamePassword = password === confirmPassword;
setConfirmPasswordError(!isSamePassword);
}
};
const isValidForm = useMemo(
(): boolean =>
@@ -94,8 +120,8 @@ function SignUp(): JSX.Element {
Boolean(email?.trim()) &&
Boolean(password?.trim()) &&
Boolean(confirmPassword?.trim()) &&
password === confirmPassword,
[loading, email, password, confirmPassword],
!confirmPasswordError,
[loading, email, password, confirmPassword, confirmPasswordError],
);
return (
@@ -114,7 +140,12 @@ function SignUp(): JSX.Element {
</Typography.Paragraph>
</div>
<FormContainer onFinish={handleSubmit} form={form} className="signup-form">
<FormContainer
onFinish={handleSubmit}
onValuesChange={handleValuesChange}
form={form}
className="signup-form"
>
<div className="signup-form-container">
<div className="signup-form-fields">
<div className="signup-field-container">
@@ -144,6 +175,7 @@ function SignUp(): JSX.Element {
placeholder="Enter new password"
disabled={loading}
className="signup-antd-input"
onBlur={handlePasswordBlur}
/>
</FormContainer.Item>
</div>
@@ -153,12 +185,6 @@ function SignUp(): JSX.Element {
<FormContainer.Item
name="confirmPassword"
validateTrigger="onBlur"
validateStatus={showPasswordMismatchError ? 'error' : undefined}
help={
showPasswordMismatchError
? "Passwords don't match. Please try again."
: undefined
}
rules={[{ required: true, message: 'Please enter confirm password!' }]}
>
<AntdInput.Password
@@ -167,7 +193,7 @@ function SignUp(): JSX.Element {
placeholder="Confirm your new password"
disabled={loading}
className="signup-antd-input"
onBlur={() => setConfirmPasswordTouched(true)}
onBlur={handleConfirmPasswordBlur}
/>
</FormContainer.Item>
</div>
@@ -179,7 +205,19 @@ function SignUp(): JSX.Element {
your admin for an invite link
</Callout>
{formError && <AuthError error={formError} />}
{confirmPasswordError && (
<Callout
type="error"
size="small"
showIcon
icon={<CircleAlert size={12} />}
className="signup-error-callout"
>
Passwords don&apos;t match. Please try again.
</Callout>
)}
{formError && !confirmPasswordError && <AuthError error={formError} />}
<div className="signup-form-actions">
<Button

View File

@@ -348,165 +348,6 @@ const customRender = (
});
};
// =============================================================================
// TIERED RENDER FUNCTIONS
// =============================================================================
// Use the lightest wrapper that meets your test's needs:
// - renderMinimal: Router + QueryClient only (fastest, for pure components)
// - renderMedium: + AppContext + ErrorModal (for components using app state)
// - render: Full provider stack (for integration tests)
// =============================================================================
/**
* Minimal provider wrapper - Router + QueryClient only.
* Use for pure components that don't need app state, redux, or other contexts.
* ~5x faster than full render.
*/
function MinimalProviders({
children,
initialRoute = '/',
}: {
children: React.ReactNode;
initialRoute?: string;
}): ReactElement {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
</NuqsAdapter>
</MemoryRouter>
);
}
export const renderMinimal = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'> & { initialRoute?: string },
): RenderResult => {
const { initialRoute, ...renderOptions } = options || {};
return render(ui, {
wrapper: ({ children }) => (
<MinimalProviders initialRoute={initialRoute}>{children}</MinimalProviders>
),
...renderOptions,
});
};
/**
* Medium provider wrapper - Router + QueryClient + AppContext + ErrorModal.
* Use for components that need app context but not Redux, Timezone, or QueryBuilder.
* ~2x faster than full render.
*/
function MediumProviders({
children,
initialRoute = '/',
role = 'ADMIN',
appContextOverrides = {},
}: {
children: React.ReactNode;
initialRoute?: string;
role?: string;
appContextOverrides?: Partial<IAppContext>;
}): ReactElement {
return (
<MemoryRouter initialEntries={[initialRoute]}>
<NuqsAdapter>
<QueryClientProvider client={queryClient}>
<AppContext.Provider value={getAppContextMock(role, appContextOverrides)}>
<ErrorModalProvider>{children}</ErrorModalProvider>
</AppContext.Provider>
</QueryClientProvider>
</NuqsAdapter>
</MemoryRouter>
);
}
interface MediumProviderProps {
initialRoute?: string;
role?: string;
appContextOverrides?: Partial<IAppContext>;
}
export const renderMedium = (
ui: ReactElement,
options?: Omit<RenderOptions, 'wrapper'>,
providerProps: MediumProviderProps = {},
): RenderResult => {
const {
initialRoute = '/',
role = 'ADMIN',
appContextOverrides = {},
} = providerProps;
return render(ui, {
wrapper: ({ children }) => (
<MediumProviders
initialRoute={initialRoute}
role={role}
appContextOverrides={appContextOverrides}
>
{children}
</MediumProviders>
),
...options,
});
};
// =============================================================================
// SIMPLIFIED CONTEXT MOCK HELPERS
// =============================================================================
/**
* Simplified IAppContext mock with minimal defaults.
* Use this when you need a lightweight mock with mostly null/false values.
* Pass userOverrides to customize only the user object.
*
* This consolidates the duplicate getAppContextMockState functions that existed
* in various test utility files.
*/
export function getAppContextMockMinimal(
userOverrides?: Partial<IAppContext['user']>,
): IAppContext {
return {
user: {
accessJwt: 'some-token',
refreshJwt: 'some-refresh-token',
id: 'some-user-id',
email: 'user@signoz.io',
displayName: 'John Doe',
createdAt: 1732544623,
organization: 'Nightswatch',
orgId: 'does-not-matter-id',
role: 'ADMIN',
...userOverrides,
},
activeLicense: null,
trialInfo: null,
featureFlags: null,
orgPreferences: null,
userPreferences: null,
isLoggedIn: false,
org: null,
isFetchingUser: false,
isFetchingActiveLicense: false,
isFetchingFeatureFlags: false,
isFetchingOrgPreferences: false,
userFetchError: undefined,
activeLicenseFetchError: null,
featureFlagsFetchError: undefined,
orgPreferencesFetchError: undefined,
changelog: null,
showChangelogModal: false,
activeLicenseRefetch: jest.fn(),
updateUser: jest.fn(),
updateOrgPreferences: jest.fn(),
updateUserPreferenceInContext: jest.fn(),
updateOrg: jest.fn(),
updateChangelog: jest.fn(),
toggleChangelogModal: jest.fn(),
versionData: null,
hasEditPermission: false,
};
}
export * from '@testing-library/react';
export { default as userEvent } from '@testing-library/user-event';
export { customRender as render };

View File

@@ -35,7 +35,7 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
ID: "ListPods",
Tags: []string{"inframonitoring"},
Summary: "List Pods for Infra Monitoring",
Description: "Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown/no_data), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts under podCountsByPhase: { pending, running, succeeded, failed, unknown } derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.",
Description: "Returns a paginated list of Kubernetes pods with key metrics: CPU usage, CPU request/limit utilization, memory working set, memory request/limit utilization, current pod phase (pending/running/succeeded/failed/unknown), and pod age (ms since start time). Each pod includes metadata attributes (namespace, node, workload owner such as deployment/statefulset/daemonset/job/cronjob, cluster). Supports filtering via a filter expression, custom groupBy to aggregate pods by any attribute, ordering by any of the six metrics (cpu, cpu_request, cpu_limit, memory, memory_request, memory_limit), and pagination via offset/limit. The response type is 'list' for the default k8s.pod.uid grouping (each row is one pod with its current phase) or 'grouped_list' for custom groupBy keys (each row aggregates pods in the group with per-phase counts: pendingPodCount, runningPodCount, succeededPodCount, failedPodCount, unknownPodCount derived from each pod's latest phase in the window). Also reports missing required metrics and whether the requested time range falls before the data retention boundary. Numeric metric fields (podCPU, podCPURequest, podCPULimit, podMemory, podMemoryRequest, podMemoryLimit, podAge) return -1 as a sentinel when no data is available for that field.",
Request: new(inframonitoringtypes.PostablePods),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.Pods),
@@ -48,24 +48,5 @@ func (provider *provider) addInfraMonitoringRoutes(router *mux.Router) error {
return err
}
if err := router.Handle("/api/v2/infra_monitoring/nodes", handler.New(
provider.authZ.ViewAccess(provider.infraMonitoringHandler.ListNodes),
handler.OpenAPIDef{
ID: "ListNodes",
Tags: []string{"inframonitoring"},
Summary: "List Nodes for Infra Monitoring",
Description: "Returns a paginated list of Kubernetes nodes with key metrics: CPU usage, CPU allocatable, memory working set, memory allocatable, per-group nodeCountsByReadiness ({ ready, notReady } from each node's latest k8s.node.condition_ready in the window) and per-group podCountsByPhase ({ pending, running, succeeded, failed, unknown } for pods scheduled on the listed nodes). Each node includes metadata attributes (k8s.node.uid, k8s.cluster.name). The response type is 'list' for the default k8s.node.name grouping (each row is one node with its current condition string: ready / not_ready / no_data) or 'grouped_list' for custom groupBy keys (each row aggregates nodes in the group; condition stays no_data). Supports filtering via a filter expression, custom groupBy, ordering by cpu / cpu_allocatable / memory / memory_allocatable, 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 (nodeCPU, nodeCPUAllocatable, nodeMemory, nodeMemoryAllocatable) return -1 as a sentinel when no data is available for that field.",
Request: new(inframonitoringtypes.PostableNodes),
RequestContentType: "application/json",
Response: new(inframonitoringtypes.Nodes),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusOK,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusUnauthorized},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleViewer),
})).Methods(http.MethodPost).GetError(); err != nil {
return err
}
return nil
}

View File

@@ -69,27 +69,3 @@ func (h *handler) ListPods(rw http.ResponseWriter, req *http.Request) {
render.Success(rw, http.StatusOK, result)
}
func (h *handler) ListNodes(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
orgID := valuer.MustNewUUID(claims.OrgID)
var parsedReq inframonitoringtypes.PostableNodes
if err := binding.JSON.BindBody(req.Body, &parsedReq); err != nil {
render.Error(rw, err)
return
}
result, err := h.module.ListNodes(req.Context(), orgID, &parsedReq)
if err != nil {
render.Error(rw, err)
return
}
render.Success(rw, http.StatusOK, result)
}

View File

@@ -174,7 +174,7 @@ func buildHostRecords(
Wait: -1,
Load15: -1,
DiskUsage: -1,
Meta: map[string]string{},
Meta: map[string]any{},
}
if metrics, ok := metricsMap[compositeKey]; ok {

View File

@@ -23,9 +23,3 @@ type podPhaseCounts struct {
Failed int
Unknown int
}
// nodeConditionCounts holds per-group node counts bucketed by latest condition_ready in window.
type nodeConditionCounts struct {
Ready int
NotReady int
}

View File

@@ -242,98 +242,3 @@ func (m *module) ListPods(ctx context.Context, orgID valuer.UUID, req *inframoni
return resp, nil
}
func (m *module) ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error) {
if err := req.Validate(); err != nil {
return nil, err
}
resp := &inframonitoringtypes.Nodes{}
if req.OrderBy == nil {
req.OrderBy = &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: inframonitoringtypes.NodesOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionDesc,
}
}
if len(req.GroupBy) == 0 {
req.GroupBy = []qbtypes.GroupByKey{nodeNameGroupByKey}
resp.Type = inframonitoringtypes.ResponseTypeList
} else {
resp.Type = inframonitoringtypes.ResponseTypeGroupedList
}
missingMetrics, minFirstReportedUnixMilli, err := m.getMetricsExistenceAndEarliestTime(ctx, nodesTableMetricNamesList)
if err != nil {
return nil, err
}
if len(missingMetrics) > 0 {
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: missingMetrics}
resp.Records = []inframonitoringtypes.NodeRecord{}
resp.Total = 0
return resp, nil
}
if req.End < int64(minFirstReportedUnixMilli) {
resp.EndTimeBeforeRetention = true
resp.Records = []inframonitoringtypes.NodeRecord{}
resp.Total = 0
return resp, nil
}
resp.RequiredMetricsCheck = inframonitoringtypes.RequiredMetricsCheck{MissingMetrics: []string{}}
metadataMap, err := m.getNodesTableMetadata(ctx, req)
if err != nil {
return nil, err
}
resp.Total = len(metadataMap)
pageGroups, err := m.getTopNodeGroups(ctx, orgID, req, metadataMap)
if err != nil {
return nil, err
}
if len(pageGroups) == 0 {
resp.Records = []inframonitoringtypes.NodeRecord{}
return resp, nil
}
filterExpr := ""
if req.Filter != nil {
filterExpr = req.Filter.Expression
}
fullQueryReq := buildFullQueryRequest(req.Start, req.End, filterExpr, req.GroupBy, pageGroups, m.newNodesTableListQuery())
queryResp, err := m.querier.QueryRange(ctx, orgID, fullQueryReq)
if err != nil {
return nil, err
}
nodeConditionCounts, err := m.getPerGroupNodeConditionCounts(ctx, req, pageGroups)
if err != nil {
return nil, err
}
// Reuse the pods phase-counts CTE function via a temp struct — it reads only
// Start/End/Filter/GroupBy from PostablePods.
podPhaseCounts, err := m.getPerGroupPodPhaseCounts(ctx, &inframonitoringtypes.PostablePods{
Start: req.Start,
End: req.End,
Filter: req.Filter,
GroupBy: req.GroupBy,
}, pageGroups)
if err != nil {
return nil, err
}
isNodeNameInGroupBy := isKeyInGroupByAttrs(req.GroupBy, nodeNameAttrKey)
resp.Records = buildNodeRecords(isNodeNameInGroupBy, queryResp, pageGroups, req.GroupBy, metadataMap, nodeConditionCounts, podPhaseCounts)
resp.Warning = queryResp.Warning
return resp, nil
}

View File

@@ -1,312 +0,0 @@
package implinframonitoring
import (
"context"
"fmt"
"slices"
"strings"
"github.com/SigNoz/signoz/pkg/querybuilder"
"github.com/SigNoz/signoz/pkg/telemetrymetrics"
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/huandu/go-sqlbuilder"
)
// buildNodeRecords assembles the page records. Condition counts come from
// conditionCounts in both modes. In list mode (isNodeNameInGroupBy=true) each
// group is one node, so exactly one count is 1; Condition is derived from
// which one. In grouped_list mode Condition stays NodeConditionNoData.
func buildNodeRecords(
isNodeNameInGroupBy bool,
resp *qbtypes.QueryRangeResponse,
pageGroups []map[string]string,
groupBy []qbtypes.GroupByKey,
metadataMap map[string]map[string]string,
nodeConditionCounts map[string]nodeConditionCounts,
podPhaseCounts map[string]podPhaseCounts,
) []inframonitoringtypes.NodeRecord {
metricsMap := parseFullQueryResponse(resp, groupBy)
records := make([]inframonitoringtypes.NodeRecord, 0, len(pageGroups))
for _, labels := range pageGroups {
compositeKey := compositeKeyFromLabels(labels, groupBy)
nodeName := labels[nodeNameAttrKey]
record := inframonitoringtypes.NodeRecord{ // initialize with default values
NodeName: nodeName,
Condition: inframonitoringtypes.NodeConditionNoData,
NodeCPU: -1,
NodeCPUAllocatable: -1,
NodeMemory: -1,
NodeMemoryAllocatable: -1,
Meta: map[string]string{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
if v, exists := metrics["A"]; exists {
record.NodeCPU = v
}
if v, exists := metrics["B"]; exists {
record.NodeCPUAllocatable = v
}
if v, exists := metrics["C"]; exists {
record.NodeMemory = v
}
if v, exists := metrics["D"]; exists {
record.NodeMemoryAllocatable = v
}
}
if nodeConditionCountsForGroup, ok := nodeConditionCounts[compositeKey]; ok {
record.NodeCountsByReadiness = inframonitoringtypes.NodeCountsByReadiness{
Ready: nodeConditionCountsForGroup.Ready,
NotReady: nodeConditionCountsForGroup.NotReady,
}
// In list mode each group is one node; the count==1 bucket identifies the condition.
if isNodeNameInGroupBy {
switch {
case nodeConditionCountsForGroup.Ready == 1:
record.Condition = inframonitoringtypes.NodeConditionReady
case nodeConditionCountsForGroup.NotReady == 1:
record.Condition = inframonitoringtypes.NodeConditionNotReady
}
}
}
if podPhaseCountsForGroup, ok := podPhaseCounts[compositeKey]; ok {
record.PodCountsByPhase = inframonitoringtypes.PodCountsByPhase{
Pending: podPhaseCountsForGroup.Pending,
Running: podPhaseCountsForGroup.Running,
Succeeded: podPhaseCountsForGroup.Succeeded,
Failed: podPhaseCountsForGroup.Failed,
Unknown: podPhaseCountsForGroup.Unknown,
}
}
if attrs, ok := metadataMap[compositeKey]; ok {
for k, v := range attrs {
record.Meta[k] = v
}
}
records = append(records, record)
}
return records
}
func (m *module) getTopNodeGroups(
ctx context.Context,
orgID valuer.UUID,
req *inframonitoringtypes.PostableNodes,
metadataMap map[string]map[string]string,
) ([]map[string]string, error) {
orderByKey := req.OrderBy.Key.Name
queryNamesForOrderBy := orderByToNodesQueryNames[orderByKey]
rankingQueryName := queryNamesForOrderBy[len(queryNamesForOrderBy)-1]
topReq := &qbtypes.QueryRangeRequest{
Start: uint64(req.Start),
End: uint64(req.End),
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: make([]qbtypes.QueryEnvelope, 0, len(queryNamesForOrderBy)),
},
}
for _, envelope := range m.newNodesTableListQuery().CompositeQuery.Queries {
if !slices.Contains(queryNamesForOrderBy, envelope.GetQueryName()) {
continue
}
copied := envelope
if copied.Type == qbtypes.QueryTypeBuilder {
existingExpr := ""
if f := copied.GetFilter(); f != nil {
existingExpr = f.Expression
}
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
merged := mergeFilterExpressions(existingExpr, reqFilterExpr)
copied.SetFilter(&qbtypes.Filter{Expression: merged})
copied.SetGroupBy(req.GroupBy)
}
topReq.CompositeQuery.Queries = append(topReq.CompositeQuery.Queries, copied)
}
resp, err := m.querier.QueryRange(ctx, orgID, topReq)
if err != nil {
return nil, err
}
allMetricGroups := parseAndSortGroups(resp, rankingQueryName, req.GroupBy, req.OrderBy.Direction)
return paginateWithBackfill(allMetricGroups, metadataMap, req.GroupBy, req.Offset, req.Limit), nil
}
func (m *module) getNodesTableMetadata(ctx context.Context, req *inframonitoringtypes.PostableNodes) (map[string]map[string]string, error) {
var nonGroupByAttrs []string
for _, key := range nodeAttrKeysForMetadata {
if !isKeyInGroupByAttrs(req.GroupBy, key) {
nonGroupByAttrs = append(nonGroupByAttrs, key)
}
}
return m.getMetadata(ctx, nodesTableMetricNamesList, req.GroupBy, nonGroupByAttrs, req.Filter, req.Start, req.End)
}
// getPerGroupNodeConditionCounts computes per-group node counts bucketed by each
// node's latest condition_ready value (0 / 1) in the requested window.
// Pipeline:
//
// timeSeriesFPs: fp ↔ (node_name, groupBy cols) from the time_series table.
// User filter + page-groups filter applied here.
// latestConditionPerNode: INNER JOIN samples × timeSeriesFPs, collapsed to
// the latest condition value per node via argMax(value, unix_milli).
// countNodesPerCondition: per-group uniqExactIf into ready/not_ready buckets.
//
// Groups absent from the result map have implicit zero counts (caller default).
func (m *module) getPerGroupNodeConditionCounts(
ctx context.Context,
req *inframonitoringtypes.PostableNodes,
pageGroups []map[string]string,
) (map[string]nodeConditionCounts, error) {
if len(pageGroups) == 0 || len(req.GroupBy) == 0 {
return map[string]nodeConditionCounts{}, nil
}
// Merged filter expression (user filter + page-groups IN clauses).
reqFilterExpr := ""
if req.Filter != nil {
reqFilterExpr = req.Filter.Expression
}
pageGroupsFilterExpr := buildPageGroupsFilterExpr(pageGroups)
filterExpr := mergeFilterExpressions(reqFilterExpr, pageGroupsFilterExpr)
// Resolve tables. Same convention as pods.
adjustedStart, adjustedEnd, _, localTimeSeriesTable := telemetrymetrics.WhichTSTableToUse(
uint64(req.Start), uint64(req.End), nil,
)
samplesTable := telemetrymetrics.WhichSamplesTableToUse(
uint64(req.Start), uint64(req.End),
metrictypes.UnspecifiedType, metrictypes.TimeAggregationUnspecified, nil,
)
valueCol := telemetrymetrics.ValueColumnForSamplesTable(samplesTable)
// ----- timeSeriesFPs -----
timeSeriesFPs := sqlbuilder.NewSelectBuilder()
timeSeriesFPsSelectCols := []string{
"fingerprint",
fmt.Sprintf("JSONExtractString(labels, %s) AS node_name", timeSeriesFPs.Var(nodeNameAttrKey)),
}
for _, key := range req.GroupBy {
timeSeriesFPsSelectCols = append(timeSeriesFPsSelectCols,
fmt.Sprintf("JSONExtractString(labels, %s) AS %s", timeSeriesFPs.Var(key.Name), quoteIdentifier(key.Name)),
)
}
timeSeriesFPs.Select(timeSeriesFPsSelectCols...)
timeSeriesFPs.From(fmt.Sprintf("%s.%s", telemetrymetrics.DBName, localTimeSeriesTable))
timeSeriesFPs.Where(
timeSeriesFPs.E("metric_name", nodeConditionMetricName),
timeSeriesFPs.GE("unix_milli", adjustedStart),
timeSeriesFPs.L("unix_milli", adjustedEnd),
)
if filterExpr != "" {
filterClause, err := m.buildFilterClause(ctx, &qbtypes.Filter{Expression: filterExpr}, req.Start, req.End)
if err != nil {
return nil, err
}
if filterClause != nil {
timeSeriesFPs.AddWhereClause(filterClause)
}
}
timeSeriesFPsGroupBy := []string{"fingerprint", "node_name"}
for _, key := range req.GroupBy {
timeSeriesFPsGroupBy = append(timeSeriesFPsGroupBy, quoteIdentifier(key.Name))
}
timeSeriesFPs.GroupBy(timeSeriesFPsGroupBy...)
timeSeriesFPsSQL, timeSeriesFPsArgs := timeSeriesFPs.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- latestConditionPerNode -----
latestConditionPerNode := sqlbuilder.NewSelectBuilder()
latestConditionPerNodeSelectCols := []string{"tsfp.node_name AS node_name"}
latestConditionPerNodeGroupBy := []string{"node_name"}
for _, key := range req.GroupBy {
col := quoteIdentifier(key.Name)
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols, fmt.Sprintf("tsfp.%s AS %s", col, col))
latestConditionPerNodeGroupBy = append(latestConditionPerNodeGroupBy, col)
}
latestConditionPerNodeSelectCols = append(latestConditionPerNodeSelectCols,
fmt.Sprintf("argMax(samples.%s, samples.unix_milli) AS condition_value", valueCol),
)
latestConditionPerNode.Select(latestConditionPerNodeSelectCols...)
latestConditionPerNode.From(fmt.Sprintf(
"%s.%s AS samples INNER JOIN time_series_fps AS tsfp ON samples.fingerprint = tsfp.fingerprint",
telemetrymetrics.DBName, samplesTable,
))
latestConditionPerNode.Where(
latestConditionPerNode.E("samples.metric_name", nodeConditionMetricName),
latestConditionPerNode.GE("samples.unix_milli", req.Start),
latestConditionPerNode.L("samples.unix_milli", req.End),
"tsfp.node_name != ''",
)
latestConditionPerNode.GroupBy(latestConditionPerNodeGroupBy...)
latestConditionPerNodeSQL, latestConditionPerNodeArgs := latestConditionPerNode.BuildWithFlavor(sqlbuilder.ClickHouse)
// ----- countNodesPerCondition (outer SELECT) -----
countNodesPerConditionSelectCols := make([]string, 0, len(req.GroupBy)+2)
countNodesPerConditionGroupBy := make([]string, 0, len(req.GroupBy))
for _, key := range req.GroupBy {
col := quoteIdentifier(key.Name)
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols, col)
countNodesPerConditionGroupBy = append(countNodesPerConditionGroupBy, col)
}
countNodesPerConditionSelectCols = append(countNodesPerConditionSelectCols,
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS ready_count", inframonitoringtypes.NodeConditionNumReady),
fmt.Sprintf("uniqExactIf(node_name, condition_value = %d) AS not_ready_count", inframonitoringtypes.NodeConditionNumNotReady),
)
countNodesPerConditionSQL := fmt.Sprintf(
"SELECT %s FROM latest_condition_per_node GROUP BY %s",
strings.Join(countNodesPerConditionSelectCols, ", "),
strings.Join(countNodesPerConditionGroupBy, ", "),
)
// Combine CTEs + outer.
cteFragments := []string{
fmt.Sprintf("time_series_fps AS (%s)", timeSeriesFPsSQL),
fmt.Sprintf("latest_condition_per_node AS (%s)", latestConditionPerNodeSQL),
}
finalSQL := querybuilder.CombineCTEs(cteFragments) + countNodesPerConditionSQL
finalArgs := querybuilder.PrependArgs([][]any{timeSeriesFPsArgs, latestConditionPerNodeArgs}, nil)
rows, err := m.telemetryStore.ClickhouseDB().Query(ctx, finalSQL, finalArgs...)
if err != nil {
return nil, err
}
defer rows.Close()
result := make(map[string]nodeConditionCounts)
for rows.Next() {
groupVals := make([]string, len(req.GroupBy))
scanPtrs := make([]any, 0, len(req.GroupBy)+2)
for i := range groupVals {
scanPtrs = append(scanPtrs, &groupVals[i])
}
var ready, notReady uint64
scanPtrs = append(scanPtrs, &ready, &notReady)
if err := rows.Scan(scanPtrs...); err != nil {
return nil, err
}
result[compositeKeyFromList(groupVals)] = nodeConditionCounts{
Ready: int(ready),
NotReady: int(notReady),
}
}
if err := rows.Err(); err != nil {
return nil, err
}
return result, nil
}

View File

@@ -1,133 +0,0 @@
package implinframonitoring
import (
"github.com/SigNoz/signoz/pkg/types/inframonitoringtypes"
"github.com/SigNoz/signoz/pkg/types/metrictypes"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
)
const (
nodeNameAttrKey = "k8s.node.name"
nodeConditionMetricName = "k8s.node.condition_ready"
)
var nodeNameGroupByKey = qbtypes.GroupByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: nodeNameAttrKey,
FieldContext: telemetrytypes.FieldContextResource,
FieldDataType: telemetrytypes.FieldDataTypeString,
},
}
// nodesTableMetricNamesList drives the existence/retention check.
// Includes condition_ready and pod.phase also.
var nodesTableMetricNamesList = []string{
"k8s.node.cpu.usage",
"k8s.node.allocatable_cpu",
"k8s.node.memory.working_set",
"k8s.node.allocatable_memory",
"k8s.node.condition_ready",
"k8s.pod.phase",
}
var nodeAttrKeysForMetadata = []string{
"k8s.node.uid",
"k8s.cluster.name",
}
var orderByToNodesQueryNames = map[string][]string{
inframonitoringtypes.NodesOrderByCPU: {"A"},
inframonitoringtypes.NodesOrderByCPUAllocatable: {"B"},
inframonitoringtypes.NodesOrderByMemory: {"C"},
inframonitoringtypes.NodesOrderByMemoryAllocatable: {"D"},
}
// newNodesTableListQuery builds the composite QB v5 request for the nodes list.
// Node condition is derived separately via getPerGroupNodeConditionCounts (works
// for both list and grouped_list modes), so no condition query is included here.
func (m *module) newNodesTableListQuery() *qbtypes.QueryRangeRequest {
queries := []qbtypes.QueryEnvelope{
// Query A: CPU usage
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "A",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.cpu.usage",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
// Query B: CPU allocatable.
// TimeAggregationLatest is the closest v5 equivalent of v1's AnyLast;
// allocatable values change rarely so divergence in practice is negligible.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "B",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_cpu",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
// Query C: Memory working set
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "C",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.memory.working_set",
TimeAggregation: metrictypes.TimeAggregationAvg,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
// Query D: Memory allocatable. Same Latest caveat as Query B.
{
Type: qbtypes.QueryTypeBuilder,
Spec: qbtypes.QueryBuilderQuery[qbtypes.MetricAggregation]{
Name: "D",
Signal: telemetrytypes.SignalMetrics,
Aggregations: []qbtypes.MetricAggregation{
{
MetricName: "k8s.node.allocatable_memory",
TimeAggregation: metrictypes.TimeAggregationLatest,
SpaceAggregation: metrictypes.SpaceAggregationSum,
ReduceTo: qbtypes.ReduceToAvg,
},
},
GroupBy: []qbtypes.GroupByKey{nodeNameGroupByKey},
Disabled: false,
},
},
}
return &qbtypes.QueryRangeRequest{
RequestType: qbtypes.RequestTypeScalar,
CompositeQuery: qbtypes.CompositeQuery{
Queries: queries,
},
}
}

View File

@@ -19,7 +19,7 @@ import (
// buildPodRecords assembles the page records. Phase counts come from
// phaseCounts in both modes. In list mode (isPodUIDInGroupBy=true) each
// group is one pod, so exactly one count is 1; PodPhase is derived from
// which one. In grouped_list mode PodPhase stays PodPhaseNoData.
// which one. In grouped_list mode PodPhase stays PodPhaseNone.
func buildPodRecords(
isPodUIDInGroupBy bool,
resp *qbtypes.QueryRangeResponse,
@@ -38,7 +38,7 @@ func buildPodRecords(
record := inframonitoringtypes.PodRecord{ // initialize with default values
PodUID: podUID,
PodPhase: inframonitoringtypes.PodPhaseNoData,
PodPhase: inframonitoringtypes.PodPhaseNone,
PodCPU: -1,
PodCPURequest: -1,
PodCPULimit: -1,
@@ -46,7 +46,7 @@ func buildPodRecords(
PodMemoryRequest: -1,
PodMemoryLimit: -1,
PodAge: -1,
Meta: map[string]string{},
Meta: map[string]any{},
}
if metrics, ok := metricsMap[compositeKey]; ok {
@@ -71,13 +71,11 @@ func buildPodRecords(
}
if phaseCountsForGroup, ok := phaseCounts[compositeKey]; ok {
record.PodCountsByPhase = inframonitoringtypes.PodCountsByPhase{
Pending: phaseCountsForGroup.Pending,
Running: phaseCountsForGroup.Running,
Succeeded: phaseCountsForGroup.Succeeded,
Failed: phaseCountsForGroup.Failed,
Unknown: phaseCountsForGroup.Unknown,
}
record.PendingPodCount = phaseCountsForGroup.Pending
record.RunningPodCount = phaseCountsForGroup.Running
record.SucceededPodCount = phaseCountsForGroup.Succeeded
record.FailedPodCount = phaseCountsForGroup.Failed
record.UnknownPodCount = phaseCountsForGroup.Unknown
// In list mode each group is one pod; the count==1 bucket identifies the phase.
if isPodUIDInGroupBy {

View File

@@ -11,11 +11,9 @@ import (
type Handler interface {
ListHosts(http.ResponseWriter, *http.Request)
ListPods(http.ResponseWriter, *http.Request)
ListNodes(http.ResponseWriter, *http.Request)
}
type Module interface {
ListHosts(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableHosts) (*inframonitoringtypes.Hosts, error)
ListPods(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostablePods) (*inframonitoringtypes.Pods, error)
ListNodes(ctx context.Context, orgID valuer.UUID, req *inframonitoringtypes.PostableNodes) (*inframonitoringtypes.Nodes, error)
}

View File

@@ -18,16 +18,16 @@ type Hosts struct {
}
type HostRecord struct {
HostName string `json:"hostName" required:"true"`
Status HostStatus `json:"status" required:"true"`
ActiveHostCount int `json:"activeHostCount" required:"true"`
InactiveHostCount int `json:"inactiveHostCount" required:"true"`
CPU float64 `json:"cpu" required:"true"`
Memory float64 `json:"memory" required:"true"`
Wait float64 `json:"wait" required:"true"`
Load15 float64 `json:"load15" required:"true"`
DiskUsage float64 `json:"diskUsage" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
HostName string `json:"hostName" required:"true"`
Status HostStatus `json:"status" required:"true"`
ActiveHostCount int `json:"activeHostCount" required:"true"`
InactiveHostCount int `json:"inactiveHostCount" required:"true"`
CPU float64 `json:"cpu" required:"true"`
Memory float64 `json:"memory" required:"true"`
Wait float64 `json:"wait" required:"true"`
Load15 float64 `json:"load15" required:"true"`
DiskUsage float64 `json:"diskUsage" required:"true"`
Meta map[string]interface{} `json:"meta" required:"true"`
}
type RequiredMetricsCheck struct {

View File

@@ -1,110 +0,0 @@
package inframonitoringtypes
import (
"encoding/json"
"slices"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
)
type Nodes struct {
Type ResponseType `json:"type" required:"true"`
Records []NodeRecord `json:"records" required:"true"`
Total int `json:"total" required:"true"`
RequiredMetricsCheck RequiredMetricsCheck `json:"requiredMetricsCheck" required:"true"`
EndTimeBeforeRetention bool `json:"endTimeBeforeRetention" required:"true"`
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
}
// NodeCountsByReadiness buckets node counts by their latest k8s.node.condition_ready
// value in the time window. Reusable across record types (node / cluster).
type NodeCountsByReadiness struct {
Ready int `json:"ready" required:"true"`
NotReady int `json:"notReady" required:"true"`
}
type NodeRecord struct {
NodeName string `json:"nodeName" required:"true"`
Condition NodeCondition `json:"condition" required:"true"`
NodeCountsByReadiness NodeCountsByReadiness `json:"nodeCountsByReadiness" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
NodeCPU float64 `json:"nodeCPU" required:"true"`
NodeCPUAllocatable float64 `json:"nodeCPUAllocatable" required:"true"`
NodeMemory float64 `json:"nodeMemory" required:"true"`
NodeMemoryAllocatable float64 `json:"nodeMemoryAllocatable" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
}
// PostableNodes is the request body for the v2 nodes list API.
type PostableNodes struct {
Start int64 `json:"start" required:"true"`
End int64 `json:"end" required:"true"`
Filter *qbtypes.Filter `json:"filter"`
GroupBy []qbtypes.GroupByKey `json:"groupBy"`
OrderBy *qbtypes.OrderBy `json:"orderBy"`
Offset int `json:"offset"`
Limit int `json:"limit" required:"true"`
}
// Validate ensures PostableNodes contains acceptable values.
func (req *PostableNodes) Validate() error {
if req == nil {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "request is nil")
}
if req.Start <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid start time %d: start must be greater than 0",
req.Start,
)
}
if req.End <= 0 {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid end time %d: end must be greater than 0",
req.End,
)
}
if req.Start >= req.End {
return errors.NewInvalidInputf(
errors.CodeInvalidInput,
"invalid time range: start (%d) must be less than end (%d)",
req.Start,
req.End,
)
}
if req.Limit < 1 || req.Limit > 5000 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "limit must be between 1 and 5000")
}
if req.Offset < 0 {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "offset cannot be negative")
}
if req.OrderBy != nil {
if !slices.Contains(NodesValidOrderByKeys, req.OrderBy.Key.Name) {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by key: %s", req.OrderBy.Key.Name)
}
if req.OrderBy.Direction != qbtypes.OrderDirectionAsc && req.OrderBy.Direction != qbtypes.OrderDirectionDesc {
return errors.NewInvalidInputf(errors.CodeInvalidInput, "invalid order by direction: %s", req.OrderBy.Direction)
}
}
return nil
}
// UnmarshalJSON validates input immediately after decoding.
func (req *PostableNodes) UnmarshalJSON(data []byte) error {
type raw PostableNodes
var decoded raw
if err := json.Unmarshal(data, &decoded); err != nil {
return err
}
*req = PostableNodes(decoded)
return req.Validate()
}

View File

@@ -1,42 +0,0 @@
package inframonitoringtypes
import "github.com/SigNoz/signoz/pkg/valuer"
type NodeCondition struct {
valuer.String
}
var (
NodeConditionReady = NodeCondition{valuer.NewString("ready")}
NodeConditionNotReady = NodeCondition{valuer.NewString("not_ready")}
NodeConditionNoData = NodeCondition{valuer.NewString("no_data")}
)
func (NodeCondition) Enum() []any {
return []any{
NodeConditionReady,
NodeConditionNotReady,
NodeConditionNoData,
}
}
// Numeric values emitted by the k8s.node.condition_ready metric
// (source: OTel kubeletstats receiver).
const (
NodeConditionNumReady = 1
NodeConditionNumNotReady = 0
)
const (
NodesOrderByCPU = "cpu"
NodesOrderByCPUAllocatable = "cpu_allocatable"
NodesOrderByMemory = "memory"
NodesOrderByMemoryAllocatable = "memory_allocatable"
)
var NodesValidOrderByKeys = []string{
NodesOrderByCPU,
NodesOrderByCPUAllocatable,
NodesOrderByMemory,
NodesOrderByMemoryAllocatable,
}

View File

@@ -1,255 +0,0 @@
package inframonitoringtypes
import (
"testing"
"github.com/SigNoz/signoz/pkg/errors"
qbtypes "github.com/SigNoz/signoz/pkg/types/querybuildertypes/querybuildertypesv5"
"github.com/SigNoz/signoz/pkg/types/telemetrytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/stretchr/testify/require"
)
func TestPostableNodes_Validate(t *testing.T) {
tests := []struct {
name string
req *PostableNodes
wantErr bool
}{
{
name: "valid request",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: false,
},
{
name: "nil request",
req: nil,
wantErr: true,
},
{
name: "start time zero",
req: &PostableNodes{
Start: 0,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time negative",
req: &PostableNodes{
Start: -1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "end time zero",
req: &PostableNodes{
Start: 1000,
End: 0,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time greater than end time",
req: &PostableNodes{
Start: 2000,
End: 1000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "start time equal to end time",
req: &PostableNodes{
Start: 1000,
End: 1000,
Limit: 100,
Offset: 0,
},
wantErr: true,
},
{
name: "limit zero",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 0,
Offset: 0,
},
wantErr: true,
},
{
name: "limit negative",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: -10,
Offset: 0,
},
wantErr: true,
},
{
name: "limit exceeds max",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 5001,
Offset: 0,
},
wantErr: true,
},
{
name: "offset negative",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: -5,
},
wantErr: true,
},
{
name: "orderBy nil is valid",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
},
wantErr: false,
},
{
name: "orderBy with valid key cpu and direction asc",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: NodesOrderByCPU,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key cpu_allocatable and direction desc",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: NodesOrderByCPUAllocatable,
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: false,
},
{
name: "orderBy with valid key memory_allocatable and direction asc",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: NodesOrderByMemoryAllocatable,
},
},
Direction: qbtypes.OrderDirectionAsc,
},
},
wantErr: false,
},
{
name: "orderBy with condition key is rejected",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "condition",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with invalid key",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: "unknown",
},
},
Direction: qbtypes.OrderDirectionDesc,
},
},
wantErr: true,
},
{
name: "orderBy with valid key but invalid direction",
req: &PostableNodes{
Start: 1000,
End: 2000,
Limit: 100,
Offset: 0,
OrderBy: &qbtypes.OrderBy{
Key: qbtypes.OrderByKey{
TelemetryFieldKey: telemetrytypes.TelemetryFieldKey{
Name: NodesOrderByMemory,
},
},
Direction: qbtypes.OrderDirection{String: valuer.NewString("invalid")},
},
},
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := tt.req.Validate()
if tt.wantErr {
require.Error(t, err)
require.True(t, errors.Ast(err, errors.TypeInvalidInput), "expected error to be of type InvalidInput")
} else {
require.NoError(t, err)
}
})
}
}

View File

@@ -17,28 +17,22 @@ type Pods struct {
Warning *qbtypes.QueryWarnData `json:"warning,omitempty"`
}
// PodCountsByPhase buckets pod counts by their latest phase in the time window.
// Reusable across record types (pod / namespace / cluster).
type PodCountsByPhase struct {
Pending int `json:"pending" required:"true"`
Running int `json:"running" required:"true"`
Succeeded int `json:"succeeded" required:"true"`
Failed int `json:"failed" required:"true"`
Unknown int `json:"unknown" required:"true"`
}
type PodRecord struct {
PodUID string `json:"podUID" required:"true"`
PodCPU float64 `json:"podCPU" required:"true"`
PodCPURequest float64 `json:"podCPURequest" required:"true"`
PodCPULimit float64 `json:"podCPULimit" required:"true"`
PodMemory float64 `json:"podMemory" required:"true"`
PodMemoryRequest float64 `json:"podMemoryRequest" required:"true"`
PodMemoryLimit float64 `json:"podMemoryLimit" required:"true"`
PodPhase PodPhase `json:"podPhase" required:"true"`
PodCountsByPhase PodCountsByPhase `json:"podCountsByPhase" required:"true"`
PodAge int64 `json:"podAge" required:"true"`
Meta map[string]string `json:"meta" required:"true"`
PodUID string `json:"podUID" required:"true"`
PodCPU float64 `json:"podCPU" required:"true"`
PodCPURequest float64 `json:"podCPURequest" required:"true"`
PodCPULimit float64 `json:"podCPULimit" required:"true"`
PodMemory float64 `json:"podMemory" required:"true"`
PodMemoryRequest float64 `json:"podMemoryRequest" required:"true"`
PodMemoryLimit float64 `json:"podMemoryLimit" required:"true"`
PodPhase PodPhase `json:"podPhase" required:"true"`
PendingPodCount int `json:"pendingPodCount" required:"true"`
RunningPodCount int `json:"runningPodCount" required:"true"`
SucceededPodCount int `json:"succeededPodCount" required:"true"`
FailedPodCount int `json:"failedPodCount" required:"true"`
UnknownPodCount int `json:"unknownPodCount" required:"true"`
PodAge int64 `json:"podAge" required:"true"`
Meta map[string]interface{} `json:"meta" required:"true"`
}
// PostablePods is the request body for the v2 pods list API.

View File

@@ -12,7 +12,7 @@ var (
PodPhaseSucceeded = PodPhase{valuer.NewString("succeeded")}
PodPhaseFailed = PodPhase{valuer.NewString("failed")}
PodPhaseUnknown = PodPhase{valuer.NewString("unknown")}
PodPhaseNoData = PodPhase{valuer.NewString("no_data")}
PodPhaseNone = PodPhase{valuer.NewString("")}
)
func (PodPhase) Enum() []any {
@@ -22,7 +22,7 @@ func (PodPhase) Enum() []any {
PodPhaseSucceeded,
PodPhaseFailed,
PodPhaseUnknown,
PodPhaseNoData,
PodPhaseNone,
}
}