Compare commits

..

177 Commits

Author SHA1 Message Date
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
487 changed files with 45729 additions and 19443 deletions

64
.github/CODEOWNERS vendored
View File

@@ -16,38 +16,38 @@ go.mod @therealpandey
# Scaffold Owners
/pkg/config/ @therealpandey
/pkg/errors/ @therealpandey
/pkg/factory/ @therealpandey
/pkg/types/ @therealpandey
/pkg/valuer/ @therealpandey
/cmd/ @therealpandey
.golangci.yml @therealpandey
/pkg/config/ @vikrantgupta25
/pkg/errors/ @vikrantgupta25
/pkg/factory/ @vikrantgupta25
/pkg/types/ @vikrantgupta25
/pkg/valuer/ @vikrantgupta25
/cmd/ @vikrantgupta25
.golangci.yml @vikrantgupta25
# Zeus Owners
/pkg/zeus/ @therealpandey
/ee/zeus/ @therealpandey
/pkg/licensing/ @therealpandey
/ee/licensing/ @therealpandey
/pkg/zeus/ @vikrantgupta25
/ee/zeus/ @vikrantgupta25
/pkg/licensing/ @vikrantgupta25
/ee/licensing/ @vikrantgupta25
# SQL Owners
/pkg/sqlmigration/ @therealpandey
/ee/sqlmigration/ @therealpandey
/pkg/sqlschema/ @therealpandey
/ee/sqlschema/ @therealpandey
/pkg/sqlmigration/ @vikrantgupta25
/ee/sqlmigration/ @vikrantgupta25
/pkg/sqlschema/ @vikrantgupta25
/ee/sqlschema/ @vikrantgupta25
# Analytics Owners
/pkg/analytics/ @therealpandey
/pkg/statsreporter/ @therealpandey
/pkg/analytics/ @vikrantgupta25
/pkg/statsreporter/ @vikrantgupta25
# Emailing Owners
/pkg/emailing/ @therealpandey
/pkg/types/emailtypes/ @therealpandey
/templates/email/ @therealpandey
/pkg/emailing/ @vikrantgupta25
/pkg/types/emailtypes/ @vikrantgupta25
/templates/email/ @vikrantgupta25
# Querier Owners
@@ -97,23 +97,23 @@ go.mod @therealpandey
# AuthN / AuthZ Owners
/pkg/authz/ @therealpandey
/ee/authz/ @therealpandey
/pkg/authn/ @therealpandey
/ee/authn/ @therealpandey
/pkg/modules/user/ @therealpandey
/pkg/modules/session/ @therealpandey
/pkg/modules/organization/ @therealpandey
/pkg/modules/authdomain/ @therealpandey
/pkg/modules/role/ @therealpandey
/pkg/authz/ @vikrantgupta25
/ee/authz/ @vikrantgupta25
/pkg/authn/ @vikrantgupta25
/ee/authn/ @vikrantgupta25
/pkg/modules/user/ @vikrantgupta25
/pkg/modules/session/ @vikrantgupta25
/pkg/modules/organization/ @vikrantgupta25
/pkg/modules/authdomain/ @vikrantgupta25
/pkg/modules/role/ @vikrantgupta25
# IdentN Owners
/pkg/identn/ @therealpandey
/pkg/http/middleware/identn.go @therealpandey
/pkg/identn/ @vikrantgupta25
/pkg/http/middleware/identn.go @vikrantgupta25
# Integration tests
/tests/integration/ @therealpandey
/tests/integration/ @vikrantgupta25
# OpenAPI types generator

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

@@ -19,11 +19,11 @@ func NewAWSCloudProvider(defStore cloudintegrationtypes.ServiceDefinitionStore)
}
func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, account *cloudintegrationtypes.Account, req *cloudintegrationtypes.GetConnectionArtifactRequest) (*cloudintegrationtypes.ConnectionArtifact, error) {
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.AWS.DeploymentRegion)
baseURL := fmt.Sprintf(cloudintegrationtypes.CloudFormationQuickCreateBaseURL.StringValue(), req.Config.Aws.DeploymentRegion)
u, _ := url.Parse(baseURL)
q := u.Query()
q.Set("region", req.Config.AWS.DeploymentRegion)
q.Set("region", req.Config.Aws.DeploymentRegion)
u.Fragment = "/stacks/quickcreate"
u.RawQuery = q.Encode()
@@ -39,7 +39,9 @@ func (provider *awscloudprovider) GetConnectionArtifact(ctx context.Context, acc
q.Set("param_IngestionKey", req.Credentials.IngestionKey)
return &cloudintegrationtypes.ConnectionArtifact{
AWS: cloudintegrationtypes.NewAWSConnectionArtifact(u.String() + "?&" + q.Encode()), // this format is required by AWS
Aws: &cloudintegrationtypes.AWSConnectionArtifact{
ConnectionURL: u.String() + "?&" + q.Encode(), // this format is required by AWS
},
}, nil
}
@@ -122,6 +124,9 @@ func (provider *awscloudprovider) BuildIntegrationConfig(
}
return &cloudintegrationtypes.ProviderIntegrationConfig{
AWS: cloudintegrationtypes.NewAWSIntegrationConfig(account.Config.AWS.Regions, collectionStrategy),
AWS: &cloudintegrationtypes.AWSIntegrationConfig{
EnabledRegions: account.Config.AWS.Regions,
TelemetryCollectionStrategy: collectionStrategy,
},
}, nil
}

View File

@@ -62,10 +62,10 @@
"@signozhq/popover": "0.1.2",
"@signozhq/radio-group": "0.0.4",
"@signozhq/resizable": "0.0.2",
"@signozhq/tabs": "0.0.11",
"@signozhq/table": "0.3.8",
"@signozhq/table": "0.3.7",
"@signozhq/tooltip": "0.0.2",
"@signozhq/ui": "0.0.6",
"@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 +137,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'),
);
@@ -244,18 +251,12 @@ export const ShortcutsPage = Loadable(
() => import(/* webpackChunkName: "ShortcutsPage" */ 'pages/Settings'),
);
export const Integrations = Loadable(
export const InstalledIntegrations = Loadable(
() =>
import(
/* webpackChunkName: "InstalledIntegrations" */ 'pages/IntegrationsModulePage'
),
);
export const IntegrationsDetailsPage = Loadable(
() =>
import(
/* webpackChunkName: "IntegrationsDetailsPage" */ 'pages/IntegrationsDetailsPage'
),
);
export const MessagingQueuesMainPage = Loadable(
() =>

View File

@@ -18,8 +18,7 @@ import {
ForgotPassword,
Home,
InfrastructureMonitoring,
Integrations,
IntegrationsDetailsPage,
InstalledIntegrations,
LicensePage,
ListAllALertsPage,
LiveLogs,
@@ -48,6 +47,7 @@ import {
StatusPage,
SupportPage,
TraceDetail,
TraceDetailV3,
TraceFilter,
TracesExplorer,
TracesFunnelDetails,
@@ -141,10 +141,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,
@@ -390,17 +397,10 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'WORKSPACE_ACCESS_RESTRICTED',
},
{
path: ROUTES.INTEGRATIONS_DETAIL,
exact: true,
component: IntegrationsDetailsPage,
isPrivate: true,
key: 'INTEGRATIONS_DETAIL',
},
{
path: ROUTES.INTEGRATIONS,
exact: true,
component: Integrations,
component: InstalledIntegrations,
isPrivate: true,
key: 'INTEGRATIONS',
},

View File

@@ -0,0 +1,19 @@
import axios from 'api';
import { ErrorResponse, SuccessResponse } from 'types/api';
const removeAwsIntegrationAccount = async (
accountId: string,
): Promise<SuccessResponse<Record<string, never>> | ErrorResponse> => {
const response = await axios.post(
`/cloud-integrations/aws/accounts/${accountId}/disconnect`,
);
return {
statusCode: 200,
error: null,
message: response.data.status,
payload: response.data.data,
};
};
export default removeAwsIntegrationAccount;

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

@@ -2,7 +2,7 @@ import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError, AxiosResponse } from 'axios';
import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder';
import { InfraMonitoringEntity } from 'container/InfraMonitoringK8s/constants';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import { createIdFromObjectFields } from 'lib/createIdFromObjectFields';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
@@ -12,7 +12,7 @@ import {
export const getHostAttributeKeys = async (
searchText = '',
entity: InfraMonitoringEntity,
entity: K8sCategory,
): Promise<SuccessResponse<IQueryAutocompleteResponse> | ErrorResponse> => {
try {
const response: AxiosResponse<{

View File

@@ -33,8 +33,6 @@ export interface HostData {
hostName: string;
active: boolean;
os: string;
/** Present when the list API returns grouped rows or extra resource attributes. */
meta?: Record<string, string>;
cpu: number;
cpuTimeSeries: TimeSeries;
memory: number;

View File

@@ -1,14 +1,13 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
export interface K8sNodesListPayload {
export interface K8sClustersListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
@@ -19,24 +18,23 @@ export interface K8sNodesListPayload {
};
}
export interface K8sNodeData {
nodeUID: string;
nodeCPUUsage: number;
nodeCPUAllocatable: number;
nodeMemoryUsage: number;
nodeMemoryAllocatable: number;
export interface K8sClustersData {
clusterUID: string;
cpuUsage: number;
cpuAllocatable: number;
memoryUsage: number;
memoryAllocatable: number;
meta: {
k8s_node_name: string;
k8s_node_uid: string;
k8s_cluster_name: string;
k8s_cluster_uid: string;
};
}
export interface K8sNodesListResponse {
export interface K8sClustersListResponse {
status: string;
data: {
type: string;
records: K8sNodeData[];
records: K8sClustersData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
@@ -44,32 +42,30 @@ export interface K8sNodesListResponse {
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const nodesMetaMap = [
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
export const clustersMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.cluster.uid', under: 'k8s_cluster_uid' },
] as const;
export function mapNodesMeta(
export function mapClustersMeta(
raw: Record<string, unknown>,
): K8sNodeData['meta'] {
): K8sClustersData['meta'] {
const out: Record<string, unknown> = { ...raw };
nodesMetaMap.forEach(({ dot, under }) => {
clustersMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sNodeData['meta'];
return out as K8sClustersData['meta'];
}
export const getK8sNodesList = async (
props: K8sBaseFilters,
export const getK8sClustersList = async (
props: K8sClustersListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
): Promise<SuccessResponse<K8sClustersListResponse> | ErrorResponse> => {
try {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
@@ -104,16 +100,16 @@ export const getK8sNodesList = async (
}
: props;
const response = await axios.post('/nodes/list', requestProps, {
const response = await axios.post('/clusters/list', requestProps, {
signal,
headers,
});
const payload: K8sNodesListResponse = response.data;
const payload: K8sClustersListResponse = response.data;
// one-liner to map dot→underscore
// one-liner meta mapping
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapNodesMeta(record.meta as Record<string, unknown>),
meta: mapClustersMeta(record.meta as Record<string, unknown>),
}));
return {

View File

@@ -1,10 +1,22 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
export interface K8sDaemonSetsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sDaemonSetsData {
daemonSetName: string;
@@ -36,7 +48,6 @@ export interface K8sDaemonSetsListResponse {
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const daemonSetsMetaMap = [
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
@@ -57,43 +68,45 @@ export function mapDaemonSetsMeta(
}
export const getK8sDaemonSetsList = async (
props: K8sBaseFilters,
props: K8sDaemonSetsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sDaemonSetsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
// filter prep (unchanged)…
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/daemonsets/list', requestProps, {
signal,
@@ -101,6 +114,7 @@ export const getK8sDaemonSetsList = async (
});
const payload: K8sDaemonSetsListResponse = response.data;
// single-line meta mapping
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapDaemonSetsMeta(record.meta as Record<string, unknown>),

View File

@@ -1,12 +1,11 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
export interface K8sDeploymentsListPayload {
filters: TagFilter;
@@ -49,7 +48,6 @@ export interface K8sDeploymentsListResponse {
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const deploymentsMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
@@ -70,43 +68,44 @@ export function mapDeploymentsMeta(
}
export const getK8sDeploymentsList = async (
props: K8sBaseFilters,
props: K8sDeploymentsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sDeploymentsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/deployments/list', requestProps, {
signal,
@@ -114,6 +113,7 @@ export const getK8sDeploymentsList = async (
});
const payload: K8sDeploymentsListResponse = response.data;
// single-line mapping
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapDeploymentsMeta(record.meta as Record<string, unknown>),

View File

@@ -1,10 +1,22 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
export interface K8sJobsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sJobsData {
jobName: string;
@@ -38,7 +50,6 @@ export interface K8sJobsListResponse {
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const jobsMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
@@ -57,43 +68,44 @@ export function mapJobsMeta(raw: Record<string, unknown>): K8sJobsData['meta'] {
}
export const getK8sJobsList = async (
props: K8sBaseFilters,
props: K8sJobsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sJobsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/jobs/list', requestProps, {
signal,
@@ -101,6 +113,7 @@ export const getK8sJobsList = async (
});
const payload: K8sJobsListResponse = response.data;
// one-liner meta mapping
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapJobsMeta(record.meta as Record<string, unknown>),

View File

@@ -1,12 +1,11 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
export interface K8sNamespacesListPayload {
filters: TagFilter;
@@ -41,7 +40,6 @@ export interface K8sNamespacesListResponse {
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const namespacesMetaMap = [
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
@@ -61,43 +59,44 @@ export function mapNamespacesMeta(
}
export const getK8sNamespacesList = async (
props: K8sBaseFilters,
props: K8sNamespacesListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sNamespacesListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/namespaces/list', requestProps, {
signal,

View File

@@ -0,0 +1,127 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { UnderscoreToDotMap } from '../utils';
export interface K8sNodesListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sNodesData {
nodeUID: string;
nodeCPUUsage: number;
nodeCPUAllocatable: number;
nodeMemoryUsage: number;
nodeMemoryAllocatable: number;
meta: {
k8s_node_name: string;
k8s_node_uid: string;
k8s_cluster_name: string;
};
}
export interface K8sNodesListResponse {
status: string;
data: {
type: string;
records: K8sNodesData[];
groups: null;
total: number;
sentAnyHostMetricsData: boolean;
isSendingK8SAgentMetrics: boolean;
};
}
export const nodesMetaMap = [
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.node.uid', under: 'k8s_node_uid' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapNodesMeta(
raw: Record<string, unknown>,
): K8sNodesData['meta'] {
const out: Record<string, unknown> = { ...raw };
nodesMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sNodesData['meta'];
}
export const getK8sNodesList = async (
props: K8sNodesListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sNodesListResponse> | ErrorResponse> => {
try {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/nodes/list', requestProps, {
signal,
headers,
});
const payload: K8sNodesListResponse = response.data;
// one-liner to map dot→underscore
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapNodesMeta(record.meta as Record<string, unknown>),
}));
return {
statusCode: 200,
error: null,
message: 'Success',
payload,
params: requestProps,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -1,36 +1,21 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
// TODO(H4ad): Erase this whole file when migrating to openapi
export const podsMetaMap = [
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
// clone everything
const out: Record<string, unknown> = { ...raw };
// overlay only the dot→under mappings
podsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sPodsData['meta'];
export interface K8sPodsListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface TimeSeriesValue {
@@ -86,44 +71,72 @@ export interface K8sPodsListResponse {
};
}
export const podsMetaMap = [
{ dot: 'k8s.cronjob.name', under: 'k8s_cronjob_name' },
{ dot: 'k8s.daemonset.name', under: 'k8s_daemonset_name' },
{ dot: 'k8s.deployment.name', under: 'k8s_deployment_name' },
{ dot: 'k8s.job.name', under: 'k8s_job_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
{ dot: 'k8s.node.name', under: 'k8s_node_name' },
{ dot: 'k8s.pod.name', under: 'k8s_pod_name' },
{ dot: 'k8s.pod.uid', under: 'k8s_pod_uid' },
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
{ dot: 'k8s.cluster.name', under: 'k8s_cluster_name' },
] as const;
export function mapPodsMeta(raw: Record<string, unknown>): K8sPodsData['meta'] {
// clone everything
const out: Record<string, unknown> = { ...raw };
// overlay only the dot→under mappings
podsMetaMap.forEach(({ dot, under }) => {
if (dot in raw) {
const v = raw[dot];
out[under] = typeof v === 'string' ? v : raw[under];
}
});
return out as K8sPodsData['meta'];
}
// getK8sPodsList
export const getK8sPodsList = async (
props: K8sBaseFilters,
props: K8sPodsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sPodsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters?.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({
...item,
key: { ...item.key, key: mappedKey },
});
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/pods/list', requestProps, {
signal,

View File

@@ -1,10 +1,22 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
export interface K8sVolumesListPayload {
filters: TagFilter;
groupBy?: BaseAutocompleteData[];
offset?: number;
limit?: number;
orderBy?: {
columnName: string;
order: 'asc' | 'desc';
};
}
export interface K8sVolumesData {
persistentVolumeClaimName: string;
@@ -37,7 +49,6 @@ export interface K8sVolumesListResponse {
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const volumesMetaMap: Array<{
dot: keyof Record<string, unknown>;
under: keyof K8sVolumesData['meta'];
@@ -57,8 +68,10 @@ export const volumesMetaMap: Array<{
export function mapVolumesMeta(
rawMeta: Record<string, unknown>,
): K8sVolumesData['meta'] {
// start with everything that was already there
const out: Record<string, unknown> = { ...rawMeta };
// for each dot→under rule, if the raw has the dot, overwrite the underscore
volumesMetaMap.forEach(({ dot, under }) => {
if (dot in rawMeta) {
const val = rawMeta[dot];
@@ -70,47 +83,42 @@ export function mapVolumesMeta(
}
export const getK8sVolumesList = async (
props: K8sBaseFilters,
props: K8sVolumesListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sVolumesListResponse> | ErrorResponse> => {
try {
const { orderBy, ...rest } = props;
const basePayload = {
...rest,
filters: props.filters ?? { items: [], op: 'and' },
...(orderBy != null ? { orderBy } : {}),
};
const requestProps = dotMetricsEnabled
? {
...basePayload,
filters: {
...basePayload.filters,
items: basePayload.filters.items.reduce<typeof basePayload.filters.items>(
(acc, item) => {
if (item.value === undefined) {
// Prepare filters
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
} else {
acc.push(item);
}
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
} else {
acc.push(item);
}
return acc;
},
[] as typeof basePayload.filters.items,
),
},
}
: basePayload;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/pvcs/list', requestProps, {
signal,

View File

@@ -1,12 +1,11 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { UnderscoreToDotMap } from 'api/utils';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { K8sBaseFilters } from '../Base/types';
import { UnderscoreToDotMap } from '../utils';
export interface K8sStatefulSetsListPayload {
filters: TagFilter;
@@ -48,7 +47,6 @@ export interface K8sStatefulSetsListResponse {
};
}
// TODO(H4ad): Erase this whole file when migrating to openapi
export const statefulSetsMetaMap = [
{ dot: 'k8s.statefulset.name', under: 'k8s_statefulset_name' },
{ dot: 'k8s.namespace.name', under: 'k8s_namespace_name' },
@@ -68,37 +66,42 @@ export function mapStatefulSetsMeta(
}
export const getK8sStatefulSetsList = async (
props: K8sBaseFilters,
props: K8sStatefulSetsListPayload,
signal?: AbortSignal,
headers?: Record<string, string>,
dotMetricsEnabled = false,
): Promise<SuccessResponse<K8sStatefulSetsListResponse> | ErrorResponse> => {
try {
const requestProps = dotMetricsEnabled
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<TagFilter['items']>((acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
} else {
acc.push(item);
}
return acc;
}, [] as TagFilter['items']),
},
}
: props;
// Prepare filters
const requestProps =
dotMetricsEnabled && Array.isArray(props.filters?.items)
? {
...props,
filters: {
...props.filters,
items: props.filters.items.reduce<typeof props.filters.items>(
(acc, item) => {
if (item.value === undefined) {
return acc;
}
if (
item.key &&
typeof item.key === 'object' &&
'key' in item.key &&
typeof item.key.key === 'string'
) {
const mappedKey = UnderscoreToDotMap[item.key.key] ?? item.key.key;
acc.push({ ...item, key: { ...item.key, key: mappedKey } });
} else {
acc.push(item);
}
return acc;
},
[] as typeof props.filters.items,
),
},
}
: props;
const response = await axios.post('/statefulsets/list', requestProps, {
signal,
@@ -106,6 +109,7 @@ export const getK8sStatefulSetsList = async (
});
const payload: K8sStatefulSetsListResponse = response.data;
// apply our helper
payload.data.records = payload.data.records.map((record) => ({
...record,
meta: mapStatefulSetsMeta(record.meta as Record<string, unknown>),

View File

@@ -0,0 +1,88 @@
import axios from 'api';
import {
CloudAccount,
Service,
ServiceData,
UpdateServiceConfigPayload,
UpdateServiceConfigResponse,
} from 'container/CloudIntegrationPage/ServicesSection/types';
import {
AccountConfigPayload,
AccountConfigResponse,
ConnectionParams,
ConnectionUrlResponse,
} from 'types/api/integrations/aws';
export const getAwsAccounts = async (): Promise<CloudAccount[]> => {
const response = await axios.get('/cloud-integrations/aws/accounts');
return response.data.data.accounts;
};
export const getAwsServices = async (
cloudAccountId?: string,
): Promise<Service[]> => {
const params = cloudAccountId
? { cloud_account_id: cloudAccountId }
: undefined;
const response = await axios.get('/cloud-integrations/aws/services', {
params,
});
return response.data.data.services;
};
export const getServiceDetails = async (
serviceId: string,
cloudAccountId?: string,
): Promise<ServiceData> => {
const params = cloudAccountId
? { cloud_account_id: cloudAccountId }
: undefined;
const response = await axios.get(
`/cloud-integrations/aws/services/${serviceId}`,
{ params },
);
return response.data.data;
};
export const generateConnectionUrl = async (params: {
agent_config: { region: string };
account_config: { regions: string[] };
account_id?: string;
}): Promise<ConnectionUrlResponse> => {
const response = await axios.post(
'/cloud-integrations/aws/accounts/generate-connection-url',
params,
);
return response.data.data;
};
export const updateAccountConfig = async (
accountId: string,
payload: AccountConfigPayload,
): Promise<AccountConfigResponse> => {
const response = await axios.post<AccountConfigResponse>(
`/cloud-integrations/aws/accounts/${accountId}/config`,
payload,
);
return response.data;
};
export const updateServiceConfig = async (
serviceId: string,
payload: UpdateServiceConfigPayload,
): Promise<UpdateServiceConfigResponse> => {
const response = await axios.post<UpdateServiceConfigResponse>(
`/cloud-integrations/aws/services/${serviceId}/config`,
payload,
);
return response.data;
};
export const getConnectionParams = async (): Promise<ConnectionParams> => {
const response = await axios.get(
'/cloud-integrations/aws/accounts/generate-connection-params',
);
return response.data.data;
};

View File

@@ -0,0 +1,66 @@
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,
};
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

@@ -1,9 +0,0 @@
<svg width="929" height="8" viewBox="0 0 929 8" fill="none" xmlns="http://www.w3.org/2000/svg">
<defs>
<pattern id="dotted-double-line-pattern" x="0" y="0" width="6" height="8" patternUnits="userSpaceOnUse">
<rect width="2" height="2" rx="1" fill="#242834" />
<rect y="6" width="2" height="2" rx="1" fill="#242834" />
</pattern>
</defs>
<rect width="929" height="8" fill="url(#dotted-double-line-pattern)" />
</svg>

Before

Width:  |  Height:  |  Size: 442 B

View File

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

View File

@@ -1,34 +0,0 @@
.cloud-service-data-collected {
display: flex;
flex-direction: column;
gap: 16px;
.cloud-service-data-collected-table {
display: flex;
flex-direction: column;
gap: 8px;
.cloud-service-data-collected-table-heading {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
color: var(--l2-foreground);
/* Bifrost (Ancient)/Content/sm */
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.cloud-service-data-collected-table-logs {
border-radius: 6px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
}
}
}

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

@@ -3,8 +3,8 @@ import { useLocation } from 'react-router-dom';
import { toast } from '@signozhq/ui';
import { Button, Input, Radio, RadioChangeEvent, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
function FeedbackModal({ onClose }: { onClose: () => void }): JSX.Element {
const [activeTab, setActiveTab] = useState('feedback');

View File

@@ -4,8 +4,8 @@ import { toast } from '@signozhq/ui';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import logEvent from 'api/common/logEvent';
import { handleContactSupport } from 'container/Integrations/utils';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
import { handleContactSupport } from 'pages/Integrations/utils';
import FeedbackModal from '../FeedbackModal';
@@ -31,7 +31,7 @@ jest.mock('hooks/useGetTenantLicense', () => ({
useGetTenantLicense: jest.fn(),
}));
jest.mock('container/Integrations/utils', () => ({
jest.mock('pages/Integrations/utils', () => ({
handleContactSupport: jest.fn(),
}));

View File

@@ -1,15 +1,16 @@
import { useTranslation } from 'react-i18next';
import { Space, Typography } from 'antd';
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
import broomUrl from '@/assets/Icons/broom.svg';
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
import 'components/HostMetricsDetail/Containers/Containers.styles.scss';
import WaitlistFragment from '../WaitlistFragment/WaitlistFragment';
import './Containers.styles.scss';
const { Text } = Typography;
function EntityContainers(): JSX.Element {
function Containers(): JSX.Element {
const { t } = useTranslation(['infraMonitoring']);
return (
@@ -43,4 +44,4 @@ function EntityContainers(): JSX.Element {
);
}
export default EntityContainers;
export default Containers;

View File

@@ -0,0 +1,7 @@
import { HostData } from 'api/infraMonitoring/getHostLists';
export type HostDetailProps = {
host: HostData | null;
isModalTimeSelection: boolean;
onClose: () => void;
};

View File

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

View File

@@ -0,0 +1,222 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import logEvent from 'api/common/logEvent';
import { ResizeTable } from 'components/ResizeTable';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import QueryBuilderSearch from 'container/QueryBuilder/filters/QueryBuilderSearch';
import { ErrorText } from 'container/TimeSeriesView/styles';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { Pagination } from 'hooks/queryPagination';
import useUrlQueryData from 'hooks/useUrlQueryData';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { VIEWS } from '../constants';
import { getHostTracesQueryPayload, selectedColumns } from './constants';
import { getListColumns } from './utils';
import './HostMetricTraces.styles.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
handleChangeTracesFilters: (
value: IBuilderQuery['filters'],
view: VIEWS,
) => void;
tracesFilters: IBuilderQuery['filters'];
selectedInterval: Time;
}
function HostMetricTraces({
timeRange,
isModalTimeSelection,
handleTimeChange,
handleChangeTracesFilters,
tracesFilters,
selectedInterval,
}: Props): JSX.Element {
const [traces, setTraces] = useState<any[]>([]);
const [offset] = useState<number>(0);
const { currentQuery } = useQueryBuilder();
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
filters: {
items:
tracesFilters?.items?.filter((item) => item.key?.key !== 'host.name') ||
[],
op: 'AND',
},
},
],
},
}),
[currentQuery, tracesFilters?.items],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const { queryData: paginationQueryData } = useUrlQueryData<Pagination>(
QueryParams.pagination,
);
const queryPayload = useMemo(
() =>
getHostTracesQueryPayload(
timeRange.startTime,
timeRange.endTime,
paginationQueryData?.offset || offset,
tracesFilters,
),
[
timeRange.startTime,
timeRange.endTime,
offset,
tracesFilters,
paginationQueryData,
],
);
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: [
'hostMetricTraces',
timeRange.startTime,
timeRange.endTime,
offset,
tracesFilters,
DEFAULT_ENTITY_VERSION,
paginationQueryData,
],
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
enabled: !!queryPayload,
});
const traceListColumns = getListColumns(selectedColumns);
useEffect(() => {
if (data?.payload?.data?.newResult?.data?.result) {
const currentData = data.payload.data.newResult.data.result;
if (currentData.length > 0 && currentData[0].list) {
if (offset === 0) {
setTraces(currentData[0].list ?? []);
} else {
setTraces((prev) => [...prev, ...(currentData[0].list ?? [])]);
}
}
}
}, [data, offset]);
const isDataEmpty =
!isLoading && !isFetching && !isError && traces.length === 0;
const hasAdditionalFilters =
tracesFilters?.items && tracesFilters?.items?.length > 1;
const totalCount =
data?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0;
const handleRowClick = useCallback(() => {
logEvent(InfraMonitoringEvents.ItemClicked, {
entity: InfraMonitoringEvents.HostEntity,
view: InfraMonitoringEvents.TracesView,
});
}, []);
return (
<div className="host-metric-traces">
<div className="host-metric-traces-header">
<div className="filter-section">
{query && (
<QueryBuilderSearch
query={query as IBuilderQuery}
onChange={(value): void =>
handleChangeTracesFilters(value, VIEWS.TRACES)
}
disableNavigationShortcuts
/>
)}
</div>
<div className="datetime-section">
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
isModalTimeSelection={isModalTimeSelection}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
</div>
{isError && <ErrorText>{data?.error || 'Something went wrong'}</ErrorText>}
{isLoading && traces.length === 0 && <TracesLoading />}
{isDataEmpty && !hasAdditionalFilters && (
<NoLogs dataSource={DataSource.TRACES} />
)}
{isDataEmpty && hasAdditionalFilters && (
<EmptyLogsSearch dataSource={DataSource.TRACES} panelType="LIST" />
)}
{!isError && traces.length > 0 && (
<div className="host-metric-traces-table">
<TraceExplorerControls
isLoading={isFetching && traces.length === 0}
totalCount={totalCount}
perPageOptions={PER_PAGE_OPTIONS}
showSizeChanger={false}
/>
<ResizeTable
tableLayout="fixed"
pagination={false}
scroll={{ x: true }}
loading={isFetching && traces.length === 0}
dataSource={traces}
columns={traceListColumns}
onRow={(): Record<string, unknown> => ({
onClick: (): void => handleRowClick(),
})}
/>
</div>
)}
</div>
);
}
export default HostMetricTraces;

View File

@@ -0,0 +1,183 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { nanoToMilli } from 'utils/timeUtils';
export const columns = [
{
dataIndex: 'timestamp',
key: 'timestamp',
title: 'Timestamp',
width: 200,
render: (timestamp: string): string => new Date(timestamp).toLocaleString(),
},
{
title: 'Service Name',
dataIndex: ['data', 'serviceName'],
key: 'serviceName-string-tag',
width: 150,
},
{
title: 'Name',
dataIndex: ['data', 'name'],
key: 'name-string-tag',
width: 145,
},
{
title: 'Duration',
dataIndex: ['data', 'durationNano'],
key: 'durationNano-float64-tag',
width: 145,
render: (duration: number): string => `${nanoToMilli(duration)}ms`,
},
{
title: 'HTTP Method',
dataIndex: ['data', 'httpMethod'],
key: 'httpMethod-string-tag',
width: 145,
},
{
title: 'Status Code',
dataIndex: ['data', 'responseStatusCode'],
key: 'responseStatusCode-string-tag',
width: 145,
},
];
export const selectedColumns: BaseAutocompleteData[] = [
{
key: 'timestamp',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'serviceName',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'name',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'durationNano',
dataType: DataTypes.Float64,
type: 'tag',
},
{
key: 'httpMethod',
dataType: DataTypes.String,
type: 'tag',
},
{
key: 'responseStatusCode',
dataType: DataTypes.String,
type: 'tag',
},
];
export const getHostTracesQueryPayload = (
start: number,
end: number,
offset = 0,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps => ({
query: {
promql: [],
clickhouse_sql: [],
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.EMPTY,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters,
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
id: '572f1d91-6ac0-46c0-b726-c21488b34434',
queryType: EQueryType.QUERY_BUILDER,
},
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
start,
end,
params: {
dataSource: DataSource.TRACES,
},
tableParams: {
pagination: {
limit: 10,
offset,
},
selectColumns: [
{
key: 'serviceName',
dataType: 'string',
type: 'tag',
id: 'serviceName--string--tag--true',
isIndexed: false,
},
{
key: 'name',
dataType: 'string',
type: 'tag',
id: 'name--string--tag--true',
isIndexed: false,
},
{
key: 'durationNano',
dataType: 'float64',
type: 'tag',
id: 'durationNano--float64--tag--true',
isIndexed: false,
},
{
key: 'httpMethod',
dataType: 'string',
type: 'tag',
id: 'httpMethod--string--tag--true',
isIndexed: false,
},
{
key: 'responseStatusCode',
dataType: 'string',
type: 'tag',
id: 'responseStatusCode--string--tag--true',
isIndexed: false,
},
],
},
});

View File

@@ -18,7 +18,7 @@ const keyToLabelMap: Record<string, string> = {
responseStatusCode: 'Status Code',
};
export const getTraceListColumns = (
export const getListColumns = (
selectedColumns: BaseAutocompleteData[],
): ColumnsType<RowData> => {
const columns: ColumnsType<RowData> =

View File

@@ -0,0 +1,176 @@
.host-detail-drawer {
border-left: 1px solid var(--l1-border);
background: var(--l2-background);
box-shadow: -4px 10px 16px 2px rgba(0, 0, 0, 0.2);
.ant-drawer-header {
padding: 8px 16px;
border-bottom: none;
align-items: stretch;
border-bottom: 1px solid var(--l1-border);
background: var(--l2-background);
}
.ant-drawer-close {
margin-inline-end: 0px;
}
.ant-drawer-body {
display: flex;
flex-direction: column;
padding: 16px;
}
.title {
color: var(--l2-foreground);
font-family: 'Geist Mono';
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
}
.radio-button {
display: flex;
align-items: center;
justify-content: center;
padding-top: var(--padding-1);
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
.host-detail-drawer__host {
.host-details-grid {
.labels-row,
.values-row {
display: grid;
grid-template-columns: 1fr 1.5fr 1.5fr 1.5fr;
gap: 30px;
align-items: center;
}
.labels-row {
margin-bottom: 8px;
}
.host-details-metadata-label {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 500;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
}
.status-tag {
margin: 0;
&.active {
color: var(--success-500);
background: var(--success-100);
border-color: var(--success-500);
}
&.inactive {
color: var(--error-500);
background: var(--error-100);
border-color: var(--error-500);
}
}
.progress-container {
width: 158px;
.ant-progress {
margin: 0;
.ant-progress-text {
font-weight: 600;
}
}
}
.ant-card {
&.ant-card-bordered {
border: 1px solid var(--l1-border) !important;
}
}
}
}
.tabs-and-search {
display: flex;
justify-content: space-between;
align-items: center;
margin: 16px 0;
.action-btn {
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
display: flex;
align-items: center;
justify-content: center;
}
}
.views-tabs-container {
margin-top: 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
.views-tabs {
color: var(--l2-foreground);
.view-title {
display: flex;
gap: var(--margin-2);
align-items: center;
justify-content: center;
font-size: var(--font-size-xs);
font-style: normal;
font-weight: var(--font-weight-normal);
}
.tab {
border: 1px solid var(--l1-border);
width: 114px;
}
.tab::before {
background: var(--l1-border);
}
.selected_view {
background: var(--l3-background);
color: var(--l1-foreground);
border: 1px solid var(--l1-border);
}
.selected_view::before {
background: var(--l1-border);
}
}
.compass-button {
width: 30px;
height: 30px;
border-radius: 2px;
border: 1px solid var(--l1-border);
background: var(--l3-background);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
}
.ant-drawer-close {
padding: 0px;
}
}

View File

@@ -0,0 +1,595 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
// eslint-disable-next-line no-restricted-imports
import { useSelector } from 'react-redux';
import { useSearchParams } from 'react-router-dom-v5-compat';
import { Color, Spacing } from '@signozhq/design-tokens';
import {
Button,
Divider,
Drawer,
Progress,
Radio,
Tag,
Typography,
} from 'antd';
import type { RadioChangeEvent } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { InfraMonitoringEvents } from 'constants/events';
import { QueryParams } from 'constants/query';
import {
initialQueryBuilderFormValuesMap,
initialQueryState,
} from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { getFiltersFromParams } from 'container/InfraMonitoringK8s/commonUtils';
import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/constants';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import {
BarChart2,
ChevronsLeftRight,
Compass,
DraftingCompass,
Package2,
ScrollText,
X,
} from 'lucide-react';
import { AppState } from 'store/reducers';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
IBuilderQuery,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import {
LogsAggregatorOperator,
TracesAggregatorOperator,
} from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import { v4 as uuidv4 } from 'uuid';
import { VIEW_TYPES, VIEWS } from './constants';
import Containers from './Containers/Containers';
import { HostDetailProps } from './HostMetricDetail.interfaces';
import HostMetricLogsDetailedView from './HostMetricsLogs/HostMetricLogsDetailedView';
import HostMetricTraces from './HostMetricTraces/HostMetricTraces';
import Metrics from './Metrics/Metrics';
import Processes from './Processes/Processes';
import './HostMetricsDetail.styles.scss';
// eslint-disable-next-line sonarjs/cognitive-complexity
function HostMetricsDetails({
host,
onClose,
isModalTimeSelection,
}: HostDetailProps): JSX.Element {
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const [searchParams, setSearchParams] = useSearchParams();
const startMs = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [
maxTime,
]);
const urlQuery = useUrlQuery();
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
const lastSelectedInterval = useRef<Time | null>(null);
const [selectedInterval, setSelectedInterval] = useState<Time>(
lastSelectedInterval.current
? lastSelectedInterval.current
: (selectedTime as Time),
);
const [selectedView, setSelectedView] = useState<VIEWS>(
(searchParams.get('view') as VIEWS) || VIEWS.METRICS,
);
const isDarkMode = useIsDarkMode();
const initialFilters = useMemo(() => {
const urlView = searchParams.get(INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW);
const queryKey =
urlView === VIEW_TYPES.LOGS
? INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS
: INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS;
const filters = getFiltersFromParams(searchParams, queryKey);
if (filters) {
return filters;
}
return {
op: 'AND',
items: [
{
id: uuidv4(),
key: {
key: 'host.name',
dataType: DataTypes.String,
type: 'resource',
id: 'host.name--string--resource--false',
},
op: '=',
value: host?.hostName || '',
},
],
};
}, [host?.hostName, searchParams]);
const [logFilters, setLogFilters] = useState<IBuilderQuery['filters']>(
initialFilters,
);
const [tracesFilters, setTracesFilters] = useState<IBuilderQuery['filters']>(
initialFilters,
);
useEffect(() => {
if (host) {
logEvent(InfraMonitoringEvents.PageVisited, {
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.DetailedPage,
});
}
}, [host]);
useEffect(() => {
setLogFilters(initialFilters);
setTracesFilters(initialFilters);
}, [initialFilters]);
useEffect(() => {
const currentSelectedInterval = lastSelectedInterval.current || selectedTime;
setSelectedInterval(currentSelectedInterval as Time);
if (currentSelectedInterval !== 'custom') {
const { maxTime, minTime } = GetMinMax(currentSelectedInterval);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
}, [selectedTime, minTime, maxTime]);
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
if (host?.hostName) {
setSelectedView(e.target.value);
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: e.target.value,
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(null),
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(null),
});
}
logEvent(InfraMonitoringEvents.TabChanged, {
entity: InfraMonitoringEvents.HostEntity,
view: e.target.value,
});
};
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
lastSelectedInterval.current = interval as Time;
setSelectedInterval(interval as Time);
if (interval === 'custom' && dateTimeRange) {
setModalTimeRange({
startTime: Math.floor(dateTimeRange[0] / 1000),
endTime: Math.floor(dateTimeRange[1] / 1000),
});
} else {
const { maxTime, minTime } = GetMinMax(interval);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
logEvent(InfraMonitoringEvents.TimeUpdated, {
entity: InfraMonitoringEvents.HostEntity,
interval,
page: InfraMonitoringEvents.DetailedPage,
});
},
[],
);
const handleChangeLogFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setLogFilters((prevFilters) => {
const hostNameFilter = prevFilters?.items?.find(
(item) => item.key?.key === 'host.name',
);
const paginationFilter = value?.items?.find(
(item) => item.key?.key === 'id',
);
const newFilters = value?.items?.filter(
(item) => item.key?.key !== 'id' && item.key?.key !== 'host.name',
);
if (newFilters && newFilters?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.HostEntity,
view: InfraMonitoringEvents.LogsView,
page: InfraMonitoringEvents.DetailedPage,
});
}
const updatedFilters = {
op: 'AND',
items: [
hostNameFilter,
...(newFilters || []),
...(paginationFilter ? [paginationFilter] : []),
].filter((item): item is TagFilterItem => item !== undefined),
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[INFRA_MONITORING_K8S_PARAMS_KEYS.LOG_FILTERS]: JSON.stringify(
updatedFilters,
),
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
});
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleChangeTracesFilters = useCallback(
(value: IBuilderQuery['filters'], view: VIEWS) => {
setTracesFilters((prevFilters) => {
const hostNameFilter = prevFilters?.items?.find(
(item) => item.key?.key === 'host.name',
);
if (value?.items && value?.items?.length > 0) {
logEvent(InfraMonitoringEvents.FilterApplied, {
entity: InfraMonitoringEvents.HostEntity,
view: InfraMonitoringEvents.TracesView,
page: InfraMonitoringEvents.DetailedPage,
});
}
const updatedFilters = {
op: 'AND',
items: [
hostNameFilter,
...(value?.items?.filter((item) => item.key?.key !== 'host.name') || []),
].filter((item): item is TagFilterItem => item !== undefined),
};
setSearchParams({
...Object.fromEntries(searchParams.entries()),
[INFRA_MONITORING_K8S_PARAMS_KEYS.TRACES_FILTERS]: JSON.stringify(
updatedFilters,
),
[INFRA_MONITORING_K8S_PARAMS_KEYS.VIEW]: view,
});
return updatedFilters;
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
const handleExplorePagesRedirect = (): void => {
if (selectedInterval !== 'custom') {
urlQuery.set(QueryParams.relativeTime, selectedInterval);
} else {
urlQuery.delete(QueryParams.relativeTime);
urlQuery.set(QueryParams.startTime, modalTimeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, modalTimeRange.endTime.toString());
}
logEvent(InfraMonitoringEvents.ExploreClicked, {
view: selectedView,
entity: InfraMonitoringEvents.HostEntity,
page: InfraMonitoringEvents.DetailedPage,
});
if (selectedView === VIEW_TYPES.LOGS) {
const filtersWithoutPagination = {
...logFilters,
items: logFilters?.items?.filter((item) => item.key?.key !== 'id') || [],
};
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.logs,
aggregateOperator: LogsAggregatorOperator.NOOP,
filters: filtersWithoutPagination,
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
} else if (selectedView === VIEW_TYPES.TRACES) {
const compositeQuery = {
...initialQueryState,
queryType: 'builder',
builder: {
...initialQueryState.builder,
queryData: [
{
...initialQueryBuilderFormValuesMap.traces,
aggregateOperator: TracesAggregatorOperator.NOOP,
filters: tracesFilters,
},
],
},
};
urlQuery.set('compositeQuery', JSON.stringify(compositeQuery));
window.open(
`${window.location.origin}${ROUTES.TRACES_EXPLORER}?${urlQuery.toString()}`,
'_blank',
);
}
};
const handleClose = (): void => {
setSelectedInterval(selectedTime as Time);
lastSelectedInterval.current = null;
setSearchParams({});
if (selectedTime !== 'custom') {
const { maxTime, minTime } = GetMinMax(selectedTime);
setModalTimeRange({
startTime: Math.floor(minTime / 1000000000),
endTime: Math.floor(maxTime / 1000000000),
});
}
setSelectedView(VIEW_TYPES.METRICS);
onClose();
};
return (
<Drawer
width="70%"
title={
<>
<Divider type="vertical" />
<Typography.Text className="title">{host?.hostName}</Typography.Text>
</>
}
placement="right"
onClose={handleClose}
open={!!host}
style={{
overscrollBehavior: 'contain',
background: isDarkMode ? Color.BG_INK_400 : Color.BG_VANILLA_100,
}}
className="host-detail-drawer"
destroyOnClose
closeIcon={<X size={16} style={{ marginTop: Spacing.MARGIN_1 }} />}
>
{host && (
<>
<div className="host-detail-drawer__host">
<div className="host-details-grid">
<div className="labels-row">
<Typography.Text
type="secondary"
className="host-details-metadata-label"
>
STATUS
</Typography.Text>
<Typography.Text
type="secondary"
className="host-details-metadata-label"
>
OPERATING SYSTEM
</Typography.Text>
<Typography.Text
type="secondary"
className="host-details-metadata-label"
>
CPU USAGE
</Typography.Text>
<Typography.Text
type="secondary"
className="host-details-metadata-label"
>
MEMORY USAGE
</Typography.Text>
</div>
<div className="values-row">
<Tag
bordered
className={`infra-monitoring-tags ${
host.active ? 'active' : 'inactive'
}`}
>
{host.active ? 'ACTIVE' : 'INACTIVE'}
</Tag>
{host.os ? (
<Tag className="infra-monitoring-tags" bordered>
{host.os}
</Tag>
) : (
<Typography.Text>-</Typography.Text>
)}
<div className="progress-container">
<Progress
percent={Number((host.cpu * 100).toFixed(1))}
size="small"
strokeColor={((): string => {
const cpuPercent = Number((host.cpu * 100).toFixed(1));
if (cpuPercent >= 90) {
return Color.BG_SAKURA_500;
}
if (cpuPercent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
</div>
<div className="progress-container">
<Progress
percent={Number((host.memory * 100).toFixed(1))}
size="small"
strokeColor={((): string => {
const memoryPercent = Number((host.memory * 100).toFixed(1));
if (memoryPercent >= 90) {
return Color.BG_CHERRY_500;
}
if (memoryPercent >= 60) {
return Color.BG_AMBER_500;
}
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
</div>
</div>
</div>
</div>
<div className="views-tabs-container">
<Radio.Group
className="views-tabs"
onChange={handleTabChange}
value={selectedView}
>
<Radio.Button
className={
selectedView === VIEW_TYPES.METRICS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.METRICS}
>
<div className="view-title">
<BarChart2 size={14} />
Metrics
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.LOGS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.LOGS}
>
<div className="view-title">
<ScrollText size={14} />
Logs
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.TRACES ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.TRACES}
>
<div className="view-title">
<DraftingCompass size={14} />
Traces
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.CONTAINERS ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.CONTAINERS}
>
<div className="view-title">
<Package2 size={14} />
Containers
</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.PROCESSES ? 'selected_view tab' : 'tab'
}
value={VIEW_TYPES.PROCESSES}
>
<div className="view-title">
<ChevronsLeftRight size={14} />
Processes
</div>
</Radio.Button>
</Radio.Group>
{(selectedView === VIEW_TYPES.LOGS ||
selectedView === VIEW_TYPES.TRACES) && (
<Button
icon={<Compass size={18} />}
className="compass-button"
onClick={handleExplorePagesRedirect}
/>
)}
</div>
{selectedView === VIEW_TYPES.METRICS && (
<Metrics
selectedInterval={selectedInterval}
hostName={host.hostName}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
isModalTimeSelection={isModalTimeSelection}
/>
)}
{selectedView === VIEW_TYPES.LOGS && (
<HostMetricLogsDetailedView
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeLogFilters={handleChangeLogFilters}
logFilters={logFilters}
selectedInterval={selectedInterval}
/>
)}
{selectedView === VIEW_TYPES.TRACES && (
<HostMetricTraces
timeRange={modalTimeRange}
isModalTimeSelection={isModalTimeSelection}
handleTimeChange={handleTimeChange}
handleChangeTracesFilters={handleChangeTracesFilters}
tracesFilters={tracesFilters}
selectedInterval={selectedInterval}
/>
)}
{selectedView === VIEW_TYPES.CONTAINERS && <Containers />}
{selectedView === VIEW_TYPES.PROCESSES && <Processes />}
</>
)}
</Drawer>
);
}
export default HostMetricsDetails;

View File

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

View File

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

View File

@@ -0,0 +1,187 @@
import { useCallback, useEffect, useMemo, useRef } from 'react';
import { useQuery } from 'react-query';
import { Virtuoso, VirtuosoHandle } from 'react-virtuoso';
import { Card } from 'antd';
import LogDetail from 'components/LogDetail';
import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { DEFAULT_ENTITY_VERSION } from 'constants/app';
import LogsError from 'container/LogsError/LogsError';
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import { FontSize } from 'container/OptionsMenu/types';
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
import useLogDetailHandlers from 'hooks/logs/useLogDetailHandlers';
import useScrollToLog from 'hooks/logs/useScrollToLog';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { ILog } from 'types/api/logs/log';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { getHostLogsQueryPayload } from './constants';
import NoLogsContainer from './NoLogsContainer';
import './HostMetricLogs.styles.scss';
interface Props {
timeRange: {
startTime: number;
endTime: number;
};
filters: IBuilderQuery['filters'];
}
function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
const virtuosoRef = useRef<VirtuosoHandle>(null);
const {
activeLog,
onAddToQuery,
selectedTab,
handleSetActiveLog,
handleCloseLogDetail,
} = useLogDetailHandlers();
const basePayload = getHostLogsQueryPayload(
timeRange.startTime,
timeRange.endTime,
filters,
);
const {
logs,
hasReachedEndOfLogs,
isPaginating,
currentPage,
setIsPaginating,
handleNewData,
loadMoreLogs,
queryPayload,
} = useHandleLogsPagination({
timeRange,
filters,
excludeFilterKeys: ['host.name'],
basePayload,
});
const { data, isLoading, isFetching, isError } = useQuery({
queryKey: [
'hostMetricsLogs',
timeRange.startTime,
timeRange.endTime,
filters,
currentPage,
],
queryFn: () => GetMetricQueryRange(queryPayload, DEFAULT_ENTITY_VERSION),
enabled: !!queryPayload,
keepPreviousData: isPaginating,
});
useEffect(() => {
if (data?.payload?.data?.newResult?.data?.result) {
handleNewData(data.payload.data.newResult.data.result);
}
}, [data, handleNewData]);
useEffect(() => {
setIsPaginating(false);
}, [data, setIsPaginating]);
const handleScrollToLog = useScrollToLog({
logs,
virtuosoRef,
});
const getItemContent = useCallback(
(_: number, logToRender: ILog): JSX.Element => {
return (
<div key={logToRender.id}>
<RawLogView
isTextOverflowEllipsisDisabled
data={logToRender}
linesPerRow={5}
fontSize={FontSize.MEDIUM}
selectedFields={[
{
dataType: 'string',
type: '',
name: 'body',
},
{
dataType: 'string',
type: '',
name: 'timestamp',
},
]}
onSetActiveLog={handleSetActiveLog}
onClearActiveLog={handleCloseLogDetail}
isActiveLog={activeLog?.id === logToRender.id}
/>
</div>
);
},
[activeLog, handleSetActiveLog, handleCloseLogDetail],
);
const renderFooter = useCallback(
(): JSX.Element | null => (
<>
{isFetching ? (
<div className="logs-loading-skeleton"> Loading more logs ... </div>
) : hasReachedEndOfLogs ? (
<div className="logs-loading-skeleton"> *** End *** </div>
) : null}
</>
),
[isFetching, hasReachedEndOfLogs],
);
const renderContent = useMemo(
() => (
<Card bordered={false} className="host-metrics-logs-list-card">
<OverlayScrollbar isVirtuoso>
<Virtuoso
className="host-metrics-logs-virtuoso"
key="host-metrics-logs-virtuoso"
ref={virtuosoRef}
data={logs}
endReached={loadMoreLogs}
totalCount={logs.length}
itemContent={getItemContent}
overscan={200}
components={{
Footer: renderFooter,
}}
/>
</OverlayScrollbar>
</Card>
),
[logs, loadMoreLogs, getItemContent, renderFooter],
);
return (
<div className="host-metrics-logs">
{isLoading && <LogsLoading />}
{!isLoading && !isError && logs.length === 0 && <NoLogsContainer />}
{isError && !isLoading && <LogsError />}
{!isLoading && !isError && logs.length > 0 && (
<div
className="host-metrics-logs-list-container"
data-log-detail-ignore="true"
>
{renderContent}
</div>
)}
{selectedTab && activeLog && (
<LogDetail
log={activeLog}
onClose={handleCloseLogDetail}
logs={logs}
onNavigateLog={handleSetActiveLog}
selectedTab={selectedTab}
onAddToQuery={onAddToQuery}
onClickActionItem={onAddToQuery}
onScrollToLog={handleScrollToLog}
/>
)}
</div>
);
}
export default HostMetricsLogs;

View File

@@ -0,0 +1,16 @@
import { Color } from '@signozhq/design-tokens';
import { Typography } from 'antd';
import { Ghost } from 'lucide-react';
const { Text } = Typography;
export default function NoLogsContainer(): React.ReactElement {
return (
<div className="no-logs-found">
<Text type="secondary">
<Ghost size={24} color={Color.BG_AMBER_500} /> No logs found for this host
in the selected time range.
</Text>
</div>
);
}

View File

@@ -0,0 +1,61 @@
import { PANEL_TYPES } from 'constants/queryBuilder';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { v4 as uuidv4 } from 'uuid';
export const getHostLogsQueryPayload = (
start: number,
end: number,
filters: IBuilderQuery['filters'],
): GetQueryResultsProps => ({
graphType: PANEL_TYPES.LIST,
selectedTime: 'GLOBAL_TIME',
query: {
clickhouse_sql: [],
promql: [],
builder: {
queryData: [
{
dataSource: DataSource.LOGS,
queryName: 'A',
aggregateOperator: 'noop',
aggregateAttribute: {
id: '------false',
dataType: DataTypes.String,
key: '',
type: '',
},
timeAggregation: 'rate',
spaceAggregation: 'sum',
functions: [],
filters,
expression: 'A',
disabled: false,
stepInterval: 60,
having: [],
limit: null,
orderBy: [
{
columnName: 'timestamp',
order: 'desc',
},
],
groupBy: [],
legend: '',
reduceTo: ReduceOperators.AVG,
offset: 0,
pageSize: 100,
},
],
queryFormulas: [],
queryTraceOperator: [],
},
id: uuidv4(),
queryType: EQueryType.QUERY_BUILDER,
},
start,
end,
});

View File

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

View File

@@ -0,0 +1,233 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { QueryFunctionContext, useQueries, UseQueryResult } from 'react-query';
import { Card, Col, Row, Skeleton, Typography } from 'antd';
import cx from 'classnames';
import Uplot from 'components/Uplot';
import { ENTITY_VERSION_V4 } from 'constants/app';
import {
getHostQueryPayload,
hostWidgetInfo,
} from 'container/LogDetailedView/InfraMetrics/constants';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useMultiIntersectionObserver } from 'hooks/useMultiIntersectionObserver';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { FeatureKeys } from '../../../constants/features';
import { useAppContext } from '../../../providers/App/App';
import './Metrics.styles.scss';
interface MetricsTabProps {
timeRange: {
startTime: number;
endTime: number;
};
isModalTimeSelection: boolean;
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
selectedInterval: Time;
hostName: string;
}
function Metrics({
selectedInterval,
hostName,
timeRange,
handleTimeChange,
isModalTimeSelection,
}: MetricsTabProps): JSX.Element {
const { featureFlags } = useAppContext();
const dotMetricsEnabled =
featureFlags?.find((flag) => flag.name === FeatureKeys.DOT_METRICS_ENABLED)
?.active || false;
const {
visibilities,
setElement,
} = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 });
const legendScrollPositionRef = useRef<{
scrollTop: number;
scrollLeft: number;
}>({
scrollTop: 0,
scrollLeft: 0,
});
const queryPayloads = useMemo(
() =>
getHostQueryPayload(
hostName,
timeRange.startTime,
timeRange.endTime,
dotMetricsEnabled,
),
[hostName, timeRange.startTime, timeRange.endTime, dotMetricsEnabled],
);
const queries = useQueries(
queryPayloads.map((payload, index) => ({
queryKey: ['host-metrics', payload, ENTITY_VERSION_V4, 'HOST'],
queryFn: ({
signal,
}: QueryFunctionContext): Promise<
SuccessResponse<MetricRangePayloadProps>
> => GetMetricQueryRange(payload, ENTITY_VERSION_V4, undefined, signal),
enabled: !!payload && visibilities[index],
keepPreviousData: true,
})),
);
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { currentQuery } = useQueryBuilder();
const chartData = useMemo(
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
[queries],
);
const [graphTimeIntervals, setGraphTimeIntervals] = useState<
{
start: number;
end: number;
}[]
>(
new Array(queries.length).fill({
start: timeRange.startTime,
end: timeRange.endTime,
}),
);
useEffect(() => {
setGraphTimeIntervals(
new Array(queries.length).fill({
start: timeRange.startTime,
end: timeRange.endTime,
}),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [timeRange]);
const onDragSelect = useCallback(
(start: number, end: number, graphIndex: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
setGraphTimeIntervals((prev) => {
const newIntervals = [...prev];
newIntervals[graphIndex] = {
start: Math.floor(startTimestamp / 1000),
end: Math.floor(endTimestamp / 1000),
};
return newIntervals;
});
},
[],
);
const options = useMemo(
() =>
queries.map(({ data }, idx) =>
getUPlotChartOptions({
apiResponse: data?.payload,
isDarkMode,
dimensions,
yAxisUnit: hostWidgetInfo[idx].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: graphTimeIntervals[idx].start,
maxTimeScale: graphTimeIntervals[idx].end,
onDragSelect: (start, end) => onDragSelect(start, end, idx),
query: currentQuery,
legendScrollPosition: legendScrollPositionRef.current,
setLegendScrollPosition: (position: {
scrollTop: number;
scrollLeft: number;
}) => {
legendScrollPositionRef.current = position;
},
}),
),
[
queries,
isDarkMode,
dimensions,
graphTimeIntervals,
onDragSelect,
currentQuery,
],
);
const renderCardContent = (
query: UseQueryResult<SuccessResponse<MetricRangePayloadProps>, unknown>,
idx: number,
): JSX.Element => {
if ((!query.data && query.isLoading) || !visibilities[idx]) {
return <Skeleton />;
}
if (query.error) {
const errorMessage =
(query.error as Error)?.message || 'Something went wrong';
return <div>{errorMessage}</div>;
}
return (
<div
className={cx('chart-container', {
'no-data-container':
!query.isLoading && !query?.data?.payload?.data?.result?.length,
})}
>
<Uplot options={options[idx]} data={chartData[idx]} />
</div>
);
};
return (
<>
<div className="metrics-header">
<div className="metrics-datetime-section">
<DateTimeSelectionV2
showAutoRefresh
showRefreshText={false}
hideShareModal
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
isModalTimeSelection={isModalTimeSelection}
modalSelectedInterval={selectedInterval}
modalInitialStartTime={timeRange.startTime * 1000}
modalInitialEndTime={timeRange.endTime * 1000}
/>
</div>
</div>
<Row gutter={24} className="host-metrics-container">
{queries.map((query, idx) => (
<Col ref={setElement(idx)} span={12} key={hostWidgetInfo[idx].title}>
<Typography.Text>{hostWidgetInfo[idx].title}</Typography.Text>
<Card bordered className="host-metrics-card" ref={graphRef}>
{renderCardContent(query, idx)}
</Card>
</Col>
))}
</Row>
</>
);
}
export default Metrics;

View File

@@ -1,15 +1,16 @@
import { useTranslation } from 'react-i18next';
import { Space, Typography } from 'antd';
import WaitlistFragment from 'components/HostMetricsDetail/WaitlistFragment/WaitlistFragment';
import broomUrl from '@/assets/Icons/broom.svg';
import infraContainersUrl from '@/assets/Icons/infraContainers.svg';
import 'components/HostMetricsDetail/Processes/Processes.styles.scss';
import WaitlistFragment from '../WaitlistFragment/WaitlistFragment';
import './Processes.styles.scss';
const { Text } = Typography;
function EntityProcesses(): JSX.Element {
function Processes(): JSX.Element {
const { t } = useTranslation(['infraMonitoring']);
return (
@@ -42,4 +43,4 @@ function EntityProcesses(): JSX.Element {
);
}
export default EntityProcesses;
export default Processes;

View File

@@ -0,0 +1,17 @@
export enum VIEWS {
METRICS = 'metrics',
LOGS = 'logs',
TRACES = 'traces',
CONTAINERS = 'containers',
PROCESSES = 'processes',
EVENTS = 'events',
}
export const VIEW_TYPES = {
METRICS: VIEWS.METRICS,
LOGS: VIEWS.LOGS,
TRACES: VIEWS.TRACES,
CONTAINERS: VIEWS.CONTAINERS,
PROCESSES: VIEWS.PROCESSES,
EVENTS: VIEWS.EVENTS,
};

View File

@@ -0,0 +1,3 @@
import HostMetricsDetails from './HostMetricsDetails';
export default HostMetricsDetails;

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

@@ -100,19 +100,16 @@ function MarkdownRenderer({
variables,
trackCopyAction,
elementDetails,
className,
}: {
markdownContent: any;
variables: any;
trackCopyAction?: boolean;
elementDetails?: Record<string, unknown>;
className?: string;
}): JSX.Element {
const interpolatedMarkdown = interpolateMarkdown(markdownContent, variables);
return (
<ReactMarkdown
className={className}
rehypePlugins={[rehypeRaw as any]}
components={{
// @ts-ignore

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

@@ -7,14 +7,6 @@
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
background: var(--l1-background);
color: var(--l1-foreground);
/* Override the background and color of the dialog content from the theme */
> div {
background: var(--l1-background);
color: var(--l1-foreground);
}
}
.cmdk-section-heading [cmdk-group-heading] {
@@ -51,22 +43,6 @@
.cmdk-item {
cursor: pointer;
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px;
&:hover {
background: var(--l1-background-hover);
}
&[data-selected='true'] {
background: var(--l3-background);
color: var(--l1-foreground);
}
}
[cmdk-item] svg {

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',
@@ -65,7 +66,6 @@ const ROUTES = {
WORKSPACE_SUSPENDED: '/workspace-suspended',
SHORTCUTS: '/settings/shortcuts',
INTEGRATIONS: '/integrations',
INTEGRATIONS_DETAIL: '/integrations/:integrationId',
MESSAGING_QUEUES_BASE: '/messaging-queues',
MESSAGING_QUEUES_KAFKA: '/messaging-queues/kafka',
MESSAGING_QUEUES_KAFKA_DETAIL: '/messaging-queues/kafka/detail',

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

@@ -0,0 +1,24 @@
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import Header from './Header/Header';
import HeroSection from './HeroSection/HeroSection';
import ServicesTabs from './ServicesSection/ServicesTabs';
function CloudIntegrationPage(): JSX.Element {
return (
<div>
<Header />
<HeroSection />
<RequestIntegrationBtn
type={IntegrationType.AWS_SERVICES}
message="Can't find the AWS service you're looking for? Request more integrations"
/>
<ServicesTabs />
</div>
);
}
export default CloudIntegrationPage;

View File

@@ -3,7 +3,7 @@
justify-content: space-between;
align-items: center;
padding: 8px 18px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--l1-border);
&__navigation {
display: flex;
@@ -18,7 +18,7 @@
}
&__breadcrumb-title {
color: var(--l1-foreground);
color: var(--l2-foreground);
font-size: 14px;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
@@ -30,8 +30,8 @@
justify-content: center;
padding: 6px;
gap: 6px;
border: 1px solid var(--border);
background: var(--card);
border: 1px solid var(--l1-border);
background: var(--l3-background);
border-radius: 2px;
font-size: 12px;
line-height: 10px;
@@ -39,11 +39,9 @@
width: 113px;
height: 32px;
cursor: pointer;
color: var(--l1-foreground);
&,
&:hover {
border-color: var(--l2-border);
color: var(--l1-foreground);
color: var(--l2-foreground);
}
}
}

View File

@@ -1,13 +1,11 @@
import { Link } from 'react-router-dom';
import { Button } from '@signozhq/button';
import Breadcrumb from 'antd/es/breadcrumb';
import { Breadcrumb } from 'antd';
import ROUTES from 'constants/routes';
import { IntegrationType } from 'container/Integrations/types';
import { Blocks, LifeBuoy } from 'lucide-react';
import './Header.styles.scss';
function Header({ title }: { title: IntegrationType }): JSX.Element {
function Header(): JSX.Element {
return (
<div className="cloud-header">
<div className="cloud-header__navigation">
@@ -18,33 +16,32 @@ function Header({ title }: { title: IntegrationType }): JSX.Element {
title: (
<Link to={ROUTES.INTEGRATIONS}>
<span className="cloud-header__breadcrumb-link">
<Blocks size={16} color="var(--l2-foreground)" />
<Blocks size={16} color="var(--bg-vanilla-400)" />
<span className="cloud-header__breadcrumb-title">Integrations</span>
</span>
</Link>
),
},
{
title: <div className="cloud-header__breadcrumb-title">{title}</div>,
title: (
<div className="cloud-header__breadcrumb-title">
Amazon Web Services
</div>
),
},
]}
/>
</div>
<div className="cloud-header__actions">
<Button
variant="solid"
size="sm"
color="secondary"
onClick={(): void => {
window.open(
'https://signoz.io/blog/native-aws-integrations-with-autodiscovery/',
'_blank',
);
}}
prefixIcon={<LifeBuoy size={12} />}
<a
href="https://signoz.io/blog/native-aws-integrations-with-autodiscovery/"
target="_blank"
rel="noopener noreferrer"
className="cloud-header__help"
>
<LifeBuoy size={12} />
Get Help
</Button>
</a>
</div>
</div>
);

View File

@@ -1,10 +1,8 @@
.hero-section {
padding: 16px;
height: 308px;
padding: 26px 16px;
display: flex;
flex-direction: column;
gap: 16px;
gap: 24px;
position: relative;
overflow: hidden;
background-position: right;
@@ -32,36 +30,7 @@
flex-direction: column;
gap: 12px;
&-header {
display: flex;
flex-direction: row;
align-items: center;
gap: 16px;
&__icon {
height: fit-content;
background-color: var(--l1-background);
padding: 12px;
border: 1px solid var(--l2-background);
border-radius: 6px;
width: 60px;
height: 60px;
}
&__title {
color: var(--l1-foreground);
font-size: 24px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.12px;
}
}
&__description {
color: var(--l2-foreground);
}
&-title {
.title {
color: var(--l1-foreground);
font-size: 24px;
font-weight: 500;
@@ -69,7 +38,7 @@
letter-spacing: -0.12px;
}
&-description {
.description {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;

View File

@@ -0,0 +1,37 @@
import { useIsDarkMode } from 'hooks/useDarkMode';
import integrationsHeroBgUrl from '@/assets/Images/integrations-hero-bg.png';
import awsDarkUrl from '@/assets/Logos/aws-dark.svg';
import AccountActions from './components/AccountActions';
import './HeroSection.style.scss';
function HeroSection(): JSX.Element {
const isDarkMode = useIsDarkMode();
return (
<div
className="hero-section"
style={
isDarkMode
? {
backgroundImage: `url('${integrationsHeroBgUrl}')`,
}
: {}
}
>
<div className="hero-section__icon">
<img src={awsDarkUrl} alt="aws-logo" />
</div>
<div className="hero-section__details">
<div className="title">Amazon Web Services</div>
<div className="description">
One-click setup for AWS monitoring with SigNoz
</div>
<AccountActions />
</div>
</div>
);
}
export default HeroSection;

View File

@@ -4,57 +4,14 @@
&-with-account {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
flex-wrap: wrap;
gap: 16px;
border-radius: 4px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
.selected-cloud-integration-account-status {
display: flex;
align-items: center;
justify-content: center;
border-right: 1px solid var(--l3-background);
border-radius: none;
width: 32px;
}
&-selector-container {
display: flex;
flex-direction: row;
align-items: center;
.account-selector-label {
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 16px;
padding: 8px 16px;
}
.account-selector {
.ant-select {
background-color: transparent !important;
border: none !important;
box-shadow: none !important;
.ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
}
}
}
flex-direction: column;
gap: 10px;
}
}
&__input-skeleton {
width: 300px;
margin-bottom: 16px;
}
&__new-account-button-skeleton {
@@ -65,13 +22,11 @@
&__account-settings-button-skeleton {
width: 140px;
}
&__action-buttons {
display: flex;
align-items: center;
gap: 8px;
}
&__action-button {
font-family: 'Inter';
border-radius: 2px;
@@ -90,16 +45,11 @@
&.secondary {
display: flex;
align-items: center;
border: 1px solid var(--l1-border);
border-radius: 2px;
background: var(--l1-background);
box-shadow: none;
border: 1px solid var(--l3-background);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
border-radius: 2px;
background: var(--l1-border);
box-shadow: none;
}
}
}
@@ -107,30 +57,25 @@
.cloud-account-selector {
border-radius: 2px;
border: 1px solid var(--l3-background);
background: var(--l1-background);
background: linear-gradient(
139deg,
color-mix(in srgb, var(--card) 80%, transparent) 0%,
color-mix(in srgb, var(--card) 90%, transparent) 98.68%
);
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
-webkit-backdrop-filter: blur(20px);
backdrop-filter: blur(20px);
.ant-select-selector {
border-color: var(--l1-border) !important;
background: var(--l1-background) !important;
padding: 6px 8px !important;
min-width: 140px !important;
}
.ant-select-item-option-active {
background: var(--l3-background) !important;
padding: 6px 8px !important;
}
.ant-select-selection-item {
color: var(--l1-foreground);
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 16px;
}
&:hover {
.ant-select-selector {
border-color: var(--l1-border) !important;
}
}
.account-option-item {
display: flex;
@@ -142,8 +87,60 @@
justify-content: center;
height: 14px;
width: 14px;
background-color: color-mix(in srgb, var(--border) 20%, transparent);
background-color: color-mix(
in srgb,
var(--border) 20%,
transparent
); /* #C0C1C3 with 0.2 opacity */
border-radius: 2px;
}
}
}
.lightMode {
.hero-section__action-button {
&.primary {
background: var(--primary-background);
color: var(--primary-foreground);
}
&.secondary {
border-color: var(--l1-border);
color: var(--l1-foreground);
background: var(--l1-background);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-selector {
background: var(--l1-background);
.ant-select-selector {
background: var(--l1-background) !important;
border-color: var(--l1-border) !important;
}
.ant-select-item-option-active {
background: var(--l3-background) !important;
}
.ant-select-selection-item {
color: var(--l1-foreground);
}
&:hover {
.ant-select-selector {
border-color: var(--l1-border) !important;
}
}
}
.account-option-item {
color: var(--l1-foreground);
&__selected {
background: var(--primary-background);
}
}
}

View File

@@ -1,23 +1,58 @@
import { useEffect, useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import { Button } from '@signozhq/button';
import { Color } from '@signozhq/design-tokens';
import { Select, Skeleton } from 'antd';
import { SelectProps } from 'antd/lib';
import { Button, Select, Skeleton } from 'antd';
import type { SelectProps } from 'antd/lib';
import logEvent from 'api/common/logEvent';
import { useListAccounts } from 'api/generated/services/cloudintegration';
import { getAccountById } from 'container/Integrations/CloudIntegration/utils';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown, Dot, PencilLine, Plug, Plus } from 'lucide-react';
import { Check, ChevronDown } from 'lucide-react';
import { mapAccountDtoToAwsCloudAccount } from '../../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../../types';
import { CloudAccount } from '../../ServicesSection/types';
import AccountSettingsModal from './AccountSettingsModal';
import CloudAccountSetupModal from './CloudAccountSetupModal';
import './AccountActions.style.scss';
interface AccountOptionItemProps {
label: React.ReactNode;
isSelected: boolean;
}
function AccountOptionItem({
label,
isSelected,
}: AccountOptionItemProps): JSX.Element {
return (
<div className="account-option-item">
{label}
{isSelected && (
<div className="account-option-item__selected">
<Check size={12} color={Color.BG_VANILLA_100} />
</div>
)}
</div>
);
}
function renderOption(
option: any,
activeAccountId: string | undefined,
): JSX.Element {
return (
<AccountOptionItem
label={option.label}
isSelected={option.value === activeAccountId}
/>
);
}
const getAccountById = (
accounts: CloudAccount[],
accountId: string,
): CloudAccount | null =>
accounts.find((account) => account.cloud_account_id === accountId) || null;
function AccountActionsRenderer({
accounts,
isLoading,
@@ -38,52 +73,55 @@ function AccountActionsRenderer({
if (isLoading) {
return (
<div className="hero-section__actions-with-account">
<Skeleton.Input active block className="hero-section__input-skeleton" />
<Skeleton.Input
active
size="large"
block
className="hero-section__input-skeleton"
/>
<div className="hero-section__action-buttons">
<Skeleton.Button
active
size="large"
className="hero-section__new-account-button-skeleton"
/>
<Skeleton.Button
active
size="large"
className="hero-section__account-settings-button-skeleton"
/>
</div>
</div>
);
}
if (accounts?.length) {
return (
<div className="hero-section__actions-with-account">
<div className="hero-section__actions-with-account-selector-container">
<div className="selected-cloud-integration-account-status">
<Dot size={24} color={Color.BG_FOREST_500} />
</div>
<div className="account-selector-label">Account:</div>
<span className="account-selector">
<Select
value={activeAccount?.providerAccountId}
options={selectOptions}
rootClassName="cloud-account-selector"
popupMatchSelectWidth={false}
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onAccountChange}
/>
</span>
</div>
<Select
value={`Account: ${activeAccount?.cloud_account_id}`}
options={selectOptions}
rootClassName="cloud-account-selector"
placeholder="Select AWS Account"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
optionRender={(option): JSX.Element =>
renderOption(option, activeAccount?.cloud_account_id)
}
onChange={onAccountChange}
/>
<div className="hero-section__action-buttons">
<Button
variant="link"
size="sm"
color="secondary"
prefixIcon={<PencilLine size={14} />}
type="primary"
className="hero-section__action-button primary"
onClick={onIntegrationModalOpen}
>
Add New AWS Account
</Button>
<Button
type="default"
className="hero-section__action-button secondary"
onClick={onAccountSettingsModalOpen}
>
Edit Account
</Button>
<Button
variant="link"
size="sm"
color="secondary"
onClick={onIntegrationModalOpen}
prefixIcon={<Plus size={14} />}
>
Add New Account
Account Settings
</Button>
</div>
</div>
@@ -91,11 +129,8 @@ function AccountActionsRenderer({
}
return (
<Button
variant="solid"
color="primary"
prefixIcon={<Plug size={14} />}
className="hero-section__action-button primary"
onClick={onIntegrationModalOpen}
size="sm"
>
Integrate Now
</Button>
@@ -105,18 +140,7 @@ function AccountActionsRenderer({
function AccountActions(): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: listAccountsResponse, isLoading } = useListAccounts({
cloudProvider: INTEGRATION_TYPES.AWS,
});
const accounts = useMemo((): CloudAccount[] | undefined => {
const raw = listAccountsResponse?.data?.accounts;
if (!raw) {
return undefined;
}
return raw
.map(mapAccountDtoToAwsCloudAccount)
.filter((account): account is CloudAccount => account !== null);
}, [listAccountsResponse]);
const { data: accounts, isLoading } = useAwsAccounts();
const initialAccount = useMemo(
() =>
@@ -138,13 +162,7 @@ function AccountActions(): JSX.Element {
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.set('cloudAccountId', initialAccount.cloud_account_id);
navigate({ search: latestUrlQuery.toString() });
return;
}
setActiveAccount(null);
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.delete('cloudAccountId');
navigate({ search: latestUrlQuery.toString() });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initialAccount]);
@@ -180,7 +198,7 @@ function AccountActions(): JSX.Element {
accounts?.length
? accounts.map((account) => ({
value: account.cloud_account_id,
label: account.providerAccountId,
label: account.cloud_account_id,
}))
: [],
[accounts],
@@ -210,10 +228,10 @@ function AccountActions(): JSX.Element {
/>
)}
{isAccountSettingsModalOpen && activeAccount && (
{isAccountSettingsModalOpen && (
<AccountSettingsModal
onClose={(): void => setIsAccountSettingsModalOpen(false)}
account={activeAccount}
account={activeAccount as CloudAccount}
setActiveAccount={setActiveAccount}
/>
)}

View File

@@ -14,13 +14,8 @@
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 14px;
&-account-info {
&-connected-account-details {
display: flex;
flex-direction: column;
gap: 8px;
&-title {
color: var(--l1-foreground);
font-size: 14px;
@@ -43,12 +38,10 @@
}
}
}
&-region-selector {
&-regions-switch {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
&-title {
color: var(--l1-foreground);
font-size: 14px;
@@ -56,14 +49,6 @@
line-height: 20px;
letter-spacing: -0.07px;
}
&-description {
color: var(--l2-foreground);
font-size: 12px;
line-height: 18px;
letter-spacing: -0.06px;
}
&-switch {
display: flex;
align-items: center;
@@ -81,17 +66,15 @@
}
}
}
&-regions-select {
margin-top: 8px;
}
}
&__footer {
padding: 16px;
display: flex;
flex-direction: row;
gap: 10px;
justify-content: space-between;
&-close-button,
&-save-button {
color: var(--l1-foreground);
font-family: Inter;
font-size: 12px;
font-weight: 500;
@@ -100,31 +83,18 @@
}
&-close-button {
border-radius: 2px;
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
background: var(--l1-border);
border: none;
}
&-save-button {
background: var(--primary-background);
color: var(--primary-foreground);
border: none;
border-radius: 2px;
margin: 0 !important;
&:disabled {
background: var(--primary-background);
color: var(--primary-foreground);
opacity: 0.6;
border: none;
}
&:not(:disabled):hover {
background: var(--primary-background-hover);
}
border-radius: 2px;
margin: 0 !important;
}
}
.ant-modal-body {
@@ -139,3 +109,81 @@
margin: 0;
}
}
.lightMode {
.account-settings-modal {
&__title-account-id {
color: var(--l1-foreground);
}
&__body {
border-color: var(--l1-border);
&-account-info {
&-connected-account-details {
&-title {
color: var(--l1-foreground);
}
&-account-id {
color: var(--l1-foreground);
&-account-id {
color: var(--l1-foreground);
}
}
}
}
&-regions-switch {
&-title {
color: var(--l1-foreground);
}
&-switch {
&-label {
color: var(--l1-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
}
}
&__footer {
&-close-button,
&-save-button {
color: var(--l1-background);
}
&-close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
&-save-button {
// Keep primary button same as dark mode
background: var(--primary-background);
color: var(--primary-foreground);
&:disabled {
background: var(--primary-background);
color: var(--primary-foreground);
opacity: 0.6;
}
&:not(:disabled):hover {
background: var(--bg-robin-400);
}
}
}
}
}

View File

@@ -0,0 +1,213 @@
import { Dispatch, SetStateAction, useCallback } from 'react';
import { useQueryClient } from 'react-query';
import { Form, Select, Switch } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
getRegionPreviewText,
useAccountSettingsModal,
} from 'hooks/integration/aws/useAccountSettingsModal';
import useUrlQuery from 'hooks/useUrlQuery';
import history from 'lib/history';
import logEvent from '../../../../api/common/logEvent';
import { CloudAccount } from '../../ServicesSection/types';
import { RegionSelector } from './RegionSelector';
import RemoveIntegrationAccount from './RemoveIntegrationAccount';
import './AccountSettingsModal.style.scss';
interface AccountSettingsModalProps {
onClose: () => void;
account: CloudAccount;
setActiveAccount: Dispatch<SetStateAction<CloudAccount | null>>;
}
function AccountSettingsModal({
onClose,
account,
setActiveAccount,
}: AccountSettingsModalProps): JSX.Element {
const {
form,
isLoading,
selectedRegions,
includeAllRegions,
isRegionSelectOpen,
isSaveDisabled,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
handleIncludeAllRegionsChange,
handleSubmit,
handleClose,
} = useAccountSettingsModal({ onClose, account, setActiveAccount });
const queryClient = useQueryClient();
const urlQuery = useUrlQuery();
const handleRemoveIntegrationAccountSuccess = (): void => {
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
urlQuery.delete('cloudAccountId');
handleClose();
history.replace({ search: urlQuery.toString() });
logEvent('AWS Integration: Account removed', {
id: account?.id,
cloudAccountId: account?.cloud_account_id,
});
};
const handleRegionDeselect = useCallback(
(item: string): void => {
if (selectedRegions.includes(item)) {
setSelectedRegions(selectedRegions.filter((region) => region !== item));
if (includeAllRegions) {
setIncludeAllRegions(false);
}
}
},
[
selectedRegions,
includeAllRegions,
setSelectedRegions,
setIncludeAllRegions,
],
);
const renderRegionSelector = useCallback(() => {
if (isRegionSelectOpen) {
return (
<RegionSelector
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
);
}
return (
<>
<div className="account-settings-modal__body-regions-switch-switch ">
<Switch
checked={includeAllRegions}
onChange={handleIncludeAllRegionsChange}
/>
<button
className="account-settings-modal__body-regions-switch-switch-label"
type="button"
onClick={(): void => handleIncludeAllRegionsChange(!includeAllRegions)}
>
Include all regions
</button>
</div>
<Select
suffixIcon={null}
placeholder="Select Region(s)"
className="cloud-account-setup-form__select account-settings-modal__body-regions-select integrations-select"
onClick={(): void => setIsRegionSelectOpen(true)}
mode="multiple"
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
onDeselect={handleRegionDeselect}
/>
</>
);
}, [
isRegionSelectOpen,
includeAllRegions,
handleIncludeAllRegionsChange,
selectedRegions,
handleRegionDeselect,
setSelectedRegions,
setIncludeAllRegions,
setIsRegionSelectOpen,
]);
const renderAccountDetails = useCallback(
() => (
<div className="account-settings-modal__body-account-info">
<div className="account-settings-modal__body-account-info-connected-account-details">
<div className="account-settings-modal__body-account-info-connected-account-details-title">
Connected Account details
</div>
<div className="account-settings-modal__body-account-info-connected-account-details-account-id">
AWS Account:{' '}
<span className="account-settings-modal__body-account-info-connected-account-details-account-id-account-id">
{account?.id}
</span>
</div>
</div>
</div>
),
[account?.id],
);
const modalTitle = (
<div className="account-settings-modal__title">
Account settings for{' '}
<span className="account-settings-modal__title-account-id">
{account?.id}
</span>
</div>
);
return (
<SignozModal
open
title={modalTitle}
onCancel={handleClose}
onOk={handleSubmit}
okText="Save"
okButtonProps={{
disabled: isSaveDisabled,
className: 'account-settings-modal__footer-save-button',
loading: isLoading,
}}
cancelButtonProps={{
className: 'account-settings-modal__footer-close-button',
}}
width={672}
rootClassName="account-settings-modal"
>
<Form
form={form}
layout="vertical"
initialValues={{
selectedRegions,
includeAllRegions,
}}
>
<div className="account-settings-modal__body">
{renderAccountDetails()}
<Form.Item
name="selectedRegions"
rules={[
{
validator: async (): Promise<void> => {
if (selectedRegions.length === 0) {
throw new Error('Please select at least one region to monitor');
}
},
message: 'Please select at least one region to monitor',
},
]}
>
{renderRegionSelector()}
</Form.Item>
<div className="integration-detail-content">
<RemoveIntegrationAccount
accountId={account?.id}
onRemoveIntegrationAccountSuccess={handleRemoveIntegrationAccountSuccess}
/>
</div>
</div>
</Form>
</SignozModal>
);
}
export default AccountSettingsModal;

View File

@@ -1,33 +1,4 @@
.cloud-account-setup-modal {
background: var(--l1-background);
color: var(--l1-foreground);
[data-slot='drawer-title'] {
color: var(--l1-foreground);
}
> div {
display: flex;
flex-direction: column;
overflow: hidden;
}
&__content {
flex: 1;
overflow-y: auto;
min-height: 0;
scrollbar-width: thin;
&::-webkit-scrollbar {
width: 2px;
}
}
&__footer {
padding: 16px;
margin-bottom: 16px;
}
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
@@ -39,24 +10,16 @@
font-family: 'Geist Mono';
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
background: var(--l1-border);
border-radius: 2px;
color: var(--l1-foreground);
font-family: 'Inter';
font-size: 12px;
font-weight: 500;
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
padding: 16px;
.disabled {
opacity: 0.4;
}
@@ -93,8 +56,6 @@
display: flex;
align-items: center;
gap: 8px;
color: var(--l1-foreground);
.retry-time {
font-family: 'Geist Mono';
font-size: 14px;
@@ -155,7 +116,7 @@
}
&__note {
padding: 12px;
color: var(--callout-primary-description);
color: var(--bg-robin-400);
font-size: 12px;
line-height: 22px;
letter-spacing: -0.06px;
@@ -183,3 +144,87 @@
}
}
}
.lightMode {
.cloud-account-setup-modal {
.account-setup-modal-footer {
&__confirm-button {
background: var(--primary-background);
color: var(--primary-foreground);
}
&__close-button {
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
}
.cloud-account-setup-form {
&__title {
color: var(--l1-foreground);
}
&__description {
color: var(--l1-foreground);
}
&__select {
.ant-select-selection-item {
color: var(--l1-foreground);
}
}
&__include-all-regions-switch {
color: var(--l1-foreground);
&-label {
color: var(--l1-foreground);
&:hover {
color: var(--l1-foreground);
}
}
}
&__note {
color: var(--primary-foreground);
border: 1px solid
color-mix(in srgb, var(--primary-background) 20%, transparent);
background: color-mix(in srgb, var(--primary-background) 10%, transparent);
}
&__submit-button {
background: var(--primary-background);
color: var(--primary-foreground);
}
&__alert {
&.ant-alert-error {
color: var(--danger-foreground);
border: 1px solid
color-mix(in srgb, var(--danger-background) 20%, transparent);
background: color-mix(in srgb, var(--danger-background) 10%, transparent);
}
&.ant-alert-warning {
color: var(--warning-foreground);
border: 1px solid
color-mix(in srgb, var(--warning-background) 20%, transparent);
background: color-mix(in srgb, var(--warning-background) 10%, transparent);
}
&-message {
.retry-time {
color: var(--l1-foreground);
}
}
}
}
}
}

View File

@@ -1,7 +1,8 @@
import { useCallback } from 'react';
import { Button } from '@signozhq/button';
import { useQueryClient } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import { DrawerWrapper } from '@signozhq/drawer';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useIntegrationModal } from 'hooks/integration/aws/useIntegrationModal';
import { SquareArrowOutUpRight } from 'lucide-react';
@@ -11,15 +12,19 @@ import {
ModalStateEnum,
} from '../types';
import { RegionForm } from './RegionForm';
import { RegionSelector } from './RegionSelector';
import { SuccessView } from './SuccessView';
import './CloudAccountSetupModal.style.scss';
function CloudAccountSetupModal({
onClose,
}: IntegrationModalProps): JSX.Element {
const queryClient = useQueryClient();
const {
form,
modalState,
setModalState,
isLoading,
activeView,
selectedRegions,
@@ -27,86 +32,97 @@ function CloudAccountSetupModal({
isGeneratingUrl,
setSelectedRegions,
setIncludeAllRegions,
handleIncludeAllRegionsChange,
handleRegionSelect,
handleSubmit,
handleClose,
setActiveView,
allRegions,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
} = useIntegrationModal({ onClose });
const renderContent = useCallback(() => {
return (
<div className="cloud-account-setup-modal__content">
<RegionForm
form={form}
modalState={modalState}
if (modalState === ModalStateEnum.SUCCESS) {
return <SuccessView />;
}
if (activeView === ActiveViewEnum.SELECT_REGIONS) {
return (
<RegionSelector
selectedRegions={selectedRegions}
includeAllRegions={includeAllRegions}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
accountId={accountId}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
onConnectionSuccess={handleConnectionSuccess}
onConnectionTimeout={handleConnectionTimeout}
onConnectionError={handleConnectionError}
/>
);
}
<div className="cloud-account-setup-modal__footer">
<Button
variant="solid"
color="primary"
prefixIcon={
<SquareArrowOutUpRight size={17} color={Color.BG_VANILLA_100} />
}
onClick={handleSubmit}
disabled={
selectedRegions.length === 0 ||
isLoading ||
isGeneratingUrl ||
modalState === ModalStateEnum.WAITING
}
>
Launch Cloud Formation Template
</Button>
</div>
</div>
return (
<RegionForm
form={form}
modalState={modalState}
setModalState={setModalState}
selectedRegions={selectedRegions}
includeAllRegions={includeAllRegions}
onIncludeAllRegionsChange={handleIncludeAllRegionsChange}
onRegionSelect={handleRegionSelect}
onSubmit={handleSubmit}
accountId={accountId}
selectedDeploymentRegion={selectedDeploymentRegion}
handleRegionChange={handleRegionChange}
connectionParams={connectionParams}
isConnectionParamsLoading={isConnectionParamsLoading}
/>
);
}, [
modalState,
activeView,
form,
setModalState,
selectedRegions,
includeAllRegions,
handleIncludeAllRegionsChange,
handleRegionSelect,
handleSubmit,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
setSelectedRegions,
setIncludeAllRegions,
isLoading,
isGeneratingUrl,
handleConnectionSuccess,
handleConnectionTimeout,
handleConnectionError,
]);
const getSelectedRegionsCount = useCallback(
(): number => selectedRegions.length,
[selectedRegions],
(): number =>
selectedRegions.includes('all') ? allRegions.length : selectedRegions.length,
[selectedRegions, allRegions],
);
const getModalConfig = useCallback(() => {
// Handle success state first
if (modalState === ModalStateEnum.SUCCESS) {
return {
title: 'AWS Integration',
okText: (
<div className="cloud-account-setup-success-view__footer-button">
Continue
</div>
),
block: true,
onOk: (): void => {
queryClient.invalidateQueries([REACT_QUERY_KEY.AWS_ACCOUNTS]);
handleClose();
},
cancelButtonProps: { style: { display: 'none' } },
disabled: false,
};
}
// Handle other views
const viewConfigs = {
[ActiveViewEnum.FORM]: {
title: 'Add AWS Account',
@@ -139,30 +155,35 @@ function CloudAccountSetupModal({
isLoading,
isGeneratingUrl,
activeView,
handleClose,
setActiveView,
queryClient,
]);
const modalConfig = getModalConfig();
const handleDrawerOpenChange = (open: boolean): void => {
if (!open) {
handleClose();
}
};
return (
<DrawerWrapper
open={true}
type="panel"
<SignozModal
open
className="cloud-account-setup-modal"
content={renderContent()}
onOpenChange={handleDrawerOpenChange}
direction="right"
showCloseButton
header={{
title: modalConfig.title,
title={modalConfig.title}
onCancel={handleClose}
onOk={modalConfig.onOk}
okText={modalConfig.okText}
okButtonProps={{
loading: isLoading,
disabled: selectedRegions.length === 0 || modalConfig.disabled,
className:
activeView === ActiveViewEnum.FORM
? 'cloud-account-setup-form__submit-button'
: 'account-setup-modal-footer__confirm-button',
block: activeView === ActiveViewEnum.FORM,
}}
/>
cancelButtonProps={modalConfig.cancelButtonProps}
width={672}
>
{renderContent()}
</SignozModal>
);
}

View File

@@ -1,19 +1,17 @@
import { Dispatch, SetStateAction } from 'react';
import { Color } from '@signozhq/design-tokens';
import { Form, Select } from 'antd';
import { Form, Select, Switch } from 'antd';
import { ChevronDown } from 'lucide-react';
import { Region } from 'utils/regions';
import { popupContainer } from 'utils/selectPopupContainer';
import { RegionSelector } from './RegionSelector';
// Form section components
function RegionDeploymentSection({
regions,
selectedDeploymentRegion,
handleRegionChange,
isFormDisabled,
}: {
regions: Region[];
selectedDeploymentRegion: string | undefined;
handleRegionChange: (value: string) => void;
isFormDisabled: boolean;
}): JSX.Element {
@@ -35,8 +33,8 @@ function RegionDeploymentSection({
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
className="cloud-account-setup-form__select integrations-select"
onChange={handleRegionChange}
value={selectedDeploymentRegion}
disabled={isFormDisabled}
getPopupContainer={popupContainer}
>
{regions.flatMap((region) =>
region.subRegions.map((subRegion) => (
@@ -52,13 +50,19 @@ function RegionDeploymentSection({
}
function MonitoringRegionsSection({
includeAllRegions,
selectedRegions,
setSelectedRegions,
setIncludeAllRegions,
onIncludeAllRegionsChange,
getRegionPreviewText,
onRegionSelect,
isFormDisabled,
}: {
includeAllRegions: boolean;
selectedRegions: string[];
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
onIncludeAllRegionsChange: (checked: boolean) => void;
getRegionPreviewText: (regions: string[]) => string[];
onRegionSelect: () => void;
isFormDisabled: boolean;
}): JSX.Element {
return (
<div className="cloud-account-setup-form__form-group">
@@ -69,12 +73,51 @@ function MonitoringRegionsSection({
Choose only the regions you want SigNoz to monitor. You can enable all at
once, or pick specific ones:
</div>
<RegionSelector
selectedRegions={selectedRegions}
setSelectedRegions={setSelectedRegions}
setIncludeAllRegions={setIncludeAllRegions}
/>
<Form.Item
name="monitorRegions"
rules={[
{
validator: async (): Promise<void> => {
if (selectedRegions.length === 0) {
return Promise.reject();
}
return Promise.resolve();
},
message: 'Please select at least one region to monitor',
},
]}
className="cloud-account-setup-form__form-item"
>
<div className="cloud-account-setup-form__include-all-regions-switch">
<Switch
size="small"
checked={includeAllRegions}
onChange={onIncludeAllRegionsChange}
disabled={isFormDisabled}
/>
<button
className="cloud-account-setup-form__include-all-regions-switch-label"
type="button"
onClick={(): void =>
!isFormDisabled
? onIncludeAllRegionsChange(!includeAllRegions)
: undefined
}
>
Include all regions
</button>
</div>
<Select
suffixIcon={null}
placeholder="Select Region(s)"
className="cloud-account-setup-form__select integrations-select"
onClick={!isFormDisabled ? onRegionSelect : undefined}
mode="multiple"
maxTagCount={3}
value={getRegionPreviewText(selectedRegions)}
open={false}
/>
</Form.Item>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useRef } from 'react';
import { Form } from 'antd';
import cx from 'classnames';
import { useAccountStatus } from 'hooks/integration/aws/useAccountStatus';
import { AccountStatusResponse } from 'types/api/integrations/aws';
import { regions } from 'utils/regions';
import logEvent from '../../../../api/common/logEvent';
import { ModalStateEnum, RegionFormProps } from '../types';
import AlertMessage from './AlertMessage';
import {
ComplianceNote,
MonitoringRegionsSection,
RegionDeploymentSection,
} from './IntegrateNowFormSections';
import RenderConnectionFields from './RenderConnectionParams';
const allRegions = (): string[] =>
regions.flatMap((r) => r.subRegions.map((sr) => sr.name));
const getRegionPreviewText = (regions: string[]): string[] => {
if (regions.includes('all')) {
return allRegions();
}
return regions;
};
export function RegionForm({
form,
modalState,
setModalState,
selectedRegions,
includeAllRegions,
onIncludeAllRegionsChange,
onRegionSelect,
onSubmit,
accountId,
selectedDeploymentRegion,
handleRegionChange,
connectionParams,
isConnectionParamsLoading,
}: RegionFormProps): JSX.Element {
const startTimeRef = useRef(Date.now());
const refetchInterval = 10 * 1000;
const errorTimeout = 10 * 60 * 1000;
const { isLoading: isAccountStatusLoading } = useAccountStatus(accountId, {
refetchInterval,
enabled: !!accountId && modalState === ModalStateEnum.WAITING,
onSuccess: (data: AccountStatusResponse) => {
if (data.data.status.integration.last_heartbeat_ts_ms !== null) {
setModalState(ModalStateEnum.SUCCESS);
logEvent('AWS Integration: Account connected', {
cloudAccountId: data?.data?.cloud_account_id,
status: data?.data?.status,
});
} else if (Date.now() - startTimeRef.current >= errorTimeout) {
setModalState(ModalStateEnum.ERROR);
logEvent('AWS Integration: Account connection attempt timed out', {
id: accountId,
});
}
},
onError: () => {
setModalState(ModalStateEnum.ERROR);
},
});
const isFormDisabled =
modalState === ModalStateEnum.WAITING || isAccountStatusLoading;
return (
<Form
form={form}
className="cloud-account-setup-form"
layout="vertical"
onFinish={onSubmit}
>
<AlertMessage modalState={modalState} />
<div
className={cx(`cloud-account-setup-form__content`, {
disabled: isFormDisabled,
})}
>
<RegionDeploymentSection
regions={regions}
handleRegionChange={handleRegionChange}
isFormDisabled={isFormDisabled}
selectedDeploymentRegion={selectedDeploymentRegion}
/>
<MonitoringRegionsSection
includeAllRegions={includeAllRegions}
selectedRegions={selectedRegions}
onIncludeAllRegionsChange={onIncludeAllRegionsChange}
getRegionPreviewText={getRegionPreviewText}
onRegionSelect={onRegionSelect}
isFormDisabled={isFormDisabled}
/>
<ComplianceNote />
<RenderConnectionFields
isConnectionParamsLoading={isConnectionParamsLoading}
connectionParams={connectionParams}
isFormDisabled={isFormDisabled}
/>
</div>
</Form>
);
}

View File

@@ -1,6 +1,5 @@
.select-all {
margin-top: 16px;
margin-bottom: 16px;
margin-bottom: 20px;
}
.regions-grid {
@@ -20,11 +19,3 @@
gap: 10px;
align-items: center;
}
.region-selector-footer {
margin-top: 36px;
display: flex;
justify-content: flex-end;
gap: 8px;
}

View File

@@ -28,12 +28,10 @@ export function RegionSelector({
<div className="region-selector">
<div className="select-all">
<Checkbox
checked={
allRegionIds.length > 0 &&
allRegionIds.every((regionId) => selectedRegions.includes(regionId))
}
checked={selectedRegions.includes('all')}
indeterminate={
selectedRegions.length > 0 && selectedRegions.length < allRegionIds.length
selectedRegions.length > 20 &&
selectedRegions.length < allRegionIds.length
}
onChange={(e): void => handleSelectAll(e.target.checked)}
>
@@ -48,7 +46,10 @@ export function RegionSelector({
{region.subRegions.map((subRegion) => (
<Checkbox
key={subRegion.id}
checked={selectedRegions.includes(subRegion.id)}
checked={
selectedRegions.includes('all') ||
selectedRegions.includes(subRegion.id)
}
onChange={(): void => handleRegionSelect(subRegion.id)}
>
{subRegion.name}

View File

@@ -0,0 +1,47 @@
.remove-integration-account {
display: flex;
justify-content: space-between;
padding: 16px;
border-radius: 4px;
border: 1px solid color-mix(in srgb, var(--bg-sakura-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-sakura-500) 6%, transparent);
&__header {
display: flex;
flex-direction: column;
gap: 6px;
}
&__title {
color: var(--bg-cherry-500);
font-size: 14px;
letter-spacing: -0.07px;
}
&__subtitle {
color: var(--bg-cherry-300);
font-size: 14px;
line-height: 22px;
letter-spacing: -0.07px;
}
&__button {
display: flex;
align-items: center;
background: var(--bg-cherry-500);
border: none;
color: var(--l1-foreground);
font-size: 12px;
font-weight: 500;
line-height: 13.3px; /* 110.833% */
padding: 9px 13px;
.ant-btn-icon {
margin-inline-end: 4px !important;
}
&:hover {
&.ant-btn-default {
color: var(--l2-foreground) !important;
}
}
}
}

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { useMutation } from 'react-query';
import { Button, Modal } from 'antd';
import logEvent from 'api/common/logEvent';
import removeAwsIntegrationAccount from 'api/Integrations/removeAwsIntegrationAccount';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { useNotifications } from 'hooks/useNotifications';
import { X } from 'lucide-react';
import { INTEGRATION_TELEMETRY_EVENTS } from 'pages/Integrations/utils';
import './RemoveIntegrationAccount.scss';
function RemoveIntegrationAccount({
accountId,
onRemoveIntegrationAccountSuccess,
}: {
accountId: string;
onRemoveIntegrationAccountSuccess: () => void;
}): JSX.Element {
const { notifications } = useNotifications();
const [isModalOpen, setIsModalOpen] = useState(false);
const showModal = (): void => {
setIsModalOpen(true);
};
const {
mutate: removeIntegration,
isLoading: isRemoveIntegrationLoading,
} = useMutation(removeAwsIntegrationAccount, {
onSuccess: () => {
onRemoveIntegrationAccountSuccess?.();
setIsModalOpen(false);
},
onError: () => {
notifications.error({
message: SOMETHING_WENT_WRONG,
});
},
});
const handleOk = (): void => {
logEvent(INTEGRATION_TELEMETRY_EVENTS.AWS_INTEGRATION_ACCOUNT_REMOVED, {
accountId,
});
removeIntegration(accountId);
};
const handleCancel = (): void => {
setIsModalOpen(false);
};
return (
<div className="remove-integration-account">
<div className="remove-integration-account__header">
<div className="remove-integration-account__title">Remove Integration</div>
<div className="remove-integration-account__subtitle">
Removing this integration won&apos;t delete any existing data but will stop
collecting new data from AWS.
</div>
</div>
<Button
className="remove-integration-account__button"
icon={<X size={14} />}
onClick={(): void => showModal()}
>
Remove
</Button>
<Modal
className="remove-integration-modal"
open={isModalOpen}
title="Remove integration"
onOk={handleOk}
onCancel={handleCancel}
okText="Remove Integration"
okButtonProps={{
danger: true,
disabled: isRemoveIntegrationLoading,
}}
>
<div className="remove-integration-modal__text">
Removing this account will remove all components created for sending
telemetry to SigNoz in your AWS account within the next ~15 minutes
(cloudformation stacks named signoz-integration-telemetry-collection in
enabled regions). <br />
<br />
After that, you can delete the cloudformation stack that was created
manually when connecting this account.
</div>
</Modal>
</div>
);
}
export default RemoveIntegrationAccount;

View File

@@ -1,5 +1,5 @@
import { Form, Input } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import { ConnectionParams } from 'types/api/integrations/aws';
function RenderConnectionFields({
isConnectionParamsLoading,
@@ -7,51 +7,51 @@ function RenderConnectionFields({
isFormDisabled,
}: {
isConnectionParamsLoading?: boolean;
connectionParams?: CloudintegrationtypesCredentialsDTO | null;
connectionParams?: ConnectionParams | null;
isFormDisabled?: boolean;
}): JSX.Element | null {
if (
isConnectionParamsLoading ||
(!!connectionParams?.ingestionUrl &&
!!connectionParams?.ingestionKey &&
!!connectionParams?.sigNozApiUrl &&
!!connectionParams?.sigNozApiKey)
(!!connectionParams?.ingestion_url &&
!!connectionParams?.ingestion_key &&
!!connectionParams?.signoz_api_url &&
!!connectionParams?.signoz_api_key)
) {
return null;
}
return (
<Form.Item name="connectionParams">
{!connectionParams?.ingestionUrl && (
<Form.Item name="connection_params">
{!connectionParams?.ingestion_url && (
<Form.Item
name="ingestionUrl"
name="ingestion_url"
label="Ingestion URL"
rules={[{ required: true, message: 'Please enter ingestion URL' }]}
>
<Input placeholder="Enter ingestion URL" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.ingestionKey && (
{!connectionParams?.ingestion_key && (
<Form.Item
name="ingestionKey"
name="ingestion_key"
label="Ingestion Key"
rules={[{ required: true, message: 'Please enter ingestion key' }]}
>
<Input placeholder="Enter ingestion key" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.sigNozApiUrl && (
{!connectionParams?.signoz_api_url && (
<Form.Item
name="sigNozApiUrl"
name="signoz_api_url"
label="SigNoz API URL"
rules={[{ required: true, message: 'Please enter SigNoz API URL' }]}
>
<Input placeholder="Enter SigNoz API URL" disabled={isFormDisabled} />
</Form.Item>
)}
{!connectionParams?.sigNozApiKey && (
{!connectionParams?.signoz_api_key && (
<Form.Item
name="sigNozApiKey"
name="signoz_api_key"
label="SigNoz API KEY"
rules={[{ required: true, message: 'Please enter SigNoz API Key' }]}
>

View File

@@ -0,0 +1,162 @@
.cloud-account-setup-success-view {
display: flex;
flex-direction: column;
gap: 40px;
text-align: center;
padding-top: 34px;
p,
h3,
h4 {
margin: 0;
}
&__content {
display: flex;
flex-direction: column;
gap: 14px;
.cloud-account-setup-success-view {
&__title {
h3 {
color: var(--l1-foreground);
font-size: 20px;
font-weight: 500;
line-height: 32px;
}
}
&__description {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
}
}
}
&__what-next {
display: flex;
flex-direction: column;
gap: 18px;
text-align: left;
&-title {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
line-height: 18px;
letter-spacing: 0.88px;
text-transform: uppercase;
}
.what-next-items-wrapper {
display: flex;
flex-direction: column;
gap: 12px;
&__item {
display: flex;
gap: 10px;
align-items: baseline;
&.ant-alert {
padding: 14px;
border-radius: 8px;
font-size: 14px;
line-height: 20px; /* 142.857% */
letter-spacing: -0.21px;
}
&.ant-alert-info {
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 50%, transparent);
background: color-mix(in srgb, var(--primary-background) 20%, transparent);
color: var(--primary-foreground);
}
.what-next-item {
color: var(--bg-robin-400);
&-bullet-icon {
font-size: 20px;
line-height: 20px;
}
&-text {
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.21px;
}
}
}
}
}
&__footer {
padding-top: 18px;
.ant-btn {
background: var(--primary-background);
color: var(--primary-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
height: 36px;
}
}
}
.lottie-container {
position: absolute;
width: 743.5px;
height: 990.342px;
top: -100px;
left: -36px;
z-index: 1;
}
.lightMode {
.cloud-account-setup-success-view {
&__content {
.cloud-account-setup-success-view {
&__title {
h3 {
color: var(--l1-foreground);
}
}
&__description {
color: var(--l1-foreground);
}
}
}
&__what-next {
&-title {
color: var(--l1-foreground);
}
.what-next-items-wrapper {
&__item {
&.ant-alert-info {
border: 1px solid color-mix(in srgb, var(--bg-robin-600) 20%, transparent);
background: color-mix(
in srgb,
var(--primary-background) 10%,
transparent
);
color: var(--primary-foreground);
}
.what-next-item {
color: var(--primary-foreground);
&-text {
color: var(--primary-foreground);
}
}
}
}
}
&__footer {
.ant-btn {
background: var(--primary-background);
color: var(--primary-foreground);
&:hover {
background: var(--primary-background-hover);
}
}
}
}
}

View File

@@ -0,0 +1,75 @@
import { useState } from 'react';
import Lottie from 'react-lottie';
import { Alert } from 'antd';
import integrationsSuccess from 'assets/Lotties/integrations-success.json';
import solidCheckCircleUrl from '@/assets/Icons/solid-check-circle.svg';
import './SuccessView.style.scss';
export function SuccessView(): JSX.Element {
const [isAnimationComplete, setIsAnimationComplete] = useState(false);
const defaultOptions = {
loop: false,
autoplay: true,
animationData: integrationsSuccess,
rendererSettings: {
preserveAspectRatio: 'xMidYMid slice',
},
};
return (
<>
{!isAnimationComplete && (
<div className="lottie-container">
<Lottie
options={defaultOptions}
height={990.342}
width={743.5}
eventListeners={[
{
eventName: 'complete',
callback: (): void => setIsAnimationComplete(true),
},
]}
/>
</div>
)}
<div className="cloud-account-setup-success-view">
<div className="cloud-account-setup-success-view__icon">
<img src={solidCheckCircleUrl} alt="Success" />
</div>
<div className="cloud-account-setup-success-view__content">
<div className="cloud-account-setup-success-view__title">
<h3>🎉 Success! </h3>
<h3>Your AWS Web Service integration is all set.</h3>
</div>
<div className="cloud-account-setup-success-view__description">
<p>Your observability journey is off to a great start. </p>
<p>Now that your data is flowing, heres what you can do next:</p>
</div>
</div>
<div className="cloud-account-setup-success-view__what-next">
<h4 className="cloud-account-setup-success-view__what-next-title">
WHAT NEXT
</h4>
<div className="what-next-items-wrapper">
<Alert
message={
<div className="what-next-items-wrapper__item">
<div className="what-next-item-bullet-icon"></div>
<div className="what-next-item-text">
Set up your AWS services effortlessly under your enabled account.
</div>
</div>
}
type="info"
className="what-next-items-wrapper__item"
/>
</div>
</div>
</div>
</>
);
}

View File

@@ -1,6 +1,6 @@
import { Dispatch, SetStateAction } from 'react';
import { FormInstance } from 'antd';
import { CloudintegrationtypesCredentialsDTO } from 'api/generated/services/sigNoz.schemas';
import { ConnectionParams } from 'types/api/integrations/aws';
export enum ActiveViewEnum {
SELECT_REGIONS = 'select-regions',
@@ -11,27 +11,23 @@ export enum ModalStateEnum {
FORM = 'form',
WAITING = 'waiting',
ERROR = 'error',
SUCCESS = 'success',
}
export interface RegionFormProps {
form: FormInstance;
modalState: ModalStateEnum;
setModalState: Dispatch<SetStateAction<ModalStateEnum>>;
selectedRegions: string[];
includeAllRegions: boolean;
onIncludeAllRegionsChange: (checked: boolean) => void;
onRegionSelect: () => void;
onSubmit: () => Promise<void>;
accountId?: string;
selectedDeploymentRegion: string | undefined;
handleRegionChange: (value: string) => void;
connectionParams?: CloudintegrationtypesCredentialsDTO;
connectionParams?: ConnectionParams;
isConnectionParamsLoading?: boolean;
setSelectedRegions: Dispatch<SetStateAction<string[]>>;
setIncludeAllRegions: Dispatch<SetStateAction<boolean>>;
onConnectionSuccess: (payload: {
cloudAccountId: string;
status?: unknown;
}) => void;
onConnectionTimeout: (payload: { id?: string }) => void;
onConnectionError: () => void;
}
export interface IntegrationModalProps {

View File

@@ -0,0 +1,50 @@
import { Link } from 'react-router-dom';
import { ServiceData } from './types';
function DashboardItem({
dashboard,
}: {
dashboard: ServiceData['assets']['dashboards'][number];
}): JSX.Element {
const content = (
<>
<div className="cloud-service-dashboard-item__title">{dashboard.title}</div>
<div className="cloud-service-dashboard-item__preview">
<img
src={dashboard.image}
alt={dashboard.title}
className="cloud-service-dashboard-item__preview-image"
/>
</div>
</>
);
return (
<div className="cloud-service-dashboard-item">
{dashboard.url ? (
<Link to={dashboard.url} className="cloud-service-dashboard-item__link">
{content}
</Link>
) : (
content
)}
</div>
);
}
function CloudServiceDashboards({
service,
}: {
service: ServiceData;
}): JSX.Element {
return (
<>
{service.assets.dashboards.map((dashboard) => (
<DashboardItem key={dashboard.id} dashboard={dashboard} />
))}
</>
);
}
export default CloudServiceDashboards;

View File

@@ -1,18 +1,13 @@
import { Table } from 'antd';
import {
CloudintegrationtypesCollectedLogAttributeDTO,
CloudintegrationtypesCollectedMetricDTO,
} from 'api/generated/services/sigNoz.schemas';
import { BarChart2, ScrollText } from 'lucide-react';
import './CloudServiceDataCollected.styles.scss';
import { ServiceData } from './types';
function CloudServiceDataCollected({
logsData,
metricsData,
}: {
logsData: CloudintegrationtypesCollectedLogAttributeDTO[] | null | undefined;
metricsData: CloudintegrationtypesCollectedMetricDTO[] | null | undefined;
logsData: ServiceData['data_collected']['logs'];
metricsData: ServiceData['data_collected']['metrics'];
}): JSX.Element {
const logsColumns = [
{
@@ -66,30 +61,24 @@ function CloudServiceDataCollected({
return (
<div className="cloud-service-data-collected">
{logsData && logsData.length > 0 && (
<div className="cloud-service-data-collected-table">
<div className="cloud-service-data-collected-table-heading">
<ScrollText size={14} />
Logs
</div>
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Logs</div>
<Table
columns={logsColumns}
dataSource={logsData}
{...tableProps}
className="cloud-service-data-collected-table-logs"
className="cloud-service-data-collected__table-logs"
/>
</div>
)}
{metricsData && metricsData.length > 0 && (
<div className="cloud-service-data-collected-table">
<div className="cloud-service-data-collected-table-heading">
<BarChart2 size={14} />
Metrics
</div>
<div className="cloud-service-data-collected__table">
<div className="cloud-service-data-collected__table-heading">Metrics</div>
<Table
columns={metricsColumns}
dataSource={metricsData}
{...tableProps}
className="cloud-service-data-collected-table-metrics"
className="cloud-service-data-collected__table-metrics"
/>
</div>
)}

View File

@@ -0,0 +1,89 @@
.configure-service-modal {
&__body {
display: flex;
flex-direction: column;
border-radius: 3px;
border: 1px solid var(--l1-border);
padding: 14px;
&-regions-switch-switch {
display: flex;
align-items: center;
gap: 6px;
&-label {
color: var(--l1-foreground);
font-size: 14px;
font-weight: 500;
line-height: 20px;
letter-spacing: -0.07px;
}
}
&-switch-description {
margin-top: 4px;
color: var(--l2-foreground);
font-size: 12px;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.06px;
}
&-form-item {
&:last-child {
margin-bottom: 0px;
}
}
}
.ant-modal-body {
padding-bottom: 0;
}
.ant-modal-footer {
margin: 0;
padding-bottom: 12px;
}
}
.lightMode {
.configure-service-modal {
&__body {
border-color: var(--l1-border);
&-regions-switch-switch {
&-label {
color: var(--l1-foreground);
}
}
&-switch-description {
color: var(--l1-foreground);
}
}
.ant-btn {
&.ant-btn-default {
background: var(--l1-background);
border: 1px solid var(--l1-border);
color: var(--l1-foreground);
&:hover {
border-color: var(--l1-border);
color: var(--l1-foreground);
}
}
&.ant-btn-primary {
// Keep primary button same as dark mode
background: var(--primary-background);
color: var(--l1-background);
&:hover {
background: var(--bg-robin-400);
}
&:disabled {
opacity: 0.6;
}
}
}
}
}

View File

@@ -0,0 +1,243 @@
import { useCallback, useMemo, useState } from 'react';
import { useQueryClient } from 'react-query';
import { Form, Switch } from 'antd';
import SignozModal from 'components/SignozModal/SignozModal';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
ServiceConfig,
SupportedSignals,
} from 'container/CloudIntegrationPage/ServicesSection/types';
import { useUpdateServiceConfig } from 'hooks/integration/aws/useUpdateServiceConfig';
import { isEqual } from 'lodash-es';
import logEvent from '../../../api/common/logEvent';
import S3BucketsSelector from './S3BucketsSelector';
import './ConfigureServiceModal.styles.scss';
export interface IConfigureServiceModalProps {
isOpen: boolean;
onClose: () => void;
serviceName: string;
serviceId: string;
cloudAccountId: string;
supportedSignals: SupportedSignals;
initialConfig?: ServiceConfig;
}
function ConfigureServiceModal({
isOpen,
onClose,
serviceName,
serviceId,
cloudAccountId,
initialConfig,
supportedSignals,
}: IConfigureServiceModalProps): JSX.Element {
const [form] = Form.useForm();
const [isLoading, setIsLoading] = useState(false);
// Track current form values
const initialValues = useMemo(
() => ({
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}),
[initialConfig],
);
const [currentValues, setCurrentValues] = useState(initialValues);
const isSaveDisabled = useMemo(
() =>
// disable only if current values are same as the initial config
currentValues.metrics === initialValues.metrics &&
currentValues.logs === initialValues.logs &&
isEqual(currentValues.s3Buckets, initialValues.s3Buckets),
[currentValues, initialValues],
);
const handleS3BucketsChange = useCallback(
(bucketsByRegion: Record<string, string[]>) => {
setCurrentValues((prev) => ({
...prev,
s3Buckets: bucketsByRegion,
}));
form.setFieldsValue({ s3Buckets: bucketsByRegion });
},
[form],
);
const {
mutate: updateServiceConfig,
isLoading: isUpdating,
} = useUpdateServiceConfig();
const queryClient = useQueryClient();
const handleSubmit = useCallback(async (): Promise<void> => {
try {
const values = await form.validateFields();
setIsLoading(true);
updateServiceConfig(
{
serviceId,
payload: {
cloud_account_id: cloudAccountId,
config: {
logs: {
enabled: values.logs,
s3_buckets: values.s3Buckets,
},
metrics: {
enabled: values.metrics,
},
},
},
},
{
onSuccess: () => {
queryClient.invalidateQueries([
REACT_QUERY_KEY.AWS_SERVICE_DETAILS,
serviceId,
]);
onClose();
logEvent('AWS Integration: Service settings saved', {
cloudAccountId,
serviceId,
logsEnabled: values?.logs,
metricsEnabled: values?.metrics,
});
},
onError: (error) => {
console.error('Failed to update service config:', error);
},
},
);
} catch (error) {
console.error('Form submission failed:', error);
} finally {
setIsLoading(false);
}
}, [
form,
updateServiceConfig,
serviceId,
cloudAccountId,
queryClient,
onClose,
]);
const handleClose = useCallback(() => {
form.resetFields();
onClose();
}, [form, onClose]);
return (
<SignozModal
title={
<div className="account-settings-modal__title">Configure {serviceName}</div>
}
centered
open={isOpen}
okText="Save"
okButtonProps={{
disabled: isSaveDisabled,
className: 'account-settings-modal__footer-save-button',
loading: isLoading || isUpdating,
}}
onCancel={handleClose}
onOk={handleSubmit}
cancelText="Close"
cancelButtonProps={{
className: 'account-settings-modal__footer-close-button',
}}
width={672}
rootClassName=" configure-service-modal"
>
<Form
form={form}
layout="vertical"
initialValues={{
metrics: initialConfig?.metrics?.enabled || false,
logs: initialConfig?.logs?.enabled || false,
s3Buckets: initialConfig?.logs?.s3_buckets || {},
}}
>
<div className=" configure-service-modal__body">
{supportedSignals.metrics && (
<Form.Item
name="metrics"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.metrics}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, metrics: checked }));
form.setFieldsValue({ metrics: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Metric Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
Metric Collection is enabled for this AWS account. We recommend keeping
this enabled, but you can disable metric collection if you do not want
to monitor your AWS infrastructure.
</div>
</Form.Item>
)}
{supportedSignals.logs && (
<>
<Form.Item
name="logs"
valuePropName="checked"
className="configure-service-modal__body-form-item"
>
<div className="configure-service-modal__body-regions-switch-switch">
<Switch
checked={currentValues.logs}
onChange={(checked): void => {
setCurrentValues((prev) => ({ ...prev, logs: checked }));
form.setFieldsValue({ logs: checked });
}}
/>
<span className="configure-service-modal__body-regions-switch-switch-label">
Log Collection
</span>
</div>
<div className="configure-service-modal__body-switch-description">
To ingest logs from your AWS services, you must complete several steps
</div>
</Form.Item>
{currentValues.logs && serviceId === 's3sync' && (
<Form.Item name="s3Buckets" noStyle>
<S3BucketsSelector
initialBucketsByRegion={currentValues.s3Buckets}
onChange={handleS3BucketsChange}
/>
</Form.Item>
)}
</>
)}
</div>
</Form>
</SignozModal>
);
}
ConfigureServiceModal.defaultProps = {
initialConfig: {
metrics: { enabled: false },
logs: { enabled: false },
},
};
export default ConfigureServiceModal;

View File

@@ -1,18 +1,13 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Select, Skeleton } from 'antd';
import { useListAccounts } from 'api/generated/services/cloudintegration';
import { INTEGRATION_TYPES } from 'container/Integrations/constants';
import { useCallback, useMemo, useState } from 'react';
import { Form, Select, Skeleton, Typography } from 'antd';
import { useAwsAccounts } from 'hooks/integration/aws/useAwsAccounts';
import useUrlQuery from 'hooks/useUrlQuery';
import { mapAccountDtoToAwsCloudAccount } from '../mapAwsCloudAccountFromDto';
import { CloudAccount } from '../types';
import './S3BucketsSelector.styles.scss';
const { Title } = Typography;
interface S3BucketsSelectorProps {
onChange?: (bucketsByRegion: Record<string, string[]>) => void;
initialBucketsByRegion?: Record<string, string[]>;
disabled?: boolean;
}
/**
@@ -22,29 +17,13 @@ interface S3BucketsSelectorProps {
function S3BucketsSelector({
onChange,
initialBucketsByRegion = {},
disabled: isSelectorDisabled = false,
}: S3BucketsSelectorProps): JSX.Element {
const cloudAccountId = useUrlQuery().get('cloudAccountId');
const { data: listAccountsResponse, isLoading } = useListAccounts({
cloudProvider: INTEGRATION_TYPES.AWS,
});
const accounts = useMemo((): CloudAccount[] | undefined => {
const raw = listAccountsResponse?.data?.accounts;
if (!raw) {
return undefined;
}
return raw
.map(mapAccountDtoToAwsCloudAccount)
.filter((account): account is CloudAccount => account !== null);
}, [listAccountsResponse]);
const { data: accounts, isLoading } = useAwsAccounts();
const [bucketsByRegion, setBucketsByRegion] = useState<
Record<string, string[]>
>(initialBucketsByRegion);
useEffect(() => {
setBucketsByRegion(initialBucketsByRegion);
}, [initialBucketsByRegion]);
// Find the active AWS account based on the URL query parameter
const activeAccount = useMemo(
() =>
@@ -102,41 +81,37 @@ function S3BucketsSelector({
return (
<div className="s3-buckets-selector">
<div className="s3-buckets-selector-title">Select S3 Buckets by Region</div>
<div className="s3-buckets-selector-content">
{allRegions.map((region) => {
const isRegionUnavailable = isRegionDisabled(region);
<Title level={5}>Select S3 Buckets by Region</Title>
return (
<div key={region} className="s3-buckets-selector-region">
<div className="s3-buckets-selector-region-header">
<div className="s3-buckets-selector-region-label">{region}</div>
{isRegionUnavailable && (
<div className="s3-buckets-selector-region-help">
Region disabled in account settings; S3 buckets here will not be
synced.
</div>
)}
</div>
<div className="s3-buckets-selector-region-select">
<Select
mode="tags"
placeholder={`Enter S3 bucket names for ${region}`}
value={bucketsByRegion[region] || []}
onChange={(value): void => handleRegionBucketsChange(region, value)}
tokenSeparators={[',']}
allowClear
disabled={isSelectorDisabled || isRegionUnavailable}
suffixIcon={null}
notFoundContent={null}
filterOption={false}
showSearch
/>
</div>
</div>
);
})}
</div>
{allRegions.map((region) => {
const disabled = isRegionDisabled(region);
return (
<Form.Item
key={region}
label={region}
{...(disabled && {
help:
'Region disabled in account settings; S3 buckets here will not be synced.',
validateStatus: 'warning',
})}
>
<Select
mode="tags"
placeholder={`Enter S3 bucket names for ${region}`}
value={bucketsByRegion[region] || []}
onChange={(value): void => handleRegionBucketsChange(region, value)}
tokenSeparators={[',']}
allowClear
disabled={disabled}
suffixIcon={null}
notFoundContent={null}
filterOption={false}
showSearch
/>
</Form.Item>
);
})}
</div>
);
}

View File

@@ -0,0 +1,189 @@
import { useEffect, useMemo, useState } from 'react';
import { Button, Tabs, TabsProps } from 'antd';
import { MarkdownRenderer } from 'components/MarkdownRenderer/MarkdownRenderer';
import Spinner from 'components/Spinner';
import CloudServiceDashboards from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDashboards';
import CloudServiceDataCollected from 'container/CloudIntegrationPage/ServicesSection/CloudServiceDataCollected';
import { IServiceStatus } from 'container/CloudIntegrationPage/ServicesSection/types';
import dayjs from 'dayjs';
import { useServiceDetails } from 'hooks/integration/aws/useServiceDetails';
import useUrlQuery from 'hooks/useUrlQuery';
import logEvent from '../../../api/common/logEvent';
import ConfigureServiceModal from './ConfigureServiceModal';
const getStatus = (
logsLastReceivedTimestamp: number | undefined,
metricsLastReceivedTimestamp: number | undefined,
): { text: string; className: string } => {
if (!logsLastReceivedTimestamp && !metricsLastReceivedTimestamp) {
return { text: 'No Data Yet', className: 'service-status--no-data' };
}
const latestTimestamp = Math.max(
logsLastReceivedTimestamp || 0,
metricsLastReceivedTimestamp || 0,
);
const isStale = dayjs().diff(dayjs(latestTimestamp), 'minute') > 30;
if (isStale) {
return { text: 'Stale Data', className: 'service-status--stale-data' };
}
return { text: 'Connected', className: 'service-status--connected' };
};
function ServiceStatus({
serviceStatus,
}: {
serviceStatus: IServiceStatus | undefined;
}): JSX.Element {
const logsLastReceivedTimestamp = serviceStatus?.logs?.last_received_ts_ms;
const metricsLastReceivedTimestamp =
serviceStatus?.metrics?.last_received_ts_ms;
const { text, className } = getStatus(
logsLastReceivedTimestamp,
metricsLastReceivedTimestamp,
);
return <div className={`service-status ${className}`}>{text}</div>;
}
function getTabItems(serviceDetailsData: any): TabsProps['items'] {
const dashboards = serviceDetailsData?.assets.dashboards || [];
const dataCollected = serviceDetailsData?.data_collected || {};
const items: TabsProps['items'] = [];
if (dashboards.length) {
items.push({
key: 'dashboards',
label: `Dashboards (${dashboards.length})`,
children: <CloudServiceDashboards service={serviceDetailsData} />,
});
}
items.push({
key: 'data-collected',
label: 'Data Collected',
children: (
<CloudServiceDataCollected
logsData={dataCollected.logs || []}
metricsData={dataCollected.metrics || []}
/>
),
});
return items;
}
function ServiceDetails(): JSX.Element | null {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId');
const serviceId = urlQuery.get('service');
const [isConfigureServiceModalOpen, setIsConfigureServiceModalOpen] = useState(
false,
);
const openServiceConfigModal = (): void => {
setIsConfigureServiceModalOpen(true);
logEvent('AWS Integration: Service settings viewed', {
cloudAccountId,
serviceId,
});
};
const { data: serviceDetailsData, isLoading } = useServiceDetails(
serviceId || '',
cloudAccountId || undefined,
);
const { config, supported_signals } = serviceDetailsData ?? {};
const totalSupportedSignals = Object.entries(supported_signals || {}).filter(
([, value]) => !!value,
).length;
const enabledSignals = useMemo(
() =>
Object.values(config || {}).filter((item) => item && item.enabled).length,
[config],
);
const isAnySignalConfigured = useMemo(
() => !!config?.logs?.enabled || !!config?.metrics?.enabled,
[config],
);
// log telemetry event on visiting details of a service.
useEffect(() => {
if (serviceId) {
logEvent('AWS Integration: Service viewed', {
cloudAccountId,
serviceId,
});
}
}, [cloudAccountId, serviceId]);
if (isLoading) {
return <Spinner size="large" height="50vh" />;
}
if (!serviceDetailsData) {
return null;
}
const tabItems = getTabItems(serviceDetailsData);
return (
<div className="service-details">
<div className="service-details__title-bar">
<div className="service-details__details-title">Details</div>
<div className="service-details__right-actions">
{isAnySignalConfigured && (
<ServiceStatus serviceStatus={serviceDetailsData.status} />
)}
{!!cloudAccountId &&
(isAnySignalConfigured ? (
<Button
className="configure-button configure-button--default"
onClick={openServiceConfigModal}
>
Configure ({enabledSignals}/{totalSupportedSignals})
</Button>
) : (
<Button
type="primary"
className="configure-button configure-button--primary"
onClick={openServiceConfigModal}
>
Enable Service
</Button>
))}
</div>
</div>
<div className="service-details__overview">
<MarkdownRenderer
variables={{}}
markdownContent={serviceDetailsData?.overview}
/>
</div>
<div className="service-details__tabs">
<Tabs items={tabItems} />
</div>
{isConfigureServiceModalOpen && (
<ConfigureServiceModal
isOpen
onClose={(): void => setIsConfigureServiceModalOpen(false)}
serviceName={serviceDetailsData.title}
serviceId={serviceId || ''}
cloudAccountId={cloudAccountId || ''}
initialConfig={serviceDetailsData.config}
supportedSignals={serviceDetailsData.supported_signals || {}}
/>
)}
</div>
);
}
export default ServiceDetails;

View File

@@ -0,0 +1,75 @@
import { useCallback, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom-v5-compat';
import Spinner from 'components/Spinner';
import { useGetAccountServices } from 'hooks/integration/aws/useGetAccountServices';
import useUrlQuery from 'hooks/useUrlQuery';
import ServiceItem from './ServiceItem';
interface ServicesListProps {
cloudAccountId: string;
filter: 'all_services' | 'enabled' | 'available';
}
function ServicesList({
cloudAccountId,
filter,
}: ServicesListProps): JSX.Element {
const urlQuery = useUrlQuery();
const navigate = useNavigate();
const { data: services = [], isLoading } = useGetAccountServices(
cloudAccountId,
);
const activeService = urlQuery.get('service');
const handleActiveService = useCallback(
(serviceId: string): void => {
const latestUrlQuery = new URLSearchParams(window.location.search);
latestUrlQuery.set('service', serviceId);
navigate({ search: latestUrlQuery.toString() });
},
[navigate],
);
const filteredServices = useMemo(() => {
if (filter === 'all_services') {
return services;
}
return services.filter((service) => {
const isEnabled =
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
return filter === 'enabled' ? isEnabled : !isEnabled;
});
}, [services, filter]);
useEffect(() => {
if (activeService || !services?.length) {
return;
}
handleActiveService(services[0].id);
}, [services, activeService, handleActiveService]);
if (isLoading) {
return <Spinner size="large" height="25vh" />;
}
if (!services) {
return <div>No services found</div>;
}
return (
<div className="services-list">
{filteredServices.map((service) => (
<ServiceItem
key={service.id}
service={service}
onClick={handleActiveService}
isActive={service.id === activeService}
/>
))}
</div>
);
}
export default ServicesList;

View File

@@ -1,8 +1,4 @@
.services-tabs {
display: flex;
flex-direction: column;
height: calc(100% - 54px); /* 54px is the height of the header */
.ant-tabs-tab {
font-family: 'Inter';
padding: 16px 4px 14px;
@@ -22,60 +18,21 @@
background: var(--primary-background);
}
}
.services-section {
display: flex;
flex: 1;
min-height: 0;
gap: 10px;
&__sidebar {
width: 240px;
border-right: 1px solid var(--l2-border);
height: 100%;
width: 16%;
padding: 0 16px;
}
&__content {
flex: 1;
height: 100%;
width: 84%;
padding: 16px;
}
}
.service-details-loading,
.services-list-loading {
display: flex;
flex-direction: column;
gap: 10px;
width: 100%;
padding: 12px;
.service-details-loading-item {
width: 100%;
height: 100%;
background-color: var(--muted);
}
}
.services-list-empty-message {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
.empty-state-svg {
height: 20px;
width: 20px;
}
}
.services-filter {
padding: 12px;
padding: 16px 0;
.ant-select-selector {
background-color: var(--l3-background) !important;
border: 1px solid var(--l1-border) !important;
@@ -89,111 +46,6 @@
}
}
.aws-services-list-view {
height: 100%;
.aws-services-list-view-sidebar {
width: 240px;
height: 100%;
border-right: 1px solid var(--l3-background);
padding: 12px;
.aws-services-list-view-sidebar-content {
display: flex;
flex-direction: column;
gap: 8px;
.aws-services-enabled {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 12px;
}
.aws-services-not-enabled {
display: flex;
flex-direction: column;
gap: 8px;
}
.aws-services-list-view-sidebar-content-header {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
}
.aws-services-list-view-sidebar-content-item-empty-message {
color: var(--l2-foreground);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 163.636% */
letter-spacing: 0.44px;
}
.aws-services-list-view-sidebar-content-item {
display: flex;
flex-direction: row;
align-items: center;
gap: 10px;
padding: 4px 12px;
border-radius: 4px;
cursor: pointer;
.aws-services-list-view-sidebar-content-item-icon {
width: 20px;
height: 20px;
}
.aws-services-list-view-sidebar-content-item-title {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
&:hover {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
}
&.active {
color: var(--l1-foreground);
font-family: Inter;
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
background-color: var(--l3-background);
.aws-services-list-view-sidebar-content-item-title {
color: var(--l1-foreground);
}
}
}
}
}
.aws-services-list-view-main {
flex: 1;
padding: 12px;
}
}
.service-item {
display: flex;
gap: 12px;
@@ -208,22 +60,20 @@
}
&.active {
background-color: var(--l3-background);
background-color: var(--bg-ink-100); /* keep: no semantic equivalent */
}
&__icon-wrapper {
height: 32px;
width: 32px;
height: 40px;
width: 40px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--l3-background);
border: 1px solid var(--l1-border);
border-radius: 4px;
.service-item__icon {
width: 16px;
height: 16px;
object-fit: contain;
width: 24px;
height: 24px;
}
}
&__title {
@@ -240,13 +90,11 @@
display: flex;
flex-direction: column;
gap: 10px;
&__title-bar {
display: flex;
justify-content: space-between;
align-items: center;
height: 48px;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
.service-details__details-title {
@@ -257,7 +105,6 @@
letter-spacing: -0.07px;
text-align: left;
}
.service-details__right-actions {
display: flex;
align-items: center;
@@ -273,30 +120,19 @@
border-radius: 2px;
line-height: normal;
&--connected {
border: 1px solid
color-mix(in srgb, var(--success-background) 10%, transparent);
background: color-mix(in srgb, var(--success-background) 10%, transparent);
color: var(--callout-success-title);
border: 1px solid color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
background: color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
color: var(--bg-forest-400);
}
&--stale-data {
background: color-mix(
in srgb,
var(--warning-background-hover) 10%,
transparent
);
border: 1px solid
color-mix(in srgb, var(--warning-background-hover) 10%, transparent);
color: var(--callout-warning-title);
background: color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
color: var(--bg-amber-400);
}
&--no-data {
border: 1px solid
color-mix(in srgb, var(--danger-background-hover) 10%, transparent);
background: color-mix(
in srgb,
var(--danger-background-hover) 10%,
transparent
);
color: var(--callout-error-description);
border: 1px solid color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
background: color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
color: var(--bg-cherry-400);
}
}
@@ -321,28 +157,21 @@
}
}
}
&__overview {
color: var(--l2-foreground);
font-size: 14px;
font-weight: 400;
line-height: 20px; /* 142.857% */
letter-spacing: -0.07px;
width: 100%;
padding: 8px 12px;
width: 800px;
}
&__tabs {
padding: 0px 12px 12px 8px;
.ant-tabs {
&-ink-bar {
background-color: transparent;
}
&-nav {
padding: 0;
padding: 8px 0 18px;
&-wrap {
padding: 0;
}
@@ -461,3 +290,153 @@
}
}
}
.lightMode {
.services-tabs {
.ant-tabs-tab {
&.ant-tabs-tab-active {
.ant-tabs-tab-btn {
color: var(--l1-foreground);
}
}
}
}
.services-filter {
.ant-select-selector {
background-color: var(--l1-background) !important;
border-color: var(--l1-border) !important;
color: var(--l1-foreground) !important;
}
.ant-select-arrow {
color: var(--l1-foreground);
}
}
.service-item {
&:not(:last-child) {
border-bottom: 1px solid var(--l1-border);
}
&.active {
background-color: var(--l3-background);
}
&__icon-wrapper {
background-color: var(--l1-background);
border-color: var(--l1-border);
}
&__title {
color: var(--l1-foreground);
}
}
.service-details {
&__title-bar {
border-bottom: 1px solid var(--l1-border);
.service-details__details-title {
color: var(--l1-foreground);
}
.configure-button {
color: var(--l1-foreground);
background: var(--l1-background);
border-color: var(--l1-border);
&:hover {
border-color: var(--l2-foreground);
color: var(--l1-foreground);
}
}
.service-status {
&--connected {
border: 1px solid color-mix(in srgb, var(--bg-forest-500) 20%, transparent);
background: color-mix(in srgb, var(--bg-forest-500) 10%, transparent);
color: var(--bg-forest-500);
}
&--stale-data {
border: 1px solid color-mix(in srgb, var(--bg-amber-400) 20%, transparent);
background: color-mix(in srgb, var(--bg-amber-400) 10%, transparent);
color: var(--bg-amber-500);
}
&--no-data {
border: 1px solid color-mix(in srgb, var(--bg-cherry-400) 20%, transparent);
background: color-mix(in srgb, var(--bg-cherry-400) 10%, transparent);
color: var(--bg-cherry-500);
}
}
}
&__overview {
color: var(--l1-foreground);
}
&__tabs {
.ant-tabs {
&-tab {
&-btn {
color: var(--l1-foreground) !important;
&[aria-selected='true'] {
color: var(--l1-foreground) !important;
}
}
&-active {
background: var(--l3-background);
}
}
&-nav-list {
border-color: var(--l1-border);
background: var(--l1-background);
}
}
}
.cloud-service {
&-dashboard-item {
&__title {
color: var(--l1-foreground);
}
}
&-data-collected {
&__table {
.ant-table {
border-color: var(--l1-border);
.ant-table-thead {
> tr > th {
color: var(--l1-foreground);
}
}
.ant-table-tbody {
> tr {
&:nth-child(odd),
&:hover > td {
background: var(--l1-background) !important;
}
> td {
color: var(--l1-foreground);
}
}
}
}
}
&__table-heading {
color: var(--l1-foreground);
}
}
}
}
}

View File

@@ -0,0 +1,124 @@
import { useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { Color } from '@signozhq/design-tokens';
import type { SelectProps, TabsProps } from 'antd';
import { Select, Tabs } from 'antd';
import { getAwsServices } from 'api/integration/aws';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useUrlQuery from 'hooks/useUrlQuery';
import { ChevronDown } from 'lucide-react';
import ServiceDetails from './ServiceDetails';
import ServicesList from './ServicesList';
import './ServicesTabs.style.scss';
export enum ServiceFilterType {
ALL_SERVICES = 'all_services',
ENABLED = 'enabled',
AVAILABLE = 'available',
}
interface ServicesFilterProps {
cloudAccountId: string;
onFilterChange: (value: ServiceFilterType) => void;
}
function ServicesFilter({
cloudAccountId,
onFilterChange,
}: ServicesFilterProps): JSX.Element | null {
const { data: services, isLoading } = useQuery(
[REACT_QUERY_KEY.AWS_SERVICES, cloudAccountId],
() => getAwsServices(cloudAccountId),
);
const { enabledCount, availableCount } = useMemo(() => {
if (!services) {
return { enabledCount: 0, availableCount: 0 };
}
return services.reduce(
(acc, service) => {
const isEnabled =
service?.config?.logs?.enabled || service?.config?.metrics?.enabled;
return {
enabledCount: acc.enabledCount + (isEnabled ? 1 : 0),
availableCount: acc.availableCount + (isEnabled ? 0 : 1),
};
},
{ enabledCount: 0, availableCount: 0 },
);
}, [services]);
const selectOptions: SelectProps['options'] = useMemo(
() => [
{ value: 'all_services', label: `All Services (${services?.length || 0})` },
{ value: 'enabled', label: `Enabled (${enabledCount})` },
{ value: 'available', label: `Available (${availableCount})` },
],
[services, enabledCount, availableCount],
);
if (isLoading) {
return null;
}
if (!services?.length) {
return null;
}
return (
<div className="services-filter">
<Select
style={{ width: '100%' }}
defaultValue={ServiceFilterType.ALL_SERVICES}
options={selectOptions}
className="services-sidebar__select"
suffixIcon={<ChevronDown size={16} color={Color.BG_VANILLA_400} />}
onChange={onFilterChange}
/>
</div>
);
}
function ServicesSection(): JSX.Element {
const urlQuery = useUrlQuery();
const cloudAccountId = urlQuery.get('cloudAccountId') || '';
const [activeFilter, setActiveFilter] = useState<
'all_services' | 'enabled' | 'available'
>('all_services');
return (
<div className="services-section">
<div className="services-section__sidebar">
<ServicesFilter
cloudAccountId={cloudAccountId}
onFilterChange={setActiveFilter}
/>
<ServicesList cloudAccountId={cloudAccountId} filter={activeFilter} />
</div>
<div className="services-section__content">
<ServiceDetails />
</div>
</div>
);
}
function ServicesTabs(): JSX.Element {
const tabItems: TabsProps['items'] = [
{
key: 'services',
label: 'Services For Integration',
children: <ServicesSection />,
},
];
return (
<div className="services-tabs">
<Tabs defaultActiveKey="services" items={tabItems} />
</div>
);
}
export default ServicesTabs;

View File

@@ -0,0 +1,161 @@
import { act, fireEvent, screen, waitFor } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest, RestRequest } from 'msw'; // Import RestRequest for req.json() typing
import { UpdateServiceConfigPayload } from '../types';
import { accountsResponse, CLOUD_ACCOUNT_ID, initialBuckets } from './mockData';
import {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
} from './utils';
// --- MOCKS ---
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => ({
get: jest.fn((paramName: string) => {
if (paramName === 'cloudAccountId') {
return CLOUD_ACCOUNT_ID;
}
return null;
}),
})),
}));
// --- TEST SUITE ---
describe('ConfigureServiceModal for S3 Sync service', () => {
jest.setTimeout(10000);
beforeEach(() => {
server.use(
rest.get(
'http://localhost/api/v1/cloud-integrations/aws/accounts',
(req, res, ctx) => res(ctx.json(accountsResponse)),
),
);
});
it('should render with logs collection switch and bucket selectors (no buckets initially selected)', async () => {
act(() => {
renderModal({}); // No initial S3 buckets, defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements({}); // Use new S3-specific assertion
});
it('should render with logs collection switch and bucket selectors (some buckets initially selected)', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements(); // Use new generic assertion
await assertS3SyncSpecificElements(initialBuckets); // Use new S3-specific assertion
});
it('should enable save button after adding a new bucket via combobox', async () => {
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const targetCombobox = screen.getAllByRole('combobox')[0];
const newBucketName = 'a-newly-added-bucket';
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
});
});
it('should send updated bucket configuration on save', async () => {
let capturedPayload: UpdateServiceConfigPayload | null = null;
const mockUpdateConfigUrl =
'http://localhost/api/v1/cloud-integrations/aws/services/s3sync/config';
// Override POST handler specifically for this test to capture payload
server.use(
rest.post(mockUpdateConfigUrl, async (req: RestRequest, res, ctx) => {
capturedPayload = await req.json();
return res(ctx.status(200), ctx.json({ message: 'Config updated' }));
}),
);
act(() => {
renderModal(initialBuckets); // Defaults to 's3sync' serviceId
});
await assertGenericModalElements();
await assertS3SyncSpecificElements(initialBuckets);
expect(screen.getByRole('button', { name: /save/i })).toBeDisabled();
const newBucketName = 'another-new-bucket';
// As before, targeting the first combobox, assumed to be for 'ap-south-1'.
const targetCombobox = screen.getAllByRole('combobox')[0];
act(() => {
fireEvent.change(targetCombobox, { target: { value: newBucketName } });
fireEvent.keyDown(targetCombobox, {
key: 'Enter',
code: 'Enter',
keyCode: 13,
});
});
await waitFor(() => {
expect(screen.getByLabelText(newBucketName)).toBeInTheDocument();
expect(screen.getByRole('button', { name: /save/i })).toBeEnabled();
act(() => {
fireEvent.click(screen.getByRole('button', { name: /save/i }));
});
});
await waitFor(() => {
expect(capturedPayload).not.toBeNull();
});
expect(capturedPayload).toEqual({
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
logs: {
enabled: true,
s3_buckets: {
'us-east-2': ['first-bucket', 'second-bucket'], // Existing buckets
'ap-south-1': [newBucketName], // Newly added bucket for the first region
},
},
metrics: {},
},
});
});
it('should not render S3 bucket region selector UI for services other than s3sync', async () => {
const otherServiceId = 'cloudwatch';
act(() => {
renderModal({}, otherServiceId);
});
await assertGenericModalElements();
await waitFor(() => {
expect(
screen.queryByRole('heading', { name: /select s3 buckets by region/i }),
).not.toBeInTheDocument();
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
regions.forEach((region) => {
expect(
screen.queryByText(`Enter S3 bucket names for ${region}`),
).not.toBeInTheDocument();
});
});
});
});

View File

@@ -0,0 +1,44 @@
import { IConfigureServiceModalProps } from '../ConfigureServiceModal';
const CLOUD_ACCOUNT_ID = '123456789012';
const initialBuckets = { 'us-east-2': ['first-bucket', 'second-bucket'] };
const accountsResponse = {
status: 'success',
data: {
accounts: [
{
id: 'a1b2c3d4-e5f6-7890-1234-567890abcdef',
cloud_account_id: CLOUD_ACCOUNT_ID,
config: {
regions: ['ap-south-1', 'ap-south-2', 'us-east-1', 'us-east-2'],
},
status: {
integration: {
last_heartbeat_ts_ms: 1747114366214,
},
},
},
],
},
};
const defaultModalProps: Omit<IConfigureServiceModalProps, 'initialConfig'> = {
isOpen: true,
onClose: jest.fn(),
serviceName: 'S3 Sync',
serviceId: 's3sync',
cloudAccountId: CLOUD_ACCOUNT_ID,
supportedSignals: {
logs: true,
metrics: false,
},
};
export {
accountsResponse,
CLOUD_ACCOUNT_ID,
defaultModalProps,
initialBuckets,
};

View File

@@ -0,0 +1,78 @@
import { render, RenderResult, screen, waitFor } from '@testing-library/react';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import ConfigureServiceModal from '../ConfigureServiceModal';
import { accountsResponse, defaultModalProps } from './mockData';
/**
* Renders the ConfigureServiceModal with specified S3 bucket initial configurations.
*/
const renderModal = (
initialConfigLogsS3Buckets: Record<string, string[]> = {},
serviceId = 's3sync',
): RenderResult => {
const initialConfig = {
logs: { enabled: true, s3_buckets: initialConfigLogsS3Buckets },
metrics: { enabled: false },
};
return render(
<MockQueryClientProvider>
<ConfigureServiceModal
{...defaultModalProps}
serviceId={serviceId}
initialConfig={initialConfig}
/>
</MockQueryClientProvider>,
);
};
/**
* Asserts that generic UI elements of the modal are present.
*/
const assertGenericModalElements = async (): Promise<void> => {
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
expect(screen.getByText(/log collection/i)).toBeInTheDocument();
expect(
screen.getByText(
/to ingest logs from your aws services, you must complete several steps/i,
),
).toBeInTheDocument();
});
};
/**
* Asserts the state of S3 bucket selectors for each region, specific to S3 Sync.
*/
const assertS3SyncSpecificElements = async (
expectedBucketsByRegion: Record<string, string[]> = {},
): Promise<void> => {
const regions = accountsResponse.data.accounts[0]?.config?.regions || [];
await waitFor(() => {
expect(
screen.getByRole('heading', { name: /select s3 buckets by region/i }),
).toBeInTheDocument();
regions.forEach((region) => {
expect(screen.getByText(region)).toBeInTheDocument();
const bucketsForRegion = expectedBucketsByRegion[region] || [];
if (bucketsForRegion.length > 0) {
bucketsForRegion.forEach((bucket) => {
expect(screen.getByText(bucket)).toBeInTheDocument();
});
} else {
expect(
screen.getByText(`Enter S3 bucket names for ${region}`),
).toBeInTheDocument();
}
});
});
};
export {
assertGenericModalElements,
assertS3SyncSpecificElements,
renderModal,
};

View File

@@ -1,40 +1,90 @@
import { ServiceData } from 'container/Integrations/types';
interface Service {
id: string;
title: string;
icon: string;
config: AWSServiceConfig;
config: ServiceConfig;
}
interface S3BucketsByRegion {
[region: string]: string[];
interface Dashboard {
id: string;
url: string;
title: string;
description: string;
image: string;
}
interface LogField {
name: string;
path: string;
type: string;
}
interface Metric {
name: string;
type: string;
unit: string;
}
interface ConfigStatus {
enabled: boolean;
}
interface DataStatus {
last_received_ts_ms: number;
last_received_from: string;
}
interface S3BucketsByRegion {
[region: string]: string[];
}
interface LogsConfig extends ConfigStatus {
s3_buckets?: S3BucketsByRegion;
}
interface AWSServiceConfig {
interface ServiceConfig {
logs: LogsConfig;
metrics: ConfigStatus;
s3_sync?: LogsConfig;
}
interface IServiceStatus {
logs: DataStatus | null;
metrics: DataStatus | null;
}
interface SupportedSignals {
metrics: boolean;
logs: boolean;
}
interface ServiceData {
id: string;
title: string;
icon: string;
overview: string;
supported_signals: SupportedSignals;
assets: {
dashboards: Dashboard[];
};
data_collected: {
logs?: LogField[];
metrics: Metric[];
};
config?: ServiceConfig;
status?: IServiceStatus;
}
interface ServiceDetailsResponse {
status: 'success';
data: ServiceData;
}
export interface AWSCloudAccountConfig {
interface CloudAccountConfig {
regions: string[];
}
export interface IntegrationStatus {
interface IntegrationStatus {
last_heartbeat_ts_ms: number;
}
@@ -45,9 +95,8 @@ interface AccountStatus {
interface CloudAccount {
id: string;
cloud_account_id: string;
config: AWSCloudAccountConfig;
config: CloudAccountConfig;
status: AccountStatus;
providerAccountId: string;
}
interface CloudAccountsData {
@@ -84,13 +133,15 @@ interface UpdateServiceConfigResponse {
}
export type {
AWSServiceConfig,
CloudAccount,
CloudAccountsData,
IServiceStatus,
S3BucketsByRegion,
Service,
ServiceConfig,
ServiceData,
ServiceDetailsResponse,
SupportedSignals,
UpdateServiceConfigPayload,
UpdateServiceConfigResponse,
};

View File

@@ -0,0 +1,64 @@
import { I18nextProvider } from 'react-i18next';
import { act, fireEvent, render, screen } from '@testing-library/react';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import {
IntegrationType,
RequestIntegrationBtn,
} from 'pages/Integrations/RequestIntegrationBtn';
import i18n from 'ReactI18';
describe('Request AWS integration', () => {
it('should render the request integration button', async () => {
let capturedPayload: any;
server.use(
rest.post('http://localhost/api/v1/event', async (req, res, ctx) => {
capturedPayload = await req.json();
return res(
ctx.status(200),
ctx.json({
statusCode: 200,
error: null,
payload: 'Event Processed Successfully',
}),
);
}),
);
act(() => {
render(
<I18nextProvider i18n={i18n}>
<RequestIntegrationBtn type={IntegrationType.AWS_SERVICES} />{' '}
</I18nextProvider>,
);
});
expect(
screen.getByText(
/can't find what youre looking for\? request more integrations/i,
),
).toBeInTheDocument();
await act(() => {
fireEvent.change(screen.getByPlaceholderText(/Enter integration name/i), {
target: { value: 's3 sync' },
});
const submitButton = screen.getByRole('button', { name: /submit/i });
expect(submitButton).toBeEnabled();
fireEvent.click(submitButton);
});
expect(capturedPayload.eventName).toBeDefined();
expect(capturedPayload.attributes).toBeDefined();
expect(capturedPayload.eventName).toBe('AWS service integration requested');
expect(capturedPayload.attributes).toEqual({
screen: 'AWS integration details',
integration: 's3 sync',
deployment_url: 'localhost',
user_email: null,
});
});
});

View File

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

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