Compare commits

..

96 Commits

Author SHA1 Message Date
Shivanshu Raj Shrivastava
c0a934fddc feat: enhance funnel context and state management
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-08 18:00:22 +05:30
Shivanshu Raj Shrivastava
ae5c3002a8 feat: enable trace funnels in production
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-08 17:59:06 +05:30
Shivanshu Raj Shrivastava
462c603ff8 feat: improve metrics display and formatting, column titles, error states
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-08 17:58:21 +05:30
Shivanshu Raj Shrivastava
44d90b29c1 feat: enhance trace funnels API and core functionality
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-08 17:56:23 +05:30
Shivanshu Raj Shrivastava
9a8137293a feat: remove trace funnels feature flag and add new query keys
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-08 17:55:27 +05:30
Shivanshu Raj Shrivastava
6080f710e3 feat: remove trace funnels feature flag from backend
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-08 17:55:10 +05:30
Shaheer Kochai
4d6a053791 chore: trace funnel changes (#7780)
* refactor: handle funnels list search on frontend

* refactor: use funnel steps update API for adding / updating step title and description

* feat: allow selecting user's typed option in trace funnel service and span name dropdowns

* chore: properly render the -> between steps in funnel results

* fix: sync funnel step name with add details modal text fields
2025-05-08 16:06:48 +05:30
Shaheer Kochai
ca39aba813 chore: trace funnels feedback changes (#7772)
* chore: change the copy from x traces to valid traces found / not found

* chore: add open funnel button in add span to funnel modal

* feat: display buttons for adding step details and funnel description + copy to clipboard

* feat: highlight funnel graph column based on selected (total / error span) from the legend items
2025-05-08 16:06:48 +05:30
Shaheer Kochai
5ff5ea72c4 refactor: incorporate the recent funnel details API changes (#7760) 2025-05-08 16:06:48 +05:30
Shivanshu Raj Shrivastava
ed03082f4e chore: fix api endpoint
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-08 16:06:48 +05:30
Shivanshu Raj Shrivastava
5f284894e7 fix: handle nested funnel response structure to fix missing funnel_id… (#7740)
* fix: handle nested funnel response structure to fix missing funnel_id in updates

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: remove console.og

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: revert explicitly passing funnelId to updateFunnelSteps

---------

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
Co-authored-by: ahmadshaheer <ashaheerki@gmail.com>
2025-05-08 16:06:48 +05:30
ahmadshaheer
f3c896f5dc refactor: address review comments 2025-05-08 16:06:48 +05:30
Shaheer Kochai
b1f350aca0 refactor: update the get and delete funnel endpoints to adjust to the BE changes (#7697) 2025-05-08 16:06:48 +05:30
ahmadshaheer
ffc8f795ab fix: handle restoring steps if updating funnel steps fail 2025-05-08 16:06:48 +05:30
ahmadshaheer
d745ed0102 chore: remove feature flag and display trace funnels only in dev envirnoment 2025-05-08 16:06:48 +05:30
ahmadshaheer
cbab8c26f8 chore: address review comments 2025-05-08 16:06:48 +05:30
ahmadshaheer
d6a9686d95 feat: change ms to ns 2025-05-08 16:06:48 +05:30
ahmadshaheer
9a5b786a15 fix: remove maxTagCount 2025-05-08 16:06:48 +05:30
ahmadshaheer
b4f0265154 fix: display funnels tab in trace details based on feature flag 2025-05-08 16:06:48 +05:30
ahmadshaheer
bf04e359cd fix: display add to funnel button based on feature flag 2025-05-08 16:06:47 +05:30
Yunus M
051fe6c1f7 chore: add null check 2025-05-08 16:06:47 +05:30
Shaheer Kochai
8d348c66f9 feat: funnel details page steps and configuration (#7424)
* chore: add a new tab for traces funnels

* feat: funnels list page basic UI

* feat: get funnels list data from mock API, and handle data, loading and empty states

* feat: implement funnel rename

* refactor: overall improvements

* feat: implement sorting in traces funnels list page

* feat: add sort column key and order to url params

* chore: move useFunnels to hooks/TracesFunnels

* feat: implement traces funnels search and refactor search and sort by extracting to custom hooks

* chore: overall improvements to rename trace funnel modal

* chore: make the rename input auto-focusable

* feat: handle create funnel modal

* feat: delete funnel modal and functionality

* fix: fix the layout shift in funnel item caused by getContainer={false}

* chore: overall improvements and use live api in traces funnels

* feat: create traces funnels details basic page + funnel -> details redirection

* fix: funnels traces light mode UI

* fix: properly display created at in funnels list item + preventDefault

* refactor: extract FunnelItemPopover into a separate component

* chore: hide funnel tab from traces explorer

* chore: add check to display trace funnels tab only in dev environment

* chore: improve funnels modals light mode

* chore: overall improvements

* fix: properly pass funnel details link

* chore: address PR review changes

* chore: add tab bar to trace funnel details page

* feat: funnel step UI with service, span, and where filters

* feat: build radio button component

* refactor: use the SignozRadioButton in funnel results -> step transitions radio buttons

* feat: inter step config (i.e. latency type) UI

* chore: improve steps header styles by removing divider width

* feat: funnel steps title, description, popover UI + pass data from API

* chore: update FilterSelect component to conditionally add url params and accept on change

* fix: fix funnel step where clause and update the state variables for filters

* chore: add support for isMultiple and fix the type in FilterSelect

* feat: centralize the steps state management in StepsContent

* fix: move steps state up + pass steps count from state

* feat: implement auto save for updating the steps whenever any step changes

* feat: implement auto save for validating steps if service name or span names change

* feat: impelement funnel step removal

* feat: implement add details modal for funnel steps

* fix: fix the overflowing time range picker

* feat: funnel details empty state

* feat: add support for saving funnel description

* chore: overall improvements

* fix: fix the light mode styles

* fix: fix the failing build + broken search UI

* refactor: remove the reference of useLocation from traceFunnel item in TraceModulePage constant

* fix: fix the issue of update steps getting triggered on initial render if we have filters

* fix: fix the edge case of stale state causing filters to be re-added after removing

* feat: funnel details page results (#7451)

* feat: funnel metrics table component

* feat: funnel metrics and steps transition metrics components UI

* feat: funnel table component

* feat: slowest traces and traces with error components

* fix: overall light theme fixes

* fix: fix the warning

* chore: add empty and loading states to FunnelMetricsTable

* feat: get overall funnel metrics from the API

* fix: fix the empty state of funnel metrics table

* feat: get data for slowest traces and traces with errors

* fix: link trace id to trace details page

* fix: get data for funnel step transition metrics and refactor the existing data fetching logic

* refactor: add funnel context + overall refactoring and optimizations

* refactor: move steps states to funnel context + handle empty and run funnel disabled states

* feat: handle run funnel

* fix: improve empty state

* chore: rename isValidateStepsMutationLoading -> isValidateStepsLoading

* chore: improve query key

* fix: display loading state if funnel results are fetching

* refactor: move steps validation fetching and states to the context API

* fix: display loading state in funnel results while steps validation is fetching

* fix: call validate steps API only on changing the service name or span name of any step

* refactor: move validateStepsQuery key out of useEffect and update the dependencies

* chore: centralize hasIncompleteSteps and run validate only if steps have service and spans

* fix: handle all empty fields state + overall improvements

* fix: handle long where query tags

* feat: build the funnel result graph component

* feat: build the funnel result graph component

* feat: handle loading, error, empty states in funnel graph

* fix: don't display change percentage if % is 0

* refactor: overall improvements

* feat: get funnel steps graph data from API + move logic to custom hook

* fix: improve empty and error states

* fix: handle funnel graph legends width using css

* fix: redirect to trace funnels list page on clicking delete from funnel details

* fix: update the query cache while updating steps

* fix: implement debounced search for funnel list search

* fix: refetch steps graph data query on clicking run funnel / sync button

* fix: improve the step footer spacing

* chore: add gap between divider to inter-step-config

* fix: handle loading state while fetching

* feat: add span to funnel flow (from trace details page) (#7477)

* chore: display add to funnel icon on hovering any span in trace details page

* chore: add className to funnel item actions popover

* feat: add funnels tab to trace details v2 tab bar

* feat: add span to funnel flow

* chore: hide actions popover button from funnel item in span -> funnel flows

* chore: improve the funnel details UI in add span to funnel modal

* fix: display empty state + don't redirect to funnels list on delete success + overall improvements
2025-05-08 16:06:47 +05:30
ahmadshaheer
0ea7109188 fix: hide step count for add button only 2025-05-08 16:06:47 +05:30
ahmadshaheer
d32dcef4ee chore: traces funnel details results skeleton 2025-05-08 16:06:47 +05:30
ahmadshaheer
291708b0e9 chore: traces funnel details page overall skeleton 2025-05-08 16:06:47 +05:30
ahmadshaheer
030b9ceb36 chore: add tab bar to trace funnel details page 2025-05-08 16:06:47 +05:30
ahmadshaheer
458b3e798d fix: properly display created at in funnels list item + preventDefault 2025-05-08 16:06:47 +05:30
ahmadshaheer
ee39d26338 feat: create traces funnels details basic page + funnel -> details redirection 2025-05-08 16:06:47 +05:30
ahmadshaheer
220465cf3b chore: move useFunnels to hooks/TracesFunnels 2025-05-08 16:06:47 +05:30
ahmadshaheer
be2b21a10a feat: implement funnel rename 2025-05-08 16:06:47 +05:30
ahmadshaheer
685f1036e8 feat: get funnels list data from mock API, and handle data, loading and empty states 2025-05-08 16:06:47 +05:30
ahmadshaheer
55928b2a84 feat: funnels list page basic UI 2025-05-08 16:06:47 +05:30
Yunus M
c7db85f44c Update CODEOWNERS (#7861) 2025-05-08 08:22:41 +00:00
Vikrant Gupta
08d9a74055 fix(api-key): make the expires in human readable in api keys (#7864)
* fix(api-key): human readable expires in

* fix(api-key): human readable expires in
2025-05-08 08:14:02 +00:00
Ekansh Gupta
503e4cdf00 Feat trace ordering on the basis of span_count or Trace_duration (#7842)
* feat: added order by span_count in traces tab

* feat: added order by span_count in traces tab

* feat: added order by span_count in traces tab

* feat: added order by span_count in traces tab

* feat: added order by span_count in traces tab

* feat: added order by span_count in traces tab

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-05-08 06:38:41 +00:00
Srikanth Chekuri
224f952da7 chore: add notification for upcoming migration for cloud region IN users (#7848) 2025-05-07 13:41:41 +00:00
Vikrant Gupta
0c28067f89 feat(error): base setup for error handling in frontend (#7851)
* feat(login): add error response v2 and error handler v2

* feat(error): added the base error class

* feat(error): added the base error class

* feat(error): remove unnecessary code

* feat(error): fix types

* feat(error): add http status code helper
2025-05-07 16:31:20 +05:30
Vibhu Pandey
8dc749b9dd fix(migration): fix cascading drops in sqlite (#7844)
* fix(foreign-key): fix cascading drops in sqlite

* fix(foreign-key): fix comments

* fix(foreign-key): fix function names

* fix(foreign-key): fix order of migration

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2025-05-07 08:18:13 +00:00
Prashant Shahi
82a111e5b1 chore(signoz): remove deprecated signoz arguments (#7849)
### Summary

- remove deprecated signoz arguments

---------

Signed-off-by: Prashant Shahi <prashant@signoz.io>
2025-05-07 07:15:37 +00:00
primus-bot[bot]
e2e6c65b4d chore(release): bump to v0.82.0 (#7847)
#### Summary
 - Release SigNoz v0.82.0
 - Bump SigNoz OTel Collector to v0.111.41

 Created by [Primus-Bot](https://github.com/apps/primus-bot)
2025-05-07 06:46:59 +00:00
Amlan Kumar Nandy
f01d21cbf2 feat: implement inspect feature for metrics explorer (#7549) 2025-05-07 05:18:56 +00:00
aniketio-ctrl
36886135d1 chore: disable writing to v2 tables and add signozclickhousemetrics in signozspanmetrics
Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-05-07 03:26:01 +00:00
Vikrant Gupta
3648027576 fix(ruler): improve the user experience for rule id migration (#7841)
* fix(ruler): improve the user experience for rule id migration

* fix(ruler): improve the user experience for rule id migration
2025-05-06 22:37:59 +05:30
Shaheer Kochai
b80626f5e2 fix: add dark class to the elements when dark mode is enabled to support components library modes (#7607) 2025-05-06 15:44:26 +00:00
Shaheer Kochai
08579242eb fix: add hideSpanScopeSelector prop to QueryBuilderSearchV2 and hide from non qb consumers (#7716)
* feat: add hideSpanScopeSelector prop to QueryBuilderSearchV2 and hide from non qb consumers

* fix: update the tests to check rendering based on hideSpanScopeSelector

* feat: display span selector in exceptions page
2025-05-06 19:02:57 +04:30
aniketio-ctrl
6e0b50dd60 fix(7832): added filters in inspect metrics api (#7833)
* fix(7842): added filters in inspect metrics api

* fix(metrics-explorer): added check for 40 time series only
2025-05-06 07:06:12 +00:00
Aditya Singh
76ed58c481 Fix/logs issues main (#7758)
* fix: context log data fix in list view

* fix: fix query builder and quick filters in light mode

* chore: add desc

* chore: added test case

* fix: fix redirect url when not in logs view

* chore: minor fix

* chore: minor fix

* chore: minor test fix

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
2025-05-06 11:02:40 +05:30
Shivanshu Raj Shrivastava
f4d029bd12 fix: correctly populate response_status (#7822)
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-05-05 13:57:09 +05:30
Amlan Kumar Nandy
b66af786e6 fix: description tooltip coming up twice in metrics list table (#7823) 2025-05-05 05:58:56 +00:00
Vibhu Pandey
5ad68a3310 docs(contributing): add sql docs (#7819)
### Summary

add sql docs
2025-05-04 02:23:44 +05:30
Vikrant Gupta
0f0693f6eb fix(ruler): scan orgIDs in string slice instead of valuer struct (#7818) 2025-05-04 00:04:20 +05:30
Ekansh Gupta
16e3c185e9 feat: quick_filter_fix (#7816)
* feat: quick_filter_fix

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters
2025-05-03 17:09:20 +00:00
Vibhu Pandey
8d6671e362 docs(contributing): add docs/contributing/go/readme (#7814)
* docs(readme): add docs/contributing/go/readme

* docs(readme): add docs/contributing/go/readme

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* docs(readme): add errors package

* Update docs/contributing/go/errors.md

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

* Update docs/contributing/go/errors.md

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>

---------

Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
2025-05-03 13:07:18 +00:00
Vikrant Gupta
5b237ee628 feat(cache): multi-tenant cache (#7805)
* feat(cache): remove the references of old cache

* feat(cache): add orgID in query range modules pt1

* feat(cache): add orgID in query range modules pt2

* feat(cache): add orgID in query range modules pt3

* feat(cache): preload metrics for all orgs

* feat(cache): fix ruler

* feat(cache): fix go build

* feat(cache): add orgID to rule

* feat(cache): fix tests

* feat(cache): address review comments

* feat(cache): use correct errors

* feat(cache): fix tests

* feat(cache): add the cache test package
2025-05-03 18:30:07 +05:30
Nageshbansal
cb08ce5e5d chore: updates os for Docker Engine Installation for redhat (#7809) 2025-05-03 10:06:16 +00:00
Ekansh Gupta
3fbc3dec48 feat: added changes related to custom options for quick filters (#7712)
* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added support for custom quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters

* feat: added changes related to custom options for quick filters
2025-05-02 22:39:26 +05:30
Vikrant Gupta
5b2f897a00 chore(ruler): remove the notification for rule ID migration (#7806) 2025-05-01 19:59:34 +05:30
Vibhu Pandey
73f57d8bee chore(codeowners): add codeowners for sqlmigration (#7779)
### Summary

- add codeowners for sqlmigration
2025-04-30 08:41:31 +00:00
Prashant Shahi
ab17bf3558 ci(build): include USERPILOT_KEY FE envs (#7777)
### Summary

- include USERPILOT_KEY FE envs in the build workflows

Signed-off-by: Prashant Shahi <prashant@signoz.io>
2025-04-30 13:32:19 +05:30
primus-bot[bot]
eb5a1b76b8 chore(release): bump to v0.81.0 (#7776)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-04-30 12:19:26 +05:30
Shivanshu Raj Shrivastava
130ff925bd feat: adds error toggle in top error page (#7773)
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-04-30 11:05:30 +05:30
Sahil Khan
75d86cea60 fix: api monitoring cosmetic changes (#7771)
fix: minor changes
2025-04-29 21:26:07 +00:00
CheetoDa
cf451d335c feat: added new datasources (#7769) 2025-04-29 22:05:34 +05:30
Yunus M
e47c7cc17b feat: initialize sentry only once (#7768) 2025-04-29 16:01:28 +00:00
Srikanth Chekuri
629c54d3f9 fix: nil pointer error on failed to create rule (#7767) 2025-04-29 15:01:31 +00:00
sawhil
ed3026eeb5 fix: removed unused file 2025-04-29 20:21:12 +05:30
Sahil Khan
ccf26883c4 chore: api monitoring tests (#7750)
* feat: added url sharing for main domain list page api monitoring

* feat: added shivanshus suggestions in qb payloads for spanid and kind string client filter

* fix: limited the endpoints table limit to 1000

* feat: date picker in domain details drawer

* feat: added top errors tab in domain details

* fix: removed console logs

* feat: new dep services top 10 errors localised date picker agrregate domain details etc

* feat: added domain level and endpoint level stats

* feat: added custom cell rendering in gridcard, added new table view in all endpoints

* feat: added port column in endpoints table

* feat: added custom title handling in gridtablecomponent

* fix: fixed the traces corelation query for status code bar charts

* feat: added zoom functionality on domain details charts

* chore: add constants for standardisation

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* chore: add constants for standardisation in the API

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* feat: add tooltip to Endpoint Overview

Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>

* feat: api monitoring feedback till 28th april

* feat: added top errors to traces corelation

* feat: added new rate col to status code table

* feat: custom color mapping for uplot tooltip implemented

* chore: added ApiMonitoringPage.test

* chore: added uts for all endpoints, top errors and their utils

* fix: minor fix

* chore: moved test files to proper folder

* chore: added endpoint details uts and its imported utils ut

* chore: added endpoint dropdown uts and its imported utils ut

* chore: added endpoint metrics uts and its imported utils ut

* chore: added dependent services uts and its imported utils ut

* chore: added status code bar chart uts and its imported utils ut

* chore: added status code table uts and its imported utils ut
2025-04-29 20:21:12 +05:30
sawhil
958924befe feat: custom color mapping for uplot tooltip implemented 2025-04-29 20:21:12 +05:30
sawhil
b70c570cdc feat: added new rate col to status code table 2025-04-29 20:21:12 +05:30
sawhil
42a026469b feat: added top errors to traces corelation 2025-04-29 20:21:12 +05:30
sawhil
6de0908a62 feat: api monitoring feedback till 28th april 2025-04-29 20:21:12 +05:30
Shivanshu Raj Shrivastava
fd21a4955e feat: add tooltip to Endpoint Overview
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-04-29 20:21:12 +05:30
Shivanshu Raj Shrivastava
3dce13d29f chore: add constants for standardisation in the API
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-04-29 20:21:12 +05:30
Shivanshu Raj Shrivastava
2ce4b60c55 chore: add constants for standardisation
Signed-off-by: Shivanshu Raj Shrivastava <shivanshu1333@gmail.com>
2025-04-29 20:21:12 +05:30
sawhil
c9888804cd feat: added zoom functionality on domain details charts 2025-04-29 20:21:12 +05:30
sawhil
413b0d9fae fix: fixed the traces corelation query for status code bar charts 2025-04-29 20:21:12 +05:30
sawhil
b24095236f feat: added custom title handling in gridtablecomponent 2025-04-29 20:21:12 +05:30
sawhil
21d239ce68 feat: added port column in endpoints table 2025-04-29 20:21:12 +05:30
sawhil
d6e4e3c5ed feat: added custom cell rendering in gridcard, added new table view in all endpoints 2025-04-29 20:21:12 +05:30
sawhil
552b103e8b feat: added domain level and endpoint level stats 2025-04-29 20:21:12 +05:30
sawhil
1123a9a93d feat: new dep services top 10 errors localised date picker agrregate domain details etc 2025-04-29 20:21:12 +05:30
sawhil
8b30e3cc5c fix: removed console logs 2025-04-29 20:21:12 +05:30
sawhil
b86e65d2ca feat: added top errors tab in domain details 2025-04-29 20:21:12 +05:30
sawhil
d5e2841083 feat: date picker in domain details drawer 2025-04-29 20:21:12 +05:30
sawhil
7dad5dcd17 fix: limited the endpoints table limit to 1000 2025-04-29 20:21:12 +05:30
sawhil
ac0b640146 feat: added shivanshus suggestions in qb payloads for spanid and kind string client filter 2025-04-29 20:21:12 +05:30
sawhil
e125d146b5 feat: added url sharing for main domain list page api monitoring 2025-04-29 20:21:12 +05:30
sawhil
a41ffceca4 fix: changed the error percentage calculation 2025-04-29 20:21:12 +05:30
sawhil
7edb047c0c fix: added support for group by sorting in endpoints table 2025-04-29 20:21:12 +05:30
sawhil
6504f2565b fix: fixed last seen sorting in endpoint table 2025-04-29 20:21:12 +05:30
sawhil
6b418a125b fix: changed error rate to error percentage 2025-04-29 20:21:12 +05:30
sawhil
36827a1667 fix: added fallback for undefined data and added support for sorting 2025-04-29 20:21:12 +05:30
sawhil
1118c56356 feat: new dep. services table added 2025-04-29 20:21:12 +05:30
sawhil
bd071e3e60 feat: added new queries to handle error rates of endpoints 2025-04-29 20:21:12 +05:30
sawhil
36f3a2e26d feat: added sorting and error rate to endpoints table 2025-04-29 20:21:12 +05:30
sawhil
fee7e96176 feat: added sorting in domains list page 2025-04-29 20:21:12 +05:30
307 changed files with 20763 additions and 4810 deletions

View File

@@ -1,5 +1,4 @@
services:
clickhouse:
image: clickhouse/clickhouse-server:24.1.2-alpine
container_name: clickhouse
@@ -24,7 +23,6 @@ services:
retries: 3
depends_on:
- zookeeper
zookeeper:
image: bitnami/zookeeper:3.7.1
container_name: zookeeper
@@ -41,9 +39,8 @@ services:
interval: 30s
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:0.111.29
image: signoz/signoz-schema-migrator:v0.111.41
container_name: schema-migrator-sync
command:
- sync
@@ -55,9 +52,8 @@ services:
clickhouse:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:0.111.29
image: signoz/signoz-schema-migrator:v0.111.41
container_name: schema-migrator-async
command:
- async

3
.github/CODEOWNERS vendored
View File

@@ -2,7 +2,7 @@
# Owners are automatically requested for review for PRs that changes code
# that they own.
/frontend/ @YounixM
/frontend/ @SigNoz/frontend @YounixM
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
/deploy/ @SigNoz/devops
@@ -11,3 +11,4 @@
/pkg/errors/ @grandwizard28
/pkg/factory/ @grandwizard28
/pkg/types/ @grandwizard28
/pkg/sqlmigration/ @vikrantgupta25

View File

@@ -69,6 +69,7 @@ jobs:
echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
echo 'CUSTOMERIO_ID="${{ secrets.CUSTOMERIO_ID }}"' >> frontend/.env
echo 'CUSTOMERIO_SITE_ID="${{ secrets.CUSTOMERIO_SITE_ID }}"' >> frontend/.env
echo 'USERPILOT_KEY="${{ secrets.USERPILOT_KEY }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@@ -64,8 +64,9 @@ jobs:
run: |
mkdir -p frontend
echo 'CI=1' > frontend/.env
echo 'TUNNEL_URL=https://telemetry.staging.signoz.cloud/tunnel' >> frontend/.env
echo 'TUNNEL_DOMAIN=https://telemetry.staging.signoz.cloud' >> frontend/.env
echo 'TUNNEL_URL="${{ secrets.NP_TUNNEL_URL }}"' >> frontend/.env
echo 'TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'USERPILOT_KEY="${{ secrets.NP_USERPILOT_KEY }}"' >> frontend/.env
- name: cache-dotenv
uses: actions/cache@v4
with:

View File

@@ -35,6 +35,7 @@ jobs:
echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
echo 'CUSTOMERIO_ID="${{ secrets.CUSTOMERIO_ID }}"' >> .env
echo 'CUSTOMERIO_SITE_ID="${{ secrets.CUSTOMERIO_SITE_ID }}"' >> .env
echo 'USERPILOT_KEY="${{ secrets.USERPILOT_KEY }}"' >> .env
- name: build-frontend
run: make js-build
- name: upload-frontend-artifact

View File

@@ -76,9 +76,7 @@ go-run-enterprise: ## Runs the enterprise go backend server
go run -race \
$(GO_BUILD_CONTEXT_ENTERPRISE)/main.go \
--config ./conf/prometheus.yml \
--cluster cluster \
--use-logs-new-schema true \
--use-trace-new-schema true
--cluster cluster
.PHONY: go-test
go-test: ## Runs go unit tests
@@ -96,9 +94,7 @@ go-run-community: ## Runs the community go backend server
go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/main.go \
--config ./conf/prometheus.yml \
--cluster cluster \
--use-logs-new-schema true \
--use-trace-new-schema true
--cluster cluster
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
go-build-community: ## Builds the go backend server for community

View File

@@ -50,7 +50,7 @@ cache:
# Time-to-live for cache entries in memory. Specify the duration in ns
ttl: 60000000000
# The interval at which the cache will be cleaned up
cleanupInterval: 1m
cleanup_interval: 1m
# redis: Uses Redis as the caching backend.
redis:
# The hostname or IP address of the Redis server.

View File

@@ -174,11 +174,9 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.80.0
image: signoz/signoz:v0.82.0
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -208,7 +206,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.111.39
image: signoz/signoz-otel-collector:v0.111.41
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -232,7 +230,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.111.39
image: signoz/signoz-schema-migrator:v0.111.41
deploy:
restart_policy:
condition: on-failure

View File

@@ -110,11 +110,9 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.80.0
image: signoz/signoz:v0.82.0
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -143,7 +141,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.111.39
image: signoz/signoz-otel-collector:v0.111.41
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
@@ -167,7 +165,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.111.39
image: signoz/signoz-schema-migrator:v0.111.41
deploy:
restart_policy:
condition: on-failure

View File

@@ -26,7 +26,7 @@ processors:
detectors: [env, system]
timeout: 2s
signozspanmetrics/delta:
metrics_exporter: clickhousemetricswrite
metrics_exporter: clickhousemetricswrite, signozclickhousemetrics
metrics_flush_interval: 60s
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
dimensions_cache_size: 100000
@@ -64,8 +64,10 @@ exporters:
endpoint: tcp://clickhouse:9000/signoz_metrics
resource_to_telemetry_conversion:
enabled: true
disable_v2: true
clickhousemetricswrite/prometheus:
endpoint: tcp://clickhouse:9000/signoz_metrics
disable_v2: true
signozclickhousemetrics:
dsn: tcp://clickhouse:9000/signoz_metrics
clickhouselogsexporter:

View File

@@ -177,12 +177,10 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.80.0}
image: signoz/signoz:${VERSION:-v0.82.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -212,7 +210,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.39}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.41}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -238,7 +236,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.39}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-sync
command:
- sync
@@ -249,7 +247,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.39}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-async
command:
- async

View File

@@ -110,12 +110,10 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.80.0}
image: signoz/signoz:${VERSION:-v0.82.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
- --use-logs-new-schema=true
- --use-trace-new-schema=true
ports:
- "8080:8080" # signoz port
# - "6060:6060" # pprof port
@@ -144,7 +142,7 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.39}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.111.41}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
@@ -166,7 +164,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.39}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +176,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.39}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.111.41}
container_name: schema-migrator-async
command:
- async

View File

@@ -26,7 +26,7 @@ processors:
detectors: [env, system]
timeout: 2s
signozspanmetrics/delta:
metrics_exporter: clickhousemetricswrite
metrics_exporter: clickhousemetricswrite, signozclickhousemetrics
metrics_flush_interval: 60s
latency_histogram_buckets: [100us, 1ms, 2ms, 6ms, 10ms, 50ms, 100ms, 250ms, 500ms, 1000ms, 1400ms, 2000ms, 5s, 10s, 20s, 40s, 60s ]
dimensions_cache_size: 100000
@@ -62,10 +62,12 @@ exporters:
use_new_schema: true
clickhousemetricswrite:
endpoint: tcp://clickhouse:9000/signoz_metrics
disable_v2: true
resource_to_telemetry_conversion:
enabled: true
clickhousemetricswrite/prometheus:
endpoint: tcp://clickhouse:9000/signoz_metrics
disable_v2: true
signozclickhousemetrics:
dsn: tcp://clickhouse:9000/signoz_metrics
clickhouselogsexporter:

View File

@@ -93,7 +93,7 @@ check_os() {
;;
Red\ Hat*)
desired_os=1
os="red hat"
os="rhel"
package_manager="yum"
;;
CentOS*)

View File

@@ -0,0 +1,103 @@
# Errors
SigNoz includes its own structured [errors](/pkg/errors/errors.go) package. It's built on top of Go's `error` interface, extending it to add additional context that helps provide more meaningful error messages throughout the application.
## How to use it?
To use the SigNoz structured errors package, use these functions instead of the standard library alternatives:
```go
// Instead of errors.New()
errors.New(typ, code, message)
// Instead of fmt.Errorf()
errors.Newf(typ, code, message, args...)
```
### Typ
The Typ (read as Type, defined as `typ`) is used to categorize errors across the codebase and is loosely coupled with HTTP/GRPC status codes. All predefined types can be found in [pkg/errors/type.go](/pkg/errors/type.go). For example:
- `TypeInvalidInput` - Indicates invalid input was provided
- `TypeNotFound` - Indicates a resource was not found
By design, `typ` is unexported and cannot be declared outside of [errors](/pkg/errors/errors.go) package. This ensures that it is consistent across the codebase and is used in a way that is meaningful.
### Code
Codes are used to provide more granular categorization within types. For instance, a type of `TypeInvalidInput` might have codes like `CodeInvalidEmail` or `CodeInvalidPassword`.
To create new error codes, use the `errors.MustNewCode` function:
```go
var (
CodeThingAlreadyExists = errors.MustNewCode("thing_already_exists")
CodeThingNotFound = errors.MustNewCode("thing_not_found")
)
```
> 💡 **Note**: Error codes must match the regex `^[a-z_]+$` otherwise the code will panic.
## Show me some examples
### Using the error
A basic example of using the error:
```go
var (
CodeThingAlreadyExists = errors.MustNewCode("thing_already_exists")
)
func CreateThing(id string) error {
t, err := thing.GetFromStore(id)
if err != nil {
if errors.As(err, errors.TypeNotFound) {
// thing was not found, create it
return thing.Create(id)
}
// something else went wrong, wrap the error with more context
return errors.Wrapf(err, errors.TypeInternal, errors.CodeUnknown, "failed to get thing from store")
}
return errors.Newf(errors.TypeAlreadyExists, CodeThingAlreadyExists, "thing with id %s already exists", id)
}
```
### Changing the error
Sometimes you may want to change the error while preserving the message:
```go
func GetUserSecurely(id string) (*User, error) {
user, err := repository.GetUser(id)
if err != nil {
if errors.Ast(err, errors.TypeNotFound) {
// Convert NotFound to Forbidden for security reasons
return nil, errors.New(errors.TypeForbidden, errors.CodeAccessDenied, "access denied to requested resource")
}
return nil, err
}
return user, nil
}
```
## Why do we need this?
In a large codebase like SigNoz, error handling is critical for maintaining reliability, debuggability, and a good user experience. We believe that it is the **responsibility of a function** to return **well-defined** errors that **accurately describe what went wrong**. With our structured error system:
- Functions can create precise errors with appropriate additional context
- Callers can make informed decisions based on the additional context
- Error context is preserved and enhanced as it moves up the call stack
The caller (which can be another function or a HTTP/gRPC handler or something else entirely), can then choose to use this error to take appropriate actions such as:
- A function can branch into different paths based on the context
- An HTTP/gRPC handler can derive the correct status code and message from the error and send it to the client
- Logging systems can capture structured error information for better diagnostics
Although there might be cases where this might seem too verbose, it makes the code more maintainable and consistent. A little verbose code is better than clever code that doesn't provide enough context.
## What should I remember?
- Think about error handling as you write your code, not as an afterthought.
- Always use the [errors](/pkg/errors/errors.go) package instead of the standard library's `errors.New()` or `fmt.Errorf()`.
- Always assign appropriate codes to errors when creating them instead of using the "catch all" error codes defined in [pkg/errors/code.go](/pkg/errors/code.go).
- Use `errors.Wrapf()` to add context to errors while preserving the original when appropriate.

View File

@@ -0,0 +1,11 @@
# Go
This document provides an overview of contributing to the SigNoz backend written in Go. The SigNoz backend is built with Go, focusing on performance, maintainability, and developer experience. We strive for clean, idiomatic code that follows established Go practices while addressing the unique needs of an observability platform.
We adhere to three primary style guides as our foundation:
- [Effective Go](https://go.dev/doc/effective_go) - For writing idiomatic Go code
- [Code Review Comments](https://go.dev/wiki/CodeReviewComments) - For understanding common comments in code reviews
- [Google Style Guide](https://google.github.io/styleguide/go/) - Additional practices from Google
We **recommend** (almost enforce) reviewing these guides before contributing to the codebase. They provide valuable insights into writing idiomatic Go code and will help you understand our approach to backend development. In addition, we have a few additional rules that make certain areas stricter than the above which can be found in area-specific files in this package.

View File

@@ -0,0 +1,94 @@
# SQL
SigNoz utilizes a relational database to store metadata including organization information, user data and other settings.
## How to use it?
The database interface is defined in [SQLStore](/pkg/sqlstore/sqlstore.go). SigNoz leverages the Bun ORM to interact with the underlying database. To access the database instance, use the `BunDBCtx` function. For operations that require transactions across multiple database operations, use the `RunInTxCtx` function. This function embeds a transaction in the context, which propagates through various functions in the callback.
```go
type Thing struct {
bun.BaseModel
ID types.Identifiable `bun:",embed"`
SomeColumn string `bun:"some_column"`
TimeAuditable types.TimeAuditable `bun:",embed"`
OrgID string `bun:"org_id"`
}
func GetThing(ctx context.Context, id string) (*Thing, error) {
thing := new(Thing)
err := sqlstore.
BunDBCtx(ctx).
NewSelect().
Model(thing).
Where("id = ?", id).
Scan(ctx)
return thing, err
}
func CreateThing(ctx context.Context, thing *Thing) error {
return sqlstore.
BunDBCtx(ctx).
NewInsert().
Model(thing).
Exec(ctx)
}
```
> 💡 **Note**: Always use line breaks while working with SQL queries to enhance code readability.
> 💡 **Note**: Always use the `new` function to create new instances of structs.
## What are hooks?
Hooks are user-defined functions that execute before and/or after specific database operations. These hooks are particularly useful for generating telemetry data such as logs, traces, and metrics, providing visibility into database interactions. Hooks are defined in the [SQLStoreHook](/pkg/sqlstore/sqlstore.go) interface.
## How is the schema designed?
SigNoz implements a star schema design with the organizations table as the central entity. All other tables link to the organizations table via foreign key constraints on the `org_id` column. This design ensures that every entity within the system is either directly or indirectly associated with an organization.
```mermaid
erDiagram
ORGANIZATIONS {
string id PK
timestamp created_at
timestamp updated_at
}
ENTITY_A {
string id PK
timestamp created_at
timestamp updated_at
string org_id FK
}
ENTITY_B {
string id PK
timestamp created_at
timestamp updated_at
string org_id FK
}
ORGANIZATIONS ||--o{ ENTITY_A : contains
ORGANIZATIONS ||--o{ ENTITY_B : contains
```
> 💡 **Note**: There are rare exceptions to the above star schema design. Consult with the maintainers before deviating from the above design.
All tables follow a consistent primary key pattern using a `id` column (referenced by the `types.Identifiable` struct) and include `created_at` and `updated_at` columns (referenced by the `types.TimeAuditable` struct) for audit purposes.
## How to write migrations?
For schema migrations, use the [SQLMigration](/pkg/sqlmigration/sqlmigration.go) interface and write the migration in the same package. When creating migrations, adhere to these guidelines:
- Do not implement **`ON CASCADE` foreign key constraints**. Deletion operations should be handled explicitly in application logic rather than delegated to the database.
- Do not **import types from the types package** in the `sqlmigration` package. Instead, define the required types within the migration package itself. This practice ensures migration stability as the core types evolve over time.
- Do not implement **`Down` migrations**. As the codebase matures, we may introduce this capability, but for now, the `Down` function should remain empty.
- Always write **idempotent** migrations. This means that if the migration is run multiple times, it should not cause an error.
- A migration which is **dependent on the underlying dialect** (sqlite, postgres, etc) should be written as part of the [SQLDialect](/pkg/sqlstore/sqlstore.go) interface. The implementation needs to go in the dialect specific package of the respective database.
## What should I remember?
- Use `BunDBCtx` and `RunInTxCtx` to access the database instance and execute transactions respectively.
- While designing new tables, ensure the consistency of `id`, `created_at`, `updated_at` and an `org_id` column with a foreign key constraint to the `organizations` table (unless the table serves as a transitive entity not directly associated with an organization but indirectly associated with one).
- Implement deletion logic in the application rather than relying on cascading deletes in the database.
- While writing migrations, adhere to the guidelines mentioned above.

View File

@@ -5,6 +5,7 @@ import (
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/valuer"
)
type DailyProvider struct {
@@ -37,7 +38,7 @@ func NewDailyProvider(opts ...GenericProviderOption[*DailyProvider]) *DailyProvi
return dp
}
func (p *DailyProvider) GetAnomalies(ctx context.Context, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
func (p *DailyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
req.Seasonality = SeasonalityDaily
return p.getAnomalies(ctx, req)
return p.getAnomalies(ctx, orgID, req)
}

View File

@@ -5,6 +5,7 @@ import (
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/valuer"
)
type HourlyProvider struct {
@@ -37,7 +38,7 @@ func NewHourlyProvider(opts ...GenericProviderOption[*HourlyProvider]) *HourlyPr
return hp
}
func (p *HourlyProvider) GetAnomalies(ctx context.Context, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
func (p *HourlyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
req.Seasonality = SeasonalityHourly
return p.getAnomalies(ctx, req)
return p.getAnomalies(ctx, orgID, req)
}

View File

@@ -2,8 +2,10 @@ package anomaly
import (
"context"
"github.com/SigNoz/signoz/pkg/valuer"
)
type Provider interface {
GetAnomalies(ctx context.Context, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error)
GetAnomalies(ctx context.Context, orgID valuer.UUID, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error)
}

View File

@@ -5,11 +5,12 @@ import (
"math"
"time"
"github.com/SigNoz/signoz/pkg/query-service/cache"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/query-service/postprocess"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
)
@@ -59,9 +60,9 @@ func (p *BaseSeasonalProvider) getQueryParams(req *GetAnomaliesRequest) *anomaly
return prepareAnomalyQueryParams(req.Params, req.Seasonality)
}
func (p *BaseSeasonalProvider) getResults(ctx context.Context, params *anomalyQueryParams) (*anomalyQueryResults, error) {
func (p *BaseSeasonalProvider) getResults(ctx context.Context, orgID valuer.UUID, params *anomalyQueryParams) (*anomalyQueryResults, error) {
zap.L().Info("fetching results for current period", zap.Any("currentPeriodQuery", params.CurrentPeriodQuery))
currentPeriodResults, _, err := p.querierV2.QueryRange(ctx, params.CurrentPeriodQuery)
currentPeriodResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.CurrentPeriodQuery)
if err != nil {
return nil, err
}
@@ -72,7 +73,7 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, params *anomalyQu
}
zap.L().Info("fetching results for past period", zap.Any("pastPeriodQuery", params.PastPeriodQuery))
pastPeriodResults, _, err := p.querierV2.QueryRange(ctx, params.PastPeriodQuery)
pastPeriodResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.PastPeriodQuery)
if err != nil {
return nil, err
}
@@ -83,7 +84,7 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, params *anomalyQu
}
zap.L().Info("fetching results for current season", zap.Any("currentSeasonQuery", params.CurrentSeasonQuery))
currentSeasonResults, _, err := p.querierV2.QueryRange(ctx, params.CurrentSeasonQuery)
currentSeasonResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.CurrentSeasonQuery)
if err != nil {
return nil, err
}
@@ -94,7 +95,7 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, params *anomalyQu
}
zap.L().Info("fetching results for past season", zap.Any("pastSeasonQuery", params.PastSeasonQuery))
pastSeasonResults, _, err := p.querierV2.QueryRange(ctx, params.PastSeasonQuery)
pastSeasonResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.PastSeasonQuery)
if err != nil {
return nil, err
}
@@ -105,7 +106,7 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, params *anomalyQu
}
zap.L().Info("fetching results for past 2 season", zap.Any("past2SeasonQuery", params.Past2SeasonQuery))
past2SeasonResults, _, err := p.querierV2.QueryRange(ctx, params.Past2SeasonQuery)
past2SeasonResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.Past2SeasonQuery)
if err != nil {
return nil, err
}
@@ -116,7 +117,7 @@ func (p *BaseSeasonalProvider) getResults(ctx context.Context, params *anomalyQu
}
zap.L().Info("fetching results for past 3 season", zap.Any("past3SeasonQuery", params.Past3SeasonQuery))
past3SeasonResults, _, err := p.querierV2.QueryRange(ctx, params.Past3SeasonQuery)
past3SeasonResults, _, err := p.querierV2.QueryRange(ctx, orgID, params.Past3SeasonQuery)
if err != nil {
return nil, err
}
@@ -335,9 +336,9 @@ func (p *BaseSeasonalProvider) getAnomalyScores(
return anomalyScoreSeries
}
func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
func (p *BaseSeasonalProvider) getAnomalies(ctx context.Context, orgID valuer.UUID, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
anomalyParams := p.getQueryParams(req)
anomalyQueryResults, err := p.getResults(ctx, anomalyParams)
anomalyQueryResults, err := p.getResults(ctx, orgID, anomalyParams)
if err != nil {
return nil, err
}

View File

@@ -5,6 +5,7 @@ import (
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/valuer"
)
type WeeklyProvider struct {
@@ -36,7 +37,7 @@ func NewWeeklyProvider(opts ...GenericProviderOption[*WeeklyProvider]) *WeeklyPr
return wp
}
func (p *WeeklyProvider) GetAnomalies(ctx context.Context, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
func (p *WeeklyProvider) GetAnomalies(ctx context.Context, orgID valuer.UUID, req *GetAnomaliesRequest) (*GetAnomaliesResponse, error) {
req.Seasonality = SeasonalityWeekly
return p.getAnomalies(ctx, req)
return p.getAnomalies(ctx, orgID, req)
}

View File

@@ -13,11 +13,12 @@ import (
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/modules/quickfilter"
quickfilterscore "github.com/SigNoz/signoz/pkg/modules/quickfilter/core"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/cloudintegrations"
"github.com/SigNoz/signoz/pkg/query-service/app/integrations"
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/cache"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
@@ -38,7 +39,6 @@ type APIHandlerOptions struct {
IntegrationsController *integrations.Controller
CloudIntegrationsController *cloudintegrations.Controller
LogsParsingPipelineController *logparsingpipeline.LogParsingPipelineController
Cache cache.Cache
Gateway *httputil.ReverseProxy
GatewayUrl string
// Querier Influx Interval
@@ -55,6 +55,8 @@ type APIHandler struct {
// NewAPIHandler returns an APIHandler
func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler, error) {
quickfiltermodule := quickfilterscore.NewQuickFilters(quickfilterscore.NewStore(signoz.SQLStore))
quickFilter := quickfilter.NewAPI(quickfiltermodule)
baseHandler, err := baseapp.NewAPIHandler(baseapp.APIHandlerOpts{
Reader: opts.DataConnector,
PreferSpanMetrics: opts.PreferSpanMetrics,
@@ -64,11 +66,12 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
IntegrationsController: opts.IntegrationsController,
CloudIntegrationsController: opts.CloudIntegrationsController,
LogsParsingPipelineController: opts.LogsParsingPipelineController,
Cache: opts.Cache,
FluxInterval: opts.FluxInterval,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
FieldsAPI: fields.NewAPI(signoz.TelemetryStore),
Signoz: signoz,
QuickFilters: quickFilter,
QuickFilterModule: quickfiltermodule,
})
if err != nil {

View File

@@ -134,7 +134,7 @@ func (ah *APIHandler) registerUser(w http.ResponseWriter, r *http.Request) {
return
}
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization)
_, registerError := baseauth.Register(ctx, req, ah.Signoz.Alertmanager, ah.Signoz.Modules.Organization, ah.QuickFilterModule)
if !registerError.IsNil() {
RespondError(w, apierr, nil)
return

View File

@@ -119,7 +119,7 @@ func (ah *APIHandler) updatePAT(w http.ResponseWriter, r *http.Request) {
req.UpdatedByUserID = claims.UserID
req.UpdatedAt = time.Now()
zap.L().Info("Got UpdateSteps PAT request", zap.Any("pat", req))
zap.L().Info("Got Update PAT request", zap.Any("pat", req))
var apierr basemodel.BaseApiError
if apierr = ah.AppDao().UpdatePAT(r.Context(), claims.OrgID, req, id); apierr != nil {
RespondError(w, apierr, nil)

View File

@@ -7,14 +7,27 @@ import (
"net/http"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/http/render"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
"github.com/SigNoz/signoz/pkg/query-service/model"
v3 "github.com/SigNoz/signoz/pkg/query-service/model/v3"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"go.uber.org/zap"
)
func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
claims, err := authtypes.ClaimsFromContext(r.Context())
if err != nil {
render.Error(w, err)
return
}
orgID, err := valuer.NewUUID(claims.OrgID)
if err != nil {
render.Error(w, err)
return
}
bodyBytes, _ := io.ReadAll(r.Body)
r.Body = io.NopCloser(bytes.NewBuffer(bodyBytes))
@@ -29,7 +42,7 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
queryRangeParams.Version = "v4"
// add temporality for each metric
temporalityErr := aH.PopulateTemporality(r.Context(), queryRangeParams)
temporalityErr := aH.PopulateTemporality(r.Context(), orgID, queryRangeParams)
if temporalityErr != nil {
zap.L().Error("Error while adding temporality for metrics", zap.Error(temporalityErr))
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: temporalityErr}, nil)
@@ -85,30 +98,30 @@ func (aH *APIHandler) queryRangeV4(w http.ResponseWriter, r *http.Request) {
switch seasonality {
case anomaly.SeasonalityWeekly:
provider = anomaly.NewWeeklyProvider(
anomaly.WithCache[*anomaly.WeeklyProvider](aH.opts.Cache),
anomaly.WithCache[*anomaly.WeeklyProvider](aH.Signoz.Cache),
anomaly.WithKeyGenerator[*anomaly.WeeklyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.WeeklyProvider](aH.opts.DataConnector),
)
case anomaly.SeasonalityDaily:
provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](aH.opts.Cache),
anomaly.WithCache[*anomaly.DailyProvider](aH.Signoz.Cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](aH.opts.DataConnector),
)
case anomaly.SeasonalityHourly:
provider = anomaly.NewHourlyProvider(
anomaly.WithCache[*anomaly.HourlyProvider](aH.opts.Cache),
anomaly.WithCache[*anomaly.HourlyProvider](aH.Signoz.Cache),
anomaly.WithKeyGenerator[*anomaly.HourlyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.HourlyProvider](aH.opts.DataConnector),
)
default:
provider = anomaly.NewDailyProvider(
anomaly.WithCache[*anomaly.DailyProvider](aH.opts.Cache),
anomaly.WithCache[*anomaly.DailyProvider](aH.Signoz.Cache),
anomaly.WithKeyGenerator[*anomaly.DailyProvider](queryBuilder.NewKeyGenerator()),
anomaly.WithReader[*anomaly.DailyProvider](aH.opts.DataConnector),
)
}
anomalies, err := provider.GetAnomalies(r.Context(), &anomaly.GetAnomaliesRequest{Params: queryRangeParams})
anomalies, err := provider.GetAnomalies(r.Context(), orgID, &anomaly.GetAnomaliesRequest{Params: queryRangeParams})
if err != nil {
RespondError(w, &model.ApiError{Typ: model.ErrorInternal, Err: err}, nil)
return

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/rules"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/http/middleware"
"github.com/SigNoz/signoz/pkg/prometheus"
"github.com/SigNoz/signoz/pkg/signoz"
@@ -41,7 +42,6 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/app/logparsingpipeline"
"github.com/SigNoz/signoz/pkg/query-service/app/opamp"
opAmpModel "github.com/SigNoz/signoz/pkg/query-service/app/opamp/model"
"github.com/SigNoz/signoz/pkg/query-service/cache"
baseconst "github.com/SigNoz/signoz/pkg/query-service/constants"
"github.com/SigNoz/signoz/pkg/query-service/healthcheck"
baseint "github.com/SigNoz/signoz/pkg/query-service/interfaces"
@@ -57,7 +57,6 @@ type ServerOptions struct {
HTTPHostPort string
PrivateHostPort string
PreferSpanMetrics bool
CacheConfigPath string
FluxInterval string
FluxIntervalForTraceDetail string
Cluster string
@@ -134,19 +133,10 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
serverOptions.SigNoz.Cache,
)
var c cache.Cache
if serverOptions.CacheConfigPath != "" {
cacheOpts, err := cache.LoadFromYAMLCacheConfigFile(serverOptions.CacheConfigPath)
if err != nil {
return nil, err
}
c = cache.NewCache(cacheOpts)
}
rm, err := makeRulesManager(
serverOptions.SigNoz.SQLStore.SQLxDB(),
reader,
c,
serverOptions.SigNoz.Cache,
serverOptions.SigNoz.Alertmanager,
serverOptions.SigNoz.SQLStore,
serverOptions.SigNoz.TelemetryStore,
@@ -223,7 +213,6 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
IntegrationsController: integrationsController,
CloudIntegrationsController: cloudIntegrationsController,
LogsParsingPipelineController: logParsingPipelineController,
Cache: c,
FluxInterval: fluxInterval,
Gateway: gatewayProxy,
GatewayUrl: serverOptions.GatewayUrl,
@@ -261,9 +250,15 @@ func NewServer(serverOptions *ServerOptions) (*Server, error) {
&opAmpModel.AllAgents, agentConfMgr,
)
errorList := reader.PreloadMetricsMetadata(context.Background())
for _, er := range errorList {
zap.L().Error("failed to preload metrics metadata", zap.Error(er))
orgs, err := apiHandler.Signoz.Modules.Organization.GetAll(context.Background())
if err != nil {
return nil, err
}
for _, org := range orgs {
errorList := reader.PreloadMetricsMetadata(context.Background(), org.ID)
for _, er := range errorList {
zap.L().Error("failed to preload metrics metadata", zap.Error(er))
}
}
return s, nil
@@ -327,7 +322,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterMessagingQueuesRoutes(r, am)
apiHandler.RegisterThirdPartyApiRoutes(r, am)
apiHandler.MetricExplorerRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},

View File

@@ -72,6 +72,7 @@ func main() {
flag.DurationVar(&dialTimeout, "dial-timeout", 5*time.Second, "(the maximum time to establish a connection.)")
// Deprecated
flag.StringVar(&ruleRepoURL, "rules.repo-url", baseconst.AlertHelpPage, "(host address used to build rule link in alert messages)")
// Deprecated
flag.StringVar(&cacheConfigPath, "experimental.cache-config", "", "(cache config to use)")
flag.StringVar(&fluxInterval, "flux-interval", "5m", "(the interval to exclude data from being cached to avoid incorrect cache for data in motion)")
flag.StringVar(&fluxIntervalForTraceDetail, "flux-interval-trace-detail", "2m", "(the interval to exclude data from being cached to avoid incorrect cache for trace data in motion)")
@@ -138,7 +139,6 @@ func main() {
HTTPHostPort: baseconst.HTTPHostPort,
PreferSpanMetrics: preferSpanMetrics,
PrivateHostPort: baseconst.PrivateHostPort,
CacheConfigPath: cacheConfigPath,
FluxInterval: fluxInterval,
FluxIntervalForTraceDetail: fluxIntervalForTraceDetail,
Cluster: cluster,

View File

@@ -62,13 +62,6 @@ var BasicPlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.TraceFunnels,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}
var EnterprisePlan = basemodel.FeatureSet{
@@ -121,11 +114,4 @@ var EnterprisePlan = basemodel.FeatureSet{
UsageLimit: -1,
Route: "",
},
basemodel.Feature{
Name: basemodel.TraceFunnels,
Active: false,
Usage: 0,
UsageLimit: -1,
Route: "",
},
}

View File

@@ -12,10 +12,11 @@ import (
"go.uber.org/zap"
"github.com/SigNoz/signoz/ee/query-service/anomaly"
"github.com/SigNoz/signoz/pkg/query-service/cache"
"github.com/SigNoz/signoz/pkg/cache"
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
"github.com/SigNoz/signoz/pkg/query-service/app/queryBuilder"
@@ -53,6 +54,7 @@ type AnomalyRule struct {
func NewAnomalyRule(
id string,
orgID valuer.UUID,
p *ruletypes.PostableRule,
reader interfaces.Reader,
cache cache.Cache,
@@ -66,7 +68,7 @@ func NewAnomalyRule(
p.RuleCondition.Target = &target
}
baseRule, err := baserules.NewBaseRule(id, p, reader, opts...)
baseRule, err := baserules.NewBaseRule(id, orgID, p, reader, opts...)
if err != nil {
return nil, err
}
@@ -158,18 +160,18 @@ func (r *AnomalyRule) GetSelectedQuery() string {
return r.Condition().GetSelectedQueryName()
}
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, ts time.Time) (ruletypes.Vector, error) {
func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, ts time.Time) (ruletypes.Vector, error) {
params, err := r.prepareQueryRange(ts)
if err != nil {
return nil, err
}
err = r.PopulateTemporality(ctx, params)
err = r.PopulateTemporality(ctx, orgID, params)
if err != nil {
return nil, fmt.Errorf("internal error while setting temporality")
}
anomalies, err := r.provider.GetAnomalies(ctx, &anomaly.GetAnomaliesRequest{
anomalies, err := r.provider.GetAnomalies(ctx, orgID, &anomaly.GetAnomaliesRequest{
Params: params,
Seasonality: r.seasonality,
})
@@ -204,7 +206,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
prevState := r.State()
valueFormatter := formatter.FromUnit(r.Unit())
res, err := r.buildAndRunQuery(ctx, ts)
res, err := r.buildAndRunQuery(ctx, r.OrgID(), ts)
if err != nil {
return nil, err
@@ -297,7 +299,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
// alerts[h] is ready, add or update active list now
for h, a := range alerts {
// Check whether we already have alerting state for the identifying label set.
// UpdateSteps the last value and annotations if so, create a new alert entry otherwise.
// Update the last value and annotations if so, create a new alert entry otherwise.
if alert, ok := r.Active[h]; ok && alert.State != model.StateInactive {
alert.Value = a.Value

View File

@@ -9,6 +9,7 @@ import (
baserules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/query-service/utils/labels"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/google/uuid"
"go.uber.org/zap"
)
@@ -23,6 +24,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// create a threshold rule
tr, err := baserules.NewThresholdRule(
ruleId,
opts.OrgID,
opts.Rule,
opts.Reader,
baserules.WithEvalDelay(opts.ManagerOpts.EvalDelay),
@@ -43,6 +45,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// create promql rule
pr, err := baserules.NewPromRule(
ruleId,
opts.OrgID,
opts.Rule,
opts.Logger,
opts.Reader,
@@ -63,6 +66,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
// create anomaly rule
ar, err := NewAnomalyRule(
ruleId,
opts.OrgID,
opts.Rule,
opts.Reader,
opts.Cache,
@@ -119,6 +123,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
// create a threshold rule
rule, err = baserules.NewThresholdRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
baserules.WithSendAlways(),
@@ -127,7 +132,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
)
if err != nil {
zap.L().Error("failed to prepare a new threshold rule for test", zap.String("name", rule.Name()), zap.Error(err))
zap.L().Error("failed to prepare a new threshold rule for test", zap.String("name", alertname), zap.Error(err))
return 0, basemodel.BadRequest(err)
}
@@ -136,6 +141,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
// create promql rule
rule, err = baserules.NewPromRule(
alertname,
opts.OrgID,
parsedRule,
opts.Logger,
opts.Reader,
@@ -146,13 +152,14 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
)
if err != nil {
zap.L().Error("failed to prepare a new promql rule for test", zap.String("name", rule.Name()), zap.Error(err))
zap.L().Error("failed to prepare a new promql rule for test", zap.String("name", alertname), zap.Error(err))
return 0, basemodel.BadRequest(err)
}
} else if parsedRule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
rule, err = NewAnomalyRule(
alertname,
opts.OrgID,
parsedRule,
opts.Reader,
opts.Cache,
@@ -161,7 +168,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
baserules.WithSQLStore(opts.SQLStore),
)
if err != nil {
zap.L().Error("failed to prepare a new anomaly rule for test", zap.String("name", rule.Name()), zap.Error(err))
zap.L().Error("failed to prepare a new anomaly rule for test", zap.String("name", alertname), zap.Error(err))
return 0, basemodel.BadRequest(err)
}
} else {
@@ -187,7 +194,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
// newTask returns an appropriate group for
// rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID string) baserules.Task {
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)
}

View File

@@ -106,3 +106,7 @@ func (provider *provider) WrapAlreadyExistsErrf(err error, code errors.Code, for
return err
}
func (dialect *dialect) ToggleForeignKeyConstraint(ctx context.Context, bun *bun.DB, enable bool) error {
return nil
}

View File

@@ -82,6 +82,7 @@
"history": "4.10.1",
"html-webpack-plugin": "5.5.0",
"http-proxy-middleware": "3.0.3",
"http-status-codes": "2.3.0",
"i18next": "^21.6.12",
"i18next-browser-languagedetector": "^6.1.3",
"i18next-http-backend": "^1.3.2",
@@ -90,7 +91,7 @@
"less": "^4.1.2",
"less-loader": "^10.2.0",
"lodash-es": "^4.17.21",
"lucide-react": "0.427.0",
"lucide-react": "0.498.0",
"mini-css-extract-plugin": "2.4.5",
"motion": "12.4.13",
"overlayscrollbars": "^2.8.1",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -0,0 +1 @@
<svg width="14" height="14" fill="none" xmlns="http://www.w3.org/2000/svg"><g stroke="#C0C1C3" stroke-width="1.167" stroke-linecap="round" stroke-linejoin="round"><path d="m12.192 3.18-1.167 2.33-.583 1.165M7.31 12.74a.583.583 0 0 1-.835-.24L1.808 3.179"/><path d="M7 1.167c2.9 0 5.25.783 5.25 1.75 0 .966-2.35 1.75-5.25 1.75s-5.25-.784-5.25-1.75c0-.967 2.35-1.75 5.25-1.75ZM8.75 10.5h3.5M10.5 12.25v-3.5"/></g></svg>

After

Width:  |  Height:  |  Size: 418 B

View File

@@ -0,0 +1 @@
<svg width="16" height="16" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#a)" stroke-linecap="round" stroke-linejoin="round"><path d="M8 14.666A6.667 6.667 0 1 0 8 1.333a6.667 6.667 0 0 0 0 13.333Z" fill="#C0C1C3" stroke="#C0C1C3" stroke-width="2"/><path d="M8 11.333v-4H6.333M8 4.667h.007" stroke="#121317" stroke-width="1.333"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 439 B

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 23.0.4, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 800.5 907.77" style="enable-background:new 0 0 800.5 907.77;" xml:space="preserve">
<style type="text/css">
.st0{fill:#FFFFFF;}
</style>
<path class="st0" d="M303.36,238.61c31.36-21.37,71.76-12.97,65-6.53c-12.89,12.28,4.26,8.65,6.11,31.31
c1.36,16.69-4.09,25.88-8.78,31.11c-9.79,1.28-21.69,3.67-36.02,8.33c-8.48,2.76-15.85,5.82-22.31,8.9
c-1.7-1.11-3.55-2.47-5.74-4.36C279.5,288.19,280.24,254.37,303.36,238.61 M490.68,370.72c5.69-4.41,31.55-12.72,55.49-15.55
c12.57-1.48,30.49-2.34,34.31-0.2c7.59,4.19,7.59,17.16,2.39,29.14c-7.57,17.4-18.27,36.63-30.39,38.21
c-19.77,2.61-38.46-8.09-59.8-24.03C485.06,392.56,480.38,378.68,490.68,370.72 M526.75,201.27c29.19,13.58,25.37,39.42,26.18,54.6
c0.22,4.36,0.15,7.3-0.22,9.32c-4.04-2.19-10.43-3.8-20.56-3.35c-2.96,0.12-5.84,0.47-8.63,0.91c-10.77-5.77-17.21-17.06-23.1-29.06
c-0.54-1.11-0.96-2.1-1.36-3.06c-0.17-0.44-0.35-0.91-0.52-1.31c-0.07-0.22-0.12-0.39-0.2-0.59c-3.23-10.25-1.06-12.3,0.3-15.46
c1.41-3.23,6.68-5.89-1.11-8.58c-0.67-0.25-1.5-0.39-2.44-0.57C500.25,197.72,515.7,196.17,526.75,201.27 M367.62,510.22
c-31.45-20.19-63.99-49.15-78.22-65.18c-2.39-1.8-2-9.79-2-9.79c12.84,9.98,66.11,48.04,122.44,65.42
c19.87,6.14,50.36,8.46,76.81-6.53c20.21-11.46,44.54-31.43,59.06-52.01l2.66,4.61c-0.1,3.06-6.78,17.97-10.18,23.96
c6.14,3.53,10.72,4.49,17.55,6.36l46.64-7.27c16.74-27.04,28.74-70.65,15.95-112.16c-7.3-23.81-45.36-71.22-48.09-73.83
c-9.56-9.19,1.6-44.69-17.35-83.42C532.86,159.41,480.67,116.69,458,98.1c6.68,4.88,47.82,21.47,67,44.62
c1.8-2.39,2.54-14.82,4.19-17.97c-16.47-21.57-17.75-59.95-17.75-70.21c0-18.81-9.56-40.13-9.56-40.13s16.47,13.04,20.73,35.5
c5.03,26.6,15.75,47.55,29.93,65.28c26.84,33.43,51.08,50.58,63.33,38.23C630.53,138.58,601,72.2,563.28,35.15
C519.25-8.09,507.74-2.52,481.91,6.7c-20.61,7.35-31.75,65.87-85.47,64.71c-9.1-1.06-32.54-1.63-44.13-1.53
c6.04-8.43,11.22-14.94,11.22-14.94s-18.02,7.25-33.38,16.44l-1.18-1.77c5.18-10.92,10.75-17.82,10.75-17.82s-14.4,8.65-27.54,19.01
c2.39-13.02,11.44-21.27,11.44-21.27s-18.19,3.28-41.36,28.77c-26.33,7.2-32.66,11.93-53.64,21.22
c-34.12-7.44-50.21-19.45-65.55-41.56c-11.68-16.89-32.47-19.45-53.71-10.72c-30.97,12.8-70.14,30.33-70.14,30.33
s12.77-0.52,26.08,0.05c-18.22,6.9-35.72,16.39-35.72,16.39s8.53-0.3,19.06-0.12c-7.27,6.04-11.29,8.92-18.22,13.51
c-16.66,12.1-30.17,26.08-30.17,26.08s11.31-5.15,21.47-8.04c-7.1,16.27-21.18,28.25-18.59,48.17
c2.49,18.19,24.82,55.66,53.64,78.66c2.49,2,41.86,38.43,71.56,23.47c29.68-14.94,41.39-28.25,46.27-48.66
c5.74-23.44,2.47-41.17-9.79-92.05c-4.04-16.79-14.57-51.37-19.65-67.91l1.13-0.81c9.71,20.49,34.56,74.5,44.57,110.78
c15.63,56.57,10.75,85.27,3.6,95.79c-21.57,31.73-76.84,35.92-101.98,18.34c-3.85,60.91,9.76,87.73,14.37,101.24
c-2.29,15.53,7.77,44.37,7.77,44.37s1.13-13.11,5.74-20.02c1.23,15.41,9,33.72,9,33.72s-0.47-11.31,3.06-21.08
c4.98,8.43,8.63,10.43,13.34,16.76c4.71,16.47,14.15,28.5,14.15,28.5s-1.53-8.83-0.69-18.02c23.05,22.14,27.02,54.45,29.31,79.28
c6.46,68.26-107.63,122.54-129.74,165.24c-16.76,25.29-26.8,65.3,1.58,88.89c68.6,56.97,42.25,72.65,76.59,97.69
c47.11,34.34,106.05,18.96,126.11-8.97c27.93-38.92,20.76-75.63,10.38-109.97c-8.11-26.85-30.15-71.46-57.41-88.72
c-27.86-17.65-54.95-20.95-77.9-18.59l2.12-2.44c33.01-6.56,67.52-2.96,92.49,13.14c28.35,18.22,54.28,49.47,67.84,97.37
c15.38-2.19,17.55-3.18,31.63-5.18l-31.7-246.76L367.62,510.22z M385.94,819.52l-3.65-34.22l71.29-108.74l80.93,23.64l69.59-116.23
L687.52,639l63.38-132.92l22.53,242.07L385.94,819.52z M774.27,456.51l-254.72,46.17c-6.31,8.13-21.91,22.41-29.41,26.13
c-32.17,16.2-53.91,11.51-72.7,6.63c-12.08-3.06-19.08-4.78-29.11-9.29l-62.17,8.53l37.74,314.87l436.35-78.66L774.27,456.51z"/>
</svg>

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -0,0 +1,70 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 21.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
viewBox="0 0 142.5 145.6" style="enable-background:new 0 0 142.5 145.6;" xml:space="preserve">
<style type="text/css">
.st0{fill:#565656;}
.st1{fill:url(#SVGID_1_);}
</style>
<g>
<path class="st0" d="M28.7,131.5c-0.3,7.9-6.6,14.1-14.4,14.1C6.1,145.6,0,139,0,130.9s6.6-14.7,14.7-14.7c3.6,0,7.2,1.6,10.2,4.4
l-2.3,2.9c-2.3-2-5.1-3.4-7.9-3.4c-5.9,0-10.8,4.8-10.8,10.8c0,6.1,4.6,10.8,10.4,10.8c5.2,0,9.3-3.8,10.2-8.8H12.6v-3.5h16.1
V131.5z"/>
<path class="st0" d="M42.3,129.5h-2.2c-2.4,0-4.4,2-4.4,4.4v11.4h-3.9v-19.6H35v1.6c1.1-1.1,2.7-1.6,4.6-1.6h4.2L42.3,129.5z"/>
<path class="st0" d="M63.7,145.3h-3.4v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4V145.3z M59.7,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C57.1,141.2,59.1,139.3,59.7,137z"/>
<path class="st0" d="M71.5,124.7v1.1h6.2v3.4h-6.2v16.1h-3.8v-20.5c0-4.3,3.1-6.8,7-6.8h4.7l-1.6,3.7h-3.1
C72.9,121.6,71.5,123,71.5,124.7z"/>
<path class="st0" d="M98.5,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H98.5z M94.5,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C92,141.2,93.9,139.3,94.5,137z"/>
<path class="st0" d="M119.4,133.8v11.5h-3.9v-11.6c0-2.4-2-4.4-4.4-4.4c-2.5,0-4.4,2-4.4,4.4v11.6h-3.9v-19.6h3.2v1.7
c1.4-1.3,3.3-2,5.2-2C115.8,125.5,119.4,129.2,119.4,133.8z"/>
<path class="st0" d="M142.4,145.3h-3.3v-2.5c-2.6,2.5-6.6,3.7-10.7,1.9c-3-1.3-5.3-4.1-5.9-7.4c-1.2-6.3,3.7-11.9,9.9-11.9
c2.6,0,5,1.1,6.7,2.8v-2.5h3.4v19.6H142.4z M138.4,137c0.9-4-2.1-7.6-6-7.6c-3.4,0-6.1,2.8-6.1,6.1c0,3.8,3.3,6.7,7.2,6.1
C135.9,141.2,137.8,139.3,138.4,137z"/>
</g>
<linearGradient id="SVGID_1_" gradientUnits="userSpaceOnUse" x1="71.25" y1="10.4893" x2="71.25" y2="113.3415" gradientTransform="matrix(1 0 0 -1 0 148.6)">
<stop offset="0" style="stop-color:#FCEE1F"/>
<stop offset="1" style="stop-color:#F15B2A"/>
</linearGradient>
<path class="st1" d="M122.9,49.9c-0.2-1.9-0.5-4.1-1.1-6.5c-0.6-2.4-1.6-5-2.9-7.8c-1.4-2.7-3.1-5.6-5.4-8.3
c-0.9-1.1-1.9-2.1-2.9-3.2c1.6-6.3-1.9-11.8-1.9-11.8c-6.1-0.4-9.9,1.9-11.3,2.9c-0.2-0.1-0.5-0.2-0.7-0.3c-1-0.4-2.1-0.8-3.2-1.2
c-1.1-0.3-2.2-0.7-3.3-0.9c-1.1-0.3-2.3-0.5-3.5-0.7c-0.2,0-0.4-0.1-0.6-0.1C83.5,3.6,75.9,0,75.9,0c-8.7,5.6-10.4,13.1-10.4,13.1
s0,0.2-0.1,0.4c-0.5,0.1-0.9,0.3-1.4,0.4c-0.6,0.2-1.3,0.4-1.9,0.7c-0.6,0.3-1.3,0.5-1.9,0.8c-1.3,0.6-2.5,1.2-3.8,1.9
c-1.2,0.7-2.4,1.4-3.5,2.2c-0.2-0.1-0.3-0.2-0.3-0.2c-11.7-4.5-22.1,0.9-22.1,0.9c-0.9,12.5,4.7,20.3,5.8,21.7
c-0.3,0.8-0.5,1.5-0.8,2.3c-0.9,2.8-1.5,5.7-1.9,8.7c-0.1,0.4-0.1,0.9-0.2,1.3c-10.8,5.3-14,16.3-14,16.3c9,10.4,19.6,11,19.6,11
l0,0c1.3,2.4,2.9,4.7,4.6,6.8c0.7,0.9,1.5,1.7,2.3,2.6c-3.3,9.4,0.5,17.3,0.5,17.3c10.1,0.4,16.7-4.4,18.1-5.5c1,0.3,2,0.6,3,0.9
c3.1,0.8,6.3,1.3,9.4,1.4c0.8,0,1.6,0,2.4,0h0.4H80h0.5H81l0,0c4.7,6.8,13.1,7.7,13.1,7.7c5.9-6.3,6.3-12.4,6.3-13.8l0,0
c0,0,0,0,0-0.1s0-0.2,0-0.2l0,0c0-0.1,0-0.2,0-0.3c1.2-0.9,2.4-1.8,3.6-2.8c2.4-2.1,4.4-4.6,6.2-7.2c0.2-0.2,0.3-0.5,0.5-0.7
c6.7,0.4,11.4-4.2,11.4-4.2c-1.1-7-5.1-10.4-5.9-11l0,0c0,0,0,0-0.1-0.1l-0.1-0.1l0,0l-0.1-0.1c0-0.4,0.1-0.8,0.1-1.3
c0.1-0.8,0.1-1.5,0.1-2.3v-0.6v-0.3v-0.1c0-0.2,0-0.1,0-0.2v-0.5v-0.6c0-0.2,0-0.4,0-0.6s0-0.4-0.1-0.6l-0.1-0.6l-0.1-0.6
c-0.1-0.8-0.3-1.5-0.4-2.3c-0.7-3-1.9-5.9-3.4-8.4c-1.6-2.6-3.5-4.8-5.7-6.8c-2.2-1.9-4.6-3.5-7.2-4.6c-2.6-1.2-5.2-1.9-7.9-2.2
c-1.3-0.2-2.7-0.2-4-0.2h-0.5h-0.1h-0.2h-0.2h-0.5c-0.2,0-0.4,0-0.5,0c-0.7,0.1-1.4,0.2-2,0.3c-2.7,0.5-5.2,1.5-7.4,2.8
c-2.2,1.3-4.1,3-5.7,4.9s-2.8,3.9-3.6,6.1c-0.8,2.1-1.3,4.4-1.4,6.5c0,0.5,0,1.1,0,1.6c0,0.1,0,0.3,0,0.4v0.4c0,0.3,0,0.5,0.1,0.8
c0.1,1.1,0.3,2.1,0.6,3.1c0.6,2,1.5,3.8,2.7,5.4s2.5,2.8,4,3.8s3,1.7,4.6,2.2c1.6,0.5,3.1,0.7,4.5,0.6c0.2,0,0.4,0,0.5,0
c0.1,0,0.2,0,0.3,0s0.2,0,0.3,0c0.2,0,0.3,0,0.5,0h0.1h0.1c0.1,0,0.2,0,0.3,0c0.2,0,0.4-0.1,0.5-0.1c0.2,0,0.3-0.1,0.5-0.1
c0.3-0.1,0.7-0.2,1-0.3c0.6-0.2,1.2-0.5,1.8-0.7c0.6-0.3,1.1-0.6,1.5-0.9c0.1-0.1,0.3-0.2,0.4-0.3c0.5-0.4,0.6-1.1,0.2-1.6
c-0.4-0.4-1-0.5-1.5-0.3C88,74,87.9,74,87.7,74.1c-0.4,0.2-0.9,0.4-1.3,0.5c-0.5,0.1-1,0.3-1.5,0.4c-0.3,0-0.5,0.1-0.8,0.1
c-0.1,0-0.3,0-0.4,0c-0.1,0-0.3,0-0.4,0s-0.3,0-0.4,0c-0.2,0-0.3,0-0.5,0c0,0-0.1,0,0,0h-0.1h-0.1c-0.1,0-0.1,0-0.2,0
s-0.3,0-0.4-0.1c-1.1-0.2-2.3-0.5-3.4-1c-1.1-0.5-2.2-1.2-3.1-2.1c-1-0.9-1.8-1.9-2.5-3.1c-0.7-1.2-1.1-2.5-1.3-3.8
c-0.1-0.7-0.2-1.4-0.1-2.1c0-0.2,0-0.4,0-0.6c0,0.1,0,0,0,0v-0.1v-0.1c0-0.1,0-0.2,0-0.3c0-0.4,0.1-0.7,0.2-1.1c0.5-3,2-5.9,4.3-8.1
c0.6-0.6,1.2-1.1,1.9-1.5c0.7-0.5,1.4-0.9,2.1-1.2c0.7-0.3,1.5-0.6,2.3-0.8s1.6-0.4,2.4-0.4c0.4,0,0.8-0.1,1.2-0.1
c0.1,0,0.2,0,0.3,0h0.3h0.2c0.1,0,0,0,0,0h0.1h0.3c0.9,0.1,1.8,0.2,2.6,0.4c1.7,0.4,3.4,1,5,1.9c3.2,1.8,5.9,4.5,7.5,7.8
c0.8,1.6,1.4,3.4,1.7,5.3c0.1,0.5,0.1,0.9,0.2,1.4v0.3V66c0,0.1,0,0.2,0,0.3c0,0.1,0,0.2,0,0.3v0.3v0.3c0,0.2,0,0.6,0,0.8
c0,0.5-0.1,1-0.1,1.5c-0.1,0.5-0.1,1-0.2,1.5s-0.2,1-0.3,1.5c-0.2,1-0.6,1.9-0.9,2.9c-0.7,1.9-1.7,3.7-2.9,5.3
c-2.4,3.3-5.7,6-9.4,7.7c-1.9,0.8-3.8,1.5-5.8,1.8c-1,0.2-2,0.3-3,0.3H81h-0.2h-0.3H80h-0.3c0.1,0,0,0,0,0h-0.1
c-0.5,0-1.1,0-1.6-0.1c-2.2-0.2-4.3-0.6-6.4-1.2c-2.1-0.6-4.1-1.4-6-2.4c-3.8-2-7.2-4.9-9.9-8.2c-1.3-1.7-2.5-3.5-3.5-5.4
s-1.7-3.9-2.3-5.9c-0.6-2-0.9-4.1-1-6.2v-0.4v-0.1v-0.1v-0.2V60v-0.1v-0.1v-0.2v-0.5V59l0,0v-0.2c0-0.3,0-0.5,0-0.8
c0-1,0.1-2.1,0.3-3.2c0.1-1.1,0.3-2.1,0.5-3.2c0.2-1.1,0.5-2.1,0.8-3.2c0.6-2.1,1.3-4.1,2.2-6c1.8-3.8,4.1-7.2,6.8-9.9
c0.7-0.7,1.4-1.3,2.2-1.9c0.3-0.3,1-0.9,1.8-1.4c0.8-0.5,1.6-1,2.5-1.4c0.4-0.2,0.8-0.4,1.3-0.6c0.2-0.1,0.4-0.2,0.7-0.3
c0.2-0.1,0.4-0.2,0.7-0.3c0.9-0.4,1.8-0.7,2.7-1c0.2-0.1,0.5-0.1,0.7-0.2c0.2-0.1,0.5-0.1,0.7-0.2c0.5-0.1,0.9-0.2,1.4-0.4
c0.2-0.1,0.5-0.1,0.7-0.2c0.2,0,0.5-0.1,0.7-0.1c0.2,0,0.5-0.1,0.7-0.1l0.4-0.1l0.4-0.1c0.2,0,0.5-0.1,0.7-0.1
c0.3,0,0.5-0.1,0.8-0.1c0.2,0,0.6-0.1,0.8-0.1c0.2,0,0.3,0,0.5-0.1h0.3h0.2h0.2c0.3,0,0.5,0,0.8-0.1h0.4c0,0,0.1,0,0,0h0.1h0.2
c0.2,0,0.5,0,0.7,0c0.9,0,1.8,0,2.7,0c1.8,0.1,3.6,0.3,5.3,0.6c3.4,0.6,6.7,1.7,9.6,3.2c2.9,1.4,5.6,3.2,7.8,5.1
c0.1,0.1,0.3,0.2,0.4,0.4c0.1,0.1,0.3,0.2,0.4,0.4c0.3,0.2,0.5,0.5,0.8,0.7c0.3,0.2,0.5,0.5,0.8,0.7c0.2,0.3,0.5,0.5,0.7,0.8
c1,1,1.9,2.1,2.7,3.1c1.6,2.1,2.9,4.2,3.9,6.2c0.1,0.1,0.1,0.2,0.2,0.4c0.1,0.1,0.1,0.2,0.2,0.4s0.2,0.5,0.4,0.7
c0.1,0.2,0.2,0.5,0.3,0.7c0.1,0.2,0.2,0.5,0.3,0.7c0.4,0.9,0.7,1.8,1,2.7c0.5,1.4,0.8,2.6,1.1,3.6c0.1,0.4,0.5,0.7,0.9,0.7
c0.5,0,0.8-0.4,0.8-0.9C123,52.7,123,51.4,122.9,49.9z"/>
</svg>

After

Width:  |  Height:  |  Size: 6.6 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="192" height="192" fill="none" viewBox="0 0 192 192"><rect width="192" height="192" fill="url(#paint0_linear_1452_5317)" rx="24"/><path fill="#F2F2F2" d="M123.34 68.6596C119.655 41.0484 110.327 18 96 18C81.6731 18 72.3454 41.0484 68.6596 68.6596C41.0484 72.3454 18 81.6731 18 96C18 110.327 41.0525 119.655 68.6596 123.34C72.3454 150.948 81.6731 174 96 174C110.327 174 119.655 150.948 123.34 123.34C150.952 119.655 174 110.327 174 96C174 81.6731 150.948 72.3454 123.34 68.6596ZM67.7583 115.298C41.3151 111.479 25.893 102.737 25.893 96C25.893 89.2629 41.3151 80.5212 67.7583 76.7021C67.1764 83.0674 66.8733 89.566 66.8733 96C66.8733 102.434 67.1764 108.937 67.7583 115.298ZM96 25.893C102.737 25.893 111.479 41.3151 115.298 67.7583C108.937 67.1764 102.434 66.8733 96 66.8733C89.566 66.8733 83.0633 67.1764 76.7021 67.7583C80.5212 41.3151 89.2629 25.893 96 25.893ZM124.242 115.298C122.94 115.488 117.602 116.114 116.252 116.248C116.118 117.602 115.488 122.936 115.302 124.238C111.483 150.681 102.741 166.103 96.0041 166.103C89.267 166.103 80.5253 150.681 76.7061 124.238C76.5202 122.936 75.8898 117.598 75.7564 116.248C75.1421 109.979 74.7703 103.246 74.7703 96C74.7703 88.7537 75.1421 82.0206 75.7564 75.7483C82.0247 75.134 88.7577 74.7622 96.0041 74.7622C103.25 74.7622 109.983 75.134 116.252 75.7483C117.606 75.8817 122.94 76.5121 124.242 76.698C150.685 80.5172 166.111 89.2629 166.111 95.996C166.111 102.729 150.685 111.479 124.242 115.298Z"/><defs><linearGradient id="paint0_linear_1452_5317" x1="183" x2="0" y1="192" y2="0" gradientUnits="userSpaceOnUse"><stop stop-color="#444CE7"/><stop offset="1" stop-color="#B664FF"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -60,6 +60,8 @@ function App(): JSX.Element {
const { isCloudUser, isEnterpriseSelfHostedUser } = useGetTenantLicense();
const [isSentryInitialized, setIsSentryInitialized] = useState(false);
const enableAnalytics = useCallback(
(user: IUser): void => {
// wait for the required data to be loaded before doing init for anything!
@@ -293,25 +295,29 @@ function App(): JSX.Element {
Userpilot.initialize(process.env.USERPILOT_KEY);
}
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
environment: 'production',
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: [],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
if (!isSentryInitialized) {
Sentry.init({
dsn: process.env.SENTRY_DSN,
tunnel: process.env.TUNNEL_URL,
environment: 'production',
integrations: [
Sentry.browserTracingIntegration(),
Sentry.replayIntegration({
maskAllText: false,
blockAllMedia: false,
}),
],
// Performance Monitoring
tracesSampleRate: 1.0, // Capture 100% of the transactions
// Set 'tracePropagationTargets' to control for which URLs distributed tracing should be enabled
tracePropagationTargets: [],
// Session Replay
replaysSessionSampleRate: 0.1, // This sets the sample rate at 10%. You may want to change it to 100% while in development and then sample at a lower rate in production.
replaysOnErrorSampleRate: 1.0, // If you're not already sampling the entire session, change the sample rate to 100% when sampling sessions where errors occur.
});
setIsSentryInitialized(true);
}
} else {
posthog.reset();
Sentry.close();
@@ -320,6 +326,7 @@ function App(): JSX.Element {
window.cioanalytics.reset();
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [isCloudUser, isEnterpriseSelfHostedUser]);
// if the user is in logged in state

View File

@@ -47,9 +47,10 @@ export const TracesFunnels = Loadable(
import(/* webpackChunkName: "Traces Funnels" */ 'pages/TracesModulePage'),
);
export const TracesFunnelDetails = Loadable(
// eslint-disable-next-line sonarjs/no-identical-functions
() =>
import(
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesFunnelDetails'
/* webpackChunkName: "Traces Funnel Details" */ 'pages/TracesModulePage'
),
);

View File

@@ -531,6 +531,7 @@ export const oldRoutes = [
'/traces-save-views',
'/settings/access-tokens',
'/messaging-queues',
'/alerts/edit',
];
export const oldNewRoutesMapping: Record<string, string> = {
@@ -541,6 +542,7 @@ export const oldNewRoutesMapping: Record<string, string> = {
'/traces-save-views': '/traces/saved-views',
'/settings/access-tokens': '/settings/api-keys',
'/messaging-queues': '/messaging-queues/overview',
'/alerts/edit': '/alerts/overview',
};
export const ROUTES_NOT_TO_BE_OVERRIDEN: string[] = [

View File

@@ -0,0 +1,46 @@
import { AxiosError } from 'axios';
import { ErrorV2 } from 'types/api';
import APIError from 'types/api/error';
// reference - https://axios-http.com/docs/handling_errors
export function ErrorResponseHandlerV2(error: AxiosError<ErrorV2>): never {
const { response, request } = error;
// The request was made and the server responded with a status code
// that falls out of the range of 2xx
if (response) {
throw new APIError({
httpStatusCode: response.status || 500,
error: {
code: response.data.code,
message: response.data.message,
url: response.data.url,
errors: response.data.errors,
},
});
}
// The request was made but no response was received
// `error.request` is an instance of XMLHttpRequest in the browser and an instance of
// http.ClientRequest in node.js
if (request) {
throw new APIError({
httpStatusCode: error.status || 500,
error: {
code: error.code || error.name,
message: error.message,
url: '',
errors: [],
},
});
}
// Something happened in setting up the request that triggered an Error
throw new APIError({
httpStatusCode: error.status || 500,
error: {
code: error.name,
message: error.message,
url: '',
errors: [],
},
});
}

View File

@@ -0,0 +1,26 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2, SuccessResponseV2 } from 'types/api';
import { PayloadProps, Props } from 'types/api/user/login';
const login = async (
props: Props,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.post<PayloadProps>(`/login`, {
...props,
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2>);
// this line is never reached but ts isn't detecting the never type properly for the ErrorResponseHandlerV2
throw error;
}
};
export default login;

View File

@@ -0,0 +1,54 @@
import axios from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
export interface InspectMetricsRequest {
metricName: string;
start: number;
end: number;
filters: TagFilter;
}
export interface InspectMetricsResponse {
status: string;
data: {
series: InspectMetricsSeries[];
};
}
export interface InspectMetricsSeries {
title?: string;
strokeColor?: string;
labels: Record<string, string>;
labelsArray: Array<Record<string, string>>;
values: InspectMetricsTimestampValue[];
}
interface InspectMetricsTimestampValue {
timestamp: number;
value: string;
}
export const getInspectMetricsDetails = async (
request: InspectMetricsRequest,
signal?: AbortSignal,
headers?: Record<string, string>,
): Promise<SuccessResponse<InspectMetricsResponse> | ErrorResponse> => {
try {
const response = await axios.post(`/metrics/inspect`, request, {
signal,
headers,
});
return {
statusCode: 200,
error: null,
message: 'Success',
payload: response.data,
};
} catch (error) {
return ErrorResponseHandler(error as AxiosError);
}
};

View File

@@ -5,6 +5,7 @@ import {
CreateFunnelPayload,
CreateFunnelResponse,
FunnelData,
FunnelStepData,
} from 'types/api/traceFunnels';
const FUNNELS_BASE_PATH = '/trace-funnels';
@@ -13,7 +14,7 @@ export const createFunnel = async (
payload: CreateFunnelPayload,
): Promise<SuccessResponse<CreateFunnelResponse> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
`${FUNNELS_BASE_PATH}/new-funnel`,
`${FUNNELS_BASE_PATH}/new`,
payload,
);
@@ -25,60 +26,45 @@ export const createFunnel = async (
};
};
interface GetFunnelsListParams {
search?: string;
}
export const getFunnelsList = async ({
search = '',
}: GetFunnelsListParams = {}): Promise<
export const getFunnelsList = async (): Promise<
SuccessResponse<FunnelData[]> | ErrorResponse
> => {
const params = new URLSearchParams();
if (search.length) {
params.set('search', search);
}
const response: AxiosResponse = await axios.get(
`${FUNNELS_BASE_PATH}/list${
params.toString() ? `?${params.toString()}` : ''
}`,
);
const response: AxiosResponse = await axios.get(`${FUNNELS_BASE_PATH}/list`);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
payload: response.data.data,
};
};
export const getFunnelById = async (
funnelId: string,
funnelId?: string,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.get(
`${FUNNELS_BASE_PATH}/get/${funnelId}`,
`${FUNNELS_BASE_PATH}/${funnelId}`,
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
payload: response.data.data,
};
};
interface RenameFunnelPayload {
id: string;
export interface RenameFunnelPayload {
funnel_id: string;
funnel_name: string;
timestamp: number;
}
export const renameFunnel = async (
payload: RenameFunnelPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.put(
`${FUNNELS_BASE_PATH}/${payload.id}/update`,
{ funnel_name: payload.funnel_name },
`${FUNNELS_BASE_PATH}/${payload.funnel_id}`,
payload,
);
return {
@@ -97,7 +83,7 @@ export const deleteFunnel = async (
payload: DeleteFunnelPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.delete(
`${FUNNELS_BASE_PATH}/delete/${payload.id}`,
`${FUNNELS_BASE_PATH}/${payload.id}`,
);
return {
@@ -107,3 +93,268 @@ export const deleteFunnel = async (
payload: response.data,
};
};
export interface UpdateFunnelStepsPayload {
funnel_id: string;
steps: FunnelStepData[];
timestamp: number;
}
export const updateFunnelSteps = async (
payload: UpdateFunnelStepsPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.put(
`${FUNNELS_BASE_PATH}/steps/update`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel steps updated successfully',
payload: response.data.data,
};
};
export interface ValidateFunnelPayload {
start_time: number;
end_time: number;
}
export interface ValidateFunnelResponse {
status: string;
data: Array<{
timestamp: string;
data: {
trace_id: string;
};
}> | null;
}
export const validateFunnelSteps = async (
funnelId: string,
payload: ValidateFunnelPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ValidateFunnelResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/validate`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface UpdateFunnelStepDetailsPayload {
funnel_id: string;
steps: Array<{
step_name: string;
description: string;
}>;
updated_at: number;
}
interface UpdateFunnelDescriptionPayload {
funnel_id: string;
description: string;
}
export const saveFunnelDescription = async (
payload: UpdateFunnelDescriptionPayload,
): Promise<SuccessResponse<FunnelData> | ErrorResponse> => {
const response: AxiosResponse = await axios.post(
`${FUNNELS_BASE_PATH}/save`,
payload,
);
return {
statusCode: 200,
error: null,
message: 'Funnel description updated successfully',
payload: response.data,
};
};
export interface FunnelOverviewPayload {
start_time: number;
end_time: number;
step_start?: number;
step_end?: number;
}
export interface FunnelOverviewResponse {
status: string;
data: Array<{
timestamp: string;
data: {
avg_duration: number;
avg_rate: number;
conversion_rate: number | null;
errors: number;
p99_latency: number;
};
}>;
}
export const getFunnelOverview = async (
funnelId: string,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/overview`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface SlowTraceData {
status: string;
data: Array<{
timestamp: string;
data: {
duration_ms: string;
span_count: number;
trace_id: string;
};
}>;
}
export const getFunnelSlowTraces = async (
funnelId: string,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<SlowTraceData> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/slow-traces`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface ErrorTraceData {
status: string;
data: Array<{
timestamp: string;
data: {
duration_ms: string;
span_count: number;
trace_id: string;
};
}>;
}
export const getFunnelErrorTraces = async (
funnelId: string,
payload: FunnelOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<ErrorTraceData> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/error-traces`,
payload,
{
signal,
},
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface FunnelStepsPayload {
start_time: number;
end_time: number;
}
export interface FunnelStepGraphMetrics {
[key: `total_s${number}_spans`]: number;
[key: `total_s${number}_errored_spans`]: number;
}
export interface FunnelStepsResponse {
status: string;
data: Array<{
timestamp: string;
data: FunnelStepGraphMetrics;
}>;
}
export const getFunnelSteps = async (
funnelId: string,
payload: FunnelStepsPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};
export interface FunnelStepsOverviewPayload {
start_time: number;
end_time: number;
step_start?: number;
step_end?: number;
}
export interface FunnelStepsOverviewResponse {
status: string;
data: Array<{
timestamp: string;
data: Record<string, unknown>;
}>;
}
export const getFunnelStepsOverview = async (
funnelId: string,
payload: FunnelStepsOverviewPayload,
signal?: AbortSignal,
): Promise<SuccessResponse<FunnelStepsOverviewResponse> | ErrorResponse> => {
const response = await axios.post(
`${FUNNELS_BASE_PATH}/${funnelId}/analytics/steps/overview`,
payload,
{ signal },
);
return {
statusCode: 200,
error: null,
message: '',
payload: response.data,
};
};

View File

@@ -9,18 +9,27 @@ import { useCeleryFilterOptions } from 'components/CeleryTask/useCeleryFilterOpt
import { SelectMaxTagPlaceholder } from 'components/MessagingQueues/MQCommon/MQCommon';
import { QueryParams } from 'constants/query';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback, useMemo, useState } from 'react';
import { useHistory, useLocation } from 'react-router-dom';
interface SelectOptionConfig {
export interface SelectOptionConfig {
placeholder: string;
queryParam: QueryParams;
filterType: string | string[];
shouldSetQueryParams?: boolean;
onChange?: (value: string | string[]) => void;
values?: string | string[];
isMultiple?: boolean;
}
function FilterSelect({
export function FilterSelect({
placeholder,
queryParam,
filterType,
values,
shouldSetQueryParams,
onChange,
isMultiple,
}: SelectOptionConfig): JSX.Element {
const { handleSearch, isFetching, options } = useCeleryFilterOptions(
filterType,
@@ -30,20 +39,73 @@ function FilterSelect({
const history = useHistory();
const location = useLocation();
// Add state to track the current search input
const [searchValue, setSearchValue] = useState<string>('');
// Use externally provided `values` if `shouldSetQueryParams` is false, otherwise get from URL params.
const selectValue =
!shouldSetQueryParams && !!values?.length
? values
: getValuesFromQueryParams(queryParam, urlQuery) || [];
// Memoize options to include the typed value if not present
const mergedOptions = useMemo(() => {
if (
!!searchValue.trim().length &&
!options.some((opt) => opt.value === searchValue)
) {
return [{ value: searchValue, label: searchValue }, ...options];
}
return options;
}, [options, searchValue]);
const handleSelectChange = useCallback(
(value: string | string[]): void => {
handleSearch('');
setSearchValue(''); // Clear search value after selection
if (shouldSetQueryParams) {
setQueryParamsFromOptions(
value as string[],
urlQuery,
history,
location,
queryParam,
);
}
onChange?.(value);
},
[
handleSearch,
shouldSetQueryParams,
urlQuery,
history,
location,
queryParam,
onChange,
],
);
// Update searchValue on user input
const handleSearchInput = (input: string): void => {
setSearchValue(input);
handleSearch(input);
};
return (
<Select
key={filterType.toString()}
placeholder={placeholder}
showSearch
mode="multiple"
options={options}
// eslint-disable-next-line react/jsx-props-no-spreading
{...(isMultiple ? { mode: 'multiple' } : {})}
options={mergedOptions}
loading={isFetching}
className="config-select-option"
onSearch={handleSearch}
onSearch={handleSearchInput}
maxTagCount={4}
allowClear
maxTagPlaceholder={SelectMaxTagPlaceholder}
value={getValuesFromQueryParams(queryParam, urlQuery) || []}
value={selectValue}
notFoundContent={
isFetching ? (
<span>
@@ -53,14 +115,18 @@ function FilterSelect({
<span>No {placeholder} found</span>
)
}
onChange={(value): void => {
handleSearch('');
setQueryParamsFromOptions(value, urlQuery, history, location, queryParam);
}}
onChange={handleSelectChange}
/>
);
}
FilterSelect.defaultProps = {
shouldSetQueryParams: true,
onChange: (): void => {},
values: [],
isMultiple: true,
};
function CeleryOverviewConfigOptions(): JSX.Element {
const selectConfigs: SelectOptionConfig[] = [
{

View File

@@ -0,0 +1,40 @@
.change-percentage-pill {
display: flex;
align-items: center;
gap: 2px;
padding: 1px 4px;
border-radius: 50px;
&__icon {
display: flex;
align-items: center;
justify-content: center;
}
&__label {
font-family: 'Geist Mono';
font-size: 12px;
line-height: normal;
}
&--positive {
.change-percentage-pill {
&__icon {
color: var(--bg-forest-500);
}
&__label {
color: var(--bg-forest-500);
}
}
}
&--negative {
background: rgba(229, 72, 77, 0.1);
.change-percentage-pill {
&__icon {
color: var(--bg-cherry-500);
}
&__label {
color: var(--bg-cherry-500);
}
}
}
}

View File

@@ -0,0 +1,38 @@
import './ChangePercentagePill.styles.scss';
import { Color } from '@signozhq/design-tokens';
import cx from 'classnames';
import { ArrowDown, ArrowUp } from 'lucide-react';
interface ChangePercentagePillProps {
percentage: number;
direction: number;
}
function ChangePercentagePill({
percentage,
direction,
}: ChangePercentagePillProps): JSX.Element | null {
if (direction === 0 || percentage === 0) {
return null;
}
const isPositive = direction > 0;
return (
<div
className={cx('change-percentage-pill', {
'change-percentage-pill--positive': isPositive,
'change-percentage-pill--negative': !isPositive,
})}
>
<div className="change-percentage-pill__icon">
{isPositive ? (
<ArrowUp size={12} color={Color.BG_FOREST_500} />
) : (
<ArrowDown size={12} color={Color.BG_CHERRY_500} />
)}
</div>
<div className="change-percentage-pill__label">{percentage}%</div>
</div>
);
}
export default ChangePercentagePill;

View File

@@ -4,14 +4,19 @@ export const getYAxisFormattedValue = (
value: string,
format: string,
): string => {
let decimalPrecision: number | undefined;
const parsedValue = getValueFormat(format)(
parseFloat(value),
undefined,
undefined,
undefined,
);
try {
const parsedValue = getValueFormat(format)(
parseFloat(value),
undefined,
undefined,
undefined,
);
if (!parsedValue?.text) {
return `${parseFloat(value)}`;
}
let decimalPrecision: number | undefined;
const decimalSplitted = parsedValue.text.split('.');
if (decimalSplitted.length === 1) {
decimalPrecision = 0;
@@ -41,9 +46,9 @@ export const getYAxisFormattedValue = (
),
);
} catch (error) {
console.error(error);
console.error('Error in getYAxisFormattedValue:', error);
return `${parseFloat(value)}`;
}
return `${parseFloat(value)}`;
};
export const getToolTipValue = (value: string, format?: string): string => {

View File

@@ -3,6 +3,7 @@
flex-direction: column;
height: 100%;
border-right: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
.header {
display: flex;
@@ -74,6 +75,7 @@
.quick-filters {
background-color: var(--bg-vanilla-100);
border-right: 1px solid var(--bg-vanilla-300);
color: var(--bg-ink-200);
.header {
border-bottom: 1px solid var(--bg-vanilla-300);

View File

@@ -1,6 +1,9 @@
import React from 'react';
import styled from 'styled-components';
export const SpanStyle = styled.span`
type SpanProps = React.HTMLAttributes<HTMLSpanElement>;
export const SpanStyle = styled.span<SpanProps>`
position: absolute;
right: -0.313rem;
bottom: 0;
@@ -12,7 +15,7 @@ export const SpanStyle = styled.span`
margin-right: 4px;
`;
export const DragSpanStyle = styled.span`
export const DragSpanStyle = styled.span<SpanProps>`
display: flex;
margin: -1rem;
padding: 1rem;

View File

@@ -0,0 +1,55 @@
.signoz-radio-group.ant-radio-group {
color: var(--text-vanilla-400);
.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(--bg-slate-400);
&:hover {
color: var(--text-vanilla-100);
}
&::before {
background: var(--bg-slate-400);
}
}
.selected_view {
&,
&:hover {
background: var(--bg-slate-300);
color: var(--text-vanilla-100);
border: 1px solid var(--bg-slate-400);
}
&::before {
background: var(--bg-slate-400);
}
}
}
// Light mode styles
.lightMode {
.signoz-radio-group {
.tab {
background: var(--bg-vanilla-100);
}
.selected_view {
background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--text-ink-400);
}
.selected_view::before {
background: var(--bg-vanilla-300);
border-left: 1px solid var(--bg-slate-300);
}
}
}

View File

@@ -0,0 +1,48 @@
import './SignozRadioGroup.styles.scss';
import { Radio } from 'antd';
import { RadioChangeEvent } from 'antd/es/radio';
interface Option {
value: string;
label: string;
}
interface SignozRadioGroupProps {
value: string;
options: Option[];
onChange: (e: RadioChangeEvent) => void;
className?: string;
}
function SignozRadioGroup({
value,
options,
onChange,
className = '',
}: SignozRadioGroupProps): JSX.Element {
return (
<Radio.Group
value={value}
buttonStyle="solid"
className={`signoz-radio-group ${className}`}
onChange={onChange}
>
{options.map((option) => (
<Radio.Button
key={option.value}
value={option.value}
className={value === option.value ? 'selected_view tab' : 'tab'}
>
{option.label}
</Radio.Button>
))}
</Radio.Group>
);
}
SignozRadioGroup.defaultProps = {
className: '',
};
export default SignozRadioGroup;

View File

@@ -8,5 +8,4 @@ export enum FeatureKeys {
PREMIUM_SUPPORT = 'PREMIUM_SUPPORT',
ANOMALY_DETECTION = 'ANOMALY_DETECTION',
ONBOARDING_V3 = 'ONBOARDING_V3',
TRACE_FUNNELS = 'TRACE_FUNNELS',
}

View File

@@ -51,10 +51,13 @@ export const REACT_QUERY_KEY = {
GET_METRICS_LIST_FILTER_VALUES: 'GET_METRICS_LIST_FILTER_VALUES',
GET_METRIC_DETAILS: 'GET_METRIC_DETAILS',
GET_RELATED_METRICS: 'GET_RELATED_METRICS',
GET_INSPECT_METRICS_DETAILS: 'GET_INSPECT_METRICS_DETAILS',
// API Monitoring Query Keys
// Traces Funnels Query Keys
GET_DOMAINS_LIST: 'GET_DOMAINS_LIST',
GET_DOMAIN_METRICS_DATA: 'GET_DOMAIN_METRICS_DATA',
GET_ENDPOINTS_LIST_BY_DOMAIN: 'GET_ENDPOINTS_LIST_BY_DOMAIN',
GET_TOP_ERRORS_BY_DOMAIN: 'GET_TOP_ERRORS_BY_DOMAIN',
GET_NESTED_ENDPOINTS_LIST: 'GET_NESTED_ENDPOINTS_LIST',
GET_ENDPOINT_METRICS_DATA: 'GET_ENDPOINT_METRICS_DATA',
GET_ENDPOINT_STATUS_CODE_DATA: 'GET_ENDPOINT_STATUS_CODE_DATA',
@@ -68,4 +71,12 @@ export const REACT_QUERY_KEY = {
'GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA',
GET_FUNNELS_LIST: 'GET_FUNNELS_LIST',
GET_FUNNEL_DETAILS: 'GET_FUNNEL_DETAILS',
UPDATE_FUNNEL_STEPS: 'UPDATE_FUNNEL_STEPS',
VALIDATE_FUNNEL_STEPS: 'VALIDATE_FUNNEL_STEPS',
UPDATE_FUNNEL_STEP_DETAILS: 'UPDATE_FUNNEL_STEP_DETAILS',
GET_FUNNEL_OVERVIEW: 'GET_FUNNEL_OVERVIEW',
GET_FUNNEL_STEPS_OVERVIEW: 'GET_FUNNEL_STEPS_OVERVIEW',
GET_FUNNEL_SLOW_TRACES: 'GET_FUNNEL_SLOW_TRACES',
GET_FUNNEL_ERROR_TRACES: 'GET_FUNNEL_ERROR_TRACES',
GET_FUNNEL_STEPS_GRAPH_DATA: 'GET_FUNNEL_STEPS_GRAPH_DATA',
} as const;

View File

@@ -27,6 +27,7 @@ import axios, { AxiosError } from 'axios';
import cx from 'classnames';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import dayjs from 'dayjs';
import relativeTime from 'dayjs/plugin/relativeTime';
import { useGetAllAPIKeys } from 'hooks/APIKeys/useGetAllAPIKeys';
import { useNotifications } from 'hooks/useNotifications';
import {
@@ -52,6 +53,8 @@ import { useCopyToClipboard } from 'react-use';
import { APIKeyProps } from 'types/api/pat/types';
import { USER_ROLES } from 'types/roles';
dayjs.extend(relativeTime);
export const showErrorNotification = (
notifications: NotificationInstance,
err: Error,
@@ -495,7 +498,7 @@ function APIKeys(): JSX.Element {
expiresIn <= 3 ? 'danger' : 'warning',
)}
>
<span className="dot" /> Expires in {expiresIn} Days
<span className="dot" /> Expires {dayjs().to(expiresOn)}
</div>
)}

File diff suppressed because it is too large Load Diff

View File

@@ -1,29 +1,17 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Select, Spin, Table, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { Select } from 'antd';
import { initialQueriesMap } from 'constants/queryBuilder';
import {
EndPointsTableRowData,
formatEndPointsDataForTable,
getEndPointsColumnsConfig,
getEndPointsQueryPayload,
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import GridCard from 'container/GridCardLayout/GridCard';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import ErrorState from './components/ErrorState';
import ExpandedRow from './components/ExpandedRow';
import { VIEW_TYPES, VIEWS } from './constants';
import { SPAN_ATTRIBUTES, VIEWS } from './constants';
function AllEndPoints({
domainName,
@@ -31,13 +19,27 @@ function AllEndPoints({
setSelectedView,
groupBy,
setGroupBy,
timeRange,
initialFilters,
setInitialFiltersEndPointStats,
}: {
domainName: string;
setSelectedEndPointName: (name: string) => void;
setSelectedView: (tab: VIEWS) => void;
groupBy: IBuilderQuery['groupBy'];
setGroupBy: (groupBy: IBuilderQuery['groupBy']) => void;
timeRange: {
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
setInitialFiltersEndPointStats: (filters: IBuilderQuery['filters']) => void;
}): JSX.Element {
const [groupBySearchValue, setGroupBySearchValue] = useState<string>('');
const [allAvailableGroupByOptions, setAllAvailableGroupByOptions] = useState<{
[key: string]: any;
}>({});
const {
data: groupByFiltersData,
isLoading: isLoadingGroupByFilters,
@@ -45,7 +47,7 @@ function AllEndPoints({
dataSource: DataSource.TRACES,
aggregateAttribute: '',
aggregateOperator: 'noop',
searchText: '',
searchText: groupBySearchValue,
tagType: '',
});
@@ -53,130 +55,144 @@ function AllEndPoints({
{ value: string; label: string }[]
>([]);
const [expandedRowKeys, setExpandedRowKeys] = useState<React.Key[]>([]);
const handleGroupByChange = useCallback(
(value: IBuilderQuery['groupBy']) => {
const groupBy = [];
const newGroupBy = [];
for (let index = 0; index < value.length; index++) {
const element = (value[index] as unknown) as string;
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
// Check if the key exists in our cached options first
if (allAvailableGroupByOptions[element]) {
newGroupBy.push(allAvailableGroupByOptions[element]);
} else {
// If not found in cache, check the current filtered results
const key = groupByFiltersData?.payload?.attributeKeys?.find(
(key) => key.key === element,
);
if (key) {
groupBy.push(key);
if (key) {
newGroupBy.push(key);
}
}
}
setGroupBy(groupBy);
setGroupBy(newGroupBy);
setGroupBySearchValue('');
},
[groupByFiltersData, setGroupBy],
[groupByFiltersData, setGroupBy, allAvailableGroupByOptions],
);
useEffect(() => {
if (groupByFiltersData?.payload) {
// Update dropdown options
setGroupByOptions(
groupByFiltersData?.payload?.attributeKeys?.map((filter) => ({
value: filter.key,
label: filter.key,
})) || [],
);
// Cache all available options to preserve selected values using functional update
// to avoid dependency on allAvailableGroupByOptions
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupByFiltersData?.payload?.attributeKeys?.forEach((filter) => {
newOptions[filter.key] = filter;
});
return newOptions;
});
}
}, [groupByFiltersData]);
}, [groupByFiltersData]); // Only depends on groupByFiltersData now
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const queryPayloads = useMemo(
() =>
getEndPointsQueryPayload(
groupBy,
domainName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
),
[groupBy, domainName, minTime, maxTime],
);
// Since only one query here
const endPointsDataQueries = useQueries(
queryPayloads.map((payload) => ({
queryKey: [
REACT_QUERY_KEY.GET_ENDPOINTS_LIST_BY_DOMAIN,
payload,
ENTITY_VERSION_V4,
groupBy,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
staleTime: 60 * 1000, // 1 minute stale time : optimize this part
})),
);
const endPointsDataQuery = endPointsDataQueries[0];
const {
data: allEndPointsData,
isLoading,
isRefetching,
isError,
refetch,
} = endPointsDataQuery;
const endPointsColumnsConfig = useMemo(
() => getEndPointsColumnsConfig(groupBy.length > 0, expandedRowKeys),
[groupBy.length, expandedRowKeys],
);
const expandedRowRender = (record: EndPointsTableRowData): JSX.Element => (
<ExpandedRow
domainName={domainName}
selectedRowData={record}
setSelectedEndPointName={setSelectedEndPointName}
setSelectedView={setSelectedView}
/>
);
const handleGroupByRowClick = (record: EndPointsTableRowData): void => {
if (expandedRowKeys.includes(record.key)) {
setExpandedRowKeys(expandedRowKeys.filter((key) => key !== record.key));
} else {
setExpandedRowKeys((expandedRowKeys) => [...expandedRowKeys, record.key]);
// Cache existing selected options on component mount
useEffect(() => {
if (groupBy && groupBy.length > 0) {
setAllAvailableGroupByOptions((prevOptions) => {
const newOptions = { ...prevOptions };
groupBy.forEach((option) => {
newOptions[option.key] = option;
});
return newOptions;
});
}
};
}, [groupBy]); // Removed allAvailableGroupByOptions from dependencies
const handleRowClick = (record: EndPointsTableRowData): void => {
if (groupBy.length === 0) {
setSelectedEndPointName(record.endpointName); // this will open up the endpoint details tab
setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS);
logEvent('API Monitoring: Endpoint name row clicked', {});
} else {
handleGroupByRowClick(record); // this will prepare the nested query payload
}
};
const currentQuery = initialQueriesMap[DataSource.TRACES];
const formattedEndPointsData = useMemo(
() =>
formatEndPointsDataForTable(
allEndPointsData?.payload?.data?.result[0]?.table?.rows,
groupBy,
),
[groupBy, allEndPointsData],
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = [...initialFilters.items];
return { op: 'AND', items: initialItems };
});
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
},
[], // Dependencies for the callback
);
if (isError) {
return (
<div className="all-endpoints-error-state-wrapper">
<ErrorState refetch={refetch} />
</div>
);
}
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters, // Use the local filters state
},
],
},
}),
[filters, currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
const allEndpointsWidgetData = useMemo(
() => getAllEndpointsWidgetData(groupBy, domainName, filters),
[groupBy, domainName, filters],
);
const onRowClick = useCallback(
(props: any): void => {
setSelectedEndPointName(props[SPAN_ATTRIBUTES.URL_PATH] as string);
setSelectedView(VIEWS.ENDPOINT_STATS);
const initialItems = [
...filters.items,
...getGroupByFiltersFromGroupByValues(props, groupBy).items,
];
setInitialFiltersEndPointStats({
items: initialItems,
op: 'AND',
});
},
[
filters,
setInitialFiltersEndPointStats,
setSelectedEndPointName,
setSelectedView,
groupBy,
],
);
return (
<div className="all-endpoints-container">
<div className="all-endpoints-header">
<div className="filter-container">
<QueryBuilderSearchV2
query={query}
onChange={handleFilterChange}
placeholder="Search for filters..."
/>
</div>
</div>
<div className="group-by-container">
<div className="group-by-label"> Group by </div>
<Select
@@ -189,49 +205,17 @@ function AllEndPoints({
placeholder="Search for attribute"
options={groupByOptions}
onChange={handleGroupByChange}
onSearch={(value: string): void => setGroupBySearchValue(value)}
/>{' '}
</div>
<div className="endpoints-table-container">
<div className="endpoints-table-header">Endpoint overview</div>
<Table
columns={endPointsColumnsConfig}
loading={{
spinning: isLoading || isRefetching,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
dataSource={isLoading || isRefetching ? [] : formattedEndPointsData}
locale={{
emptyText:
isLoading || isRefetching ? null : (
<div className="no-filtered-endpoints-message-container">
<div className="no-filtered-endpoints-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-endpoints-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => handleRowClick(record),
className: 'clickable-row',
})}
expandable={{
expandedRowRender: groupBy.length > 0 ? expandedRowRender : undefined,
expandedRowKeys,
expandIconColumnIndex: -1,
}}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
<GridCard
widget={allEndpointsWidgetData}
isQueryEnabled
onDragSelect={(): void => {}}
customOnDragSelect={(): void => {}}
customTimeRange={timeRange}
customOnRowClick={onRowClick}
/>
</div>
</div>

View File

@@ -12,6 +12,12 @@
background: var(--bg-ink-300);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}
.domain-details-drawer-header-right-container {
display: flex;
align-items: center;
gap: 12px;
}
}
.domain-detail-drawer {
@@ -246,6 +252,9 @@
border: 1px solid var(--bg-slate-500);
.endpoints-table-header {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
color: var(--Vanilla-100, #fff);
font-family: Inter;
@@ -299,6 +308,7 @@
.ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.04);
cursor: pointer;
}
.ant-table-cell:first-child {
@@ -386,6 +396,21 @@
padding-top: 20px;
}
.top-errors-dropdown-container {
display: flex;
flex-direction: row;
gap: 10px;
align-items: center;
.endpoint-details-filters-container-dropdown {
width: 100%;
}
.endpoint-details-filters-container-search {
flex: 1;
}
}
.endpoint-details-container {
display: flex;
flex-direction: column;
@@ -690,30 +715,140 @@
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
.top-services-title {
border-bottom: 1px solid var(--bg-slate-500);
padding: 10px 12px;
border-radius: 3px 3px 0px 0px;
background: rgba(171, 189, 255, 0.04);
.title-wrapper {
display: inline-flex;
padding: 1px 2px;
align-items: center;
border-radius: 2px;
background: rgba(113, 144, 249, 0.08);
.title-wrapper {
display: inline-flex;
padding: 1px 2px;
align-items: center;
border-radius: 2px;
background: rgba(113, 144, 249, 0.08);
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
color: var(--bg-robin-400);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px;
letter-spacing: -0.07px;
}
.dependent-services-container {
padding: 10px 12px;
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
.ant-table {
.ant-table-thead > tr > th {
padding: 12px;
font-weight: 500;
font-size: 12px;
line-height: 18px;
border-bottom: none;
color: var(--text-vanilla-400);
font-family: Inter;
font-size: 11px;
font-style: normal;
font-weight: 600;
line-height: 18px;
/* 163.636% */
letter-spacing: 0.44px;
text-transform: uppercase;
background: none;
&::before {
background-color: transparent;
}
}
.ant-table-thead > tr > th:has(.status-code-header) {
background: var(--bg-ink-300);
opacity: 0.6;
}
.ant-table-cell {
padding: 12px;
font-size: 13px;
line-height: 20px;
color: var(--bg-vanilla-100);
border-bottom: none;
background: var(--bg-ink-400);
}
.ant-table-cell:has(.col-title) {
background: rgba(171, 189, 255, 0.04);
}
.ant-table-cell:has(.top-services-item-latency) {
text-align: center;
opacity: 0.8;
background: rgba(171, 189, 255, 0.04);
}
.ant-table-cell:has(.top-services-item-latency-title) {
text-align: center;
opacity: 0.8;
background: rgba(171, 189, 255, 0.04);
}
.ant-table-tbody > tr:hover > td {
background: rgba(255, 255, 255, 0.04);
}
.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;
}
.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;
}
.table-row-dark {
background: var(--bg-ink-300);
}
.ant-table-content {
margin-bottom: 0px;
}
}
.no-status-code-data-message-container {
height: 30vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.no-status-code-data-message-content {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
width: fit-content;
padding: 24px;
}
.no-status-code-data-message {
margin-top: 8px;
}
}
.top-services-item {
display: flex;
justify-content: space-between;
@@ -743,6 +878,7 @@
.top-services-item-progress-bar {
background-color: var(--bg-slate-400);
border-radius: 2px;
height: 100%;
position: absolute;
top: 0;
@@ -758,7 +894,7 @@
.top-services-load-more {
border-top: 1px solid var(--bg-slate-500);
padding-top: 10px;
padding: 10px;
color: var(--text-vanilla-400);
font-family: Inter;

View File

@@ -3,15 +3,27 @@ import './DomainDetails.styles.scss';
import { Color, Spacing } from '@signozhq/design-tokens';
import { Button, Divider, Drawer, Radio, Typography } from 'antd';
import { RadioChangeEvent } from 'antd/lib';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';
import { ArrowDown, ArrowUp, X } from 'lucide-react';
import { useState } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import AllEndPoints from './AllEndPoints';
import DomainMetrics from './components/DomainMetrics';
import { VIEW_TYPES, VIEWS } from './constants';
import EndPointDetailsWrapper from './EndPointDetailsWrapper';
import EndPointDetails from './EndPointDetails';
import TopErrors from './TopErrors';
const TimeRangeOffset = 1000000000;
function DomainDetails({
domainData,
@@ -33,12 +45,58 @@ function DomainDetails({
const [endPointsGroupBy, setEndPointsGroupBy] = useState<
IBuilderQuery['groupBy']
>([]);
const [initialFiltersEndPointStats, setInitialFiltersEndPointStats] = useState<
IBuilderQuery['filters']
>(domainListFilters);
const isDarkMode = useIsDarkMode();
const handleTabChange = (e: RadioChangeEvent): void => {
setSelectedView(e.target.value);
};
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const startMs = useMemo(() => Math.floor(Number(minTime) / TimeRangeOffset), [
minTime,
]);
const endMs = useMemo(() => Math.floor(Number(maxTime) / TimeRangeOffset), [
maxTime,
]);
const [selectedInterval, setSelectedInterval] = useState<Time>(
selectedTime as Time,
);
const [modalTimeRange, setModalTimeRange] = useState(() => ({
startTime: startMs,
endTime: endMs,
}));
const handleTimeChange = useCallback(
(interval: Time | CustomTimeType, dateTimeRange?: [number, number]): void => {
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 / TimeRangeOffset),
endTime: Math.floor(maxTime / TimeRangeOffset),
});
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[],
);
return (
<Drawer
width="60%"
@@ -50,32 +108,44 @@ function DomainDetails({
{domainData.domainName}
</Typography.Text>
</div>
<Button.Group className="domain-details-drawer-header-ctas">
<Button
className="domain-navigate-cta"
onClick={(): void => {
setSelectedDomainIndex(selectedDomainIndex - 1);
setSelectedEndPointName('');
setEndPointsGroupBy([]);
setSelectedView(VIEW_TYPES.ALL_ENDPOINTS);
}}
icon={<ArrowUp size={16} />}
disabled={selectedDomainIndex === 0}
title="Previous domain"
<div className="domain-details-drawer-header-right-container">
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
onTimeChange={handleTimeChange}
defaultRelativeTime="5m"
isModalTimeSelection
modalSelectedInterval={selectedInterval}
modalInitialStartTime={modalTimeRange.startTime * 1000}
modalInitialEndTime={modalTimeRange.endTime * 1000}
/>
<Button
className="domain-navigate-cta"
onClick={(): void => {
setSelectedDomainIndex(selectedDomainIndex + 1);
setSelectedEndPointName('');
setEndPointsGroupBy([]);
setSelectedView(VIEW_TYPES.ALL_ENDPOINTS);
}}
icon={<ArrowDown size={16} />}
disabled={selectedDomainIndex === domainListLength - 1}
title="Next domain"
/>
</Button.Group>
<Button.Group className="domain-details-drawer-header-ctas">
<Button
className="domain-navigate-cta"
onClick={(): void => {
setSelectedDomainIndex(selectedDomainIndex - 1);
setSelectedEndPointName('');
setEndPointsGroupBy([]);
setSelectedView(VIEW_TYPES.ALL_ENDPOINTS);
}}
icon={<ArrowUp size={16} />}
disabled={selectedDomainIndex === 0}
title="Previous domain"
/>
<Button
className="domain-navigate-cta"
onClick={(): void => {
setSelectedDomainIndex(selectedDomainIndex + 1);
setSelectedEndPointName('');
setEndPointsGroupBy([]);
setSelectedView(VIEW_TYPES.ALL_ENDPOINTS);
}}
icon={<ArrowDown size={16} />}
disabled={selectedDomainIndex === domainListLength - 1}
title="Next domain"
/>
</Button.Group>
</div>
</div>
}
placement="right"
@@ -91,7 +161,11 @@ function DomainDetails({
>
{domainData && (
<>
<DomainMetrics domainData={domainData} />
<DomainMetrics
domainName={domainData.domainName}
domainListFilters={domainListFilters}
timeRange={modalTimeRange}
/>
<div className="views-tabs-container">
<Radio.Group
className="views-tabs"
@@ -109,13 +183,21 @@ function DomainDetails({
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.ENDPOINT_DETAILS
selectedView === VIEW_TYPES.ENDPOINT_STATS
? 'tab selected_view'
: 'tab'
}
value={VIEW_TYPES.ENDPOINT_DETAILS}
value={VIEW_TYPES.ENDPOINT_STATS}
>
<div className="view-title">Endpoint Details</div>
<div className="view-title">Endpoint(s) Stats</div>
</Radio.Button>
<Radio.Button
className={
selectedView === VIEW_TYPES.TOP_ERRORS ? 'tab selected_view' : 'tab'
}
value={VIEW_TYPES.TOP_ERRORS}
>
<div className="view-title">Top 10 Errors</div>
</Radio.Button>
</Radio.Group>
</div>
@@ -126,15 +208,28 @@ function DomainDetails({
setSelectedView={setSelectedView}
groupBy={endPointsGroupBy}
setGroupBy={setEndPointsGroupBy}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
setInitialFiltersEndPointStats={setInitialFiltersEndPointStats}
/>
)}
{selectedView === VIEW_TYPES.ENDPOINT_DETAILS && (
<EndPointDetailsWrapper
{selectedView === VIEW_TYPES.ENDPOINT_STATS && (
<EndPointDetails
domainName={domainData.domainName}
endPointName={selectedEndPointName}
setSelectedEndPointName={setSelectedEndPointName}
domainListFilters={domainListFilters}
initialFilters={initialFiltersEndPointStats}
timeRange={modalTimeRange}
handleTimeChange={handleTimeChange}
/>
)}
{selectedView === VIEW_TYPES.TOP_ERRORS && (
<TopErrors
domainName={domainData.domainName}
timeRange={modalTimeRange}
initialFilters={domainListFilters}
/>
)}
</>

View File

@@ -8,16 +8,18 @@ import {
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DependentServices from './components/DependentServices';
import EndPointMetrics from './components/EndPointMetrics';
@@ -25,33 +27,107 @@ import EndPointsDropDown from './components/EndPointsDropDown';
import MetricOverTimeGraph from './components/MetricOverTimeGraph';
import StatusCodeBarCharts from './components/StatusCodeBarCharts';
import StatusCodeTable from './components/StatusCodeTable';
import { SPAN_ATTRIBUTES } from './constants';
const httpUrlKey = {
dataType: DataTypes.String,
isColumn: false,
isJSON: false,
key: SPAN_ATTRIBUTES.URL_PATH,
type: 'tag',
};
function EndPointDetails({
domainName,
endPointName,
setSelectedEndPointName,
domainListFilters,
initialFilters,
timeRange,
handleTimeChange,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
domainListFilters: IBuilderQuery['filters'];
initialFilters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
};
handleTimeChange: (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void;
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { startTime: minTime, endTime: maxTime } = timeRange;
const currentQuery = initialQueriesMap[DataSource.TRACES];
const [filters, setFilters] = useState<IBuilderQuery['filters']>({
op: 'AND',
items: [],
// Local state for filters, combining endpoint filter and search filters
const [filters, setFilters] = useState<IBuilderQuery['filters']>(() => {
// Initialize filters based on the initial endPointName prop
const initialItems = [...initialFilters.items];
if (endPointName) {
initialItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: initialItems };
});
// Manually update the query to include the filters
// Because using the hook is causing the global domain
// query to be updated and causing main domain list to
// refetch with the filters of endpoints
// Effect to synchronize local filters when the endPointName prop changes (e.g., from dropdown)
useEffect(() => {
setFilters((currentFilters) => {
const existingHttpUrlFilter = currentFilters.items.find(
(item) => item.key?.key === httpUrlKey.key,
);
const existingHttpUrlValue = (existingHttpUrlFilter?.value as string) || '';
// Only update filters if the prop value is different from what's already in filters
if (endPointName === existingHttpUrlValue) {
return currentFilters; // No change needed, prevents loop
}
// Rebuild filters: Keep non-http.url filters and add/update http.url filter based on prop
const otherFilters = currentFilters.items.filter(
(item) => item.key?.key !== httpUrlKey.key,
);
const newItems = [...otherFilters];
if (endPointName) {
newItems.push({
id: '92b8a1c1',
key: httpUrlKey,
op: '=',
value: endPointName,
});
}
return { op: 'AND', items: newItems };
});
}, [endPointName]);
// Handler for changes from the QueryBuilderSearchV2 component
const handleFilterChange = useCallback(
(newFilters: IBuilderQuery['filters']): void => {
// 1. Update local filters state immediately
setFilters(newFilters);
// 2. Derive the endpoint name from the *new* filters state
const httpUrlFilter = newFilters.items.find(
(item) => item.key?.key === httpUrlKey.key,
);
const derivedEndPointName = (httpUrlFilter?.value as string) || '';
// 3. If the derived endpoint name is different from the current prop,
// it means the search change modified the effective endpoint.
// Notify the parent component.
if (derivedEndPointName !== endPointName) {
setSelectedEndPointName(derivedEndPointName);
}
},
[endPointName, setSelectedEndPointName], // Dependencies for the callback
);
const updatedCurrentQuery = useMemo(
() => ({
@@ -62,7 +138,7 @@ function EndPointDetails({
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
filters,
filters, // Use the local filters state
},
],
},
@@ -78,15 +154,8 @@ function EndPointDetails({
);
const endPointDetailsQueryPayload = useMemo(
() =>
getEndPointDetailsQueryPayload(
domainName,
endPointName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
filters,
),
[domainName, endPointName, filters, minTime, maxTime],
() => getEndPointDetailsQueryPayload(domainName, minTime, maxTime, filters),
[domainName, filters, minTime, maxTime],
);
const endPointDetailsDataQueries = useQueries(
@@ -94,7 +163,7 @@ function EndPointDetails({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
payload,
filters.items,
filters.items, // Include filters.items in queryKey for better caching
ENTITY_VERSION_V4,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
@@ -123,22 +192,30 @@ function EndPointDetails({
);
const { endpoint, port } = useMemo(
() => extractPortAndEndpoint(endPointName),
() => extractPortAndEndpoint(endPointName), // Derive display info from the prop
[endPointName],
);
const [rateOverTimeWidget, latencyOverTimeWidget] = useMemo(
() => [
getRateOverTimeWidgetData(domainName, endPointName, {
items: [...domainListFilters.items, ...filters.items],
op: filters.op,
}),
getLatencyOverTimeWidgetData(domainName, endPointName, {
items: [...domainListFilters.items, ...filters.items],
op: filters.op,
}),
getRateOverTimeWidgetData(domainName, endPointName, filters),
getLatencyOverTimeWidgetData(domainName, endPointName, filters),
],
[domainName, endPointName, filters, domainListFilters],
[domainName, endPointName, filters], // Use combinedFilters
);
// // [TODO] Fix this later
const onDragSelect = useCallback(
(start: number, end: number) => {
const startTimestamp = Math.trunc(start);
const endTimestamp = Math.trunc(end);
if (startTimestamp !== endTimestamp) {
// update the value in local time picker
handleTimeChange('custom', [startTimestamp, endTimestamp]);
}
},
[handleTimeChange],
);
return (
@@ -156,9 +233,7 @@ function EndPointDetails({
<div className="endpoint-details-filters-container-search">
<QueryBuilderSearchV2
query={query}
onChange={(searchFilters): void => {
setFilters(searchFilters);
}}
onChange={handleFilterChange}
placeholder="Search for filters..."
/>
</div>
@@ -166,7 +241,9 @@ function EndPointDetails({
<div className="endpoint-meta-data">
<div className="endpoint-meta-data-pill">
<div className="endpoint-meta-data-label">Endpoint</div>
<div className="endpoint-meta-data-value">{endpoint || '-'}</div>
<div className="endpoint-meta-data-value">
{endpoint || 'All Endpoints'}
</div>
</div>
<div className="endpoint-meta-data-pill">
<div className="endpoint-meta-data-label">Port</div>
@@ -177,6 +254,7 @@ function EndPointDetails({
{!isServicesFilterApplied && (
<DependentServices
dependentServicesQuery={endPointDependentServicesDataQuery}
timeRange={timeRange}
/>
)}
<StatusCodeBarCharts
@@ -186,12 +264,21 @@ function EndPointDetails({
}
domainName={domainName}
endPointName={endPointName}
domainListFilters={domainListFilters}
filters={filters}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>
<StatusCodeTable endPointStatusCodeDataQuery={endPointStatusCodeDataQuery} />
<MetricOverTimeGraph widget={rateOverTimeWidget} />
<MetricOverTimeGraph widget={latencyOverTimeWidget} />
<MetricOverTimeGraph
widget={rateOverTimeWidget}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>
<MetricOverTimeGraph
widget={latencyOverTimeWidget}
timeRange={timeRange}
onDragSelect={onDragSelect}
/>
</div>
);
}

View File

@@ -1,80 +0,0 @@
import { ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { getEndPointZeroStateQueryPayload } from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import EndPointDetailsZeroState from './components/EndPointDetailsZeroState';
import EndPointDetails from './EndPointDetails';
function EndPointDetailsWrapper({
domainName,
endPointName,
setSelectedEndPointName,
domainListFilters,
}: {
domainName: string;
endPointName: string;
setSelectedEndPointName: (value: string) => void;
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const endPointZeroStateQueryPayload = useMemo(
() =>
getEndPointZeroStateQueryPayload(
domainName,
Math.floor(minTime / 1e9),
Math.floor(maxTime / 1e9),
),
[domainName, minTime, maxTime],
);
const endPointZeroStateDataQueries = useQueries(
endPointZeroStateQueryPayload.map((payload) => ({
queryKey: [
// Since only one query here
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
payload,
ENTITY_VERSION_V4,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
})),
);
const [endPointZeroStateDataQuery] = useMemo(
() => [endPointZeroStateDataQueries[0]],
[endPointZeroStateDataQueries],
);
if (endPointName === '') {
return (
<EndPointDetailsZeroState
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointZeroStateDataQuery}
/>
);
}
return (
<EndPointDetails
domainName={domainName}
endPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
domainListFilters={domainListFilters}
/>
);
}
export default EndPointDetailsWrapper;

View File

@@ -0,0 +1,251 @@
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
formatTopErrorsDataForTable,
getEndPointDetailsQueryPayload,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
TopErrorsResponseRow,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { Info } from 'lucide-react';
import { useMemo, useState } from 'react';
import { useQueries } from 'react-query';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import EndPointsDropDown from './components/EndPointsDropDown';
import ErrorState from './components/ErrorState';
import { SPAN_ATTRIBUTES } from './constants';
function TopErrors({
domainName,
timeRange,
initialFilters,
}: {
domainName: string;
timeRange: {
startTime: number;
endTime: number;
};
initialFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const [endPointName, setSelectedEndPointName] = useState<string>('');
const [showStatusCodeErrors, setShowStatusCodeErrors] = useState<boolean>(
true,
);
const queryPayloads = useMemo(
() =>
getTopErrorsQueryPayload(
domainName,
minTime,
maxTime,
{
items: endPointName
? [
{
id: '92b8a1c1',
key: {
dataType: DataTypes.String,
isColumn: false,
isJSON: false,
key: SPAN_ATTRIBUTES.URL_PATH,
type: 'tag',
},
op: '=',
value: endPointName,
},
...initialFilters.items,
]
: [...initialFilters.items],
op: 'AND',
},
showStatusCodeErrors,
),
[
domainName,
endPointName,
minTime,
maxTime,
initialFilters,
showStatusCodeErrors,
],
);
const topErrorsDataQueries = useQueries(
queryPayloads.map((payload) => ({
queryKey: [
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
payload,
DEFAULT_ENTITY_VERSION,
showStatusCodeErrors,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, DEFAULT_ENTITY_VERSION),
enabled: !!payload,
staleTime: 0,
cacheTime: 0,
})),
);
const topErrorsDataQuery = topErrorsDataQueries[0];
const {
data: topErrorsData,
isLoading,
isRefetching,
isError,
refetch,
} = topErrorsDataQuery;
const topErrorsColumnsConfig = useMemo(() => getTopErrorsColumnsConfig(), []);
const formattedTopErrorsData = useMemo(
() =>
formatTopErrorsDataForTable(
topErrorsData?.payload?.data?.result as TopErrorsResponseRow[],
),
[topErrorsData],
);
const endPointDropDownQueryPayload = useMemo(
() => [
getEndPointDetailsQueryPayload(domainName, minTime, maxTime, {
items: [],
op: 'AND',
})[2],
],
[domainName, minTime, maxTime],
);
const endPointDropDownDataQueries = useQueries(
endPointDropDownQueryPayload.map((payload) => ({
queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[4],
payload,
ENTITY_VERSION_V4,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
staleTime: 60 * 1000,
})),
);
const [endPointDropDownDataQuery] = useMemo(
() => [endPointDropDownDataQueries[0]],
[endPointDropDownDataQueries],
);
const navigateToExplorer = useNavigateToExplorer();
if (isError) {
return (
<div className="all-endpoints-error-state-wrapper">
<ErrorState refetch={refetch} />
</div>
);
}
return (
<div className="all-endpoints-container">
<div className="top-errors-dropdown-container">
<div className="endpoint-details-filters-container-dropdown">
<EndPointsDropDown
selectedEndPointName={endPointName}
setSelectedEndPointName={setSelectedEndPointName}
endPointDropDownDataQuery={endPointDropDownDataQuery}
parentContainerDiv=".endpoint-details-filters-container"
/>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Switch
checked={showStatusCodeErrors}
onChange={setShowStatusCodeErrors}
size="small"
/>
<span style={{ color: 'white', fontSize: '14px' }}>
Status Message Exists
</span>
<Tooltip title="When enabled, shows errors that have a status message. When disabled, shows all errors regardless of status message">
<Info size={16} color="white" />
</Tooltip>
</div>
</div>
<div className="endpoints-table-container">
<div className="endpoints-table-header">
{showStatusCodeErrors ? 'Errors with Status Message' : 'All Errors'}{' '}
<Tooltip
title={
showStatusCodeErrors
? 'Shows errors that have a status message'
: 'Shows all errors regardless of status message'
}
>
<Info size={16} color="white" />
</Tooltip>
</div>
<Table
columns={topErrorsColumnsConfig}
loading={{
spinning: isLoading || isRefetching,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
dataSource={isLoading || isRefetching ? [] : formattedTopErrorsData}
locale={{
emptyText:
isLoading || isRefetching ? null : (
<div className="no-filtered-endpoints-message-container">
<div className="no-filtered-endpoints-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-endpoints-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
onRow={(record): { onClick: () => void } => ({
onClick: (): void => {
const filters = getTopErrorsCoRelationQueryFilters(
domainName,
record.endpointName,
record.statusCode,
);
navigateToExplorer({
filters: [...filters.items],
dataSource: DataSource.TRACES,
startTime: minTime,
endTime: maxTime,
shouldResolveQuery: true,
});
},
})}
/>
</div>
</div>
);
}
export default TopErrors;

View File

@@ -1,6 +1,13 @@
import { Typography } from 'antd';
import '../DomainDetails.styles.scss';
import { Table, TablePaginationConfig, Typography } from 'antd';
import Skeleton from 'antd/lib/skeleton';
import { getFormattedDependentServicesData } from 'container/ApiMonitoring/utils';
import { QueryParams } from 'constants/query';
import {
dependentServicesColumns,
DependentServicesData,
getFormattedDependentServicesData,
} from 'container/ApiMonitoring/utils';
import { UnfoldVertical } from 'lucide-react';
import { useMemo, useState } from 'react';
import { UseQueryResult } from 'react-query';
@@ -10,10 +17,15 @@ import ErrorState from './ErrorState';
interface DependentServicesProps {
dependentServicesQuery: UseQueryResult<SuccessResponse<any>, unknown>;
timeRange: {
startTime: number;
endTime: number;
};
}
function DependentServices({
dependentServicesQuery,
timeRange,
}: DependentServicesProps): JSX.Element {
const {
data,
@@ -23,19 +35,25 @@ function DependentServices({
isRefetching,
} = dependentServicesQuery;
const [currentRenderCount, setCurrentRenderCount] = useState(0);
const [isExpanded, setIsExpanded] = useState<boolean>(false);
const dependentServicesData = useMemo(() => {
const formattedDependentServicesData = getFormattedDependentServicesData(
data?.payload?.data?.result[0].table.rows,
);
setCurrentRenderCount(Math.min(formattedDependentServicesData.length, 5));
return formattedDependentServicesData;
}, [data]);
const handleShowMoreClick = (): void => {
setIsExpanded((prev) => !prev);
};
const renderItems = useMemo(
() => dependentServicesData.slice(0, currentRenderCount),
[currentRenderCount, dependentServicesData],
const dependentServicesData = useMemo(
(): DependentServicesData[] =>
getFormattedDependentServicesData(data?.payload?.data?.result[0].table.rows),
[data],
);
const paginationConfig = useMemo(
(): TablePaginationConfig => ({
pageSize: isExpanded ? dependentServicesData.length : 5,
hideOnSinglePage: true,
position: ['none', 'none'],
}),
[isExpanded, dependentServicesData.length],
);
if (isLoading || isRefetching) {
@@ -48,56 +66,66 @@ function DependentServices({
return (
<div className="top-services-content">
<div className="top-services-title">
<span className="title-wrapper">Dependent Services</span>
</div>
<div className="dependent-services-container">
{renderItems.length === 0 ? (
<div className="no-dependent-services-message-container">
<div className="no-dependent-services-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Table
loading={isLoading || isRefetching}
dataSource={dependentServicesData || []}
columns={dependentServicesColumns}
rowClassName="table-row-dark"
pagination={paginationConfig}
locale={{
emptyText:
isLoading || isRefetching ? null : (
<div className="no-status-code-data-message-container">
<div className="no-status-code-data-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-dependent-services-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
) : (
renderItems.map((item) => (
<div className="top-services-item" key={item.key}>
<div className="top-services-item-progress">
<div className="top-services-item-key">{item.serviceName}</div>
<div className="top-services-item-count">{item.count}</div>
<div
className="top-services-item-progress-bar"
style={{ width: `${item.percentage}%` }}
/>
</div>
<div className="top-services-item-percentage">
{item.percentage.toFixed(2)}%
</div>
</div>
))
)}
<Typography.Text className="no-status-code-data-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
</div>
),
}}
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
const url = new URL(
`/services/${
record.serviceData.serviceName &&
record.serviceData.serviceName !== '-'
? record.serviceData.serviceName
: ''
}`,
window.location.origin,
);
const urlQuery = new URLSearchParams();
urlQuery.set(QueryParams.startTime, timeRange.startTime.toString());
urlQuery.set(QueryParams.endTime, timeRange.endTime.toString());
url.search = urlQuery.toString();
window.open(url.toString(), '_blank');
},
className: 'clickable-row',
})}
/>
{currentRenderCount < dependentServicesData.length && (
{dependentServicesData.length > 5 && (
<div
className="top-services-load-more"
onClick={(): void => setCurrentRenderCount(dependentServicesData.length)}
onClick={handleShowMoreClick}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
setCurrentRenderCount(dependentServicesData.length);
handleShowMoreClick();
}
}}
role="button"
tabIndex={0}
>
<UnfoldVertical size={14} />
Show more...
{isExpanded ? 'Show less...' : 'Show more...'}
</div>
)}
</div>

View File

@@ -1,8 +1,88 @@
import { Color } from '@signozhq/design-tokens';
import { Progress, Tooltip, Typography } from 'antd';
import { getLastUsedRelativeTime } from 'container/ApiMonitoring/utils';
import { Progress, Skeleton, Tooltip, Typography } from 'antd';
import { ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
DomainMetricsResponseRow,
formatDomainMetricsDataForTable,
getDomainMetricsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useMemo } from 'react';
import { useQueries } from 'react-query';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from './ErrorState';
function DomainMetrics({
domainName,
timeRange,
domainListFilters,
}: {
domainName: string;
timeRange: { startTime: number; endTime: number };
domainListFilters: IBuilderQuery['filters'];
}): JSX.Element {
const { startTime: minTime, endTime: maxTime } = timeRange;
const queryPayloads = useMemo(
() =>
getDomainMetricsQueryPayload(
domainName,
minTime,
maxTime,
domainListFilters,
),
[domainName, minTime, maxTime, domainListFilters],
);
// Since only one query here
const domainMetricsDataQueries = useQueries(
queryPayloads.map((payload) => ({
queryKey: [
REACT_QUERY_KEY.GET_DOMAIN_METRICS_DATA,
payload,
ENTITY_VERSION_V4,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload,
staleTime: 60 * 1000, // 1 minute stale time : optimize this part
})),
);
const domainMetricsDataQuery = domainMetricsDataQueries[0];
// [TODO] handle the case where the data is not available
// [TODO] Format the data properly
const {
data: domainMetricsData,
isLoading,
isRefetching,
isError,
refetch,
} = domainMetricsDataQuery;
// [TODO] Fix type error
const formattedDomainMetricsData = useMemo(() => {
// Safely access the data with proper type checking
const rowData = domainMetricsData?.payload?.data?.result[0]?.table?.rows[0];
// Only pass the data if it matches the expected format
return formatDomainMetricsDataForTable(
rowData as DomainMetricsResponseRow | undefined,
);
}, [domainMetricsData]);
if (isError) {
return (
<div className="all-endpoints-error-state-wrapper">
<ErrorState refetch={refetch} />
</div>
);
}
function DomainMetrics({ domainData }: { domainData: any }): JSX.Element {
return (
<div className="domain-detail-drawer__endpoint">
<div className="domain-details-grid">
@@ -23,7 +103,7 @@ function DomainMetrics({ domainData }: { domainData: any }): JSX.Element {
type="secondary"
className="domain-details-metadata-label"
>
ERROR RATE
ERROR %
</Typography.Text>
<Typography.Text
type="secondary"
@@ -35,43 +115,62 @@ function DomainMetrics({ domainData }: { domainData: any }): JSX.Element {
<div className="values-row">
<Typography.Text className="domain-details-metadata-value">
<Tooltip title={domainData.endpointCount}>
<span className="round-metric-tag">{domainData.endpointCount}</span>
</Tooltip>
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={formattedDomainMetricsData.endpointCount}>
<span className="round-metric-tag">
{formattedDomainMetricsData.endpointCount}
</span>
</Tooltip>
)}
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="domain-details-metadata-value">
<Tooltip title={domainData.latency}>
<span className="round-metric-tag">
{(domainData.latency / 1000).toFixed(3)}s
</span>
</Tooltip>
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={formattedDomainMetricsData.latency}>
<span className="round-metric-tag">
{(Number(formattedDomainMetricsData.latency) / 1000).toFixed(3)}s
</span>
</Tooltip>
)}
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="domain-details-metadata-value error-rate">
<Tooltip title={domainData.errorRate}>
<Progress
status="active"
percent={Number((domainData.errorRate * 100).toFixed(1))}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
(domainData.errorRate * 100).toFixed(1),
);
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
</Tooltip>
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={formattedDomainMetricsData.errorRate}>
<Progress
status="active"
percent={Number(
Number(formattedDomainMetricsData.errorRate).toFixed(2),
)}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
Number(formattedDomainMetricsData.errorRate).toFixed(2),
);
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
</Tooltip>
)}
</Typography.Text>
{/* // update the tooltip as well */}
<Typography.Text className="domain-details-metadata-value">
<Tooltip title={domainData.lastUsed}>
{getLastUsedRelativeTime(domainData.lastUsed)}
</Tooltip>
{isLoading || isRefetching ? (
<Skeleton.Button active size="small" />
) : (
<Tooltip title={formattedDomainMetricsData.lastUsed}>
{formattedDomainMetricsData.lastUsed}
</Tooltip>
)}
</Typography.Text>
</div>
</div>

View File

@@ -54,7 +54,7 @@ function EndPointMetrics({
type="secondary"
className="domain-details-metadata-label"
>
ERROR RATE
ERROR %
</Typography.Text>
<Typography.Text
type="secondary"
@@ -89,12 +89,13 @@ function EndPointMetrics({
) : (
<Tooltip title={metricsData?.errorRate}>
<Progress
percent={Number((metricsData?.errorRate ?? 0 * 100).toFixed(1))}
status="active"
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
(metricsData?.errorRate ?? 0 * 100).toFixed(1),
Number(metricsData?.errorRate ?? 0).toFixed(2),
);
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
if (errorRatePercent >= 60) return Color.BG_AMBER_500;

View File

@@ -52,6 +52,10 @@ function EndPointsDropDown({
: (triggerNode): HTMLElement => triggerNode.parentNode as HTMLElement
}
dropdownStyle={dropdownStyle}
allowClear
onClear={(): void => {
setSelectedEndPointName('');
}}
/>
);
}

View File

@@ -19,6 +19,7 @@ import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { OrderByPayload } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { VIEW_TYPES, VIEWS } from '../constants';
@@ -28,11 +29,13 @@ function ExpandedRow({
selectedRowData,
setSelectedEndPointName,
setSelectedView,
orderBy,
}: {
domainName: string;
selectedRowData: EndPointsTableRowData;
setSelectedEndPointName: (name: string) => void;
setSelectedView: (view: VIEWS) => void;
orderBy: OrderByPayload | null;
}): JSX.Element {
const nestedColumns = useMemo(() => getEndPointsColumnsConfig(false, []), []);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
@@ -100,6 +103,7 @@ function ExpandedRow({
? formatEndPointsDataForTable(
groupedByRowQuery.data?.payload.data.result[0].table?.rows,
[],
orderBy,
)
: []
}
@@ -114,7 +118,7 @@ function ExpandedRow({
onRow={(record): { onClick: () => void; className: string } => ({
onClick: (): void => {
setSelectedEndPointName(record.endpointName);
setSelectedView(VIEW_TYPES.ENDPOINT_DETAILS);
setSelectedView(VIEW_TYPES.ENDPOINT_STATS);
logEvent('API Monitoring: Endpoint name row clicked', {});
},
className: 'expanded-clickable-row',

View File

@@ -2,7 +2,15 @@ import { Card } from 'antd';
import GridCard from 'container/GridCardLayout/GridCard';
import { Widgets } from 'types/api/dashboard/getAll';
function MetricOverTimeGraph({ widget }: { widget: Widgets }): JSX.Element {
function MetricOverTimeGraph({
widget,
timeRange,
onDragSelect,
}: {
widget: Widgets;
timeRange: { startTime: number; endTime: number };
onDragSelect: (start: number, end: number) => void;
}): JSX.Element {
return (
<div>
<Card bordered className="endpoint-details-card">
@@ -10,8 +18,9 @@ function MetricOverTimeGraph({ widget }: { widget: Widgets }): JSX.Element {
<GridCard
widget={widget}
isQueryEnabled
onDragSelect={(): void => {}}
onDragSelect={onDragSelect}
customOnDragSelect={(): void => {}}
customTimeRange={timeRange}
/>
</div>
</Card>

View File

@@ -21,12 +21,9 @@ import { getUPlotChartOptions } from 'lib/uPlotLib/getUplotChartOptions';
import { getUPlotChartData } from 'lib/uPlotLib/utils/getUplotChartData';
import { useCallback, useMemo, useRef, useState } from 'react';
import { UseQueryResult } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { GlobalReducer } from 'types/reducer/globalTime';
import { Options } from 'uplot';
import ErrorState from './ErrorState';
@@ -36,8 +33,9 @@ function StatusCodeBarCharts({
endPointStatusCodeLatencyBarChartsDataQuery,
domainName,
endPointName,
domainListFilters,
filters,
timeRange,
onDragSelect,
}: {
endPointStatusCodeBarChartsDataQuery: UseQueryResult<
SuccessResponse<any>,
@@ -49,8 +47,12 @@ function StatusCodeBarCharts({
>;
domainName: string;
endPointName: string;
domainListFilters: IBuilderQuery['filters'];
filters: IBuilderQuery['filters'];
timeRange: {
startTime: number;
endTime: number;
};
onDragSelect: (start: number, end: number) => void;
}): JSX.Element {
// 0 : Status Code Count
// 1 : Status Code Latency
@@ -64,9 +66,7 @@ function StatusCodeBarCharts({
data: endPointStatusCodeLatencyBarChartsData,
} = endPointStatusCodeLatencyBarChartsDataQuery;
const { minTime, maxTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { startTime: minTime, endTime: maxTime } = timeRange;
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
@@ -115,25 +115,30 @@ function StatusCodeBarCharts({
const navigateToExplorerPages = useNavigateToExplorerPages();
const { notifications } = useNotifications();
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
drawStyle: 'bars',
colorMapping: {
const colorMapping = useMemo(
() => ({
'200-299': Color.BG_FOREST_500,
'300-399': Color.BG_AMBER_400,
'400-499': Color.BG_CHERRY_500,
'500-599': Color.BG_ROBIN_500,
Other: Color.BG_SIENNA_500,
},
}),
[],
);
const { getCustomSeries } = useGetGraphCustomSeries({
isDarkMode,
drawStyle: 'bars',
colorMapping,
});
const widget = useMemo<Widgets>(
() =>
getStatusCodeBarChartWidgetData(domainName, endPointName, {
items: [...domainListFilters.items, ...filters.items],
items: [...filters.items],
op: filters.op,
}),
[domainName, endPointName, domainListFilters, filters],
[domainName, endPointName, filters],
);
const graphClickHandler = useCallback(
@@ -182,11 +187,13 @@ function StatusCodeBarCharts({
yAxisUnit: statusCodeWidgetInfo[currentWidgetInfoIndex].yAxisUnit,
softMax: null,
softMin: null,
minTimeScale: Math.floor(minTime / 1e9),
maxTimeScale: Math.floor(maxTime / 1e9),
minTimeScale: minTime,
maxTimeScale: maxTime,
panelType: PANEL_TYPES.BAR,
onClickHandler: graphClickHandler,
customSeries: getCustomSeries,
onDragSelect,
colorMapping,
}),
[
minTime,
@@ -198,6 +205,8 @@ function StatusCodeBarCharts({
isDarkMode,
graphClickHandler,
getCustomSeries,
onDragSelect,
colorMapping,
],
);

View File

@@ -1,9 +1,19 @@
export enum VIEWS {
ALL_ENDPOINTS = 'all_endpoints',
ENDPOINT_DETAILS = 'endpoint_details',
ENDPOINT_STATS = 'endpoint_stats',
TOP_ERRORS = 'top_errors',
}
export const VIEW_TYPES = {
ALL_ENDPOINTS: VIEWS.ALL_ENDPOINTS,
ENDPOINT_DETAILS: VIEWS.ENDPOINT_DETAILS,
ENDPOINT_STATS: VIEWS.ENDPOINT_STATS,
TOP_ERRORS: VIEWS.TOP_ERRORS,
};
// Span attribute keys - these are the source of truth for all attribute keys
export const SPAN_ATTRIBUTES = {
URL_PATH: 'http.url',
RESPONSE_STATUS_CODE: 'response_status_code',
SERVER_NAME: 'net.peer.name',
SERVER_PORT: 'net.peer.port',
} as const;

View File

@@ -7,16 +7,22 @@ import logEvent from 'api/common/logEvent';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios';
import cx from 'classnames';
import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import RightToolbarActions from 'container/QueryBuilder/components/ToolbarActions/RightToolbarActions';
import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useMemo, useState } from 'react';
import Toolbar from 'container/Toolbar/Toolbar';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { ErrorResponse, SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { HandleChangeQueryData } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import {
@@ -26,20 +32,50 @@ import {
} from '../../utils';
import DomainDetails from './DomainDetails/DomainDetails';
function DomainList({
query,
showIP,
handleChangeQueryData,
}: {
query: IBuilderQuery;
showIP: boolean;
handleChangeQueryData: HandleChangeQueryData;
}): JSX.Element {
function DomainList({ showIP }: { showIP: boolean }): JSX.Element {
const [selectedDomainIndex, setSelectedDomainIndex] = useState<number>(-1);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { currentQuery, handleRunQuery } = useQueryBuilder();
const query = useMemo(() => currentQuery?.builder?.queryData[0] || null, [
currentQuery,
]);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query,
entityVersion: '',
});
// initialise tab with default query.
useShareBuilderUrl({
...initialQueriesMap.traces,
builder: {
...initialQueriesMap.traces.builder,
queryData: [
{
...initialQueriesMap.traces.builder.queryData[0],
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
...initialQueriesMap.traces.builder.queryData[0].aggregateAttribute,
},
},
],
},
});
const compositeData = useGetCompositeQueryParam();
const handleChangeTagFilters = useCallback(
(value: IBuilderQuery['filters']) => {
handleChangeQueryData('filters', value);
},
[handleChangeQueryData],
);
const fetchApiOverview = async (): Promise<
SuccessResponse<any> | ErrorResponse
> => {
@@ -49,7 +85,21 @@ function DomainList({
show_ip: showIP,
filters: {
op: 'AND',
items: query?.filters.items,
items: [
{
id: '212678b9',
key: {
key: 'kind_string',
dataType: 'string',
type: '',
isColumn: true,
isJSON: false,
},
op: '=',
value: 'Client',
},
...(compositeData?.builder?.queryData[0]?.filters.items || []),
],
},
};
@@ -70,7 +120,7 @@ function DomainList({
};
const { data, isLoading, isFetching } = useQuery(
[REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, query, showIP],
[REACT_QUERY_KEY.GET_DOMAINS_LIST, minTime, maxTime, compositeData, showIP],
fetchApiOverview,
);
@@ -81,20 +131,18 @@ function DomainList({
return (
<section className={cx('api-module-right-section')}>
<Toolbar
showAutoRefresh={false}
rightActions={<RightToolbarActions onStageRunQuery={handleRunQuery} />}
/>
{/* add bottom border here */}
<div className={cx('api-monitoring-list-header')}>
<QueryBuilderSearchV2
query={query}
onChange={(searchFilters): void =>
handleChangeQueryData('filters', searchFilters)
}
onChange={handleChangeTagFilters}
placeholder="Search filters..."
hardcodedAttributeKeys={hardcodedAttributeKeys}
/>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
/>
</div>
<Table
className={cx('api-monitoring-domain-list-table')}

View File

@@ -9,6 +9,7 @@
.api-quick-filters-header {
padding: 12px;
border-bottom: 1px solid var(--bg-slate-400);
border-right: 1px solid var(--bg-slate-400);
display: flex;
align-items: center;
@@ -24,6 +25,10 @@
flex-direction: column;
width: 100%;
.toolbar {
border-bottom: 1px solid var(--bg-slate-400);
}
.api-monitoring-list-header {
width: 100%;
padding: 8px;

View File

@@ -7,12 +7,8 @@ import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuickFilters from 'components/QuickFilters/QuickFilters';
import { QuickFiltersSource } from 'components/QuickFilters/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useEffect, useMemo, useState } from 'react';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { useEffect, useState } from 'react';
import { ApiMonitoringQuickFiltersConfig } from '../utils';
import DomainList from './Domains/DomainList';
@@ -20,39 +16,10 @@ import DomainList from './Domains/DomainList';
function Explorer(): JSX.Element {
const [showIP, setShowIP] = useState<boolean>(true);
const { currentQuery } = useQueryBuilder();
useEffect(() => {
logEvent('API Monitoring: Landing page visited', {});
}, []);
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
dataSource: DataSource.TRACES,
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
},
],
},
}),
[currentQuery],
);
const query = updatedCurrentQuery?.builder?.queryData[0] || null;
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
<div className={cx('api-monitoring-page', 'filter-visible')}>
@@ -83,16 +50,9 @@ function Explorer(): JSX.Element {
source={QuickFiltersSource.API_MONITORING}
config={ApiMonitoringQuickFiltersConfig}
handleFilterVisibilityChange={(): void => {}}
onFilterChange={(query: Query): void =>
handleChangeQueryData('filters', query.builder.queryData[0].filters)
}
/>
</section>
<DomainList
query={query}
showIP={showIP}
handleChangeQueryData={handleChangeQueryData}
/>
<DomainList showIP={showIP} />
</div>
</Sentry.ErrorBoundary>
);

View File

@@ -0,0 +1,190 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react';
import {
getAllEndpointsWidgetData,
getGroupByFiltersFromGroupByValues,
} from 'container/ApiMonitoring/utils';
import { useGetAggregateKeys } from 'hooks/queryBuilder/useGetAggregateKeys';
import AllEndPoints from '../Explorer/Domains/DomainDetails/AllEndPoints';
import {
SPAN_ATTRIBUTES,
VIEWS,
} from '../Explorer/Domains/DomainDetails/constants';
// Mock the dependencies
jest.mock('container/ApiMonitoring/utils', () => ({
getAllEndpointsWidgetData: jest.fn(),
getGroupByFiltersFromGroupByValues: jest.fn(),
}));
jest.mock('container/GridCardLayout/GridCard', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ customOnRowClick }) => (
<div data-testid="grid-card-mock">
<button
type="button"
data-testid="row-click-button"
onClick={(): void =>
customOnRowClick({ [SPAN_ATTRIBUTES.URL_PATH]: '/api/test' })
}
>
Click Row
</button>
</div>
)),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-mock">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [{ id: 'test', key: 'test', op: '=', value: 'test' }],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
jest.mock('hooks/queryBuilder/useGetAggregateKeys', () => ({
useGetAggregateKeys: jest.fn(),
}));
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="select-mock">
<button
data-testid="select-change-button"
type="button"
onClick={(): void => onChange(['http.status_code'])}
>
Change GroupBy
</button>
</div>
)),
};
});
describe('AllEndPoints', () => {
const mockProps = {
domainName: 'test-domain',
setSelectedEndPointName: jest.fn(),
setSelectedView: jest.fn(),
groupBy: [],
setGroupBy: jest.fn(),
timeRange: {
startTime: 1609459200000,
endTime: 1609545600000,
},
initialFilters: { op: 'AND', items: [] },
setInitialFiltersEndPointStats: jest.fn(),
};
beforeEach(() => {
jest.clearAllMocks();
// Setup mock implementations
(useGetAggregateKeys as jest.Mock).mockReturnValue({
data: {
payload: {
attributeKeys: [
{
key: 'http.status_code',
dataType: 'string',
isColumn: true,
isJSON: false,
type: '',
},
],
},
},
isLoading: false,
});
(getAllEndpointsWidgetData as jest.Mock).mockReturnValue({
id: 'test-widget',
title: 'Endpoint Overview',
description: 'Endpoint Overview',
panelTypes: 'table',
queryData: [],
});
(getGroupByFiltersFromGroupByValues as jest.Mock).mockReturnValue({
items: [{ id: 'group-filter', key: 'status', op: '=', value: '200' }],
op: 'AND',
});
});
// Add cleanup after each test
afterEach(() => {
cleanup();
});
it('renders component correctly', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<AllEndPoints {...mockProps} />);
// Verify basic component rendering
expect(screen.getByText('Group by')).toBeInTheDocument();
expect(screen.getByTestId('query-builder-mock')).toBeInTheDocument();
expect(screen.getByTestId('select-mock')).toBeInTheDocument();
expect(screen.getByTestId('grid-card-mock')).toBeInTheDocument();
});
it('handles filter changes', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<AllEndPoints {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
// Check if getAllEndpointsWidgetData was called with updated filters
expect(getAllEndpointsWidgetData).toHaveBeenCalledWith(
expect.anything(),
'test-domain',
expect.objectContaining({
items: expect.arrayContaining([expect.objectContaining({ id: 'test' })]),
op: 'AND',
}),
);
});
it('handles group by changes', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<AllEndPoints {...mockProps} />);
// Trigger group by change
fireEvent.click(screen.getByTestId('select-change-button'));
// Check if setGroupBy was called with updated group by value
expect(mockProps.setGroupBy).toHaveBeenCalled();
});
it('handles row click in grid card', async () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<AllEndPoints {...mockProps} />);
// Trigger row click
fireEvent.click(screen.getByTestId('row-click-button'));
// Check if proper functions were called
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith('/api/test');
expect(mockProps.setSelectedView).toHaveBeenCalledWith(VIEWS.ENDPOINT_STATS);
expect(mockProps.setInitialFiltersEndPointStats).toHaveBeenCalled();
expect(getGroupByFiltersFromGroupByValues).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,366 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getFormattedDependentServicesData } from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
import DependentServices from '../Explorer/Domains/DomainDetails/components/DependentServices';
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
// Create a partial mock of the UseQueryResult interface for testing
interface MockQueryResult {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
data?: any;
refetch: () => void;
}
// Mock the utility function
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedDependentServicesData: jest.fn(),
dependentServicesColumns: [
{ title: 'Dependent Services', dataIndex: 'serviceData', key: 'serviceData' },
{ title: 'AVG. LATENCY', dataIndex: 'latency', key: 'latency' },
{ title: 'ERROR %', dataIndex: 'errorPercentage', key: 'errorPercentage' },
{ title: 'AVG. RATE', dataIndex: 'rate', key: 'rate' },
],
}));
// Mock the ErrorState component
jest.mock('../Explorer/Domains/DomainDetails/components/ErrorState', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ refetch }) => (
<div data-testid="error-state-mock">
<button type="button" data-testid="refetch-button" onClick={refetch}>
Retry
</button>
</div>
)),
}));
// Mock antd components
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Table: jest
.fn()
.mockImplementation(({ dataSource, loading, pagination, onRow }) => (
<div data-testid="table-mock">
<div data-testid="loading-state">
{loading ? 'Loading' : 'Not Loading'}
</div>
<div data-testid="row-count">{dataSource?.length || 0}</div>
<div data-testid="page-size">{pagination?.pageSize}</div>
{dataSource?.map((item: any, index: number) => (
<div
key={`service-${item.key || index}`}
data-testid={`table-row-${index}`}
onClick={(): void => onRow?.(item)?.onClick?.()}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>): void => {
if (e.key === 'Enter' || e.key === ' ') {
onRow?.(item)?.onClick?.();
}
}}
role="button"
tabIndex={0}
>
{item.serviceData.serviceName}
</div>
))}
</div>
)),
Skeleton: jest
.fn()
.mockImplementation(() => <div data-testid="skeleton-mock" />),
Typography: {
Text: jest
.fn()
.mockImplementation(({ children }) => (
<div data-testid="typography-text">{children}</div>
)),
},
};
});
describe('DependentServices', () => {
// Sample mock data to use in tests
const mockDependentServicesData = [
{
key: 'service1',
serviceData: {
// eslint-disable-next-line sonarjs/no-duplicate-string
serviceName: 'auth-service',
count: 500,
percentage: 62.5,
},
latency: 120,
rate: '15',
errorPercentage: '2.5',
},
{
key: 'service2',
serviceData: {
serviceName: 'db-service',
count: 300,
percentage: 37.5,
},
latency: 80,
rate: '10',
errorPercentage: '1.2',
},
];
// Default props for tests
const mockTimeRange = {
startTime: 1609459200000,
endTime: 1609545600000,
};
const refetchFn = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(getFormattedDependentServicesData as jest.Mock).mockReturnValue(
mockDependentServicesData,
);
});
it('renders loading state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
isError: false,
data: undefined,
refetch: refetchFn,
};
// Act
const { container } = render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('renders error state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: true,
data: undefined,
refetch: refetchFn,
};
// Act
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(screen.getByTestId('error-state-mock')).toBeInTheDocument();
expect(ErrorState).toHaveBeenCalledWith(
{ refetch: expect.any(Function) },
expect.anything(),
);
});
it('renders data correctly when loaded', () => {
// Arrange
const mockData = {
payload: {
data: {
result: [
{
table: {
rows: [
{
data: {
'service.name': 'auth-service',
A: '500',
B: '120000000',
C: '15',
F1: '2.5',
},
},
],
},
},
],
},
},
} as SuccessResponse<any>;
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
// Act
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(getFormattedDependentServicesData).toHaveBeenCalledWith(
mockData.payload.data.result[0].table.rows,
);
// Check the table was rendered with the correct data
expect(screen.getByTestId('table-mock')).toBeInTheDocument();
expect(screen.getByTestId('loading-state')).toHaveTextContent('Not Loading');
expect(screen.getByTestId('row-count')).toHaveTextContent('2');
// Default (collapsed) pagination should be 5
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
});
it('handles refetching state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: true,
isError: false,
data: undefined,
refetch: refetchFn,
};
// Act
const { container } = render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Assert
expect(container.querySelector('.ant-skeleton')).toBeInTheDocument();
});
it('handles row click correctly', () => {
// Mock window.open
const originalOpen = window.open;
window.open = jest.fn();
// Arrange
const mockData = {
payload: {
data: {
result: [
{
table: {
rows: [
{
data: {
'service.name': 'auth-service',
A: '500',
B: '120000000',
C: '15',
F1: '2.5',
},
},
],
},
},
],
},
},
} as SuccessResponse<any>;
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
// Act
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Click on the first row
fireEvent.click(screen.getByTestId('table-row-0'));
// Assert
expect(window.open).toHaveBeenCalledWith(
expect.stringContaining('/services/auth-service'),
'_blank',
);
// Restore original window.open
window.open = originalOpen;
});
it('expands table when showing more', () => {
// Set up more than 5 items so the "show more" button appears
const moreItems = Array(8)
.fill(0)
.map((_, index) => ({
key: `service${index}`,
serviceData: {
serviceName: `service-${index}`,
count: 100,
percentage: 12.5,
},
latency: 100,
rate: '10',
errorPercentage: '1',
}));
(getFormattedDependentServicesData as jest.Mock).mockReturnValue(moreItems);
const mockData = {
payload: { data: { result: [{ table: { rows: [] } }] } },
} as SuccessResponse<any>;
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
// Render the component
render(
<DependentServices
dependentServicesQuery={mockQuery as any}
timeRange={mockTimeRange}
/>,
);
// Find the "Show more" button (using container query since it might not have a testId)
const showMoreButton = screen.getByText(/Show more/i);
expect(showMoreButton).toBeInTheDocument();
// Initial page size should be 5
expect(screen.getByTestId('page-size')).toHaveTextContent('5');
// Click the button to expand
fireEvent.click(showMoreButton);
// Page size should now be the full data length
expect(screen.getByTestId('page-size')).toHaveTextContent('8');
// Text should have changed to "Show less"
expect(screen.getByText(/Show less/i)).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,386 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
extractPortAndEndpoint,
getEndPointDetailsQueryPayload,
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
import { useQueries } from 'react-query';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {
TagFilter,
TagFilterItem,
} from 'types/api/queryBuilder/queryBuilderData';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import EndPointDetails from '../Explorer/Domains/DomainDetails/EndPointDetails';
// Mock dependencies
jest.mock('react-query', () => ({
useQueries: jest.fn(),
}));
jest.mock('container/ApiMonitoring/utils', () => ({
END_POINT_DETAILS_QUERY_KEYS_ARRAY: [
'endPointMetricsData',
'endPointStatusCodeData',
'endPointDropDownData',
'endPointDependentServicesData',
'endPointStatusCodeBarChartsData',
'endPointStatusCodeLatencyBarChartsData',
],
extractPortAndEndpoint: jest.fn(),
getEndPointDetailsQueryPayload: jest.fn(),
getLatencyOverTimeWidgetData: jest.fn(),
getRateOverTimeWidgetData: jest.fn(),
}));
jest.mock(
'container/QueryBuilder/filters/QueryBuilderSearchV2/QueryBuilderSearchV2',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ onChange }) => (
<div data-testid="query-builder-search">
<button
type="button"
data-testid="filter-change-button"
onClick={(): void =>
onChange({
items: [
{
id: 'test-filter',
key: {
key: 'test.key',
dataType: DataTypes.String,
type: 'tag',
isColumn: false,
isJSON: false,
},
op: '=',
value: 'test-value',
},
],
op: 'AND',
})
}
>
Change Filter
</button>
</div>
)),
}),
);
// Mock all child components to simplify testing
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointMetrics',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => (
<div data-testid="endpoint-metrics">EndPoint Metrics</div>
)),
}),
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(({ setSelectedEndPointName }) => (
<div data-testid="endpoints-dropdown">
<button
type="button"
data-testid="select-endpoint-button"
onClick={(): void => setSelectedEndPointName('/api/new-endpoint')}
>
Select Endpoint
</button>
</div>
)),
}),
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/DependentServices',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => (
<div data-testid="dependent-services">Dependent Services</div>
)),
}),
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/StatusCodeBarCharts',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => (
<div data-testid="status-code-bar-charts">Status Code Bar Charts</div>
)),
}),
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/StatusCodeTable',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(() => (
<div data-testid="status-code-table">Status Code Table</div>
)),
}),
);
jest.mock(
'../Explorer/Domains/DomainDetails/components/MetricOverTimeGraph',
() => ({
__esModule: true,
default: jest
.fn()
.mockImplementation(({ widget }) => (
<div data-testid={`metric-graph-${widget.title}`}>{widget.title} Graph</div>
)),
}),
);
describe('EndPointDetails Component', () => {
const mockQueryResults = Array(6).fill({
data: { data: [] },
isLoading: false,
isError: false,
error: null,
});
const mockProps = {
// eslint-disable-next-line sonarjs/no-duplicate-string
domainName: 'test-domain',
endPointName: '/api/test',
setSelectedEndPointName: jest.fn(),
initialFilters: { items: [], op: 'AND' } as TagFilter,
timeRange: {
startTime: 1609459200000,
endTime: 1609545600000,
},
handleTimeChange: jest.fn() as (
interval: Time | CustomTimeType,
dateTimeRange?: [number, number],
) => void,
};
beforeEach(() => {
jest.clearAllMocks();
(extractPortAndEndpoint as jest.Mock).mockReturnValue({
port: '8080',
endpoint: '/api/test',
});
(getEndPointDetailsQueryPayload as jest.Mock).mockReturnValue([
{ id: 'query1', label: 'Query 1' },
{ id: 'query2', label: 'Query 2' },
{ id: 'query3', label: 'Query 3' },
{ id: 'query4', label: 'Query 4' },
{ id: 'query5', label: 'Query 5' },
{ id: 'query6', label: 'Query 6' },
]);
(getRateOverTimeWidgetData as jest.Mock).mockReturnValue({
title: 'Rate Over Time',
id: 'rate-widget',
});
(getLatencyOverTimeWidgetData as jest.Mock).mockReturnValue({
title: 'Latency Over Time',
id: 'latency-widget',
});
(useQueries as jest.Mock).mockReturnValue(mockQueryResults);
});
it('renders the component correctly', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...mockProps} />);
// Check all major components are rendered
expect(screen.getByTestId('query-builder-search')).toBeInTheDocument();
expect(screen.getByTestId('endpoints-dropdown')).toBeInTheDocument();
expect(screen.getByTestId('endpoint-metrics')).toBeInTheDocument();
expect(screen.getByTestId('dependent-services')).toBeInTheDocument();
expect(screen.getByTestId('status-code-bar-charts')).toBeInTheDocument();
expect(screen.getByTestId('status-code-table')).toBeInTheDocument();
expect(screen.getByTestId('metric-graph-Rate Over Time')).toBeInTheDocument();
expect(
screen.getByTestId('metric-graph-Latency Over Time'),
).toBeInTheDocument();
// Check endpoint metadata is displayed
expect(screen.getByText(/8080/i)).toBeInTheDocument();
expect(screen.getByText('/api/test')).toBeInTheDocument();
});
it('calls getEndPointDetailsQueryPayload with correct parameters', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.URL_PATH }),
value: '/api/test',
}),
]),
op: 'AND',
}),
);
});
it('adds endpoint filter to initial filters', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...mockProps} />);
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.URL_PATH }),
value: '/api/test',
}),
]),
}),
);
});
it('updates filters when QueryBuilderSearch changes', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...mockProps} />);
// Trigger filter change
fireEvent.click(screen.getByTestId('filter-change-button'));
// Check that filters were updated in subsequent calls to utility functions
expect(getEndPointDetailsQueryPayload).toHaveBeenCalledTimes(2);
expect(getEndPointDetailsQueryPayload).toHaveBeenLastCalledWith(
expect.anything(),
expect.anything(),
expect.anything(),
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'test.key' }),
value: 'test-value',
}),
]),
}),
);
});
it('handles endpoint dropdown selection', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...mockProps} />);
// Trigger endpoint selection
fireEvent.click(screen.getByTestId('select-endpoint-button'));
// Check if endpoint was updated
expect(mockProps.setSelectedEndPointName).toHaveBeenCalledWith(
'/api/new-endpoint',
);
});
it('does not display dependent services when service filter is applied', () => {
const propsWithServiceFilter = {
...mockProps,
initialFilters: {
items: [
{
id: 'service-filter',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'tag',
isColumn: false,
isJSON: false,
},
op: '=',
value: 'test-service',
},
] as TagFilterItem[],
op: 'AND',
} as TagFilter,
};
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...propsWithServiceFilter} />);
// Dependent services should not be displayed
expect(screen.queryByTestId('dependent-services')).not.toBeInTheDocument();
});
it('passes the correct parameters to widget data generators', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...mockProps} />);
expect(getRateOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.URL_PATH }),
value: '/api/test',
}),
]),
}),
);
expect(getLatencyOverTimeWidgetData).toHaveBeenCalledWith(
'test-domain',
'/api/test',
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.URL_PATH }),
value: '/api/test',
}),
]),
}),
);
});
it('generates correct query parameters for useQueries', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointDetails {...mockProps} />);
// Check if useQueries was called with correct parameters
expect(useQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
queryKey: expect.arrayContaining([END_POINT_DETAILS_QUERY_KEYS_ARRAY[0]]),
}),
expect.objectContaining({
queryKey: expect.arrayContaining([END_POINT_DETAILS_QUERY_KEYS_ARRAY[1]]),
}),
// ... and so on for other queries
]),
);
});
});

View File

@@ -0,0 +1,211 @@
import { render, screen } from '@testing-library/react';
import { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
import EndPointMetrics from '../Explorer/Domains/DomainDetails/components/EndPointMetrics';
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
// Create a partial mock of the UseQueryResult interface for testing
interface MockQueryResult {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
data?: any;
refetch: () => void;
}
// Mock the utils function
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedEndPointMetricsData: jest.fn(),
}));
// Mock the ErrorState component
jest.mock('../Explorer/Domains/DomainDetails/components/ErrorState', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ refetch }) => (
<div data-testid="error-state-mock">
<button type="button" data-testid="refetch-button" onClick={refetch}>
Retry
</button>
</div>
)),
}));
// Mock antd components
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Progress: jest
.fn()
.mockImplementation(() => <div data-testid="progress-bar-mock" />),
Skeleton: {
Button: jest
.fn()
.mockImplementation(() => <div data-testid="skeleton-button-mock" />),
},
Tooltip: jest
.fn()
.mockImplementation(({ children }) => (
<div data-testid="tooltip-mock">{children}</div>
)),
Typography: {
Text: jest.fn().mockImplementation(({ children, className }) => (
<div data-testid={`typography-${className}`} className={className}>
{children}
</div>
)),
},
};
});
describe('EndPointMetrics', () => {
// Common metric data to use in tests
const mockMetricsData = {
key: 'test-key',
rate: '42',
latency: 99,
errorRate: 5.5,
lastUsed: '5 minutes ago',
};
// Basic props for tests
const refetchFn = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(getFormattedEndPointMetricsData as jest.Mock).mockReturnValue(
mockMetricsData,
);
});
it('renders loading state correctly', () => {
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
isError: false,
data: undefined,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify skeleton loaders are visible
const skeletonElements = screen.getAllByTestId('skeleton-button-mock');
expect(skeletonElements.length).toBe(4);
// Verify labels are visible even during loading
expect(screen.getByText('Rate')).toBeInTheDocument();
expect(screen.getByText('AVERAGE LATENCY')).toBeInTheDocument();
expect(screen.getByText('ERROR %')).toBeInTheDocument();
expect(screen.getByText('LAST USED')).toBeInTheDocument();
});
it('renders error state correctly', () => {
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: true,
data: undefined,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify error state is shown
expect(screen.getByTestId('error-state-mock')).toBeInTheDocument();
expect(ErrorState).toHaveBeenCalledWith(
{ refetch: expect.any(Function) },
expect.anything(),
);
});
it('renders data correctly when loaded', () => {
const mockData = {
payload: {
data: {
result: [
{
table: {
rows: [
{ data: { A: '42', B: '99000000', D: '1609459200000000', F1: '5.5' } },
],
},
},
],
},
},
} as SuccessResponse<any>;
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify the utils function was called with the data
expect(getFormattedEndPointMetricsData).toHaveBeenCalledWith(
mockData.payload.data.result[0].table.rows,
);
// Verify data is displayed
expect(
screen.getByText(`${mockMetricsData.rate} ops/sec`),
).toBeInTheDocument();
expect(screen.getByText(`${mockMetricsData.latency}ms`)).toBeInTheDocument();
expect(screen.getByText(mockMetricsData.lastUsed)).toBeInTheDocument();
expect(screen.getByTestId('progress-bar-mock')).toBeInTheDocument(); // For error rate
});
it('handles refetching state correctly', () => {
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: true,
isError: false,
data: undefined,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify skeleton loaders are visible during refetching
const skeletonElements = screen.getAllByTestId('skeleton-button-mock');
expect(skeletonElements.length).toBe(4);
});
it('handles null metrics data gracefully', () => {
// Mock the utils function to return null to simulate missing data
(getFormattedEndPointMetricsData as jest.Mock).mockReturnValue(null);
const mockData = {
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
} as SuccessResponse<any>;
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Even with null data, the component should render without crashing
expect(screen.getByText('Rate')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,221 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { getFormattedEndPointDropDownData } from 'container/ApiMonitoring/utils';
import EndPointsDropDown from '../Explorer/Domains/DomainDetails/components/EndPointsDropDown';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
// Mock the Select component from antd
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Select: jest
.fn()
.mockImplementation(({ value, loading, onChange, options, onClear }) => (
<div data-testid="mock-select">
<div data-testid="select-value">{value}</div>
<div data-testid="select-loading">
{loading ? 'loading' : 'not-loading'}
</div>
<select
data-testid="select-element"
value={value || ''}
onChange={(e): void => onChange(e.target.value)}
>
<option value="">Select...</option>
{options?.map((option: { value: string; label: string; key: string }) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
<button data-testid="select-clear-button" type="button" onClick={onClear}>
Clear
</button>
</div>
)),
};
});
// Mock the utilities
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedEndPointDropDownData: jest.fn(),
}));
describe('EndPointsDropDown Component', () => {
const mockEndPoints = [
// eslint-disable-next-line sonarjs/no-duplicate-string
{ key: '1', value: '/api/endpoint1', label: '/api/endpoint1' },
// eslint-disable-next-line sonarjs/no-duplicate-string
{ key: '2', value: '/api/endpoint2', label: '/api/endpoint2' },
];
const mockSetSelectedEndPointName = jest.fn();
// Create a mock that satisfies the UseQueryResult interface
const createMockQueryResult = (overrides: any = {}): any => ({
data: {
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
},
dataUpdatedAt: 0,
error: null,
errorUpdatedAt: 0,
failureCount: 0,
isError: false,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isIdle: false,
isLoading: false,
isLoadingError: false,
isPlaceholderData: false,
isPreviousData: false,
isRefetchError: false,
isRefetching: false,
isStale: false,
isSuccess: true,
refetch: jest.fn(),
remove: jest.fn(),
status: 'success',
...overrides,
});
const defaultProps = {
selectedEndPointName: '',
setSelectedEndPointName: mockSetSelectedEndPointName,
endPointDropDownDataQuery: createMockQueryResult(),
};
beforeEach(() => {
jest.clearAllMocks();
(getFormattedEndPointDropDownData as jest.Mock).mockReturnValue(
mockEndPoints,
);
});
it('renders the component correctly', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...defaultProps} />);
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
// eslint-disable-next-line sonarjs/no-duplicate-string
expect(screen.getByTestId('select-loading')).toHaveTextContent('not-loading');
});
it('shows loading state when data is loading', () => {
const loadingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isLoading: true,
}),
};
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...loadingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('shows loading state when data is fetching', () => {
const fetchingProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
isFetching: true,
}),
};
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...fetchingProps} />);
expect(screen.getByTestId('select-loading')).toHaveTextContent('loading');
});
it('displays the selected endpoint', () => {
const selectedProps = {
...defaultProps,
selectedEndPointName: '/api/endpoint1',
};
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...selectedProps} />);
expect(screen.getByTestId('select-value')).toHaveTextContent(
'/api/endpoint1',
);
});
it('calls setSelectedEndPointName when an option is selected', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...defaultProps} />);
// Get the select element and change its value
const selectElement = screen.getByTestId('select-element');
fireEvent.change(selectElement, { target: { value: '/api/endpoint2' } });
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('/api/endpoint2');
});
it('calls setSelectedEndPointName with empty string when cleared', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...defaultProps} />);
// Click the clear button
const clearButton = screen.getByTestId('select-clear-button');
fireEvent.click(clearButton);
expect(mockSetSelectedEndPointName).toHaveBeenCalledWith('');
});
it('passes dropdown style prop correctly', () => {
const styleProps = {
...defaultProps,
dropdownStyle: { maxHeight: '200px' },
};
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...styleProps} />);
// We can't easily test style props in our mock, but at least ensure the component rendered
expect(screen.getByTestId('mock-select')).toBeInTheDocument();
});
it('formats data using the utility function', () => {
const mockRows = [
{ data: { [SPAN_ATTRIBUTES.URL_PATH]: '/api/test', A: 10 } },
];
const dataProps = {
...defaultProps,
endPointDropDownDataQuery: createMockQueryResult({
data: {
payload: {
data: {
result: [
{
table: {
rows: mockRows,
},
},
],
},
},
},
}),
};
// eslint-disable-next-line react/jsx-props-no-spreading
render(<EndPointsDropDown {...dataProps} />);
expect(getFormattedEndPointDropDownData).toHaveBeenCalledWith(mockRows);
});
});

View File

@@ -0,0 +1,493 @@
import { fireEvent, render, screen } from '@testing-library/react';
import {
getCustomFiltersForBarChart,
getFormattedEndPointStatusCodeChartData,
getStatusCodeBarChartWidgetData,
} from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
import StatusCodeBarCharts from '../Explorer/Domains/DomainDetails/components/StatusCodeBarCharts';
// Create a partial mock of the UseQueryResult interface for testing
interface MockQueryResult {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
error?: Error;
data?: any;
refetch: () => void;
}
// Mocks
jest.mock('components/Uplot', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => <div data-testid="uplot-mock" />),
}));
jest.mock('components/CeleryTask/useGetGraphCustomSeries', () => ({
useGetGraphCustomSeries: (): { getCustomSeries: jest.Mock } => ({
getCustomSeries: jest.fn(),
}),
}));
jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
useNavigateToExplorer: (): { navigateToExplorer: jest.Mock } => ({
navigateToExplorer: jest.fn(),
}),
}));
jest.mock('container/GridCardLayout/useGraphClickToShowButton', () => ({
useGraphClickToShowButton: (): {
componentClick: boolean;
htmlRef: HTMLElement | null;
} => ({
componentClick: false,
htmlRef: null,
}),
}));
jest.mock('container/GridCardLayout/useNavigateToExplorerPages', () => ({
__esModule: true,
default: (): { navigateToExplorerPages: jest.Mock } => ({
navigateToExplorerPages: jest.fn(),
}),
}));
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false,
}));
jest.mock('hooks/useDimensions', () => ({
useResizeObserver: (): { width: number; height: number } => ({
width: 800,
height: 400,
}),
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
jest.mock('lib/uPlotLib/getUplotChartOptions', () => ({
getUPlotChartOptions: jest.fn().mockReturnValue({}),
}));
jest.mock('lib/uPlotLib/utils/getUplotChartData', () => ({
getUPlotChartData: jest.fn().mockReturnValue([]),
}));
// Mock utility functions
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedEndPointStatusCodeChartData: jest.fn(),
getStatusCodeBarChartWidgetData: jest.fn(),
getCustomFiltersForBarChart: jest.fn(),
statusCodeWidgetInfo: [
{ title: 'Status Code Count', yAxisUnit: 'count' },
{ title: 'Status Code Latency', yAxisUnit: 'ms' },
],
}));
// Mock the ErrorState component
jest.mock('../Explorer/Domains/DomainDetails/components/ErrorState', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ refetch }) => (
<div data-testid="error-state-mock">
<button type="button" data-testid="refetch-button" onClick={refetch}>
Retry
</button>
</div>
)),
}));
// Mock antd components
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Card: jest.fn().mockImplementation(({ children, className }) => (
<div data-testid="card-mock" className={className}>
{children}
</div>
)),
Typography: {
Text: jest
.fn()
.mockImplementation(({ children }) => (
<div data-testid="typography-text">{children}</div>
)),
},
Button: {
...originalModule.Button,
Group: jest.fn().mockImplementation(({ children, className }) => (
<div data-testid="button-group" className={className}>
{children}
</div>
)),
},
Skeleton: jest
.fn()
.mockImplementation(() => (
<div data-testid="skeleton-mock">Loading skeleton...</div>
)),
};
});
describe('StatusCodeBarCharts', () => {
// Default props for tests
const mockFilters: IBuilderQuery['filters'] = { items: [], op: 'AND' };
const mockTimeRange = {
startTime: 1609459200000,
endTime: 1609545600000,
};
const mockDomainName = 'test-domain';
const mockEndPointName = '/api/test';
const onDragSelectMock = jest.fn();
const refetchFn = jest.fn();
// Mock formatted data
const mockFormattedData = {
data: {
result: [
{
values: [[1609459200, 10]],
metric: { statusCode: '200-299' },
queryName: 'A',
},
{
values: [[1609459200, 5]],
metric: { statusCode: '400-499' },
queryName: 'B',
},
],
resultType: 'matrix',
},
};
// Mock filter values
const mockStatusCodeFilters = [
{
id: 'test-id-1',
key: {
dataType: 'string',
id: 'response_status_code--string--tag--false',
isColumn: false,
isJSON: false,
key: 'response_status_code',
type: 'tag',
},
op: '>=',
value: '200',
},
{
id: 'test-id-2',
key: {
dataType: 'string',
id: 'response_status_code--string--tag--false',
isColumn: false,
isJSON: false,
key: 'response_status_code',
type: 'tag',
},
op: '<=',
value: '299',
},
];
beforeEach(() => {
jest.clearAllMocks();
(getFormattedEndPointStatusCodeChartData as jest.Mock).mockReturnValue(
mockFormattedData,
);
(getStatusCodeBarChartWidgetData as jest.Mock).mockReturnValue({
id: 'test-widget',
title: 'Status Code',
description: 'Shows status code distribution',
query: { builder: { queryData: [] } },
panelTypes: 'bar',
});
(getCustomFiltersForBarChart as jest.Mock).mockReturnValue(
mockStatusCodeFilters,
);
});
it('renders loading state correctly', () => {
// Arrange
const mockStatusCodeQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
isError: false,
data: undefined,
refetch: refetchFn,
};
const mockLatencyQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: undefined,
refetch: refetchFn,
};
// Act
render(
<StatusCodeBarCharts
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
endPointName={mockEndPointName}
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
);
// Assert
expect(screen.getByTestId('skeleton-mock')).toBeInTheDocument();
});
it('renders error state correctly', () => {
// Arrange
const mockStatusCodeQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: true,
error: new Error('Test error'),
data: undefined,
refetch: refetchFn,
};
const mockLatencyQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: undefined,
refetch: refetchFn,
};
// Act
render(
<StatusCodeBarCharts
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
endPointName={mockEndPointName}
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
);
// Assert
expect(screen.getByTestId('error-state-mock')).toBeInTheDocument();
expect(ErrorState).toHaveBeenCalledWith(
{ refetch: expect.any(Function) },
expect.anything(),
);
});
it('renders chart data correctly when loaded', () => {
// Arrange
const mockData = {
payload: mockFormattedData,
} as SuccessResponse<any>;
const mockStatusCodeQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
const mockLatencyQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
// Act
render(
<StatusCodeBarCharts
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
endPointName={mockEndPointName}
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
);
// Assert
expect(getFormattedEndPointStatusCodeChartData).toHaveBeenCalledWith(
mockData.payload,
'sum',
);
expect(screen.getByTestId('uplot-mock')).toBeInTheDocument();
expect(screen.getByText('Number of calls')).toBeInTheDocument();
expect(screen.getByText('Latency')).toBeInTheDocument();
});
it('switches between number of calls and latency views', () => {
// Arrange
const mockData = {
payload: mockFormattedData,
} as SuccessResponse<any>;
const mockStatusCodeQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
const mockLatencyQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
// Act
render(
<StatusCodeBarCharts
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
endPointName={mockEndPointName}
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
);
// Initially should be showing number of calls (index 0)
const latencyButton = screen.getByText('Latency');
// Click to switch to latency view
fireEvent.click(latencyButton);
// Should now format with the latency data
expect(getFormattedEndPointStatusCodeChartData).toHaveBeenCalledWith(
mockData.payload,
'average',
);
});
it('uses getCustomFiltersForBarChart when needed', () => {
// Arrange
const mockData = {
payload: mockFormattedData,
} as SuccessResponse<any>;
const mockStatusCodeQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
const mockLatencyQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
// Act
render(
<StatusCodeBarCharts
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
endPointName={mockEndPointName}
filters={mockFilters}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
);
// Assert
// Initially getCustomFiltersForBarChart won't be called until a graph click event
expect(getCustomFiltersForBarChart).not.toHaveBeenCalled();
// We can't easily test the graph click handler directly,
// but we've confirmed the function is mocked and ready to be tested
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
mockEndPointName,
expect.objectContaining({
items: [],
op: 'AND',
}),
);
});
it('handles widget generation with current filters', () => {
// Arrange
const mockCustomFilters = {
items: [
{
id: 'custom-filter',
key: { key: 'test-key' },
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
const mockData = {
payload: mockFormattedData,
} as SuccessResponse<any>;
const mockStatusCodeQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
const mockLatencyQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
// Act
render(
<StatusCodeBarCharts
endPointStatusCodeBarChartsDataQuery={mockStatusCodeQuery as any}
endPointStatusCodeLatencyBarChartsDataQuery={mockLatencyQuery as any}
domainName={mockDomainName}
endPointName={mockEndPointName}
filters={mockCustomFilters as IBuilderQuery['filters']}
timeRange={mockTimeRange}
onDragSelect={onDragSelectMock}
/>,
);
// Assert widget creation was called with the correct parameters
expect(getStatusCodeBarChartWidgetData).toHaveBeenCalledWith(
mockDomainName,
mockEndPointName,
expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({ id: 'custom-filter' }),
]),
op: 'AND',
}),
);
});
});

View File

@@ -0,0 +1,175 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import StatusCodeTable from '../Explorer/Domains/DomainDetails/components/StatusCodeTable';
// Mock the ErrorState component
jest.mock('../Explorer/Domains/DomainDetails/components/ErrorState', () =>
jest.fn().mockImplementation(({ refetch }) => (
<div
data-testid="error-state-mock"
onClick={refetch}
onKeyDown={(e: React.KeyboardEvent<HTMLDivElement>): void => {
if (e.key === 'Enter' || e.key === ' ') {
refetch();
}
}}
role="button"
tabIndex={0}
>
Error state
</div>
)),
);
// Mock antd components
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Table: jest
.fn()
.mockImplementation(({ loading, dataSource, columns, locale }) => (
<div data-testid="table-mock">
{loading && <div data-testid="loading-indicator">Loading...</div>}
{dataSource &&
dataSource.length === 0 &&
!loading &&
locale?.emptyText && (
<div data-testid="empty-table">{locale.emptyText}</div>
)}
{dataSource && dataSource.length > 0 && (
<div data-testid="table-data">
Data loaded with {dataSource.length} rows and {columns.length} columns
</div>
)}
</div>
)),
Typography: {
Text: jest.fn().mockImplementation(({ children, className }) => (
<div data-testid="typography-text" className={className}>
{children}
</div>
)),
},
};
});
// Create a mock query result type
interface MockQueryResult {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
error?: Error;
data?: any;
refetch: () => void;
}
describe('StatusCodeTable', () => {
const refetchFn = jest.fn();
it('renders loading state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
isError: false,
data: undefined,
refetch: refetchFn,
};
// Act
render(<StatusCodeTable endPointStatusCodeDataQuery={mockQuery as any} />);
// Assert
expect(screen.getByTestId('loading-indicator')).toBeInTheDocument();
});
it('renders error state correctly', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: true,
error: new Error('Test error'),
data: undefined,
refetch: refetchFn,
};
// Act
render(<StatusCodeTable endPointStatusCodeDataQuery={mockQuery as any} />);
// Assert
expect(screen.getByTestId('error-state-mock')).toBeInTheDocument();
});
it('renders empty state when no data is available', () => {
// Arrange
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: {
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
},
refetch: refetchFn,
};
// Act
render(<StatusCodeTable endPointStatusCodeDataQuery={mockQuery as any} />);
// Assert
expect(screen.getByTestId('empty-table')).toBeInTheDocument();
});
it('renders table data correctly when data is available', () => {
// Arrange
const mockData = [
{
data: {
response_status_code: '200',
A: '150', // count
B: '10000000', // latency in nanoseconds
C: '5', // rate
},
},
];
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: {
payload: {
data: {
result: [
{
table: {
rows: mockData,
},
},
],
},
},
},
refetch: refetchFn,
};
// Act
render(<StatusCodeTable endPointStatusCodeDataQuery={mockQuery as any} />);
// Assert
expect(screen.getByTestId('table-data')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,374 @@
import { fireEvent, render, screen, within } from '@testing-library/react';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import {
formatTopErrorsDataForTable,
getEndPointDetailsQueryPayload,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { useQueries } from 'react-query';
import { DataSource } from 'types/common/queryBuilder';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
// Mock the EndPointsDropDown component to avoid issues
jest.mock(
'../Explorer/Domains/DomainDetails/components/EndPointsDropDown',
() => ({
__esModule: true,
default: jest.fn().mockImplementation(
({ setSelectedEndPointName }): JSX.Element => (
<div data-testid="endpoints-dropdown-mock">
<select
data-testid="endpoints-select"
onChange={(e): void => setSelectedEndPointName(e.target.value)}
role="combobox"
>
<option value="/api/test">/api/test</option>
<option value="/api/new-endpoint">/api/new-endpoint</option>
</select>
</div>
),
),
}),
);
// Mock dependencies
jest.mock('react-query', () => ({
useQueries: jest.fn(),
}));
jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
useNavigateToExplorer: jest.fn(),
}));
jest.mock('container/ApiMonitoring/utils', () => ({
END_POINT_DETAILS_QUERY_KEYS_ARRAY: ['key1', 'key2', 'key3', 'key4', 'key5'],
formatTopErrorsDataForTable: jest.fn(),
getEndPointDetailsQueryPayload: jest.fn(),
getTopErrorsColumnsConfig: jest.fn(),
getTopErrorsCoRelationQueryFilters: jest.fn(),
getTopErrorsQueryPayload: jest.fn(),
}));
describe('TopErrors', () => {
const mockProps = {
// eslint-disable-next-line sonarjs/no-duplicate-string
domainName: 'test-domain',
timeRange: {
startTime: 1000000000,
endTime: 1000010000,
},
initialFilters: {
items: [],
op: 'AND',
},
};
// Setup basic mocks
beforeEach(() => {
jest.clearAllMocks();
// Mock getTopErrorsColumnsConfig
(getTopErrorsColumnsConfig as jest.Mock).mockReturnValue([
{
title: 'Endpoint',
dataIndex: 'endpointName',
key: 'endpointName',
},
{
title: 'Status Code',
dataIndex: 'statusCode',
key: 'statusCode',
},
{
title: 'Status Message',
dataIndex: 'statusMessage',
key: 'statusMessage',
},
{
title: 'Count',
dataIndex: 'count',
key: 'count',
},
]);
// Mock useQueries
(useQueries as jest.Mock).mockImplementation((queryConfigs) => {
// For topErrorsDataQueries
if (
queryConfigs.length === 1 &&
queryConfigs[0].queryKey &&
queryConfigs[0].queryKey[0] === REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN
) {
return [
{
data: {
payload: {
data: {
result: [
{
metric: {
'http.url': '/api/test',
status_code: '500',
// eslint-disable-next-line sonarjs/no-duplicate-string
status_message: 'Internal Server Error',
},
values: [[1000000100, '10']],
queryName: 'A',
legend: 'Test Legend',
},
],
},
},
},
isLoading: false,
isRefetching: false,
isError: false,
refetch: jest.fn(),
},
];
}
// For endPointDropDownDataQueries
return [
{
data: {
payload: {
data: {
result: [
{
table: {
rows: [
{
'http.url': '/api/test',
A: 100,
},
],
},
},
],
},
},
},
isLoading: false,
isRefetching: false,
isError: false,
},
];
});
// Mock formatTopErrorsDataForTable
(formatTopErrorsDataForTable as jest.Mock).mockReturnValue([
{
key: '1',
endpointName: '/api/test',
statusCode: '500',
statusMessage: 'Internal Server Error',
count: 10,
},
]);
// Mock getTopErrorsQueryPayload
(getTopErrorsQueryPayload as jest.Mock).mockReturnValue([
{
queryName: 'TopErrorsQuery',
start: mockProps.timeRange.startTime,
end: mockProps.timeRange.endTime,
step: 60,
},
]);
// Mock getEndPointDetailsQueryPayload
(getEndPointDetailsQueryPayload as jest.Mock).mockReturnValue([
{},
{},
{
queryName: 'EndpointDropdownQuery',
start: mockProps.timeRange.startTime,
end: mockProps.timeRange.endTime,
step: 60,
},
]);
// Mock useNavigateToExplorer
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
// Mock getTopErrorsCoRelationQueryFilters
(getTopErrorsCoRelationQueryFilters as jest.Mock).mockReturnValue({
items: [
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
],
op: 'AND',
});
});
it('renders component correctly', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
const { container } = render(<TopErrors {...mockProps} />);
// Check if the title and toggle are rendered
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
expect(screen.getByText('Status Message Exists')).toBeInTheDocument();
// Find the table row and verify content
const tableBody = container.querySelector('.ant-table-tbody');
expect(tableBody).not.toBeNull();
if (tableBody) {
const row = within(tableBody as HTMLElement).getByRole('row');
expect(within(row).getByText('/api/test')).toBeInTheDocument();
expect(within(row).getByText('500')).toBeInTheDocument();
expect(within(row).getByText('Internal Server Error')).toBeInTheDocument();
}
});
it('renders error state when isError is true', () => {
// Mock useQueries to return isError: true
(useQueries as jest.Mock).mockImplementationOnce(() => [
{
isError: true,
refetch: jest.fn(),
},
]);
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Error state should be shown with the actual text displayed in the UI
expect(
screen.getByText('Uh-oh :/ We ran into an error.'),
).toBeInTheDocument();
expect(screen.getByText('Please refresh this panel.')).toBeInTheDocument();
expect(screen.getByText('Refresh this panel')).toBeInTheDocument();
});
it('handles row click correctly', () => {
const navigateMock = jest.fn();
(useNavigateToExplorer as jest.Mock).mockReturnValue(navigateMock);
// eslint-disable-next-line react/jsx-props-no-spreading
const { container } = render(<TopErrors {...mockProps} />);
// Find and click on the table cell containing the endpoint
const tableBody = container.querySelector('.ant-table-tbody');
expect(tableBody).not.toBeNull();
if (tableBody) {
const row = within(tableBody as HTMLElement).getByRole('row');
const cellWithEndpoint = within(row).getByText('/api/test');
fireEvent.click(cellWithEndpoint);
}
// Check if navigateToExplorer was called with correct params
expect(navigateMock).toHaveBeenCalledWith({
filters: [
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
],
dataSource: DataSource.TRACES,
startTime: mockProps.timeRange.startTime,
endTime: mockProps.timeRange.endTime,
shouldResolveQuery: true,
});
});
it('updates endpoint filter when dropdown value changes', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Find the dropdown
const dropdown = screen.getByRole('combobox');
// Mock the change
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
// Check if getTopErrorsQueryPayload was called with updated parameters
expect(getTopErrorsQueryPayload).toHaveBeenCalled();
});
it('handles status message toggle correctly', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Find the toggle switch
const toggle = screen.getByRole('switch');
expect(toggle).toBeInTheDocument();
// Toggle should be on by default
expect(toggle).toHaveAttribute('aria-checked', 'true');
// Click the toggle to turn it off
fireEvent.click(toggle);
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=false
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
mockProps.domainName,
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.any(Object),
false,
);
// Title should change
expect(screen.getByText('All Errors')).toBeInTheDocument();
// Click the toggle to turn it back on
fireEvent.click(toggle);
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=true
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
mockProps.domainName,
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.any(Object),
true,
);
// Title should change back
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
});
it('includes toggle state in query key for cache busting', () => {
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
const toggle = screen.getByRole('switch');
// Initial query should include showStatusCodeErrors=true
expect(useQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
queryKey: expect.arrayContaining([
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
expect.any(Object),
expect.any(String),
true,
]),
}),
]),
);
// Click toggle
fireEvent.click(toggle);
// Query should be called with showStatusCodeErrors=false in key
expect(useQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
queryKey: expect.arrayContaining([
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
expect.any(Object),
expect.any(String),
false,
]),
}),
]),
);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -42,7 +42,7 @@ import { Helmet } from 'react-helmet-async';
import { useTranslation } from 'react-i18next';
import { useMutation, useQueries } from 'react-query';
import { useDispatch } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { matchPath, useLocation } from 'react-router-dom';
import { Dispatch } from 'redux';
import AppActions from 'types/actions';
import {
@@ -362,6 +362,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
routeKey === 'INFRASTRUCTURE_MONITORING_HOSTS' ||
routeKey === 'INFRASTRUCTURE_MONITORING_KUBERNETES';
const isTracesFunnels = (): boolean => routeKey === 'TRACES_FUNNELS';
const isTracesFunnelDetails = (): boolean =>
!!matchPath(pathname, ROUTES.TRACES_FUNNELS_DETAIL);
const isPathMatch = (regex: RegExp): boolean => regex.test(pathname);
const isDashboardView = (): boolean =>
@@ -376,9 +379,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
useEffect(() => {
if (isDarkMode) {
document.body.classList.remove('lightMode');
document.body.classList.add('dark');
document.body.classList.add('darkMode');
} else {
document.body.classList.add('lightMode');
document.body.classList.remove('dark');
document.body.classList.remove('darkMode');
}
}, [isDarkMode]);
@@ -588,7 +593,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
);
return (
<Layout className={cx(isDarkMode ? 'darkMode' : 'lightMode')}>
<Layout className={cx(isDarkMode ? 'darkMode dark' : 'lightMode')}>
<Helmet>
<title>{pageTitle}</title>
</Helmet>
@@ -638,7 +643,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
</div>
)}
<Flex className={cx('app-layout', isDarkMode ? 'darkMode' : 'lightMode')}>
<Flex
className={cx('app-layout', isDarkMode ? 'darkMode dark' : 'lightMode')}
>
{isToDisplayLayout && !renderFullScreen && <SideNav />}
<div
className={cx('app-content', {
@@ -668,7 +675,11 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
? 0
: '0 1rem',
...(isTraceDetailsView() || isTracesFunnels() ? { margin: 0 } : {}),
...(isTraceDetailsView() ||
isTracesFunnels() ||
isTracesFunnelDetails()
? { margin: 0 }
: {}),
}}
>
{isToDisplayLayout && !renderFullScreen && <TopNav />}

View File

@@ -56,6 +56,7 @@ function WidgetGraphComponent({
onOpenTraceBtnClick,
customSeries,
customErrorMessage,
customOnRowClick,
}: WidgetGraphComponentProps): JSX.Element {
const { safeNavigate } = useSafeNavigate();
const [deleteModal, setDeleteModal] = useState(false);
@@ -380,6 +381,7 @@ function WidgetGraphComponent({
openTracesButton={openTracesButton}
onOpenTraceBtnClick={onOpenTraceBtnClick}
customSeries={customSeries}
customOnRowClick={customOnRowClick}
/>
</div>
)}

View File

@@ -47,6 +47,8 @@ function GridCardGraph({
start,
end,
analyticsEvent,
customTimeRange,
customOnRowClick,
}: GridCardGraphProps): JSX.Element {
const dispatch = useDispatch();
const [errorMessage, setErrorMessage] = useState<string>();
@@ -130,6 +132,8 @@ function GridCardGraph({
variables: getDashboardVariables(variables),
fillGaps: widget.fillSpans,
formatForWeb: widget.panelTypes === PANEL_TYPES.TABLE,
start: customTimeRange?.startTime || start,
end: customTimeRange?.endTime || end,
};
}
updatedQuery.builder.queryData[0].pageSize = 10;
@@ -149,6 +153,8 @@ function GridCardGraph({
initialDataSource === DataSource.TRACES && widget.selectedTracesFields,
},
fillGaps: widget.fillSpans,
start: customTimeRange?.startTime || start,
end: customTimeRange?.endTime || end,
};
});
@@ -187,8 +193,8 @@ function GridCardGraph({
variables: getDashboardVariables(variables),
selectedTime: widget.timePreferance || 'GLOBAL_TIME',
globalSelectedInterval,
start,
end,
start: customTimeRange?.startTime || start,
end: customTimeRange?.endTime || end,
},
version || DEFAULT_ENTITY_VERSION,
{
@@ -202,6 +208,9 @@ function GridCardGraph({
widget.timePreferance,
widget.fillSpans,
requestData,
...(customTimeRange && customTimeRange.startTime && customTimeRange.endTime
? [customTimeRange.startTime, customTimeRange.endTime]
: []),
],
retry(failureCount, error): boolean {
if (
@@ -279,6 +288,7 @@ function GridCardGraph({
onOpenTraceBtnClick={onOpenTraceBtnClick}
customSeries={customSeries}
customErrorMessage={isInternalServerError ? customErrorMessage : undefined}
customOnRowClick={customOnRowClick}
/>
)}
</div>

View File

@@ -39,6 +39,7 @@ export interface WidgetGraphComponentProps {
onOpenTraceBtnClick?: (record: RowData) => void;
customSeries?: (data: QueryData[]) => uPlot.Series[];
customErrorMessage?: string;
customOnRowClick?: (record: RowData) => void;
}
export interface GridCardGraphProps {
@@ -61,6 +62,11 @@ export interface GridCardGraphProps {
start?: number;
end?: number;
analyticsEvent?: string;
customTimeRange?: {
startTime: number;
endTime: number;
};
customOnRowClick?: (record: RowData) => void;
}
export interface GetGraphVisibilityStateOnLegendClickProps {

View File

@@ -43,6 +43,7 @@ function GridTableComponent({
sticky,
openTracesButton,
onOpenTraceBtnClick,
customOnRowClick,
widgetId,
...props
}: GridTableComponentProps): JSX.Element {
@@ -214,6 +215,30 @@ function GridTableComponent({
[newColumnData],
);
const newColumnsWithRenderColumnCell = useMemo(
() =>
newColumnData.map((column) => ({
...column,
...('dataIndex' in column &&
props.renderColumnCell?.[column.dataIndex as string]
? { render: props.renderColumnCell[column.dataIndex as string] }
: {}),
})),
[newColumnData, props.renderColumnCell],
);
const newColumnsWithCustomColTitles = useMemo(
() =>
newColumnsWithRenderColumnCell.map((column) => ({
...column,
...('dataIndex' in column &&
props.customColTitles?.[column.dataIndex as string]
? { title: props.customColTitles[column.dataIndex as string] }
: {}),
})),
[newColumnsWithRenderColumnCell, props.customColTitles],
);
useEffect(() => {
eventEmitter.emit(Events.TABLE_COLUMNS_DATA, {
columns: newColumnData,
@@ -227,15 +252,22 @@ function GridTableComponent({
query={query}
queryTableData={data}
loading={false}
columns={openTracesButton ? columnDataWithOpenTracesButton : newColumnData}
columns={
openTracesButton
? columnDataWithOpenTracesButton
: newColumnsWithCustomColTitles
}
dataSource={dataSource}
sticky={sticky}
widgetId={widgetId}
onRow={
openTracesButton
openTracesButton || customOnRowClick
? (record): React.HTMLAttributes<HTMLElement> => ({
onClick: (): void => {
onOpenTraceBtnClick?.(record);
if (openTracesButton) {
onOpenTraceBtnClick?.(record);
}
customOnRowClick?.(record);
},
})
: undefined

View File

@@ -4,6 +4,7 @@ import {
ThresholdOperators,
ThresholdProps,
} from 'container/NewWidget/RightContainer/Threshold/types';
import { QueryTableProps } from 'container/QueryTable/QueryTable.intefaces';
import { RowData } from 'lib/query/createTableColumnsFromQuery';
import { ColumnUnit } from 'types/api/dashboard/getAll';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -17,7 +18,10 @@ export type GridTableComponentProps = {
searchTerm?: string;
openTracesButton?: boolean;
onOpenTraceBtnClick?: (record: RowData) => void;
customOnRowClick?: (record: RowData) => void;
widgetId?: string;
renderColumnCell?: QueryTableProps['renderColumnCell'];
customColTitles?: Record<string, string>;
} & Pick<LogsExplorerTableProps, 'data'> &
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;

View File

@@ -14,6 +14,7 @@ import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import ROUTES from 'constants/routes';
import { getHostListsQuery } from 'container/InfraMonitoringHosts/utils';
import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData';
import { useGetHostList } from 'hooks/infraMonitoring/useGetHostList';
import { useGetK8sPodsList } from 'hooks/infraMonitoring/useGetK8sPodsList';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
@@ -26,6 +27,7 @@ import Card from 'periscope/components/Card/Card';
import { useAppContext } from 'providers/App/App';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation, useQuery } from 'react-query';
import { LicensePlatform } from 'types/api/licensesV3/getActive';
import { DataSource } from 'types/common/queryBuilder';
import { UserPreference } from 'types/reducer/app';
import { USER_ROLES } from 'types/roles';
@@ -290,6 +292,20 @@ export default function Home(): JSX.Element {
}
}, [hostData, k8sPodsData, handleUpdateChecklistDoneItem]);
const { activeLicenseV3, isFetchingActiveLicenseV3 } = useAppContext();
const [isEnabled, setIsEnabled] = useState(false);
useEffect(() => {
if (isFetchingActiveLicenseV3) {
setIsEnabled(false);
return;
}
setIsEnabled(Boolean(activeLicenseV3?.platform === LicensePlatform.CLOUD));
}, [activeLicenseV3, isFetchingActiveLicenseV3]);
const { data: deploymentsData } = useGetDeploymentsData(isEnabled);
useEffect(() => {
logEvent('Homepage: Visited', {});
}, []);
@@ -642,17 +658,33 @@ export default function Home(): JSX.Element {
</>
)}
</div>
<div className="home-right-content">
<div className="home-notifications-container">
<div className="notification">
<Alert
message="We're transitioning alert rule IDs from integers to UUIDs on April 23, 2025. Both old and new alert links will continue to work after this change - existing notifications using integer IDs will remain functional while new alerts will use the UUID format."
type="info"
showIcon
/>
{deploymentsData?.data?.data?.cluster?.region?.name === 'in' && (
<div className="home-notifications-container">
<div className="notification">
<Alert
message={
<>
We&apos;re updating our metric ingestion processing pipeline.
Currently, metric names and labels are normalized to replace dots and
other special characters with underscores (_). This restriction will
soon be removed. Learn more{' '}
<a
href="https://signoz.io/guides/metrics-migration-cloud-users"
target="_blank"
rel="noopener noreferrer"
>
here
</a>
.
</>
}
type="warning"
showIcon
/>
</div>
</div>
</div>
)}
{!isWelcomeChecklistSkipped && !loadingUserPreferences && (
<AnimatePresence initial={false}>

View File

@@ -5,6 +5,7 @@ import RawLogView from 'components/Logs/RawLogView';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import ShowButton from 'container/LogsContextList/ShowButton';
import { convertKeysToColumnFields } from 'container/LogsExplorerList/utils';
import { useOptionsMenu } from 'container/OptionsMenu';
@@ -14,7 +15,6 @@ import { ORDERBY_FILTERS } from 'container/QueryBuilder/filters/OrderByFilter/co
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useLocation } from 'react-router-dom';
import { Virtuoso } from 'react-virtuoso';
import { ILog } from 'types/api/logs/log';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
@@ -106,8 +106,6 @@ function ContextLogRenderer({
const urlQuery = useUrlQuery();
const { pathname } = useLocation();
const handleLogClick = useCallback(
(logId: string): void => {
urlQuery.set(QueryParams.activeLogId, `"${logId}"`);
@@ -117,11 +115,10 @@ function ContextLogRenderer({
encodeURIComponent(JSON.stringify(query)),
);
const link = `${pathname}?${urlQuery.toString()}`;
const link = `${ROUTES.LOGS_EXPLORER}?${urlQuery.toString()}`;
window.open(link, '_blank', 'noopener,noreferrer');
},
[pathname, query, urlQuery],
[query, urlQuery],
);
const getItemContent = useCallback(
@@ -143,7 +140,9 @@ function ContextLogRenderer({
linesPerRow={1}
fontSize={options.fontSize}
selectedFields={convertKeysToColumnFields(
options.selectColumns ?? defaultLogsSelectedColumns,
options.selectColumns?.length
? options.selectColumns
: defaultLogsSelectedColumns,
)}
/>
</Button>

View File

@@ -0,0 +1,145 @@
import {
act,
render,
RenderResult,
screen,
waitFor,
} from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { ENVIRONMENT } from 'constants/env';
import { initialQueriesMap } from 'constants/queryBuilder';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import TimezoneProvider from 'providers/Timezone';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import { VirtuosoMockContext } from 'react-virtuoso';
import store from 'store';
import ContextLogRenderer from '../ContextLogRenderer';
import {
mockLog,
mockQuery,
mockQueryRangeResponse,
mockTagFilter,
} from './mockData';
// Mock the useContextLogData hook
const mockHandleRunQuery = jest.fn();
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('container/OptionsMenu', () => ({
useOptionsMenu: (): any => ({
options: {
fontSize: 'medium',
selectColumns: [],
},
}),
}));
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
}),
}));
// Common wrapper component for tests
const renderContextLogRenderer = (): RenderResult => {
const defaultProps = {
isEdit: false,
query: mockQuery,
log: mockLog,
filters: mockTagFilter,
};
return render(
<MemoryRouter>
<TimezoneProvider>
<Provider store={store}>
<MockQueryClientProvider>
<QueryBuilderContext.Provider
value={
{
currentQuery: initialQueriesMap.traces,
handleRunQuery: mockHandleRunQuery,
} as any
}
>
<VirtuosoMockContext.Provider
value={{ viewportHeight: 300, itemHeight: 50 }}
>
<ContextLogRenderer
isEdit={defaultProps.isEdit}
query={defaultProps.query}
log={defaultProps.log}
filters={defaultProps.filters}
/>
</VirtuosoMockContext.Provider>
</QueryBuilderContext.Provider>
</MockQueryClientProvider>
</Provider>
</TimezoneProvider>
</MemoryRouter>,
);
};
describe('ContextLogRenderer', () => {
beforeEach(() => {
server.use(
rest.get(`${ENVIRONMENT.baseURL}/api/v1/logs`, (req, res, ctx) =>
res(ctx.status(200), ctx.json({ logs: [mockLog] })),
),
);
server.use(
rest.post(`${ENVIRONMENT.baseURL}/api/v3/query_range`, (req, res, ctx) =>
res(ctx.status(200), ctx.json(mockQueryRangeResponse)),
),
);
});
it('renders without crashing', async () => {
await act(async () => {
renderContextLogRenderer();
});
await waitFor(() => {
expect(screen.getAllByText('Load more')).toHaveLength(2);
expect(screen.getByText(/Test log message/)).toBeInTheDocument();
});
});
it('loads new logs when clicking Load more button', async () => {
await act(async () => {
renderContextLogRenderer();
});
await waitFor(() => {
expect(screen.getAllByText('Load more')).toHaveLength(2);
expect(screen.getByText(/Test log message/)).toBeInTheDocument();
});
const loadMoreButtons = screen.getAllByText('Load more');
await act(async () => {
await userEvent.click(loadMoreButtons[1]);
});
await waitFor(() => {
expect(screen.getAllByText(/Failed to authenticate/)).toHaveLength(3);
});
});
});

View File

@@ -0,0 +1,146 @@
import { ILog } from 'types/api/logs/log';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
export const mockLog: ILog = {
id: 'test-log-id',
date: '2024-03-20T10:00:00Z',
timestamp: '2024-03-20T10:00:00Z',
body: 'Test log message',
attributesString: {},
attributesInt: {},
attributesFloat: {},
attributes_string: {},
severityText: 'info',
severityNumber: 0,
traceId: '',
spanID: '',
traceFlags: 0,
resources_string: {},
scope_string: {},
severity_text: 'info',
severity_number: 0,
};
export const mockQuery: Query = {
queryType: EQueryType.QUERY_BUILDER,
builder: {
queryData: [
{
aggregateOperator: 'count',
disabled: false,
queryName: 'A',
groupBy: [],
orderBy: [],
limit: 100,
dataSource: DataSource.LOGS,
aggregateAttribute: {
key: 'body',
type: 'string',
dataType: DataTypes.String,
isColumn: true,
},
timeAggregation: 'sum',
functions: [],
having: [],
stepInterval: 60,
legend: '',
filters: {
items: [],
op: 'AND',
},
expression: 'A',
reduceTo: 'sum',
},
],
queryFormulas: [],
},
clickhouse_sql: [],
id: 'test-query-id',
promql: [],
};
const mockBaseAutocompleteData: BaseAutocompleteData = {
key: 'service',
type: 'string',
dataType: DataTypes.String,
isColumn: true,
};
export const mockTagFilter: TagFilter = {
items: [
{
id: 'test-filter-id',
key: mockBaseAutocompleteData,
op: '=',
value: 'test-service',
},
],
op: 'AND',
};
export const mockQueryRangeResponse = {
status: 'success',
data: {
resultType: '',
result: [
{
queryName: 'A',
list: [
{
timestamp: '2025-04-29T09:55:22.462039242Z',
data: {
attributes_bool: {},
attributes_number: {},
attributes_string: {
'log.file.path':
'/var/log/pods/generator_mongodb-0_755b8973-28c1-4698-a20f-22ee85c52c3f/mongodb/0.log',
'log.iostream': 'stdout',
logtag: 'F',
},
body:
'{"t":{"$date":"2025-04-29T09:55:22.461+00:00"},"s":"I", "c":"ACCESS", "id":5286307, "ctx":"conn231150","msg":"Failed to authenticate","attr":{"client":"10.32.2.33:58258","isSpeculative":false,"isClusterMember":false,"mechanism":"SCRAM-SHA-1","user":"$(MONGO_USER)","db":"admin","error":"UserNotFound: Could not find user \\"$(MONGO_USER)\\" for db \\"admin\\"","result":11,"metrics":{"conversation_duration":{"micros":473,"summary":{"0":{"step":1,"step_total":2,"duration_micros":446}}}},"extraInfo":{}}}',
id: '2wOlVEhbqYipTUgs3PRMFF1hqjJ',
resources_string: {
'cloud.account.id': 'signoz-staging',
'cloud.availability_zone': 'us-central1-c',
'cloud.platform': 'gcp_kubernetes_engine',
'cloud.provider': 'gcp',
'container.image.name': 'docker.io/bitnami/mongodb',
'container.image.tag': '7.0.14-debian-12-r0',
'deployment.environment': 'sample-flask',
'host.id': '6006012725680193244',
'host.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-d54x',
'k8s.cluster.name': 'mgmt',
'k8s.container.name': 'mongodb',
'k8s.container.restart_count': '0',
'k8s.namespace.name': 'generator',
'k8s.node.name': 'gke-mgmt-pl-generator-e2st4-sp-41c1bdc8-d54x',
'k8s.node.uid': 'ef650183-226d-41c0-8295-aeec210b15dd',
'k8s.pod.name': 'mongodb-0',
'k8s.pod.start_time': '2025-04-26T04:47:44Z',
'k8s.pod.uid': '755b8973-28c1-4698-a20f-22ee85c52c3f',
'k8s.statefulset.name': 'mongodb',
'os.type': 'linux',
'service.name': 'mongodb',
},
scope_name: '',
scope_string: {},
scope_version: '',
severity_number: 0,
severity_text: '',
span_id: '',
trace_flags: 0,
trace_id: '',
},
},
],
},
],
},
};

View File

@@ -134,6 +134,8 @@ export const useContextLogData = ({
enabled: !!requestData,
onSuccess: handleSuccess,
},
undefined, // params
false, // isDependentOnQB
);
const handleShowNextLines = useCallback(() => {

View File

@@ -0,0 +1,350 @@
/* eslint-disable sonarjs/no-identical-functions */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import { Color } from '@signozhq/design-tokens';
import { Card, Tooltip, Typography } from 'antd';
import { ColumnsType } from 'antd/es/table';
import { InspectMetricsSeries } from 'api/metricsExplorer/getInspectMetricsDetails';
import classNames from 'classnames';
import ResizeTable from 'components/ResizeTable/ResizeTable';
import { DataType } from 'container/LogDetailedView/TableView';
import { ArrowDownCircle, ArrowRightCircle, Focus } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW,
TIME_AGGREGATION_OPTIONS,
} from './constants';
import {
ExpandedViewProps,
InspectionStep,
SpaceAggregationOptions,
TimeAggregationOptions,
} from './types';
import {
formatTimestampToFullDateTime,
getRawDataFromTimeSeries,
getSpaceAggregatedDataFromTimeSeries,
} from './utils';
function ExpandedView({
options,
spaceAggregationSeriesMap,
step,
metricInspectionOptions,
timeAggregatedSeriesMap,
}: ExpandedViewProps): JSX.Element {
const [
selectedTimeSeries,
setSelectedTimeSeries,
] = useState<InspectMetricsSeries | null>(null);
useEffect(() => {
if (step !== InspectionStep.COMPLETED) {
setSelectedTimeSeries(options?.timeSeries ?? null);
} else {
setSelectedTimeSeries(null);
}
}, [step, options?.timeSeries]);
const spaceAggregatedData = useMemo(() => {
if (
!options?.timeSeries ||
!options?.timestamp ||
step !== InspectionStep.COMPLETED
) {
return [];
}
return getSpaceAggregatedDataFromTimeSeries(
options?.timeSeries,
spaceAggregationSeriesMap,
options?.timestamp,
true,
);
}, [options?.timeSeries, options?.timestamp, spaceAggregationSeriesMap, step]);
const rawData = useMemo(() => {
if (!selectedTimeSeries || !options?.timestamp) {
return [];
}
return getRawDataFromTimeSeries(selectedTimeSeries, options?.timestamp, true);
}, [selectedTimeSeries, options?.timestamp]);
const absoluteValue = useMemo(
() =>
options?.timeSeries?.values.find(
(value) => value.timestamp >= options?.timestamp,
)?.value ?? options?.value,
[options],
);
const timeAggregatedData = useMemo(() => {
if (step !== InspectionStep.SPACE_AGGREGATION || !options?.timestamp) {
return [];
}
return (
timeAggregatedSeriesMap
.get(options?.timestamp)
?.filter(
(popoverData) =>
popoverData.title && popoverData.title === options.timeSeries?.title,
) ?? []
);
}, [
step,
options?.timestamp,
options?.timeSeries?.title,
timeAggregatedSeriesMap,
]);
const tableData = useMemo(() => {
if (!selectedTimeSeries) {
return [];
}
return Object.entries(selectedTimeSeries.labels).map(([key, value]) => ({
label: key,
value,
}));
}, [selectedTimeSeries]);
const columns: ColumnsType<DataType> = useMemo(
() => [
{
title: 'Label',
dataIndex: 'label',
key: 'label',
width: 50,
align: 'left',
className: 'labels-key',
},
{
title: 'Value',
dataIndex: 'value',
key: 'value',
width: 50,
align: 'left',
ellipsis: true,
className: 'labels-value',
},
],
[],
);
return (
<div className="expanded-view">
<div className="expanded-view-header">
<Typography.Title level={5}>
<Focus size={16} color={Color.BG_VANILLA_100} />
<div>POINT INSPECTOR</div>
</Typography.Title>
</div>
{/* Show only when space aggregation is completed */}
{step === InspectionStep.COMPLETED && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
<Typography.Text strong>
{`${absoluteValue} is the ${
SPACE_AGGREGATION_OPTIONS_FOR_EXPANDED_VIEW[
metricInspectionOptions.spaceAggregationOption ??
SpaceAggregationOptions.SUM_BY
]
} of`}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{spaceAggregatedData?.map(({ value, title, timestamp }) => (
<Tooltip key={`${title}-${timestamp}-${value}`} title={value}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{value}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIME SERIES
</Typography.Text>
<div className="graph-popover-inner-row">
{spaceAggregatedData?.map(({ title, timeSeries }) => (
<Tooltip key={title} title={title}>
<div
data-testid="graph-popover-cell"
className={classNames('graph-popover-cell', 'timeseries-cell', {
selected: title === selectedTimeSeries?.title,
})}
onClick={(): void => {
setSelectedTimeSeries(timeSeries ?? null);
}}
>
{title}
{selectedTimeSeries?.title === title ? (
<ArrowDownCircle color={Color.BG_FOREST_300} size={12} />
) : (
<ArrowRightCircle size={12} />
)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Show only for space aggregated or raw data */}
{selectedTimeSeries && step !== InspectionStep.SPACE_AGGREGATION && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
{step !== InspectionStep.COMPLETED && (
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
)}
<Typography.Text strong>
{step === InspectionStep.COMPLETED
? `${
selectedTimeSeries?.values.find(
(value) => value?.timestamp >= (options?.timestamp || 0),
)?.value ?? options?.value
} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`
: selectedTimeSeries?.values.find(
(value) => value?.timestamp >= (options?.timestamp || 0),
)?.value ?? options?.value}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
RAW VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{rawData?.map(({ value: rawValue, timestamp, title }) => (
<Tooltip key={`${title}-${timestamp}-${rawValue}`} title={rawValue}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{rawValue}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIMESTAMPS
</Typography.Text>
<div className="graph-popover-inner-row">
{rawData?.map(({ timestamp }) => (
<Tooltip
key={timestamp}
title={formatTimestampToFullDateTime(timestamp ?? '', true)}
>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{formatTimestampToFullDateTime(timestamp ?? '', true)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Show raw values breakdown only for time aggregated data */}
{selectedTimeSeries && step === InspectionStep.SPACE_AGGREGATION && (
<div className="graph-popover">
<Card className="graph-popover-card" size="small">
{/* Header */}
<div className="graph-popover-row">
<Typography.Text className="graph-popover-header-text">
{formatTimestampToFullDateTime(options?.timestamp ?? 0)}
</Typography.Text>
<Typography.Text strong>
{`${absoluteValue} is the ${
TIME_AGGREGATION_OPTIONS[
metricInspectionOptions.timeAggregationOption ??
TimeAggregationOptions.SUM
]
} of`}
</Typography.Text>
</div>
{/* Table */}
<div className="graph-popover-section">
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
RAW VALUES
</Typography.Text>
<div className="graph-popover-inner-row">
{timeAggregatedData?.map(({ value, title, timestamp }) => (
<Tooltip key={`${title}-${timestamp}-${value}`} title={value}>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{value}
</div>
</Tooltip>
))}
</div>
</div>
<div className="graph-popover-row">
<Typography.Text className="graph-popover-row-label">
TIMESTAMPS
</Typography.Text>
<div className="graph-popover-inner-row">
{timeAggregatedData?.map(({ timestamp }) => (
<Tooltip
key={timestamp}
title={formatTimestampToFullDateTime(timestamp ?? '', true)}
>
<div className="graph-popover-cell" data-testid="graph-popover-cell">
{formatTimestampToFullDateTime(timestamp ?? '', true)}
</div>
</Tooltip>
))}
</div>
</div>
</div>
</Card>
</div>
)}
{/* Labels */}
{selectedTimeSeries && (
<>
<Typography.Title
level={5}
>{`${selectedTimeSeries?.title} Labels`}</Typography.Title>
<ResizeTable
columns={columns}
tableLayout="fixed"
dataSource={tableData}
pagination={false}
showHeader={false}
scroll={{ y: 600 }}
className="labels-table"
/>
</>
)}
</div>
);
}
export default ExpandedView;

View File

@@ -0,0 +1,71 @@
import { Button, Card, Typography } from 'antd';
import { ArrowRight } from 'lucide-react';
import { useMemo } from 'react';
import { GraphPopoverProps } from './types';
import { formatTimestampToFullDateTime } from './utils';
function GraphPopover({
options,
popoverRef,
openInExpandedView,
}: GraphPopoverProps): JSX.Element | null {
const { x, y, value, timestamp, timeSeries } = options || {
x: 0,
y: 0,
value: 0,
timestamp: 0,
timeSeries: null,
};
const closestTimestamp = useMemo(() => {
if (!timeSeries) {
return timestamp;
}
return timeSeries?.values.reduce((prev, curr) => {
const prevDiff = Math.abs(prev.timestamp - timestamp);
const currDiff = Math.abs(curr.timestamp - timestamp);
return prevDiff < currDiff ? prev : curr;
}).timestamp;
}, [timeSeries, timestamp]);
const closestValue = useMemo(() => {
if (!timeSeries) {
return value;
}
const index = timeSeries.values.findIndex(
(value) => value.timestamp === closestTimestamp,
);
return index !== undefined && index >= 0
? timeSeries?.values[index].value
: null;
}, [timeSeries, closestTimestamp, value]);
return (
<div
style={{
top: y + 10,
left: x + 10,
}}
ref={popoverRef}
className="inspect-graph-popover"
>
<Card className="inspect-graph-popover-content" size="small">
<div className="inspect-graph-popover-row">
<Typography.Text type="secondary">
{formatTimestampToFullDateTime(closestTimestamp)}
</Typography.Text>
<Typography.Text>{Number(closestValue).toFixed(2)}</Typography.Text>
</div>
<div className="inspect-graph-popover-button-row">
<Button size="small" type="primary" onClick={openInExpandedView}>
<Typography.Text>View details</Typography.Text>
<ArrowRight size={10} />
</Button>
</div>
</Card>
</div>
);
}
export default GraphPopover;

View File

@@ -0,0 +1,256 @@
import { Color } from '@signozhq/design-tokens';
import { Button, Skeleton, Switch, Typography } from 'antd';
import Uplot from 'components/Uplot';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import { AppState } from 'store/reducers';
import { GlobalReducer } from 'types/reducer/globalTime';
import { formatNumberIntoHumanReadableFormat } from '../Summary/utils';
import { METRIC_TYPE_TO_COLOR_MAP, METRIC_TYPE_TO_ICON_MAP } from './constants';
import GraphPopover from './GraphPopover';
import TableView from './TableView';
import { GraphPopoverOptions, GraphViewProps } from './types';
import { HoverPopover, onGraphClick, onGraphHover } from './utils';
function GraphView({
inspectMetricsTimeSeries,
formattedInspectMetricsTimeSeries,
metricUnit,
metricName,
metricType,
spaceAggregationSeriesMap,
inspectionStep,
setPopoverOptions,
popoverOptions,
setShowExpandedView,
setExpandedViewOptions,
metricInspectionOptions,
isInspectMetricsRefetching,
}: GraphViewProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const graphRef = useRef<HTMLDivElement>(null);
const dimensions = useResizeObserver(graphRef);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const start = useMemo(() => Math.floor(Number(minTime) / 1000000000), [
minTime,
]);
const end = useMemo(() => Math.floor(Number(maxTime) / 1000000000), [maxTime]);
const [showGraphPopover, setShowGraphPopover] = useState(false);
const [showHoverPopover, setShowHoverPopover] = useState(false);
const [
hoverPopoverOptions,
setHoverPopoverOptions,
] = useState<GraphPopoverOptions | null>(null);
const [viewType, setViewType] = useState<'graph' | 'table'>('graph');
const popoverRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handleClickOutside(event: MouseEvent): void {
if (
popoverRef.current &&
!popoverRef.current.contains(event.target as Node) &&
graphRef.current &&
!graphRef.current.contains(event.target as Node)
) {
setShowGraphPopover(false);
}
}
document.addEventListener('mousedown', handleClickOutside);
return (): void => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [popoverRef, graphRef]);
const options: uPlot.Options = useMemo(
() => ({
width: dimensions.width,
height: 500,
legend: {
show: false,
},
axes: [
{
stroke: isDarkMode ? Color.TEXT_VANILLA_400 : Color.BG_SLATE_400,
grid: {
show: false,
},
values: (_, vals): string[] =>
vals.map((v) => {
const d = new Date(v);
const date = `${String(d.getDate()).padStart(2, '0')}/${String(
d.getMonth() + 1,
).padStart(2, '0')}`;
const time = `${String(d.getHours()).padStart(2, '0')}:${String(
d.getMinutes(),
).padStart(2, '0')}:${String(d.getSeconds()).padStart(2, '0')}`;
return `${date}\n${time}`; // two-line label
}),
},
{
label: metricUnit || '',
stroke: isDarkMode ? Color.TEXT_VANILLA_400 : Color.BG_SLATE_400,
grid: {
show: true,
stroke: isDarkMode ? Color.BG_SLATE_500 : Color.BG_SLATE_200,
},
values: (_, vals): string[] =>
vals.map((v) => formatNumberIntoHumanReadableFormat(v, false)),
},
],
series: [
{ label: 'Time' }, // This config is required as a placeholder for x-axis,
...formattedInspectMetricsTimeSeries.slice(1).map((_, index) => ({
drawStyle: 'line',
lineInterpolation: 'spline',
show: true,
label: String.fromCharCode(65 + (index % 26)),
stroke: inspectMetricsTimeSeries[index]?.strokeColor,
width: 2,
spanGaps: true,
points: {
size: 5,
show: false,
stroke: inspectMetricsTimeSeries[index]?.strokeColor,
},
scales: {
x: {
min: start,
max: end,
},
},
})),
],
hooks: {
ready: [
(u: uPlot): void => {
u.over.addEventListener('click', (e) => {
onGraphClick(
e,
u,
popoverRef,
setPopoverOptions,
inspectMetricsTimeSeries,
showGraphPopover,
setShowGraphPopover,
formattedInspectMetricsTimeSeries,
);
});
u.over.addEventListener('mousemove', (e) => {
onGraphHover(
e,
u,
setHoverPopoverOptions,
inspectMetricsTimeSeries,
formattedInspectMetricsTimeSeries,
);
});
u.over.addEventListener('mouseenter', () => {
setShowHoverPopover(true);
});
u.over.addEventListener('mouseleave', () => {
setShowHoverPopover(false);
});
},
],
},
}),
[
dimensions.width,
isDarkMode,
metricUnit,
formattedInspectMetricsTimeSeries,
inspectMetricsTimeSeries,
start,
end,
setPopoverOptions,
showGraphPopover,
],
);
const MetricTypeIcon = metricType ? METRIC_TYPE_TO_ICON_MAP[metricType] : null;
return (
<div className="inspect-metrics-graph-view" ref={graphRef}>
<div className="inspect-metrics-graph-view-header">
<Button.Group>
<Button
className="metric-name-button-label"
size="middle"
icon={
MetricTypeIcon && metricType ? (
<MetricTypeIcon
size={14}
color={METRIC_TYPE_TO_COLOR_MAP[metricType]}
/>
) : null
}
disabled
>
{metricName}
</Button>
<Button className="time-series-button-label" size="middle" disabled>
{/* First time series in that of timestamps. Hence -1 */}
{`${formattedInspectMetricsTimeSeries.length - 1} time series`}
</Button>
</Button.Group>
<div className="view-toggle-button">
<Switch
checked={viewType === 'graph'}
onChange={(checked): void => setViewType(checked ? 'graph' : 'table')}
/>
<Typography.Text>
{viewType === 'graph' ? 'Graph View' : 'Table View'}
</Typography.Text>
</div>
</div>
<div className="graph-view-container">
{viewType === 'graph' &&
(isInspectMetricsRefetching ? (
<Skeleton active />
) : (
<Uplot data={formattedInspectMetricsTimeSeries} options={options} />
))}
{viewType === 'table' && (
<TableView
inspectionStep={inspectionStep}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
setShowExpandedView={setShowExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
metricInspectionOptions={metricInspectionOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
)}
</div>
{showGraphPopover && (
<GraphPopover
options={popoverOptions}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
popoverRef={popoverRef}
step={inspectionStep}
openInExpandedView={(): void => {
setShowGraphPopover(false);
setShowExpandedView(true);
setExpandedViewOptions(popoverOptions);
}}
/>
)}
{showHoverPopover && !showGraphPopover && hoverPopoverOptions && (
<HoverPopover
options={hoverPopoverOptions}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
/>
)}
</div>
);
}
export default GraphView;

View File

@@ -1,4 +1,14 @@
.inspect-metrics-modal {
display: flex;
gap: 16px;
.inspect-metrics-fallback {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
}
.inspect-metrics-title {
display: flex;
align-items: center;
@@ -13,4 +23,567 @@
color: var(--text-vanilla-500);
}
}
.inspect-metrics-content {
display: flex;
flex-direction: row;
justify-content: space-between;
.inspect-metrics-content-first-col {
display: flex;
flex-direction: column;
flex: 2;
gap: 16px;
padding-right: 24px;
border-right: 1px solid var(--bg-slate-400);
width: 60%;
.inspect-metrics-graph-view {
display: flex;
flex-direction: column;
gap: 32px;
.inspect-metrics-graph-view-header {
display: flex;
align-items: center;
justify-content: space-between;
.ant-btn-group {
.time-series-button-label,
.metric-name-button-label {
display: flex;
align-items: center;
justify-content: center;
cursor: default;
span {
color: var(--text-vanilla-100);
}
}
}
.view-toggle-button {
display: flex;
gap: 8px;
align-items: center;
}
}
.graph-view-container {
min-height: 520px;
.inspect-metrics-table-view {
max-width: 100%;
.ant-spin-nested-loading {
.ant-spin-container {
.ant-table {
height: 450px;
overflow-y: scroll;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
}
}
}
.table-view-title-header,
.table-view-values-header {
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
.ant-card {
cursor: pointer;
width: 100px;
max-width: 100px;
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
.ant-card-body {
padding: 6px 8px;
}
&:hover {
opacity: 0.8;
}
}
}
}
}
}
.inspect-metrics-query-builder {
display: flex;
flex-direction: column;
gap: 4px;
.inspect-metrics-query-builder-header {
.query-builder-button-label {
display: flex;
align-items: center;
justify-content: center;
cursor: default;
span {
color: var(--text-vanilla-100);
}
}
}
.inspect-metrics-query-builder-content {
.ant-card-body {
display: flex;
flex-direction: column;
gap: 16px;
.selected-step {
color: var(--bg-sakura-500);
.ant-typography {
color: var(--bg-sakura-500);
}
}
.inspect-metrics-input-group {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
.ant-typography {
min-width: 130px;
}
.ant-select {
flex-grow: 1;
}
.no-arrows-input input[type='number']::-webkit-inner-spin-button,
.no-arrows-input input[type='number']::-webkit-outer-spin-button {
-webkit-appearance: none;
margin: 0;
}
/* Hide number input arrows (Firefox) */
.no-arrows-input input[type='number'] {
appearance: none;
-moz-appearance: textfield;
}
}
.metric-time-aggregation {
display: flex;
flex-direction: column;
gap: 16px;
.metric-time-aggregation-header {
display: flex;
gap: 8px;
}
.metric-time-aggregation-content {
display: flex;
gap: 24px;
width: 100%;
.inspect-metrics-input-group {
width: 50%;
}
}
}
.metric-space-aggregation {
display: flex;
flex-direction: column;
gap: 16px;
.metric-space-aggregation-header {
display: flex;
gap: 8px;
}
.metric-space-aggregation-content {
display: flex;
gap: 8px;
width: 100%;
.metric-space-aggregation-content-left {
width: 130px;
}
}
}
}
}
.metric-filters {
.query-builder-search-container {
width: 100%;
.ant-select {
.ant-select-selector {
background-color: var(--bg-ink-400);
color: var(--text-vanilla-100);
border-color: var(--bg-slate-400);
}
}
}
}
}
}
.inspect-metrics-content-second-col {
display: flex;
flex-direction: column;
gap: 16px;
flex: 1;
.home-checklist-container {
padding-left: 40px;
display: flex;
flex-direction: column;
gap: 16px;
padding-bottom: 32px;
border-bottom: 1px solid var(--bg-slate-400);
.home-checklist-title {
display: flex;
flex-direction: column;
gap: 8px;
}
.completed-checklist-container {
margin-left: 20px;
}
.completed-message-container {
display: flex;
flex-direction: column;
gap: 16px;
height: 100px;
.ant-btn {
width: fit-content;
}
}
}
.expanded-view {
display: flex;
flex-direction: column;
gap: 16px;
padding-left: 40px;
}
}
}
}
.inspect-graph-popover {
position: fixed;
z-index: 1000;
.inspect-graph-popover-content {
display: flex;
flex-direction: column;
gap: 16px;
min-width: 350px;
.inspect-graph-popover-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
width: 100%;
}
.inspect-graph-popover-button-row {
display: flex;
align-items: center;
justify-content: flex-end;
.ant-btn {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 16px;
}
}
}
}
.graph-popover {
position: fixed;
z-index: 1000;
.graph-popover-card {
width: 550px;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 16px;
.ant-card-body {
width: fit-content;
}
.graph-popover-row {
margin-top: 12px;
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
.graph-popover-row-label {
width: 100px;
}
.graph-popover-inner-row {
display: flex;
align-items: center;
gap: 8px;
.ant-typography {
width: 400px;
margin-top: 4px;
align-items: center;
display: flex;
gap: 8px;
overflow-x: auto;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
text-overflow: ellipsis;
}
}
}
.graph-popover-header-text {
color: var(--text-vanilla-400);
}
.graph-popover-row-label {
color: var(--bg-slate-50);
width: 10%;
}
.graph-popover-cell {
padding: 4px 8px;
background-color: #1f1f1f;
border-radius: 4px;
color: #fff;
min-width: 60px;
max-width: 60px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.footer-row {
margin-top: 12px;
display: flex;
gap: 8px;
align-items: center;
.footer-text {
white-space: nowrap;
}
.footer-divider {
flex: 1;
border-top: 1px dashed #ccc;
margin: 0 8px;
}
}
}
}
.expanded-view {
.expanded-view-header {
.ant-typography {
display: flex;
align-items: center;
justify-content: flex-start;
gap: 8px;
}
}
.graph-popover {
z-index: 2;
position: initial;
.graph-popover-card {
width: 100%;
.timeseries-cell {
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
&:hover {
opacity: 60%;
}
}
.selected {
opacity: 90%;
}
.graph-popover-section {
width: 500px;
overflow-x: scroll;
white-space: nowrap;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
scrollbar-color: #ccc transparent;
text-overflow: ellipsis;
.graph-popover-row {
.graph-popover-row-label {
min-width: 100px;
}
.graph-popover-inner-row {
display: flex;
align-items: center;
gap: 8px;
}
}
}
}
}
.labels-table {
border: 1px solid var(--bg-slate-400);
.labels-key {
color: var(--bg-vanilla-400);
background-color: var(--bg-slate-500);
font-family: 'Geist Mono';
}
.labels-value {
background-color: var(--bg-slate-500);
opacity: 80%;
font-family: 'Geist Mono';
.field-renderer-container {
.label {
color: var(--bg-slate-400);
}
}
}
}
}
.hover-popover-card {
position: fixed;
z-index: 500;
max-width: 700px;
display: flex;
flex-direction: column;
gap: 8px;
.hover-popover-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
}
.lightMode {
.inspect-metrics-modal {
.inspect-metrics-title {
.inspect-metrics-button {
color: var(--text-ink-400);
}
}
.inspect-metrics-content {
.inspect-metrics-content-first-col {
.inspect-metrics-graph-view {
.inspect-metrics-graph-view-header {
.ant-btn-group {
.time-series-button-label,
.metric-name-button-label {
span {
color: var(--text-ink-100);
}
}
}
}
}
.inspect-metrics-query-builder {
.inspect-metrics-query-builder-header {
.query-builder-button-label {
span {
color: var(--text-ink-100);
}
}
}
.metric-filters {
.query-builder-search-v2 {
.ant-select {
.ant-select-selector {
background-color: var(--bg-vanilla-100);
color: var(--text-ink-100);
border: 0.5px solid var(--bg-slate-300) !important;
}
}
}
}
}
}
}
}
.graph-popover {
.graph-popover-card {
.graph-popover-header-text {
color: var(--text-ink-400);
}
.graph-popover-row-label {
color: var(--bg-slate-50);
}
.graph-popover-cell {
background-color: var(--bg-vanilla-300);
color: var(--text-ink-100);
}
.footer-row {
.footer-divider {
border-top: 1px dashed var(--bg-slate-300);
}
}
}
}
.expanded-view {
.labels-table {
border: 1px solid var(--bg-vanilla-400);
.labels-key {
color: var(--bg-slate-400);
background-color: var(--bg-vanilla-400);
}
.labels-value {
background-color: var(--bg-vanilla-400);
.field-renderer-container {
.label {
color: var(--bg-vanilla-400);
}
}
}
}
}
}

View File

@@ -2,15 +2,236 @@ import './Inspect.styles.scss';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens';
import { Button, Drawer, Typography } from 'antd';
import { Button, Drawer, Empty, Skeleton, Typography } from 'antd';
import { useGetMetricDetails } from 'hooks/metricsExplorer/useGetMetricDetails';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { Compass } from 'lucide-react';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { InspectProps } from './types';
import ExpandedView from './ExpandedView';
import GraphView from './GraphView';
import QueryBuilder from './QueryBuilder';
import Stepper from './Stepper';
import { GraphPopoverOptions, InspectProps } from './types';
import { useInspectMetrics } from './useInspectMetrics';
function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element {
function Inspect({
metricName: defaultMetricName,
isOpen,
onClose,
}: InspectProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const [metricName, setMetricName] = useState<string | null>(defaultMetricName);
const [
popoverOptions,
setPopoverOptions,
] = useState<GraphPopoverOptions | null>(null);
const [
expandedViewOptions,
setExpandedViewOptions,
] = useState<GraphPopoverOptions | null>(null);
const [showExpandedView, setShowExpandedView] = useState(false);
const { data: metricDetailsData } = useGetMetricDetails(metricName ?? '', {
enabled: !!metricName,
});
const { currentQuery } = useQueryBuilder();
const { handleChangeQueryData } = useQueryOperations({
index: 0,
query: currentQuery.builder.queryData[0],
entityVersion: '',
});
const updatedCurrentQuery = useMemo(
() => ({
...currentQuery,
builder: {
...currentQuery.builder,
queryData: [
{
...currentQuery.builder.queryData[0],
aggregateOperator: 'noop',
aggregateAttribute: {
...currentQuery.builder.queryData[0].aggregateAttribute,
},
},
],
},
}),
[currentQuery],
);
const searchQuery = updatedCurrentQuery?.builder?.queryData[0] || null;
useEffect(() => {
handleChangeQueryData('filters', {
op: 'AND',
items: [],
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const {
inspectMetricsTimeSeries,
inspectMetricsStatusCode,
isInspectMetricsLoading,
isInspectMetricsError,
formattedInspectMetricsTimeSeries,
spaceAggregationLabels,
metricInspectionOptions,
dispatchMetricInspectionOptions,
inspectionStep,
isInspectMetricsRefetching,
spaceAggregatedSeriesMap: spaceAggregationSeriesMap,
aggregatedTimeSeries,
timeAggregatedSeriesMap,
reset,
} = useInspectMetrics(metricName);
const selectedMetricType = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.metric_type,
[metricDetailsData],
);
const selectedMetricUnit = useMemo(
() => metricDetailsData?.payload?.data?.metadata?.unit,
[metricDetailsData],
);
const resetInspection = useCallback(() => {
setShowExpandedView(false);
setPopoverOptions(null);
setExpandedViewOptions(null);
reset();
}, [reset]);
// Reset inspection when the selected metric changes
useEffect(() => {
resetInspection();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [metricName]);
// Hide expanded view whenever inspection step changes
useEffect(() => {
setShowExpandedView(false);
setExpandedViewOptions(null);
}, [inspectionStep]);
const content = useMemo(() => {
if (isInspectMetricsLoading && !isInspectMetricsRefetching) {
return (
<div
data-testid="inspect-metrics-loading"
className="inspect-metrics-fallback"
>
<Skeleton active />
</div>
);
}
if (isInspectMetricsError || inspectMetricsStatusCode !== 200) {
const errorMessage =
inspectMetricsStatusCode === 400
? 'The time range is too large. Please modify it to be within 30 minutes.'
: 'Error loading inspect metrics.';
return (
<div
data-testid="inspect-metrics-error"
className="inspect-metrics-fallback"
>
<Empty description={errorMessage} />
</div>
);
}
if (!inspectMetricsTimeSeries.length) {
return (
<div
data-testid="inspect-metrics-empty"
className="inspect-metrics-fallback"
>
<Empty description="No time series found for this metric to inspect." />
</div>
);
}
return (
<div className="inspect-metrics-content">
<div className="inspect-metrics-content-first-col">
<GraphView
inspectMetricsTimeSeries={aggregatedTimeSeries}
formattedInspectMetricsTimeSeries={formattedInspectMetricsTimeSeries}
resetInspection={resetInspection}
metricName={metricName}
metricUnit={selectedMetricUnit}
metricType={selectedMetricType}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
inspectionStep={inspectionStep}
setPopoverOptions={setPopoverOptions}
setShowExpandedView={setShowExpandedView}
showExpandedView={showExpandedView}
setExpandedViewOptions={setExpandedViewOptions}
popoverOptions={popoverOptions}
metricInspectionOptions={metricInspectionOptions}
isInspectMetricsRefetching={isInspectMetricsRefetching}
/>
<QueryBuilder
metricName={metricName}
metricType={selectedMetricType}
setMetricName={setMetricName}
spaceAggregationLabels={spaceAggregationLabels}
metricInspectionOptions={metricInspectionOptions}
dispatchMetricInspectionOptions={dispatchMetricInspectionOptions}
inspectionStep={inspectionStep}
inspectMetricsTimeSeries={inspectMetricsTimeSeries}
searchQuery={searchQuery}
/>
</div>
<div className="inspect-metrics-content-second-col">
<Stepper
inspectionStep={inspectionStep}
resetInspection={resetInspection}
/>
{showExpandedView && (
<ExpandedView
options={expandedViewOptions}
spaceAggregationSeriesMap={spaceAggregationSeriesMap}
step={inspectionStep}
metricInspectionOptions={metricInspectionOptions}
timeAggregatedSeriesMap={timeAggregatedSeriesMap}
/>
)}
</div>
</div>
);
}, [
isInspectMetricsLoading,
isInspectMetricsRefetching,
isInspectMetricsError,
inspectMetricsStatusCode,
inspectMetricsTimeSeries,
aggregatedTimeSeries,
formattedInspectMetricsTimeSeries,
resetInspection,
metricName,
selectedMetricUnit,
selectedMetricType,
spaceAggregationSeriesMap,
inspectionStep,
showExpandedView,
popoverOptions,
metricInspectionOptions,
spaceAggregationLabels,
dispatchMetricInspectionOptions,
searchQuery,
expandedViewOptions,
timeAggregatedSeriesMap,
]);
return (
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
@@ -38,8 +259,7 @@ function Inspect({ metricName, isOpen, onClose }: InspectProps): JSX.Element {
className="inspect-metrics-modal"
destroyOnClose
>
<div>Inspect</div>
<div>{metricName}</div>
{content}
</Drawer>
</Sentry.ErrorBoundary>
);

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