Compare commits

...

26 Commits

Author SHA1 Message Date
manika-signoz
5cf4e814a4 Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-22 17:55:46 +05:30
SagarRajput-7
84ae5b4ca9 fix: added dashboard route param, to allow trigger when new panel creation action happens (#9141) 2025-09-22 11:53:14 +05:30
Nityananda Gohain
a564fa9d28 fix: dont accept materialized key from payload (#9139)
* fix: dont accept materialized key from payload

* fix: use correct omit operator
2025-09-22 05:11:57 +00:00
aniketio-ctrl
7f4390f370 fix: Edit and patch rule functionality (#9125)
* fix: fixed edit and patch rule functionality

* fix: fixed edit and patch rule functionality

* fix: fixed edit and patch rule functionality

* fix: added patch rule test and rule mock store

* fix: removed schema version field

* fix: removed schema version field

* fix: added test cases for patch, create, edit

* fix: removed schema version field
2025-09-21 17:48:31 +05:30
Aditya Singh
c41ae00433 fix intermittent failing test (#9138)
* feat: context links processors

* feat: context variables hook added

* feat: add support for field variables

* feat: minor refactor

* feat: minor refactor

* feat: minor refactor

* feat: handle on save

* feat: minor refactor

* feat: snapshot update

* feat: revert qbv5

* feat: aggregation header val

* feat: fix header color

* feat: minor refactor

* feat: minor refactor

* feat: fix breaking changes from qb v5

* feat: change api for breakout opitons

* feat: minor refactor

* feat: minor refactor

* fix: added fix for extractquerypararms when value is string in multivalue operator

* feat: minor refactor

* feat: add back in breakout

* feat: minor refactor

* feat: add substitute var api call to decode vars

* feat: minor fix

* feat: optimize query value comparison in QueryBuilderV2

* feat: minor fix

* feat: minor fix

* feat: test fix

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added variable in url and made dashboard sync around that and sharable (#7944)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added variable in url and made dashboard sync around that and sharable

* feat: added test cases

* feat: added safety check

* feat: enabled url setting on first load itself

* feat: code refactor

* feat: cleared options query param when on dashboard list page

* feat: resolved conflicts

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: updated test case

* feat: corrected the regex matcher for resolved titles

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* feat: minor refactor

* feat: added test cases

* feat: refactor

* feat: remove consoles

* feat: pass panel types to substitutevars

* feat: cross filtering init

* fix: added fix for query builder filters

* feat: cross filtering add set/unset/create functionality

* feat: test update

* fix: added migration to filter expression for crud operations of variable

* feat: format legend name according to existing format

* feat: breakout test init

* feat: breakout test match query

* feat: context links tests

* feat: minor refactor

* feat: show edit only if user has access

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: updated test case

* feat: corrected the regex matcher for resolved titles

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* fix: added migration to filter expression for crud operations of variable

* feat: reverted dynamic variable url config changes (#8877)

* Revert "feat: changed query param name"

This reverts commit 62bee5f003.

* Revert "feat: added user-friendly format to dashboard variable url"

This reverts commit 6de8b1c2e8.

* feat: reverted url var changes

* feat: reverted url changed from usedashboardvarupdate hook

* feat: send empty array for widgetId

* feat: added type in the variables in query_range payload for dynamic

* feat: minor fixes

* fix: added fix for multivalue operator without brackets

* feat: minor fix

* feat: fix failing test

* feat: change revert

* test: added tests for querycontextUtils + querybuilderv2 utils

* fix: added fix for replacing filter with the new value

* fix: added fix for replacing filters + datetimepicker composite query

* test: fixed querybuilderv2 utils test

* feat: handle number dataType in filters

* feat: correct the variable addition to panel format for new qb expression

* feat: remove other queries in breakout

* feat: add metric to traces mapping

* feat: pass proper time range

* feat: update time range logic

* feat: value panel drilldown init

* feat: value panel drilldown init

* feat: enable context links in value panel

* feat: minor fix

* feat: update snapshot

* feat: hide breakout in value panel

* feat: add panel type to view mode

* feat: add support to change panel in breakouts

* feat: panel change for breakout logic added

* chore: fix style

* chore: show variables suggestion while creating context links

* chore: add timestamp to graphs

* chore: add timestamp to table panel

* chore: fix failing tests

* chore: fix infinite re-rendering due to queryRange

* chore: send appropriate time range when signal is metrics

* chore: show variables suggestion while creating context links

* chore: minor refactor

* chore: show trace details link if filter has trace_id

* chore: fix infinite render of table component

* chore: added tests for v2

* fix: context links set from dropdown

* chore: minor refactor

* chore: minor refactor

* chore: fix test

* chore: fix timerange for apm metrics

* fix: get correct timestamp for clicked data

* chore: comment out change to histogram on breakout by number

* chore: change panel type on panel type change in url

* chore: remove consoles

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: fix lint and test cases

* feat: fix typo

* feat: fixed test case

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: corrected the regex matcher for resolved titles

* feat: fixed test cases

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* fix: added migration to filter expression for crud operations of variable

* feat: added type in the variables in query_range payload for dynamic

* feat: correct the variable addition to panel format for new qb expression

* feat: added test cases for dynamic variable and add/remove panel feat

* feat: implemented where clause suggestion in new qb v5

* feat: added retries for dyn variable and fixed on-enter selection issue

* feat: added relatedValues and existing query in param related changes

* feat: sanitized data storage and removed duplicates

* fix: fixed typechecks

* feat: updated panel wait and refetch logic and ALL option selection

* feat: fixed variable tabel reordering issue

* feat: added empty name validation in variable creation

* feat: change value to searchtext in values API

* feat: added option for regex in the component, disabled for now

* feat: added beta and not rec. tag in variable tabs

* feat: added check to prevent api and updates calls with same payload

* feat: optimized localstorage for all selection in dynamic variable and updated __all__ case

* feat: resolved variable tables infinite loop update error

* feat: aded variable name auto-update based on attribute name entered for dynamic variables

* feat: modified only/all click behaviour and set all selection always true for dynamic variable

* feat: fix dropdown closing doesn't reset us back to our all available values when we have a search

* feat: handled all state distinction and carry forward in existing variables

* feat: trucate + n more tooltip content to 10

* feat: fixed infinite loop because of dependency of frequently changing object ref in var table

* feat: fixed inconsist search implementations

* feat: reverted only - all updated area implementation

* feat: added more space for search in multiselect component

* feat: checked for variable id instead of variable key for refetch

* feat: improved performance around multiselect component and added confirm modal for apply to all

* feat: rewrite functionality around add and remove panels

* feat: changed color for apply to all modal

* feat: added changes under flag to handle variable specific removal for removeKeysFromExpression func

* feat: added validation in variable edit panel

* chore: fix dynamic variable update in context menu to latest logic

* chore: minor fix

* chore: type fix

* fix: remove unwanted code

* fix: remove unwanted code

* fix: resolved pr comments

* fix: minor fix

* fix: fix tests

* fix: style fix

* fix: hide drilldown options in view mode for non-builder panels

* chore: add global uplot mock

* chore: query builder context update to all provider

* chore: add cursor rules init

* chore: useSafeNavigate mock added

* chore: more cleanups

* chore: remove react-router-v5 mock from setup

* chore: update cursorrules

* chore: add tests readme init

* chore: minor refactor

* fix: refetch quick filters on revisit to page

* fix: return expected response from queryFn and use as state

* fix: change getByRole to getByText for performant test

* chore: add sonner mock

* chore: mock revert

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
Co-authored-by: Abhi Kumar <ahrefabhi@gmail.com>
Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2025-09-20 11:18:04 +05:30
Amlan Kumar Nandy
9aacf7f2f5 chore: add context and time utils for usage in alerts (#9114) 2025-09-19 06:18:53 +00:00
SagarRajput-7
792d0f3db6 fix: removed staleTime and cacheTime from query client level (#9124)
* fix: removed staleTime for dashboard API, to fetch fresh data while switching between dashboards

* fix: removed query client level staleTime and cacheTime

* fix: adding dashbaordID to the query key

* fix: removed unnecessary query key
2025-09-19 10:55:19 +05:30
Aditya Singh
47e8a89dbe Fix: No quick filter found screen on navigating back from a diff screen (#9121)
* feat: minor refactor

* feat: change contextlinks data structure

* feat: context menu changes init

* feat: context menu hook refactor

* feat: context links processors

* feat: context variables hook added

* feat: add support for field variables

* feat: minor refactor

* feat: minor refactor

* feat: minor refactor

* feat: handle on save

* feat: minor refactor

* feat: snapshot update

* feat: revert qbv5

* feat: aggregation header val

* feat: fix header color

* feat: minor refactor

* feat: minor refactor

* feat: fix breaking changes from qb v5

* feat: change api for breakout opitons

* feat: minor refactor

* feat: minor refactor

* fix: added fix for extractquerypararms when value is string in multivalue operator

* feat: minor refactor

* feat: add back in breakout

* feat: minor refactor

* feat: add substitute var api call to decode vars

* feat: minor fix

* feat: optimize query value comparison in QueryBuilderV2

* feat: minor fix

* feat: minor fix

* feat: test fix

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added variable in url and made dashboard sync around that and sharable (#7944)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added variable in url and made dashboard sync around that and sharable

* feat: added test cases

* feat: added safety check

* feat: enabled url setting on first load itself

* feat: code refactor

* feat: cleared options query param when on dashboard list page

* feat: resolved conflicts

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: updated test case

* feat: corrected the regex matcher for resolved titles

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* feat: minor refactor

* feat: added test cases

* feat: refactor

* feat: remove consoles

* feat: pass panel types to substitutevars

* feat: cross filtering init

* fix: added fix for query builder filters

* feat: cross filtering add set/unset/create functionality

* feat: test update

* fix: added migration to filter expression for crud operations of variable

* feat: format legend name according to existing format

* feat: breakout test init

* feat: breakout test match query

* feat: context links tests

* feat: minor refactor

* feat: show edit only if user has access

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: updated test case

* feat: corrected the regex matcher for resolved titles

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* fix: added migration to filter expression for crud operations of variable

* feat: reverted dynamic variable url config changes (#8877)

* Revert "feat: changed query param name"

This reverts commit 62bee5f003.

* Revert "feat: added user-friendly format to dashboard variable url"

This reverts commit 6de8b1c2e8.

* feat: reverted url var changes

* feat: reverted url changed from usedashboardvarupdate hook

* feat: send empty array for widgetId

* feat: added type in the variables in query_range payload for dynamic

* feat: minor fixes

* fix: added fix for multivalue operator without brackets

* feat: minor fix

* feat: fix failing test

* feat: change revert

* test: added tests for querycontextUtils + querybuilderv2 utils

* fix: added fix for replacing filter with the new value

* fix: added fix for replacing filters + datetimepicker composite query

* test: fixed querybuilderv2 utils test

* feat: handle number dataType in filters

* feat: correct the variable addition to panel format for new qb expression

* feat: remove other queries in breakout

* feat: add metric to traces mapping

* feat: pass proper time range

* feat: update time range logic

* feat: value panel drilldown init

* feat: value panel drilldown init

* feat: enable context links in value panel

* feat: minor fix

* feat: update snapshot

* feat: hide breakout in value panel

* feat: add panel type to view mode

* feat: add support to change panel in breakouts

* feat: panel change for breakout logic added

* chore: fix style

* chore: show variables suggestion while creating context links

* chore: add timestamp to graphs

* chore: add timestamp to table panel

* chore: fix failing tests

* chore: fix infinite re-rendering due to queryRange

* chore: send appropriate time range when signal is metrics

* chore: show variables suggestion while creating context links

* chore: minor refactor

* chore: show trace details link if filter has trace_id

* chore: fix infinite render of table component

* chore: added tests for v2

* fix: context links set from dropdown

* chore: minor refactor

* chore: minor refactor

* chore: fix test

* chore: fix timerange for apm metrics

* fix: get correct timestamp for clicked data

* chore: comment out change to histogram on breakout by number

* chore: change panel type on panel type change in url

* chore: remove consoles

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: fix lint and test cases

* feat: fix typo

* feat: fixed test case

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: corrected the regex matcher for resolved titles

* feat: fixed test cases

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* fix: added migration to filter expression for crud operations of variable

* feat: added type in the variables in query_range payload for dynamic

* feat: correct the variable addition to panel format for new qb expression

* feat: added test cases for dynamic variable and add/remove panel feat

* feat: implemented where clause suggestion in new qb v5

* feat: added retries for dyn variable and fixed on-enter selection issue

* feat: added relatedValues and existing query in param related changes

* feat: sanitized data storage and removed duplicates

* fix: fixed typechecks

* feat: updated panel wait and refetch logic and ALL option selection

* feat: fixed variable tabel reordering issue

* feat: added empty name validation in variable creation

* feat: change value to searchtext in values API

* feat: added option for regex in the component, disabled for now

* feat: added beta and not rec. tag in variable tabs

* feat: added check to prevent api and updates calls with same payload

* feat: optimized localstorage for all selection in dynamic variable and updated __all__ case

* feat: resolved variable tables infinite loop update error

* feat: aded variable name auto-update based on attribute name entered for dynamic variables

* feat: modified only/all click behaviour and set all selection always true for dynamic variable

* feat: fix dropdown closing doesn't reset us back to our all available values when we have a search

* feat: handled all state distinction and carry forward in existing variables

* feat: trucate + n more tooltip content to 10

* feat: fixed infinite loop because of dependency of frequently changing object ref in var table

* feat: fixed inconsist search implementations

* feat: reverted only - all updated area implementation

* feat: added more space for search in multiselect component

* feat: checked for variable id instead of variable key for refetch

* feat: improved performance around multiselect component and added confirm modal for apply to all

* feat: rewrite functionality around add and remove panels

* feat: changed color for apply to all modal

* feat: added changes under flag to handle variable specific removal for removeKeysFromExpression func

* feat: added validation in variable edit panel

* chore: fix dynamic variable update in context menu to latest logic

* chore: minor fix

* chore: type fix

* fix: remove unwanted code

* fix: remove unwanted code

* fix: resolved pr comments

* fix: minor fix

* fix: fix tests

* fix: style fix

* fix: hide drilldown options in view mode for non-builder panels

* chore: add global uplot mock

* chore: query builder context update to all provider

* chore: add cursor rules init

* chore: useSafeNavigate mock added

* chore: more cleanups

* chore: remove react-router-v5 mock from setup

* chore: update cursorrules

* chore: add tests readme init

* chore: minor refactor

* fix: refetch quick filters on revisit to page

* fix: return expected response from queryFn and use as state

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
Co-authored-by: Abhi Kumar <ahrefabhi@gmail.com>
Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2025-09-18 12:51:54 +05:30
Aditya Singh
bced4774bb feat: frontend unit test suite setup (#9027)
* feat: update context link modal form init

* feat: add double way sync on urls and param

* feat: minor refactor

* feat: minor refactor

* feat: change contextlinks data structure

* feat: context menu changes init

* feat: context menu hook refactor

* feat: context links processors

* feat: context variables hook added

* feat: add support for field variables

* feat: minor refactor

* feat: minor refactor

* feat: minor refactor

* feat: handle on save

* feat: minor refactor

* feat: snapshot update

* feat: revert qbv5

* feat: aggregation header val

* feat: fix header color

* feat: minor refactor

* feat: minor refactor

* feat: fix breaking changes from qb v5

* feat: change api for breakout opitons

* feat: minor refactor

* feat: minor refactor

* fix: added fix for extractquerypararms when value is string in multivalue operator

* feat: minor refactor

* feat: add back in breakout

* feat: minor refactor

* feat: add substitute var api call to decode vars

* feat: minor fix

* feat: optimize query value comparison in QueryBuilderV2

* feat: minor fix

* feat: minor fix

* feat: test fix

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added variable in url and made dashboard sync around that and sharable (#7944)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added variable in url and made dashboard sync around that and sharable

* feat: added test cases

* feat: added safety check

* feat: enabled url setting on first load itself

* feat: code refactor

* feat: cleared options query param when on dashboard list page

* feat: resolved conflicts

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: updated test case

* feat: corrected the regex matcher for resolved titles

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* feat: minor refactor

* feat: added test cases

* feat: refactor

* feat: remove consoles

* feat: pass panel types to substitutevars

* feat: cross filtering init

* fix: added fix for query builder filters

* feat: cross filtering add set/unset/create functionality

* feat: test update

* fix: added migration to filter expression for crud operations of variable

* feat: format legend name according to existing format

* feat: breakout test init

* feat: breakout test match query

* feat: context links tests

* feat: minor refactor

* feat: show edit only if user has access

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: updated test case

* feat: corrected the regex matcher for resolved titles

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* fix: added migration to filter expression for crud operations of variable

* feat: reverted dynamic variable url config changes (#8877)

* Revert "feat: changed query param name"

This reverts commit 62bee5f003.

* Revert "feat: added user-friendly format to dashboard variable url"

This reverts commit 6de8b1c2e8.

* feat: reverted url var changes

* feat: reverted url changed from usedashboardvarupdate hook

* feat: send empty array for widgetId

* feat: added type in the variables in query_range payload for dynamic

* feat: minor fixes

* fix: added fix for multivalue operator without brackets

* feat: minor fix

* feat: fix failing test

* feat: change revert

* test: added tests for querycontextUtils + querybuilderv2 utils

* fix: added fix for replacing filter with the new value

* fix: added fix for replacing filters + datetimepicker composite query

* test: fixed querybuilderv2 utils test

* feat: handle number dataType in filters

* feat: correct the variable addition to panel format for new qb expression

* feat: remove other queries in breakout

* feat: add metric to traces mapping

* feat: pass proper time range

* feat: update time range logic

* feat: value panel drilldown init

* feat: value panel drilldown init

* feat: enable context links in value panel

* feat: minor fix

* feat: update snapshot

* feat: hide breakout in value panel

* feat: add panel type to view mode

* feat: add support to change panel in breakouts

* feat: panel change for breakout logic added

* chore: fix style

* chore: show variables suggestion while creating context links

* chore: add timestamp to graphs

* chore: add timestamp to table panel

* chore: fix failing tests

* chore: fix infinite re-rendering due to queryRange

* chore: send appropriate time range when signal is metrics

* chore: show variables suggestion while creating context links

* chore: minor refactor

* chore: show trace details link if filter has trace_id

* chore: fix infinite render of table component

* chore: added tests for v2

* fix: context links set from dropdown

* chore: minor refactor

* chore: minor refactor

* chore: fix test

* chore: fix timerange for apm metrics

* fix: get correct timestamp for clicked data

* chore: comment out change to histogram on breakout by number

* chore: change panel type on panel type change in url

* chore: remove consoles

* feat: added dynamic variables creation flow (#7541)

* feat: added dynamic variables creation flow

* feat: added keys and value apis and hooks

* feat: added api and select component changes

* feat: added keys fetching and preview values

* feat: added dynamic variable to variable items

* feat: handled value persistence and tab switches

* feat: added default value and formed a schema for dyn-variables

* feat: added client and server side searches

* feat: corrected the initial load getfieldKey api

* feat: removed fetch on mount restriction

* feat: added dynamic variable to the dashboard details (#7755)

* feat: added dynamic variable to the dashboard details

* feat: added new component to existing variables

* feat: added enhancement to multiselect and select for dyn-variables

* feat: added refetch method between all dynamic-variables

* feat: correct error handling

* feat: correct error handling

* feat: enforced non-empty selectedvalues and default value

* feat: added client and server side searches

* feat: retry on error

* feat: correct error handling

* feat: handle defautl value in existing variables

* feat: lowercase the source for payload

* feat: fixed the incorrect assignment of active indices

* feat: improved handling of all option

* feat: improved the ALL option visuals

* feat: handled default value enforcement in existing variables

* feat: added unix time to values call

* feat: added incomplete data message and info to search

* feat: changed dashboard panel call handling with existing variables

* feat: adjusted the response type and data with the new API schema for values

* feat: code refactor

* feat: made dyn-variable option as the default

* feat: added test cases for dyn variable creation and completion

* feat: updated test cases

* feat: fix lint and test cases

* feat: fix typo

* feat: fixed test case

* feat: added dynamic variable suggestion in where clause

* feat: added test cases for hooks and api call functions

* feat: added test case for querybuildersearchv2 suggestion changes

* feat: code refactor

* feat: corrected the regex matcher for resolved titles

* feat: fixed test cases

* feat: added ability to add/remove variable filter to one or more existing panels

* feat: added widgetselector on variable creation

* feat: show labels in widget selector

* feat: added apply to all and variable removal logical

* feat: refectch only related and affected panels in case of dynamic variables

* feat: added button loader for apply-all

* feat: light-mode styles

* fix: added migration to filter expression for crud operations of variable

* feat: added type in the variables in query_range payload for dynamic

* feat: correct the variable addition to panel format for new qb expression

* feat: added test cases for dynamic variable and add/remove panel feat

* feat: implemented where clause suggestion in new qb v5

* feat: added retries for dyn variable and fixed on-enter selection issue

* feat: added relatedValues and existing query in param related changes

* feat: sanitized data storage and removed duplicates

* fix: fixed typechecks

* feat: updated panel wait and refetch logic and ALL option selection

* feat: fixed variable tabel reordering issue

* feat: added empty name validation in variable creation

* feat: change value to searchtext in values API

* feat: added option for regex in the component, disabled for now

* feat: added beta and not rec. tag in variable tabs

* feat: added check to prevent api and updates calls with same payload

* feat: optimized localstorage for all selection in dynamic variable and updated __all__ case

* feat: resolved variable tables infinite loop update error

* feat: aded variable name auto-update based on attribute name entered for dynamic variables

* feat: modified only/all click behaviour and set all selection always true for dynamic variable

* feat: fix dropdown closing doesn't reset us back to our all available values when we have a search

* feat: handled all state distinction and carry forward in existing variables

* feat: trucate + n more tooltip content to 10

* feat: fixed infinite loop because of dependency of frequently changing object ref in var table

* feat: fixed inconsist search implementations

* feat: reverted only - all updated area implementation

* feat: added more space for search in multiselect component

* feat: checked for variable id instead of variable key for refetch

* feat: improved performance around multiselect component and added confirm modal for apply to all

* feat: rewrite functionality around add and remove panels

* feat: changed color for apply to all modal

* feat: added changes under flag to handle variable specific removal for removeKeysFromExpression func

* feat: added validation in variable edit panel

* chore: fix dynamic variable update in context menu to latest logic

* chore: minor fix

* chore: type fix

* fix: remove unwanted code

* fix: remove unwanted code

* fix: resolved pr comments

* fix: minor fix

* fix: fix tests

* fix: style fix

* fix: hide drilldown options in view mode for non-builder panels

* chore: add global uplot mock

* chore: query builder context update to all provider

* chore: add cursor rules init

* chore: useSafeNavigate mock added

* chore: more cleanups

* chore: remove react-router-v5 mock from setup

* chore: update cursorrules

* chore: add tests readme init

* chore: minor refactor

---------

Co-authored-by: Aditya Singh <adityasingh@Adityas-MacBook-Pro.local>
Co-authored-by: Abhi Kumar <ahrefabhi@gmail.com>
Co-authored-by: SagarRajput-7 <162284829+SagarRajput-7@users.noreply.github.com>
Co-authored-by: SagarRajput-7 <sagar@signoz.io>
2025-09-18 12:19:57 +05:30
Vikrant Gupta
0c25de9560 feat(authz): build authz service (#9064)
* feat(authz): define the domain layer

* feat(authz): added openfga schema and split the enterprise code

* feat(authz): revert http handler

* feat(authz): address comments

* feat(authz): address comments

* feat(authz): typo comments

* feat(authz): address review comments

* feat(authz): address review comments

* feat(authz): update the oss model

* feat(authz): update the sequential check
2025-09-17 21:35:11 +05:30
Shaheer Kochai
24307b48ff Fix: trace details bugfixes (#9116)
* fix: make the trace details sidebar scrollable

* fix: fix the long value overflowing trace details attributes

* fix: fix the layout issues in trace details v2

* Revert "fix: make the trace details sidebar scrollable"

This reverts commit 469022ed6a.

* fix: make the trace details sidebar scrollable

* fix: make the attribute value take 100% width

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-09-17 19:28:34 +05:30
manika-signoz
bba2385c8b Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-17 18:03:13 +05:30
manika-signoz
6491e31242 Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-16 10:08:50 +05:30
manika-signoz
86b572b1af Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-12 15:21:49 +05:30
manika-signoz
1779d66a9e fix: failing test for create alert button 2025-09-12 02:54:56 +05:30
manika-signoz
1c7e80cf38 Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-12 01:40:23 +05:30
manika-signoz
b091746051 chore: cleanups, add more places 2025-09-12 01:37:56 +05:30
manika-signoz
eaaf244ab1 fix: use override method on alerts and overview 2025-09-11 01:56:28 +05:30
manika-signoz
1f2db8bf4b Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-10 20:30:26 +05:30
manika-signoz
b4c2427c24 fix: typescript errors 2025-09-10 20:29:40 +05:30
manika-signoz
24ace81568 fix: remove metaKeyHandler utils 2025-09-10 20:04:09 +05:30
manika-signoz
b5a784b50f Merge branch 'enhancement/cmd-click-across-routes' of github.com:SigNoz/signoz into enhancement/cmd-click-across-routes 2025-09-10 20:00:48 +05:30
manika-signoz
c43482a572 feat: override history.push and safeNavigate 2025-09-10 19:59:24 +05:30
manika-signoz
9f480b6fbe Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-08 21:03:25 +05:30
manika-signoz
b48ac31f0d Merge branch 'main' into enhancement/cmd-click-across-routes 2025-09-07 20:45:31 +05:30
manika-signoz
4668c7f15b feat: init cmd click 2025-09-04 21:34:40 +05:30
134 changed files with 5455 additions and 1128 deletions

View File

@@ -0,0 +1,44 @@
module base
type organisation
relations
define read: [user, role#assignee]
define update: [user, role#assignee]
type user
relations
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type anonymous
type role
relations
define assignee: [user]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resources
relations
define create: [user, role#assignee]
define list: [user, role#assignee]
define read: [user, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
type resource
relations
define read: [user, anonymous, role#assignee]
define update: [user, role#assignee]
define delete: [user, role#assignee]
define block: [user, role#assignee]
type telemetry
relations
define read: [user, anonymous, role#assignee]

View File

@@ -0,0 +1,29 @@
package openfgaschema
import (
"context"
_ "embed"
"github.com/SigNoz/signoz/pkg/authz"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
)
var (
//go:embed base.fga
baseDSL string
)
type schema struct{}
func NewSchema() authz.Schema {
return &schema{}
}
func (schema *schema) Get(ctx context.Context) []openfgapkgtransformer.ModuleFile {
return []openfgapkgtransformer.ModuleFile{
{
Name: "base.fga",
Contents: baseDSL,
},
}
}

132
ee/http/middleware/authz.go Normal file
View File

@@ -0,0 +1,132 @@
package middleware
import (
"log/slog"
"net/http"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/gorilla/mux"
)
const (
authzDeniedMessage string = "::AUTHZ-DENIED::"
)
type AuthZ struct {
logger *slog.Logger
authzService authz.AuthZ
}
func NewAuthZ(logger *slog.Logger) *AuthZ {
if logger == nil {
panic("cannot build authz middleware, logger is empty")
}
return &AuthZ{logger: logger}
}
func (middleware *AuthZ) ViewAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsViewer(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) EditAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsEditor(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) AdminAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
if err := claims.IsAdmin(); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) SelfAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
id := mux.Vars(req)["id"]
if err := claims.IsSelfAccess(id); err != nil {
middleware.logger.WarnContext(req.Context(), authzDeniedMessage, "claims", claims)
render.Error(rw, err)
return
}
next(rw, req)
})
}
func (middleware *AuthZ) OpenAccess(next http.HandlerFunc) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
next(rw, req)
})
}
// Check middleware accepts the relation, typeable, parentTypeable (for direct access + group relations) and a callback function to derive selector and parentSelectors on per request basis.
func (middleware *AuthZ) Check(next http.HandlerFunc, relation authtypes.Relation, translation authtypes.Relation, typeable authtypes.Typeable, parentTypeable authtypes.Typeable, cb authtypes.SelectorCallbackFn) http.HandlerFunc {
return http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
selector, parentSelectors, err := cb(req)
if err != nil {
render.Error(rw, err)
return
}
err = middleware.authzService.CheckWithTupleCreation(req.Context(), claims, relation, typeable, selector, parentTypeable, parentSelectors...)
if err != nil {
render.Error(rw, err)
return
}
next(rw, req)
})
}

View File

@@ -8,6 +8,8 @@ import (
"net/http"
_ "net/http/pprof" // http profiler
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"github.com/gorilla/handlers"
"github.com/SigNoz/signoz/ee/query-service/app/api"
@@ -334,6 +336,8 @@ func makeRulesManager(
querier querier.Querier,
logger *slog.Logger,
) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
managerOpts := &baserules.ManagerOptions{
TelemetryStore: telemetryStore,
@@ -348,8 +352,10 @@ func makeRulesManager(
PrepareTaskFunc: rules.PrepareTaskFunc,
PrepareTestRuleFunc: rules.TestNotification,
Alertmanager: alertmanager,
SQLStore: sqlstore,
OrgGetter: orgGetter,
RuleStore: ruleStore,
MaintenanceStore: maintenanceStore,
SqlStore: sqlstore,
}
// create Manager

484
frontend/.cursorrules Normal file
View File

@@ -0,0 +1,484 @@
# Persona
You are an expert developer with deep knowledge of Jest, React Testing Library, MSW, and TypeScript, tasked with creating unit tests for this repository.
# Auto-detect TypeScript Usage
Check for TypeScript in the project through tsconfig.json or package.json dependencies.
Adjust syntax based on this detection.
# TypeScript Type Safety for Jest Tests
**CRITICAL**: All Jest tests MUST be fully type-safe with proper TypeScript types.
**Type Safety Requirements:**
- Use proper TypeScript interfaces for all mock data
- Type all Jest mock functions with `jest.MockedFunction<T>`
- Use generic types for React components and hooks
- Define proper return types for mock functions
- Use `as const` for literal types when needed
- Avoid `any` type use proper typing instead
# Unit Testing Focus
Focus on critical functionality (business logic, utility functions, component behavior)
Mock dependencies (API calls, external modules) before imports
Test multiple data scenarios (valid inputs, invalid inputs, edge cases)
Write maintainable tests with descriptive names grouped in describe blocks
# Global vs Local Mocks
**Use Global Mocks for:**
- High-frequency dependencies (20+ test files)
- Core infrastructure (react-router-dom, react-query, antd)
- Standard implementations across the app
- Browser APIs (ResizeObserver, matchMedia, localStorage)
- Utility libraries (date-fns, lodash)
**Use Local Mocks for:**
- Business logic dependencies (5-15 test files)
- Test-specific behavior (different data per test)
- API endpoints with specific responses
- Domain-specific components
- Error scenarios and edge cases
**Global Mock Files Available (from jest.config.ts):**
- `uplot` → `__mocks__/uplotMock.ts`
# Repo-specific Testing Conventions
## Imports
Always import from our harness:
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
```
For API mocks:
```ts
import { server, rest } from 'mocks-server/server';
```
Do not import directly from `@testing-library/react`.
## Router
Use the router built into render:
```ts
render(<Page />, undefined, { initialRoute: '/traces-explorer' });
```
Only mock `useLocation` / `useParams` if the test depends on them.
## Hook Mocks
Pattern:
```ts
import useFoo from 'hooks/useFoo';
jest.mock('hooks/useFoo');
const mockUseFoo = jest.mocked(useFoo);
mockUseFoo.mockReturnValue(/* minimal shape */ as any);
```
Prefer helpers (`rqSuccess`, `rqLoading`, `rqError`) for React Query results.
## MSW
Global MSW server runs automatically.
Override per-test:
```ts
server.use(
rest.get('*/api/v1/foo', (_req, res, ctx) => res(ctx.status(200), ctx.json({ ok: true })))
);
```
Keep large responses in `mocks-server/__mockdata_`.
## Interactions
- Prefer `userEvent` for real user interactions (click, type, select, tab).
- Use `fireEvent` only for low-level/programmatic events not covered by `userEvent` (e.g., scroll, resize, setting `element.scrollTop` for virtualization). Wrap in `act(...)` if needed.
- Always await interactions:
```ts
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(screen.getByRole('button', { name: /save/i }));
```
```ts
// Example: virtualized list scroll (no userEvent helper)
const scroller = container.querySelector('[data-test-id="virtuoso-scroller"]') as HTMLElement;
scroller.scrollTop = targetScrollTop;
act(() => { fireEvent.scroll(scroller); });
```
## Timers
❌ No global fake timers.
✅ Per-test only, for debounce/throttle:
```ts
jest.useFakeTimers();
const user = userEvent.setup({ advanceTimers: (ms) => jest.advanceTimersByTime(ms) });
await user.type(screen.getByRole('textbox'), 'query');
jest.advanceTimersByTime(400);
jest.useRealTimers();
```
## Queries
Prefer accessible queries (`getByRole`, `findByRole`, `getByLabelText`).
Fallback: visible text.
Last resort: `data-testid`.
# Example Test (using only configured global mocks)
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# Best Practices
- **Critical Functionality**: Prioritize testing business logic and utilities
- **Dependency Mocking**: Global mocks for infra, local mocks for business logic
- **Data Scenarios**: Always test valid, invalid, and edge cases
- **Descriptive Names**: Make test intent clear
- **Organization**: Group related tests in describe
- **Consistency**: Match repo conventions
- **Edge Cases**: Test null, undefined, unexpected values
- **Limit Scope**: 35 focused tests per file
- **Use Helpers**: `rqSuccess`, `makeUser`, etc.
- **No Any**: Enforce type safety
# Example Test
```ts
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { server, rest } from 'mocks-server/server';
import MyComponent from '../MyComponent';
describe('MyComponent', () => {
it('renders and interacts', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
server.use(
rest.get('*/api/v1/example', (_req, res, ctx) => res(ctx.status(200), ctx.json({ value: 42 })))
);
render(<MyComponent />, undefined, { initialRoute: '/foo' });
expect(await screen.findByText(/value: 42/i)).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: /refresh/i }));
await waitFor(() => expect(screen.getByText(/loading/i)).toBeInTheDocument());
});
});
```
# Anti-patterns
❌ Importing RTL directly
❌ Using global fake timers
❌ Wrapping render in `act(...)`
❌ Mocking infra dependencies locally (router, react-query)
✅ Use our harness (`tests/test-utils`)
✅ Use MSW for API overrides
✅ Use userEvent + await
✅ Pin time only in tests that assert relative dates
# TypeScript Type Safety Examples
## Proper Mock Typing
```ts
// ✅ GOOD - Properly typed mocks
interface User {
id: number;
name: string;
email: string;
}
interface ApiResponse<T> {
data: T;
status: number;
message: string;
}
// Type the mock functions
const mockFetchUser = jest.fn() as jest.MockedFunction<(id: number) => Promise<ApiResponse<User>>>;
const mockUpdateUser = jest.fn() as jest.MockedFunction<(user: User) => Promise<ApiResponse<User>>>;
// Mock implementation with proper typing
mockFetchUser.mockResolvedValue({
data: { id: 1, name: 'John Doe', email: 'john@example.com' },
status: 200,
message: 'Success'
});
// ❌ BAD - Using any type
const mockFetchUser = jest.fn() as any; // Don't do this
```
## React Component Testing with Types
```ts
// ✅ GOOD - Properly typed component testing
interface ComponentProps {
title: string;
data: User[];
onUserSelect: (user: User) => void;
isLoading?: boolean;
}
const TestComponent: React.FC<ComponentProps> = ({ title, data, onUserSelect, isLoading = false }) => {
// Component implementation
};
describe('TestComponent', () => {
it('should render with proper props', () => {
// Arrange - Type the props properly
const mockProps: ComponentProps = {
title: 'Test Title',
data: [{ id: 1, name: 'John', email: 'john@example.com' }],
onUserSelect: jest.fn() as jest.MockedFunction<(user: User) => void>,
isLoading: false
};
// Act
render(<TestComponent {...mockProps} />);
// Assert
expect(screen.getByText('Test Title')).toBeInTheDocument();
});
});
```
## Hook Testing with Types
```ts
// ✅ GOOD - Properly typed hook testing
interface UseUserDataReturn {
user: User | null;
loading: boolean;
error: string | null;
refetch: () => void;
}
const useUserData = (id: number): UseUserDataReturn => {
// Hook implementation
};
describe('useUserData', () => {
it('should return user data with proper typing', () => {
// Arrange
const mockUser: User = { id: 1, name: 'John', email: 'john@example.com' };
mockFetchUser.mockResolvedValue({
data: mockUser,
status: 200,
message: 'Success'
});
// Act
const { result } = renderHook(() => useUserData(1));
// Assert
expect(result.current.user).toEqual(mockUser);
expect(result.current.loading).toBe(false);
expect(result.current.error).toBeNull();
});
});
```
## Global Mock Type Safety
```ts
// ✅ GOOD - Type-safe global mocks
// In __mocks__/routerMock.ts
export const mockUseLocation = (overrides: Partial<Location> = {}): Location => ({
pathname: '/traces',
search: '',
hash: '',
state: null,
key: 'test-key',
...overrides,
});
// In test files
const location = useLocation(); // Properly typed from global mock
expect(location.pathname).toBe('/traces');
```
# TypeScript Configuration for Jest
## Required Jest Configuration
```json
// jest.config.ts
{
"preset": "ts-jest/presets/js-with-ts-esm",
"globals": {
"ts-jest": {
"useESM": true,
"isolatedModules": true,
"tsconfig": "<rootDir>/tsconfig.jest.json"
}
},
"extensionsToTreatAsEsm": [".ts", ".tsx"],
"moduleFileExtensions": ["ts", "tsx", "js", "json"]
}
```
## TypeScript Jest Configuration
```json
// tsconfig.jest.json
{
"extends": "./tsconfig.json",
"compilerOptions": {
"types": ["jest", "@testing-library/jest-dom"],
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"moduleResolution": "node"
},
"include": [
"src/**/*",
"**/*.test.ts",
"**/*.test.tsx",
"__mocks__/**/*"
]
}
```
## Common Type Safety Patterns
### Mock Function Typing
```ts
// ✅ GOOD - Proper mock function typing
const mockApiCall = jest.fn() as jest.MockedFunction<typeof apiCall>;
const mockEventHandler = jest.fn() as jest.MockedFunction<(event: Event) => void>;
// ❌ BAD - Using any
const mockApiCall = jest.fn() as any;
```
### Generic Mock Typing
```ts
// ✅ GOOD - Generic mock typing
interface MockApiResponse<T> {
data: T;
status: number;
}
const mockFetchData = jest.fn() as jest.MockedFunction<
<T>(endpoint: string) => Promise<MockApiResponse<T>>
>;
// Usage
mockFetchData<User>('/users').mockResolvedValue({
data: { id: 1, name: 'John' },
status: 200
});
```
### React Testing Library with Types
```ts
// ✅ GOOD - Typed testing utilities
import { render, screen, RenderResult } from '@testing-library/react';
import { ComponentProps } from 'react';
type TestComponentProps = ComponentProps<typeof TestComponent>;
const renderTestComponent = (props: Partial<TestComponentProps> = {}): RenderResult => {
const defaultProps: TestComponentProps = {
title: 'Test',
data: [],
onSelect: jest.fn(),
...props
};
return render(<TestComponent {...defaultProps} />);
};
```
### Error Handling with Types
```ts
// ✅ GOOD - Typed error handling
interface ApiError {
message: string;
code: number;
details?: Record<string, unknown>;
}
const mockApiError: ApiError = {
message: 'API Error',
code: 500,
details: { endpoint: '/users' }
};
mockFetchUser.mockRejectedValue(new Error(JSON.stringify(mockApiError)));
```
## Type Safety Checklist
- [ ] All mock functions use `jest.MockedFunction<T>`
- [ ] All mock data has proper interfaces
- [ ] No `any` types in test files
- [ ] Generic types are used where appropriate
- [ ] Error types are properly defined
- [ ] Component props are typed
- [ ] Hook return types are defined
- [ ] API response types are defined
- [ ] Global mocks are type-safe
- [ ] Test utilities are properly typed
# Mock Decision Tree
```
Is it used in 20+ test files?
├─ YES → Use Global Mock
│ ├─ react-router-dom
│ ├─ react-query
│ ├─ antd components
│ └─ browser APIs
└─ NO → Is it business logic?
├─ YES → Use Local Mock
│ ├─ API endpoints
│ ├─ Custom hooks
│ └─ Domain components
└─ NO → Is it test-specific?
├─ YES → Use Local Mock
│ ├─ Error scenarios
│ ├─ Loading states
│ └─ Specific data
└─ NO → Consider Global Mock
└─ If it becomes frequently used
```
# Common Anti-Patterns to Avoid
❌ **Don't mock global dependencies locally:**
```js
// BAD - This is already globally mocked
jest.mock('react-router-dom', () => ({ ... }));
```
❌ **Don't create global mocks for test-specific data:**
```js
// BAD - This should be local
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => specificTestData)
}));
```
✅ **Do use global mocks for infrastructure:**
```js
// GOOD - Use global mock
import { useLocation } from 'react-router-dom';
```
✅ **Do create local mocks for business logic:**
```js
// GOOD - Local mock for specific test needs
jest.mock('../api/tracesService', () => ({
getTraces: jest.fn(() => mockTracesData)
}));
```

View File

@@ -0,0 +1,51 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
// Mock for uplot library used in tests
export interface MockUPlotInstance {
setData: jest.Mock;
setSize: jest.Mock;
destroy: jest.Mock;
redraw: jest.Mock;
setSeries: jest.Mock;
}
export interface MockUPlotPaths {
spline: jest.Mock;
bars: jest.Mock;
}
// Create mock instance methods
const createMockUPlotInstance = (): MockUPlotInstance => ({
setData: jest.fn(),
setSize: jest.fn(),
destroy: jest.fn(),
redraw: jest.fn(),
setSeries: jest.fn(),
});
// Create mock paths
const mockPaths: MockUPlotPaths = {
spline: jest.fn(),
bars: jest.fn(),
};
// Mock static methods
const mockTzDate = jest.fn(
(date: Date, _timezone: string) => new Date(date.getTime()),
);
// Mock uPlot constructor - this needs to be a proper constructor function
function MockUPlot(
_options: unknown,
_data: unknown,
_target: HTMLElement,
): MockUPlotInstance {
return createMockUPlotInstance();
}
// Add static methods to the constructor
MockUPlot.tzDate = mockTzDate;
MockUPlot.paths = mockPaths;
// Export the constructor as default
export default MockUPlot;

View File

@@ -0,0 +1,29 @@
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
interface SafeNavigateOptions {
replace?: boolean;
state?: unknown;
}
interface SafeNavigateTo {
pathname?: string;
search?: string;
hash?: string;
}
type SafeNavigateToType = string | SafeNavigateTo;
interface UseSafeNavigateReturn {
safeNavigate: jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>;
}
export const useSafeNavigate = (): UseSafeNavigateReturn => ({
safeNavigate: jest.fn(
(to: SafeNavigateToType, options?: SafeNavigateOptions) => {
console.log(`Mock safeNavigate called with:`, to, options);
},
) as jest.MockedFunction<
(to: SafeNavigateToType, options?: SafeNavigateOptions) => void
>,
});

View File

@@ -1,5 +1,7 @@
import type { Config } from '@jest/types';
const USE_SAFE_NAVIGATE_MOCK_PATH = '<rootDir>/__mocks__/useSafeNavigate.ts';
const config: Config.InitialOptions = {
clearMocks: true,
coverageDirectory: 'coverage',
@@ -10,6 +12,10 @@ const config: Config.InitialOptions = {
moduleNameMapper: {
'\\.(css|less|scss)$': '<rootDir>/__mocks__/cssMock.ts',
'\\.md$': '<rootDir>/__mocks__/cssMock.ts',
'^uplot$': '<rootDir>/__mocks__/uplotMock.ts',
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
},
globals: {
extensionsToTreatAsEsm: ['.ts'],

View File

@@ -139,6 +139,7 @@
"redux": "^4.0.5",
"redux-thunk": "^2.3.0",
"rehype-raw": "7.0.0",
"rrule": "2.8.1",
"stream": "^0.0.2",
"style-loader": "1.3.0",
"styled-components": "^5.3.11",

View File

@@ -119,7 +119,9 @@ const filterAndSortTimezones = (
return createTimezoneEntry(normalizedTz, offset);
});
const generateTimezoneData = (includeEtcTimezones = false): Timezone[] => {
export const generateTimezoneData = (
includeEtcTimezones = false,
): Timezone[] => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const allTimezones = (Intl as any).supportedValuesOf('timeZone');
const timezones: Timezone[] = [];

View File

@@ -19,20 +19,6 @@ beforeAll(() => {
});
});
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-dnd', () => ({
useDrop: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),
useDrag: jest.fn().mockImplementation(() => [jest.fn(), jest.fn(), jest.fn()]),

View File

@@ -1,4 +1,4 @@
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import APIError from 'types/api/error';
import ErrorModal from './ErrorModal';
@@ -56,9 +56,8 @@ describe('ErrorModal Component', () => {
// Click the close button
const closeButton = screen.getByTestId('close-button');
act(() => {
fireEvent.click(closeButton);
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(closeButton);
// Check if onClose was called
expect(onCloseMock).toHaveBeenCalledTimes(1);
@@ -149,9 +148,8 @@ it('should open the modal when the trigger component is clicked', async () => {
// Click the trigger component
const triggerButton = screen.getByText('Open Error Modal');
act(() => {
fireEvent.click(triggerButton);
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(triggerButton);
// Check if the modal is displayed
expect(screen.getByText('An error occurred')).toBeInTheDocument();
@@ -170,18 +168,15 @@ it('should close the modal when the onCancel event is triggered', async () => {
// Click the trigger component
const triggerButton = screen.getByText('error');
act(() => {
fireEvent.click(triggerButton);
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(triggerButton);
await waitFor(() => {
expect(screen.getByText('An error occurred')).toBeInTheDocument();
});
// Trigger the onCancel event
act(() => {
fireEvent.click(screen.getByTestId('close-button'));
});
await user.click(screen.getByTestId('close-button'));
// Check if the modal is closed
expect(onCloseMock).toHaveBeenCalledTimes(1);

View File

@@ -50,7 +50,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
filterConfig,
isDynamicFilters,
customFilters,
setIsStale,
refetchCustomFilters,
isCustomFiltersLoading,
} = useFilterConfig({ signal, config });
@@ -263,7 +263,7 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
signal={signal}
setIsSettingsOpen={setIsSettingsOpen}
customFilters={customFilters}
setIsStale={setIsStale}
refetchCustomFilters={refetchCustomFilters}
/>
)}
</div>

View File

@@ -14,12 +14,12 @@ function QuickFiltersSettings({
signal,
setIsSettingsOpen,
customFilters,
setIsStale,
refetchCustomFilters,
}: {
signal: SignalType | undefined;
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
setIsStale: (isStale: boolean) => void;
refetchCustomFilters: () => void;
}): JSX.Element {
const {
handleSettingsClose,
@@ -34,7 +34,7 @@ function QuickFiltersSettings({
} = useQuickFilterSettings({
setIsSettingsOpen,
customFilters,
setIsStale,
refetchCustomFilters,
signal,
});

View File

@@ -12,7 +12,7 @@ import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
interface UseQuickFilterSettingsProps {
setIsSettingsOpen: (isSettingsOpen: boolean) => void;
customFilters: FilterType[];
setIsStale: (isStale: boolean) => void;
refetchCustomFilters: () => void;
signal?: SignalType;
}
@@ -32,7 +32,7 @@ interface UseQuickFilterSettingsReturn {
const useQuickFilterSettings = ({
customFilters,
setIsSettingsOpen,
setIsStale,
refetchCustomFilters,
signal,
}: UseQuickFilterSettingsProps): UseQuickFilterSettingsReturn => {
const [inputValue, setInputValue] = useState<string>('');
@@ -46,7 +46,7 @@ const useQuickFilterSettings = ({
} = useMutation(updateCustomFiltersAPI, {
onSuccess: () => {
setIsSettingsOpen(false);
setIsStale(true);
refetchCustomFilters();
logEvent('Quick Filters Settings: changes saved', {
addedFilters,
});

View File

@@ -1,12 +1,8 @@
import getCustomFilters from 'api/quickFilters/getCustomFilters';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useMemo, useState } from 'react';
import { useMemo } from 'react';
import { useQuery } from 'react-query';
import { ErrorResponse, SuccessResponse } from 'types/api';
import {
Filter as FilterType,
PayloadProps,
} from 'types/api/quickFilters/getCustomFilters';
import { Filter as FilterType } from 'types/api/quickFilters/getCustomFilters';
import { IQuickFiltersConfig, SignalType } from '../types';
import { getFilterConfig } from '../utils';
@@ -18,37 +14,34 @@ interface UseFilterConfigProps {
interface UseFilterConfigReturn {
filterConfig: IQuickFiltersConfig[];
customFilters: FilterType[];
setCustomFilters: React.Dispatch<React.SetStateAction<FilterType[]>>;
isCustomFiltersLoading: boolean;
isDynamicFilters: boolean;
setIsStale: React.Dispatch<React.SetStateAction<boolean>>;
refetchCustomFilters: () => void;
}
const useFilterConfig = ({
signal,
config,
}: UseFilterConfigProps): UseFilterConfigReturn => {
const [customFilters, setCustomFilters] = useState<FilterType[]>([]);
const [isStale, setIsStale] = useState(true);
const {
isFetching: isCustomFiltersLoading,
data: customFilters = [],
refetch,
} = useQuery<FilterType[], Error>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
async () => {
const res = await getCustomFilters({ signal: signal || '' });
return 'payload' in res && res.payload?.filters ? res.payload.filters : [];
},
{
enabled: !!signal,
},
);
const isDynamicFilters = useMemo(() => customFilters.length > 0, [
customFilters,
]);
const { isFetching: isCustomFiltersLoading } = useQuery<
SuccessResponse<PayloadProps> | ErrorResponse,
Error
>(
[REACT_QUERY_KEY.GET_CUSTOM_FILTERS, signal],
() => getCustomFilters({ signal: signal || '' }),
{
onSuccess: (data) => {
if ('payload' in data && data.payload?.filters) {
setCustomFilters(data.payload.filters || ([] as FilterType[]));
}
setIsStale(false);
},
enabled: !!signal && isStale,
},
);
const filterConfig = useMemo(
() => getFilterConfig(signal, customFilters, config),
[config, customFilters, signal],
@@ -57,10 +50,9 @@ const useFilterConfig = ({
return {
filterConfig,
customFilters,
setCustomFilters,
isCustomFiltersLoading,
isDynamicFilters,
setIsStale,
refetchCustomFilters: refetch,
};
};

View File

@@ -1,15 +1,6 @@
import '@testing-library/jest-dom';
import {
act,
cleanup,
fireEvent,
render,
screen,
waitFor,
} from '@testing-library/react';
import { ENVIRONMENT } from 'constants/env';
import ROUTES from 'constants/routes';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
@@ -18,8 +9,7 @@ import {
} from 'mocks-server/__mockdata__/customQuickFilters';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import MockQueryClientProvider from 'providers/test/MockQueryClientProvider';
import { USER_ROLES } from 'types/roles';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import QuickFilters from '../QuickFilters';
import { IQuickFiltersConfig, QuickFiltersSource, SignalType } from '../types';
@@ -29,21 +19,6 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
// eslint-disable-next-line sonarjs/no-duplicate-string
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({
pathname: `${process.env.FRONTEND_API_ENDPOINT}/${ROUTES.TRACES_EXPLORER}/`,
}),
}));
const userRole = USER_ROLES.ADMIN;
// mock useAppContext
jest.mock('providers/App/App', () => ({
useAppContext: jest.fn(() => ({ user: { role: userRole } })),
}));
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn();
@@ -78,11 +53,10 @@ const setupServer = (): void => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
rest.get(quickFiltersAttributeValuesURL, (req, res, ctx) =>
rest.get(quickFiltersAttributeValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
rest.get(fieldsValuesURL, (req, res, ctx) =>
rest.get(fieldsValuesURL, (_req, res, ctx) =>
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
),
);
@@ -96,14 +70,12 @@ function TestQuickFilters({
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<MockQueryClientProvider>
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
</MockQueryClientProvider>
<QuickFilters
source={QuickFiltersSource.EXCEPTIONS}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
);
}
@@ -118,11 +90,11 @@ beforeAll(() => {
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
afterAll(() => {
server.close();
cleanup();
});
beforeEach(() => {
@@ -151,9 +123,13 @@ describe('Quick Filters', () => {
});
it('should add filter data to query when checkbox is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters />);
const checkbox = screen.getByText('mq-kafka');
fireEvent.click(checkbox);
// Prefer role if possible; if label text isnt wired to input, clicking the label text is OK
const target = await screen.findByText('mq-kafka');
await user.click(target);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
@@ -182,16 +158,20 @@ describe('Quick Filters', () => {
describe('Quick Filters with custom filters', () => {
it('loads the custom filters correctly', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(screen.getByText('Filters for')).toBeInTheDocument();
expect(screen.getByText(QUERY_NAME)).toBeInTheDocument();
await screen.findByText(FILTER_SERVICE_NAME);
const allByText = await screen.findAllByText('otel-demo');
// since 2 filter collapse are open, there are 2 filter items visible
expect(allByText).toHaveLength(2);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
expect(await screen.findByText('Edit quick filters')).toBeInTheDocument();
@@ -207,16 +187,19 @@ describe('Quick Filters with custom filters', () => {
});
it('adds a filter from OTHER FILTERS to ADDED FILTERS when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const otherFilterItem = await screen.findByText(FILTER_K8S_DEPLOYMENT_NAME);
const addButton = otherFilterItem.parentElement?.querySelector('button');
expect(addButton).not.toBeNull();
fireEvent.click(addButton as HTMLButtonElement);
await user.click(addButton as HTMLButtonElement);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
await waitFor(() => {
@@ -225,17 +208,21 @@ describe('Quick Filters with custom filters', () => {
});
it('removes a filter from ADDED FILTERS and moves it to OTHER FILTERS', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
await user.click(removeBtn as HTMLButtonElement);
await waitFor(() => {
expect(addedSection).not.toContainElement(
@@ -250,17 +237,20 @@ describe('Quick Filters with custom filters', () => {
});
it('restores original filter state on Discard', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const addedSection = screen.getByText(ADDED_FILTERS_LABEL).parentElement!;
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
await user.click(removeBtn as HTMLButtonElement);
const otherSection = screen.getByText(OTHER_FILTERS_LABEL).parentElement!;
await waitFor(() => {
@@ -272,7 +262,11 @@ describe('Quick Filters with custom filters', () => {
);
});
fireEvent.click(screen.getByText(DISCARD_TEXT));
const discardBtn = screen
.getByText(DISCARD_TEXT)
.closest('button') as HTMLButtonElement;
expect(discardBtn).not.toBeNull();
await user.click(discardBtn);
await waitFor(() => {
expect(addedSection).toContainElement(
@@ -285,18 +279,25 @@ describe('Quick Filters with custom filters', () => {
});
it('saves the updated filters by calling PUT with correct payload', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
fireEvent.click(icon);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector('button');
expect(removeBtn).not.toBeNull();
fireEvent.click(removeBtn as HTMLButtonElement);
await user.click(removeBtn as HTMLButtonElement);
fireEvent.click(screen.getByText(SAVE_CHANGES_TEXT));
const saveBtn = screen
.getByText(SAVE_CHANGES_TEXT)
.closest('button') as HTMLButtonElement;
expect(saveBtn).not.toBeNull();
await user.click(saveBtn);
await waitFor(() => {
expect(putHandler).toHaveBeenCalled();
@@ -311,31 +312,36 @@ describe('Quick Filters with custom filters', () => {
expect(requestBody.signal).toBe(SIGNAL);
});
// render duration filter
it('should render duration slider for duration_nono filter', async () => {
// Set up fake timers **before rendering**
// Use fake timers only in this test (for debounce), and wire them to userEvent
jest.useFakeTimers();
const user = userEvent.setup({
advanceTimers: (ms) => jest.advanceTimersByTime(ms),
pointerEventsCheck: 0,
});
const { getByTestId } = render(<TestQuickFilters signal={SIGNAL} />);
await screen.findByText(FILTER_SERVICE_NAME);
expect(screen.getByText('Duration')).toBeInTheDocument();
// click to open the duration filter
fireEvent.click(screen.getByText('Duration'));
// Open the duration section (use role if its a button/collapse)
await user.click(screen.getByText('Duration'));
const minDuration = getByTestId('min-input') as HTMLInputElement;
const maxDuration = getByTestId('max-input') as HTMLInputElement;
expect(minDuration).toHaveValue(null);
expect(minDuration).toHaveProperty('placeholder', '0');
expect(maxDuration).toHaveValue(null);
expect(maxDuration).toHaveProperty('placeholder', '100000000');
await act(async () => {
// set values
fireEvent.change(minDuration, { target: { value: '10000' } });
fireEvent.change(maxDuration, { target: { value: '20000' } });
jest.advanceTimersByTime(2000);
});
// Type values and advance debounce
await user.clear(minDuration);
await user.type(minDuration, '10000');
await user.clear(maxDuration);
await user.type(maxDuration, '20000');
jest.advanceTimersByTime(2000);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
@@ -363,6 +369,144 @@ describe('Quick Filters with custom filters', () => {
);
});
jest.useRealTimers(); // Clean up
jest.useRealTimers();
});
});
describe('Quick Filters refetch behavior', () => {
it('fetches custom filters on every mount when signal is provided', async () => {
let getCalls = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
const { unmount } = render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
unmount();
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
expect(getCalls).toBe(2);
});
it('does not fetch custom filters when signal is undefined', async () => {
let getCalls = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
);
render(<TestQuickFilters signal={undefined} />);
await waitFor(() => expect(getCalls).toBe(0));
});
it('refetches custom filters after saving settings', async () => {
let getCalls = 0;
putHandler.mockClear();
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCalls += 1;
return res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
rest.put(saveQuickFiltersURL, async (req, res, ctx) => {
putHandler(await req.json());
return res(ctx.status(200), ctx.json({}));
}),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector(
'button',
) as HTMLButtonElement;
await user.click(removeBtn);
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => expect(putHandler).toHaveBeenCalled());
await waitFor(() => expect(getCalls).toBeGreaterThanOrEqual(2));
});
it('renders updated filters after refetch post-save', async () => {
const updatedResponse = {
...quickFiltersListResponse,
data: {
...quickFiltersListResponse.data,
filters: [
...(quickFiltersListResponse.data.filters ?? []),
{
key: 'new.custom.filter',
dataType: 'string',
type: 'resource',
} as const,
],
},
};
let getCount = 0;
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) => {
getCount += 1;
return getCount >= 2
? res(ctx.status(200), ctx.json(updatedResponse))
: res(ctx.status(200), ctx.json(quickFiltersListResponse));
}),
rest.put(saveQuickFiltersURL, async (_req, res, ctx) =>
res(ctx.status(200), ctx.json({})),
),
);
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFilters signal={SIGNAL} />);
expect(await screen.findByText(FILTER_SERVICE_NAME)).toBeInTheDocument();
const icon = await screen.findByTestId(SETTINGS_ICON_TEST_ID);
const settingsButton = icon.closest('button') ?? icon;
await user.click(settingsButton);
// Make a minimal change so Save button appears
const target = await screen.findByText(FILTER_OS_DESCRIPTION);
const removeBtn = target.parentElement?.querySelector(
'button',
) as HTMLButtonElement;
await user.click(removeBtn);
await user.click(screen.getByText(SAVE_CHANGES_TEXT));
await waitFor(() => {
expect(screen.getByText('New Custom Filter')).toBeInTheDocument();
});
});
it('shows empty state when GET fails', async () => {
server.use(
rest.get(quickFiltersListURL, (_req, res, ctx) =>
res(ctx.status(500), ctx.json({})),
),
);
render(<TestQuickFilters signal={SIGNAL} config={[]} />);
expect(await screen.findByText('No filters found')).toBeInTheDocument();
});
});

View File

@@ -43,7 +43,7 @@ function TopContributorsCard({
return newState;
});
history.push({ search: searchParams.toString() });
history.push(`${history.location.pathname}?${searchParams.toString()}`);
};
return (

View File

@@ -102,9 +102,7 @@ function HorizontalTimelineGraph({
urlQuery.set(QueryParams.startTime, startTimestamp.toString());
urlQuery.set(QueryParams.endTime, endTimestamp.toString());
history.push({
search: urlQuery.toString(),
});
history.push(`${history.location.pathname}?${urlQuery.toString()}`);
}
}
},

View File

@@ -50,7 +50,7 @@ function TimelineFilters(): JSX.Element {
const handleFilter = (value: TimelineFilter): void => {
searchParams.set('timelineFilter', value);
history.push({ search: searchParams.toString() });
history.push(`${history.location.pathname}?${searchParams.toString()}`);
};
const tabs = [

View File

@@ -20,11 +20,12 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
const { user } = useAppContext();
const [action] = useComponentPermission(['new_alert_action'], user.role);
const onClickEditHandler = useCallback((id: string) => {
const onClickEditHandler = useCallback((id: string, e: React.MouseEvent) => {
history.push(
generatePath(ROUTES.CHANNELS_EDIT, {
channelId: id,
}),
e,
);
}, []);
@@ -52,7 +53,10 @@ function AlertChannels({ allChannels }: AlertChannelsProps): JSX.Element {
width: 80,
render: (id: string): JSX.Element => (
<>
<Button onClick={(): void => onClickEditHandler(id)} type="link">
<Button
onClick={(e: React.MouseEvent): void => onClickEditHandler(id, e)}
type="link"
>
{t('column_channel_edit')}
</Button>
<Delete id={id} notifications={notifications} />

View File

@@ -22,6 +22,8 @@ jest.mock('react-router-dom', () => ({
describe('Alert Channels Settings List page', () => {
beforeEach(async () => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
@@ -29,6 +31,7 @@ describe('Alert Channels Settings List page', () => {
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible ', () => {

View File

@@ -28,6 +28,7 @@ jest.mock('react-router-dom', () => ({
describe('Alert Channels Settings List page (Normal User)', () => {
beforeEach(async () => {
jest.useFakeTimers();
render(<AlertChannels />);
await waitFor(() =>
expect(screen.getByText('sending_channels_note')).toBeInTheDocument(),
@@ -35,6 +36,7 @@ describe('Alert Channels Settings List page (Normal User)', () => {
});
afterEach(() => {
jest.restoreAllMocks();
jest.useRealTimers();
});
describe('Should display the Alert Channels page properly', () => {
it('Should check if "The alerts will be sent to all the configured channels." is visible ', async () => {

View File

@@ -30,8 +30,8 @@ function AlertChannels(): JSX.Element {
['add_new_channel'],
user.role,
);
const onToggleHandler = useCallback(() => {
history.push(ROUTES.CHANNELS_NEW);
const onToggleHandler = useCallback((e: React.MouseEvent) => {
history.push(ROUTES.CHANNELS_NEW, e);
}, []);
const { isLoading, data, error } = useQuery<
@@ -78,7 +78,7 @@ function AlertChannels(): JSX.Element {
}
>
<Button
onClick={onToggleHandler}
onClick={(e: React.MouseEvent): void => onToggleHandler(e)}
icon={<PlusOutlined />}
disabled={!addNewChannelPermission}
>

View File

@@ -9,22 +9,6 @@ import { getFormattedDate } from 'utils/timeUtils';
import BillingContainer from './BillingContainer';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({
@@ -67,78 +51,103 @@ describe('BillingContainer', () => {
expect(currentBill).toBeInTheDocument();
});
test('OnTrail', async () => {
await act(async () => {
render(<BillingContainer />, undefined, undefined, {
trialInfo: licensesSuccessResponse.data,
describe('Trial scenarios', () => {
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(new Date('2023-10-20'));
});
afterEach(() => {
jest.useRealTimers();
});
test('OnTrail', async () => {
// Pin "now" so trial end (20 Oct 2023) is tomorrow => "1 days_remaining"
render(
<BillingContainer />,
{},
{ appContextOverrides: { trialInfo: licensesSuccessResponse.data } },
);
// If the component schedules any setTimeout on mount, flush them:
jest.runOnlyPendingTimers();
expect(await screen.findByText('Free Trial')).toBeInTheDocument();
expect(await screen.findByText('billing')).toBeInTheDocument();
expect(await screen.findByText(/\$0/i)).toBeInTheDocument();
expect(
await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
),
).toBeInTheDocument();
expect(await screen.findByText(/1 days_remaining/i)).toBeInTheDocument();
const upgradeButtons = await screen.findAllByRole('button', {
name: /upgrade_plan/i,
});
expect(upgradeButtons).toHaveLength(2);
expect(upgradeButtons[1]).toBeInTheDocument();
expect(await screen.findByText(/checkout_plans/i)).toBeInTheDocument();
expect(
await screen.findByRole('link', { name: /here/i }),
).toBeInTheDocument();
});
const freeTrailText = await screen.findByText('Free Trial');
expect(freeTrailText).toBeInTheDocument();
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
);
expect(onTrail).toBeInTheDocument();
const numberOfDayRemaining = await screen.findByText(/1 days_remaining/i);
expect(numberOfDayRemaining).toBeInTheDocument();
const upgradeButton = await screen.findAllByRole('button', {
name: /upgrade_plan/i,
});
expect(upgradeButton[1]).toBeInTheDocument();
expect(upgradeButton.length).toBe(2);
const checkPaidPlan = await screen.findByText(/checkout_plans/i);
expect(checkPaidPlan).toBeInTheDocument();
const link = await screen.findByRole('link', { name: /here/i });
expect(link).toBeInTheDocument();
});
test('OnTrail but trialConvertedToSubscription', async () => {
await act(async () => {
render(<BillingContainer />, undefined, undefined, {
trialInfo: trialConvertedToSubscriptionResponse.data,
test('OnTrail but trialConvertedToSubscription', async () => {
await act(async () => {
render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: trialConvertedToSubscriptionResponse.data,
},
},
);
});
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
);
expect(onTrail).toBeInTheDocument();
const receivedCardDetails = await screen.findByText(
/card_details_recieved_and_billing_info/i,
);
expect(receivedCardDetails).toBeInTheDocument();
const manageBillingButton = await screen.findByRole('button', {
name: /manage_billing/i,
});
expect(manageBillingButton).toBeInTheDocument();
const dayRemainingInBillingPeriod = await screen.findByText(
/1 days_remaining/i,
);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
});
const currentBill = await screen.findByText('billing');
expect(currentBill).toBeInTheDocument();
const dollar0 = await screen.findByText(/\$0/i);
expect(dollar0).toBeInTheDocument();
const onTrail = await screen.findByText(
/You are in free trial period. Your free trial will end on 20 Oct 2023/i,
);
expect(onTrail).toBeInTheDocument();
const receivedCardDetails = await screen.findByText(
/card_details_recieved_and_billing_info/i,
);
expect(receivedCardDetails).toBeInTheDocument();
const manageBillingButton = await screen.findByRole('button', {
name: /manage_billing/i,
});
expect(manageBillingButton).toBeInTheDocument();
const dayRemainingInBillingPeriod = await screen.findByText(
/1 days_remaining/i,
);
expect(dayRemainingInBillingPeriod).toBeInTheDocument();
});
test('Not on ontrail', async () => {
const { findByText } = render(<BillingContainer />, undefined, undefined, {
trialInfo: notOfTrailResponse.data,
});
const { findByText } = render(
<BillingContainer />,
{},
{
appContextOverrides: {
trialInfo: notOfTrailResponse.data,
},
},
);
const billingPeriodText = `Your current billing period is from ${getFormattedDate(
billingSuccessResponse.data.billingPeriodStart,

View File

@@ -1,7 +1,6 @@
import ROUTES from 'constants/routes';
import * as usePrefillAlertConditions from 'container/FormAlertRules/usePrefillAlertConditions';
import CreateAlertPage from 'pages/CreateAlert';
import { MemoryRouter, Route } from 'react-router-dom';
import { act, fireEvent, render } from 'tests/test-utils';
import { AlertTypes } from 'types/api/alerts/alertTypes';
@@ -14,20 +13,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: jest.fn(),
@@ -84,11 +69,11 @@ describe('Alert rule documentation redirection', () => {
beforeEach(() => {
act(() => {
renderResult = render(
<MemoryRouter initialEntries={['/alerts/new']}>
<Route path={ROUTES.ALERTS_NEW}>
<CreateAlertPage />
</Route>
</MemoryRouter>,
<CreateAlertPage />,
{},
{
initialRoute: ROUTES.ALERTS_NEW,
},
);
});
});

View File

@@ -15,20 +15,6 @@ jest.mock('react-router-dom', () => ({
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
window.ResizeObserver =
window.ResizeObserver ||
jest.fn().mockImplementation(() => ({

View File

@@ -0,0 +1,51 @@
.time-input-container {
display: flex;
align-items: center;
gap: 0;
.time-input-field {
width: 40px;
height: 32px;
background-color: var(--bg-ink-400);
border: 1px solid var(--bg-slate-400);
color: var(--bg-vanilla-100);
font-family: 'Space Mono', monospace;
font-size: 14px;
font-weight: 600;
text-align: center;
border-radius: 4px;
&::placeholder {
color: var(--bg-vanilla-400);
font-family: 'Space Mono', monospace;
}
&:hover {
border-color: var(--bg-vanilla-300);
}
&:focus {
border-color: var(--bg-vanilla-300);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.1);
outline: none;
}
&:disabled {
background-color: var(--bg-ink-300);
color: var(--bg-vanilla-400);
cursor: not-allowed;
&:hover {
border-color: var(--bg-slate-400);
}
}
}
.time-input-separator {
color: var(--bg-vanilla-400);
font-size: 14px;
font-weight: 600;
margin: 0 4px;
user-select: none;
}
}

View File

@@ -0,0 +1,195 @@
import './TimeInput.scss';
import { Input } from 'antd';
import React, { useEffect, useState } from 'react';
export interface TimeInputProps {
value?: string; // Format: "HH:MM:SS"
onChange?: (value: string) => void;
disabled?: boolean;
className?: string;
}
function TimeInput({
value = '00:00:00',
onChange,
disabled = false,
className = '',
}: TimeInputProps): JSX.Element {
const [hours, setHours] = useState('00');
const [minutes, setMinutes] = useState('00');
const [seconds, setSeconds] = useState('00');
// Parse initial value
useEffect(() => {
if (value) {
const timeParts = value.split(':');
if (timeParts.length === 3) {
setHours(timeParts[0]);
setMinutes(timeParts[1]);
setSeconds(timeParts[2]);
}
}
}, [value]);
const notifyChange = (h: string, m: string, s: string): void => {
const rawValue = `${h}:${m}:${s}`;
onChange?.(rawValue);
};
const notifyFormattedChange = (h: string, m: string, s: string): void => {
const formattedValue = `${h.padStart(2, '0')}:${m.padStart(
2,
'0',
)}:${s.padStart(2, '0')}`;
onChange?.(formattedValue);
};
const handleHoursChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newHours = e.target.value.replace(/\D/g, '');
if (newHours.length > 2) {
newHours = newHours.slice(0, 2);
}
if (newHours && parseInt(newHours, 10) > 23) {
newHours = '23';
}
setHours(newHours);
notifyChange(newHours, minutes, seconds);
};
const handleMinutesChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newMinutes = e.target.value.replace(/\D/g, '');
if (newMinutes.length > 2) {
newMinutes = newMinutes.slice(0, 2);
}
if (newMinutes && parseInt(newMinutes, 10) > 59) {
newMinutes = '59';
}
setMinutes(newMinutes);
notifyChange(hours, newMinutes, seconds);
};
const handleSecondsChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
let newSeconds = e.target.value.replace(/\D/g, '');
if (newSeconds.length > 2) {
newSeconds = newSeconds.slice(0, 2);
}
if (newSeconds && parseInt(newSeconds, 10) > 59) {
newSeconds = '59';
}
setSeconds(newSeconds);
notifyChange(hours, minutes, newSeconds);
};
const handleHoursBlur = (): void => {
const formattedHours = hours.padStart(2, '0');
setHours(formattedHours);
notifyFormattedChange(formattedHours, minutes, seconds);
};
const handleMinutesBlur = (): void => {
const formattedMinutes = minutes.padStart(2, '0');
setMinutes(formattedMinutes);
notifyFormattedChange(hours, formattedMinutes, seconds);
};
const handleSecondsBlur = (): void => {
const formattedSeconds = seconds.padStart(2, '0');
setSeconds(formattedSeconds);
notifyFormattedChange(hours, minutes, formattedSeconds);
};
// Helper functions for field navigation
const getNextField = (current: string): string => {
switch (current) {
case 'hours':
return 'minutes';
case 'minutes':
return 'seconds';
default:
return 'hours';
}
};
const getPrevField = (current: string): string => {
switch (current) {
case 'seconds':
return 'minutes';
case 'minutes':
return 'hours';
default:
return 'seconds';
}
};
// Handle key navigation
const handleKeyDown = (
e: React.KeyboardEvent<HTMLInputElement>,
currentField: 'hours' | 'minutes' | 'seconds',
): void => {
if (e.key === 'ArrowRight' || e.key === 'Tab') {
e.preventDefault();
const nextField = document.querySelector(
`[data-field="${getNextField(currentField)}"]`,
) as HTMLInputElement;
nextField?.focus();
} else if (e.key === 'ArrowLeft') {
e.preventDefault();
const prevField = document.querySelector(
`[data-field="${getPrevField(currentField)}"]`,
) as HTMLInputElement;
prevField?.focus();
}
};
return (
<div className={`time-input-container ${className}`}>
<Input
data-field="hours"
value={hours}
onChange={handleHoursChange}
onBlur={handleHoursBlur}
onKeyDown={(e): void => handleKeyDown(e, 'hours')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
<span className="time-input-separator">:</span>
<Input
data-field="minutes"
value={minutes}
onChange={handleMinutesChange}
onBlur={handleMinutesBlur}
onKeyDown={(e): void => handleKeyDown(e, 'minutes')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
<span className="time-input-separator">:</span>
<Input
data-field="seconds"
value={seconds}
onChange={handleSecondsChange}
onBlur={handleSecondsBlur}
onKeyDown={(e): void => handleKeyDown(e, 'seconds')}
disabled={disabled}
maxLength={2}
className="time-input-field"
placeholder="00"
/>
</div>
);
}
TimeInput.defaultProps = {
value: '00:00:00',
onChange: undefined,
disabled: false,
className: '',
};
export default TimeInput;

View File

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

View File

@@ -0,0 +1,241 @@
import { fireEvent, render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import TimeInput from '../TimeInput/TimeInput';
describe('TimeInput', () => {
const mockOnChange = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('should render with default value', () => {
render(<TimeInput />);
expect(screen.getAllByDisplayValue('00')).toHaveLength(3); // hours, minutes, seconds
});
it('should render with provided value', () => {
render(<TimeInput value="12:34:56" />);
expect(screen.getByDisplayValue('12')).toBeInTheDocument(); // hours
expect(screen.getByDisplayValue('34')).toBeInTheDocument(); // minutes
expect(screen.getByDisplayValue('56')).toBeInTheDocument(); // seconds
});
it('should handle hours changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '12' } });
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should handle minutes changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '30' } });
expect(mockOnChange).toHaveBeenCalledWith('00:30:00');
});
it('should handle seconds changes', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '45' } });
expect(mockOnChange).toHaveBeenCalledWith('00:00:45');
});
it('should pad single digits with zeros on blur', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '5' } });
fireEvent.blur(hoursInput);
expect(mockOnChange).toHaveBeenCalledWith('05:00:00');
});
it('should filter non-numeric characters', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '1a2b3c' } });
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should limit input to 2 characters', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '123456' } });
expect(hoursInput).toHaveValue('12');
expect(mockOnChange).toHaveBeenCalledWith('12:00:00');
});
it('should handle keyboard navigation with ArrowRight', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(hoursInput);
await user.keyboard('{ArrowRight}');
expect(minutesInput).toHaveFocus();
});
it('should handle keyboard navigation with ArrowLeft', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(minutesInput);
await user.keyboard('{ArrowLeft}');
expect(hoursInput).toHaveFocus();
});
it('should handle Tab navigation', async () => {
const user = userEvent.setup();
render(<TimeInput />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
const minutesInput = screen.getAllByDisplayValue('00')[1];
await user.click(hoursInput);
await user.keyboard('{Tab}');
expect(minutesInput).toHaveFocus();
});
it('should disable inputs when disabled prop is true', () => {
render(<TimeInput disabled />);
const inputs = screen.getAllByRole('textbox');
inputs.forEach((input) => {
expect(input).toBeDisabled();
});
});
it('should update internal state when value prop changes', () => {
const { rerender } = render(<TimeInput value="01:02:03" />);
expect(screen.getByDisplayValue('01')).toBeInTheDocument();
expect(screen.getByDisplayValue('02')).toBeInTheDocument();
expect(screen.getByDisplayValue('03')).toBeInTheDocument();
rerender(<TimeInput value="04:05:06" />);
expect(screen.getByDisplayValue('04')).toBeInTheDocument();
expect(screen.getByDisplayValue('05')).toBeInTheDocument();
expect(screen.getByDisplayValue('06')).toBeInTheDocument();
});
it('should handle partial time values', () => {
render(<TimeInput value="12:34" />);
// Should fall back to default values for incomplete format
expect(screen.getAllByDisplayValue('00')).toHaveLength(3);
});
it('should cap hours at 23 when user enters value > 23', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '25' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should cap hours at 23 when user enters value = 24', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '24' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should allow hours value of 23', () => {
render(<TimeInput onChange={mockOnChange} />);
const hoursInput = screen.getAllByDisplayValue('00')[0];
fireEvent.change(hoursInput, { target: { value: '23' } });
expect(hoursInput).toHaveValue('23');
expect(mockOnChange).toHaveBeenCalledWith('23:00:00');
});
it('should cap minutes at 59 when user enters value > 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '65' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should cap minutes at 59 when user enters value = 60', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '60' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should allow minutes value of 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const minutesInput = screen.getAllByDisplayValue('00')[1];
fireEvent.change(minutesInput, { target: { value: '59' } });
expect(minutesInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:59:00');
});
it('should cap seconds at 59 when user enters value > 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '75' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
it('should cap seconds at 59 when user enters value = 60', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '60' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
it('should allow seconds value of 59', () => {
render(<TimeInput onChange={mockOnChange} />);
const secondsInput = screen.getAllByDisplayValue('00')[2];
fireEvent.change(secondsInput, { target: { value: '59' } });
expect(secondsInput).toHaveValue('59');
expect(mockOnChange).toHaveBeenCalledWith('00:00:59');
});
});

View File

@@ -0,0 +1,656 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable import/first */
// Mock dayjs before importing any other modules
const MOCK_DATE_STRING = '2024-01-15T00:30:00Z';
const MOCK_DATE_STRING_NON_LEAP_YEAR = '2023-01-15T00:30:00Z';
const MOCK_DATE_STRING_SPANS_MONTHS = '2024-01-31T00:30:00Z';
const FREQ_DAILY = 'FREQ=DAILY';
const TEN_THIRTY_TIME = '10:30:00';
const NINE_AM_TIME = '09:00:00';
jest.mock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = jest.fn((date?: string | Date) => {
if (date) {
return originalDayjs(date);
}
return originalDayjs(MOCK_DATE_STRING);
});
Object.keys(originalDayjs).forEach((key) => {
((mockDayjs as unknown) as Record<string, unknown>)[key] = originalDayjs[key];
});
return mockDayjs;
});
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { EvaluationWindowState } from 'container/CreateAlertV2/context/types';
import dayjs, { Dayjs } from 'dayjs';
import { rrulestr } from 'rrule';
import { RollingWindowTimeframes } from '../types';
import {
buildAlertScheduleFromCustomSchedule,
buildAlertScheduleFromRRule,
getCumulativeWindowTimeframeText,
getCustomRollingWindowTimeframeText,
getEvaluationWindowTypeText,
getRollingWindowTimeframeText,
getTimeframeText,
isValidRRule,
} from '../utils';
jest.mock('rrule', () => ({
rrulestr: jest.fn(),
}));
jest.mock('components/CustomTimePicker/timezoneUtils', () => ({
generateTimezoneData: jest.fn().mockReturnValue([
{ name: 'UTC', value: 'UTC', offset: '+00:00' },
{ name: 'America/New_York', value: 'America/New_York', offset: '-05:00' },
{ name: 'Europe/London', value: 'Europe/London', offset: '+00:00' },
]),
}));
const mockEvaluationWindowState: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
number: '0',
timezone: 'UTC',
time: '00:00:00',
unit: UniversalYAxisUnit.MINUTES,
},
};
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
describe('utils', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('getEvaluationWindowTypeText', () => {
it('should return correct text for rolling window', () => {
expect(getEvaluationWindowTypeText('rolling')).toBe('Rolling');
});
it('should return correct text for cumulative window', () => {
expect(getEvaluationWindowTypeText('cumulative')).toBe('Cumulative');
});
it('should default to empty string for unknown type', () => {
expect(
getEvaluationWindowTypeText('unknown' as 'rolling' | 'cumulative'),
).toBe('');
});
});
describe('getCumulativeWindowTimeframeText', () => {
it('should return correct text for current hour', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentHour',
}),
).toBe('Current hour, starting at minute 0 (UTC)');
});
it('should return correct text for current day', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentDay',
}),
).toBe('Current day, starting from 00:00:00 (UTC)');
});
it('should return correct text for current month', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentMonth',
}),
).toBe('Current month, starting from day 0 at 00:00:00 (UTC)');
});
it('should default to empty string for unknown timeframe', () => {
expect(
getCumulativeWindowTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'unknown',
}),
).toBe('');
});
});
describe('getRollingWindowTimeframeText', () => {
it('should return correct text for last 5 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_5_MINUTES),
).toBe('Last 5 minutes');
});
it('should return correct text for last 10 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_10_MINUTES),
).toBe('Last 10 minutes');
});
it('should return correct text for last 15 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_15_MINUTES),
).toBe('Last 15 minutes');
});
it('should return correct text for last 30 minutes', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_30_MINUTES),
).toBe('Last 30 minutes');
});
it('should return correct text for last 1 hour', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_1_HOUR),
).toBe('Last 1 hour');
});
it('should return correct text for last 2 hours', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_2_HOURS),
).toBe('Last 2 hours');
});
it('should return correct text for last 4 hours', () => {
expect(
getRollingWindowTimeframeText(RollingWindowTimeframes.LAST_4_HOURS),
).toBe('Last 4 hours');
});
it('should default to Last 5 minutes for unknown timeframe', () => {
expect(
getRollingWindowTimeframeText('unknown' as RollingWindowTimeframes),
).toBe('');
});
});
describe('getCustomRollingWindowTimeframeText', () => {
it('should return correct text for custom rolling window', () => {
expect(getCustomRollingWindowTimeframeText(mockEvaluationWindowState)).toBe(
'Last 0 Minutes',
);
});
});
describe('getTimeframeText', () => {
it('should call getCustomRollingWindowTimeframeText for custom rolling window', () => {
expect(
getTimeframeText({
...mockEvaluationWindowState,
windowType: 'rolling',
timeframe: 'custom',
startingAt: {
...mockEvaluationWindowState.startingAt,
number: '4',
},
}),
).toBe('Last 4 Minutes');
});
it('should call getRollingWindowTimeframeText for rolling window', () => {
expect(getTimeframeText(mockEvaluationWindowState)).toBe('Last 5 minutes');
});
it('should call getCumulativeWindowTimeframeText for cumulative window', () => {
expect(
getTimeframeText({
...mockEvaluationWindowState,
windowType: 'cumulative',
timeframe: 'currentDay',
}),
).toBe('Current day, starting from 00:00:00 (UTC)');
});
});
describe('buildAlertScheduleFromRRule', () => {
const mockRRule = {
all: jest.fn((callback) => {
const dates = [
new Date(MOCK_DATE_STRING),
new Date('2024-01-16T10:30:00Z'),
new Date('2024-01-17T10:30:00Z'),
];
dates.forEach((date, index) => callback(date, index));
}),
};
beforeEach(() => {
(rrulestr as jest.Mock).mockReturnValue(mockRRule);
});
it('should return null for empty rrule string', () => {
const result = buildAlertScheduleFromRRule('', null, '10:30:00');
expect(result).toBeNull();
});
it('should build schedule from valid rrule string', () => {
const result = buildAlertScheduleFromRRule(
FREQ_DAILY,
null,
TEN_THIRTY_TIME,
);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
expect(result).toEqual([
new Date(MOCK_DATE_STRING),
new Date('2024-01-16T10:30:00Z'),
new Date('2024-01-17T10:30:00Z'),
]);
});
it('should handle rrule with DTSTART', () => {
const date = dayjs('2024-01-20');
buildAlertScheduleFromRRule(FREQ_DAILY, date, NINE_AM_TIME);
// When date is provided, DTSTART is automatically added to the rrule string
expect(rrulestr).toHaveBeenCalledWith(
expect.stringMatching(/DTSTART:20240120T\d{6}Z/),
);
});
it('should handle rrule without DTSTART', () => {
// Test with no date provided - should use original rrule string
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, NINE_AM_TIME);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
expect(result).toHaveLength(3);
});
it('should handle escaped newlines', () => {
buildAlertScheduleFromRRule('FREQ=DAILY\\nINTERVAL=1', null, '10:30:00');
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
});
it('should limit occurrences to maxOccurrences', () => {
const result = buildAlertScheduleFromRRule(FREQ_DAILY, null, '10:30:00', 2);
expect(result).toHaveLength(2);
});
it('should return null on error', () => {
(rrulestr as jest.Mock).mockImplementation(() => {
throw new Error('Invalid rrule');
});
const result = buildAlertScheduleFromRRule('INVALID', null, '10:30:00');
expect(result).toBeNull();
});
});
describe('buildAlertScheduleFromCustomSchedule', () => {
it('should generate monthly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['1', '15'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'01-02-2024 10:30:00',
'15-02-2024 10:30:00',
'01-03-2024 10:30:00',
'15-03-2024 10:30:00',
]);
});
it('should generate weekly occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'12:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 12:30:00',
'19-01-2024 12:30:00',
'22-01-2024 12:30:00',
'26-01-2024 12:30:00',
'29-01-2024 12:30:00',
]);
});
it('should generate weekly occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today included (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'19-01-2024 10:30:00',
'22-01-2024 10:30:00',
'26-01-2024 10:30:00',
'29-01-2024 10:30:00',
]);
});
it('should generate weekly occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'19-01-2024 00:00:00',
'22-01-2024 00:00:00',
'26-01-2024 00:00:00',
'29-01-2024 00:00:00',
'02-02-2024 00:00:00',
]);
});
it('should generate weekly occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'week',
['monday', 'friday'],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 00:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'19-01-2024 00:30:00',
'22-01-2024 00:30:00',
'26-01-2024 00:30:00',
'29-01-2024 00:30:00',
'02-02-2024 00:30:00',
]);
});
it('should generate monthly occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today included (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'15-02-2024 10:30:00',
'15-03-2024 10:30:00',
'15-04-2024 10:30:00',
'15-05-2024 10:30:00',
]);
});
it('should generate monthly occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-02-2024 00:00:00',
'15-03-2024 00:00:00',
'15-04-2024 00:00:00',
'15-05-2024 00:00:00',
'15-06-2024 00:00:00',
]);
});
it('should generate monthly occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['15'],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
// today excluded (15-01-2024 10:30:00)
expect(result?.map((res) => formatDate(res))).toEqual([
'15-02-2024 00:30:00',
'15-03-2024 00:30:00',
'15-04-2024 00:30:00',
'15-05-2024 00:30:00',
'15-06-2024 00:30:00',
]);
});
it('should account for february 29th in a leap year', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['29'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'29-01-2024 10:30:00',
'29-02-2024 10:30:00',
'29-03-2024 10:30:00',
'29-04-2024 10:30:00',
'29-05-2024 10:30:00',
]);
});
it('should skip 31st on 30-day months', () => {
const result = buildAlertScheduleFromCustomSchedule(
'month',
['31'],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'31-01-2024 10:30:00',
'31-03-2024 10:30:00',
'31-05-2024 10:30:00',
'31-07-2024 10:30:00',
'31-08-2024 10:30:00',
]);
});
it('should skip february 29th in a non-leap year', async () => {
jest.resetModules(); // clear previous mocks
jest.doMock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: string | Date): Dayjs => {
if (date) return originalDayjs(date);
return originalDayjs(MOCK_DATE_STRING_NON_LEAP_YEAR);
};
Object.assign(mockDayjs, originalDayjs);
return mockDayjs;
});
const { buildAlertScheduleFromCustomSchedule } = await import('../utils');
const { default: dayjs } = await import('dayjs');
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
const result = buildAlertScheduleFromCustomSchedule(
'month',
['29'],
'10:30:00',
5,
);
expect(result?.map((res) => formatDate(res))).toEqual([
'29-01-2023 10:30:00',
'29-03-2023 10:30:00',
'29-04-2023 10:30:00',
'29-05-2023 10:30:00',
'29-06-2023 10:30:00',
]);
});
it('should generate daily occurrences', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:40:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:40:00',
'16-01-2024 10:40:00',
'17-01-2024 10:40:00',
'18-01-2024 10:40:00',
'19-01-2024 10:40:00',
]);
});
it('should generate daily occurrences excluding today if alert time is in the past', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'00:00:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'16-01-2024 00:00:00',
'17-01-2024 00:00:00',
'18-01-2024 00:00:00',
'19-01-2024 00:00:00',
'20-01-2024 00:00:00',
]);
});
it('should generate daily occurrences excluding today if alert time is in the present (right now)', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'00:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'16-01-2024 00:30:00',
'17-01-2024 00:30:00',
'18-01-2024 00:30:00',
'19-01-2024 00:30:00',
'20-01-2024 00:30:00',
]);
});
it('should generate daily occurrences including today if alert time is in the future', () => {
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'15-01-2024 10:30:00',
'16-01-2024 10:30:00',
'17-01-2024 10:30:00',
'18-01-2024 10:30:00',
'19-01-2024 10:30:00',
]);
});
it('daily occurrences should span across months correctly', async () => {
jest.resetModules(); // clear previous mocks
jest.doMock('dayjs', () => {
const originalDayjs = jest.requireActual('dayjs');
const mockDayjs = (date?: string | Date): Dayjs => {
if (date) return originalDayjs(date);
return originalDayjs(MOCK_DATE_STRING_SPANS_MONTHS);
};
Object.assign(mockDayjs, originalDayjs);
return mockDayjs;
});
const { buildAlertScheduleFromCustomSchedule } = await import('../utils');
const { default: dayjs } = await import('dayjs');
const formatDate = (date: Date): string =>
dayjs(date).format('DD-MM-YYYY HH:mm:ss');
const result = buildAlertScheduleFromCustomSchedule(
'day',
[],
'10:30:00',
5,
);
expect(result).toBeDefined();
expect(Array.isArray(result)).toBe(true);
expect(result?.map((res) => formatDate(res))).toEqual([
'31-01-2024 10:30:00',
'01-02-2024 10:30:00',
'02-02-2024 10:30:00',
'03-02-2024 10:30:00',
'04-02-2024 10:30:00',
]);
});
});
describe('isValidRRule', () => {
beforeEach(() => {
(rrulestr as jest.Mock).mockReturnValue({});
});
it('should return true for valid rrule', () => {
expect(isValidRRule(FREQ_DAILY)).toBe(true);
expect(rrulestr).toHaveBeenCalledWith(FREQ_DAILY);
});
it('should handle escaped newlines', () => {
expect(isValidRRule('FREQ=DAILY\\nINTERVAL=1')).toBe(true);
expect(rrulestr).toHaveBeenCalledWith('FREQ=DAILY\nINTERVAL=1');
});
it('should return false for invalid rrule', () => {
(rrulestr as jest.Mock).mockImplementation(() => {
throw new Error('Invalid rrule');
});
expect(isValidRRule('INVALID')).toBe(false);
});
});
});

View File

@@ -0,0 +1,61 @@
import { generateTimezoneData } from 'components/CustomTimePicker/timezoneUtils';
export const EVALUATION_WINDOW_TYPE = [
{ label: 'Rolling', value: 'rolling' },
{ label: 'Cumulative', value: 'cumulative' },
];
export const EVALUATION_WINDOW_TIMEFRAME = {
rolling: [
{ label: 'Last 5 minutes', value: '5m0s' },
{ label: 'Last 10 minutes', value: '10m0s' },
{ label: 'Last 15 minutes', value: '15m0s' },
{ label: 'Last 30 minutes', value: '30m0s' },
{ label: 'Last 1 hour', value: '1h0m0s' },
{ label: 'Last 2 hours', value: '2h0m0s' },
{ label: 'Last 4 hours', value: '4h0m0s' },
],
cumulative: [
{ label: 'Current hour', value: 'currentHour' },
{ label: 'Current day', value: 'currentDay' },
{ label: 'Current month', value: 'currentMonth' },
],
};
export const EVALUATION_CADENCE_REPEAT_EVERY_OPTIONS = [
{ label: 'WEEK', value: 'week' },
{ label: 'MONTH', value: 'month' },
];
export const EVALUATION_CADENCE_REPEAT_EVERY_WEEK_OPTIONS = [
{ label: 'SUNDAY', value: 'sunday' },
{ label: 'MONDAY', value: 'monday' },
{ label: 'TUESDAY', value: 'tuesday' },
{ label: 'WEDNESDAY', value: 'wednesday' },
{ label: 'THURSDAY', value: 'thursday' },
{ label: 'FRIDAY', value: 'friday' },
{ label: 'SATURDAY', value: 'saturday' },
];
export const EVALUATION_CADENCE_REPEAT_EVERY_MONTH_OPTIONS = Array.from(
{ length: 31 },
(_, i) => {
const value = String(i + 1);
return { label: value, value };
},
);
export const WEEKDAY_MAP: { [key: string]: number } = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
export const TIMEZONE_DATA = generateTimezoneData().map((timezone) => ({
label: `${timezone.name} (${timezone.offset})`,
value: timezone.value,
}));

View File

@@ -0,0 +1,53 @@
import { Dispatch, SetStateAction } from 'react';
import {
EvaluationWindowAction,
EvaluationWindowState,
} from '../context/types';
export interface IAdvancedOptionItemProps {
title: string;
description: string;
input: JSX.Element;
}
export enum RollingWindowTimeframes {
'LAST_5_MINUTES' = '5m0s',
'LAST_10_MINUTES' = '10m0s',
'LAST_15_MINUTES' = '15m0s',
'LAST_30_MINUTES' = '30m0s',
'LAST_1_HOUR' = '1h0m0s',
'LAST_2_HOURS' = '2h0m0s',
'LAST_4_HOURS' = '4h0m0s',
}
export enum CumulativeWindowTimeframes {
'CURRENT_HOUR' = 'currentHour',
'CURRENT_DAY' = 'currentDay',
'CURRENT_MONTH' = 'currentMonth',
}
export interface IEvaluationWindowPopoverProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface IEvaluationWindowDetailsProps {
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
}
export interface IEvaluationCadenceDetailsProps {
isOpen: boolean;
setIsOpen: Dispatch<SetStateAction<boolean>>;
}
export interface TimeInputProps {
value?: string; // Format: "HH:MM:SS"
onChange?: (value: string) => void;
placeholder?: string;
disabled?: boolean;
className?: string;
}

View File

@@ -0,0 +1,295 @@
import * as Sentry from '@sentry/react';
import dayjs, { Dayjs } from 'dayjs';
import timezone from 'dayjs/plugin/timezone';
import utc from 'dayjs/plugin/utc';
import { rrulestr } from 'rrule';
import { ADVANCED_OPTIONS_TIME_UNIT_OPTIONS } from '../context/constants';
import { EvaluationWindowState } from '../context/types';
import { WEEKDAY_MAP } from './constants';
import { CumulativeWindowTimeframes, RollingWindowTimeframes } from './types';
// Extend dayjs with timezone plugins
dayjs.extend(utc);
dayjs.extend(timezone);
export const getEvaluationWindowTypeText = (
windowType: 'rolling' | 'cumulative',
): string => {
switch (windowType) {
case 'rolling':
return 'Rolling';
case 'cumulative':
return 'Cumulative';
default:
return '';
}
};
export const getCumulativeWindowTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string => {
switch (evaluationWindow.timeframe) {
case CumulativeWindowTimeframes.CURRENT_HOUR:
return `Current hour, starting at minute ${evaluationWindow.startingAt.number} (${evaluationWindow.startingAt.timezone})`;
case CumulativeWindowTimeframes.CURRENT_DAY:
return `Current day, starting from ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
case CumulativeWindowTimeframes.CURRENT_MONTH:
return `Current month, starting from day ${evaluationWindow.startingAt.number} at ${evaluationWindow.startingAt.time} (${evaluationWindow.startingAt.timezone})`;
default:
return '';
}
};
export const getRollingWindowTimeframeText = (
timeframe: RollingWindowTimeframes,
): string => {
switch (timeframe) {
case RollingWindowTimeframes.LAST_5_MINUTES:
return 'Last 5 minutes';
case RollingWindowTimeframes.LAST_10_MINUTES:
return 'Last 10 minutes';
case RollingWindowTimeframes.LAST_15_MINUTES:
return 'Last 15 minutes';
case RollingWindowTimeframes.LAST_30_MINUTES:
return 'Last 30 minutes';
case RollingWindowTimeframes.LAST_1_HOUR:
return 'Last 1 hour';
case RollingWindowTimeframes.LAST_2_HOURS:
return 'Last 2 hours';
case RollingWindowTimeframes.LAST_4_HOURS:
return 'Last 4 hours';
default:
return '';
}
};
export const getCustomRollingWindowTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string =>
`Last ${evaluationWindow.startingAt.number} ${
ADVANCED_OPTIONS_TIME_UNIT_OPTIONS.find(
(option) => option.value === evaluationWindow.startingAt.unit,
)?.label
}`;
export const getTimeframeText = (
evaluationWindow: EvaluationWindowState,
): string => {
if (evaluationWindow.windowType === 'rolling') {
if (evaluationWindow.timeframe === 'custom') {
return getCustomRollingWindowTimeframeText(evaluationWindow);
}
return getRollingWindowTimeframeText(
evaluationWindow.timeframe as RollingWindowTimeframes,
);
}
return getCumulativeWindowTimeframeText(evaluationWindow);
};
export function buildAlertScheduleFromRRule(
rruleString: string,
date: Dayjs | null,
startAt: string,
maxOccurrences = 10,
): Date[] | null {
try {
if (!rruleString) return null;
// Handle literal \n in string
let finalRRuleString = rruleString.replace(/\\n/g, '\n');
if (date) {
const dt = dayjs(date);
if (!dt.isValid()) throw new Error('Invalid date provided');
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
const dtWithTime = dt
.set('hour', hours)
.set('minute', minutes)
.set('second', seconds)
.set('millisecond', 0);
const dtStartStr = dtWithTime
.toISOString()
.replace(/[-:]/g, '')
.replace(/\.\d{3}Z$/, 'Z');
if (!/DTSTART/i.test(finalRRuleString)) {
finalRRuleString = `DTSTART:${dtStartStr}\n${finalRRuleString}`;
}
}
const rruleObj = rrulestr(finalRRuleString);
const occurrences: Date[] = [];
rruleObj.all((date, index) => {
if (index >= maxOccurrences) return false;
occurrences.push(date);
return true;
});
return occurrences;
} catch (error) {
return null;
}
}
function generateMonthlyOccurrences(
targetDays: number[],
hours: number,
minutes: number,
seconds: number,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentMonth = dayjs().startOf('month');
const currentDate = dayjs();
const scanMonths = maxOccurrences + 12;
for (let monthOffset = 0; monthOffset < scanMonths; monthOffset++) {
const monthDate = currentMonth.add(monthOffset, 'month');
targetDays.forEach((day) => {
if (occurrences.length >= maxOccurrences) return;
const daysInMonth = monthDate.daysInMonth();
if (day <= daysInMonth) {
const targetDate = monthDate
.date(day)
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(currentDate)) {
occurrences.push(targetDate.toDate());
}
}
});
}
return occurrences;
}
function generateWeeklyOccurrences(
targetWeekdays: number[],
hours: number,
minutes: number,
seconds: number,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentWeek = dayjs().startOf('week');
const currentDate = dayjs();
for (let weekOffset = 0; weekOffset < maxOccurrences; weekOffset++) {
const weekDate = currentWeek.add(weekOffset, 'week');
targetWeekdays.forEach((weekday) => {
if (occurrences.length >= maxOccurrences) return;
const targetDate = weekDate
.day(weekday)
.hour(hours)
.minute(minutes)
.second(seconds);
if (targetDate.isAfter(currentDate)) {
occurrences.push(targetDate.toDate());
}
});
}
return occurrences;
}
export function generateDailyOccurrences(
hours: number,
minutes: number,
seconds: number,
maxOccurrences: number,
): Date[] {
const occurrences: Date[] = [];
const currentDate = dayjs();
const currentTime =
currentDate.hour() * 3600 + currentDate.minute() * 60 + currentDate.second();
const targetTime = hours * 3600 + minutes * 60 + seconds;
// Start from today if target time is after current time, otherwise start from tomorrow
const startDayOffset = targetTime > currentTime ? 0 : 1;
for (
let dayOffset = startDayOffset;
dayOffset < startDayOffset + maxOccurrences;
dayOffset++
) {
const dayDate = currentDate.add(dayOffset, 'day');
const targetDate = dayDate.hour(hours).minute(minutes).second(seconds);
occurrences.push(targetDate.toDate());
}
return occurrences;
}
export function buildAlertScheduleFromCustomSchedule(
repeatEvery: string,
occurence: string[],
startAt: string,
maxOccurrences = 10,
): Date[] | null {
try {
const [hours = 0, minutes = 0, seconds = 0] = startAt.split(':').map(Number);
let occurrences: Date[] = [];
if (repeatEvery === 'month') {
const targetDays = occurence
.map((day) => parseInt(day, 10))
.filter((day) => !Number.isNaN(day));
occurrences = generateMonthlyOccurrences(
targetDays,
hours,
minutes,
seconds,
maxOccurrences,
);
} else if (repeatEvery === 'week') {
const targetWeekdays = occurence
.map((day) => WEEKDAY_MAP[day.toLowerCase()])
.filter((day) => day !== undefined);
occurrences = generateWeeklyOccurrences(
targetWeekdays,
hours,
minutes,
seconds,
maxOccurrences,
);
} else if (repeatEvery === 'day') {
occurrences = generateDailyOccurrences(
hours,
minutes,
seconds,
maxOccurrences,
);
}
occurrences.sort((a, b) => a.getTime() - b.getTime());
return occurrences.slice(0, maxOccurrences);
} catch (error) {
Sentry.captureEvent({
message: `Error building alert schedule from custom schedule: ${
error instanceof Error ? error.message : 'Unknown error'
}`,
level: 'error',
});
return null;
}
}
export function isValidRRule(rruleString: string): boolean {
try {
// normalize escaped \n
const finalRRuleString = rruleString.replace(/\\n/g, '\n');
rrulestr(finalRRuleString); // will throw if invalid
return true;
} catch {
return false;
}
}

View File

@@ -1,13 +1,18 @@
import { Color } from '@signozhq/design-tokens';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { TIMEZONE_DATA } from 'container/CreateAlertV2/EvaluationSettings/constants';
import dayjs from 'dayjs';
import getRandomColor from 'lib/getRandomColor';
import { v4 } from 'uuid';
import {
AdvancedOptionsState,
AlertState,
AlertThresholdMatchType,
AlertThresholdOperator,
AlertThresholdState,
Algorithm,
EvaluationWindowState,
Seasonality,
Threshold,
TimeDuration,
@@ -70,6 +75,49 @@ export const INITIAL_ALERT_THRESHOLD_STATE: AlertThresholdState = {
thresholds: [INITIAL_CRITICAL_THRESHOLD],
};
export const INITIAL_ADVANCED_OPTIONS_STATE: AdvancedOptionsState = {
sendNotificationIfDataIsMissing: {
toleranceLimit: 15,
timeUnit: UniversalYAxisUnit.MINUTES,
},
enforceMinimumDatapoints: {
minimumDatapoints: 0,
},
delayEvaluation: {
delay: 5,
timeUnit: UniversalYAxisUnit.MINUTES,
},
evaluationCadence: {
mode: 'default',
default: {
value: 1,
timeUnit: UniversalYAxisUnit.MINUTES,
},
custom: {
repeatEvery: 'week',
startAt: '00:00:00',
occurence: [],
timezone: TIMEZONE_DATA[0].value,
},
rrule: {
date: dayjs(),
startAt: '00:00:00',
rrule: '',
},
},
};
export const INITIAL_EVALUATION_WINDOW_STATE: EvaluationWindowState = {
windowType: 'rolling',
timeframe: '5m0s',
startingAt: {
time: '00:00:00',
number: '1',
timezone: TIMEZONE_DATA[0].value,
unit: UniversalYAxisUnit.MINUTES,
},
};
export const THRESHOLD_OPERATOR_OPTIONS = [
{ value: AlertThresholdOperator.IS_ABOVE, label: 'IS ABOVE' },
{ value: AlertThresholdOperator.IS_BELOW, label: 'IS BELOW' },
@@ -115,3 +163,10 @@ export const ANOMALY_SEASONALITY_OPTIONS = [
{ value: Seasonality.DAILY, label: 'Daily' },
{ value: Seasonality.WEEKLY, label: 'Weekly' },
];
export const ADVANCED_OPTIONS_TIME_UNIT_OPTIONS = [
{ value: UniversalYAxisUnit.SECONDS, label: 'Seconds' },
{ value: UniversalYAxisUnit.MINUTES, label: 'Minutes' },
{ value: UniversalYAxisUnit.HOURS, label: 'Hours' },
{ value: UniversalYAxisUnit.DAYS, label: 'Days' },
];

View File

@@ -14,14 +14,18 @@ import { useLocation } from 'react-router-dom';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from './constants';
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
import {
advancedOptionsReducer,
alertCreationReducer,
alertThresholdReducer,
buildInitialAlertDef,
evaluationWindowReducer,
getInitialAlertTypeFromURL,
} from './utils';
@@ -80,6 +84,16 @@ export function CreateAlertProvider(
INITIAL_ALERT_THRESHOLD_STATE,
);
const [evaluationWindow, setEvaluationWindow] = useReducer(
evaluationWindowReducer,
INITIAL_EVALUATION_WINDOW_STATE,
);
const [advancedOptions, setAdvancedOptions] = useReducer(
advancedOptionsReducer,
INITIAL_ADVANCED_OPTIONS_STATE,
);
useEffect(() => {
setThresholdState({
type: 'RESET',
@@ -94,8 +108,19 @@ export function CreateAlertProvider(
setAlertType: handleAlertTypeChange,
thresholdState,
setThresholdState,
evaluationWindow,
setEvaluationWindow,
advancedOptions,
setAdvancedOptions,
}),
[alertState, alertType, handleAlertTypeChange, thresholdState],
[
alertState,
alertType,
handleAlertTypeChange,
thresholdState,
evaluationWindow,
advancedOptions,
],
);
return (

View File

@@ -1,3 +1,4 @@
import { Dayjs } from 'dayjs';
import { Dispatch } from 'react';
import { AlertTypes } from 'types/api/alerts/alertTypes';
import { Labels } from 'types/api/alerts/def';
@@ -9,6 +10,10 @@ export interface ICreateAlertContextProps {
setAlertType: Dispatch<AlertTypes>;
thresholdState: AlertThresholdState;
setThresholdState: Dispatch<AlertThresholdAction>;
advancedOptions: AdvancedOptionsState;
setAdvancedOptions: Dispatch<AdvancedOptionsAction>;
evaluationWindow: EvaluationWindowState;
setEvaluationWindow: Dispatch<EvaluationWindowAction>;
}
export interface ICreateAlertProviderProps {
@@ -101,3 +106,87 @@ export type AlertThresholdAction =
| { type: 'SET_SEASONALITY'; payload: string }
| { type: 'SET_THRESHOLDS'; payload: Threshold[] }
| { type: 'RESET' };
export interface AdvancedOptionsState {
sendNotificationIfDataIsMissing: {
toleranceLimit: number;
timeUnit: string;
};
enforceMinimumDatapoints: {
minimumDatapoints: number;
};
delayEvaluation: {
delay: number;
timeUnit: string;
};
evaluationCadence: {
mode: EvaluationCadenceMode;
default: {
value: number;
timeUnit: string;
};
custom: {
repeatEvery: string;
startAt: string;
occurence: string[];
timezone: string;
};
rrule: {
date: Dayjs | null;
startAt: string;
rrule: string;
};
};
}
export type AdvancedOptionsAction =
| {
type: 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING';
payload: { toleranceLimit: number; timeUnit: string };
}
| {
type: 'SET_ENFORCE_MINIMUM_DATAPOINTS';
payload: { minimumDatapoints: number };
}
| {
type: 'SET_DELAY_EVALUATION';
payload: { delay: number; timeUnit: string };
}
| {
type: 'SET_EVALUATION_CADENCE';
payload: {
default: { value: number; timeUnit: string };
custom: {
repeatEvery: string;
startAt: string;
timezone: string;
occurence: string[];
};
rrule: { date: Dayjs | null; startAt: string; rrule: string };
};
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'RESET' };
export interface EvaluationWindowState {
windowType: 'rolling' | 'cumulative';
timeframe: string;
startingAt: {
time: string;
number: string;
timezone: string;
unit: string;
};
}
export type EvaluationWindowAction =
| { type: 'SET_WINDOW_TYPE'; payload: 'rolling' | 'cumulative' }
| { type: 'SET_TIMEFRAME'; payload: string }
| {
type: 'SET_STARTING_AT';
payload: { time: string; number: string; timezone: string; unit: string };
}
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
| { type: 'RESET' };
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';

View File

@@ -11,12 +11,20 @@ import { AlertDef } from 'types/api/alerts/def';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { INITIAL_ALERT_THRESHOLD_STATE } from './constants';
import {
INITIAL_ADVANCED_OPTIONS_STATE,
INITIAL_ALERT_THRESHOLD_STATE,
INITIAL_EVALUATION_WINDOW_STATE,
} from './constants';
import {
AdvancedOptionsAction,
AdvancedOptionsState,
AlertState,
AlertThresholdAction,
AlertThresholdState,
CreateAlertAction,
EvaluationWindowAction,
EvaluationWindowState,
} from './types';
export const alertCreationReducer = (
@@ -110,3 +118,57 @@ export const alertThresholdReducer = (
return state;
}
};
export const advancedOptionsReducer = (
state: AdvancedOptionsState,
action: AdvancedOptionsAction,
): AdvancedOptionsState => {
switch (action.type) {
case 'SET_SEND_NOTIFICATION_IF_DATA_IS_MISSING':
return { ...state, sendNotificationIfDataIsMissing: action.payload };
case 'SET_ENFORCE_MINIMUM_DATAPOINTS':
return { ...state, enforceMinimumDatapoints: action.payload };
case 'SET_DELAY_EVALUATION':
return { ...state, delayEvaluation: action.payload };
case 'SET_EVALUATION_CADENCE':
return {
...state,
evaluationCadence: { ...state.evaluationCadence, ...action.payload },
};
case 'SET_EVALUATION_CADENCE_MODE':
return {
...state,
evaluationCadence: { ...state.evaluationCadence, mode: action.payload },
};
case 'RESET':
return INITIAL_ADVANCED_OPTIONS_STATE;
default:
return state;
}
};
export const evaluationWindowReducer = (
state: EvaluationWindowState,
action: EvaluationWindowAction,
): EvaluationWindowState => {
switch (action.type) {
case 'SET_WINDOW_TYPE':
return {
...state,
windowType: action.payload,
startingAt: INITIAL_EVALUATION_WINDOW_STATE.startingAt,
timeframe:
action.payload === 'rolling'
? INITIAL_EVALUATION_WINDOW_STATE.timeframe
: 'currentHour',
};
case 'SET_TIMEFRAME':
return { ...state, timeframe: action.payload };
case 'SET_STARTING_AT':
return { ...state, startingAt: action.payload };
case 'RESET':
return INITIAL_EVALUATION_WINDOW_STATE;
default:
return state;
}
};

View File

@@ -111,14 +111,16 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
value: errorDetail[key as keyof GetByErrorTypeAndServicePayload],
}));
const onClickTraceHandler = (): void => {
const onClickTraceHandler = (event?: React.MouseEvent): void => {
logEvent('Exception: Navigate to trace detail page', {
groupId: errorDetail?.groupID,
spanId: errorDetail.spanID,
traceId: errorDetail.traceID,
exceptionId: errorDetail?.errorId,
});
history.push(`/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`);
const path = `/trace/${errorDetail.traceID}?spanId=${errorDetail.spanID}`;
history.push(path, event);
};
const logEventCalledRef = useRef(false);
@@ -185,7 +187,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
<DashedContainer>
<Typography>{t('see_trace_graph')}</Typography>
<Button onClick={onClickTraceHandler} type="primary">
<Button onClick={(e): void => onClickTraceHandler(e)} type="primary">
{t('see_error_in_trace_graph')}
</Button>
</DashedContainer>

View File

@@ -190,7 +190,7 @@ function ExplorerOptions({
);
const onCreateAlertsHandler = useCallback(
(defaultQuery: Query | null) => {
(defaultQuery: Query | null, event?: React.MouseEvent) => {
if (sourcepage === DataSource.TRACES) {
logEvent('Traces Explorer: Create alert', {
panelType,
@@ -209,11 +209,20 @@ function ExplorerOptions({
const stringifiedQuery = handleConditionalQueryModification(defaultQuery);
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
if (event) {
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
event,
);
} else {
history.push(
`${ROUTES.ALERTS_NEW}?${QueryParams.compositeQuery}=${encodeURIComponent(
stringifiedQuery,
)}`,
);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[handleConditionalQueryModification, history],
@@ -726,7 +735,7 @@ function ExplorerOptions({
<Button
disabled={disabled}
shape="round"
onClick={(): void => onCreateAlertsHandler(query)}
onClick={(e: React.MouseEvent): void => onCreateAlertsHandler(query, e)}
icon={<ConciergeBell size={16} />}
>
Create an Alert

View File

@@ -9,8 +9,8 @@ export default function FullScreenHeader({
}: {
overrideRoute?: string;
}): React.ReactElement {
const handleLogoClick = (): void => {
history.push(overrideRoute || '/');
const handleLogoClick = (event?: React.MouseEvent): void => {
history.push(overrideRoute || '/', event);
};
return (
<div className="full-screen-header-container">

View File

@@ -30,20 +30,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock data
const mockProps: WidgetGraphComponentProps = {
widget: {

View File

@@ -114,7 +114,10 @@ export default function AlertRules({
</div>
);
const onEditHandler = (record: GettableAlert) => (): void => {
const onEditHandler = (
record: GettableAlert,
event?: React.MouseEvent | React.KeyboardEvent,
) => (): void => {
logEvent('Homepage: Alert clicked', {
ruleId: record.id,
ruleName: record.alert,
@@ -131,7 +134,9 @@ export default function AlertRules({
params.set(QueryParams.ruleId, record.id.toString());
history.push(`${ROUTES.ALERT_OVERVIEW}?${params.toString()}`);
const path = `${ROUTES.ALERT_OVERVIEW}?${params.toString()}`;
history.push(path, event);
};
const renderAlertRules = (): JSX.Element => (
@@ -143,10 +148,10 @@ export default function AlertRules({
tabIndex={0}
className="alert-rule-item home-data-item"
key={rule.id}
onClick={onEditHandler(rule)}
onClick={(e): void => onEditHandler(rule, e)()}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
onEditHandler(rule);
onEditHandler(rule, e)();
}
}}
>

View File

@@ -84,14 +84,14 @@ function DataSourceInfo({
icon={<img src="/Icons/container-plus.svg" alt="plus" />}
role="button"
tabIndex={0}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Connect dataSource clicked', {});
if (
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(ROUTES.GET_STARTED_WITH_CLOUD);
history.push(ROUTES.GET_STARTED_WITH_CLOUD, e);
} else {
window?.open(
DOCS_LINKS.ADD_DATA_SOURCE,

View File

@@ -413,12 +413,12 @@ export default function Home(): JSX.Element {
role="button"
tabIndex={0}
className="active-ingestion-card-actions"
onClick={(): void => {
onClick={(e): void => {
// eslint-disable-next-line sonarjs/no-duplicate-string
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Logs',
});
history.push(ROUTES.LOGS_EXPLORER);
history.push(ROUTES.LOGS_EXPLORER, e);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -455,11 +455,11 @@ export default function Home(): JSX.Element {
className="active-ingestion-card-actions"
role="button"
tabIndex={0}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Traces',
});
history.push(ROUTES.TRACES_EXPLORER);
history.push(ROUTES.TRACES_EXPLORER, e);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -496,11 +496,11 @@ export default function Home(): JSX.Element {
className="active-ingestion-card-actions"
role="button"
tabIndex={0}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Ingestion Active Explore clicked', {
source: 'Metrics',
});
history.push(ROUTES.METRICS_EXPLORER);
history.push(ROUTES.METRICS_EXPLORER, e);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
@@ -550,11 +550,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Wrench size={14} />}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Explore clicked', {
source: 'Logs',
});
history.push(ROUTES.LOGS_EXPLORER);
history.push(ROUTES.LOGS_EXPLORER, e);
}}
>
Open Logs Explorer
@@ -564,11 +564,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Wrench size={14} />}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Explore clicked', {
source: 'Traces',
});
history.push(ROUTES.TRACES_EXPLORER);
history.push(ROUTES.TRACES_EXPLORER, e);
}}
>
Open Traces Explorer
@@ -578,11 +578,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Wrench size={14} />}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Explore clicked', {
source: 'Metrics',
});
history.push(ROUTES.METRICS_EXPLORER_EXPLORER);
history.push(ROUTES.METRICS_EXPLORER_EXPLORER, e);
}}
>
Open Metrics Explorer
@@ -619,11 +619,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Plus size={14} />}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Explore clicked', {
source: 'Dashboards',
});
history.push(ROUTES.ALL_DASHBOARD);
history.push(ROUTES.ALL_DASHBOARD, e);
}}
>
Create dashboard
@@ -661,11 +661,11 @@ export default function Home(): JSX.Element {
type="default"
className="periscope-btn secondary"
icon={<Plus size={14} />}
onClick={(): void => {
onClick={(e): void => {
logEvent('Homepage: Explore clicked', {
source: 'Alerts',
});
history.push(ROUTES.ALERTS_NEW);
history.push(ROUTES.ALERTS_NEW, e);
}}
>
Create an alert

View File

@@ -86,18 +86,18 @@ function HomeChecklist({
<Button
type="default"
className="periscope-btn secondary"
onClick={(): void => {
onClick={(e): void => {
logEvent('Welcome Checklist: Get started clicked', {
step: item.id,
});
if (item.toRoute !== ROUTES.GET_STARTED_WITH_CLOUD) {
history.push(item.toRoute || '');
history.push(item.toRoute || '', e);
} else if (
activeLicense &&
activeLicense.platform === LicensePlatform.CLOUD
) {
history.push(item.toRoute || '');
history.push(item.toRoute || '', e);
} else {
window?.open(
item.docsLink || '',

View File

@@ -116,7 +116,7 @@ const ServicesListTable = memo(
onRowClick,
}: {
services: ServicesList[];
onRowClick: (record: ServicesList) => void;
onRowClick: (record: ServicesList, event: React.MouseEvent) => void;
}): JSX.Element => (
<div className="services-list-container home-data-item-container metrics-services-list">
<div className="services-list">
@@ -125,8 +125,8 @@ const ServicesListTable = memo(
dataSource={services}
pagination={false}
className="services-table"
onRow={(record): { onClick: () => void } => ({
onClick: (): void => onRowClick(record),
onRow={(record): { onClick: (event: React.MouseEvent) => void } => ({
onClick: (event: React.MouseEvent): void => onRowClick(record, event),
})}
/>
</div>
@@ -284,11 +284,12 @@ function ServiceMetrics({
}, [onUpdateChecklistDoneItem, loadingUserPreferences, servicesExist]);
const handleRowClick = useCallback(
(record: ServicesList) => {
(record: ServicesList, event: React.MouseEvent) => {
logEvent('Homepage: Service clicked', {
serviceName: record.serviceName,
});
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, event);
},
[safeNavigate],
);

View File

@@ -172,13 +172,13 @@ export default function ServiceTraces({
dataSource={top5Services}
pagination={false}
className="services-table"
onRow={(record): { onClick: () => void } => ({
onClick: (): void => {
onRow={(record): { onClick: (e: React.MouseEvent) => void } => ({
onClick: (e: React.MouseEvent): void => {
logEvent('Homepage: Service clicked', {
serviceName: record.serviceName,
});
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`);
safeNavigate(`${ROUTES.APPLICATION}/${record.serviceName}`, e);
},
})}
/>

View File

@@ -33,19 +33,6 @@ jest.mock('components/CustomTimePicker/CustomTimePicker', () => ({
const queryClient = new QueryClient();
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({

View File

@@ -3,20 +3,6 @@ import { render, screen } from '@testing-library/react';
import HostsListTable from '../HostsListTable';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const EMPTY_STATE_CONTAINER_CLASS = '.hosts-empty-state-container';
describe('HostsListTable', () => {

View File

@@ -4,20 +4,6 @@ import { formatDataForTable, GetHostsQuickFiltersConfig } from '../utils';
const PROGRESS_BAR_CLASS = '.progress-bar';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('InfraMonitoringHosts utils', () => {
describe('formatDataForTable', () => {
it('should format host data correctly', () => {

View File

@@ -44,20 +44,6 @@ const verifyEntityLogsPayload = ({
return queryData;
};
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock(
'components/OverlayScrollbar/OverlayScrollbar',
() =>

View File

@@ -4,14 +4,8 @@ import setupCommonMocks from '../../commonMocks';
setupCommonMocks();
import { fireEvent, render, screen } from '@testing-library/react';
import JobDetails from 'container/InfraMonitoringK8s/Jobs/JobDetails/JobDetails';
import { QueryClient, QueryClientProvider } from 'react-query';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import store from 'store';
const queryClient = new QueryClient();
import { fireEvent, render, screen } from 'tests/test-utils';
describe('JobDetails', () => {
const mockJob = {
@@ -24,13 +18,7 @@ describe('JobDetails', () => {
it('should render modal with relevant metadata', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
);
const jobNameElements = screen.getAllByText('test-job');
@@ -44,13 +32,7 @@ describe('JobDetails', () => {
it('should render modal with 4 tabs', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
);
const metricsTab = screen.getByText('Metrics');
@@ -68,13 +50,7 @@ describe('JobDetails', () => {
it('default tab should be metrics', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
);
const metricsTab = screen.getByRole('radio', { name: 'Metrics' });
@@ -83,13 +59,7 @@ describe('JobDetails', () => {
it('should switch to events tab when events tab is clicked', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
);
const eventsTab = screen.getByRole('radio', { name: 'Events' });
@@ -100,13 +70,7 @@ describe('JobDetails', () => {
it('should close modal when close button is clicked', () => {
render(
<QueryClientProvider client={queryClient}>
<Provider store={store}>
<MemoryRouter>
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />
</MemoryRouter>
</Provider>
</QueryClientProvider>,
<JobDetails job={mockJob} isModalTimeSelection onClose={mockOnClose} />,
);
const closeButton = screen.getByRole('button', { name: 'Close' });

View File

@@ -56,19 +56,6 @@ const setupCommonMocks = (): void => {
useNavigationType: (): any => 'PUSH',
}));
jest.mock('hooks/useUrlQuery', () => ({
__esModule: true,
default: jest.fn(() => ({
set: jest.fn(),
delete: jest.fn(),
get: jest.fn(),
has: jest.fn(),
entries: jest.fn(() => []),
append: jest.fn(),
toString: jest.fn(() => ''),
})),
}));
jest.mock('lib/getMinMax', () => ({
__esModule: true,
default: jest.fn().mockImplementation(() => ({

View File

@@ -36,9 +36,9 @@ export function AlertsEmptyState(): JSX.Element {
const [loading, setLoading] = useState(false);
const onClickNewAlertHandler = useCallback(() => {
const onClickNewAlertHandler = useCallback((e: React.MouseEvent) => {
setLoading(false);
history.push(ROUTES.ALERTS_NEW);
history.push(ROUTES.ALERTS_NEW, e);
}, []);
return (
@@ -70,7 +70,7 @@ export function AlertsEmptyState(): JSX.Element {
<div className="action-container">
<Button
className="add-alert-btn"
onClick={onClickNewAlertHandler}
onClick={(e: React.MouseEvent): void => onClickNewAlertHandler(e)}
icon={<PlusOutlined />}
disabled={!addNewAlert}
loading={loading}

View File

@@ -2,6 +2,8 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable @typescript-eslint/explicit-function-return-type */
// Had to add event to the props because we need to pass the event to the onCreateNewDashboard function
import './DashboardTemplatesModal.styles.scss';
import { Button, Input, Modal, Typography } from 'antd';
@@ -118,7 +120,7 @@ const templatesList: DashboardTemplate[] = [
interface DashboardTemplatesModalProps {
showNewDashboardTemplatesModal: boolean;
onCreateNewDashboard: () => void;
onCreateNewDashboard: (event: React.MouseEvent) => void;
onCancel: () => void;
}
@@ -204,7 +206,9 @@ export default function DashboardTemplatesModal({
type="primary"
className="periscope-btn primary"
icon={<Plus size={14} />}
onClick={onCreateNewDashboard}
onClick={(event: React.MouseEvent): void =>
onCreateNewDashboard(event)
}
>
New dashboard
</Button>

View File

@@ -282,35 +282,39 @@ function DashboardsList(): JSX.Element {
refetchDashboardList,
})) || [];
const onNewDashboardHandler = useCallback(async () => {
try {
logEvent('Dashboard List: Create dashboard clicked', {});
setNewDashboardState({
...newDashboardState,
loading: true,
});
const response = await createDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
const onNewDashboardHandler = useCallback(
async (event: React.MouseEvent) => {
try {
logEvent('Dashboard List: Create dashboard clicked', {});
setNewDashboardState({
...newDashboardState,
loading: true,
});
const response = await createDashboard({
title: t('new_dashboard_title', {
ns: 'dashboard',
}),
uploadedGrafana: false,
version: ENTITY_VERSION_V5,
});
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.data.id,
}),
);
} catch (error) {
showErrorModal(error as APIError);
setNewDashboardState({
...newDashboardState,
error: true,
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
});
}
}, [newDashboardState, safeNavigate, showErrorModal, t]);
safeNavigate(
generatePath(ROUTES.DASHBOARD, {
dashboardId: response.data.id,
}),
event,
);
} catch (error) {
showErrorModal(error as APIError);
setNewDashboardState({
...newDashboardState,
error: true,
errorMessage: (error as AxiosError).toString() || 'Something went Wrong',
});
}
},
[newDashboardState, safeNavigate, showErrorModal, t],
);
const onModalHandler = (uploadedGrafana: boolean): void => {
logEvent('Dashboard List: Import JSON clicked', {});
@@ -639,8 +643,8 @@ function DashboardsList(): JSX.Element {
label: (
<div
className="create-dashboard-menu-item"
onClick={(): void => {
onNewDashboardHandler();
onClick={(e: React.MouseEvent): void => {
onNewDashboardHandler(e);
}}
>
<LayoutGrid size={14} /> Create dashboard
@@ -927,7 +931,9 @@ function DashboardsList(): JSX.Element {
<DashboardTemplatesModal
showNewDashboardTemplatesModal={showNewDashboardTemplatesModal}
onCreateNewDashboard={onNewDashboardHandler}
onCreateNewDashboard={(e: React.MouseEvent): Promise<void> =>
onNewDashboardHandler(e)
}
onCancel={(): void => {
setShowNewDashboardTemplatesModal(false);
}}

View File

@@ -56,13 +56,11 @@ function SearchFilter({
const updateURLWithQuery = useCallback(
(inputQueries?: IQueryStructure[]): void => {
history.push({
pathname: history.location.pathname,
search:
inputQueries || queries
? `?search=${convertQueriesToURLQuery(inputQueries || queries)}`
: '',
});
const searchQuery =
inputQueries || queries
? `?search=${convertQueriesToURLQuery(inputQueries || queries)}`
: '';
history.push(`${history.location.pathname}${searchQuery}`);
},
[queries],
);
@@ -149,10 +147,7 @@ function SearchFilter({
const clearQueries = (): void => {
setQueries([]);
history.push({
pathname: history.location.pathname,
search: ``,
});
history.push(history.location.pathname);
};
return (

View File

@@ -32,20 +32,6 @@ import {
// 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: {

View File

@@ -302,8 +302,8 @@ function Login({
<Typography.Paragraph italic style={{ color: '#ACACAC' }}>
If you are admin,{' '}
<Typography.Link
onClick={(): void => {
history.push(ROUTES.SIGN_UP);
onClick={(e): void => {
history.push(ROUTES.SIGN_UP, e);
}}
style={{ fontWeight: 700 }}
>

View File

@@ -73,20 +73,6 @@ jest.mock('hooks/useSafeNavigate', () => ({
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('hooks/queryBuilder/useGetExplorerQueryRange', () => ({
__esModule: true,
useGetExplorerQueryRange: jest.fn(),

View File

@@ -7,18 +7,16 @@ import { logsresponse } from 'mocks-server/__mockdata__/query_range';
import { server } from 'mocks-server/server';
import { rest } from 'msw';
import LogsExplorer from 'pages/LogsExplorer';
import { QueryBuilderContext } from 'providers/QueryBuilder';
import React from 'react';
import { I18nextProvider } from 'react-i18next';
import { MemoryRouter } from 'react-router-dom-v5-compat';
import { VirtuosoMockContext } from 'react-virtuoso';
import i18n from 'ReactI18';
import {
act,
AllTheProviders,
fireEvent,
render,
RenderResult,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import { IBuilderQuery, Query } from 'types/api/queryBuilder/queryBuilderData';
@@ -91,20 +89,6 @@ getStateSpy.mockImplementation(() => {
return originalState;
});
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { search: string; pathname: string } => ({
@@ -277,9 +261,7 @@ describe.skip('LogsExplorerViews Pagination', () => {
act(() => {
renderResult = render(
<VirtuosoMockContext.Provider value={{ viewportHeight, itemHeight }}>
<I18nextProvider i18n={i18n}>
<LogsExplorer />
</I18nextProvider>
<LogsExplorer />
</VirtuosoMockContext.Provider>,
);
});
@@ -453,13 +435,14 @@ function LogsExplorerWithMockContext({
);
return (
<MemoryRouter>
<QueryBuilderContext.Provider value={contextValue as any}>
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
<LogsExplorer />
</VirtuosoMockContext.Provider>
</QueryBuilderContext.Provider>
</MemoryRouter>
<AllTheProviders
queryBuilderOverrides={contextValue as any}
initialRoute="/logs"
>
<VirtuosoMockContext.Provider value={virtuosoContextValue}>
<LogsExplorer />
</VirtuosoMockContext.Provider>
</AllTheProviders>
);
}
@@ -536,13 +519,12 @@ describe('Logs Explorer -> stage and run query', () => {
const initialEnd = initialPayload.end;
// Click the Stage & Run Query button
await act(async () => {
fireEvent.click(
screen.getByRole('button', {
name: /stage & run query/i,
}),
);
});
const user = userEvent.setup({ pointerEventsCheck: 0 });
await user.click(
screen.getByRole('button', {
name: /stage & run query/i,
}),
);
// Wait for additional API calls to be made after clicking Stage & Run Query
await waitFor(

View File

@@ -33,20 +33,6 @@ const lodsQueryServerRequest = (): void =>
),
);
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// mocking the graph components in this test as this should be handled separately
jest.mock(
'container/TimeSeriesView/TimeSeriesView',

View File

@@ -18,10 +18,6 @@ const MOCK_SEARCH_PARAMS =
'?graphType=list&widgetId=36a7b342-c642-4b92-abe4-cb833a244786&compositeQuery=%7B%22id%22%3A%22b325ac88-5e75-4117-a38c-1a2a7caf8115%22%2C%22builder%22%3A%7B%22queryData%22%3A%5B%7B%22dataSource%22%3A%22logs%22%2C%22queryName%22%3A%22A%22%2C%22aggregateOperator%22%3A%22noop%22%2C%22aggregateAttribute%22%3A%7B%22id%22%3A%22------%22%2C%22dataType%22%3A%22%22%2C%22key%22%3A%22%22%2C%22isColumn%22%3Afalse%2C%22type%22%3A%22%22%2C%22isJSON%22%3Afalse%7D%2C%22timeAggregation%22%3A%22rate%22%2C%22spaceAggregation%22%3A%22sum%22%2C%22functions%22%3A%5B%5D%2C%22filters%22%3A%7B%22items%22%3A%5B%5D%2C%22op%22%3A%22AND%22%7D%2C%22expression%22%3A%22A%22%2C%22disabled%22%3Afalse%2C%22stepInterval%22%3A60%2C%22having%22%3A%5B%5D%2C%22limit%22%3Anull%2C%22orderBy%22%3A%5B%7B%22columnName%22%3A%22timestamp%22%2C%22order%22%3A%22desc%22%7D%5D%2C%22groupBy%22%3A%5B%5D%2C%22legend%22%3A%22%22%2C%22reduceTo%22%3A%22avg%22%2C%22offset%22%3A0%2C%22pageSize%22%3A100%7D%5D%2C%22queryFormulas%22%3A%5B%5D%7D%2C%22clickhouse_sql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%2C%22query%22%3A%22%22%7D%5D%2C%22promql%22%3A%5B%7B%22name%22%3A%22A%22%2C%22query%22%3A%22%22%2C%22legend%22%3A%22%22%2C%22disabled%22%3Afalse%7D%5D%2C%22queryType%22%3A%22builder%22%7D&relativeTime=30m&options=%7B%22selectColumns%22%3A%5B%5D%2C%22maxLines%22%3A2%2C%22format%22%3A%22list%22%2C%22fontSize%22%3A%22small%22%7D';
// Mocks
jest.mock('uplot', () => ({
paths: { spline: jest.fn(), bars: jest.fn() },
default: jest.fn(() => ({ paths: { spline: jest.fn(), bars: jest.fn() } })),
}));
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
__esModule: true,

View File

@@ -235,7 +235,9 @@ function Application(): JSX.Element {
timestamp: number,
apmToTraceQuery: Query,
isViewLogsClicked?: boolean,
e?: React.MouseEvent,
): (() => void) => (): void => {
// eslint-disable-line @typescript-eslint/no-explicit-any
const endTime = secondsToMilliseconds(timestamp);
const startTime = secondsToMilliseconds(timestamp - stepInterval);
@@ -259,7 +261,7 @@ function Application(): JSX.Element {
queryString,
);
history.push(newPath);
history.push(newPath, e);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[stepInterval],
@@ -299,6 +301,28 @@ function Application(): JSX.Element {
});
const { safeNavigate } = useSafeNavigate();
const handleViewTracesClick = useCallback(
(e: React.MouseEvent): void => {
onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
event: e,
})();
},
[
servicename,
selectedTraceTags,
selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
],
);
return (
<>
<Row gutter={24}>
@@ -319,14 +343,7 @@ function Application(): JSX.Element {
type="default"
size="small"
id="Rate_button"
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onClick={handleViewTracesClick}
>
View Traces
</Button>
@@ -349,14 +366,7 @@ function Application(): JSX.Element {
type="default"
size="small"
id="ApDex_button"
onClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onClick={handleViewTracesClick}
>
View Traces
</Button>
@@ -370,15 +380,12 @@ function Application(): JSX.Element {
<ColErrorContainer>
<GraphControlsPanel
id="Error_button"
onViewLogsClick={onErrorTrackHandler(
selectedTimeStamp,
logErrorQuery,
true,
)}
onViewTracesClick={onErrorTrackHandler(
selectedTimeStamp,
errorTrackQuery,
)}
onViewLogsClick={(e: React.MouseEvent): void =>
onErrorTrackHandler(selectedTimeStamp, logErrorQuery, undefined, e)()
}
onViewTracesClick={(e: React.MouseEvent): void =>
onErrorTrackHandler(selectedTimeStamp, errorTrackQuery, undefined, e)()
}
/>
<TopLevelOperation

View File

@@ -6,9 +6,9 @@ import { Binoculars, DraftingCompass, ScrollText } from 'lucide-react';
interface GraphControlsPanelProps {
id: string;
onViewLogsClick?: () => void;
onViewTracesClick: () => void;
onViewAPIMonitoringClick?: () => void;
onViewLogsClick?: (e: React.MouseEvent) => void;
onViewTracesClick: (e: React.MouseEvent) => void;
onViewAPIMonitoringClick?: (e: React.MouseEvent) => void;
}
function GraphControlsPanel({
@@ -23,7 +23,7 @@ function GraphControlsPanel({
type="link"
icon={<DraftingCompass size={14} />}
size="small"
onClick={onViewTracesClick}
onClick={(e: React.MouseEvent): void => onViewTracesClick(e)}
style={{ color: Color.BG_VANILLA_100 }}
>
View traces
@@ -33,7 +33,7 @@ function GraphControlsPanel({
type="link"
icon={<ScrollText size={14} />}
size="small"
onClick={onViewLogsClick}
onClick={(e: React.MouseEvent): void => onViewLogsClick(e)}
style={{ color: Color.BG_VANILLA_100 }}
>
View logs
@@ -44,7 +44,7 @@ function GraphControlsPanel({
type="link"
icon={<Binoculars size={14} />}
size="small"
onClick={onViewAPIMonitoringClick}
onClick={(e: React.MouseEvent): void => onViewAPIMonitoringClick(e)}
style={{ color: Color.BG_VANILLA_100 }}
>
View External APIs

View File

@@ -103,23 +103,29 @@ function ServiceOverview({
<>
<GraphControlsPanel
id="Service_button"
onViewLogsClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery: apmToLogQuery,
isViewLogsClicked: true,
stepInterval,
safeNavigate,
})}
onViewTracesClick={onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
})}
onViewLogsClick={(e: React.MouseEvent): void => {
onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery: apmToLogQuery,
isViewLogsClicked: true,
stepInterval,
safeNavigate,
event: e,
})();
}}
onViewTracesClick={(e: React.MouseEvent): void => {
onViewTracePopupClick({
servicename,
selectedTraceTags,
timestamp: selectedTimeStamp,
apmToTraceQuery,
stepInterval,
safeNavigate,
event: e,
})();
}}
/>
<Card data-testid="service_latency">
<GraphContainer>

View File

@@ -42,7 +42,8 @@ interface OnViewTracePopupClickProps {
apmToTraceQuery: Query;
isViewLogsClicked?: boolean;
stepInterval?: number;
safeNavigate: (url: string) => void;
safeNavigate: (url: string, event?: React.MouseEvent) => void;
event?: React.MouseEvent;
}
interface OnViewAPIMonitoringPopupClickProps {
@@ -51,8 +52,8 @@ interface OnViewAPIMonitoringPopupClickProps {
stepInterval?: number;
domainName: string;
isError: boolean;
safeNavigate: (url: string) => void;
event?: React.MouseEvent;
safeNavigate: (url: string, event?: React.MouseEvent) => void;
}
export function generateExplorerPath(
@@ -83,7 +84,7 @@ export function generateExplorerPath(
* @param isViewLogsClicked - Whether this is for viewing logs vs traces
* @param stepInterval - Time interval in seconds
* @param safeNavigate - Navigation function
* @param event - Event object
*/
export function onViewTracePopupClick({
selectedTraceTags,
@@ -93,6 +94,7 @@ export function onViewTracePopupClick({
isViewLogsClicked,
stepInterval,
safeNavigate,
event,
}: OnViewTracePopupClickProps): VoidFunction {
return (): void => {
const endTime = secondsToMilliseconds(timestamp);
@@ -118,7 +120,11 @@ export function onViewTracePopupClick({
queryString,
);
safeNavigate(newPath);
if (event) {
safeNavigate(newPath, event);
} else {
safeNavigate(newPath);
}
};
}
@@ -149,6 +155,7 @@ export function onViewAPIMonitoringPopupClick({
isError,
stepInterval,
safeNavigate,
event,
}: OnViewAPIMonitoringPopupClickProps): VoidFunction {
return (): void => {
const endTime = timestamp + (stepInterval || 60);
@@ -190,7 +197,11 @@ export function onViewAPIMonitoringPopupClick({
filters,
);
safeNavigate(newPath);
if (event) {
safeNavigate(newPath, event);
} else {
safeNavigate(newPath);
}
};
}

View File

@@ -68,19 +68,6 @@ jest.mock('hooks/useNotifications', () => ({
},
}),
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('react-redux', () => ({
...jest.requireActual('react-redux'),
useSelector: (): any => ({

View File

@@ -15,12 +15,6 @@ import {
TimeAggregationOptions,
} from '../types';
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: jest.fn(),
})),
);
const mockResizeObserver = jest.fn();
mockResizeObserver.mockImplementation(() => ({
observe: (): void => undefined,

View File

@@ -76,12 +76,6 @@ jest
isLoading: false,
} as any);
jest.mock('uplot', () =>
jest.fn().mockImplementation(() => ({
destroy: jest.fn(),
})),
);
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: (): { pathname: string } => ({

View File

@@ -1,6 +1,5 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { QueryParams } from 'constants/query';
import * as useSafeNavigate from 'hooks/useSafeNavigate';
import DashboardsAndAlertsPopover from '../DashboardsAndAlertsPopover';
@@ -24,9 +23,11 @@ const mockAlerts = [mockAlert1, mockAlert2];
const mockDashboards = [mockDashboard1, mockDashboard2];
const mockSafeNavigate = jest.fn();
jest.spyOn(useSafeNavigate, 'useSafeNavigate').mockReturnValue({
safeNavigate: mockSafeNavigate,
});
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
const mockSetQuery = jest.fn();
const mockUrlQuery = {

View File

@@ -11,19 +11,6 @@ import store from 'store';
import Summary from '../Summary';
import { TreemapViewType } from '../types';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
jest.mock('d3-hierarchy', () => ({
stratify: jest.fn().mockReturnValue({
id: jest.fn().mockReturnValue({

View File

@@ -21,17 +21,25 @@ function NewExplorerCTA(): JSX.Element | null {
[location.pathname],
);
const onClickHandler = useCallback((): void => {
if (location.pathname === ROUTES.LOGS_EXPLORER) {
history.push(ROUTES.OLD_LOGS_EXPLORER);
} else if (location.pathname === ROUTES.TRACE) {
history.push(ROUTES.TRACES_EXPLORER);
} else if (location.pathname === ROUTES.OLD_LOGS_EXPLORER) {
history.push(ROUTES.LOGS_EXPLORER);
} else if (location.pathname === ROUTES.TRACES_EXPLORER) {
history.push(ROUTES.TRACE);
}
}, [location.pathname]);
const onClickHandler = useCallback(
(event?: React.MouseEvent): void => {
let targetRoute = '';
if (location.pathname === ROUTES.LOGS_EXPLORER) {
targetRoute = ROUTES.OLD_LOGS_EXPLORER;
} else if (location.pathname === ROUTES.TRACE) {
targetRoute = ROUTES.TRACES_EXPLORER;
} else if (location.pathname === ROUTES.OLD_LOGS_EXPLORER) {
targetRoute = ROUTES.LOGS_EXPLORER;
} else if (location.pathname === ROUTES.TRACES_EXPLORER) {
targetRoute = ROUTES.TRACE;
}
if (targetRoute) {
history.push(targetRoute, event);
}
},
[location.pathname],
);
const button = useMemo(
() => (

View File

@@ -38,7 +38,7 @@ export default function NoLogs({
} else {
link = ROUTES.GET_STARTED_LOGS_MANAGEMENT;
}
history.push(link);
history.push(link, e);
} else if (dataSource === 'traces') {
window.open(DOCLINKS.TRACES_EXPLORER_EMPTY_STATE, '_blank');
} else if (dataSource === DataSource.METRICS) {

View File

@@ -61,7 +61,7 @@ export interface ModuleProps {
export interface SelectedModuleStepProps {
id: string;
title: string;
component: any;
component: any; // eslint-disable-line @typescript-eslint/no-explicit-any
}
export const useCases = {
@@ -317,10 +317,18 @@ export default function Onboarding(): JSX.Element {
{activeStep === 1 && (
<div className="onboarding-page">
<div
onClick={(): void => {
onClick={(e): void => {
logEvent('Onboarding V2: Skip Button Clicked', {});
history.push(ROUTES.APPLICATION);
history.push(ROUTES.APPLICATION, e);
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
logEvent('Onboarding V2: Skip Button Clicked', {});
history.push(ROUTES.APPLICATION);
}
}}
role="button"
tabIndex={0}
className="skip-to-console"
>
{t('skip')}
@@ -332,7 +340,7 @@ export default function Onboarding(): JSX.Element {
<div className="modulesContainer">
<div className="moduleContainerRowStyles">
{Object.keys(ModulesMap).map((module) => {
const selectedUseCase = (useCases as any)[module];
const selectedUseCase = (useCases as any)[module]; // eslint-disable-line @typescript-eslint/no-explicit-any
return (
<Card

View File

@@ -10,20 +10,6 @@ import store from 'store';
import ChangeHistory from '../index';
import { pipelineData, pipelineDataHistory } from './testUtils';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const queryClient = new QueryClient({
defaultOptions: {
queries: {

View File

@@ -5,20 +5,6 @@ import { PipelineData } from 'types/api/pipeline/def';
import { pipelineMockData } from '../mocks/pipeline';
import AddNewPipeline from '../PipelineListsView/AddNewPipeline';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
export function matchMedia(): void {
Object.defineProperty(window, 'matchMedia', {
writable: true,

View File

@@ -11,20 +11,6 @@ jest.mock('../PipelineListsView/AddNewProcessor/config', () => ({
DEFAULT_PROCESSOR_TYPE: 'json_parser',
}));
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const selectedProcessorData = {
id: '1',
orderId: 1,

View File

@@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18';
import store from 'store';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => {
it('should render DeleteAction section', () => {
const { asFragment } = render(

View File

@@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18';
import store from 'store';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => {
it('should render DragAction section', () => {
const { asFragment } = render(

View File

@@ -6,20 +6,6 @@ import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18';
import store from 'store';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => {
it('should render EditAction section', () => {
const { asFragment } = render(

View File

@@ -8,20 +8,6 @@ import store from 'store';
import { pipelineMockData } from '../mocks/pipeline';
import PipelineActions from '../PipelineListsView/TableComponents/PipelineActions';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('PipelinePage container test', () => {
it('should render PipelineActions section', () => {
const { asFragment } = render(

View File

@@ -3,20 +3,6 @@ import { render } from 'tests/test-utils';
import { pipelineMockData } from '../mocks/pipeline';
import PipelineExpandView from '../PipelineListsView/PipelineExpandView';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,

View File

@@ -6,20 +6,6 @@ import { findByText, fireEvent, render, waitFor } from 'tests/test-utils';
import { pipelineApiResponseMockData } from '../mocks/pipeline';
import PipelineListsView from '../PipelineListsView';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
// Mock useUrlQuery hook
const mockUrlQuery = {
get: jest.fn(),

View File

@@ -4,20 +4,6 @@ import { v4 } from 'uuid';
import PipelinePageLayout from '../Layouts/Pipeline';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
beforeAll(() => {
Object.defineProperty(window, 'matchMedia', {
writable: true,

View File

@@ -7,20 +7,6 @@ import store from 'store';
import TagInput from '../components/TagInput';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('Pipeline Page', () => {
it('should render TagInput section', () => {
const { asFragment } = render(

View File

@@ -1,24 +1,11 @@
import { render } from '@testing-library/react';
import Tags from 'container/PipelinePage/PipelineListsView/TableComponents/Tags';
import { I18nextProvider } from 'react-i18next';
import { Provider } from 'react-redux';
import { MemoryRouter } from 'react-router-dom';
import i18n from 'ReactI18';
import store from 'store';
import { render } from 'tests/test-utils';
const tags = ['server', 'app'];
describe('PipelinePage container test', () => {
it('should render Tags section', () => {
const { asFragment } = render(
<MemoryRouter>
<Provider store={store}>
<I18nextProvider i18n={i18n}>
<Tags tags={tags} />
</I18nextProvider>
</Provider>
</MemoryRouter>,
);
const { asFragment } = render(<Tags tags={tags} />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -11,20 +11,6 @@ import {
getTableColumn,
} from '../PipelineListsView/utils';
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
describe('Utils testing of Pipeline Page', () => {
test('it should be check form field of add pipeline', () => {
expect(pipelineFields.length).toBe(3);

View File

@@ -6,7 +6,7 @@ import { PlannedDowntime } from '../PlannedDowntime';
describe('PlannedDowntime Component', () => {
it('renders the PlannedDowntime component properly', () => {
render(<PlannedDowntime />, {}, 'ADMIN');
render(<PlannedDowntime />, {}, { role: 'ADMIN' });
// Check if title is rendered
expect(screen.getByText('Planned Downtime')).toBeInTheDocument();
@@ -30,7 +30,7 @@ describe('PlannedDowntime Component', () => {
});
it('disables the "New downtime" button for users with VIEWER role', () => {
render(<PlannedDowntime />, {}, USER_ROLES.VIEWER);
render(<PlannedDowntime />, {}, { role: USER_ROLES.VIEWER });
// Check if "New downtime" button is disabled for VIEWER
const newDowntimeButton = screen.getByRole('button', {

View File

@@ -58,12 +58,16 @@
padding: 2px 8px;
align-items: center;
width: fit-content;
max-width: calc(100% - 120px); /* Reserve space for action buttons */
max-width: 100%;
gap: 8px;
border-radius: 50px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-slate-500);
.copy-wrapper {
overflow: hidden;
}
.item-value {
color: var(--bg-vanilla-400);
font-family: Inter;

View File

@@ -97,11 +97,13 @@ function Attributes(props: IAttributesProps): JSX.Element {
</div>
<div className="value-wrapper">
<Tooltip title={item.value}>
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
<Typography.Text className="item-value" ellipsis>
{item.value}
</Typography.Text>{' '}
</CopyClipboardHOC>
<div className="copy-wrapper">
<CopyClipboardHOC entityKey={item.value} textToCopy={item.value}>
<Typography.Text className="item-value" ellipsis>
{item.value}
</Typography.Text>
</CopyClipboardHOC>
</div>
</Tooltip>
<AttributeActions
record={item}

View File

@@ -3,7 +3,7 @@
flex-direction: column;
height: calc(100vh - 44px); //44px -> trace details top bar
border-left: 1px solid var(--bg-slate-400);
overflow-y: auto;
overflow-y: auto !important;
&:not(&-docked) {
min-width: 450px;
}

View File

@@ -1,7 +1,5 @@
import { fireEvent, screen } from '@testing-library/react';
import { useSafeNavigate } from 'hooks/useSafeNavigate';
import useUrlQuery from 'hooks/useUrlQuery';
import { render } from 'tests/test-utils';
import { fireEvent, render, screen } from 'tests/test-utils';
import { Span } from 'types/api/trace/getTraceV2';
import { SpanDuration } from '../Success';
@@ -15,7 +13,6 @@ const DIMMED_SPAN_CLASS = 'dimmed-span';
const SELECTED_NON_MATCHING_SPAN_CLASS = 'selected-non-matching-span';
// Mock the hooks
jest.mock('hooks/useSafeNavigate');
jest.mock('hooks/useUrlQuery');
jest.mock('@signozhq/badge', () => ({
Badge: jest.fn(),
@@ -52,24 +49,17 @@ const mockTraceMetadata = {
hasMissingSpans: false,
};
jest.mock('uplot', () => {
const paths = {
spline: jest.fn(),
bars: jest.fn(),
};
const uplotMock = jest.fn(() => ({
paths,
}));
return {
paths,
default: uplotMock,
};
});
const mockSafeNavigate = jest.fn();
jest.mock('hooks/useSafeNavigate', () => ({
useSafeNavigate: (): any => ({
safeNavigate: mockSafeNavigate,
}),
}));
describe('SpanDuration', () => {
const mockSetSelectedSpan = jest.fn();
const mockUrlQuerySet = jest.fn();
const mockSafeNavigate = jest.fn();
const mockUrlQueryGet = jest.fn();
beforeEach(() => {
@@ -81,11 +71,6 @@ describe('SpanDuration', () => {
get: mockUrlQueryGet,
toString: () => 'spanId=test-span-id',
});
// Mock safe navigate hook
(useSafeNavigate as jest.Mock).mockReturnValue({
safeNavigate: mockSafeNavigate,
});
});
it('updates URL and selected span when clicked', () => {

View File

@@ -83,6 +83,84 @@ const isDefaultNavigation = (currentUrl: URL, targetUrl: URL): boolean => {
return newKeys.length > 0;
};
// Helper function to determine if an argument is an event
const isEventObject = (arg: any): boolean =>
arg &&
(arg instanceof MouseEvent ||
arg instanceof KeyboardEvent ||
arg.nativeEvent instanceof MouseEvent ||
arg.nativeEvent instanceof KeyboardEvent ||
arg.metaKey !== undefined ||
arg.ctrlKey !== undefined);
// Helper function to extract options from arguments
const extractOptions = (
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
): NavigateOptions => {
const isEvent = isEventObject(optionsOrEvent);
const actualOptions = isEvent ? options : (optionsOrEvent as NavigateOptions);
const shouldOpenInNewTab =
isEvent &&
((optionsOrEvent as any).metaKey || (optionsOrEvent as any).ctrlKey);
return {
...actualOptions,
newTab: shouldOpenInNewTab || actualOptions?.newTab,
};
};
// Helper function to create target URL
const createTargetUrl = (
to: string | SafeNavigateParams,
location: { pathname: string; search: string },
): URL => {
if (typeof to === 'string') {
return new URL(to, window.location.origin);
}
return new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
};
// Helper function to handle new tab navigation
const handleNewTabNavigation = (
to: string | SafeNavigateParams,
location: { pathname: string; search: string },
): void => {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
};
// Helper function to perform navigation
const performNavigation = (
to: string | SafeNavigateParams,
navigationOptions: NavigateOptions,
navigate: any,
location: { pathname: string; search: string },
): void => {
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
};
export const useSafeNavigate = (
{ preventSameUrlNavigation }: UseSafeNavigateProps = {
preventSameUrlNavigation: true,
@@ -90,6 +168,11 @@ export const useSafeNavigate = (
): {
safeNavigate: (
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => void;
} => {
@@ -97,30 +180,25 @@ export const useSafeNavigate = (
const location = useLocation();
const safeNavigate = useCallback(
(to: string | SafeNavigateParams, options?: NavigateOptions) => {
(
to: string | SafeNavigateParams,
optionsOrEvent?:
| NavigateOptions
| React.MouseEvent
| MouseEvent
| KeyboardEvent,
options?: NavigateOptions,
) => {
const finalOptions = extractOptions(optionsOrEvent, options);
const currentUrl = new URL(
`${location.pathname}${location.search}`,
window.location.origin,
);
const targetUrl = createTargetUrl(to, location);
let targetUrl: URL;
if (typeof to === 'string') {
targetUrl = new URL(to, window.location.origin);
} else {
targetUrl = new URL(
`${to.pathname || location.pathname}${to.search || ''}`,
window.location.origin,
);
}
// If newTab is true, open in new tab and return early
if (options?.newTab) {
const targetPath =
typeof to === 'string'
? to
: `${to.pathname || location.pathname}${to.search || ''}`;
window.open(targetPath, '_blank');
// Handle new tab navigation
if (finalOptions?.newTab) {
handleNewTabNavigation(to, location);
return;
}
@@ -132,23 +210,13 @@ export const useSafeNavigate = (
}
const navigationOptions = {
...options,
replace: isDefaultParamsNavigation || options?.replace,
...finalOptions,
replace: isDefaultParamsNavigation || finalOptions?.replace,
};
if (typeof to === 'string') {
navigate(to, navigationOptions);
} else {
navigate(
{
pathname: to.pathname || location.pathname,
search: to.search,
},
navigationOptions,
);
}
performNavigation(to, navigationOptions, navigate, location);
},
[navigate, location.pathname, location.search, preventSameUrlNavigation],
[navigate, location, preventSameUrlNavigation],
);
return { safeNavigate };

View File

@@ -16,8 +16,6 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
staleTime: 2 * 60 * 1000, // 2 minutes - data becomes stale after 2 minutes
cacheTime: 5 * 60 * 1000, // 5 minutes - cache entries are garbage collected after 5 minutes
retry(failureCount, error): boolean {
if (
// in case of manually throwing errors please make sure to send error.response.status

View File

@@ -1,3 +1,53 @@
import { createBrowserHistory } from 'history';
import { createBrowserHistory, History } from 'history';
export default createBrowserHistory();
// Create the base history instance
const baseHistory = createBrowserHistory();
// Extend the History interface to include enhanced push method
interface EnhancedHistory extends History {
push: {
(path: string, state?: any): void;
(
path: string,
event?: React.MouseEvent | MouseEvent | KeyboardEvent,
state?: any,
): void;
};
originalPush: History['push'];
}
// Create enhanced history with overridden push method
const history = baseHistory as EnhancedHistory;
// Store the original push method
history.originalPush = baseHistory.push;
// Override push to handle meta/ctrl key events
history.push = function (
path: string,
eventOrState?: React.MouseEvent | MouseEvent | KeyboardEvent | any,
state?: any,
): void {
// Check if second argument is an event object
const isEvent =
eventOrState &&
(eventOrState instanceof MouseEvent ||
eventOrState instanceof KeyboardEvent ||
eventOrState.nativeEvent instanceof MouseEvent ||
eventOrState.nativeEvent instanceof KeyboardEvent ||
eventOrState.metaKey !== undefined ||
eventOrState.ctrlKey !== undefined);
// If it's an event and meta/ctrl key is pressed, open in new tab
if (isEvent && (eventOrState.metaKey || eventOrState.ctrlKey)) {
window.open(path, '_blank');
return;
}
// Otherwise, use normal navigation
// If eventOrState is not an event, treat it as state
const actualState = isEvent ? state : eventOrState;
history.originalPush(path, actualState);
};
export default history;

View File

@@ -1,7 +1,10 @@
// src/mocks/server.js
import { rest } from 'msw';
import { setupServer } from 'msw/node';
import { handlers } from './handlers';
// This configures a request mocking server with the given request handlers.
export const server = setupServer(...handlers);
export { rest };

View File

@@ -79,7 +79,7 @@ function OldLogsExplorer(): JSX.Element {
});
const params = new URLSearchParams(location.search);
params.set(QueryParams.order, value);
history.push({ search: params.toString() });
history.push(`${history.location.pathname}?${params.toString()}`);
};
return (

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