Compare commits

..

184 Commits

Author SHA1 Message Date
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
227 changed files with 19199 additions and 2914 deletions

View File

@@ -4360,6 +4360,146 @@ components:
TimeDuration:
format: int64
type: integer
TracedetailtypesEvent:
properties:
attributeMap:
additionalProperties: {}
type: object
isError:
type: boolean
name:
type: string
timeUnixNano:
minimum: 0
type: integer
type: object
TracedetailtypesWaterfallRequest:
properties:
limit:
minimum: 0
type: integer
selectedSpanId:
type: string
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesWaterfallResponse:
properties:
endTimestampMillis:
minimum: 0
type: integer
hasMissingSpans:
type: boolean
hasMore:
type: boolean
rootServiceEntryPoint:
type: string
rootServiceName:
type: string
serviceNameToTotalDurationMap:
additionalProperties:
minimum: 0
type: integer
nullable: true
type: object
spans:
items:
$ref: '#/components/schemas/TracedetailtypesWaterfallSpan'
nullable: true
type: array
startTimestampMillis:
minimum: 0
type: integer
totalErrorSpansCount:
minimum: 0
type: integer
totalSpansCount:
minimum: 0
type: integer
uncollapsedSpans:
items:
type: string
nullable: true
type: array
type: object
TracedetailtypesWaterfallSpan:
properties:
attributes:
additionalProperties: {}
nullable: true
type: object
db_name:
type: string
db_operation:
type: string
duration_nano:
minimum: 0
type: integer
events:
items:
$ref: '#/components/schemas/TracedetailtypesEvent'
nullable: true
type: array
external_http_method:
type: string
external_http_url:
type: string
flags:
minimum: 0
type: integer
has_children:
type: boolean
has_error:
type: boolean
http_host:
type: string
http_method:
type: string
http_url:
type: string
is_remote:
type: string
kind:
format: int32
type: integer
kind_string:
type: string
level:
minimum: 0
type: integer
name:
type: string
parent_span_id:
type: string
resource:
additionalProperties:
type: string
nullable: true
type: object
response_status_code:
type: string
span_id:
type: string
status_code:
type: integer
status_code_string:
type: string
status_message:
type: string
sub_tree_node_count:
minimum: 0
type: integer
timestamp:
minimum: 0
type: integer
trace_id:
type: string
trace_state:
type: string
type: object
TypesAlertStatus:
properties:
inhibitedBy:
@@ -12572,6 +12712,76 @@ paths:
summary: Put profile in Zeus for a deployment.
tags:
- zeus
/api/v3/traces/{traceID}/waterfall:
post:
deprecated: false
description: Returns the waterfall view of spans for a given trace ID with tree
structure, metadata, and windowed pagination
operationId: GetWaterfall
parameters:
- in: path
name: traceID
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/TracedetailtypesWaterfallRequest'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TracedetailtypesWaterfallResponse'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get waterfall view for a trace
tags:
- tracedetail
/api/v5/query_range:
post:
deprecated: false

View File

@@ -62,10 +62,11 @@
"@signozhq/popover": "0.1.2",
"@signozhq/radio-group": "0.0.4",
"@signozhq/resizable": "0.0.2",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.6",
"@signozhq/tabs": "0.0.11",
"@signozhq/table": "0.3.8",
"@signozhq/toggle-group": "0.0.3",
"@signozhq/ui": "0.0.5",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.13.22",
"@uiw/codemirror-theme-copilot": "4.23.11",
@@ -137,10 +138,12 @@
"react-helmet-async": "1.3.0",
"react-hook-form": "7.71.2",
"react-i18next": "^11.16.1",
"react-json-tree": "^0.20.0",
"react-lottie": "1.2.10",
"react-markdown": "8.0.7",
"react-query": "3.39.3",
"react-redux": "^7.2.2",
"react-rnd": "^10.5.3",
"react-router-dom": "^5.2.0",
"react-router-dom-v5-compat": "6.27.0",
"react-syntax-highlighter": "15.5.0",

View File

@@ -65,6 +65,13 @@ export const TraceDetail = Loadable(
),
);
export const TraceDetailV3 = Loadable(
() =>
import(
/* webpackChunkName: "TraceDetailV3 Page" */ 'pages/TraceDetailV3Page/index'
),
);
export const UsageExplorerPage = Loadable(
() => import(/* webpackChunkName: "UsageExplorerPage" */ 'modules/Usage'),
);

View File

@@ -48,6 +48,7 @@ import {
StatusPage,
SupportPage,
TraceDetail,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
@@ -141,10 +142,17 @@ const routes: AppRoutes[] = [
{
path: ROUTES.TRACE_DETAIL,
exact: true,
component: TraceDetail,
component: TraceDetailV3,
isPrivate: true,
key: 'TRACE_DETAIL',
},
{
path: ROUTES.TRACE_DETAIL_OLD,
exact: true,
component: TraceDetail,
isPrivate: true,
key: 'TRACE_DETAIL_OLD',
},
{
path: ROUTES.SETTINGS,
exact: false,

View File

@@ -5332,6 +5332,248 @@ export interface TelemetrytypesTelemetryFieldValuesDTO {
export type TimeDurationDTO = number;
export type TracedetailtypesEventDTOAttributeMap = { [key: string]: unknown };
export interface TracedetailtypesEventDTO {
/**
* @type object
*/
attributeMap?: TracedetailtypesEventDTOAttributeMap;
/**
* @type boolean
*/
isError?: boolean;
/**
* @type string
*/
name?: string;
/**
* @type integer
* @minimum 0
*/
timeUnixNano?: number;
}
export interface TracedetailtypesWaterfallRequestDTO {
/**
* @type integer
* @minimum 0
*/
limit?: number;
/**
* @type string
*/
selectedSpanId?: string;
/**
* @type array
* @nullable true
*/
uncollapsedSpans?: string[] | null;
}
/**
* @nullable
*/
export type TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap = {
[key: string]: number;
} | null;
export interface TracedetailtypesWaterfallResponseDTO {
/**
* @type integer
* @minimum 0
*/
endTimestampMillis?: number;
/**
* @type boolean
*/
hasMissingSpans?: boolean;
/**
* @type boolean
*/
hasMore?: boolean;
/**
* @type string
*/
rootServiceEntryPoint?: string;
/**
* @type string
*/
rootServiceName?: string;
/**
* @type object
* @nullable true
*/
serviceNameToTotalDurationMap?: TracedetailtypesWaterfallResponseDTOServiceNameToTotalDurationMap;
/**
* @type array
* @nullable true
*/
spans?: TracedetailtypesWaterfallSpanDTO[] | null;
/**
* @type integer
* @minimum 0
*/
startTimestampMillis?: number;
/**
* @type integer
* @minimum 0
*/
totalErrorSpansCount?: number;
/**
* @type integer
* @minimum 0
*/
totalSpansCount?: number;
/**
* @type array
* @nullable true
*/
uncollapsedSpans?: string[] | null;
}
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOAttributes = {
[key: string]: unknown;
} | null;
/**
* @nullable
*/
export type TracedetailtypesWaterfallSpanDTOResource = {
[key: string]: string;
} | null;
export interface TracedetailtypesWaterfallSpanDTO {
/**
* @type object
* @nullable true
*/
attributes?: TracedetailtypesWaterfallSpanDTOAttributes;
/**
* @type string
*/
db_name?: string;
/**
* @type string
*/
db_operation?: string;
/**
* @type integer
* @minimum 0
*/
duration_nano?: number;
/**
* @type array
* @nullable true
*/
events?: TracedetailtypesEventDTO[] | null;
/**
* @type string
*/
external_http_method?: string;
/**
* @type string
*/
external_http_url?: string;
/**
* @type integer
* @minimum 0
*/
flags?: number;
/**
* @type boolean
*/
has_children?: boolean;
/**
* @type boolean
*/
has_error?: boolean;
/**
* @type string
*/
http_host?: string;
/**
* @type string
*/
http_method?: string;
/**
* @type string
*/
http_url?: string;
/**
* @type string
*/
is_remote?: string;
/**
* @type integer
* @format int32
*/
kind?: number;
/**
* @type string
*/
kind_string?: string;
/**
* @type integer
* @minimum 0
*/
level?: number;
/**
* @type string
*/
name?: string;
/**
* @type string
*/
parent_span_id?: string;
/**
* @type object
* @nullable true
*/
resource?: TracedetailtypesWaterfallSpanDTOResource;
/**
* @type string
*/
response_status_code?: string;
/**
* @type string
*/
span_id?: string;
/**
* @type integer
*/
status_code?: number;
/**
* @type string
*/
status_code_string?: string;
/**
* @type string
*/
status_message?: string;
/**
* @type integer
* @minimum 0
*/
sub_tree_node_count?: number;
/**
* @type integer
* @minimum 0
*/
timestamp?: number;
/**
* @type string
*/
trace_id?: string;
/**
* @type string
*/
trace_state?: string;
}
export interface TypesAlertStatusDTO {
/**
* @type array
@@ -7258,6 +7500,17 @@ export type GetHosts200 = {
status: string;
};
export type GetWaterfallPathParameters = {
traceID: string;
};
export type GetWaterfall200 = {
data: TracedetailtypesWaterfallResponseDTO;
/**
* @type string
*/
status: string;
};
export type QueryRangeV5200 = {
data: Querybuildertypesv5QueryRangeResponseDTO;
/**

View File

@@ -0,0 +1,121 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
MutationFunction,
UseMutationOptions,
UseMutationResult,
} from 'react-query';
import { useMutation } from 'react-query';
import type { BodyType, ErrorType } from '../../../generatedAPIInstance';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
import type {
GetWaterfall200,
GetWaterfallPathParameters,
RenderErrorResponseDTO,
TracedetailtypesWaterfallRequestDTO,
} from '../sigNoz.schemas';
/**
* Returns the waterfall view of spans for a given trace ID with tree structure, metadata, and windowed pagination
* @summary Get waterfall view for a trace
*/
export const getWaterfall = (
{ traceID }: GetWaterfallPathParameters,
tracedetailtypesWaterfallRequestDTO: BodyType<TracedetailtypesWaterfallRequestDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetWaterfall200>({
url: `/api/v3/traces/${traceID}/waterfall`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: tracedetailtypesWaterfallRequestDTO,
signal,
});
};
export const getGetWaterfallMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
> => {
const mutationKey = ['getWaterfall'];
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 getWaterfall>>,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return getWaterfall(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type GetWaterfallMutationResult = NonNullable<
Awaited<ReturnType<typeof getWaterfall>>
>;
export type GetWaterfallMutationBody = BodyType<TracedetailtypesWaterfallRequestDTO>;
export type GetWaterfallMutationError = ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get waterfall view for a trace
*/
export const useGetWaterfall = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof getWaterfall>>,
TError,
{
pathParams: GetWaterfallPathParameters;
data: BodyType<TracedetailtypesWaterfallRequestDTO>;
},
TContext
> => {
const mutationOptions = getGetWaterfallMutationOptions(options);
return useMutation(mutationOptions);
};

View File

@@ -0,0 +1,72 @@
import { ApiV3Instance as axios } from 'api';
import { omit } from 'lodash-es';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
GetTraceV3PayloadProps,
GetTraceV3SuccessResponse,
SpanV3,
} from 'types/api/trace/getTraceV3';
const getTraceV3 = async (
props: GetTraceV3PayloadProps,
): Promise<SuccessResponse<GetTraceV3SuccessResponse> | ErrorResponse> => {
let uncollapsedSpans = [...props.uncollapsedSpans];
if (!props.isSelectedSpanIDUnCollapsed) {
uncollapsedSpans = uncollapsedSpans.filter(
(node) => node !== props.selectedSpanId,
);
} else if (
props.selectedSpanId &&
!uncollapsedSpans.includes(props.selectedSpanId)
) {
// V3 backend only uses uncollapsedSpans list (unlike V2 which also interprets
// isSelectedSpanIDUnCollapsed server-side), so explicitly add the selected span
uncollapsedSpans.push(props.selectedSpanId);
}
const postData: GetTraceV3PayloadProps = {
...props,
uncollapsedSpans,
limit: 10000,
};
const response = await axios.post<GetTraceV3SuccessResponse>(
`/traces/${props.traceId}/waterfall`,
omit(postData, 'traceId'),
);
// V3 API wraps response in { status, data }
const rawPayload = (response.data as any).data || response.data;
// Derive 'service.name' from resource for convenience — only derived field
const spans: SpanV3[] = (rawPayload.spans || []).map((span: any) => ({
...span,
'service.name': span.resource?.['service.name'] || '',
}));
// V3 API returns startTimestampMillis/endTimestampMillis as relative durations (ms from epoch offset),
// not absolute unix millis like V2. The span timestamps are absolute unix millis.
// Convert by using the first span's timestamp as the base if there's a mismatch.
let { startTimestampMillis, endTimestampMillis } = rawPayload;
if (
spans.length > 0 &&
spans[0].timestamp > 0 &&
startTimestampMillis < spans[0].timestamp / 10
) {
const durationMillis = endTimestampMillis - startTimestampMillis;
startTimestampMillis = spans[0].timestamp;
endTimestampMillis = startTimestampMillis + durationMillis;
}
return {
statusCode: 200,
error: null,
message: 'Success',
payload: {
...rawPayload,
spans,
startTimestampMillis,
endTimestampMillis,
},
};
};
export default getTraceV3;

View File

@@ -24,7 +24,8 @@ import '@signozhq/input';
import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/tooltip';
import '@signozhq/ui';
import '@signozhq/tabs';
import '@signozhq/table';
import '@signozhq/toggle-group';
import '@signozhq/ui';

View File

@@ -79,7 +79,7 @@ export function useNavigateToExplorer(): (
);
const { getUpdatedQuery } = useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(
@@ -111,7 +111,7 @@ export function useNavigateToExplorer(): (
panelTypes: PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
selectedDashboard,
})
.then((query) => {
preparedQuery = query;
@@ -140,7 +140,7 @@ export function useNavigateToExplorer(): (
minTime,
maxTime,
getUpdatedQuery,
dashboardData,
selectedDashboard,
notifications,
],
);

View File

@@ -0,0 +1,40 @@
.details-header {
// ghost + secondary missing hover bg token in @signozhq/button
--button-ghost-hover-background: var(--l3-background);
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--l1-border);
height: 36px;
background: var(--l2-background);
&__icon-btn {
flex-shrink: 0;
}
&__title {
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__actions {
display: flex;
align-items: center;
gap: 4px;
flex-shrink: 0;
}
&__nav {
display: flex;
align-items: center;
gap: 2px;
}
}

View File

@@ -0,0 +1,59 @@
import { ReactNode } from 'react';
import { Button } from '@signozhq/button';
import { X } from '@signozhq/icons';
import './DetailsHeader.styles.scss';
export interface HeaderAction {
key: string;
component: ReactNode; // check later if we can use direct btn itself or not.
}
export interface DetailsHeaderProps {
title: string;
onClose: () => void;
actions?: HeaderAction[];
closePosition?: 'left' | 'right';
className?: string;
}
function DetailsHeader({
title,
onClose,
actions,
closePosition = 'right',
className,
}: DetailsHeaderProps): JSX.Element {
const closeButton = (
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={onClose}
aria-label="Close"
className="details-header__icon-btn"
>
<X size={14} />
</Button>
);
return (
<div className={`details-header ${className || ''}`}>
{closePosition === 'left' && closeButton}
<span className="details-header__title">{title}</span>
{actions && (
<div className="details-header__actions">
{actions.map((action) => (
<div key={action.key}>{action.component}</div>
))}
</div>
)}
{closePosition === 'right' && closeButton}
</div>
);
}
export default DetailsHeader;

View File

@@ -0,0 +1,7 @@
.details-panel-drawer {
&__body {
display: flex;
flex-direction: column;
height: 100%;
}
}

View File

@@ -0,0 +1,36 @@
import { DrawerWrapper } from '@signozhq/drawer';
import './DetailsPanelDrawer.styles.scss';
interface DetailsPanelDrawerProps {
isOpen: boolean;
onClose: () => void;
children: React.ReactNode;
className?: string;
}
function DetailsPanelDrawer({
isOpen,
onClose,
children,
className,
}: DetailsPanelDrawerProps): JSX.Element {
return (
<DrawerWrapper
open={isOpen}
onOpenChange={(open): void => {
if (!open) {
onClose();
}
}}
direction="right"
type="panel"
showOverlay={false}
allowOutsideClick
className={`details-panel-drawer ${className || ''}`}
content={<div className="details-panel-drawer__body">{children}</div>}
/>
);
}
export default DetailsPanelDrawer;

View File

@@ -0,0 +1,8 @@
export type {
DetailsHeaderProps,
HeaderAction,
} from './DetailsHeader/DetailsHeader';
export { default as DetailsHeader } from './DetailsHeader/DetailsHeader';
export { default as DetailsPanelDrawer } from './DetailsPanelDrawer';
export type { DetailsPanelState, UseDetailsPanelOptions } from './types';
export { default as useDetailsPanel } from './useDetailsPanel';

View File

@@ -0,0 +1,10 @@
export interface DetailsPanelState {
isOpen: boolean;
open: () => void;
close: () => void;
}
export interface UseDetailsPanelOptions {
entityId: string | undefined;
onClose?: () => void;
}

View File

@@ -0,0 +1,29 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { DetailsPanelState, UseDetailsPanelOptions } from './types';
function useDetailsPanel({
entityId,
onClose,
}: UseDetailsPanelOptions): DetailsPanelState {
const [isOpen, setIsOpen] = useState<boolean>(false);
const prevEntityIdRef = useRef<string>('');
useEffect(() => {
const currentId = entityId || '';
if (currentId && currentId !== prevEntityIdRef.current) {
setIsOpen(true);
}
prevEntityIdRef.current = currentId;
}, [entityId]);
const open = useCallback(() => setIsOpen(true), []);
const close = useCallback(() => {
setIsOpen(false);
onClose?.();
}, [onClose]);
return { isOpen, open, close };
}
export default useDetailsPanel;

View File

@@ -15,7 +15,6 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import ContextView from 'container/LogDetailedView/ContextView/ContextView';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import JSONView from 'container/LogDetailedView/JsonView';
import Overview from 'container/LogDetailedView/Overview';
import {
aggregateAttributesResourcesToString,
@@ -45,6 +44,7 @@ import {
TextSelect,
X,
} from 'lucide-react';
import { JsonView } from 'periscope/components/JsonView';
import { AppState } from 'store/reducers';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource, StringOperators } from 'types/common/queryBuilder';
@@ -527,7 +527,9 @@ function LogDetailInner({
handleChangeSelectedView={handleChangeSelectedView}
/>
)}
{selectedView === VIEW_TYPES.JSON && <JSONView logData={log} />}
{selectedView === VIEW_TYPES.JSON && (
<JsonView data={LogJsonData} height="68vh" />
)}
{selectedView === VIEW_TYPES.CONTEXT && (
<ContextView

View File

@@ -87,8 +87,8 @@ jest.mock('hooks/useDarkMode', () => ({
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
useDashboardStore: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
}),
}));

View File

@@ -0,0 +1,20 @@
.timeline-v3-container {
overflow: visible;
position: relative;
}
.timeline-v3-cursor-badge {
position: absolute;
top: 0;
transform: translateX(-50%);
background: var(--l3-background);
border: 1px solid var(--l2-border);
border-radius: 4px;
padding: 2px 8px;
font-size: 11px;
font-weight: 500;
color: var(--l1-foreground);
white-space: nowrap;
pointer-events: none;
z-index: 1;
}

View File

@@ -0,0 +1,119 @@
import { useEffect, useMemo, useState } from 'react';
import { useMeasure } from 'react-use';
import { resolveTimeFromInterval } from 'components/TimelineV2/utils';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { toFixed } from 'utils/toFixed';
import {
getIntervals,
getIntervalUnit,
getMinimumIntervalsBasedOnWidth,
Interval,
} from './utils';
import './TimelineV3.styles.scss';
interface ITimelineV3Props {
startTimestamp: number;
endTimestamp: number;
timelineHeight: number;
offsetTimestamp: number;
/** Cursor X as a fraction of the timeline width (01). null = no cursor. */
cursorXPercent?: number | null;
}
function TimelineV3(props: ITimelineV3Props): JSX.Element {
const {
startTimestamp,
endTimestamp,
timelineHeight,
offsetTimestamp,
cursorXPercent,
} = props;
const [intervals, setIntervals] = useState<Interval[]>([]);
const [ref, { width }] = useMeasure<HTMLDivElement>();
const isDarkMode = useIsDarkMode();
const spread = endTimestamp - startTimestamp;
useEffect(() => {
if (spread < 0) {
return;
}
const minIntervals = getMinimumIntervalsBasedOnWidth(width);
const intervalisedSpread = (spread / minIntervals) * 1.0;
const newIntervals = getIntervals(
intervalisedSpread,
spread,
offsetTimestamp,
);
setIntervals(newIntervals);
}, [startTimestamp, endTimestamp, width, offsetTimestamp, spread]);
// Compute cursor time label using the same unit as timeline ticks
const cursorLabel = useMemo(() => {
if (cursorXPercent == null || spread <= 0) {
return null;
}
const timeAtCursor = offsetTimestamp + cursorXPercent * spread;
const unit = getIntervalUnit(spread, offsetTimestamp);
const formatted = toFixed(resolveTimeFromInterval(timeAtCursor, unit), 2);
return `${formatted}${unit.name}`;
}, [cursorXPercent, spread, offsetTimestamp]);
if (endTimestamp < startTimestamp) {
console.error(
'endTimestamp cannot be less than startTimestamp',
startTimestamp,
endTimestamp,
);
return <div />;
}
const strokeColor = isDarkMode ? ' rgb(192,193,195,0.8)' : 'black';
const svgHeight = timelineHeight * 2.5;
const cursorX = cursorXPercent != null ? cursorXPercent * width : null;
return (
<div ref={ref as never} className="timeline-v3-container">
<svg
width={width}
height={svgHeight}
xmlns="http://www.w3.org/2000/svg"
overflow="visible"
>
{intervals &&
intervals.length > 0 &&
intervals.map((interval, index) => (
<g
transform={`translate(${(interval.percentage * width) / 100},0)`}
key={`${interval.percentage + interval.label + index}`}
textAnchor="middle"
fontSize="0.6rem"
>
<text
x={index === intervals.length - 1 ? -10 : 0}
y={timelineHeight * 2}
fill={strokeColor}
>
{interval.label}
</text>
<line y1={0} y2={timelineHeight} stroke={strokeColor} strokeWidth="1" />
</g>
))}
</svg>
{/* Cursor time badge — DOM element for easy CSS styling */}
{cursorX !== null && cursorLabel && (
<div className="timeline-v3-cursor-badge" style={{ left: cursorX }}>
{cursorLabel}
</div>
)}
</div>
);
}
export default TimelineV3;

View File

@@ -0,0 +1,109 @@
import {
IIntervalUnit,
Interval,
INTERVAL_UNITS,
resolveTimeFromInterval,
} from 'components/TimelineV2/utils';
import { toFixed } from 'utils/toFixed';
export type { Interval };
/**
* Select the interval unit matching the timeline's logic.
* Exported so crosshair labels use the same unit as timeline ticks.
*/
export function getIntervalUnit(
spread: number,
offsetTimestamp: number,
): IIntervalUnit {
const minIntervals = 6;
const intervalSpread = spread / minIntervals;
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let unit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
if (valueForUnitSelection * INTERVAL_UNITS[idx].multiplier >= 1) {
unit = INTERVAL_UNITS[idx];
break;
}
}
return unit;
}
/** Fewer intervals than TimelineV2 for a cleaner flamegraph ruler. */
export function getMinimumIntervalsBasedOnWidth(width: number): number {
if (width < 640) {
return 3;
}
if (width < 768) {
return 4;
}
if (width < 1024) {
return 5;
}
return 6;
}
/**
* Computes timeline intervals with offset-aware labels.
* Labels reflect absolute time from trace start (offsetTimestamp + elapsed),
* so when zoomed into a window, the first tick shows e.g. "50ms" not "0ms".
*/
export function getIntervals(
intervalSpread: number,
baseSpread: number,
offsetTimestamp: number,
): Interval[] {
const integerPartString = intervalSpread.toString().split('.')[0];
const integerPartLength = integerPartString.length;
const intervalSpreadNormalized =
intervalSpread < 1.0
? intervalSpread
: Math.floor(Number(integerPartString) / 10 ** (integerPartLength - 1)) *
10 ** (integerPartLength - 1);
// Unit must suit both: (1) tick granularity (intervalSpread) and (2) label magnitude
// (offsetTimestamp). When zoomed deep into a trace, labels show offsetTimestamp + elapsed,
// so we must pick a unit where that value is readable (e.g. "500.00s" not "500000.00ms").
const valueForUnitSelection = Math.max(offsetTimestamp, intervalSpread);
let intervalUnit: IIntervalUnit = INTERVAL_UNITS[0];
for (let idx = INTERVAL_UNITS.length - 1; idx >= 0; idx -= 1) {
const standardInterval = INTERVAL_UNITS[idx];
if (valueForUnitSelection * standardInterval.multiplier >= 1) {
intervalUnit = INTERVAL_UNITS[idx];
break;
}
}
const intervals: Interval[] = [
{
label: `${toFixed(
resolveTimeFromInterval(offsetTimestamp, intervalUnit),
2,
)}${intervalUnit.name}`,
percentage: 0,
},
];
// Only show even-interval ticks — skip the trailing partial tick at the edge.
// The last even tick sits before the full width, so it doesn't conflict with
// span duration labels that may have sub-millisecond precision.
let elapsedIntervals = 0;
while (
elapsedIntervals + intervalSpreadNormalized <= baseSpread &&
intervals.length < 20
) {
elapsedIntervals += intervalSpreadNormalized;
const labelTime = offsetTimestamp + elapsedIntervals;
intervals.push({
label: `${toFixed(resolveTimeFromInterval(labelTime, intervalUnit), 2)}${
intervalUnit.name
}`,
percentage: (elapsedIntervals / baseSpread) * 100,
});
}
return intervals;
}

View File

@@ -37,5 +37,6 @@ export enum LOCALSTORAGE {
SHOW_FREQUENCY_CHART = 'SHOW_FREQUENCY_CHART',
DISSMISSED_COST_METER_INFO = 'DISMISSED_COST_METER_INFO',
DISMISSED_API_KEYS_DEPRECATION_BANNER = 'DISMISSED_API_KEYS_DEPRECATION_BANNER',
TRACE_DETAILS_SPAN_DETAILS_POSITION = 'TRACE_DETAILS_SPAN_DETAILS_POSITION',
LICENSE_KEY_CALLOUT_DISMISSED = 'LICENSE_KEY_CALLOUT_DISMISSED',
}

View File

@@ -32,6 +32,7 @@ export const REACT_QUERY_KEY = {
UPDATE_ALERT_RULE: 'UPDATE_ALERT_RULE',
GET_ACTIVE_LICENSE_V3: 'GET_ACTIVE_LICENSE_V3',
GET_TRACE_V2_WATERFALL: 'GET_TRACE_V2_WATERFALL',
GET_TRACE_V3_WATERFALL: 'GET_TRACE_V3_WATERFALL',
GET_TRACE_V2_FLAMEGRAPH: 'GET_TRACE_V2_FLAMEGRAPH',
GET_POD_LIST: 'GET_POD_LIST',
GET_NODE_LIST: 'GET_NODE_LIST',

View File

@@ -8,6 +8,7 @@ const ROUTES = {
SERVICE_MAP: '/service-map',
TRACE: '/trace',
TRACE_DETAIL: '/trace/:id',
TRACE_DETAIL_OLD: '/trace-old/:id',
TRACES_EXPLORER: '/traces-explorer',
ONBOARDING: '/onboarding',
GET_STARTED: '/get-started',

View File

@@ -33,6 +33,102 @@ const themeColors = {
purple: '#800080',
cyan: '#00FFFF',
},
traceDetailColorsV3: {
// Blues
blue1: '#2F80ED',
blue2: '#3366E6',
blue3: '#4682B4',
blue4: '#1F63E0',
blue5: '#3A7AED',
blue6: '#5A9DF5',
blue7: '#2874A6',
blue8: '#2E86C1',
blue9: '#3498DB',
blue10: '#1E90FF',
blue11: '#4169E1',
// Cyans / Teals
cyan1: '#00CEC9',
cyan2: '#22A6F2',
cyan3: '#00B0AA',
cyan4: '#33D6C2',
cyan5: '#66E9DA',
cyan6: '#48DBFB',
cyan7: '#00BFFF',
cyan8: '#63B8FF',
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
teal4: '#76D7C4',
teal5: '#20B2AA',
// Greens
green1: '#27AE60',
green2: '#3CB371',
green3: '#1E8449',
green4: '#2ECC71',
green5: '#58D68D',
green6: '#229954',
green7: '#52BE80',
green8: '#82E0AA',
green9: '#73C6B6',
// Limes
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#84CC16',
lime4: '#65A30D',
// Yellows
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
yellow4: '#F4D03F',
yellow5: '#D4AC0D',
// Golds / Ambers
gold1: '#F2C94C',
gold2: '#FFD93D',
gold3: '#FFCA28',
gold4: '#B7950B',
gold5: '#D4A017',
// Oranges (non-red)
orange1: '#F39C12',
orange2: '#E67E22',
orange3: '#F5B041',
orange4: '#D35400',
orange5: '#EB984E',
orange6: '#FAD7A0',
// Purples / Violets
purple1: '#BB6BD9',
purple2: '#9B51E0',
purple3: '#DA77F2',
purple4: '#C77DFF',
purple5: '#6C5CE7',
purple6: '#8E44AD',
purple7: '#9B59B6',
purple8: '#BB8FCE',
purple9: '#7D3C98',
purple10: '#A569BD',
// Lavenders
lavender1: '#AF7AC5',
lavender2: '#C39BD3',
lavender3: '#D2B4DE',
// Pinks / Magentas
pink1: '#E91E8C',
pink2: '#FF6FD8',
pink3: '#F06292',
pink4: '#CE93D8',
// Salmons / Corals (distinct from error red)
salmon1: '#FF8A65',
salmon2: '#FFAB91',
salmon3: '#E0876A',
},
chartcolors: {
// Blues (3)
dodgerBlue: '#2F80ED',

View File

@@ -196,12 +196,12 @@ describe('Dashboard landing page actions header tests', () => {
(useLocation as jest.Mock).mockReturnValue(mockLocation);
useDashboardStore.setState({
dashboardData: (getDashboardById.data as unknown) as Dashboard,
selectedDashboard: (getDashboardById.data as unknown) as Dashboard,
layouts: [],
panelMap: {},
setPanelMap: jest.fn(),
setLayouts: jest.fn(),
setDashboardData: jest.fn(),
setSelectedDashboard: jest.fn(),
columnWidths: {},
});

View File

@@ -78,12 +78,12 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const {
dashboardData,
selectedDashboard,
panelMap,
setPanelMap,
layouts,
setLayouts,
setDashboardData,
setSelectedDashboard,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
@@ -98,10 +98,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const isPublicDashboardEnabled = isCloudUser || isEnterpriseSelfHostedUser;
const selectedData = dashboardData
const selectedData = selectedDashboard
? {
...dashboardData.data,
uuid: dashboardData.id,
...selectedDashboard.data,
uuid: selectedDashboard.id,
}
: ({} as DashboardData);
const { dashboardVariables } = useDashboardVariables();
@@ -133,8 +133,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
let isAuthor = false;
if (dashboardData && user && user.email) {
isAuthor = dashboardData?.createdBy === user?.email;
if (selectedDashboard && user && user.email) {
isAuthor = selectedDashboard?.createdBy === user?.email;
}
let permissions: ComponentTypes[] = ['add_panel'];
@@ -146,7 +146,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const { notifications } = useNotifications();
const userRole: ROLES | null =
dashboardData?.createdBy === user?.email
selectedDashboard?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -155,9 +155,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);
@@ -168,14 +168,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
};
const onNameChangeHandler = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const updatedDashboard: Props = {
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
title: updatedTitle,
},
};
@@ -186,7 +186,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
});
setIsRenameDashboardOpen(false);
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
setSelectedDashboard(updatedDashboard.data);
}
},
onError: () => {
@@ -203,10 +203,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
// the context value is sometimes not available during the initial render
// due to which the updatedTitle is set to some previous value
useEffect(() => {
if (dashboardData) {
setUpdatedTitle(dashboardData.data.title);
if (selectedDashboard) {
setUpdatedTitle(selectedDashboard.data.title);
}
}, [dashboardData]);
}, [selectedDashboard]);
useEffect(() => {
if (state.error) {
@@ -227,7 +227,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}, [state.error, state.value, t, notifications]);
function handleAddRow(): void {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const id = uuid();
@@ -246,10 +246,10 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
}
const updatedDashboard: Props = {
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
layout: [
{
i: id,
@@ -265,7 +265,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
],
panelMap: { ...panelMap, [id]: newRowWidgetMap },
widgets: [
...(dashboardData.data.widgets || []),
...(selectedDashboard.data.widgets || []),
{
id,
title: sectionName,
@@ -282,7 +282,7 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setDashboardData(updatedDashboard.data);
setSelectedDashboard(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
@@ -299,8 +299,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
error: errorPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardMeta(
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
selectedDashboard?.id || '',
!!selectedDashboard?.id && isPublicDashboardEnabled,
);
useEffect(() => {
@@ -378,14 +378,14 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
{(isAuthor || user.role === USER_ROLES.ADMIN) && (
<Tooltip
title={
dashboardData?.createdBy === 'integration' &&
selectedDashboard?.createdBy === 'integration' &&
'Dashboards created by integrations cannot be unlocked'
}
>
<Button
type="text"
icon={<LockKeyhole size={14} />}
disabled={dashboardData?.createdBy === 'integration'}
disabled={selectedDashboard?.createdBy === 'integration'}
onClick={handleLockDashboardToggle}
data-testid="lock-unlock-dashboard"
>
@@ -457,9 +457,9 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
</section>
<section className="delete-dashboard">
<DeleteButton
createdBy={dashboardData?.createdBy || ''}
name={dashboardData?.data.title || ''}
id={String(dashboardData?.id) || ''}
createdBy={selectedDashboard?.createdBy || ''}
name={selectedDashboard?.data.title || ''}
id={String(selectedDashboard?.id) || ''}
isLocked={isDashboardLocked}
routeToListPage
/>

View File

@@ -239,7 +239,7 @@ function VariableItem({
const [selectedWidgets, setSelectedWidgets] = useState<string[]>([]);
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
const widgetsByDynamicVariableId = useWidgetsByDynamicVariableId();
useEffect(() => {
@@ -248,7 +248,7 @@ function VariableItem({
} else if (dynamicVariablesSelectedValue?.name) {
const widgets = getWidgetsHavingDynamicVariableAttribute(
dynamicVariablesSelectedValue?.name,
(dashboardData?.data?.widgets?.filter(
(selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || []) as Widgets[],
variableData.name,
@@ -257,7 +257,7 @@ function VariableItem({
}
}, [
dynamicVariablesSelectedValue?.name,
dashboardData,
selectedDashboard,
variableData.id,
variableData.name,
widgetsByDynamicVariableId,

View File

@@ -12,17 +12,17 @@ export function WidgetSelector({
selectedWidgets: string[];
setSelectedWidgets: (widgets: string[]) => void;
}): JSX.Element {
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
// Get layout IDs for cross-referencing
const layoutIds = new Set(
(dashboardData?.data?.layout || []).map((item) => item.i),
(selectedDashboard?.data?.layout || []).map((item) => item.i),
);
// Filter and deduplicate widgets by ID, keeping only those with layout entries
// and excluding row widgets since they are not panels that can have variables
const widgets = Object.values(
(dashboardData?.data?.widgets || []).reduce(
(selectedDashboard?.data?.widgets || []).reduce(
(acc: Record<string, WidgetRow | Widgets>, widget: WidgetRow | Widgets) => {
if (
widget.id &&

View File

@@ -87,7 +87,7 @@ function VariablesSettings({
const { t } = useTranslation(['dashboard']);
const { dashboardData, setDashboardData } = useDashboardStore();
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const { notifications } = useNotifications();
@@ -173,7 +173,7 @@ function VariablesSettings({
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
@@ -181,16 +181,16 @@ function VariablesSettings({
(currentRequestedId &&
updatedVariablesData[currentRequestedId || '']?.type === 'DYNAMIC' &&
addDynamicVariableToPanels(
dashboardData,
selectedDashboard,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
dashboardData;
selectedDashboard;
updateMutation.mutateAsync(
{
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...newDashboard.data,
@@ -200,7 +200,7 @@ function VariablesSettings({
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
setSelectedDashboard(updatedDashboard.data);
notifications.success({
message: t('variable_updated_successfully'),
});

View File

@@ -15,11 +15,11 @@ import './GeneralSettings.styles.scss';
const { Option } = Select;
function GeneralDashboardSettings(): JSX.Element {
const { dashboardData, setDashboardData } = useDashboardStore();
const { selectedDashboard, setSelectedDashboard } = useDashboardStore();
const updateDashboardMutation = useUpdateDashboard();
const selectedData = dashboardData?.data;
const selectedData = selectedDashboard?.data;
const { title = '', tags = [], description = '', image = Base64Icons[0] } =
selectedData || {};
@@ -37,15 +37,15 @@ function GeneralDashboardSettings(): JSX.Element {
const { t } = useTranslation('common');
const onSaveHandler = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
updateDashboardMutation.mutate(
{
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
description: updatedDescription,
tags: updatedTags,
title: updatedTitle,
@@ -55,7 +55,7 @@ function GeneralDashboardSettings(): JSX.Element {
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
setSelectedDashboard(updatedDashboard.data);
}
},
onError: () => {},

View File

@@ -41,7 +41,7 @@ const DASHBOARD_VARIABLES_WARNING =
// Use wildcard pattern to match both relative and absolute URLs in MSW
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
const mockDashboardData = {
const mockSelectedDashboard = {
id: MOCK_DASHBOARD_ID,
data: {
title: 'Test Dashboard',
@@ -70,7 +70,7 @@ beforeEach(() => {
// Mock useDashboardStore
mockUseDashboard.mockReturnValue(({
dashboardData: mockDashboardData,
selectedDashboard: mockSelectedDashboard,
} as unknown) as ReturnType<typeof useDashboardStore>);
// Mock useCopyToClipboard

View File

@@ -60,7 +60,7 @@ function PublicDashboardSetting(): JSX.Element {
const [defaultTimeRange, setDefaultTimeRange] = useState(DEFAULT_TIME_RANGE);
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
@@ -85,8 +85,8 @@ function PublicDashboardSetting(): JSX.Element {
refetch: refetchPublicDashboard,
error: errorPublicDashboard,
} = useGetPublicDashboardMeta(
dashboardData?.id || '',
!!dashboardData?.id && isPublicDashboardEnabled,
selectedDashboard?.id || '',
!!selectedDashboard?.id && isPublicDashboardEnabled,
);
const isPublicDashboard = !!publicDashboardData?.publicPath;
@@ -155,36 +155,36 @@ function PublicDashboardSetting(): JSX.Element {
});
const handleCreatePublicDashboard = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
createPublicDashboard({
dashboardId: dashboardData.id,
dashboardId: selectedDashboard.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleUpdatePublicDashboard = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
updatePublicDashboard({
dashboardId: dashboardData.id,
dashboardId: selectedDashboard.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleRevokePublicDashboardAccess = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
revokePublicDashboardAccess({
id: dashboardData.id,
id: selectedDashboard.id,
});
};

View File

@@ -26,10 +26,10 @@ import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const { dashboardId, setDashboardData } = useDashboardStore(
const { dashboardId, setSelectedDashboard } = useDashboardStore(
useShallow((s) => ({
dashboardId: s.dashboardData?.id ?? '',
setDashboardData: s.setDashboardData,
dashboardId: s.selectedDashboard?.id ?? '',
setSelectedDashboard: s.setSelectedDashboard,
})),
);
@@ -99,7 +99,7 @@ function DashboardVariableSelection(): JSX.Element | null {
// Synchronously update the external store with the new variable value so that
// child variables see the updated parent value when they refetch, rather than
// waiting for setDashboardData → useEffect → updateDashboardVariablesStore.
// waiting for setSelectedDashboard → useEffect → updateDashboardVariablesStore.
const updatedVariables = { ...dashboardVariables };
if (updatedVariables[id]) {
updatedVariables[id] = {
@@ -119,7 +119,7 @@ function DashboardVariableSelection(): JSX.Element | null {
}
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
setDashboardData((prev) => {
setSelectedDashboard((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
// this is added to handle case where we have two different
@@ -157,7 +157,7 @@ function DashboardVariableSelection(): JSX.Element | null {
// Safe to call synchronously now that the store already has the updated value.
enqueueDescendantsOfVariable(name);
},
[dashboardId, dashboardVariables, updateUrlVariable, setDashboardData],
[dashboardId, dashboardVariables, updateUrlVariable, setSelectedDashboard],
);
return (

View File

@@ -30,11 +30,11 @@ const mockVariableItemCallbacks: {
} = {};
// Mock providers/Dashboard/Dashboard
const mockSetDashboardData = jest.fn();
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
interface MockDashboardStoreState {
dashboardData?: { id: string };
setDashboardData: typeof mockSetDashboardData;
selectedDashboard?: { id: string };
setSelectedDashboard: typeof mockSetSelectedDashboard;
updateLocalStorageDashboardVariables: typeof mockUpdateLocalStorageDashboardVariables;
}
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
@@ -42,8 +42,8 @@ jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
selector?: (s: Record<string, unknown>) => MockDashboardStoreState,
): MockDashboardStoreState => {
const state = {
dashboardData: { id: 'dash-1' },
setDashboardData: mockSetDashboardData,
selectedDashboard: { id: 'dash-1' },
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
};
return selector ? selector(state) : state;

View File

@@ -38,11 +38,15 @@ interface UseDashboardVariableUpdateReturn {
}
export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn => {
const { dashboardId, dashboardData, setDashboardData } = useDashboardStore(
const {
dashboardId,
selectedDashboard,
setSelectedDashboard,
} = useDashboardStore(
useShallow((s) => ({
dashboardId: s.dashboardData?.id ?? '',
dashboardData: s.dashboardData,
setDashboardData: s.setDashboardData,
dashboardId: s.selectedDashboard?.id ?? '',
selectedDashboard: s.selectedDashboard,
setSelectedDashboard: s.setSelectedDashboard,
})),
);
const addDynamicVariableToPanels = useAddDynamicVariableToPanels();
@@ -70,8 +74,8 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
isDynamic,
);
if (dashboardData) {
setDashboardData((prev) => {
if (selectedDashboard) {
setSelectedDashboard((prev) => {
if (prev) {
const oldVariables = prev?.data.variables;
// this is added to handle case where we have two different
@@ -106,7 +110,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
}
}
},
[dashboardId, dashboardData, setDashboardData],
[dashboardId, selectedDashboard, setSelectedDashboard],
);
const updateVariables = useCallback(
@@ -116,23 +120,23 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
widgetIds?: string[],
applyToAll?: boolean,
): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const newDashboard =
(currentRequestedId &&
addDynamicVariableToPanels(
dashboardData,
selectedDashboard,
updatedVariablesData[currentRequestedId || ''],
widgetIds,
applyToAll,
)) ||
dashboardData;
selectedDashboard;
updateMutation.mutateAsync(
{
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...newDashboard.data,
@@ -142,7 +146,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
{
onSuccess: (updatedDashboard) => {
if (updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
setSelectedDashboard(updatedDashboard.data);
// notifications.success({
// message: t('variable_updated_successfully'),
// });
@@ -151,7 +155,12 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
},
);
},
[dashboardData, addDynamicVariableToPanels, updateMutation, setDashboardData],
[
selectedDashboard,
addDynamicVariableToPanels,
updateMutation,
setSelectedDashboard,
],
);
const createVariable = useCallback(
@@ -163,13 +172,13 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
source: 'logs' | 'traces' | 'metrics' | 'all sources' = 'all sources',
// widgetId?: string,
): void => {
if (!dashboardData) {
if (!selectedDashboard) {
console.warn('No dashboard selected for variable creation');
return;
}
// Get current dashboard variables
const currentVariables = dashboardData.data.variables || {};
const currentVariables = selectedDashboard.data.variables || {};
// Create tableRowData like Dashboard Settings does
const tableRowData = [];
@@ -225,7 +234,7 @@ export const useDashboardVariableUpdate = (): UseDashboardVariableUpdateReturn =
const updatedVariables = convertVariablesToDbFormat(tableRowData);
updateVariables(updatedVariables, newVariable.id, [], false);
},
[dashboardData, updateVariables],
[selectedDashboard, updateVariables],
);
return {

View File

@@ -47,12 +47,12 @@ const mockDashboard = {
};
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetDashboardData = jest.fn();
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
dashboardData: mockDashboard,
setDashboardData: mockSetDashboardData,
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
}),
}));

View File

@@ -58,7 +58,7 @@ const mockDashboard = {
// Mock dependencies
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
dashboardData: mockDashboard,
selectedDashboard: mockDashboard,
}),
}));
@@ -154,7 +154,7 @@ describe('Panel Management Tests', () => {
// Temporarily mock the dashboard
jest.doMock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
dashboardData: modifiedDashboard,
selectedDashboard: modifiedDashboard,
}),
}));

View File

@@ -13,13 +13,13 @@ import './DashboardBreadcrumbs.styles.scss';
function DashboardBreadcrumbs(): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const { dashboardData } = useDashboardStore();
const updatedAtRef = useRef(dashboardData?.updatedAt);
const { selectedDashboard } = useDashboardStore();
const updatedAtRef = useRef(selectedDashboard?.updatedAt);
const selectedData = dashboardData
const selectedData = selectedDashboard
? {
...dashboardData.data,
uuid: dashboardData.id,
...selectedDashboard.data,
uuid: selectedDashboard.id,
}
: ({} as DashboardData);
@@ -31,7 +31,7 @@ function DashboardBreadcrumbs(): JSX.Element {
);
const hasDashboardBeenUpdated =
dashboardData?.updatedAt !== updatedAtRef.current;
selectedDashboard?.updatedAt !== updatedAtRef.current;
if (!hasDashboardBeenUpdated && dashboardsListQueryParamsString) {
safeNavigate({
pathname: ROUTES.ALL_DASHBOARD,
@@ -40,7 +40,7 @@ function DashboardBreadcrumbs(): JSX.Element {
} else {
safeNavigate(ROUTES.ALL_DASHBOARD);
}
}, [safeNavigate, dashboardData?.updatedAt]);
}, [safeNavigate, selectedDashboard?.updatedAt]);
return (
<div className="dashboard-breadcrumbs">

View File

@@ -1,4 +1,4 @@
import { useCallback, useMemo, useRef } from 'react';
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
@@ -30,7 +30,6 @@ export default function ChartWrapper({
onDestroy = noop,
children,
layoutChildren,
yAxisUnit,
customTooltip,
pinnedTooltipElement,
'data-testid': testId,
@@ -63,13 +62,6 @@ export default function ChartWrapper({
[customTooltip],
);
const syncMetadata = useMemo(
() => ({
yAxisUnit,
}),
[yAxisUnit],
);
return (
<PlotContextProvider>
<ChartLayout
@@ -107,7 +99,6 @@ export default function ChartWrapper({
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
syncMetadata={syncMetadata}
render={renderTooltipCallback}
pinnedTooltipElement={pinnedTooltipElement}
/>

View File

@@ -24,12 +24,13 @@ export default function Histogram(props: HistogramChartProps): JSX.Element {
}
const tooltipProps: HistogramTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
};
return <HistogramTooltip {...tooltipProps} />;
},
[customTooltip, rest.yAxisUnit, rest.decimalPrecision],
[customTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (

View File

@@ -12,7 +12,10 @@ interface BaseChartProps {
height: number;
showTooltip?: boolean;
showLegend?: boolean;
timezone?: Timezone;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
pinnedTooltipElement?: (clickData: TooltipClickData) => React.ReactNode;
customTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
@@ -29,31 +32,18 @@ interface UPlotBasedChartProps {
layoutChildren?: React.ReactNode;
}
interface UPlotChartDataProps {
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
timezone?: Timezone;
}
UPlotBasedChartProps {}
export interface HistogramChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
UPlotBasedChartProps {
isQueriesMerged?: boolean;
}
export interface BarChartProps
extends BaseChartProps,
UPlotBasedChartProps,
UPlotChartDataProps {
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
isStackedBarChart?: boolean;
timezone?: Timezone;
}
export type ChartProps =

View File

@@ -35,15 +35,15 @@ jest.mock('lib/uPlotV2/hooks/useLegendsSync', () => ({
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (
selector?: (s: {
dashboardData: { locked: boolean } | undefined;
}) => { dashboardData: { locked: boolean } },
): { dashboardData: { locked: boolean } } => {
const mockState = { dashboardData: { locked: false } };
selectedDashboard: { locked: boolean } | undefined;
}) => { selectedDashboard: { locked: boolean } },
): { selectedDashboard: { locked: boolean } } => {
const mockState = { selectedDashboard: { locked: false } };
return selector ? selector(mockState) : mockState;
},
selectIsDashboardLocked: (s: {
dashboardData: { locked: boolean } | undefined;
}): boolean => s.dashboardData?.locked ?? false,
selectedDashboard: { locked: boolean } | undefined;
}): boolean => s.selectedDashboard?.locked ?? false,
}));
jest.mock('hooks/useNotifications', () => ({

View File

@@ -123,13 +123,13 @@ function BarPanel(props: PanelWrapperProps): JSX.Element {
}}
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
>
<ContextMenu

View File

@@ -3,6 +3,8 @@ import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import { useTimezone } from 'providers/Timezone';
import uPlot from 'uplot';
import Histogram from '../../charts/Histogram/Histogram';
@@ -27,6 +29,7 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
const config = useMemo(() => {
return prepareHistogramPanelConfig({
@@ -89,9 +92,11 @@ function HistogramPanel(props: PanelWrapperProps): JSX.Element {
onDestroy={(): void => {
uPlotRef.current = null;
}}
isQueriesMerged={widget.mergeAllActiveQueries}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
isQueriesMerged={widget.mergeAllActiveQueries}
syncMode={DashboardCursorSync.Crosshair}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -48,8 +48,8 @@ jest.mock(
{JSON.stringify({
legendPosition: props.legendConfig?.position,
isQueriesMerged: props.isQueriesMerged,
yAxisUnit: props?.yAxisUnit,
decimalPrecision: props?.decimalPrecision,
yAxisUnit: props.yAxisUnit,
decimalPrecision: props.decimalPrecision,
})}
</div>
{props.layoutChildren}

View File

@@ -112,9 +112,9 @@ function TimeSeriesPanel(props: PanelWrapperProps): JSX.Element {
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
timezone={timezone}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}

View File

@@ -123,6 +123,7 @@
&__row {
display: flex;
flex-direction: row;
flex-wrap: wrap;
align-items: flex-end;
max-width: 825px;
gap: 25px;

View File

@@ -24,7 +24,9 @@ function ExportPanelContainer({
}: ExportPanelProps): JSX.Element {
const { t } = useTranslation(['dashboard']);
const [dashboardId, setDashboardId] = useState<string | null>(null);
const [selectedDashboardId, setSelectedDashboardId] = useState<string | null>(
null,
);
const {
data,
@@ -53,17 +55,17 @@ function ExportPanelContainer({
const handleExportClick = useCallback((): void => {
const currentSelectedDashboard = data?.data?.find(
({ id }) => id === dashboardId,
({ id }) => id === selectedDashboardId,
);
onExport(currentSelectedDashboard || null, false);
}, [data, dashboardId, onExport]);
}, [data, selectedDashboardId, onExport]);
const handleSelect = useCallback(
(selectedDashboardId: string): void => {
setDashboardId(selectedDashboardId);
(selectedDashboardValue: string): void => {
setSelectedDashboardId(selectedDashboardValue);
},
[setDashboardId],
[setSelectedDashboardId],
);
const handleNewDashboard = useCallback(async () => {
@@ -83,7 +85,10 @@ function ExportPanelContainer({
const isDashboardLoading = isAllDashboardsLoading || createDashboardLoading;
const isDisabled =
isAllDashboardsLoading || !options?.length || !dashboardId || isLoading;
isAllDashboardsLoading ||
!options?.length ||
!selectedDashboardId ||
isLoading;
return (
<Wrapper direction="vertical">
@@ -96,7 +101,7 @@ function ExportPanelContainer({
showSearch
loading={isDashboardLoading}
disabled={isDashboardLoading}
value={dashboardId}
value={selectedDashboardId}
onSelect={handleSelect}
filterOption={filterOptions}
/>

View File

@@ -27,7 +27,7 @@ export default function DashboardEmptyState(): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const variablesSettingsTabHandle = useRef<VariablesSettingsTab>(null);
@@ -43,7 +43,7 @@ export default function DashboardEmptyState(): JSX.Element {
}
const userRole: ROLES | null =
dashboardData?.createdBy === user?.email
selectedDashboard?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -52,9 +52,9 @@ export default function DashboardEmptyState(): JSX.Element {
const onEmptyWidgetHandler = useCallback(() => {
setIsPanelTypeSelectionModalOpen(true);
logEvent('Dashboard Detail: Add new panel clicked', {
dashboardId: dashboardData?.id,
dashboardName: dashboardData?.data.title,
numberOfPanels: dashboardData?.data.widgets?.length,
dashboardId: selectedDashboard?.id,
dashboardName: selectedDashboard?.data.title,
numberOfPanels: selectedDashboard?.data.widgets?.length,
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setIsPanelTypeSelectionModalOpen]);

View File

@@ -91,7 +91,7 @@ function FullView({
setCurrentGraphRef(fullViewRef);
}, [setCurrentGraphRef]);
const { dashboardData, setColumnWidths } = useDashboardStore();
const { selectedDashboard, setColumnWidths } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const onColumnWidthsChange = useCallback(
@@ -166,7 +166,7 @@ function FullView({
enableDrillDown,
widget,
setRequestData,
dashboardData,
selectedDashboard,
selectedPanelType,
});
@@ -344,7 +344,7 @@ function FullView({
<>
<QueryBuilderV2
panelType={selectedPanelType}
version={dashboardData?.data?.version || 'v3'}
version={selectedDashboard?.data?.version || 'v3'}
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
signalSourceChangeEnabled
// filterConfigs={filterConfigs}

View File

@@ -19,7 +19,7 @@ export interface DrilldownQueryProps {
widget: Widgets;
setRequestData: Dispatch<SetStateAction<GetQueryResultsProps>>;
enableDrillDown: boolean;
dashboardData: Dashboard | undefined;
selectedDashboard: Dashboard | undefined;
selectedPanelType: PANEL_TYPES;
}
@@ -34,7 +34,7 @@ const useDrilldown = ({
enableDrillDown,
widget,
setRequestData,
dashboardData,
selectedDashboard,
selectedPanelType,
}: DrilldownQueryProps): UseDrilldownReturn => {
const isMounted = useRef(false);
@@ -60,11 +60,11 @@ const useDrilldown = ({
isMounted.current = true;
}, [widget, enableDrillDown, compositeQuery, redirectWithQueryBuilderData]);
const dashboardEditView = dashboardData?.id
const dashboardEditView = selectedDashboard?.id
? generateExportToDashboardLink({
query: currentQuery,
panelType: selectedPanelType,
dashboardId: dashboardData?.id || '',
dashboardId: selectedDashboard?.id || '',
widgetId: widget.id,
})
: '';

View File

@@ -163,13 +163,13 @@ const mockProps: WidgetGraphComponentProps = {
// Mock useDashabord hook
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
dashboardData: {
selectedDashboard: {
data: {
variables: [],
},
},
setLayouts: jest.fn(),
setDashboardData: jest.fn(),
setSelectedDashboard: jest.fn(),
setColumnWidths: jest.fn(),
}),
}));

View File

@@ -103,8 +103,8 @@ function WidgetGraphComponent({
const {
setLayouts,
dashboardData,
setDashboardData,
selectedDashboard,
setSelectedDashboard,
setColumnWidths,
} = useDashboardStore();
@@ -125,33 +125,33 @@ function WidgetGraphComponent({
const updateDashboardMutation = useUpdateDashboard();
const onDeleteHandler = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const updatedWidgets = dashboardData?.data?.widgets?.filter(
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
(e) => e.id !== widget.id,
);
const updatedLayout =
dashboardData.data.layout?.filter((e) => e.i !== widget.id) || [];
selectedDashboard.data.layout?.filter((e) => e.i !== widget.id) || [];
const updatedDashboardData: Props = {
const updatedSelectedDashboard: Props = {
data: {
...dashboardData.data,
...selectedDashboard.data,
widgets: updatedWidgets,
layout: updatedLayout,
},
id: dashboardData.id,
id: selectedDashboard.id,
};
updateDashboardMutation.mutateAsync(updatedDashboardData, {
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
}
setDeleteModal(false);
},
@@ -159,35 +159,35 @@ function WidgetGraphComponent({
};
const onCloneHandler = async (): Promise<void> => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const uuid = v4();
// this is added to make sure the cloned panel is of the same dimensions as the original one
const originalPanelLayout = dashboardData.data.layout?.find(
const originalPanelLayout = selectedDashboard.data.layout?.find(
(l) => l.i === widget.id,
);
const newLayoutItem = placeWidgetAtBottom(
uuid,
dashboardData?.data.layout || [],
selectedDashboard?.data.layout || [],
originalPanelLayout?.w || 6,
originalPanelLayout?.h || 6,
);
const layout = [...(dashboardData.data.layout || []), newLayoutItem];
const layout = [...(selectedDashboard.data.layout || []), newLayoutItem];
updateDashboardMutation.mutateAsync(
{
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
layout,
widgets: [
...(dashboardData.data.widgets || []),
...(selectedDashboard.data.widgets || []),
{
...{
...widget,
@@ -202,8 +202,8 @@ function WidgetGraphComponent({
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
}
notifications.success({
message: 'Panel cloned successfully, redirecting to new copy.',

View File

@@ -70,16 +70,16 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
useIsFetching([REACT_QUERY_KEY.DASHBOARD_BY_ID]) > 0;
const {
dashboardData,
selectedDashboard,
layouts,
setLayouts,
panelMap,
setPanelMap,
setDashboardData,
setSelectedDashboard,
columnWidths,
} = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const { data } = dashboardData || {};
const { data } = selectedDashboard || {};
const { pathname } = useLocation();
const dispatch = useDispatch();
@@ -124,7 +124,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}
const userRole: ROLES | null =
dashboardData?.createdBy === user?.email
selectedDashboard?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
@@ -146,27 +146,27 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
useEffect(() => {
if (!logEventCalledRef.current && !isUndefined(data)) {
logEvent('Dashboard Detail: Opened', {
dashboardId: dashboardData?.id,
dashboardId: selectedDashboard?.id,
dashboardName: data.title,
numberOfPanels: data.widgets?.length,
numberOfVariables: Object.keys(dashboardVariables).length || 0,
});
logEventCalledRef.current = true;
}
}, [dashboardVariables, data, dashboardData?.id]);
}, [dashboardVariables, data, selectedDashboard?.id]);
const onSaveHandler = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const updatedDashboard: Props = {
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
panelMap: { ...currentPanelMap },
layout: dashboardLayout.filter((e) => e.i !== PANEL_TYPES.EMPTY_WIDGET),
widgets: dashboardData?.data?.widgets?.map((widget) => {
widgets: selectedDashboard?.data?.widgets?.map((widget) => {
if (columnWidths?.[widget.id]) {
return {
...widget,
@@ -184,7 +184,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
if (updatedDashboard.data.data.layout) {
setLayouts(sortLayout(updatedDashboard.data.data.layout));
}
setDashboardData(updatedDashboard.data);
setSelectedDashboard(updatedDashboard.data);
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
}
},
@@ -243,7 +243,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
dashboardLayout &&
Array.isArray(dashboardLayout) &&
dashboardLayout.length > 0 &&
hasColumnWidthsChanged(columnWidths, dashboardData);
hasColumnWidthsChanged(columnWidths, selectedDashboard);
if (shouldSaveLayout || shouldSaveColumnWidths) {
onSaveHandler();
@@ -253,7 +253,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
const onSettingsModalSubmit = (): void => {
const newTitle = form.getFieldValue('title');
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
@@ -261,7 +261,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const currentWidget = dashboardData?.data?.widgets?.find(
const currentWidget = selectedDashboard?.data?.widgets?.find(
(e) => e.id === currentSelectRowId,
);
@@ -269,25 +269,25 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const updatedWidgets = dashboardData?.data?.widgets?.map((e) =>
const updatedWidgets = selectedDashboard?.data?.widgets?.map((e) =>
e.id === currentSelectRowId ? { ...e, title: newTitle } : e,
);
const updatedDashboardData: Props = {
id: dashboardData.id,
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
widgets: updatedWidgets,
},
};
updateDashboardMutation.mutateAsync(updatedDashboardData, {
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
}
if (setPanelMap) {
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
@@ -311,7 +311,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
}, [currentSelectRowId, form, widgets]);
const handleRowCollapse = (id: string): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const { updatedLayout, updatedPanelMap } = applyRowCollapse(
@@ -343,7 +343,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
};
const handleRowDelete = (): void => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
@@ -351,33 +351,34 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
return;
}
const updatedWidgets = dashboardData?.data?.widgets?.filter(
const updatedWidgets = selectedDashboard?.data?.widgets?.filter(
(e) => e.id !== currentSelectRowId,
);
const updatedLayout =
dashboardData.data.layout?.filter((e) => e.i !== currentSelectRowId) || [];
selectedDashboard.data.layout?.filter((e) => e.i !== currentSelectRowId) ||
[];
const updatedPanelMap = { ...currentPanelMap };
delete updatedPanelMap[currentSelectRowId];
const updatedDashboardData: Props = {
id: dashboardData.id,
const updatedSelectedDashboard: Props = {
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
widgets: updatedWidgets,
layout: updatedLayout,
panelMap: updatedPanelMap,
},
};
updateDashboardMutation.mutateAsync(updatedDashboardData, {
updateDashboardMutation.mutateAsync(updatedSelectedDashboard, {
onSuccess: (updatedDashboard) => {
if (setLayouts) {
setLayouts(updatedDashboard.data?.data?.layout || []);
}
if (setDashboardData && updatedDashboard.data) {
setDashboardData(updatedDashboard.data);
if (setSelectedDashboard && updatedDashboard.data) {
setSelectedDashboard(updatedDashboard.data);
}
if (setPanelMap) {
setPanelMap(updatedDashboard.data?.data?.panelMap || {});
@@ -389,8 +390,10 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
};
const isDashboardEmpty = useMemo(
() =>
dashboardData?.data.layout ? dashboardData?.data.layout?.length === 0 : true,
[dashboardData],
selectedDashboard?.data.layout
? selectedDashboard?.data.layout?.length === 0
: true,
[selectedDashboard],
);
let isDataAvailableInAnyWidget = false;
@@ -509,7 +512,7 @@ function GraphLayout(props: GraphLayoutProps): JSX.Element {
widget={(currentWidget as Widgets) || ({ id, query: {} } as Widgets)}
headerMenuList={widgetActions}
variables={dashboardVariables}
// version={dashboardData?.data?.version}
// version={selectedDashboard?.data?.version}
version={ENTITY_VERSION_V5}
onDragSelect={onDragSelect}
dataAvailable={checkIfDataExists}

View File

@@ -42,14 +42,14 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
(s) => s.setIsPanelTypeSelectionModalOpen,
);
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
const isDashboardLocked = useDashboardStore(selectIsDashboardLocked);
const permissions: ComponentTypes[] = ['add_panel'];
const { user } = useAppContext();
const userRole: ROLES | null =
dashboardData?.createdBy === user?.email
selectedDashboard?.createdBy === user?.email
? (USER_ROLES.AUTHOR as ROLES)
: user.role;
const [addPanelPermission] = useComponentPermission(permissions, userRole);
@@ -87,11 +87,11 @@ export function WidgetRowHeader(props: WidgetRowHeaderProps): JSX.Element {
icon={<Plus size={14} />}
onClick={(): void => {
// TODO: @AshwinBhatkal Simplify this check in cleanup of https://github.com/SigNoz/engineering-pod/issues/3953
if (!dashboardData?.id) {
if (!selectedDashboard?.id) {
return;
}
setSelectedRowWidgetId(dashboardData.id, id);
setSelectedRowWidgetId(selectedDashboard.id, id);
setIsPanelTypeSelectionModalOpen(true);
}}
>

View File

@@ -121,7 +121,7 @@ function useNavigateToExplorerPages(): (
) => Promise<{
[queryName: string]: { filters: TagFilterItem[]; dataSource?: string };
}> {
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
const { notifications } = useNotifications();
return useCallback(
@@ -143,7 +143,7 @@ function useNavigateToExplorerPages(): (
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[dashboardData, notifications],
[selectedDashboard, notifications],
);
}

View File

@@ -20,7 +20,7 @@ interface UseUpdatedQueryOptions {
panelTypes: PANEL_TYPES;
timePreferance: timePreferenceType;
};
dashboardData?: any;
selectedDashboard?: any;
}
interface UseUpdatedQueryResult {
@@ -44,7 +44,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
const getUpdatedQuery = useCallback(
async ({
widgetConfig,
dashboardData,
selectedDashboard,
}: UseUpdatedQueryOptions): Promise<Query> => {
// Prepare query payload with resolved variables
const { queryPayload } = prepareQueryRangePayloadV5({
@@ -52,7 +52,7 @@ function useUpdatedQuery(): UseUpdatedQueryResult {
graphType: getGraphType(widgetConfig.panelTypes),
selectedTime: widgetConfig.timePreferance,
globalSelectedInterval,
variables: getDashboardVariables(dashboardData?.data?.variables),
variables: getDashboardVariables(selectedDashboard?.data?.variables),
originalGraphType: widgetConfig.panelTypes,
dynamicVariables: dashboardDynamicVariables,
});

View File

@@ -149,16 +149,16 @@ export function extractQueryNamesFromExpression(expression: string): string[] {
export const hasColumnWidthsChanged = (
columnWidths: Record<string, Record<string, number>>,
dashboardData?: Dashboard,
selectedDashboard?: Dashboard,
): boolean => {
// If no column widths stored, no changes
if (isEmpty(columnWidths) || !dashboardData) {
if (isEmpty(columnWidths) || !selectedDashboard) {
return false;
}
// Check each widget's column widths
return Object.keys(columnWidths).some((widgetId) => {
const dashboardWidget = dashboardData?.data?.widgets?.find(
const dashboardWidget = selectedDashboard?.data?.widgets?.find(
(widget) => widget.id === widgetId,
) as Widgets;

View File

@@ -37,10 +37,6 @@ jest.mock('utils/navigation', () => ({
const openInNewTabMock = openInNewTab as jest.Mock;
// Mock Date.now to prevent flaky tests due to time-dependent values
const MOCK_NOW = 1700000000000; // Fixed timestamp
jest.spyOn(Date, 'now').mockReturnValue(MOCK_NOW);
// Mock DrawerWrapper to avoid CSS issues with jsdom
// SyntaxError: 'div#radix-:rbv,,._dialog__content_qf8bf_22 :focus' is not a valid selector
jest.mock('@signozhq/ui', () => ({
@@ -192,7 +188,6 @@ describe('K8sBaseList', () => {
await waitFor(() => {
const selectedItem = onUrlUpdateMock.mock.calls
.map((call) => call[0].searchParams.get('selectedItem'))
.filter(Boolean)
.pop();
expect(selectedItem).toBe(`PodId:${itemId}`);
});

View File

@@ -11,6 +11,12 @@
}
}
.infra-metrics-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 24px;
}
.infra-metrics-card {
margin: 1rem 0;
height: 300px;

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -163,16 +163,16 @@ function NodeMetrics({
);
};
return (
<Row gutter={24}>
<div className="infra-metrics-grid">
{queries.map((query, idx) => (
<Col span={12} key={widgetInfo[idx].title}>
<div key={widgetInfo[idx].title}>
<Typography.Text>{widgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</Col>
</div>
))}
</Row>
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { useMemo, useRef } from 'react';
import { useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import { Card, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
@@ -146,16 +146,16 @@ function PodMetrics({
};
return (
<Row gutter={24}>
<div className="infra-metrics-grid">
{queries.map((query, idx) => (
<Col span={12} key={podWidgetInfo[idx].title}>
<div key={podWidgetInfo[idx].title}>
<Typography.Text>{podWidgetInfo[idx].title}</Typography.Text>
<Card bordered className="infra-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</Col>
</div>
))}
</Row>
</div>
);
}

View File

@@ -93,8 +93,8 @@ jest.mock('hooks/useDarkMode', () => ({
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): { dashboardData: undefined } => ({
dashboardData: undefined,
useDashboardStore: (): { selectedDashboard: undefined } => ({
selectedDashboard: undefined,
}),
}));

View File

@@ -106,7 +106,7 @@ describe('LogsPanelComponent', () => {
<PreferenceContextProvider>
<NewWidget
dashboardId=""
dashboardData={undefined}
selectedDashboard={undefined}
selectedGraph={PANEL_TYPES.LIST}
/>
</PreferenceContextProvider>

View File

@@ -30,7 +30,7 @@ function LeftContainer({
setRequestData,
setQueryResponse,
enableDrillDown = false,
dashboardData,
selectedDashboard,
isNewPanel = false,
}: WidgetGraphProps): JSX.Element {
const { stagedQuery } = useQueryBuilder();
@@ -79,8 +79,8 @@ function LeftContainer({
isLoadingQueries={queryResponse.isFetching}
selectedWidget={selectedWidget}
dashboardVersion={ENTITY_VERSION_V5}
dashboardId={dashboardData?.id}
dashboardName={dashboardData?.data.title}
dashboardId={selectedDashboard?.id}
dashboardName={selectedDashboard?.data.title}
isNewPanel={isNewPanel}
/>
{selectedGraph === PANEL_TYPES.LIST && (

View File

@@ -326,7 +326,7 @@ describe('Stacking bar in new panel', () => {
<PreferenceContextProvider>
<NewWidget
dashboardId=""
dashboardData={undefined}
selectedDashboard={undefined}
selectedGraph={PANEL_TYPES.BAR}
/>
</PreferenceContextProvider>
@@ -378,7 +378,7 @@ describe('when switching to BAR panel type', () => {
<DashboardBootstrapWrapper dashboardId="">
<NewWidget
dashboardId=""
dashboardData={undefined}
selectedDashboard={undefined}
selectedGraph={PANEL_TYPES.BAR}
/>
</DashboardBootstrapWrapper>,

View File

@@ -86,7 +86,7 @@ import {
import './NewWidget.styles.scss';
function NewWidget({
dashboardData,
selectedDashboard,
dashboardId,
selectedGraph,
enableDrillDown = false,
@@ -135,7 +135,7 @@ function NewWidget({
[selectedGraph, globalSelectedInterval, isLogsQuery],
);
const { widgets = [] } = dashboardData?.data || {};
const { widgets = [] } = selectedDashboard?.data || {};
const query = useUrlQuery();
@@ -154,9 +154,9 @@ function NewWidget({
if (!logEventCalledRef.current) {
logEvent('Panel Edit: Page visited', {
panelType: selectedWidget?.panelTypes,
dashboardId: dashboardData?.id,
dashboardId: selectedDashboard?.id,
widgetId: selectedWidget?.id,
dashboardName: dashboardData?.data.title,
dashboardName: selectedDashboard?.data.title,
isNewPanel: !!isWidgetNotPresent,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
});
@@ -362,7 +362,7 @@ function NewWidget({
const updateDashboardMutation = useUpdateDashboard();
const { afterWidgets, preWidgets } = useMemo(() => {
if (!dashboardData) {
if (!selectedDashboard) {
return {
selectedWidget: {} as Widgets,
preWidgets: [],
@@ -372,18 +372,21 @@ function NewWidget({
const widgetId = query.get('widgetId');
const selectedWidgetIndex = getSelectedWidgetIndex(dashboardData, widgetId);
const selectedWidgetIndex = getSelectedWidgetIndex(
selectedDashboard,
widgetId,
);
const preWidgets = getPreviousWidgets(dashboardData, selectedWidgetIndex);
const preWidgets = getPreviousWidgets(selectedDashboard, selectedWidgetIndex);
const afterWidgets = getNextWidgets(dashboardData, selectedWidgetIndex);
const afterWidgets = getNextWidgets(selectedDashboard, selectedWidgetIndex);
const selectedWidget = (dashboardData.data.widgets || [])[
const selectedWidget = (selectedDashboard.data.widgets || [])[
selectedWidgetIndex || 0
];
return { selectedWidget, preWidgets, afterWidgets };
}, [dashboardData, query]);
}, [selectedDashboard, query]);
// this loading state is to take care of mismatch in the responses for table and other panels
// hence while changing the query contains the older value and the processing logic fails
@@ -480,12 +483,12 @@ function NewWidget({
}, [dashboardId, query, safeNavigate]);
const onClickSaveHandler = useCallback(() => {
if (!dashboardData) {
if (!selectedDashboard) {
return;
}
const widgetId = query.get('widgetId') || '';
let updatedLayout = dashboardData.data.layout || [];
let updatedLayout = selectedDashboard.data.layout || [];
const selectedRowWidgetId = getSelectedRowWidgetId(dashboardId);
@@ -519,10 +522,10 @@ function NewWidget({
const adjustedQueryForV5 = adjustQueryForV5(currentQuery);
const dashboard: Props = {
id: dashboardData.id,
id: selectedDashboard.id,
data: {
...dashboardData.data,
...selectedDashboard.data,
widgets: isNewDashboard
? [
...afterWidgets,
@@ -600,7 +603,7 @@ function NewWidget({
},
});
}, [
dashboardData,
selectedDashboard,
query,
isNewDashboard,
afterWidgets,
@@ -669,9 +672,9 @@ function NewWidget({
const onSaveDashboard = useCallback((): void => {
logEvent('Panel Edit: Save changes', {
panelType: selectedWidget.panelTypes,
dashboardId: dashboardData?.id,
dashboardId: selectedDashboard?.id,
widgetId: selectedWidget.id,
dashboardName: dashboardData?.data.title,
dashboardName: selectedDashboard?.data.title,
queryType: currentQuery.queryType,
isNewPanel,
dataSource: currentQuery?.builder?.queryData?.[0]?.dataSource,
@@ -867,7 +870,7 @@ function NewWidget({
<OverlayScrollbar>
{selectedWidget && (
<LeftContainer
dashboardData={dashboardData}
selectedDashboard={selectedDashboard}
selectedGraph={graphType}
selectedLogFields={selectedLogFields}
setSelectedLogFields={setSelectedLogFields}

View File

@@ -10,7 +10,7 @@ import { timePreferance } from './RightContainer/timeItems';
export interface NewWidgetProps {
dashboardId: string;
dashboardData: Dashboard | undefined;
selectedDashboard: Dashboard | undefined;
selectedGraph: PANEL_TYPES;
enableDrillDown?: boolean;
}
@@ -34,7 +34,7 @@ export interface WidgetGraphProps {
>
>;
enableDrillDown?: boolean;
dashboardData: Dashboard | undefined;
selectedDashboard: Dashboard | undefined;
isNewPanel?: boolean;
}

View File

@@ -66,7 +66,7 @@ const useBaseAggregateOptions = ({
getUpdatedQuery,
isLoading: isResolveQueryLoading,
} = useUpdatedQuery();
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
useEffect(() => {
if (!aggregateData) {
@@ -79,7 +79,7 @@ const useBaseAggregateOptions = ({
panelTypes: panelType || PANEL_TYPES.TIME_SERIES,
timePreferance: 'GLOBAL_TIME',
},
dashboardData,
selectedDashboard,
});
setResolvedQuery(updatedQuery);
};

View File

@@ -14,7 +14,7 @@ jest.mock('react-router-dom', () => ({
// Mock useDashabord hook
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): any => ({
dashboardData: {
selectedDashboard: {
data: {
variables: [],
},

View File

@@ -46,6 +46,7 @@ export const routeConfig: Record<string, QueryParams[]> = {
[ROUTES.TRACES_EXPLORER]: [QueryParams.resourceAttributes],
[ROUTES.TRACE]: [QueryParams.resourceAttributes],
[ROUTES.TRACE_DETAIL]: [QueryParams.resourceAttributes],
[ROUTES.TRACE_DETAIL_OLD]: [QueryParams.resourceAttributes],
[ROUTES.UN_AUTHORIZED]: [QueryParams.resourceAttributes],
[ROUTES.USAGE_EXPLORER]: [QueryParams.resourceAttributes],
[ROUTES.VERSION]: [QueryParams.resourceAttributes],

View File

@@ -23,7 +23,6 @@
&-empty-content {
height: 100%;
border: 1px solid var(--l1-border);
border-top: none;
display: flex;
flex-direction: column;

View File

@@ -143,6 +143,7 @@ export const routesToSkip = [
ROUTES.SETTINGS,
ROUTES.LIST_ALL_ALERT,
ROUTES.TRACE_DETAIL,
ROUTES.TRACE_DETAIL_OLD,
ROUTES.ALL_CHANNELS,
ROUTES.USAGE_EXPLORER,
ROUTES.GET_STARTED,

View File

@@ -48,7 +48,7 @@ export function useDashboardBootstrap(
);
const {
setDashboardData,
setSelectedDashboard,
setLayouts,
setPanelMap,
resetDashboardStore,
@@ -65,7 +65,7 @@ export function useDashboardBootstrap(
transformDashboardVariables,
} = useTransformDashboardVariables(dashboardId);
// Keep the external variables store in sync with dashboardData
// Keep the external variables store in sync with selectedDashboard
useDashboardVariablesSync(dashboardId);
const dashboardQuery = useDashboardQuery(dashboardId);
@@ -88,7 +88,7 @@ export function useDashboardBootstrap(
if (variables) {
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
}
setDashboardData(updatedDashboardData);
setSelectedDashboard(updatedDashboardData);
dashboardRef.current = updatedDashboardData;
setLayouts(sortLayout(getUpdatedLayout(updatedDashboardData?.data.layout)));
setPanelMap(defaultTo(updatedDashboardData?.data?.panelMap, {}));
@@ -107,7 +107,7 @@ export function useDashboardBootstrap(
title: t('dashboard_has_been_updated'),
content: t('do_you_want_to_refresh_the_dashboard'),
onOk() {
setDashboardData(updatedDashboardData);
setSelectedDashboard(updatedDashboardData);
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,

View File

@@ -13,21 +13,21 @@ import { useDashboardVariablesSelector } from './useDashboardVariables';
/**
* Keeps the external variables store in sync with the zustand dashboard store.
* When dashboardData changes, propagates variable updates to the variables store.
* When selectedDashboard changes, propagates variable updates to the variables store.
*/
export function useDashboardVariablesSync(dashboardId: string): void {
const dashboardVariables = useDashboardVariablesSelector((s) => s.variables);
const savedDashboardId = useDashboardVariablesSelector((s) => s.dashboardId);
const dashboardData = useDashboardStore(
(s: DashboardStore) => s.dashboardData,
const selectedDashboard = useDashboardStore(
(s: DashboardStore) => s.selectedDashboard,
);
useEffect(() => {
const updatedVariables = dashboardData?.data.variables || {};
const updatedVariables = selectedDashboard?.data.variables || {};
if (savedDashboardId !== dashboardId) {
setDashboardVariablesStore({ dashboardId, variables: updatedVariables });
} else if (!isEqual(dashboardVariables, updatedVariables)) {
updateDashboardVariablesStore({ dashboardId, variables: updatedVariables });
}
}, [dashboardData]); // eslint-disable-line react-hooks/exhaustive-deps
}, [selectedDashboard]); // eslint-disable-line react-hooks/exhaustive-deps
}

View File

@@ -1,7 +1,7 @@
import { useMutation } from 'react-query';
import locked from 'api/v1/dashboards/id/lock';
import {
getDashboardData,
getSelectedDashboard,
useDashboardStore,
} from 'providers/Dashboard/store/useDashboardStore';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -13,11 +13,13 @@ import APIError from 'types/api/error';
*/
export function useLockDashboard(): (value: boolean) => Promise<void> {
const { showErrorModal } = useErrorModal();
const { setDashboardData } = useDashboardStore();
const { setSelectedDashboard } = useDashboardStore();
const { mutate: lockDashboard } = useMutation(locked, {
onSuccess: (_, props) => {
setDashboardData((prev) => (prev ? { ...prev, locked: props.lock } : prev));
setSelectedDashboard((prev) =>
prev ? { ...prev, locked: props.lock } : prev,
);
},
onError: (error) => {
showErrorModal(error as APIError);
@@ -25,11 +27,11 @@ export function useLockDashboard(): (value: boolean) => Promise<void> {
});
return async (value: boolean): Promise<void> => {
const dashboardData = getDashboardData();
if (dashboardData) {
const selectedDashboard = getSelectedDashboard();
if (selectedDashboard) {
try {
await lockDashboard({
id: dashboardData.id,
id: selectedDashboard.id,
lock: value,
});
} catch (error) {

View File

@@ -12,11 +12,11 @@ import { useDashboardVariablesByType } from './useDashboardVariablesByType';
*/
export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
const dynamicVariables = useDashboardVariablesByType('DYNAMIC', 'values');
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
return useMemo(() => {
const widgets =
dashboardData?.data?.widgets?.filter(
selectedDashboard?.data?.widgets?.filter(
(widget) => widget.panelTypes !== PANEL_GROUP_TYPES.ROW,
) || [];
@@ -24,5 +24,5 @@ export function useWidgetsByDynamicVariableId(): Record<string, string[]> {
dynamicVariables,
widgets as Widgets[],
);
}, [dashboardData, dynamicVariables]);
}, [selectedDashboard, dynamicVariables]);
}

View File

@@ -1,197 +0,0 @@
import { useMutation } from 'react-query';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { act, renderHook } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import { mapQueryDataFromApi } from 'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi';
import { Widgets } from 'types/api/dashboard/getAll';
import { EQueryType } from 'types/common/dashboard';
import useCreateAlerts from '../useCreateAlerts';
jest.mock('react-query', () => ({
useMutation: jest.fn(),
}));
jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('api/common/logEvent', () => ({
__esModule: true,
default: jest.fn(),
}));
jest.mock('api/dashboard/substitute_vars', () => ({
getSubstituteVars: jest.fn(),
}));
jest.mock('api/v5/v5', () => ({
prepareQueryRangePayloadV5: jest.fn().mockReturnValue({ queryPayload: {} }),
}));
jest.mock(
'lib/newQueryBuilder/queryBuilderMappers/mapQueryDataFromApi',
() => ({
mapQueryDataFromApi: jest.fn(),
}),
);
jest.mock('hooks/dashboard/useDashboardVariables', () => ({
useDashboardVariables: (): unknown => ({ dashboardVariables: {} }),
}));
jest.mock('hooks/dashboard/useDashboardVariablesByType', () => ({
useDashboardVariablesByType: (): unknown => ({}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): unknown => ({
notifications: { error: jest.fn() },
}),
}));
jest.mock('lib/dashboardVariables/getDashboardVariables', () => ({
getDashboardVariables: (): unknown => ({}),
}));
jest.mock('providers/Dashboard/store/useDashboardStore', () => ({
useDashboardStore: (): unknown => ({ dashboardData: undefined }),
}));
jest.mock('utils/getGraphType', () => ({
getGraphType: jest.fn().mockReturnValue('time_series'),
}));
const mockMapQueryDataFromApi = mapQueryDataFromApi as jest.MockedFunction<
typeof mapQueryDataFromApi
>;
const mockUseMutation = useMutation as jest.MockedFunction<typeof useMutation>;
const mockUseSelector = useSelector as jest.MockedFunction<typeof useSelector>;
const buildWidget = (queryType: EQueryType | undefined): Widgets =>
(({
id: 'widget-1',
panelTypes: 'graph',
timePreferance: 'GLOBAL_TIME',
yAxisUnit: '',
query: {
queryType,
builder: { queryData: [], queryFormulas: [] },
clickhouse_sql: [],
promql: [],
id: 'q-1',
},
} as unknown) as Widgets);
const getCompositeQueryFromLastOpen = (): Record<string, unknown> => {
const [url] = (window.open as jest.Mock).mock.calls[0];
const query = new URLSearchParams((url as string).split('?')[1]);
const raw = query.get(QueryParams.compositeQuery);
if (!raw) {
throw new Error('compositeQuery not found in URL');
}
return JSON.parse(decodeURIComponent(raw));
};
describe('useCreateAlerts', () => {
let capturedOnSuccess:
| ((data: { data: { compositeQuery: unknown } }) => void)
| null = null;
beforeEach(() => {
jest.clearAllMocks();
capturedOnSuccess = null;
mockUseSelector.mockReturnValue({ selectedTime: '1h' });
mockUseMutation.mockReturnValue(({
mutate: jest.fn((_payload, opts) => {
capturedOnSuccess = opts?.onSuccess ?? null;
}),
} as unknown) as ReturnType<typeof useMutation>);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(window as any).open = jest.fn();
});
it('preserves widget queryType when the API response maps to a different queryType', () => {
mockMapQueryDataFromApi.mockReturnValue({
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [], queryFormulas: [] },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const widget = buildWidget(EQueryType.CLICKHOUSE);
const { result } = renderHook(() => useCreateAlerts(widget));
act(() => {
result.current();
});
expect(capturedOnSuccess).not.toBeNull();
act(() => {
capturedOnSuccess?.({ data: { compositeQuery: {} } });
});
const composite = getCompositeQueryFromLastOpen();
expect(composite.queryType).toBe(EQueryType.CLICKHOUSE);
});
it('preserves promql queryType through the alert navigation URL', () => {
mockMapQueryDataFromApi.mockReturnValue({
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [], queryFormulas: [] },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const widget = buildWidget(EQueryType.PROM);
const { result } = renderHook(() => useCreateAlerts(widget));
act(() => {
result.current();
});
act(() => {
capturedOnSuccess?.({ data: { compositeQuery: {} } });
});
const composite = getCompositeQueryFromLastOpen();
expect(composite.queryType).toBe(EQueryType.PROM);
});
it('falls back to the mapped queryType when widget has no queryType', () => {
mockMapQueryDataFromApi.mockReturnValue({
queryType: EQueryType.QUERY_BUILDER,
builder: { queryData: [], queryFormulas: [] },
// eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any);
const widget = buildWidget(undefined);
const { result } = renderHook(() => useCreateAlerts(widget));
act(() => {
result.current();
});
act(() => {
capturedOnSuccess?.({ data: { compositeQuery: {} } });
});
const composite = getCompositeQueryFromLastOpen();
// No override, so the mapped value wins.
expect(composite.queryType).toBe(EQueryType.QUERY_BUILDER);
});
it('does nothing when widget is undefined', () => {
const { result } = renderHook(() => useCreateAlerts(undefined));
act(() => {
result.current();
});
const mutateCalls = (mockUseMutation.mock.results[0].value as ReturnType<
typeof useMutation
>).mutate as jest.Mock;
expect(mutateCalls).not.toHaveBeenCalled();
});
});

View File

@@ -33,7 +33,7 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
const { notifications } = useNotifications();
const { dashboardData } = useDashboardStore();
const { selectedDashboard } = useDashboardStore();
const { dashboardVariables } = useDashboardVariables();
const dashboardDynamicVariables = useDashboardVariablesByType(
@@ -49,8 +49,8 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
if (caller === 'panelView') {
logEvent('Panel Edit: Create alert', {
panelType: widget.panelTypes,
dashboardName: dashboardData?.data?.title,
dashboardId: dashboardData?.id,
dashboardName: selectedDashboard?.data?.title,
dashboardId: selectedDashboard?.id,
widgetId: widget.id,
queryType: widget.query.queryType,
});
@@ -58,8 +58,8 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
logEvent('Dashboard Detail: Panel action', {
action: MenuItemKeys.CreateAlerts,
panelType: widget.panelTypes,
dashboardName: dashboardData?.data?.title,
dashboardId: dashboardData?.id,
dashboardName: selectedDashboard?.data?.title,
dashboardId: selectedDashboard?.id,
widgetId: widget.id,
queryType: widget.query.queryType,
});
@@ -76,9 +76,6 @@ const useCreateAlerts = (widget?: Widgets, caller?: string): VoidFunction => {
queryRangeMutation.mutate(queryPayload, {
onSuccess: (data) => {
const updatedQuery = mapQueryDataFromApi(data.data.compositeQuery);
if (widget.query.queryType) {
updatedQuery.queryType = widget.query.queryType;
}
// If widget has a y-axis unit, set it to the updated query if it is not already set
if (widget.yAxisUnit && !isEmpty(widget.yAxisUnit)) {
updatedQuery.unit = widget.yAxisUnit;

View File

@@ -1,17 +1,22 @@
import { MouseEventHandler, useCallback } from 'react';
import { useLocation } from 'react-router-dom';
import { useCopyToClipboard } from 'react-use';
import { useNotifications } from 'hooks/useNotifications';
import { toast } from '@signozhq/ui';
import useUrlQuery from 'hooks/useUrlQuery';
import { Span } from 'types/api/trace/getTraceV2';
// Accepts both V2 (spanId) and V3 (span_id) span shapes
// TODO: Remove V2 (spanId) support when phasing out V2
interface SpanLike {
spanId?: string;
span_id?: string;
}
export const useCopySpanLink = (
span?: Span,
span?: SpanLike,
): { onSpanCopy: MouseEventHandler<HTMLElement> } => {
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const onSpanCopy: MouseEventHandler<HTMLElement> = useCallback(
(event) => {
@@ -24,18 +29,20 @@ export const useCopySpanLink = (
urlQuery.delete('spanId');
if (span.spanId) {
urlQuery.set('spanId', span?.spanId);
const id = span.span_id || span.spanId;
if (id) {
urlQuery.set('spanId', id);
}
const link = `${window.location.origin}${pathname}?${urlQuery.toString()}`;
setCopy(link);
notifications.success({
message: 'Copied to clipboard',
toast.success('Copied to clipboard', {
richColors: true,
position: 'top-right',
});
},
[span, urlQuery, pathname, setCopy, notifications],
[span, urlQuery, pathname, setCopy],
);
return {

View File

@@ -16,7 +16,7 @@ const useGetTraceFlamegraph = (
queryKey: [
REACT_QUERY_KEY.GET_TRACE_V2_FLAMEGRAPH,
props.traceId,
props.selectedSpanId,
// props.selectedSpanId,
],
enabled: !!props.traceId,
keepPreviousData: true,

View File

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

View File

@@ -7,6 +7,23 @@ export function hashFn(str: string): number {
return hash >>> 0;
}
export function colorToRgb(color: string): string {
// Handle hex colors
const hexMatch = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(color);
if (hexMatch) {
return `${parseInt(hexMatch[1], 16)}, ${parseInt(
hexMatch[2],
16,
)}, ${parseInt(hexMatch[3], 16)}`;
}
// Handle rgb() colors
const rgbMatch = /^rgb\((\d+),\s*(\d+),\s*(\d+)\)$/.exec(color);
if (rgbMatch) {
return `${rgbMatch[1]}, ${rgbMatch[2]}, ${rgbMatch[3]}`;
}
return '136, 136, 136';
}
export function generateColor(
key: string,
colorMap: Record<string, string>,

View File

@@ -62,10 +62,10 @@ export interface TooltipRenderArgs {
export interface BaseTooltipProps {
showTooltipHeader?: boolean;
timezone?: Timezone;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
content?: TooltipContentItem[];
timezone?: Timezone;
}
export interface TimeSeriesTooltipProps

View File

@@ -4,7 +4,6 @@ import cx from 'classnames';
import { getFocusedSeriesAtPosition } from 'lib/uPlotLib/plugins/onClickPlugin';
import uPlot from 'uplot';
import { syncCursorRegistry } from './syncCursorRegistry';
import {
createInitialControllerState,
createSetCursorHandler,
@@ -41,7 +40,6 @@ export default function TooltipPlugin({
maxHeight = 600,
syncMode = DashboardCursorSync.None,
syncKey = '_tooltip_sync_global_',
syncMetadata,
pinnedTooltipElement,
canPinTooltip = false,
}: TooltipPluginProps): JSX.Element | null {
@@ -102,29 +100,7 @@ export default function TooltipPlugin({
// crosshair / tooltip can follow the dashboard-wide cursor.
if (syncMode !== DashboardCursorSync.None && config.scales[0]?.props.time) {
config.setCursor({
sync: { key: syncKey, scales: ['x', 'y'] },
});
// Show the horizontal crosshair only when the receiving panel shares
// the same y-axis unit as the source panel. When this panel is the
// source (cursor.event != null) the line is always shown and this
// panel's metadata is written to the registry so receivers can read it.
config.addHook('setCursor', (u: uPlot): void => {
const yCursorEl = u.root.querySelector<HTMLElement>('.u-cursor-y');
if (!yCursorEl) {
return;
}
if (u.cursor.event != null) {
// This panel is the source — publish metadata and always show line.
syncCursorRegistry.setMetadata(syncKey, syncMetadata);
yCursorEl.style.display = '';
} else {
// This panel is receiving sync — show only if units match.
const sourceMeta = syncCursorRegistry.getMetadata(syncKey);
yCursorEl.style.display =
sourceMeta?.yAxisUnit === syncMetadata?.yAxisUnit ? '' : 'none';
}
sync: { key: syncKey, scales: ['x', null] },
});
}

View File

@@ -1,24 +0,0 @@
import type { TooltipSyncMetadata } from './types';
/**
* Module-level registry that tracks the metadata of the panel currently
* acting as the cursor source (the one being hovered) per sync group.
*
* uPlot fires the source panel's setCursor hook before broadcasting to
* receivers, so the registry is always populated before receivers read it.
*
* Receivers use this to make decisions such as:
* - Whether to show the horizontal crosshair line (matching yAxisUnit)
* - Future: what to render inside the tooltip (matching groupBy, etc.)
*/
const metadataBySyncKey = new Map<string, TooltipSyncMetadata | undefined>();
export const syncCursorRegistry = {
setMetadata(syncKey: string, metadata: TooltipSyncMetadata | undefined): void {
metadataBySyncKey.set(syncKey, metadata);
},
getMetadata(syncKey: string): TooltipSyncMetadata | undefined {
return metadataBySyncKey.get(syncKey);
},
};

View File

@@ -34,16 +34,11 @@ export interface TooltipLayoutInfo {
height: number;
}
export interface TooltipSyncMetadata {
yAxisUnit?: string;
}
export interface TooltipPluginProps {
config: UPlotConfigBuilder;
canPinTooltip?: boolean;
syncMode?: DashboardCursorSync;
syncKey?: string;
syncMetadata?: TooltipSyncMetadata;
render: (args: TooltipRenderArgs) => ReactNode;
pinnedTooltipElement?: (clickData: TooltipClickData) => ReactNode;
maxWidth?: number;

View File

@@ -516,7 +516,7 @@ describe('TooltipPlugin', () => {
);
expect(setCursorSpy).toHaveBeenCalledWith({
sync: { key: 'dashboard-sync', scales: ['x', 'y'] },
sync: { key: 'dashboard-sync', scales: ['x', null] },
});
});
@@ -569,8 +569,12 @@ describe('TooltipPlugin', () => {
}),
);
const resizeCall = addSpy.mock.calls.find(([type]) => type === 'resize');
const scrollCall = addSpy.mock.calls.find(([type]) => type === 'scroll');
const resizeCall = addSpy.mock.calls.find(
([type]) => type === ('resize' as keyof WindowEventMap),
);
const scrollCall = addSpy.mock.calls.find(
([type]) => type === ('scroll' as keyof WindowEventMap),
);
expect(resizeCall).toBeDefined();
expect(scrollCall).toBeDefined();

View File

@@ -21,7 +21,9 @@ function DashboardPage(): JSX.Element {
error,
} = useDashboardBootstrap(dashboardId, { confirm: onModal.confirm });
const dashboardTitle = useDashboardStore((s) => s.dashboardData?.data.title);
const dashboardTitle = useDashboardStore(
(s) => s.selectedDashboard?.data.title,
);
useEffect(() => {
document.title = dashboardTitle || document.title;

View File

@@ -62,9 +62,9 @@ function DashboardWidgetInternal({
widgetId: string;
graphType: PANEL_TYPES;
}): JSX.Element | null {
const [dashboardData, setDashboardData] = useState<Dashboard | undefined>(
undefined,
);
const [selectedDashboard, setSelectedDashboard] = useState<
Dashboard | undefined
>(undefined);
const { transformDashboardVariables } = useTransformDashboardVariables(
dashboardId,
@@ -83,7 +83,7 @@ function DashboardWidgetInternal({
cacheTime: DASHBOARD_CACHE_TIME,
onSuccess: (response) => {
const updatedDashboardData = transformDashboardVariables(response.data);
setDashboardData(updatedDashboardData);
setSelectedDashboard(updatedDashboardData);
setDashboardVariablesStore({
dashboardId,
variables: updatedDashboardData.data.variables,
@@ -108,7 +108,7 @@ function DashboardWidgetInternal({
dashboardId={dashboardId}
selectedGraph={graphType}
enableDrillDown={isDrilldownEnabled()}
dashboardData={dashboardData}
selectedDashboard={selectedDashboard}
/>
);
}

View File

@@ -0,0 +1,5 @@
import TraceDetailsV3 from '../TraceDetailsV3';
export default function TraceDetailV3Page(): JSX.Element {
return <TraceDetailsV3 />;
}

View File

@@ -0,0 +1,96 @@
.analytics-panel {
&__body {
padding: 12px 6px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow: hidden;
background: var(--l1-background);
// TabsRoot — last direct child div
> div:last-child {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
}
&__list {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 4px 8px;
padding: 8px 0;
align-items: center;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 2px;
}
&__service-name {
font-size: 13px;
color: var(--l1-foreground);
word-break: break-word;
}
&__bar-cell {
display: flex;
align-items: center;
gap: 8px;
}
&__bar {
flex: 1;
height: 6px;
background: var(--l3-background);
border-radius: 3px;
min-width: 40px;
&--small {
max-width: 80px;
flex: 0 0 80px;
}
}
&__bar-fill {
height: 100%;
border-radius: 3px;
}
&__value {
flex-shrink: 0;
text-align: right;
font-size: 13px;
font-weight: 500;
color: var(--l1-foreground);
&--wide {
min-width: 55px;
}
&--narrow {
min-width: 25px;
}
}
// Tabs root
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}

View File

@@ -0,0 +1,185 @@
import { useMemo } from 'react';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
import { DetailsHeader } from 'components/DetailsPanel';
import { themeColors } from 'constants/theme';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import './AnalyticsPanel.styles.scss';
interface AnalyticsPanelProps {
isOpen: boolean;
onClose: () => void;
serviceExecTime?: Record<string, number>;
traceStartTime?: number;
traceEndTime?: number;
// TODO: Re-enable when backend provides per-service span counts
// spans?: Span[];
}
const PANEL_WIDTH = 350;
const PANEL_MARGIN_RIGHT = 100;
const PANEL_MARGIN_TOP = 50;
const PANEL_MARGIN_BOTTOM = 50;
function AnalyticsPanel({
isOpen,
onClose,
serviceExecTime = {},
traceStartTime = 0,
traceEndTime = 0,
}: AnalyticsPanelProps): JSX.Element | null {
const spread = traceEndTime - traceStartTime;
const execTimeRows = useMemo(() => {
if (spread <= 0) {
return [];
}
return Object.entries(serviceExecTime)
.map(([service, duration]) => ({
service,
percentage: (duration * 100) / spread,
color: generateColor(service, themeColors.traceDetailColorsV3),
}))
.sort((a, b) => b.percentage - a.percentage);
}, [serviceExecTime, spread]);
// const spanCountRows = useMemo(() => {
// const counts: Record<string, number> = {};
// for (const span of spans) {
// const name = span.serviceName || 'unknown';
// counts[name] = (counts[name] || 0) + 1;
// }
// return Object.entries(counts)
// .map(([service, count]) => ({
// service,
// count,
// color: generateColor(service, themeColors.traceDetailColorsV3),
// }))
// .sort((a, b) => b.count - a.count);
// }, [spans]);
// const maxSpanCount = spanCountRows[0]?.count || 1;
if (!isOpen) {
return null;
}
return (
<FloatingPanel
isOpen
className="analytics-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
enableResizing={{
top: true,
bottom: true,
left: false,
right: false,
topLeft: false,
topRight: false,
bottomLeft: false,
bottomRight: false,
}}
>
<DetailsHeader
title="Analytics"
onClose={onClose}
className="floating-panel__drag-handle"
/>
<div className="analytics-panel__body">
<TabsRoot defaultValue="exec-time">
<TabsList variant="secondary">
<TabsTrigger value="exec-time" variant="secondary">
% exec time
</TabsTrigger>
{/* TODO: Enable when backend provides per-service span counts
<TabsTrigger value="spans" variant="secondary">
Spans
</TabsTrigger>
*/}
</TabsList>
<div className="analytics-panel__tabs-scroll">
<TabsContent value="exec-time">
<div className="analytics-panel__list">
{execTimeRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.service}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${Math.min(row.percentage, 100)}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className="analytics-panel__value analytics-panel__value--wide">
{row.percentage.toFixed(2)}%
</span>
</div>
</>
))}
</div>
</TabsContent>
{/* TODO: Enable when backend provides per-service span counts
<TabsContent value="spans">
<div className="analytics-panel__list">
{spanCountRows.map((row) => (
<>
<div
key={`${row.service}-dot`}
className="analytics-panel__dot"
style={{ backgroundColor: row.color }}
/>
<span
key={`${row.service}-name`}
className="analytics-panel__service-name"
>
{row.service}
</span>
<div key={`${row.service}-bar`} className="analytics-panel__bar-cell">
<div className="analytics-panel__bar">
<div
className="analytics-panel__bar-fill"
style={{
width: `${(row.count / maxSpanCount) * 100}%`,
backgroundColor: row.color,
}}
/>
</div>
<span className="analytics-panel__value analytics-panel__value--narrow">
{row.count}
</span>
</div>
</>
))}
</div>
</TabsContent>
*/}
</div>
</TabsRoot>
</div>
</FloatingPanel>
);
}
export default AnalyticsPanel;

View File

@@ -0,0 +1,34 @@
.linked-spans {
position: relative;
&__toggle {
display: inline-flex;
align-items: center;
gap: 4px;
background: none;
border: none;
padding: 0;
cursor: pointer;
color: inherit;
font: inherit;
}
&__label {
white-space: nowrap;
}
&__chevron {
transition: transform 0.15s ease;
&--open {
transform: rotate(90deg);
}
}
&__list {
display: flex;
flex-direction: column;
gap: 12px;
padding: 12px 0;
}
}

View File

@@ -0,0 +1,118 @@
import { useCallback, useMemo, useState } from 'react';
import { Link } from 'react-router-dom';
import { ChevronDown, ChevronRight } from '@signozhq/icons';
import { Badge } from '@signozhq/ui';
import ROUTES from 'constants/routes';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import './LinkedSpans.styles.scss';
interface SpanReference {
traceId: string;
spanId: string;
refType: string;
}
interface LinkedSpansProps {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
references: any;
}
interface LinkedSpansState {
linkedSpans: SpanReference[];
count: number;
isOpen: boolean;
toggleOpen: () => void;
}
export function useLinkedSpans(references: any): LinkedSpansState {
const [isOpen, setIsOpen] = useState(false);
const linkedSpans: SpanReference[] = useMemo(
() =>
(references || []).filter(
(ref: SpanReference) => ref.refType !== 'CHILD_OF',
),
[references],
);
const toggleOpen = useCallback(() => setIsOpen((prev) => !prev), []);
return {
linkedSpans,
count: linkedSpans.length,
isOpen,
toggleOpen,
};
}
export function LinkedSpansToggle({
count,
isOpen,
toggleOpen,
}: {
count: number;
isOpen: boolean;
toggleOpen: () => void;
}): JSX.Element {
if (count === 0) {
return <span className="linked-spans__label">0 linked spans</span>;
}
return (
<button type="button" className="linked-spans__toggle" onClick={toggleOpen}>
<span className="linked-spans__label">
{count} linked span{count !== 1 ? 's' : ''}
</span>
{isOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
);
}
export function LinkedSpansPanel({
linkedSpans,
isOpen,
}: {
linkedSpans: SpanReference[];
isOpen: boolean;
}): JSX.Element | null {
const getLink = useCallback(
(item: SpanReference): string =>
`${ROUTES.TRACE}/${item.traceId}?spanId=${item.spanId}`,
[],
);
if (!isOpen || linkedSpans.length === 0) {
return null;
}
return (
<div className="linked-spans__list">
{linkedSpans.map((item) => (
<KeyValueLabel
key={item.spanId}
badgeKey="Linked Span ID"
badgeValue={
<Link to={getLink(item)}>
<Badge color="vanilla">{item.spanId}</Badge>
</Link>
}
direction="column"
/>
))}
</div>
);
}
function LinkedSpans({ references }: LinkedSpansProps): JSX.Element {
const { linkedSpans, count, isOpen, toggleOpen } = useLinkedSpans(references);
return (
<div className="linked-spans">
<LinkedSpansToggle count={count} isOpen={isOpen} toggleOpen={toggleOpen} />
<LinkedSpansPanel linkedSpans={linkedSpans} isOpen={isOpen} />
</div>
);
}
export default LinkedSpans;

View File

@@ -0,0 +1,147 @@
.span-details-panel {
height: 100%;
display: flex;
flex-direction: column;
overflow: hidden;
&__header-nav {
display: flex;
align-items: center;
gap: 2px;
}
&__body {
padding: 12px;
display: flex;
flex-direction: column;
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
background: var(--l1-background);
font-size: 14px;
gap: 16px;
.data-viewer {
min-height: 500px;
}
}
&__details-section {
flex-shrink: 0;
min-width: 0;
}
&__tabs-section {
flex: 1;
min-width: 0;
max-height: 100%;
min-height: 400px;
display: flex;
flex-direction: column;
overflow: hidden;
// TabsRoot — direct child of tabs-section
> div {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
[role='tablist'] {
flex-shrink: 0;
}
[class*='tabs__list-wrapper'] {
padding-left: 0 !important;
}
}
&__tabs-scroll {
flex: 1;
min-height: 0;
overflow-y: auto;
scrollbar-width: none;
[role='tabpanel'] {
padding: 0;
}
}
&__span-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
}
&__span-info {
display: flex;
flex-wrap: wrap;
gap: 4px 16px;
padding: 8px 0;
}
&__span-info-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--l2-foreground);
white-space: nowrap;
}
&__highlighted-options {
padding: 8px 0;
display: flex;
flex-wrap: wrap;
gap: 0;
.key-value-label {
flex: 1 1 50%;
min-width: 120px;
overflow: hidden;
}
}
&__service-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bg-forest-500);
}
&__trace-id {
color: var(--accent-primary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
display: block;
}
&__key-attributes {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px 0;
&-label {
font-size: var(--font-size-xs);
font-weight: var(--font-weight-medium);
color: var(--l2-foreground);
text-transform: uppercase;
letter-spacing: 0.48px;
line-height: var(--line-height-20);
}
&-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
}
}

View File

@@ -0,0 +1,607 @@
import { useCallback, useMemo, useState } from 'react';
import { Button } from '@signozhq/button';
import {
Bookmark,
CalendarClock,
ChartBar,
ChartColumnBig,
Dock,
Link2,
Logs,
PanelBottom,
ScrollText,
Timer,
} from '@signozhq/icons';
import { TabsContent, TabsList, TabsRoot, TabsTrigger } from '@signozhq/ui';
import { Skeleton, Tooltip } from 'antd';
import { DetailsHeader, DetailsPanelDrawer } from 'components/DetailsPanel';
import { HeaderAction } from 'components/DetailsPanel/DetailsHeader/DetailsHeader';
import { DetailsPanelState } from 'components/DetailsPanel/types';
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
import Events from 'container/SpanDetailsDrawer/Events/Events';
import SpanLogs from 'container/SpanDetailsDrawer/SpanLogs/SpanLogs';
import { useSpanContextLogs } from 'container/SpanDetailsDrawer/SpanLogs/useSpanContextLogs';
import dayjs from 'dayjs';
import { getSpanAttribute, hasInfraMetadata } from 'pages/TraceDetailsV3/utils';
import { DataViewer } from 'periscope/components/DataViewer';
import { FloatingPanel } from 'periscope/components/FloatingPanel';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { getLeafKeyFromPath } from 'periscope/components/PrettyView/utils';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { SpanV3 } from 'types/api/trace/getTraceV3';
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
import AnalyticsPanel from './AnalyticsPanel/AnalyticsPanel';
import { HIGHLIGHTED_OPTIONS } from './config';
import {
// KEY_ATTRIBUTE_KEYS, // uncomment when key attributes section is re-enabled
SpanDetailVariant,
VISIBLE_ACTIONS,
} from './constants';
import { useSpanAttributeActions } from './hooks/useSpanAttributeActions';
import {
LinkedSpansPanel,
LinkedSpansToggle,
useLinkedSpans,
} from './LinkedSpans/LinkedSpans';
import SpanPercentileBadge from './SpanPercentile/SpanPercentileBadge';
import SpanPercentilePanel from './SpanPercentile/SpanPercentilePanel';
import useSpanPercentile from './SpanPercentile/useSpanPercentile';
import './SpanDetailsPanel.styles.scss';
interface SpanDetailsPanelProps {
panelState: DetailsPanelState;
selectedSpan: SpanV3 | undefined;
variant?: SpanDetailVariant;
onVariantChange?: (variant: SpanDetailVariant) => void;
traceStartTime?: number;
traceEndTime?: number;
serviceExecTime?: Record<string, number>;
}
function SpanDetailsContent({
selectedSpan,
traceStartTime,
traceEndTime,
}: {
selectedSpan: SpanV3;
traceStartTime?: number;
traceEndTime?: number;
}): JSX.Element {
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
const spanAttributeActions = useSpanAttributeActions();
const percentile = useSpanPercentile(selectedSpan);
const linkedSpans = useLinkedSpans((selectedSpan as any).references);
// Map span attribute actions to PrettyView actions format.
// Use the last key in fieldKeyPath (the actual attribute key), not the full display path.
const prettyViewCustomActions = useMemo(
() =>
spanAttributeActions.map((action) => ({
key: action.value,
label: action.label,
icon: action.icon,
shouldHide: action.shouldHide,
onClick: (context: {
fieldKey: string;
fieldKeyPath: (string | number)[];
fieldValue: unknown;
}): void => {
const leafKey = getLeafKeyFromPath(context.fieldKeyPath, context.fieldKey);
action.callback({
key: leafKey,
value: String(context.fieldValue),
});
},
})),
[spanAttributeActions],
);
// const [, setCopy] = useCopyToClipboard();
// Key attributes section — commented out as pinning in PrettyView covers this use case.
// Uncomment when key attributes need to be shown separately.
// const buildKeyAttrMenu = useCallback(
// (key: string, value: string): ActionMenuItem[] => {
// const items: ActionMenuItem[] = [
// {
// key: 'copy-value',
// label: 'Copy Value',
// icon: <Copy size={12} />,
// onClick: (): void => {
// setCopy(value);
// toast.success('Copied to clipboard', {
// richColors: true,
// position: 'top-right',
// });
// },
// },
// ];
// spanAttributeActions.forEach((action) => {
// if (action.shouldHide && action.shouldHide(key)) {
// return;
// }
// items.push({
// key: action.value,
// label: action.label,
// icon: action.icon,
// onClick: (): void => {
// action.callback({ key, value });
// },
// });
// });
// return items;
// },
// [spanAttributeActions, setCopy],
// );
const {
logs,
isLoading: isLogsLoading,
isError: isLogsError,
isFetching: isLogsFetching,
isLogSpanRelated,
hasTraceIdLogs,
} = useSpanContextLogs({
traceId: selectedSpan.trace_id,
spanId: selectedSpan.span_id,
timeRange: {
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
},
isDrawerOpen: true,
});
const infraMetadata = useMemo(() => {
if (!hasInfraMetadata(selectedSpan)) {
return null;
}
return {
clusterName: getSpanAttribute(selectedSpan, 'k8s.cluster.name') || '',
podName: getSpanAttribute(selectedSpan, 'k8s.pod.name') || '',
nodeName: getSpanAttribute(selectedSpan, 'k8s.node.name') || '',
hostName: getSpanAttribute(selectedSpan, 'host.name') || '',
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
};
}, [selectedSpan]);
const handleExplorerPageRedirect = useCallback((): void => {
const startTimeMs = (traceStartTime || 0) - FIVE_MINUTES_IN_MS;
const endTimeMs = (traceEndTime || 0) + FIVE_MINUTES_IN_MS;
const traceIdFilter = {
op: 'AND',
items: [
{
id: 'trace-id-filter',
key: {
key: 'trace_id',
id: 'trace-id-key',
dataType: 'string' as const,
isColumn: true,
type: '',
isJSON: false,
} as BaseAutocompleteData,
op: '=',
value: selectedSpan.trace_id,
},
],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: traceIdFilter,
},
],
},
};
const searchParams = new URLSearchParams();
searchParams.set(QueryParams.compositeQuery, JSON.stringify(compositeQuery));
searchParams.set(QueryParams.startTime, startTimeMs.toString());
searchParams.set(QueryParams.endTime, endTimeMs.toString());
window.open(
`${window.location.origin}${
ROUTES.LOGS_EXPLORER
}?${searchParams.toString()}`,
'_blank',
'noopener,noreferrer',
);
}, [selectedSpan.trace_id, traceStartTime, traceEndTime]);
const emptyLogsStateConfig = useMemo(
() => ({
...getEmptyLogsListConfig(() => {}),
showClearFiltersButton: false,
}),
[],
);
// const keyAttributes = useMemo(() => {
// const keys = KEY_ATTRIBUTE_KEYS.traces || [];
// const allAttrs: Record<string, string> = {};
// Object.entries(selectedSpan.resource || {}).forEach(([k, v]) => {
// allAttrs[k] = String(v);
// });
// Object.entries(selectedSpan.attributes || {}).forEach(([k, v]) => {
// allAttrs[k] = String(v);
// });
// const span = (selectedSpan as unknown) as Record<string, unknown>;
// keys.forEach((key) => {
// if (!(key in allAttrs) && span[key] != null && span[key] !== '') {
// allAttrs[key] = String(span[key]);
// }
// });
// return keys
// .filter((key) => allAttrs[key])
// .map((key) => ({ key, value: allAttrs[key] }));
// }, [selectedSpan]);
return (
<div className="span-details-panel__body">
<div className="span-details-panel__details-section">
<div className="span-details-panel__span-row">
<KeyValueLabel
badgeKey="Span name"
badgeValue={selectedSpan.name}
maxCharacters={50}
/>
<SpanPercentileBadge
loading={percentile.loading}
percentileValue={percentile.percentileValue}
duration={percentile.duration}
spanPercentileData={percentile.spanPercentileData}
isOpen={percentile.isOpen}
toggleOpen={percentile.toggleOpen}
/>
</div>
<SpanPercentilePanel selectedSpan={selectedSpan} percentile={percentile} />
{/* Span info: exec time + start time */}
<div className="span-details-panel__span-info">
<div className="span-details-panel__span-info-item">
<Timer size={14} />
<span>
{getYAxisFormattedValue(`${selectedSpan.duration_nano / 1000000}`, 'ms')}
{traceStartTime && traceEndTime && traceEndTime > traceStartTime && (
<>
{' — '}
<strong>
{(
(selectedSpan.duration_nano * 100) /
((traceEndTime - traceStartTime) * 1e6)
).toFixed(2)}
%
</strong>
{' of total exec time'}
</>
)}
</span>
</div>
<div className="span-details-panel__span-info-item">
<CalendarClock size={14} />
<span>
{dayjs(selectedSpan.timestamp).format('HH:mm:ss — MMM D, YYYY')}
</span>
</div>
<div className="span-details-panel__span-info-item">
<Link2 size={14} />
<LinkedSpansToggle
count={linkedSpans.count}
isOpen={linkedSpans.isOpen}
toggleOpen={linkedSpans.toggleOpen}
/>
</div>
</div>
<LinkedSpansPanel
linkedSpans={linkedSpans.linkedSpans}
isOpen={linkedSpans.isOpen}
/>
{/* Step 6: HighlightedOptions */}
<div className="span-details-panel__highlighted-options">
{HIGHLIGHTED_OPTIONS.map((option) => {
const rendered = option.render(selectedSpan);
if (!rendered) {
return null;
}
return (
<KeyValueLabel
key={option.key}
badgeKey={option.label}
badgeValue={rendered}
direction="column"
/>
);
})}
</div>
{/* Step 7: KeyAttributes — commented out, pinning in PrettyView covers this.
{keyAttributes.length > 0 && (
<div className="span-details-panel__key-attributes">
<div className="span-details-panel__key-attributes-label">
KEY ATTRIBUTES
</div>
<div className="span-details-panel__key-attributes-chips">
{keyAttributes.map(({ key, value }) => (
<ActionMenu
key={key}
items={buildKeyAttrMenu(key, value)}
trigger={['click']}
placement="bottomRight"
>
<div>
<KeyValueLabel badgeKey={key} badgeValue={value} />
</div>
</ActionMenu>
))}
</div>
</div>
)}
*/}
{/* Step 8: MiniTraceContext */}
</div>
<div className="span-details-panel__tabs-section">
{/* Step 9: ContentTabs */}
<TabsRoot defaultValue="overview">
<TabsList variant="secondary">
<TabsTrigger value="overview" variant="secondary">
<Bookmark size={14} /> Overview
</TabsTrigger>
<TabsTrigger value="events" variant="secondary">
<ScrollText size={14} /> Events ({selectedSpan.events?.length || 0})
</TabsTrigger>
<TabsTrigger value="logs" variant="secondary">
<Logs size={14} /> Logs
</TabsTrigger>
{infraMetadata && (
<TabsTrigger value="metrics" variant="secondary">
<ChartColumnBig size={14} /> Metrics
</TabsTrigger>
)}
</TabsList>
<div className="span-details-panel__tabs-scroll">
<TabsContent value="overview">
<DataViewer
data={selectedSpan}
drawerKey="trace-details"
prettyViewProps={{
showPinned: true,
actions: prettyViewCustomActions,
visibleActions: VISIBLE_ACTIONS,
}}
/>
</TabsContent>
<TabsContent value="events">
{/* V2 Events component expects span.event (singular), V3 has span.events (plural) */}
<Events
span={{ ...selectedSpan, event: selectedSpan.events } as any}
startTime={traceStartTime || 0}
isSearchVisible
/>
</TabsContent>
<TabsContent value="logs">
<SpanLogs
traceId={selectedSpan.trace_id}
spanId={selectedSpan.span_id}
timeRange={{
startTime: (traceStartTime || 0) - FIVE_MINUTES_IN_MS,
endTime: (traceEndTime || 0) + FIVE_MINUTES_IN_MS,
}}
logs={logs}
isLoading={isLogsLoading}
isError={isLogsError}
isFetching={isLogsFetching}
isLogSpanRelated={isLogSpanRelated}
handleExplorerPageRedirect={handleExplorerPageRedirect}
emptyStateConfig={!hasTraceIdLogs ? emptyLogsStateConfig : undefined}
/>
</TabsContent>
{infraMetadata && (
<TabsContent value="metrics">
<InfraMetrics
clusterName={infraMetadata.clusterName}
podName={infraMetadata.podName}
nodeName={infraMetadata.nodeName}
hostName={infraMetadata.hostName}
timestamp={infraMetadata.spanTimestamp}
dataSource={DataSource.TRACES}
/>
</TabsContent>
)}
</div>
</TabsRoot>
</div>
</div>
);
}
function SpanDetailsPanel({
panelState,
selectedSpan,
variant = SpanDetailVariant.DIALOG,
onVariantChange,
traceStartTime,
traceEndTime,
serviceExecTime,
}: SpanDetailsPanelProps): JSX.Element {
const [isAnalyticsOpen, setIsAnalyticsOpen] = useState(false);
const headerActions = useMemo((): HeaderAction[] => {
const actions: HeaderAction[] = [
{
key: 'analytics',
component: (
<Button
variant="ghost"
size="sm"
color="secondary"
prefixIcon={<ChartBar size={14} />}
onClick={(): void => setIsAnalyticsOpen((prev) => !prev)}
>
Analytics
</Button>
),
},
// TODO: Add back when driven through separate config for different pages
// {
// key: 'view-full-trace',
// component: (
// <Button variant="ghost" size="sm" color="secondary" prefixIcon={<ExternalLink size={14} />} onClick={noop}>
// View full trace
// </Button>
// ),
// },
// TODO: Add back when used in trace explorer page
// {
// key: 'nav',
// component: (
// <div className="span-details-panel__header-nav">
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronUp size={14} /></Button>
// <Button variant="ghost" size="icon" color="secondary" onClick={noop}><ChevronDown size={14} /></Button>
// </div>
// ),
// },
];
if (onVariantChange) {
const isDocked = variant === SpanDetailVariant.DOCKED;
actions.push({
key: 'dock-toggle',
component: (
<Tooltip title={isDocked ? 'Open as floating panel' : 'Dock on the side'}>
<Button
variant="ghost"
size="icon"
color="secondary"
onClick={(): void =>
onVariantChange(
isDocked ? SpanDetailVariant.DIALOG : SpanDetailVariant.DOCKED,
)
}
>
{isDocked ? <Dock size={14} /> : <PanelBottom size={14} />}
</Button>
</Tooltip>
),
});
}
return actions;
}, [variant, onVariantChange]);
const PANEL_WIDTH = 500;
const PANEL_MARGIN_RIGHT = 20;
const PANEL_MARGIN_TOP = 25;
const PANEL_MARGIN_BOTTOM = 25;
const content = (
<>
<DetailsHeader
title="Span details"
onClose={panelState.close}
actions={headerActions}
className={
variant === SpanDetailVariant.DIALOG ? 'floating-panel__drag-handle' : ''
}
/>
{selectedSpan ? (
<SpanDetailsContent
selectedSpan={selectedSpan}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
) : (
<div className="span-details-panel__body">
<Skeleton active paragraph={{ rows: 6 }} title={{ width: '60%' }} />
</div>
)}
</>
);
const analyticsPanel = (
<AnalyticsPanel
isOpen={isAnalyticsOpen}
onClose={(): void => setIsAnalyticsOpen(false)}
serviceExecTime={serviceExecTime}
traceStartTime={traceStartTime}
traceEndTime={traceEndTime}
/>
);
if (variant === SpanDetailVariant.DOCKED) {
return (
<>
<div className="span-details-panel">{content}</div>
{analyticsPanel}
</>
);
}
if (variant === SpanDetailVariant.DRAWER) {
return (
<>
<DetailsPanelDrawer
isOpen={panelState.isOpen}
onClose={panelState.close}
className="span-details-panel"
>
{content}
</DetailsPanelDrawer>
{analyticsPanel}
</>
);
}
return (
<>
<FloatingPanel
isOpen={panelState.isOpen}
className="span-details-panel"
width={PANEL_WIDTH}
height={window.innerHeight - PANEL_MARGIN_TOP - PANEL_MARGIN_BOTTOM}
defaultPosition={{
x: window.innerWidth - PANEL_WIDTH - PANEL_MARGIN_RIGHT,
y: PANEL_MARGIN_TOP,
}}
enableResizing={{
top: true,
right: true,
bottom: true,
left: true,
topRight: false,
bottomRight: false,
bottomLeft: false,
topLeft: false,
}}
>
{content}
</FloatingPanel>
{analyticsPanel}
</>
);
}
export default SpanDetailsPanel;

View File

@@ -0,0 +1,258 @@
// Badge — wraps a KeyValueLabel, clickable to toggle panel
.span-percentile-badge {
cursor: pointer;
// Override key color for the percentile value (p99)
.key-value-label__key {
color: var(--text-sakura-400, #f56c87);
}
&__loader {
display: inline-flex;
align-items: center;
padding: 2px 4px;
color: var(--foreground);
}
&__value {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__icon {
flex-shrink: 0;
color: var(--l2-foreground);
}
}
// Panel — collapsible, renders below the row
.span-percentile-panel {
display: flex;
flex-direction: column;
position: relative;
border: 1px solid var(--l1-border);
border-radius: 4px;
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
backdrop-filter: blur(20px);
margin: 8px 16px;
&__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
&-text {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
&-icon {
color: var(--l2-foreground);
}
}
&__content {
display: flex;
flex-direction: column;
gap: 8px;
padding: 8px;
&-title {
font-size: var(--font-size-sm);
line-height: var(--line-height-20);
}
&-highlight {
color: var(--text-sakura-400, #f56c87);
}
&-loader {
display: inline-flex;
align-items: flex-end;
margin: 0 4px;
line-height: 18px;
}
}
&__timerange {
width: 100%;
&-select {
width: 100%;
margin-top: 8px;
margin-bottom: 16px;
.ant-select-selector {
border-radius: 50px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
color: var(--l1-foreground);
font-size: 12px;
height: 32px;
}
}
}
&__table {
&-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
&-text {
color: var(--l1-foreground);
font-size: 11px;
font-weight: 500;
line-height: 20px;
text-transform: uppercase;
}
}
&-rows {
margin-top: 8px;
display: flex;
flex-direction: column;
gap: 4px;
}
&-skeleton {
.ant-skeleton-title {
width: 100% !important;
margin-top: 0 !important;
}
.ant-skeleton-paragraph {
margin-top: 8px;
& > li + li {
margin-top: 10px;
width: 100% !important;
}
}
}
&-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 0 4px;
&-key {
flex: 0 0 auto;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 20px;
}
&-value {
color: var(--l2-foreground);
font-size: 12px;
line-height: 20px;
}
&-dash {
flex: 1;
height: 0;
margin: 0 8px;
border-top: 1px solid transparent;
border-image: repeating-linear-gradient(
to right,
var(--l1-border) 0,
var(--l1-border) 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
&--current {
border-radius: 2px;
background: rgba(78, 116, 248, 0.2);
.span-percentile-panel__table-row-key {
color: var(--text-robin-300);
}
.span-percentile-panel__table-row-dash {
border-image: repeating-linear-gradient(
to right,
#abbdff 0,
#abbdff 10px,
transparent 10px,
transparent 20px
)
1 stretch;
}
.span-percentile-panel__table-row-value {
color: var(--text-robin-400);
}
}
}
}
&__resource-selector {
overflow: hidden;
width: calc(100% + 16px);
position: absolute;
top: 32px;
left: -8px;
z-index: 1000;
border-radius: 4px;
border: 1px solid var(--l1-border);
background: var(--l1-background);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
backdrop-filter: blur(20px);
&-header {
border-bottom: 1px solid var(--l1-border);
}
&-input {
border-radius: 0;
border: none !important;
box-shadow: none !important;
height: 36px;
}
&-items {
height: 200px;
overflow-y: auto;
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--l3-background);
}
}
&-item {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 12px;
&-value {
color: var(--l1-foreground);
font-size: 13px;
line-height: 20px;
}
}
}
}

View File

@@ -0,0 +1,67 @@
import { ChevronDown, ChevronUp, Loader2 } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { UseSpanPercentileReturn } from './useSpanPercentile';
import './SpanPercentile.styles.scss';
type SpanPercentileBadgeProps = Pick<
UseSpanPercentileReturn,
| 'loading'
| 'percentileValue'
| 'duration'
| 'spanPercentileData'
| 'isOpen'
| 'toggleOpen'
>;
function SpanPercentileBadge({
loading,
percentileValue,
duration,
spanPercentileData,
isOpen,
toggleOpen,
}: SpanPercentileBadgeProps): JSX.Element | null {
if (loading) {
return (
<div className="span-percentile-badge__loader">
<Loader2 size={14} className="animate-spin" />
</div>
);
}
if (!spanPercentileData) {
return null;
}
return (
<div
className="span-percentile-badge"
onClick={toggleOpen}
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
toggleOpen();
}
}}
>
<KeyValueLabel
badgeKey={`p${percentileValue}`}
badgeValue={
<span className="span-percentile-badge__value">
{duration}
{isOpen ? (
<ChevronUp size={14} className="span-percentile-badge__icon" />
) : (
<ChevronDown size={14} className="span-percentile-badge__icon" />
)}
</span>
}
/>
</div>
);
}
export default SpanPercentileBadge;

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