Compare commits

..

65 Commits

Author SHA1 Message Date
Nikhil Soni
005f98fad7 fix: fix old attribute usage in tests 2026-02-17 11:25:32 +05:30
Nikhil Soni
2275bb9bf9 fix: use constants defined where possible 2026-02-17 11:25:32 +05:30
Nikhil Soni
09e3677d13 fix: switch to using http_host instead of external_http_url
external_http_url stores the hostname but the name
is confusing, so switching to http_host
2026-02-17 11:25:32 +05:30
Nikhil Soni
3691589bfc fix: fix incosistent use of http attribute in ext. api
HTTP attributes like http.url, url.full along with server.name and net.peer.name
were used inconsitantly leading to bugs in aggregation query and they were
expensive to query as well since these attr are stored as json instead of
direct columns. Using columns like http_url optimises these queries since
it gets populated using all relevant attributes during ingestion itself.
2026-02-17 11:25:32 +05:30
SagarRajput-7
aa39db8ac2 feat: updated onboarding question and used radio from signozhq (#10317)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: updated onboarding question and used radio from signozhq

* feat: added and updated test cases

* feat: removed the editorg and updateorg from onboarding questionnaire

* feat: addressed comments and feedback
2026-02-16 20:55:18 +05:30
Abhi kumar
eb2c6b78c8 feat: added new barpanel (#10319)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-16 12:52:06 +00:00
Abhi kumar
2d2d0c3d9f fix: fixed series visiblity logic, not relying on index now (#10318) 2026-02-16 12:40:37 +00:00
Ashwin Bhatkal
8a4544cbac chore: dynamic variables first load (#10316) 2026-02-16 17:13:32 +05:30
Ashwin Bhatkal
8898f02698 chore: variables based panel fetching (#10292)
* chore: replace prop drilling with fetch store

* chore: variables based panel fetching

* chore: add tests

* chore: move tests

* chore: add tests for new hook

* chore: resolve comments
2026-02-16 09:11:06 +00:00
Ashwin Bhatkal
f277009ff8 chore: replace prop drilling with fetch store (#10291)
* chore: shared utils update + API plumbing

* chore: variable fetch state machine

* chore: add tests

* chore: add tests

* chore: move tests

* chore: fix tests

* chore: replace prop drilling with fetch store

* chore: fix types

* chore: add tests for new utils

* chore: resolve comments
2026-02-16 08:50:08 +00:00
Abhi kumar
17c6b79d79 Revert "feat: enabled new bar panel (#10312)" (#10314)
This reverts commit 82dffdda56.
2026-02-16 14:09:19 +05:30
Abhi kumar
76d6c23217 fix: added fix for bar chart width calculation based on stepinterval (#10305) 2026-02-16 13:00:20 +05:30
Abhi kumar
82dffdda56 feat: enabled new bar panel (#10312) 2026-02-16 12:43:45 +05:30
Abhi kumar
8f38398863 test: added tests for barpanel hooks + utils (#10295)
* chore: refactored the config builder and added base config builder

* chore: added a common chart wrapper

* chore: tsc fix

* fix: pr review changes

* fix: pr review changes

* chore: added different tooltips

* chore: removed dayjs extention

* feat: added new barpanel component

* fix: added fix for pr review changes

* chore: added support for bar alignment configuration

* chore: updated structure for bar panel

* test: added tests for barpanel hooks + utils
2026-02-16 06:18:14 +00:00
Ashwin Bhatkal
eb39772d3c chore: variable fetch state machine (#10290)
* chore: shared utils update + API plumbing

* chore: variable fetch state machine

* chore: add tests

* chore: add tests

* chore: move tests

* chore: fix tests
2026-02-16 11:36:16 +05:30
Pandey
df72c897f9 feat: change invitation and password reset emails (#10297)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-15 14:59:54 +00:00
Abhishek Kumar Singh
4bbe5ead07 test(integration): alerts e2e test cases with basic rule manager alerts (#10163)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-14 22:32:50 +05:30
Yunus M
e36689ecba fix: show ip addresses toggle and add regression test (#10251) 2026-02-14 19:47:16 +05:30
Abhi kumar
2c948ef9f6 feat: added new barpanel component (#10266)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: refactored the config builder and added base config builder

* chore: added a common chart wrapper

* chore: tsc fix

* fix: pr review changes

* fix: pr review changes

* chore: added different tooltips

* chore: removed dayjs extention

* feat: added new barpanel component

* fix: added fix for pr review changes

* chore: added support for bar alignment configuration

* chore: updated structure for bar panel

* chore: pr review fix
2026-02-13 23:22:52 +05:30
Abhi kumar
3c30114642 feat: added option to copy legend text (#10294)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: added option to copy legend text

* chore: added test for legend copy action

* chore: updated legend styles

* chore: added check icon when legend copied

* chore: added copytoclipboard hook

* chore: removed copytoclipboard options
2026-02-13 08:08:04 +00:00
Ashwin Bhatkal
d042fad1e3 chore: shared utils update + API plumbing (#10257)
* chore: shared utils update + API plumbing

* chore: add tests
2026-02-13 06:05:44 +00:00
Abhi kumar
235c606b44 test: added tests for utils + components (#10281)
* test: added tests for utils + components

* fix: added fix for legend test

* chore: pr review comments

* chore: fixed plotcontext test

* fix: added tests failure handing + moved from map based approach to array

* fix: updated the way we used to consume graph visibility state

* fix: fixed label spelling
2026-02-13 05:45:35 +00:00
Abhi kumar
a49d7e1662 chore: resetting spangaps to old default state in the new timeseries chart + added thresholds in scale computation (#10287)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: moved spangaps to old default state in the new timeseries chart

* chore: added thresholds in scale builder
2026-02-12 22:22:41 +05:30
Abhi kumar
b3e41b5520 feat: enabled new time-series panel (#10273) 2026-02-12 21:20:22 +05:30
Abhi kumar
83bb97cc58 test: added unit tests for uplot config builders (#10220)
* test: added unit tests for uplot config builders

* test: added more tests

* test: updated tests

* fix: updated tests

* fix: fixed failing test

* fix: updated tests

* test: added test for axis

* chore: added test for thresholds with different scale key

* chore: pr review comments

* chore: pr review comments

* fix: fixed axis tests

---------

Co-authored-by: Ashwin Bhatkal <ashwin96@gmail.com>
2026-02-12 15:22:58 +00:00
swapnil-signoz
68ea28cf6b test(cloudintegration): add tests for cloudintegrations (#10237)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: adding tests for cloudintegration api

* refactor: updating method

* ci: updated logs

* ci: fmt fixes

* refactor: removing unused vars

* refactor: updating test assertion

* refactor: worked on review comments

* fix: fixing tests

* refactor: using yield

* refactor: fixing lint issues

* refactor: cleaning tests

* refactor: review comments

* refactor: removing unused imports

* refactor: updating fixture name

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-02-12 13:43:29 +05:30
Naman Verma
3726c0aac1 feat: add support for simultaneous delta and cumulative temporality (#10202) 2026-02-12 06:36:07 +00:00
Nikhil Soni
dae2d3239b fix: guide user on empty external api monitoring page (#10133)
* fix: guide user on empty external api monitoring page

Fixes #3703

* fix: change language of msg to handle empty query result

* fix: hide table header for empty result

* fix: add styling for empty state message
2026-02-12 05:54:02 +00:00
Ishan
0c660f8618 feat: Faster way to view associated logs for a trace in logs explorer (#10242)
Some checks failed
build-staging / staging (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat: replace filter feature

* feat: updated parsing field value

* feat: pr comments fix
2026-02-12 08:40:29 +05:30
Nageshbansal
97cffbc20a fix: remove unused flag for otel-col (#10275)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-12 01:34:32 +05:30
SagarRajput-7
8317eb1735 feat: added forgot password feature (#10172)
* feat: updated the generated apis

* feat: added forgot password feature

* feat: handled single org id

* feat: correct the success message

* feat: removed comments

* feat: added test cases

* feat: added loading in submit enabled condition

* feat: updated styles

* feat: removed light mode overrides as used semantic tokens

* feat: addressed comments and feedback

* feat: changed logic according to new open api spec

* feat: addressed comments and used signozhq

* feat: added signozhq icon in modulenamemapper

* feat: used styles variables from design-token for typography

* feat: refactored code to resolve comments
2026-02-11 13:24:47 +00:00
SagarRajput-7
b1789ea3f7 feat: upgraded the ingestion gateway apis (#10203)
* feat: upgraded the ingestion gateway apis

* feat: updated test case and refactored code

* feat: refactored the api query hooks usage and other refactoring

* feat: refactored code to resolve comments

* feat: refactored code to resolve comments
2026-02-11 18:44:38 +05:30
Ishan
3b41d0a731 feat: Filtering UI starts glitching when text is truncated (#10243)
* feat: tooltip scroll bug

* feat: updated to have mouseEnterDelay with 200ms

* feat: typography tooltip

* feat: typography tooltip updated
2026-02-11 18:32:55 +05:30
Abhi kumar
a171f7122f chore: refactored the config builder and added base config builder (#10256)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: refactored the config builder and added base config builder

* chore: added a common chart wrapper

* chore: tsc fix

* fix: pr review changes

* fix: pr review changes

* chore: added different tooltips

* chore: removed dayjs extention

* fix: added fix for pr review changes
2026-02-11 09:14:36 +00:00
Abhi kumar
4a20e93b20 test: added tests for uplotv2 utils (#10253)
* test: added tests for uplotv2 utils

* fix: added fix for pr review changes
2026-02-11 08:59:14 +00:00
Abhi kumar
d4dc709aa5 test: added test for tooltip plugin (#10248)
* test: added test for tooltip plugin

* fix: added fix for pr review changes
2026-02-11 14:18:40 +05:30
Srikanth Chekuri
cd014652a1 chore: add openapi check for js and regenerate (#10249) 2026-02-11 07:26:23 +00:00
primus-bot[bot]
538351131f chore(release): bump to v0.111.0 (#10271)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-02-11 12:20:30 +05:30
Vishal Sharma
a3bd72ad86 feat(onboarding): add Docker support for apm data sources, Convex and simplify PHP flow (#10261)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(onboarding): add Docker support for apm data sources, Convex and simplify PHP flow

- Add Convex logo asset for integration branding
- Add Docker deployment options for Java frameworks (Spring Boot, Tomcat, JBoss, Quarkus)
- Update JBoss configuration to include WildFly support and associated keywords
- Restructure PHP onboarding flow to prioritize environment selection over framework
- Update Java keywords to include JDBC and remove deprecated metrics tag

* chore: remove 'angular' from related keywords in the onboarding configuration
2026-02-10 18:23:24 +05:30
Nikhil Soni
786070d90b refactor: switch group by instead of triggering aggregation (#10263)
Using FINAL in clickhouse query will trigger the aggregation
merge of data while using group by will be more efficient.
It's recommended id docs as well - https://clickhouse.com/
docs/engines/table-engines/mergetree-family/
aggregatingmergetree#select-and-insert
2026-02-10 12:35:31 +00:00
Vikrant Gupta
e699ad8122 fix(meter): custom step intervals for meter aggregations (#10255)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* fix(meter): custom step interval support

* fix(meter): custom step interval support

* fix(meter): custom step interval support

* fix(meter): custom step interval support

* fix(meter): remove frontend harcoding for step interval

* fix(meter): remove frontend harcoding for step interval
2026-02-10 16:54:14 +05:30
Ishan
a1cc05848c feat: added open filter (#10244) 2026-02-10 11:33:17 +05:30
Ederson G. Elias
6d78df2275 fix: Service Map environment filter not working with DOT_METRICS_ENABLED (#10227)
* fix: Service Map environment filter not working with DOT_METRICS_ENABLED

When DOT_METRICS_ENABLED is active, resource attribute keys use dot notation
(e.g. resource_deployment.environment) instead of underscore notation
(e.g. resource_deployment_environment). The whilelistedKeys array only
contained underscore-notation keys, causing getVisibleQueries and
mappingWithRoutesAndKeys to filter out valid queries on the Service Map.

- Add dot-notation variants to whilelistedKeys for environment, k8s cluster
  name, and k8s namespace
- Remove unnecessary onBlur handler from environment Select component
- Add unit tests for whilelistedKeys and mappingWithRoutesAndKeys

Closes #10226

* chore: run perttify

---------

Co-authored-by: srikanthccv <srikanth.chekuri92@gmail.com>
2026-02-10 04:48:31 +00:00
Ashwin Bhatkal
df49484bea fix: fix flaky dashboard test (#10254)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-09 14:43:48 +00:00
Ashwin Bhatkal
72b0f27494 chore: use variable select strategy + (#10245)
* chore: use variable select strategy

* chore: revert custom multi select

* chore: fix tests

* chore: fix tests

* chore: fix flaky test
2026-02-09 14:29:28 +00:00
Abhi kumar
e36b647bc7 test: added test suites for uplotchart component (#10247)
* test: added test suites for uplotchart component

* chore: resolved pr review comments

* chore: resolved pr review comments
2026-02-09 14:15:21 +00:00
Nikhil Soni
b491772eaa fix: ensure trace time range is fetch correctly (#10252)
If multiple batches are inserted with same trace_id, then
trace_summary table can have multiple rows before they are
aggregated by clickhouse. Query to get the time range from
trace_summary was assuming a single which was create
unpredictable behaviour as any random row could be returned.
2026-02-09 19:31:08 +05:30
Ashwin Bhatkal
128497f27a chore: folder name change + CODEOWNER update (#10246)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: folder name change + CODEOWNER update

* chore: revert multi select file change
2026-02-09 09:40:26 +00:00
Jatinderjit Singh
9e466b56b2 chore: preserve the original duration format (#10149) 2026-02-09 09:24:58 +00:00
Vikrant Gupta
4ad0baa2a2 feat(authz): add support for wildcard selector (#10208)
* feat(authz): remove unnecessary dependency injection for role setter

* feat(authz): deprecate role module

* feat(authz): deprecate role module

* feat(authz): split between server and sql actions

* feat(authz): add bootstrap for managed role transactions

* feat(authz): update and add integration tests

* feat(authz): match names for factory and migration

* feat(authz): fix integration tests

* feat(authz): reduce calls on organisation creeation
2026-02-09 14:37:44 +05:30
Srikanth Chekuri
24b588bfba chore: move fields api to openapi spec (#10219) 2026-02-09 13:43:36 +05:30
Abhi kumar
e5867cc2ad chore: updated chart theme colors (#10233)
* chore: updated chart theme colors

* fix: fixed failing tests
2026-02-09 12:25:36 +05:30
Srikanth Chekuri
b420ca494e chore: remove inline enum and implement jsonschema.Enum for metric types (#10238)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-09 07:02:32 +05:30
Piyush Singariya
e4693ce64c fix: improve qbtoexpr test suite (#10217)
Some checks failed
build-staging / prepare (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
* fix: improve qbtoexpr test suite

* fix: assert eq order

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2026-02-06 15:15:56 +00:00
Ashwin Bhatkal
ca9b3a910a chore: separate out query and custom variable (#10221)
* chore: separate out query and custom variable

* chore: resolve own comments
2026-02-06 19:06:30 +05:30
Karan Balani
9dc7d2389a fix: minor changes for gateway and forgot password apis (#10204)
* fix: make size and count included in json if zero

* fix: make forgot password api fields required

* fix: openapi spec

* fix: error message casing for frontend

* chore: fix openapi spec

* fix: openapi specs
2026-02-06 18:00:33 +05:30
Abhi kumar
da5860297f feat: added new time series panel (#10207)
* feat: added new time series panel

* fix: pr review comments

* fix: pr review comments

* chore: memoized data creation in chartmanager
2026-02-06 16:35:30 +05:30
Ashwin Bhatkal
54fca5ba44 chore: separate out textbox variable into it's own component (#10206)
* chore: initiliase fetch store

* chore: small refactor

* chore: separate out textbox component into it's own component
2026-02-06 16:19:49 +05:30
Ashwin Bhatkal
66f4c3d6ec chore: add max-params eslint rule and update contribution guidelines (#10200)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: add max-params eslint rule and update contribution guidelines

* chore: add no-cycle rule
2026-02-06 13:03:59 +05:30
Srikanth Chekuri
a0f407a848 chore(metricsexplorer): update tags and regenerate (#10197) 2026-02-06 12:10:11 +05:30
Abhi kumar
3562de8fbb fix: added fix for tooltip sizing (#10205)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-02-05 22:46:17 +05:30
Abhi kumar
ef80fb39fd feat: added new time-series graph (#10201)
* feat: added new time-series graph

* chore: updated types for charts
2026-02-05 11:29:21 +00:00
Abhi kumar
594d4dc737 feat: added changes to compute legend items width for virtualization (#10196)
* feat: added changes to compute legend items width for virtualization

* feat: added support for single row in legends
2026-02-05 16:27:43 +05:30
Abhishek Kumar Singh
01415b58be chore: made group_wait and group_interval configuration dynamic for alert manager (#10198) 2026-02-05 09:56:27 +00:00
Ashwin Bhatkal
f7728c9019 chore: update variables store with derived values (#10194)
Some checks failed
build-staging / js-build (push) Has been cancelled
build-staging / prepare (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* chore: remove redundant sort

* chore: use sorted variables array

* chore: update variables store with derived values

* chore: fix types

* chore: resolve cursor comments
2026-02-05 14:09:46 +05:30
416 changed files with 24510 additions and 7780 deletions

View File

@@ -42,7 +42,7 @@ services:
timeout: 5s
retries: 3
schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.13
image: signoz/signoz-schema-migrator:v0.142.0
container_name: schema-migrator-sync
command:
- sync
@@ -55,7 +55,7 @@ services:
condition: service_healthy
restart: on-failure
schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.13
image: signoz/signoz-schema-migrator:v0.142.0
container_name: schema-migrator-async
command:
- async

View File

@@ -4,7 +4,6 @@ services:
container_name: signoz-otel-collector-dev
command:
- --config=/etc/otel-collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
environment:

13
.github/CODEOWNERS vendored
View File

@@ -43,6 +43,12 @@
/pkg/analytics/ @vikrantgupta25
/pkg/statsreporter/ @vikrantgupta25
# Emailing Owners
/pkg/emailing/ @vikrantgupta25
/pkg/types/emailtypes/ @vikrantgupta25
/templates/email/ @vikrantgupta25
# Querier Owners
/pkg/querier/ @srikanthccv
@@ -133,5 +139,8 @@
/frontend/src/pages/PublicDashboard/ @SigNoz/pulse-frontend
/frontend/src/container/PublicDashboardContainer/ @SigNoz/pulse-frontend
## UplotV2
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
## Dashboard Libs + Components
/frontend/src/lib/uPlotV2/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboard/ @SigNoz/pulse-frontend
/frontend/src/lib/dashboardVariables/ @SigNoz/pulse-frontend
/frontend/src/components/NewSelect/ @SigNoz/pulse-frontend

View File

@@ -93,3 +93,13 @@ jobs:
run: |
go run cmd/enterprise/*.go generate openapi
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in openapi spec. Run go run cmd/enterprise/*.go generate openapi locally and commit."; exit 1)
- name: node-install
uses: actions/setup-node@v5
with:
node-version: "22"
- name: install-frontend
run: cd frontend && yarn install
- name: generate-api-clients
run: |
cd frontend && yarn generate:api
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in generated api clients. Run yarn generate:api in frontend/ locally and commit."; exit 1)

5
.gitignore vendored
View File

@@ -1,8 +1,11 @@
node_modules
# editor
.vscode
!.vscode/settings.json
.zed
.idea
deploy/docker/environment_tiny/common_test
frontend/node_modules
@@ -31,8 +34,6 @@ frontend/yarn-debug.log*
frontend/yarn-error.log*
frontend/src/constants/env.ts
.idea
**/build
**/storage
**/locust-scripts/__pycache__/

View File

@@ -14,5 +14,8 @@
},
"[sql]": {
"editor.defaultFormatter": "adpyke.vscode-sql-formatter"
},
"[html]": {
"editor.defaultFormatter": "vscode.html-language-features"
}
}

View File

@@ -18,8 +18,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
"github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/modules/role/implrole"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/query-service/app"
"github.com/SigNoz/signoz/pkg/queryparser"
@@ -78,18 +76,15 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
},
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
func(ctx context.Context, sqlstore sqlstore.SQLStore, _ licensing.Licensing, _ dashboard.Module) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, _ role.Setter, _ role.Granter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
func(store sqlstore.SQLStore, authz authz.AuthZ, licensing licensing.Licensing, _ []role.RegisterTypeable) role.Setter {
return implrole.NewSetter(implrole.NewStore(store), authz)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -14,7 +14,6 @@ import (
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/ee/modules/role/implrole"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
"github.com/SigNoz/signoz/ee/sqlschema/postgressqlschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
@@ -29,8 +28,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
pkgimplrole "github.com/SigNoz/signoz/pkg/modules/role/implrole"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
@@ -118,18 +115,15 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil
},
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
func(ctx context.Context, sqlstore sqlstore.SQLStore, licensing licensing.Licensing, dashboardModule dashboard.Module) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx), licensing, dashboardModule)
},
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, roleSetter role.Setter, granter role.Granter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, roleSetter, granter, queryParser, querier, licensing)
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
func(store sqlstore.SQLStore, authz authz.AuthZ, licensing licensing.Licensing, registry []role.RegisterTypeable) role.Setter {
return implrole.NewSetter(pkgimplrole.NewStore(store), authz, licensing, registry)
},
)
if err != nil {

View File

@@ -193,6 +193,15 @@ emailing:
templates:
# The directory containing the email templates. This directory should contain a list of files defined at pkg/types/emailtypes/template.go.
directory: /opt/signoz/conf/templates/email
format:
header:
enabled: false
logo_url: ""
help:
enabled: false
email: ""
footer:
enabled: false
smtp:
# The SMTP server address.
address: localhost:25

View File

@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.110.1
image: signoz/signoz:v0.111.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -209,12 +209,11 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.13
image: signoz/signoz-otel-collector:v0.142.0
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -233,7 +232,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.13
image: signoz/signoz-schema-migrator:v0.142.0
deploy:
restart_policy:
condition: on-failure

View File

@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:v0.110.1
image: signoz/signoz:v0.111.0
command:
- --config=/root/config/prometheus.yml
ports:
@@ -150,12 +150,11 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.13
image: signoz/signoz-otel-collector:v0.142.0
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
configs:
- source: otel-collector-config
target: /etc/otel-collector-config.yaml
@@ -176,7 +175,7 @@ services:
- signoz
schema-migrator:
!!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.13
image: signoz/signoz-schema-migrator:v0.142.0
deploy:
restart_policy:
condition: on-failure

View File

@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.110.1}
image: signoz/signoz:${VERSION:-v0.111.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -213,13 +213,12 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -239,7 +238,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
container_name: schema-migrator-sync
command:
- sync
@@ -250,7 +249,7 @@ services:
condition: service_healthy
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
container_name: schema-migrator-async
command:
- async

View File

@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz:
!!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.110.1}
image: signoz/signoz:${VERSION:-v0.111.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml
@@ -144,13 +144,12 @@ services:
retries: 3
otel-collector:
!!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.13}
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.142.0}
container_name: signoz-otel-collector
command:
- --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml
- --copy-path=/var/tmp/collector-config.yaml
- --feature-gates=-pkg.translator.prometheus.NormalizeName
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
- ../common/signoz/otel-collector-opamp-config.yaml:/etc/manager-config.yaml
@@ -166,7 +165,7 @@ services:
condition: service_healthy
schema-migrator-sync:
!!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
container_name: schema-migrator-sync
command:
- sync
@@ -178,7 +177,7 @@ services:
restart: on-failure
schema-migrator-async:
!!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.13}
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.142.0}
container_name: schema-migrator-async
command:
- async

View File

@@ -607,6 +607,178 @@ paths:
summary: Update auth domain
tags:
- authdomains
/api/v1/fields/keys:
get:
deprecated: false
description: This endpoint returns field keys
operationId: GetFieldsKeys
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: source
schema:
type: string
- in: query
name: limit
schema:
type: integer
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: fieldContext
schema:
type: string
- in: query
name: fieldDataType
schema:
type: string
- in: query
name: metricName
schema:
type: string
- in: query
name: searchText
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldKeys'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get field keys
tags:
- fields
/api/v1/fields/values:
get:
deprecated: false
description: This endpoint returns field values
operationId: GetFieldsValues
parameters:
- in: query
name: signal
schema:
type: string
- in: query
name: source
schema:
type: string
- in: query
name: limit
schema:
type: integer
- in: query
name: startUnixMilli
schema:
format: int64
type: integer
- in: query
name: endUnixMilli
schema:
format: int64
type: integer
- in: query
name: fieldContext
schema:
type: string
- in: query
name: fieldDataType
schema:
type: string
- in: query
name: metricName
schema:
type: string
- in: query
name: searchText
schema:
type: string
- in: query
name: name
schema:
type: string
- in: query
name: existingQuery
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/TelemetrytypesGettableFieldValues'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- VIEWER
- tokenizer:
- VIEWER
summary: Get field values
tags:
- fields
/api/v1/getResetPasswordToken/{id}:
get:
deprecated: false
@@ -2226,6 +2398,12 @@ paths:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"422":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unprocessable Entity
"500":
content:
application/json:
@@ -2665,6 +2843,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2719,6 +2898,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2774,6 +2954,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -2940,6 +3121,7 @@ paths:
parameters:
- in: query
name: metricName
required: true
schema:
type: string
responses:
@@ -3807,6 +3989,9 @@ components:
type: string
alertName:
type: string
required:
- alertName
- alertId
type: object
MetricsexplorertypesMetricAlertsResponse:
properties:
@@ -3815,6 +4000,8 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesMetricAlert'
nullable: true
type: array
required:
- alerts
type: object
MetricsexplorertypesMetricAttribute:
properties:
@@ -3828,6 +4015,10 @@ components:
type: string
nullable: true
type: array
required:
- key
- values
- valueCount
type: object
MetricsexplorertypesMetricAttributesRequest:
properties:
@@ -3839,6 +4030,8 @@ components:
start:
nullable: true
type: integer
required:
- metricName
type: object
MetricsexplorertypesMetricAttributesResponse:
properties:
@@ -3850,6 +4043,9 @@ components:
totalKeys:
format: int64
type: integer
required:
- attributes
- totalKeys
type: object
MetricsexplorertypesMetricDashboard:
properties:
@@ -3861,6 +4057,11 @@ components:
type: string
widgetName:
type: string
required:
- dashboardName
- dashboardId
- widgetId
- widgetName
type: object
MetricsexplorertypesMetricDashboardsResponse:
properties:
@@ -3869,6 +4070,8 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesMetricDashboard'
nullable: true
type: array
required:
- dashboards
type: object
MetricsexplorertypesMetricHighlightsResponse:
properties:
@@ -3884,6 +4087,11 @@ components:
totalTimeSeries:
minimum: 0
type: integer
required:
- dataPoints
- lastReceived
- totalTimeSeries
- activeTimeSeries
type: object
MetricsexplorertypesMetricMetadata:
properties:
@@ -3892,11 +4100,17 @@ components:
isMonotonic:
type: boolean
temporality:
type: string
$ref: '#/components/schemas/MetrictypesTemporality'
type:
type: string
$ref: '#/components/schemas/MetrictypesType'
unit:
type: string
required:
- description
- type
- unit
- temporality
- isMonotonic
type: object
MetricsexplorertypesStat:
properties:
@@ -3911,9 +4125,16 @@ components:
minimum: 0
type: integer
type:
type: string
$ref: '#/components/schemas/MetrictypesType'
unit:
type: string
required:
- metricName
- description
- type
- unit
- timeseries
- samples
type: object
MetricsexplorertypesStatsRequest:
properties:
@@ -3931,6 +4152,10 @@ components:
start:
format: int64
type: integer
required:
- start
- end
- limit
type: object
MetricsexplorertypesStatsResponse:
properties:
@@ -3942,6 +4167,9 @@ components:
total:
minimum: 0
type: integer
required:
- metrics
- total
type: object
MetricsexplorertypesTreemapEntry:
properties:
@@ -3953,7 +4181,16 @@ components:
totalValue:
minimum: 0
type: integer
required:
- metricName
- percentage
- totalValue
type: object
MetricsexplorertypesTreemapMode:
enum:
- timeseries
- samples
type: string
MetricsexplorertypesTreemapRequest:
properties:
end:
@@ -3964,10 +4201,15 @@ components:
limit:
type: integer
mode:
type: string
$ref: '#/components/schemas/MetricsexplorertypesTreemapMode'
start:
format: int64
type: integer
required:
- start
- end
- limit
- mode
type: object
MetricsexplorertypesTreemapResponse:
properties:
@@ -3981,6 +4223,9 @@ components:
$ref: '#/components/schemas/MetricsexplorertypesTreemapEntry'
nullable: true
type: array
required:
- timeseries
- samples
type: object
MetricsexplorertypesUpdateMetricMetadataRequest:
properties:
@@ -3991,12 +4236,33 @@ components:
metricName:
type: string
temporality:
type: string
$ref: '#/components/schemas/MetrictypesTemporality'
type:
type: string
$ref: '#/components/schemas/MetrictypesType'
unit:
type: string
required:
- metricName
- type
- description
- unit
- temporality
- isMonotonic
type: object
MetrictypesTemporality:
enum:
- delta
- cumulative
- unspecified
type: string
MetrictypesType:
enum:
- gauge
- sum
- histogram
- summary
- exponentialhistogram
type: string
PreferencetypesPreference:
properties:
allowedScopes:
@@ -4089,6 +4355,8 @@ components:
type: string
unit:
type: string
required:
- name
type: object
Querybuildertypesv5QueryData:
properties:
@@ -4150,6 +4418,68 @@ components:
format: date-time
type: string
type: object
TelemetrytypesGettableFieldKeys:
properties:
complete:
type: boolean
keys:
additionalProperties:
items:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldKey'
type: array
nullable: true
type: object
required:
- keys
- complete
type: object
TelemetrytypesGettableFieldValues:
properties:
complete:
type: boolean
values:
$ref: '#/components/schemas/TelemetrytypesTelemetryFieldValues'
required:
- values
- complete
type: object
TelemetrytypesTelemetryFieldKey:
properties:
description:
type: string
fieldContext:
type: string
fieldDataType:
type: string
name:
type: string
signal:
type: string
unit:
type: string
required:
- name
type: object
TelemetrytypesTelemetryFieldValues:
properties:
boolValues:
items:
type: boolean
type: array
numberValues:
items:
format: double
type: number
type: array
relatedValues:
items:
type: string
type: array
stringValues:
items:
type: string
type: array
type: object
TypesChangePasswordRequest:
properties:
newPassword:
@@ -4278,6 +4608,9 @@ components:
type: string
orgId:
type: string
required:
- orgId
- email
type: object
TypesPostableInvite:
properties:

View File

@@ -2,12 +2,18 @@ package openfgaauthz
import (
"context"
"slices"
"github.com/SigNoz/signoz/ee/authz/openfgaserver"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/authzstore/sqlauthzstore"
pkgopenfgaauthz "github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
openfgapkgtransformer "github.com/openfga/language/pkg/go/transformer"
@@ -15,84 +21,320 @@ import (
type provider struct {
pkgAuthzService authz.AuthZ
openfgaServer *openfgaserver.Server
licensing licensing.Licensing
store roletypes.Store
registry []authz.RegisterTypeable
}
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) factory.ProviderFactory[authz.AuthZ, authz.Config] {
func NewProviderFactory(sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, licensing licensing.Licensing, registry ...authz.RegisterTypeable) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return factory.NewProviderFactory(factory.MustNewName("openfga"), func(ctx context.Context, ps factory.ProviderSettings, config authz.Config) (authz.AuthZ, error) {
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema)
return newOpenfgaProvider(ctx, ps, config, sqlstore, openfgaSchema, licensing, registry)
})
}
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile) (authz.AuthZ, error) {
func newOpenfgaProvider(ctx context.Context, settings factory.ProviderSettings, config authz.Config, sqlstore sqlstore.SQLStore, openfgaSchema []openfgapkgtransformer.ModuleFile, licensing licensing.Licensing, registry []authz.RegisterTypeable) (authz.AuthZ, error) {
pkgOpenfgaAuthzProvider := pkgopenfgaauthz.NewProviderFactory(sqlstore, openfgaSchema)
pkgAuthzService, err := pkgOpenfgaAuthzProvider.New(ctx, settings, config)
if err != nil {
return nil, err
}
openfgaServer, err := openfgaserver.NewOpenfgaServer(ctx, pkgAuthzService)
if err != nil {
return nil, err
}
return &provider{
pkgAuthzService: pkgAuthzService,
openfgaServer: openfgaServer,
licensing: licensing,
store: sqlauthzstore.NewSqlAuthzStore(sqlstore),
registry: registry,
}, nil
}
func (provider *provider) Start(ctx context.Context) error {
return provider.pkgAuthzService.Start(ctx)
return provider.openfgaServer.Start(ctx)
}
func (provider *provider) Stop(ctx context.Context) error {
return provider.pkgAuthzService.Stop(ctx)
return provider.openfgaServer.Stop(ctx)
}
func (provider *provider) Check(ctx context.Context, tuple *openfgav1.TupleKey) error {
return provider.pkgAuthzService.Check(ctx, tuple)
return provider.openfgaServer.Check(ctx, tuple)
}
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = provider.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreation(ctx, claims, orgID, relation, typeable, selectors, roleSelectors)
}
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = provider.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, roleSelectors []authtypes.Selector) error {
return provider.openfgaServer.CheckWithTupleCreationWithoutClaims(ctx, orgID, relation, typeable, selectors, roleSelectors)
}
func (provider *provider) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error {
return provider.pkgAuthzService.BatchCheck(ctx, tuples)
return provider.openfgaServer.BatchCheck(ctx, tuples)
}
func (provider *provider) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
return provider.pkgAuthzService.ListObjects(ctx, subject, relation, typeable)
return provider.openfgaServer.ListObjects(ctx, subject, relation, typeable)
}
func (provider *provider) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return provider.pkgAuthzService.Write(ctx, additions, deletions)
return provider.openfgaServer.Write(ctx, additions, deletions)
}
func (provider *provider) Get(ctx context.Context, orgID valuer.UUID, id valuer.UUID) (*roletypes.Role, error) {
return provider.pkgAuthzService.Get(ctx, orgID, id)
}
func (provider *provider) GetByOrgIDAndName(ctx context.Context, orgID valuer.UUID, name string) (*roletypes.Role, error) {
return provider.pkgAuthzService.GetByOrgIDAndName(ctx, orgID, name)
}
func (provider *provider) List(ctx context.Context, orgID valuer.UUID) ([]*roletypes.Role, error) {
return provider.pkgAuthzService.List(ctx, orgID)
}
func (provider *provider) ListByOrgIDAndNames(ctx context.Context, orgID valuer.UUID, names []string) ([]*roletypes.Role, error) {
return provider.pkgAuthzService.ListByOrgIDAndNames(ctx, orgID, names)
}
func (provider *provider) Grant(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
return provider.pkgAuthzService.Grant(ctx, orgID, name, subject)
}
func (provider *provider) ModifyGrant(ctx context.Context, orgID valuer.UUID, existingRoleName string, updatedRoleName string, subject string) error {
return provider.pkgAuthzService.ModifyGrant(ctx, orgID, existingRoleName, updatedRoleName, subject)
}
func (provider *provider) Revoke(ctx context.Context, orgID valuer.UUID, name string, subject string) error {
return provider.pkgAuthzService.Revoke(ctx, orgID, name, subject)
}
func (provider *provider) CreateManagedRoles(ctx context.Context, orgID valuer.UUID, managedRoles []*roletypes.Role) error {
return provider.pkgAuthzService.CreateManagedRoles(ctx, orgID, managedRoles)
}
func (provider *provider) CreateManagedUserRoleTransactions(ctx context.Context, orgID valuer.UUID, userID valuer.UUID) error {
tuples := make([]*openfgav1.TupleKey, 0)
grantTuples, err := provider.getManagedRoleGrantTuples(orgID, userID)
if err != nil {
return err
}
tuples = append(tuples, grantTuples...)
managedRoleTuples, err := provider.getManagedRoleTransactionTuples(orgID)
if err != nil {
return err
}
tuples = append(tuples, managedRoleTuples...)
return provider.Write(ctx, tuples, nil)
}
func (provider *provider) Create(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
}
func (provider *provider) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) (*roletypes.Role, error) {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := provider.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if existingRole != nil {
return roletypes.NewRoleFromStorableRole(existingRole), nil
}
err = provider.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
if err != nil {
return nil, err
}
return role, nil
}
func (provider *provider) GetResources(_ context.Context) []*authtypes.Resource {
typeables := make([]authtypes.Typeable, 0)
for _, register := range provider.registry {
typeables = append(typeables, register.MustGetTypeables()...)
}
// role module cannot self register itself!
typeables = append(typeables, provider.MustGetTypeables()...)
resources := make([]*authtypes.Resource, 0)
for _, typeable := range typeables {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (provider *provider) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*authtypes.Object, 0)
for _, resource := range provider.GetResources(ctx) {
if slices.Contains(authtypes.TypeableRelations[resource.Type], relation) {
resourceObjects, err := provider.
ListObjects(
ctx,
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.ID.String(), orgID, &authtypes.RelationAssignee),
relation,
authtypes.MustNewTypeableFromType(resource.Type, resource.Name),
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
}
return objects, nil
}
func (provider *provider) Patch(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return provider.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
}
func (provider *provider) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
err = provider.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return nil
}
func (provider *provider) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableRole, err := provider.store.Get(ctx, orgID, id)
if err != nil {
return err
}
role := roletypes.NewRoleFromStorableRole(storableRole)
err = role.CanEditDelete()
if err != nil {
return err
}
return provider.store.Delete(ctx, orgID, id)
}
func (provider *provider) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, roletypes.TypeableResourcesRoles}
}
func (provider *provider) getManagedRoleGrantTuples(orgID valuer.UUID, userID valuer.UUID) ([]*openfgav1.TupleKey, error) {
tuples := []*openfgav1.TupleKey{}
// Grant the admin role to the user
adminSubject := authtypes.MustNewSubject(authtypes.TypeableUser, userID.String(), orgID, nil)
adminTuple, err := authtypes.TypeableRole.Tuples(
adminSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAdminRoleName),
},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, adminTuple...)
// Grant the admin role to the anonymous user
anonymousSubject := authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
anonymousTuple, err := authtypes.TypeableRole.Tuples(
anonymousSubject,
authtypes.RelationAssignee,
[]authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeRole, roletypes.SigNozAnonymousRoleName),
},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, anonymousTuple...)
return tuples, nil
}
func (provider *provider) getManagedRoleTransactionTuples(orgID valuer.UUID) ([]*openfgav1.TupleKey, error) {
transactionsByRole := make(map[string][]*authtypes.Transaction)
for _, register := range provider.registry {
for roleName, txns := range register.MustGetManagedRoleTransactions() {
transactionsByRole[roleName] = append(transactionsByRole[roleName], txns...)
}
}
tuples := make([]*openfgav1.TupleKey, 0)
for roleName, transactions := range transactionsByRole {
for _, txn := range transactions {
typeable := authtypes.MustNewTypeableFromType(txn.Object.Resource.Type, txn.Object.Resource.Name)
txnTuples, err := typeable.Tuples(
authtypes.MustNewSubject(
authtypes.TypeableRole,
roleName,
orgID,
&authtypes.RelationAssignee,
),
txn.Relation,
[]authtypes.Selector{txn.Object.Selector},
orgID,
)
if err != nil {
return nil, err
}
tuples = append(tuples, txnTuples...)
}
}
return tuples, nil
}

View File

@@ -0,0 +1,83 @@
package openfgaserver
import (
"context"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/valuer"
openfgav1 "github.com/openfga/api/proto/openfga/v1"
)
type Server struct {
pkgAuthzService authz.AuthZ
}
func NewOpenfgaServer(ctx context.Context, pkgAuthzService authz.AuthZ) (*Server, error) {
return &Server{
pkgAuthzService: pkgAuthzService,
}, nil
}
func (server *Server) Start(ctx context.Context) error {
return server.pkgAuthzService.Start(ctx)
}
func (server *Server) Stop(ctx context.Context) error {
return server.pkgAuthzService.Stop(ctx)
}
func (server *Server) Check(ctx context.Context, tuple *openfgav1.TupleKey) error {
return server.pkgAuthzService.Check(ctx, tuple)
}
func (server *Server) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = server.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
}
func (server *Server) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector, _ []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = server.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
}
func (server *Server) BatchCheck(ctx context.Context, tuples []*openfgav1.TupleKey) error {
return server.pkgAuthzService.BatchCheck(ctx, tuples)
}
func (server *Server) ListObjects(ctx context.Context, subject string, relation authtypes.Relation, typeable authtypes.Typeable) ([]*authtypes.Object, error) {
return server.pkgAuthzService.ListObjects(ctx, subject, relation, typeable)
}
func (server *Server) Write(ctx context.Context, additions []*openfgav1.TupleKey, deletions []*openfgav1.TupleKey) error {
return server.pkgAuthzService.Write(ctx, additions, deletions)
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
"github.com/SigNoz/signoz/pkg/modules/organization"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/querier"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/types"
@@ -26,13 +25,11 @@ type module struct {
pkgDashboardModule dashboard.Module
store dashboardtypes.Store
settings factory.ScopedProviderSettings
roleSetter role.Setter
granter role.Granter
querier querier.Querier
licensing licensing.Licensing
}
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, roleSetter role.Setter, granter role.Granter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
scopedProviderSettings := factory.NewScopedProviderSettings(settings, "github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard")
pkgDashboardModule := pkgimpldashboard.NewModule(store, settings, analytics, orgGetter, queryParser)
@@ -40,8 +37,6 @@ func NewModule(store dashboardtypes.Store, settings factory.ProviderSettings, an
pkgDashboardModule: pkgDashboardModule,
store: store,
settings: scopedProviderSettings,
roleSetter: roleSetter,
granter: granter,
querier: querier,
licensing: licensing,
}
@@ -61,29 +56,6 @@ func (module *module) CreatePublic(ctx context.Context, orgID valuer.UUID, publi
return errors.Newf(errors.TypeAlreadyExists, dashboardtypes.ErrCodePublicDashboardAlreadyExists, "dashboard with id %s is already public", storablePublicDashboard.DashboardID)
}
role, err := module.roleSetter.GetOrCreate(ctx, orgID, roletypes.NewRole(roletypes.SigNozAnonymousRoleName, roletypes.SigNozAnonymousRoleDescription, roletypes.RoleTypeManaged, orgID))
if err != nil {
return err
}
err = module.granter.Grant(ctx, orgID, roletypes.SigNozAnonymousRoleName, authtypes.MustNewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.StringValue(), orgID, nil))
if err != nil {
return err
}
additionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, []*authtypes.Object{additionObject}, nil)
if err != nil {
return err
}
err = module.store.CreatePublic(ctx, dashboardtypes.NewStorablePublicDashboardFromPublicDashboard(publicDashboard))
if err != nil {
return err
@@ -128,6 +100,7 @@ func (module *module) GetPublicDashboardSelectorsAndOrg(ctx context.Context, id
return []authtypes.Selector{
authtypes.MustNewSelector(authtypes.TypeMetaResource, id.StringValue()),
authtypes.MustNewSelector(authtypes.TypeMetaResource, authtypes.WildCardSelectorString),
}, storableDashboard.OrgID, nil
}
@@ -190,29 +163,6 @@ func (module *module) DeletePublic(ctx context.Context, orgID valuer.UUID, dashb
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
publicDashboard, err := module.GetPublic(ctx, orgID, dashboardID)
if err != nil {
return err
}
role, err := module.roleSetter.GetOrCreate(ctx, orgID, roletypes.NewRole(roletypes.SigNozAnonymousRoleName, roletypes.SigNozAnonymousRoleDescription, roletypes.RoleTypeManaged, orgID))
if err != nil {
return err
}
deletionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
if err != nil {
return err
@@ -250,10 +200,6 @@ func (module *module) GetByMetricNames(ctx context.Context, orgID valuer.UUID, m
return module.pkgDashboardModule.GetByMetricNames(ctx, orgID, metricNames)
}
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) List(ctx context.Context, orgID valuer.UUID) ([]*dashboardtypes.Dashboard, error) {
return module.pkgDashboardModule.List(ctx, orgID)
}
@@ -266,34 +212,27 @@ func (module *module) LockUnlock(ctx context.Context, orgID valuer.UUID, id valu
return module.pkgDashboardModule.LockUnlock(ctx, orgID, id, updatedBy, role, lock)
}
func (module *module) deletePublic(ctx context.Context, orgID valuer.UUID, dashboardID valuer.UUID) error {
publicDashboard, err := module.store.GetPublic(ctx, dashboardID.String())
if err != nil {
return err
}
role, err := module.roleSetter.GetOrCreate(ctx, orgID, roletypes.NewRole(roletypes.SigNozAnonymousRoleName, roletypes.SigNozAnonymousRoleDescription, roletypes.RoleTypeManaged, orgID))
if err != nil {
return err
}
deletionObject := authtypes.MustNewObject(
authtypes.Resource{
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
Type: authtypes.TypeMetaResource,
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, publicDashboard.ID.String()),
)
err = module.roleSetter.PatchObjects(ctx, orgID, role.Name, authtypes.RelationRead, nil, []*authtypes.Object{deletionObject})
if err != nil {
return err
}
err = module.store.DeletePublic(ctx, dashboardID.StringValue())
if err != nil {
return err
}
return nil
func (module *module) MustGetTypeables() []authtypes.Typeable {
return module.pkgDashboardModule.MustGetTypeables()
}
func (module *module) MustGetManagedRoleTransactions() map[string][]*authtypes.Transaction {
return map[string][]*authtypes.Transaction{
roletypes.SigNozAnonymousRoleName: {
{
Relation: authtypes.RelationRead,
Object: *authtypes.MustNewObject(
authtypes.Resource{
Type: authtypes.TypeMetaResource,
Name: dashboardtypes.TypeableMetaResourcePublicDashboard.Name(),
},
authtypes.MustNewSelector(authtypes.TypeMetaResource, "*"),
),
},
},
}
}
func (module *module) deletePublic(ctx context.Context, _ valuer.UUID, dashboardID valuer.UUID) error {
return module.store.DeletePublic(ctx, dashboardID.StringValue())
}

View File

@@ -1,165 +0,0 @@
package implrole
import (
"context"
"slices"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/role"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/roletypes"
"github.com/SigNoz/signoz/pkg/valuer"
)
type setter struct {
store roletypes.Store
authz authz.AuthZ
licensing licensing.Licensing
registry []role.RegisterTypeable
}
func NewSetter(store roletypes.Store, authz authz.AuthZ, licensing licensing.Licensing, registry []role.RegisterTypeable) role.Setter {
return &setter{
store: store,
authz: authz,
licensing: licensing,
registry: registry,
}
}
func (setter *setter) Create(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return setter.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
}
func (setter *setter) GetOrCreate(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) (*roletypes.Role, error) {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
existingRole, err := setter.store.GetByOrgIDAndName(ctx, role.OrgID, role.Name)
if err != nil {
if !errors.Ast(err, errors.TypeNotFound) {
return nil, err
}
}
if existingRole != nil {
return roletypes.NewRoleFromStorableRole(existingRole), nil
}
err = setter.store.Create(ctx, roletypes.NewStorableRoleFromRole(role))
if err != nil {
return nil, err
}
return role, nil
}
func (setter *setter) GetResources(_ context.Context) []*authtypes.Resource {
typeables := make([]authtypes.Typeable, 0)
for _, register := range setter.registry {
typeables = append(typeables, register.MustGetTypeables()...)
}
// role module cannot self register itself!
typeables = append(typeables, setter.MustGetTypeables()...)
resources := make([]*authtypes.Resource, 0)
for _, typeable := range typeables {
resources = append(resources, &authtypes.Resource{Name: typeable.Name(), Type: typeable.Type()})
}
return resources
}
func (setter *setter) GetObjects(ctx context.Context, orgID valuer.UUID, id valuer.UUID, relation authtypes.Relation) ([]*authtypes.Object, error) {
storableRole, err := setter.store.Get(ctx, orgID, id)
if err != nil {
return nil, err
}
objects := make([]*authtypes.Object, 0)
for _, resource := range setter.GetResources(ctx) {
if slices.Contains(authtypes.TypeableRelations[resource.Type], relation) {
resourceObjects, err := setter.
authz.
ListObjects(
ctx,
authtypes.MustNewSubject(authtypes.TypeableRole, storableRole.ID.String(), orgID, &authtypes.RelationAssignee),
relation,
authtypes.MustNewTypeableFromType(resource.Type, resource.Name),
)
if err != nil {
return nil, err
}
objects = append(objects, resourceObjects...)
}
}
return objects, nil
}
func (setter *setter) Patch(ctx context.Context, orgID valuer.UUID, role *roletypes.Role) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
return setter.store.Update(ctx, orgID, roletypes.NewStorableRoleFromRole(role))
}
func (setter *setter) PatchObjects(ctx context.Context, orgID valuer.UUID, name string, relation authtypes.Relation, additions, deletions []*authtypes.Object) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
additionTuples, err := roletypes.GetAdditionTuples(name, orgID, relation, additions)
if err != nil {
return err
}
deletionTuples, err := roletypes.GetDeletionTuples(name, orgID, relation, deletions)
if err != nil {
return err
}
err = setter.authz.Write(ctx, additionTuples, deletionTuples)
if err != nil {
return err
}
return nil
}
func (setter *setter) Delete(ctx context.Context, orgID valuer.UUID, id valuer.UUID) error {
_, err := setter.licensing.GetActive(ctx, orgID)
if err != nil {
return errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "a valid license is not available").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
storableRole, err := setter.store.Get(ctx, orgID, id)
if err != nil {
return err
}
role := roletypes.NewRoleFromStorableRole(storableRole)
err = role.CanEditDelete()
if err != nil {
return err
}
return setter.store.Delete(ctx, orgID, id)
}
func (setter *setter) MustGetTypeables() []authtypes.Typeable {
return []authtypes.Typeable{authtypes.TypeableRole, roletypes.TypeableResourcesRoles}
}

View File

@@ -9,7 +9,6 @@ import (
"github.com/SigNoz/signoz/ee/query-service/integrations/gateway"
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
@@ -56,7 +55,6 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
FluxInterval: opts.FluxInterval,
AlertmanagerAPI: alertmanager.NewAPI(signoz.Alertmanager),
LicensingAPI: httplicensing.NewLicensingAPI(signoz.Licensing),
FieldsAPI: fields.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.TelemetryStore),
Signoz: signoz,
QuerierAPI: querierAPI.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.Querier, signoz.Analytics),
QueryParserAPI: queryparser.NewAPI(signoz.Instrumentation.ToProviderSettings(), signoz.QueryParser),

View File

@@ -211,7 +211,7 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz, s.signoz.Modules.RoleGetter)
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
r.Use(otelmux.Middleware(
"apiserver",
@@ -237,7 +237,6 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.RegisterLogsRoutes(r, am)
apiHandler.RegisterIntegrationRoutes(r, am)
apiHandler.RegisterCloudIntegrationsRoutes(r, am)
apiHandler.RegisterFieldsRoutes(r, am)
apiHandler.RegisterQueryRangeV3Routes(r, am)
apiHandler.RegisterInfraMetricsRoutes(r, am)
apiHandler.RegisterQueryRangeV4Routes(r, am)

View File

@@ -15,7 +15,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/common"
"github.com/SigNoz/signoz/pkg/query-service/model"
"github.com/SigNoz/signoz/pkg/transition"
ruletypes "github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/types/ruletypes"
"github.com/SigNoz/signoz/pkg/valuer"
querierV2 "github.com/SigNoz/signoz/pkg/query-service/app/querier/v2"
@@ -63,6 +63,8 @@ type AnomalyRule struct {
seasonality anomaly.Seasonality
}
var _ baserules.Rule = (*AnomalyRule)(nil)
func NewAnomalyRule(
id string,
orgID valuer.UUID,
@@ -490,7 +492,7 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (int, error) {
continue
}
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration() {
if a.State == model.StatePending && ts.Sub(a.ActiveAt) >= r.HoldDuration().Duration() {
a.State = model.StateFiring
a.FiredAt = ts
state := model.StateFiring
@@ -553,7 +555,7 @@ func (r *AnomalyRule) String() string {
ar := ruletypes.PostableRule{
AlertName: r.Name(),
RuleCondition: r.Condition(),
EvalWindow: ruletypes.Duration(r.EvalWindow()),
EvalWindow: r.EvalWindow(),
Labels: r.Labels().Map(),
Annotations: r.Annotations().Map(),
PreferredChannels: r.PreferredChannels(),

View File

@@ -40,7 +40,7 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
// Test basic AlertOnAbsent functionality (without AbsentFor grace period)
baseTime := time.Unix(1700000000, 0)
evalWindow := 5 * time.Minute
evalWindow := valuer.MustParseTextDuration("5m")
evalTime := baseTime.Add(5 * time.Minute)
target := 500.0
@@ -50,8 +50,8 @@ func TestAnomalyRule_NoData_AlertOnAbsent(t *testing.T) {
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(evalWindow),
Frequency: ruletypes.Duration(1 * time.Minute),
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,
@@ -147,7 +147,7 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
// 3. Alert fires only if t2 - t1 > AbsentFor
baseTime := time.Unix(1700000000, 0)
evalWindow := 5 * time.Minute
evalWindow := valuer.MustParseTextDuration("5m")
// Set target higher than test data so regular threshold alerts don't fire
target := 500.0
@@ -157,8 +157,8 @@ func TestAnomalyRule_NoData_AbsentFor(t *testing.T) {
AlertType: ruletypes.AlertTypeMetric,
RuleType: RuleTypeAnomaly,
Evaluation: &ruletypes.EvaluationEnvelope{Kind: ruletypes.RollingEvaluation, Spec: ruletypes.RollingWindow{
EvalWindow: ruletypes.Duration(evalWindow),
Frequency: ruletypes.Duration(time.Minute),
EvalWindow: evalWindow,
Frequency: valuer.MustParseTextDuration("1m"),
}},
RuleCondition: &ruletypes.RuleCondition{
CompareOp: ruletypes.ValueIsAbove,

View File

@@ -48,7 +48,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, tr)
// create ch rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeProm {
@@ -72,7 +72,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, pr)
// create promql rule task for evaluation
task = newTask(baserules.TaskTypeProm, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeProm, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else if opts.Rule.RuleType == ruletypes.RuleTypeAnomaly {
// create anomaly rule
@@ -96,7 +96,7 @@ func PrepareTaskFunc(opts baserules.PrepareTaskOptions) (baserules.Task, error)
rules = append(rules, ar)
// create anomaly rule task for evaluation
task = newTask(baserules.TaskTypeCh, opts.TaskName, time.Duration(evaluation.GetFrequency()), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
task = newTask(baserules.TaskTypeCh, opts.TaskName, evaluation.GetFrequency().Duration(), rules, opts.ManagerOpts, opts.NotifyFunc, opts.MaintenanceStore, opts.OrgID)
} else {
return nil, fmt.Errorf("unsupported rule type %s. Supported types: %s, %s", opts.Rule.RuleType, ruletypes.RuleTypeProm, ruletypes.RuleTypeThreshold)
@@ -213,8 +213,7 @@ func TestNotification(opts baserules.PrepareTestRuleOptions) (int, *basemodel.Ap
return alertsFound, nil
}
// newTask returns an appropriate group for
// rule type
// newTask returns an appropriate group for the rule type
func newTask(taskType baserules.TaskType, name string, frequency time.Duration, rules []baserules.Rule, opts *baserules.ManagerOptions, notify baserules.NotifyFunc, maintenanceStore ruletypes.MaintenanceStore, orgID valuer.UUID) baserules.Task {
if taskType == baserules.TaskTypeCh {
return baserules.NewRuleTask(name, "", frequency, rules, opts, notify, maintenanceStore, orgID)

View File

@@ -61,6 +61,8 @@ module.exports = {
curly: 'error', // Requires curly braces for all control statements
eqeqeq: ['error', 'always', { null: 'ignore' }], // Enforces === and !== (allows == null for null/undefined check)
'no-console': ['error', { allow: ['warn', 'error'] }], // Warns on console.log, allows console.warn/error
// TODO: Change this to error in May 2026
'max-params': ['warn', 3], // a function can have max 3 params after which it should become an object
// TypeScript rules
'@typescript-eslint/explicit-function-return-type': 'error', // Requires explicit return types on functions
@@ -116,7 +118,7 @@ module.exports = {
},
],
'import/no-extraneous-dependencies': ['error', { devDependencies: true }], // Prevents importing packages not in package.json
// 'import/no-cycle': 'warn', // TODO: Enable later to detect circular dependencies
'import/no-cycle': 'warn', // Warns about circular dependencies
// Import sorting rules
'simple-import-sort/imports': [
@@ -146,6 +148,19 @@ module.exports = {
'sonarjs/no-duplicate-string': 'off', // Disabled - can be noisy (enable periodically to check)
},
overrides: [
{
files: [
'**/*.test.{js,jsx,ts,tsx}',
'**/*.spec.{js,jsx,ts,tsx}',
'**/__tests__/**/*.{js,jsx,ts,tsx}',
],
rules: {
// Tests often have intentional duplication and complexity - disable SonarJS rules
'sonarjs/cognitive-complexity': 'off', // Tests can be complex
'sonarjs/no-identical-functions': 'off', // Similar test patterns are OK
'sonarjs/no-small-switch': 'off', // Small switches are OK in tests
},
},
{
files: ['src/api/generated/**/*.ts'],
rules: {
@@ -153,7 +168,6 @@ module.exports = {
'@typescript-eslint/explicit-module-boundary-types': 'off',
'no-nested-ternary': 'off',
'@typescript-eslint/no-unused-vars': 'warn',
'sonarjs/no-duplicate-string': 'off',
},
},
],

View File

@@ -2,6 +2,11 @@
Embrace the spirit of collaboration and contribute to the success of our open-source project by adhering to these frontend development guidelines with precision and passion.
### Export Style
- **React components** (`src/components/`, `src/container/`, `src/pages/`): Prefer **default exports** for the main component in each file
- **Utilities, hooks, APIs, types, constants** (`src/utils/`, `src/hooks/`, `src/api/`, `src/lib/`, `src/types/`, `src/constants/`): Prefer **named exports** for better tree-shaking and explicit imports
### React and Components
- Strive to create small and modular components, ensuring they are divided into individual pieces for improved maintainability and reusability.

View File

@@ -12,6 +12,8 @@ export interface MockUPlotInstance {
export interface MockUPlotPaths {
spline: jest.Mock;
bars: jest.Mock;
linear: jest.Mock;
stepped: jest.Mock;
}
// Create mock instance methods
@@ -23,10 +25,23 @@ const createMockUPlotInstance = (): MockUPlotInstance => ({
setSeries: jest.fn(),
});
// Create mock paths
const mockPaths: MockUPlotPaths = {
spline: jest.fn(),
bars: jest.fn(),
// Path builder: (self, seriesIdx, idx0, idx1) => paths or null
const createMockPathBuilder = (name: string): jest.Mock =>
jest.fn(() => ({
name, // To test if the correct pathBuilder is used
stroke: jest.fn(),
fill: jest.fn(),
clip: jest.fn(),
}));
// Create mock paths - linear, spline, stepped needed by UPlotSeriesBuilder.getPathBuilder
const mockPaths = {
spline: jest.fn(() => createMockPathBuilder('spline')),
bars: jest.fn(() => createMockPathBuilder('bars')),
linear: jest.fn(() => createMockPathBuilder('linear')),
stepped: jest.fn((opts?: { align?: number }) =>
createMockPathBuilder(`stepped-(${opts?.align ?? 0})`),
),
};
// Mock static methods

View File

@@ -17,6 +17,8 @@ const config: Config.InitialOptions = {
'^hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^src/hooks/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^.*/useSafeNavigate$': USE_SAFE_NAVIGATE_MOCK_PATH,
'^@signozhq/icons$':
'<rootDir>/node_modules/@signozhq/icons/dist/index.esm.js',
},
globals: {
extensionsToTreatAsEsm: ['.ts'],

View File

@@ -19,7 +19,7 @@
"commitlint": "commitlint --edit $1",
"test": "jest",
"test:changedsince": "jest --changedSince=main --coverage --silent",
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh && prettier --write src/api/generated && (eslint --fix src/api/generated || true)"
"generate:api": "orval --config ./orval.config.ts && sh scripts/post-types-generation.sh"
},
"engines": {
"node": ">=16.15.0"
@@ -52,8 +52,10 @@
"@signozhq/combobox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "2.1.1",
"@signozhq/icons": "0.1.0",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
"@signozhq/radio-group": "0.0.2",
"@signozhq/resizable": "0.0.0",
"@signozhq/sonner": "0.1.0",
"@signozhq/table": "0.3.7",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="184" height="188" fill="none" viewBox="0 0 184 188"><path fill="#f3b01c" d="M108.092 130.021c18.166-2.018 35.293-11.698 44.723-27.854-4.466 39.961-48.162 65.218-83.83 49.711-3.286-1.425-6.115-3.796-8.056-6.844-8.016-12.586-10.65-28.601-6.865-43.135 10.817 18.668 32.81 30.111 54.028 28.122"/><path fill="#8d2676" d="M53.401 90.174c-7.364 17.017-7.682 36.94 1.345 53.336-31.77-23.902-31.423-75.052-.388-98.715 2.87-2.187 6.282-3.485 9.86-3.683 14.713-.776 29.662 4.91 40.146 15.507-21.3.212-42.046 13.857-50.963 33.555"/><path fill="#ee342f" d="M114.637 61.855C103.89 46.87 87.069 36.668 68.639 36.358c35.625-16.17 79.446 10.047 84.217 48.807.444 3.598-.139 7.267-1.734 10.512-6.656 13.518-18.998 24.002-33.42 27.882 10.567-19.599 9.263-43.544-3.065-61.704"/></svg>

After

Width:  |  Height:  |  Size: 811 B

View File

@@ -1,6 +1,7 @@
{
"SIGN_UP": "SigNoz | Sign Up",
"LOGIN": "SigNoz | Login",
"FORGOT_PASSWORD": "SigNoz | Forgot Password",
"HOME": "SigNoz | Home",
"SERVICE_METRICS": "SigNoz | Service Metrics",
"SERVICE_MAP": "SigNoz | Service Map",

View File

@@ -1,5 +1,6 @@
#!/bin/bash
echo "\n\n---\nRenamed tag files to index.ts...\n"
# Rename tag files to index.ts in services directories
# tags-split creates: services/tagName/tagName.ts -> rename to services/tagName/index.ts
find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r dir; do
@@ -11,4 +12,33 @@ find src/api/generated/services -mindepth 1 -maxdepth 1 -type d | while read -r
fi
done
echo "Tag files renamed to index.ts"
echo "\n✅ Tag files renamed to index.ts"
# Format generated files
echo "\n\n---\nRunning prettier...\n"
if ! prettier --write src/api/generated; then
echo "Prettier formatting failed!"
exit 1
fi
echo "\n✅ Prettier formatting successful"
# Fix linting issues
echo "\n\n---\nRunning eslint...\n"
if ! yarn lint --fix --quiet src/api/generated; then
echo "ESLint check failed! Please fix linting errors before proceeding."
exit 1
fi
echo "\n✅ ESLint check successful"
# Check for type errors
echo "\n\n---\nChecking for type errors...\n"
if ! tsc --noEmit; then
echo "Type check failed! Please fix type errors before proceeding."
exit 1
fi
echo "\n✅ Type check successful"
echo "\n\n---\n ✅✅✅ API generation complete!"

View File

@@ -194,6 +194,10 @@ export const Login = Loadable(
() => import(/* webpackChunkName: "Login" */ 'pages/Login'),
);
export const ForgotPassword = Loadable(
() => import(/* webpackChunkName: "ForgotPassword" */ 'pages/ForgotPassword'),
);
export const UnAuthorized = Loadable(
() => import(/* webpackChunkName: "UnAuthorized" */ 'pages/UnAuthorized'),
);

View File

@@ -17,6 +17,7 @@ import {
DashboardWidget,
EditRulesPage,
ErrorDetails,
ForgotPassword,
Home,
InfrastructureMonitoring,
InstalledIntegrations,
@@ -339,6 +340,13 @@ const routes: AppRoutes[] = [
isPrivate: false,
key: 'LOGIN',
},
{
path: ROUTES.FORGOT_PASSWORD,
exact: true,
component: ForgotPassword,
isPrivate: false,
key: 'FORGOT_PASSWORD',
},
{
path: ROUTES.UN_AUTHORIZED,
exact: true,

View File

@@ -11,6 +11,7 @@ import {
const dashboardVariablesQuery = async (
props: Props,
signal?: AbortSignal,
): Promise<SuccessResponse<VariableResponseProps> | ErrorResponse> => {
try {
const { globalTime } = store.getState();
@@ -32,7 +33,7 @@ const dashboardVariablesQuery = async (
payload.variables = { ...payload.variables, ...timeVariables };
const response = await axios.post(`/variables/query`, payload);
const response = await axios.post(`/variables/query`, payload, { signal });
return {
statusCode: 200,

View File

@@ -19,6 +19,7 @@ export const getFieldValues = async (
startUnixMilli?: number,
endUnixMilli?: number,
existingQuery?: string,
abortSignal?: AbortSignal,
): Promise<SuccessResponseV2<FieldValueResponse>> => {
const params: Record<string, string> = {};
@@ -47,7 +48,10 @@ export const getFieldValues = async (
}
try {
const response = await axios.get('/fields/values', { params });
const response = await axios.get('/fields/values', {
params,
signal: abortSignal,
});
// Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) {

View File

@@ -0,0 +1,222 @@
/**
* ! Do not edit manually
* * The file has been auto-generated using Orval for SigNoz
* * regenerate with 'yarn generate:api'
* SigNoz
*/
import type {
InvalidateOptions,
QueryClient,
QueryFunction,
QueryKey,
UseQueryOptions,
UseQueryResult,
} from 'react-query';
import { useQuery } from 'react-query';
import { GeneratedAPIInstance } from '../../../index';
import type {
GetFieldsKeys200,
GetFieldsKeysParams,
GetFieldsValues200,
GetFieldsValuesParams,
RenderErrorResponseDTO,
} from '../sigNoz.schemas';
type AwaitedInput<T> = PromiseLike<T> | T;
type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
/**
* This endpoint returns field keys
* @summary Get field keys
*/
export const getFieldsKeys = (
params?: GetFieldsKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFieldsKeys200>({
url: `/api/v1/fields/keys`,
method: 'GET',
params,
signal,
});
};
export const getGetFieldsKeysQueryKey = (params?: GetFieldsKeysParams) => {
return ['getFieldsKeys', ...(params ? [params] : [])] as const;
};
export const getGetFieldsKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetFieldsKeysQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsKeys>>> = ({
signal,
}) => getFieldsKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetFieldsKeysQueryResult = NonNullable<
Awaited<ReturnType<typeof getFieldsKeys>>
>;
export type GetFieldsKeysQueryError = RenderErrorResponseDTO;
/**
* @summary Get field keys
*/
export function useGetFieldsKeys<
TData = Awaited<ReturnType<typeof getFieldsKeys>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetFieldsKeysQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get field keys
*/
export const invalidateGetFieldsKeys = async (
queryClient: QueryClient,
params?: GetFieldsKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetFieldsKeysQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint returns field values
* @summary Get field values
*/
export const getFieldsValues = (
params?: GetFieldsValuesParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetFieldsValues200>({
url: `/api/v1/fields/values`,
method: 'GET',
params,
signal,
});
};
export const getGetFieldsValuesQueryKey = (params?: GetFieldsValuesParams) => {
return ['getFieldsValues', ...(params ? [params] : [])] as const;
};
export const getGetFieldsValuesQueryOptions = <
TData = Awaited<ReturnType<typeof getFieldsValues>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetFieldsValuesQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getFieldsValues>>> = ({
signal,
}) => getFieldsValues(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetFieldsValuesQueryResult = NonNullable<
Awaited<ReturnType<typeof getFieldsValues>>
>;
export type GetFieldsValuesQueryError = RenderErrorResponseDTO;
/**
* @summary Get field values
*/
export function useGetFieldsValues<
TData = Awaited<ReturnType<typeof getFieldsValues>>,
TError = RenderErrorResponseDTO
>(
params?: GetFieldsValuesParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getFieldsValues>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetFieldsValuesQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
query.queryKey = queryOptions.queryKey;
return query;
}
/**
* @summary Get field values
*/
export const invalidateGetFieldsValues = async (
queryClient: QueryClient,
params?: GetFieldsValuesParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetFieldsValuesQueryKey(params) },
options,
);
return queryClient;
};

View File

@@ -28,8 +28,10 @@ import type {
GatewaytypesPostableIngestionKeyLimitDTO,
GatewaytypesUpdatableIngestionKeyLimitDTO,
GetIngestionKeys200,
GetIngestionKeysParams,
RenderErrorResponseDTO,
SearchIngestionKeys200,
SearchIngestionKeysParams,
UpdateIngestionKeyLimitPathParameters,
UpdateIngestionKeyPathParameters,
} from '../sigNoz.schemas';
@@ -42,35 +44,44 @@ type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
* This endpoint returns the ingestion keys for a workspace
* @summary Get ingestion keys for workspace
*/
export const getIngestionKeys = (signal?: AbortSignal) => {
export const getIngestionKeys = (
params?: GetIngestionKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetIngestionKeys200>({
url: `/api/v2/gateway/ingestion_keys`,
method: 'GET',
params,
signal,
});
};
export const getGetIngestionKeysQueryKey = () => {
return ['getIngestionKeys'] as const;
export const getGetIngestionKeysQueryKey = (
params?: GetIngestionKeysParams,
) => {
return ['getIngestionKeys', ...(params ? [params] : [])] as const;
};
export const getGetIngestionKeysQueryOptions = <
TData = Awaited<ReturnType<typeof getIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
}) => {
>(
params?: GetIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getGetIngestionKeysQueryKey();
const queryKey = queryOptions?.queryKey ?? getGetIngestionKeysQueryKey(params);
const queryFn: QueryFunction<Awaited<ReturnType<typeof getIngestionKeys>>> = ({
signal,
}) => getIngestionKeys(signal);
}) => getIngestionKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
@@ -91,14 +102,17 @@ export type GetIngestionKeysQueryError = RenderErrorResponseDTO;
export function useGetIngestionKeys<
TData = Awaited<ReturnType<typeof getIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetIngestionKeysQueryOptions(options);
>(
params?: GetIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getIngestionKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetIngestionKeysQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -114,10 +128,11 @@ export function useGetIngestionKeys<
*/
export const invalidateGetIngestionKeys = async (
queryClient: QueryClient,
params?: GetIngestionKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetIngestionKeysQueryKey() },
{ queryKey: getGetIngestionKeysQueryKey(params) },
options,
);
@@ -662,35 +677,45 @@ export const useUpdateIngestionKeyLimit = <
* This endpoint returns the ingestion keys for a workspace
* @summary Search ingestion keys for workspace
*/
export const searchIngestionKeys = (signal?: AbortSignal) => {
export const searchIngestionKeys = (
params?: SearchIngestionKeysParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<SearchIngestionKeys200>({
url: `/api/v2/gateway/ingestion_keys/search`,
method: 'GET',
params,
signal,
});
};
export const getSearchIngestionKeysQueryKey = () => {
return ['searchIngestionKeys'] as const;
export const getSearchIngestionKeysQueryKey = (
params?: SearchIngestionKeysParams,
) => {
return ['searchIngestionKeys', ...(params ? [params] : [])] as const;
};
export const getSearchIngestionKeysQueryOptions = <
TData = Awaited<ReturnType<typeof searchIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
}) => {
>(
params?: SearchIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey = queryOptions?.queryKey ?? getSearchIngestionKeysQueryKey();
const queryKey =
queryOptions?.queryKey ?? getSearchIngestionKeysQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof searchIngestionKeys>>
> = ({ signal }) => searchIngestionKeys(signal);
> = ({ signal }) => searchIngestionKeys(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
@@ -711,14 +736,17 @@ export type SearchIngestionKeysQueryError = RenderErrorResponseDTO;
export function useSearchIngestionKeys<
TData = Awaited<ReturnType<typeof searchIngestionKeys>>,
TError = RenderErrorResponseDTO
>(options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getSearchIngestionKeysQueryOptions(options);
>(
params?: SearchIngestionKeysParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof searchIngestionKeys>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getSearchIngestionKeysQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
@@ -734,10 +762,11 @@ export function useSearchIngestionKeys<
*/
export const invalidateSearchIngestionKeys = async (
queryClient: QueryClient,
params?: SearchIngestionKeysParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getSearchIngestionKeysQueryKey() },
{ queryKey: getSearchIngestionKeysQueryKey(params) },
options,
);

View File

@@ -47,7 +47,7 @@ type Awaited<O> = O extends AwaitedInput<infer T> ? T : never;
* @summary Get metric alerts
*/
export const getMetricAlerts = (
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricAlerts200>({
@@ -66,7 +66,7 @@ export const getGetMetricAlertsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -103,7 +103,7 @@ export function useGetMetricAlerts<
TData = Awaited<ReturnType<typeof getMetricAlerts>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricAlerts>>,
@@ -128,7 +128,7 @@ export function useGetMetricAlerts<
*/
export const invalidateGetMetricAlerts = async (
queryClient: QueryClient,
params?: GetMetricAlertsParams,
params: GetMetricAlertsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -144,7 +144,7 @@ export const invalidateGetMetricAlerts = async (
* @summary Get metric dashboards
*/
export const getMetricDashboards = (
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricDashboards200>({
@@ -165,7 +165,7 @@ export const getGetMetricDashboardsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -203,7 +203,7 @@ export function useGetMetricDashboards<
TData = Awaited<ReturnType<typeof getMetricDashboards>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricDashboards>>,
@@ -228,7 +228,7 @@ export function useGetMetricDashboards<
*/
export const invalidateGetMetricDashboards = async (
queryClient: QueryClient,
params?: GetMetricDashboardsParams,
params: GetMetricDashboardsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -244,7 +244,7 @@ export const invalidateGetMetricDashboards = async (
* @summary Get metric highlights
*/
export const getMetricHighlights = (
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricHighlights200>({
@@ -265,7 +265,7 @@ export const getGetMetricHighlightsQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -303,7 +303,7 @@ export function useGetMetricHighlights<
TData = Awaited<ReturnType<typeof getMetricHighlights>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricHighlights>>,
@@ -328,7 +328,7 @@ export function useGetMetricHighlights<
*/
export const invalidateGetMetricHighlights = async (
queryClient: QueryClient,
params?: GetMetricHighlightsParams,
params: GetMetricHighlightsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
@@ -526,7 +526,7 @@ export const useGetMetricAttributes = <
* @summary Get metric metadata
*/
export const getMetricMetadata = (
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetMetricMetadata200>({
@@ -547,7 +547,7 @@ export const getGetMetricMetadataQueryOptions = <
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
@@ -585,7 +585,7 @@ export function useGetMetricMetadata<
TData = Awaited<ReturnType<typeof getMetricMetadata>>,
TError = RenderErrorResponseDTO
>(
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getMetricMetadata>>,
@@ -610,7 +610,7 @@ export function useGetMetricMetadata<
*/
export const invalidateGetMetricMetadata = async (
queryClient: QueryClient,
params?: GetMetricMetadataParams,
params: GetMetricMetadataParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(

View File

@@ -650,11 +650,11 @@ export interface MetricsexplorertypesMetricAlertDTO {
/**
* @type string
*/
alertId?: string;
alertId: string;
/**
* @type string
*/
alertName?: string;
alertName: string;
}
export interface MetricsexplorertypesMetricAlertsResponseDTO {
@@ -662,24 +662,24 @@ export interface MetricsexplorertypesMetricAlertsResponseDTO {
* @type array
* @nullable true
*/
alerts?: MetricsexplorertypesMetricAlertDTO[] | null;
alerts: MetricsexplorertypesMetricAlertDTO[] | null;
}
export interface MetricsexplorertypesMetricAttributeDTO {
/**
* @type string
*/
key?: string;
key: string;
/**
* @type integer
* @minimum 0
*/
valueCount?: number;
valueCount: number;
/**
* @type array
* @nullable true
*/
values?: string[] | null;
values: string[] | null;
}
export interface MetricsexplorertypesMetricAttributesRequestDTO {
@@ -691,7 +691,7 @@ export interface MetricsexplorertypesMetricAttributesRequestDTO {
/**
* @type string
*/
metricName?: string;
metricName: string;
/**
* @type integer
* @nullable true
@@ -704,31 +704,31 @@ export interface MetricsexplorertypesMetricAttributesResponseDTO {
* @type array
* @nullable true
*/
attributes?: MetricsexplorertypesMetricAttributeDTO[] | null;
attributes: MetricsexplorertypesMetricAttributeDTO[] | null;
/**
* @type integer
* @format int64
*/
totalKeys?: number;
totalKeys: number;
}
export interface MetricsexplorertypesMetricDashboardDTO {
/**
* @type string
*/
dashboardId?: string;
dashboardId: string;
/**
* @type string
*/
dashboardName?: string;
dashboardName: string;
/**
* @type string
*/
widgetId?: string;
widgetId: string;
/**
* @type string
*/
widgetName?: string;
widgetName: string;
}
export interface MetricsexplorertypesMetricDashboardsResponseDTO {
@@ -736,7 +736,7 @@ export interface MetricsexplorertypesMetricDashboardsResponseDTO {
* @type array
* @nullable true
*/
dashboards?: MetricsexplorertypesMetricDashboardDTO[] | null;
dashboards: MetricsexplorertypesMetricDashboardDTO[] | null;
}
export interface MetricsexplorertypesMetricHighlightsResponseDTO {
@@ -744,74 +744,65 @@ export interface MetricsexplorertypesMetricHighlightsResponseDTO {
* @type integer
* @minimum 0
*/
activeTimeSeries?: number;
activeTimeSeries: number;
/**
* @type integer
* @minimum 0
*/
dataPoints?: number;
dataPoints: number;
/**
* @type integer
* @minimum 0
*/
lastReceived?: number;
lastReceived: number;
/**
* @type integer
* @minimum 0
*/
totalTimeSeries?: number;
totalTimeSeries: number;
}
export interface MetricsexplorertypesMetricMetadataDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type boolean
*/
isMonotonic?: boolean;
isMonotonic: boolean;
temporality: MetrictypesTemporalityDTO;
type: MetrictypesTypeDTO;
/**
* @type string
*/
temporality?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
unit: string;
}
export interface MetricsexplorertypesStatDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type string
*/
metricName?: string;
metricName: string;
/**
* @type integer
* @minimum 0
*/
samples?: number;
samples: number;
/**
* @type integer
* @minimum 0
*/
timeseries?: number;
timeseries: number;
type: MetrictypesTypeDTO;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
unit: string;
}
export interface MetricsexplorertypesStatsRequestDTO {
@@ -819,12 +810,12 @@ export interface MetricsexplorertypesStatsRequestDTO {
* @type integer
* @format int64
*/
end?: number;
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type integer
*/
limit?: number;
limit: number;
/**
* @type integer
*/
@@ -834,7 +825,7 @@ export interface MetricsexplorertypesStatsRequestDTO {
* @type integer
* @format int64
*/
start?: number;
start: number;
}
export interface MetricsexplorertypesStatsResponseDTO {
@@ -842,51 +833,52 @@ export interface MetricsexplorertypesStatsResponseDTO {
* @type array
* @nullable true
*/
metrics?: MetricsexplorertypesStatDTO[] | null;
metrics: MetricsexplorertypesStatDTO[] | null;
/**
* @type integer
* @minimum 0
*/
total?: number;
total: number;
}
export interface MetricsexplorertypesTreemapEntryDTO {
/**
* @type string
*/
metricName?: string;
metricName: string;
/**
* @type number
* @format double
*/
percentage?: number;
percentage: number;
/**
* @type integer
* @minimum 0
*/
totalValue?: number;
totalValue: number;
}
export enum MetricsexplorertypesTreemapModeDTO {
timeseries = 'timeseries',
samples = 'samples',
}
export interface MetricsexplorertypesTreemapRequestDTO {
/**
* @type integer
* @format int64
*/
end?: number;
end: number;
filter?: Querybuildertypesv5FilterDTO;
/**
* @type integer
*/
limit?: number;
/**
* @type string
*/
mode?: string;
limit: number;
mode: MetricsexplorertypesTreemapModeDTO;
/**
* @type integer
* @format int64
*/
start?: number;
start: number;
}
export interface MetricsexplorertypesTreemapResponseDTO {
@@ -894,41 +886,47 @@ export interface MetricsexplorertypesTreemapResponseDTO {
* @type array
* @nullable true
*/
samples?: MetricsexplorertypesTreemapEntryDTO[] | null;
samples: MetricsexplorertypesTreemapEntryDTO[] | null;
/**
* @type array
* @nullable true
*/
timeseries?: MetricsexplorertypesTreemapEntryDTO[] | null;
timeseries: MetricsexplorertypesTreemapEntryDTO[] | null;
}
export interface MetricsexplorertypesUpdateMetricMetadataRequestDTO {
/**
* @type string
*/
description?: string;
description: string;
/**
* @type boolean
*/
isMonotonic?: boolean;
isMonotonic: boolean;
/**
* @type string
*/
metricName?: string;
metricName: string;
temporality: MetrictypesTemporalityDTO;
type: MetrictypesTypeDTO;
/**
* @type string
*/
temporality?: string;
/**
* @type string
*/
type?: string;
/**
* @type string
*/
unit?: string;
unit: string;
}
export enum MetrictypesTemporalityDTO {
delta = 'delta',
cumulative = 'cumulative',
unspecified = 'unspecified',
}
export enum MetrictypesTypeDTO {
gauge = 'gauge',
sum = 'sum',
histogram = 'histogram',
summary = 'summary',
exponentialhistogram = 'exponentialhistogram',
}
export interface PreferencetypesPreferenceDTO {
/**
* @type array
@@ -1051,7 +1049,7 @@ export interface Querybuildertypesv5OrderByKeyDTO {
/**
* @type string
*/
name?: string;
name: string;
/**
* @type string
*/
@@ -1143,6 +1141,79 @@ export interface RoletypesRoleDTO {
updatedAt?: Date;
}
/**
* @nullable
*/
export type TelemetrytypesGettableFieldKeysDTOKeys = {
[key: string]: TelemetrytypesTelemetryFieldKeyDTO[];
} | null;
export interface TelemetrytypesGettableFieldKeysDTO {
/**
* @type boolean
*/
complete: boolean;
/**
* @type object
* @nullable true
*/
keys: TelemetrytypesGettableFieldKeysDTOKeys;
}
export interface TelemetrytypesGettableFieldValuesDTO {
/**
* @type boolean
*/
complete: boolean;
values: TelemetrytypesTelemetryFieldValuesDTO;
}
export interface TelemetrytypesTelemetryFieldKeyDTO {
/**
* @type string
*/
description?: string;
/**
* @type string
*/
fieldContext?: string;
/**
* @type string
*/
fieldDataType?: string;
/**
* @type string
*/
name: string;
/**
* @type string
*/
signal?: string;
/**
* @type string
*/
unit?: string;
}
export interface TelemetrytypesTelemetryFieldValuesDTO {
/**
* @type array
*/
boolValues?: boolean[];
/**
* @type array
*/
numberValues?: number[];
/**
* @type array
*/
relatedValues?: string[];
/**
* @type array
*/
stringValues?: string[];
}
export interface TypesChangePasswordRequestDTO {
/**
* @type string
@@ -1347,7 +1418,7 @@ export interface TypesPostableForgotPasswordDTO {
/**
* @type string
*/
email?: string;
email: string;
/**
* @type string
*/
@@ -1355,7 +1426,7 @@ export interface TypesPostableForgotPasswordDTO {
/**
* @type string
*/
orgId?: string;
orgId: string;
}
export interface TypesPostableInviteDTO {
@@ -1590,6 +1661,132 @@ export type DeleteAuthDomainPathParameters = {
export type UpdateAuthDomainPathParameters = {
id: string;
};
export type GetFieldsKeysParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
source?: string;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type string
* @description undefined
*/
fieldContext?: string;
/**
* @type string
* @description undefined
*/
fieldDataType?: string;
/**
* @type string
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
searchText?: string;
};
export type GetFieldsKeys200 = {
data?: TelemetrytypesGettableFieldKeysDTO;
/**
* @type string
*/
status?: string;
};
export type GetFieldsValuesParams = {
/**
* @type string
* @description undefined
*/
signal?: string;
/**
* @type string
* @description undefined
*/
source?: string;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
startUnixMilli?: number;
/**
* @type integer
* @format int64
* @description undefined
*/
endUnixMilli?: number;
/**
* @type string
* @description undefined
*/
fieldContext?: string;
/**
* @type string
* @description undefined
*/
fieldDataType?: string;
/**
* @type string
* @description undefined
*/
metricName?: string;
/**
* @type string
* @description undefined
*/
searchText?: string;
/**
* @type string
* @description undefined
*/
name?: string;
/**
* @type string
* @description undefined
*/
existingQuery?: string;
};
export type GetFieldsValues200 = {
data?: TelemetrytypesGettableFieldValuesDTO;
/**
* @type string
*/
status?: string;
};
export type GetResetPasswordTokenPathParameters = {
id: string;
};
@@ -1851,6 +2048,19 @@ export type GetFeatures200 = {
status?: string;
};
export type GetIngestionKeysParams = {
/**
* @type integer
* @description undefined
*/
page?: number;
/**
* @type integer
* @description undefined
*/
per_page?: number;
};
export type GetIngestionKeys200 = {
data?: GatewaytypesGettableIngestionKeysDTO;
/**
@@ -1890,6 +2100,24 @@ export type DeleteIngestionKeyLimitPathParameters = {
export type UpdateIngestionKeyLimitPathParameters = {
limitId: string;
};
export type SearchIngestionKeysParams = {
/**
* @type string
* @description undefined
*/
name?: string;
/**
* @type integer
* @description undefined
*/
page?: number;
/**
* @type integer
* @description undefined
*/
per_page?: number;
};
export type SearchIngestionKeys200 = {
data?: GatewaytypesGettableIngestionKeysDTO;
/**
@@ -1903,7 +2131,7 @@ export type GetMetricAlertsParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricAlerts200 = {
@@ -1919,7 +2147,7 @@ export type GetMetricDashboardsParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricDashboards200 = {
@@ -1935,7 +2163,7 @@ export type GetMetricHighlightsParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricHighlights200 = {
@@ -1962,7 +2190,7 @@ export type GetMetricMetadataParams = {
* @type string
* @description undefined
*/
metricName?: string;
metricName: string;
};
export type GetMetricMetadata200 = {

View File

@@ -73,7 +73,7 @@ describe('convertV5ResponseToLegacy', () => {
const v5Data: QueryRangeResponseV5 = {
type: 'time_series',
data: { results: [timeSeries] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
};
const params = makeBaseParams('time_series', [
@@ -156,7 +156,7 @@ describe('convertV5ResponseToLegacy', () => {
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
};
const params = makeBaseParams('scalar', [
@@ -239,7 +239,7 @@ describe('convertV5ResponseToLegacy', () => {
const v5Data: QueryRangeResponseV5 = {
type: 'scalar',
data: { results: [scalar] },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0 },
meta: { rowsScanned: 0, bytesScanned: 0, durationMs: 0, stepIntervals: {} },
};
const params = makeBaseParams('scalar', [

View File

@@ -388,6 +388,7 @@ export function convertV5ResponseToLegacy(
warnings: v5Data?.data?.warning || [],
},
warning: v5Data?.warning || undefined,
meta: v5Data?.meta,
},
warning: v5Data?.warning || undefined,
};
@@ -406,6 +407,7 @@ export function convertV5ResponseToLegacy(
payload: {
data: convertedData,
warning: v5Response.payload?.data?.warning || undefined,
meta: v5Data?.meta,
},
};

View File

@@ -18,8 +18,10 @@ import '@signozhq/checkbox';
import '@signozhq/combobox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/icons';
import '@signozhq/input';
import '@signozhq/popover';
import '@signozhq/radio-group';
import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/table';

View File

@@ -73,6 +73,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
enableRegexOption = false,
isDynamicVariable = false,
showRetryButton = true,
waitingMessage,
...rest
}) => {
// ===== State & Refs =====
@@ -140,10 +141,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
}, [selectedValues, allAvailableValues, enableAllSelection]);
// Define allOptionShown earlier in the code
const allOptionShown = useMemo(
() => value === ALL_SELECTED_VALUE || value === 'ALL',
[value],
);
const allOptionShown = value === ALL_SELECTED_VALUE;
// Value passed to the underlying Ant Select component
const displayValue = useMemo(
@@ -1684,6 +1682,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{!loading &&
!errorMessage &&
!noDataMessage &&
!waitingMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
@@ -1701,7 +1700,17 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
<div className="navigation-text">Refreshing values...</div>
</div>
)}
{errorMessage && !loading && (
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}
</div>
</div>
)}
{errorMessage && !loading && !waitingMessage && (
<div className="navigation-error">
<div className="navigation-text">
{errorMessage || SOMETHING_WENT_WRONG}
@@ -1723,6 +1732,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!waitingMessage &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
@@ -1765,6 +1775,7 @@ const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
isDarkMode,
isDynamicVariable,
showRetryButton,
waitingMessage,
]);
// Custom handler for dropdown visibility changes

View File

@@ -63,6 +63,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
showIncompleteDataMessage = false,
showRetryButton = true,
isDynamicVariable = false,
waitingMessage,
...rest
}) => {
// ===== State & Refs =====
@@ -568,6 +569,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{!loading &&
!errorMessage &&
!noDataMessage &&
!waitingMessage &&
!(showIncompleteDataMessage && isScrolledToBottom) && (
<section className="navigate">
<ArrowDown size={8} className="icons" />
@@ -583,6 +585,16 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
<div className="navigation-text">Refreshing values...</div>
</div>
)}
{!loading && waitingMessage && (
<div className="navigation-loading">
<div className="navigation-icons">
<LoadingOutlined />
</div>
<div className="navigation-text" title={waitingMessage}>
{waitingMessage}
</div>
</div>
)}
{errorMessage && !loading && (
<div className="navigation-error">
<div className="navigation-text">
@@ -605,6 +617,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
{showIncompleteDataMessage &&
isScrolledToBottom &&
!loading &&
!waitingMessage &&
!errorMessage && (
<div className="navigation-text-incomplete">
Don&apos;t see the value? Use search
@@ -641,6 +654,7 @@ const CustomSelect: React.FC<CustomSelectProps> = ({
showRetryButton,
isDarkMode,
isDynamicVariable,
waitingMessage,
]);
// Handle dropdown visibility changes

View File

@@ -78,12 +78,10 @@ function TestWrapper({ children }: { children: React.ReactNode }): JSX.Element {
describe('VariableItem Integration Tests', () => {
let user: ReturnType<typeof userEvent.setup>;
let mockOnValueUpdate: jest.Mock;
let mockSetVariablesToGetUpdated: jest.Mock;
beforeEach(() => {
user = userEvent.setup();
mockOnValueUpdate = jest.fn();
mockSetVariablesToGetUpdated = jest.fn();
jest.clearAllMocks();
});
@@ -102,9 +100,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -150,9 +145,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -195,9 +187,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -247,9 +236,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -272,9 +258,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -308,9 +291,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -344,9 +324,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -369,9 +346,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -405,9 +379,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -461,9 +432,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -508,9 +476,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -548,9 +513,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);
@@ -582,9 +544,6 @@ describe('VariableItem Integration Tests', () => {
variableData={variable}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={null}
/>
</TestWrapper>,
);

View File

@@ -30,6 +30,7 @@ export interface CustomSelectProps extends Omit<SelectProps, 'options'> {
showIncompleteDataMessage?: boolean;
showRetryButton?: boolean;
isDynamicVariable?: boolean;
waitingMessage?: string;
}
export interface CustomTagProps {
@@ -66,4 +67,5 @@ export interface CustomMultiSelectProps
enableRegexOption?: boolean;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
waitingMessage?: string;
}

View File

@@ -648,7 +648,13 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
) : (
<Typography.Text
className="value-string"
ellipsis={{ tooltip: { placement: 'top' } }}
ellipsis={{
tooltip: {
placement: 'top',
mouseEnterDelay: 0.2,
mouseLeaveDelay: 0,
},
}}
>
{String(value)}
</Typography.Text>

View File

@@ -282,11 +282,11 @@ export default function QuickFilters(props: IQuickFiltersProps): JSX.Element {
size="small"
style={{ marginLeft: 'auto' }}
checked={showIP ?? true}
onClick={(): void => {
onChange={(checked): void => {
logEvent('API Monitoring: Show IP addresses clicked', {
showIP: !(showIP ?? true),
showIP: checked,
});
setParams({ showIP });
setParams({ showIP: checked });
}}
/>
</div>

View File

@@ -1,4 +1,8 @@
import { ENVIRONMENT } from 'constants/env';
import {
ApiMonitoringParams,
useApiMonitoringParams,
} from 'container/ApiMonitoring/queryParams';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import {
otherFiltersResponse,
@@ -18,10 +22,15 @@ import { QuickFiltersConfig } from './constants';
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
useQueryBuilder: jest.fn(),
}));
jest.mock('container/ApiMonitoring/queryParams');
const handleFilterVisibilityChange = jest.fn();
const redirectWithQueryBuilderData = jest.fn();
const putHandler = jest.fn();
const mockSetApiMonitoringParams = jest.fn() as jest.MockedFunction<
(newParams: Partial<ApiMonitoringParams>, replace?: boolean) => void
>;
const mockUseApiMonitoringParams = jest.mocked(useApiMonitoringParams);
const BASE_URL = ENVIRONMENT.baseURL;
const SIGNAL = SignalType.LOGS;
@@ -84,6 +93,28 @@ TestQuickFilters.defaultProps = {
config: QuickFiltersConfig,
};
function TestQuickFiltersApiMonitoring({
signal = SignalType.LOGS,
config = QuickFiltersConfig,
}: {
signal?: SignalType;
config?: IQuickFiltersConfig[];
}): JSX.Element {
return (
<QuickFilters
source={QuickFiltersSource.API_MONITORING}
config={config}
handleFilterVisibilityChange={handleFilterVisibilityChange}
signal={signal}
/>
);
}
TestQuickFiltersApiMonitoring.defaultProps = {
signal: '',
config: QuickFiltersConfig,
};
beforeAll(() => {
server.listen();
});
@@ -112,6 +143,10 @@ beforeEach(() => {
lastUsedQuery: 0,
redirectWithQueryBuilderData,
});
mockUseApiMonitoringParams.mockReturnValue([
{ showIP: true } as ApiMonitoringParams,
mockSetApiMonitoringParams,
]);
setupServer();
});
@@ -251,6 +286,24 @@ describe('Quick Filters', () => {
);
});
});
it('toggles Show IP addresses and updates API Monitoring params', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<TestQuickFiltersApiMonitoring />);
// Switch should be rendered and initially checked
expect(screen.getByText('Show IP addresses')).toBeInTheDocument();
const toggle = screen.getByRole('switch');
expect(toggle).toHaveAttribute('aria-checked', 'true');
await user.click(toggle);
await waitFor(() => {
expect(mockSetApiMonitoringParams).toHaveBeenCalledWith(
expect.objectContaining({ showIP: false }),
);
});
});
});
describe('Quick Filters with custom filters', () => {

View File

@@ -1,6 +1,7 @@
const ROUTES = {
SIGN_UP: '/signup',
LOGIN: '/login',
FORGOT_PASSWORD: '/forgot-password',
HOME: '/home',
SERVICE_METRICS: '/services/:servicename',
SERVICE_TOP_LEVEL_OPERATIONS: '/services/:servicename/top-level-operations',

View File

@@ -34,159 +34,230 @@ const themeColors = {
cyan: '#00FFFF',
},
chartcolors: {
radicalRed: '#FF1A66',
// Blues (3)
dodgerBlue: '#2F80ED',
mediumOrchid: '#BB6BD9',
seaBuckthorn: '#F2994A',
seaGreen: '#219653',
turquoiseBlue: '#56CCF2',
festivalOrange: '#F2C94C',
silver: '#BDBDBD',
outrageousOrange: '#FF6633',
roseBud: '#FFB399',
canary: '#FFFF99',
deepSkyBlue: '#00B3E6',
goldTips: '#E6B333',
royalBlue: '#3366E6',
avocado: '#999966',
mintGreen: '#99FF99',
chestnut: '#B34D4D',
lima: '#80B300',
olive: '#809900',
beautyBush: '#E6B3B3',
danube: '#6680B3',
oliveDrab: '#66991A',
lavenderRose: '#FF99E6',
electricLime: '#CCFF1A',
robin: '#3F5ECC',
harleyOrange: '#E6331A',
turquoise: '#33FFCC',
gladeGreen: '#66994D',
hemlock: '#66664D',
vidaLoca: '#4D8000',
rust: '#B33300',
red: '#FF0000', // Adding more colors, we need to get better colors from design team
blue: '#0000FF',
green: '#00FF00',
yellow: '#FFFF00',
purple: '#800080',
cyan: '#00FFFF',
magenta: '#FF00FF',
orange: '#FFA500',
pink: '#FFC0CB',
brown: '#A52A2A',
teal: '#008080',
lime: '#00FF00',
maroon: '#800000',
navy: '#000080',
aquamarine: '#7FFFD4',
darkSeaGreen: '#8FBC8F',
gray: '#808080',
skyBlue: '#87CEEB',
indigo: '#4B0082',
slateGray: '#708090',
chocolate: '#D2691E',
tomato: '#FF6347',
steelBlue: '#4682B4',
peru: '#CD853F',
darkOliveGreen: '#556B2F',
indianRed: '#CD5C5C',
mediumSlateBlue: '#7B68EE',
rosyBrown: '#BC8F8F',
darkSlateGray: '#2F4F4F',
mediumAquamarine: '#66CDAA',
lavender: '#E6E6FA',
thistle: '#D8BFD8',
salmon: '#FA8072',
darkSalmon: '#E9967A',
paleVioletRed: '#DB7093',
mediumPurple: '#9370DB',
darkOrchid: '#9932CC',
lawnGreen: '#7CFC00',
// Teals / Cyans (3)
turquoise: '#00CEC9',
lagoon: '#1ABC9C',
cyanBright: '#22A6F2',
// Greens (3)
emeraldGreen: '#27AE60',
mediumSeaGreen: '#3CB371',
lightCoral: '#F08080',
gold: '#FFD700',
sandyBrown: '#F4A460',
darkKhaki: '#BDB76B',
cornflowerBlue: '#6495ED',
mediumVioletRed: '#C71585',
paleGreen: '#98FB98',
limeGreen: '#A3E635',
// Yellows / Golds (3)
festivalYellow: '#F2C94C',
sunflower: '#FFD93D',
warmAmber: '#FFCA28',
// Oranges (3)
festivalOrange: '#F2994A',
coralOrange: '#E17055',
pumpkin: '#FF7F50',
// Reds (3)
radicalRed: '#FF1A66',
crimsonRed: '#EB5757',
fireRed: '#E10600',
// Pinks (3)
hotPink: '#E84393',
rosePink: '#FD79A8',
blush: '#FF7EB6',
// Purples / Violets (3)
mediumPurple: '#BB6BD9',
royalPurple: '#9B51E0',
orchid: '#DA77F2',
// Accent / Neon / Unique Colors (3)
neonViolet: '#C77DFF',
electricPurple: '#6C5CE7',
arcticBlue: '#48DBFB',
// Extended palette — systematic variations to reach 100 colors
blue1: '#1F63E0',
blue2: '#3A7AED',
blue3: '#5A9DF5',
cyan1: '#00B0AA',
cyan2: '#33D6C2',
cyan3: '#66E9DA',
green1: '#1E8449',
green2: '#2ECC71',
green3: '#58D68D',
yellow1: '#F1C40F',
yellow2: '#F7DC6F',
yellow3: '#F9E79F',
orange1: '#D35400',
orange2: '#E67E22',
orange3: '#F5B041',
red1: '#C0392B',
red2: '#E74C3C',
red3: '#EC7063',
pink1: '#D81B60',
pink2: '#E91E63',
pink3: '#F06292',
purple1: '#8E44AD',
purple2: '#9B59B6',
purple3: '#BB8FCE',
teal1: '#009688',
teal2: '#1ABC9C',
teal3: '#48C9B0',
lime1: '#A3E635',
lime2: '#B9F18D',
lime3: '#D4FFB0',
gold1: '#F39C12',
gold2: '#F1C40F',
gold3: '#F7DC6F',
coral1: '#E67E22',
coral2: '#F39C12',
coral3: '#F5B041',
crimson1: '#C0392B',
crimson2: '#E74C3C',
crimson3: '#EC7063',
violet1: '#8E44AD',
violet2: '#9B59B6',
violet3: '#BB8FCE',
aqua1: '#00BFFF',
aqua2: '#1E90FF',
aqua3: '#63B8FF',
forest1: '#27AE60',
forest2: '#2ECC71',
forest3: '#58D68D',
blush1: '#FF6F91',
blush2: '#FF85A2',
blush3: '#FFA0B3',
lavender1: '#9B59B6',
lavender2: '#AF7AC5',
lavender3: '#C39BD3',
tomato1: '#E74C3C',
tomato2: '#EC7063',
tomato3: '#F1948A',
salmon1: '#FF6B6B',
salmon2: '#FF8787',
salmon3: '#FFA1A1',
mustard1: '#F1C40F',
mustard2: '#F7DC6F',
mustard3: '#F9E79F',
teal4: '#1ABC9C',
teal5: '#48C9B0',
teal6: '#76D7C4',
magenta1: '#D6336C',
magenta2: '#E84393',
magenta3: '#F06292',
violet4: '#7D3C98',
violet5: '#8E44AD',
violet6: '#9B59B6',
green4: '#229954',
green5: '#27AE60',
green6: '#52BE80',
blue4: '#2874A6',
blue5: '#2E86C1',
blue6: '#3498DB',
red4: '#C0392B',
red5: '#E74C3C',
red6: '#EC7063',
orange4: '#D35400',
orange5: '#E67E22',
orange6: '#EB984E',
pink4: '#C2185B',
pink5: '#D81B60',
pink6: '#E91E63',
gold4: '#B7950B',
gold5: '#F1C40F',
gold6: '#F4D03F',
},
lightModeColor: {
radicalRed: '#FF1A66',
dodgerBlueDark: '#0C6EED',
steelgrey: '#2f4b7c',
steelpurple: '#665191',
steelindigo: '#a05195',
steelpink: '#d45087',
steelcoral: '#f95d6a',
steelorange: '#ff7c43',
steelgold: '#ffa600',
steelrust: '#de425b',
steelgreen: '#41967e',
mediumOrchidDark: '#C326FD',
seaBuckthornDark: '#E66E05',
seaGreen: '#219653',
turquoiseBlueDark: '#0099CC',
silverDark: '#757575',
outrageousOrangeDark: '#F9521A',
roseBudDark: '#EB6437',
deepSkyBlueDark: '#0595BD',
royalBlue: '#3366E6',
avocadoDark: '#8E8E29',
mintGreenDark: '#00C700',
chestnut: '#B34D4D',
limaDark: '#6E9900',
olive: '#809900',
beautyBushDark: '#E25555',
danube: '#6680B3',
oliveDrab: '#66991A',
lavenderRoseDark: '#F024BD',
electricLimeDark: '#84A800',
robin: '#3F5ECC',
harleyOrange: '#E6331A',
gladeGreen: '#66994D',
hemlock: '#66664D',
vidaLoca: '#4D8000',
rust: '#B33300',
red: '#FF0000', // Adding more colors, we need to get better colors from design team
blue: '#0000FF',
green: '#00FF00',
purple: '#800080',
magentaDark: '#EB00EB',
pinkDark: '#FF3D5E',
brown: '#A52A2A',
teal: '#008080',
limeDark: '#07A207',
maroon: '#800000',
navy: '#000080',
gray: '#808080',
skyBlueDark: '#0CA7E4',
indigo: '#4B0082',
slateGray: '#708090',
chocolate: '#D2691E',
tomato: '#FF6347',
steelBlue: '#4682B4',
peruDark: '#D16E0A',
darkOliveGreen: '#556B2F',
indianRed: '#CD5C5C',
mediumSlateBlue: '#7B68EE',
rosyBrownDark: '#CB4848',
darkSlateGray: '#2F4F4F',
fuchsia: '#FF0AFF',
salmonDark: '#FF432E',
darkSalmonDark: '#D26541',
paleVioletRedDark: '#E83089',
mediumPurple: '#9370DB',
darkOrchid: '#9932CC',
mediumSeaGreenDark: '#109E50',
lightCoralDark: '#F85959',
gold: '#FFD700',
sandyBrownDark: '#D97117',
darkKhakiDark: '#99900A',
cornflowerBlueDark: '#3371E6',
mediumVioletRed: '#C71585',
paleGreenDark: '#0D910D',
radicalRed: '#D81B60',
dodgerBlueDark: '#1E5BD9',
steelgrey: '#344B6B',
steelpurple: '#5E548E',
steelindigo: '#8E4A7C',
steelpink: '#B63A6F',
steelcoral: '#E14B5A',
steelorange: '#E76F2F',
steelgold: '#E09B00',
steelrust: '#C93A50',
steelgreen: '#2F7D69',
mediumOrchidDark: '#8E24AA',
seaBuckthornDark: '#C75A00',
seaGreen: '#1E7F5A',
turquoiseBlueDark: '#007EA7',
silverDark: '#5F5F5F',
outrageousOrangeDark: '#E64A19',
roseBudDark: '#D84315',
deepSkyBlueDark: '#0277BD',
royalBlue: '#2A4FDB',
avocadoDark: '#6B6B1E',
mintGreenDark: '#2E9E55',
chestnut: '#8B3A3A',
limaDark: '#5C7F00',
olive: '#6E7F00',
beautyBushDark: '#C93C3C',
danube: '#4F6FB3',
oliveDrab: '#4F7F1A',
lavenderRoseDark: '#B0178F',
electricLimeDark: '#6B8F00',
robin: '#2F4FCC',
harleyOrange: '#CC2E12',
gladeGreen: '#4F7F46',
hemlock: '#5C5C45',
vidaLoca: '#3D6B00',
rust: '#993300',
red: '#C62828',
blue: '#1A237E',
green: '#1B7F3A',
purple: '#6A1B9A',
magentaDark: '#B000B5',
pinkDark: '#C2185B',
brown: '#7A3A1E',
teal: '#006D6F',
limeDark: '#4C8C2B',
maroon: '#6D1B1B',
navy: '#0D1B5E',
gray: '#616161',
skyBlueDark: '#0288D1',
indigo: '#303F9F',
slateGray: '#556B7C',
chocolate: '#9C4A1A',
tomato: '#E53935',
steelBlue: '#3A6EA5',
peruDark: '#B35E00',
darkOliveGreen: '#445B1F',
indianRed: '#B04040',
mediumSlateBlue: '#5C6BC0',
rosyBrownDark: '#A94444',
darkSlateGray: '#2E4A4A',
fuchsia: '#C511C5',
salmonDark: '#E64A3C',
darkSalmonDark: '#C85A3A',
paleVioletRedDark: '#C2186A',
mediumPurple: '#7E57C2',
darkOrchid: '#7B1FA2',
mediumSeaGreenDark: '#2E8B57',
lightCoralDark: '#E57373',
gold: '#D4AF37',
sandyBrownDark: '#C76A15',
darkKhakiDark: '#8A7F00',
cornflowerBlueDark: '#355FCC',
mediumVioletRed: '#AD1457',
paleGreenDark: '#2E7D32',
},
errorColor: '#d32f2f',
royalGrey: '#888888',

View File

@@ -14,6 +14,6 @@ export const VIEW_TYPES = {
export const SPAN_ATTRIBUTES = {
URL_PATH: 'http.url',
RESPONSE_STATUS_CODE: 'response_status_code',
SERVER_NAME: 'net.peer.name',
SERVER_NAME: 'http_host',
SERVER_PORT: 'net.peer.port',
} as const;

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useSelector } from 'react-redux';
import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Table, Typography } from 'antd';
import { Spin, Table } from 'antd';
import logEvent from 'api/common/logEvent';
import cx from 'classnames';
import QuerySearch from 'components/QueryBuilderV2/QueryV2/QuerySearch/QuerySearch';
@@ -14,11 +14,13 @@ import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations
import { useShareBuilderUrl } from 'hooks/queryBuilder/useShareBuilderUrl';
import { useListOverview } from 'hooks/thirdPartyApis/useListOverview';
import { get } from 'lodash-es';
import { MoveUpRight } from 'lucide-react';
import { AppState } from 'store/reducers';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder';
import { GlobalReducer } from 'types/reducer/globalTime';
import DOCLINKS from 'utils/docLinks';
import { ApiMonitoringHardcodedAttributeKeys } from '../../constants';
import { DEFAULT_PARAMS, useApiMonitoringParams } from '../../queryParams';
@@ -125,51 +127,67 @@ function DomainList(): JSX.Element {
hardcodedAttributeKeys={ApiMonitoringHardcodedAttributeKeys}
/>
</div>
<Table
className={cx('api-monitoring-domain-list-table')}
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
locale={{
emptyText:
isFetching || isLoading ? null : (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
{!isFetching && !isLoading && formattedDataForTable.length === 0 && (
<div className="no-filtered-domains-message-container">
<div className="no-filtered-domains-message-content">
<img
src="/Icons/emptyState.svg"
alt="thinking-emoji"
className="empty-state-svg"
/>
<Typography.Text className="no-filtered-domains-message">
This query had no results. Edit your query and try again!
</Typography.Text>
</div>
<div className="no-filtered-domains-message">
<div className="no-domain-title">
No External API calls detected with applied filters.
</div>
),
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
logEvent('API Monitoring: Domain name row clicked', {});
}
},
className: 'expanded-clickable-row',
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
<div className="no-domain-subtitle">
Ensure all HTTP client spans are being sent with kind as{' '}
<span className="attribute">Client</span> and url set in{' '}
<span className="attribute">url.full</span> or{' '}
<span className="attribute">http.url</span> attribute.
</div>
<a
href={DOCLINKS.EXTERNAL_API_MONITORING}
target="_blank"
rel="noreferrer"
className="external-api-doc-link"
>
Learn how External API monitoring works in SigNoz{' '}
<MoveUpRight size={14} />
</a>
</div>
</div>
</div>
)}
{(isFetching || isLoading || formattedDataForTable.length > 0) && (
<Table
className="api-monitoring-domain-list-table"
dataSource={isFetching || isLoading ? [] : formattedDataForTable}
columns={columnsConfig}
loading={{
spinning: isFetching || isLoading,
indicator: <Spin indicator={<LoadingOutlined size={14} spin />} />,
}}
scroll={{ x: true }}
tableLayout="fixed"
onRow={(record, index): { onClick: () => void; className: string } => ({
onClick: (): void => {
if (index !== undefined) {
const dataIndex = formattedDataForTable.findIndex(
(item) => item.key === record.key,
);
setSelectedDomainIndex(dataIndex);
setParams({ selectedDomain: record.domainName });
logEvent('API Monitoring: Domain name row clicked', {});
}
},
className: 'expanded-clickable-row',
})}
rowClassName={(_, index): string =>
index % 2 === 0 ? 'table-row-dark' : 'table-row-light'
}
/>
)}
{selectedDomainIndex !== -1 && (
<DomainDetails
domainData={formattedDataForTable[selectedDomainIndex]}

View File

@@ -180,10 +180,59 @@
.no-filtered-domains-message {
margin-top: 8px;
display: flex;
gap: 8px;
flex-direction: column;
.no-domain-title {
color: var(--bg-vanilla-100, #fff);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: 20px; /* 142.857% */
}
.no-domain-subtitle {
color: var(--bg-vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 20px; /* 142.857% */
.attribute {
font-family: 'Space Mono';
}
}
.external-api-doc-link {
display: flex;
flex-direction: row;
gap: 4px;
align-items: center;
}
}
}
.lightMode {
.no-filtered-domains-message-container {
.no-filtered-domains-message-content {
.no-filtered-domains-message {
.no-domain-title {
color: var(--text-ink-500);
}
.no-domain-subtitle {
color: var(--text-ink-400);
.attribute {
font-family: 'Space Mono';
}
}
}
}
}
.api-monitoring-domain-list-table {
.ant-table {
.ant-table-thead > tr > th {

View File

@@ -4,6 +4,7 @@ import { rest, server } from 'mocks-server/server';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
import { DataSource } from 'types/common/queryBuilder';
import { SPAN_ATTRIBUTES } from '../Explorer/Domains/DomainDetails/constants';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';
@@ -215,7 +216,7 @@ describe('TopErrors', () => {
value: 'true',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'net.peer.name' }),
key: expect.objectContaining({ key: SPAN_ATTRIBUTES.SERVER_NAME }),
op: '=',
value: 'test-domain',
}),

View File

@@ -638,7 +638,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -685,7 +685,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -733,7 +733,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -780,7 +780,7 @@ export const getEndPointsQueryPayload = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -1302,7 +1302,7 @@ export const getTopErrorsCoRelationQueryFilters = (
{
id: 'e8a043b7',
key: {
key: 'net.peer.name',
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: '',
},
@@ -2198,7 +2198,7 @@ export const getEndPointZeroStateQueryPayload = (
key: {
key: SPAN_ATTRIBUTES.SERVER_NAME,
dataType: DataTypes.String,
type: 'tag',
type: '',
},
op: '=',
value: domainName,
@@ -2793,7 +2793,7 @@ export const getStatusCodeBarChartWidgetData = (
key: {
dataType: DataTypes.String,
key: SPAN_ATTRIBUTES.SERVER_NAME,
type: 'tag',
type: '',
},
op: '=',
value: domainName,

View File

@@ -18,8 +18,8 @@ import { useWidgetsByDynamicVariableId } from 'hooks/dashboard/useWidgetsByDynam
import { getWidgetsHavingDynamicVariableAttribute } from 'hooks/dashboard/utils';
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isEmpty, map } from 'lodash-es';
import {
ArrowLeft,

View File

@@ -22,7 +22,7 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useNotifications } from 'hooks/useNotifications';
import { PenLine, Trash2 } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { TVariableMode } from './types';

View File

@@ -0,0 +1,69 @@
import { memo, useEffect, useMemo } from 'react';
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { VariableItemProps } from './VariableItem';
import { customVariableSelectStrategy } from './variableSelectStrategy/customVariableSelectStrategy';
type CustomVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate'
>;
function CustomVariableInput({
variableData,
onValueUpdate,
}: CustomVariableInputProps): JSX.Element {
const optionsData: (string | number | boolean)[] = useMemo(() => {
return sortValues(
commaValuesParser(variableData.customValue || ''),
variableData.sort,
) as (string | number | boolean)[];
}, [variableData.customValue, variableData.sort]);
const {
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: customVariableSelectStrategy,
});
// Apply default on mount — options are available synchronously for custom variables
// eslint-disable-next-line react-hooks/exhaustive-deps
useEffect(applyDefaultIfNeeded, []);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
/>
);
}
export default memo(CustomVariableInput);

View File

@@ -25,12 +25,6 @@
}
}
&.focused {
.variable-value {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-value {
display: flex;
min-width: 120px;
@@ -48,6 +42,11 @@
font-style: normal;
font-weight: 400;
line-height: 16px; /* 133.333% */
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
.variable-select {
@@ -99,12 +98,6 @@
.lightMode {
.variable-item {
&.focused {
.variable-value {
border: 1px solid var(--bg-robin-400);
}
}
.variable-name {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
@@ -115,6 +108,11 @@
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
color: var(--bg-ink-400);
&:hover,
&:focus-within {
outline: 1px solid var(--bg-robin-400);
}
}
}
}
@@ -124,3 +122,9 @@
padding: 4px 12px;
font-size: 12px;
}
.dashboard-variables-selection-container {
display: flex;
flex-wrap: wrap;
gap: 12px;
}

View File

@@ -1,45 +1,41 @@
import { memo, useEffect, useState } from 'react';
import { memo, useCallback, useEffect, useMemo } from 'react';
import { useSelector } from 'react-redux';
import { Row } from 'antd';
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
import { useDashboardVariables } from 'hooks/dashboard/useDashboardVariables';
import {
useDashboardVariables,
useDashboardVariablesSelector,
} from 'hooks/dashboard/useDashboardVariables';
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
import { isEmpty } from 'lodash-es';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
import {
enqueueDescendantsOfVariable,
enqueueFetchOfAllVariables,
initializeVariableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import DynamicVariableSelection from './DynamicVariableSelection';
import {
buildDependencies,
buildDependencyGraph,
buildParentDependencyGraph,
IDependencyData,
onUpdateVariableNode,
} from './util';
import VariableItem from './VariableItem';
import './DashboardVariableSelection.styles.scss';
function DashboardVariableSelection(): JSX.Element | null {
const {
selectedDashboard,
setSelectedDashboard,
updateLocalStorageDashboardVariables,
variablesToGetUpdated,
setVariablesToGetUpdated,
} = useDashboard();
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
const { dashboardVariables } = useDashboardVariables();
const [variablesTableData, setVariablesTableData] = useState<any>([]);
const [dependencyData, setDependencyData] = useState<IDependencyData | null>(
null,
const sortedVariablesArray = useDashboardVariablesSelector(
(state) => state.sortedVariablesArray,
);
const dependencyData = useDashboardVariablesSelector(
(state) => state.dependencyData,
);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
@@ -47,24 +43,6 @@ function DashboardVariableSelection(): JSX.Element | null {
);
useEffect(() => {
const tableRowData = [];
// eslint-disable-next-line no-restricted-syntax
for (const [key, value] of Object.entries(dashboardVariables)) {
const { id } = value;
tableRowData.push({
key,
name: key,
...dashboardVariables[key],
id,
});
}
tableRowData.sort((a, b) => a.order - b.order);
setVariablesTableData(tableRowData);
// Initialize variables with default values if not in URL
initializeDefaultVariables(
dashboardVariables,
@@ -73,58 +51,39 @@ function DashboardVariableSelection(): JSX.Element | null {
);
}, [getUrlVariables, updateUrlVariable, dashboardVariables]);
useEffect(() => {
if (variablesTableData.length > 0) {
const depGrp = buildDependencies(variablesTableData);
const { order, graph, hasCycle, cycleNodes } = buildDependencyGraph(depGrp);
const parentDependencyGraph = buildParentDependencyGraph(graph);
// cleanup order to only include variables that are of type 'QUERY'
const cleanedOrder = order.filter((variable) => {
const variableData = variablesTableData.find(
(v: IDashboardVariable) => v.name === variable,
);
return variableData?.type === 'QUERY';
});
setDependencyData({
order: cleanedOrder,
graph,
parentDependencyGraph,
hasCycle,
cycleNodes,
});
}
}, [dashboardVariables, variablesTableData]);
// this handles the case where the dependency order changes i.e. variable list updated via creation or deletion etc. and we need to refetch the variables
// also trigger when the global time changes
useEffect(
() => {
if (!isEmpty(dependencyData?.order)) {
setVariablesToGetUpdated(dependencyData?.order || []);
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[JSON.stringify(dependencyData?.order), minTime, maxTime],
// Memoize the order key to avoid unnecessary triggers
const dependencyOrderKey = useMemo(
() => dependencyData?.order?.join(',') ?? '',
[dependencyData?.order],
);
// Initialize fetch store then start a new fetch cycle.
// Runs on dependency order changes, and time range changes.
useEffect(() => {
const allVariableNames = sortedVariablesArray
.map((v) => v.name)
.filter((name): name is string => !!name);
initializeVariableFetchStore(allVariableNames);
enqueueFetchOfAllVariables();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dependencyOrderKey, minTime, maxTime]);
// Performance optimization: For dynamic variables with allSelected=true, we don't store
// individual values in localStorage since we can always derive them from available options.
// This makes localStorage much lighter and more efficient.
const onValueUpdate = (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
if (id) {
const onValueUpdate = useCallback(
(
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
// eslint-disable-next-line sonarjs/cognitive-complexity
): void => {
// For dynamic variables, only store in localStorage when NOT allSelected
// This makes localStorage much lighter by avoiding storing all individual values
const variable = dashboardVariables?.[id] || dashboardVariables?.[name];
const isDynamic = variable?.type === 'DYNAMIC';
const variable = dashboardVariables[id] || dashboardVariables[name];
const isDynamic = variable.type === 'DYNAMIC';
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
if (allSelected) {
@@ -133,98 +92,65 @@ function DashboardVariableSelection(): JSX.Element | null {
updateUrlVariable(name || id, value);
}
if (selectedDashboard) {
setSelectedDashboard((prev) => {
if (prev) {
const oldVariables = prev?.data.variables;
// this is added to handle case where we have two different
// schemas for variable response
if (oldVariables?.[id]) {
oldVariables[id] = {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
if (oldVariables?.[name]) {
oldVariables[name] = {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
...prev,
data: {
...prev?.data,
variables: {
...oldVariables,
},
},
setSelectedDashboard((prev) => {
if (prev) {
const oldVariables = { ...prev?.data.variables };
// this is added to handle case where we have two different
// schemas for variable response
if (oldVariables?.[id]) {
oldVariables[id] = {
...oldVariables[id],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return prev;
});
}
if (oldVariables?.[name]) {
oldVariables[name] = {
...oldVariables[name],
selectedValue: value,
allSelected,
haveCustomValuesSelected,
};
}
return {
...prev,
data: {
...prev?.data,
variables: {
...oldVariables,
},
},
};
}
return prev;
});
if (dependencyData) {
const updatedVariables: string[] = [];
onUpdateVariableNode(
name,
dependencyData.graph,
dependencyData.order,
(node) => updatedVariables.push(node),
);
setVariablesToGetUpdated((prev) => [
...new Set([...prev, ...updatedVariables.filter((v) => v !== name)]),
]);
} else {
setVariablesToGetUpdated((prev) => prev.filter((v) => v !== name));
}
}
};
if (!dashboardVariables) {
return null;
}
const orderBasedSortedVariables = variablesTableData.sort(
(a: { order: number }, b: { order: number }) => a.order - b.order,
// Cascade: enqueue query-type descendants for refetching
enqueueDescendantsOfVariable(name);
},
[
dashboardVariables,
updateLocalStorageDashboardVariables,
updateUrlVariable,
setSelectedDashboard,
],
);
return (
<Row style={{ display: 'flex', gap: '12px' }}>
{orderBasedSortedVariables &&
Array.isArray(orderBasedSortedVariables) &&
orderBasedSortedVariables.length > 0 &&
orderBasedSortedVariables.map((variable) =>
variable.type === 'DYNAMIC' ? (
<DynamicVariableSelection
key={`${variable.name}${variable.id}${variable.order}`}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
/>
) : (
<VariableItem
key={`${variable.name}${variable.id}}${variable.order}`}
existingVariables={dashboardVariables}
variableData={{
name: variable.name,
...variable,
}}
onValueUpdate={onValueUpdate}
variablesToGetUpdated={variablesToGetUpdated}
setVariablesToGetUpdated={setVariablesToGetUpdated}
dependencyData={dependencyData}
/>
),
)}
<Row className="dashboard-variables-selection-container">
{sortedVariablesArray.map((variable) => {
const key = `${variable.name}${variable.id}${variable.order}`;
return (
<VariableItem
key={key}
existingVariables={dashboardVariables}
variableData={variable}
onValueUpdate={onValueUpdate}
/>
);
})}
</Row>
);
}

View File

@@ -0,0 +1,318 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import useDebounce from 'hooks/useDebounce';
import { AppState } from 'store/reducers';
import { SuccessResponseV2 } from 'types/api';
import { FieldValueResponse } from 'types/api/dynamicVariables/getFieldValues';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import {
buildExistingDynamicVariableQuery,
extractErrorMessage,
getOptionsForDynamicVariable,
mergeUniqueStrings,
settleVariableFetch,
} from './util';
import { VariableItemProps } from './VariableItem';
import { dynamicVariableSelectStrategy } from './variableSelectStrategy/dynamicVariableSelectStrategy';
import './DashboardVariableSelection.styles.scss';
type DynamicVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate' | 'existingVariables'
>;
function DynamicVariableInput({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const allAvailableOptionStrings = useMemo(
() => mergeUniqueStrings(optionsData, relatedValues),
[optionsData, relatedValues],
);
const {
value,
tempSelection,
setTempSelection,
handleClear,
enableSelectAll,
defaultValue,
applyDefaultIfNeeded,
onChange,
onDropdownVisibleChange,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: dynamicVariableSelectStrategy,
allAvailableOptionStrings,
});
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
const existingQuery = useMemo(
() =>
buildExistingDynamicVariableQuery(
existingVariables,
variableData.id,
!!variableData.dynamicVariablesAttribute,
),
[existingVariables, variableData.id, variableData.dynamicVariablesAttribute],
);
// Wrap the hook's onDropdownVisibleChange to also track isDropdownOpen and handle cleanup
const handleSelectDropdownVisibilityChange = useCallback(
(visible: boolean): void => {
setIsDropdownOpen(visible);
onDropdownVisibleChange(visible);
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
},
[onDropdownVisibleChange, optionsData, originalRelatedValues],
);
const handleQuerySuccess = useCallback(
(data: SuccessResponseV2<FieldValueResponse>): void => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Sync temp selection with latest API values when ALL is active and dropdown is open
if (variableData.allSelected && isDropdownOpen) {
const latestValues = mergeUniqueStrings(
newNormalizedValues,
newRelatedValues,
);
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
// Apply default if no value is selected (e.g., new variable, first load)
if (!debouncedApiSearchText) {
applyDefaultIfNeeded(
mergeUniqueStrings(newNormalizedValues, newRelatedValues),
);
}
settleVariableFetch(variableData.name, 'complete');
},
[
debouncedApiSearchText,
variableData.allSelected,
variableData.name,
isDropdownOpen,
tempSelection,
setTempSelection,
applyDefaultIfNeeded,
],
);
const handleQueryError = useCallback(
(error: { message?: string } | null): void => {
if (error) {
setErrorMessage(extractErrorMessage(error));
setIsRetryableError(checkIfRetryableError(error));
}
settleVariableFetch(variableData.name, 'failure');
},
[variableData.name],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
variableFetchCycleId,
],
{
/*
* enabled if
* - we have dynamic variable source and attribute defined (ALWAYS)
* - AND
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled:
!!variableData.dynamicVariablesSource &&
!!variableData.dynamicVariablesAttribute &&
(isVariableFetching || (isVariableSettled && hasVariableFetchedOnce)),
queryFn: ({ signal }) =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
signal,
),
onSuccess: handleQuerySuccess,
onError: handleQueryError,
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}, [refetch]);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const lowerText = text.toLowerCase();
setFilteredOptionsData(
optionsData.filter((option) =>
option.toString().toLowerCase().includes(lowerText),
),
);
setRelatedValues(
originalRelatedValues.filter((val) =>
val.toLowerCase().includes(lowerText),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const selectOptions = useMemo(
() =>
getOptionsForDynamicVariable(filteredOptionsData || [], relatedValues || []),
[filteredOptionsData, relatedValues],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={handleSelectDropdownVisibilityChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// dynamic variable specific + API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
isDynamicVariable
showRetryButton={isRetryableError}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
onSearch={handleSearch}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}
export default memo(DynamicVariableInput);

View File

@@ -1,602 +0,0 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable no-nested-ternary */
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { DEBOUNCE_DELAY } from 'constants/queryBuilderFilterConfig';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import useDebounce from 'hooks/useDebounce';
import { isEmpty, isUndefined } from 'lodash-es';
import { AppState } from 'store/reducers';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { GlobalReducer } from 'types/reducer/globalTime';
import { isRetryableError as checkIfRetryableError } from 'utils/errorUtils';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
import {
areArraysEqual,
getOptionsForDynamicVariable,
uniqueValues,
} from './util';
import { getSelectValue } from './VariableItem';
import './DashboardVariableSelection.styles.scss';
interface DynamicVariableSelectionProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
arg1: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
}
function DynamicVariableSelection({
variableData,
onValueUpdate,
existingVariables,
}: DynamicVariableSelectionProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const [isRetryableError, setIsRetryableError] = useState<boolean>(true);
const [isComplete, setIsComplete] = useState<boolean>(false);
const [filteredOptionsData, setFilteredOptionsData] = useState<
(string | number | boolean)[]
>([]);
const [relatedValues, setRelatedValues] = useState<string[]>([]);
const [originalRelatedValues, setOriginalRelatedValues] = useState<string[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// Track dropdown open state for auto-checking new values
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
// Create a dependency key from all dynamic variables
const dynamicVariablesKey = useMemo(() => {
if (!existingVariables) {
return 'no_variables';
}
const dynamicVars = Object.values(existingVariables)
.filter((v) => v.type === 'DYNAMIC')
.map(
(v) => `${v.name || 'unnamed'}:${JSON.stringify(v.selectedValue || null)}`,
)
.join('|');
return dynamicVars || 'no_dynamic_variables';
}, [existingVariables]);
const [apiSearchText, setApiSearchText] = useState<string>('');
const debouncedApiSearchText = useDebounce(apiSearchText, DEBOUNCE_DELAY);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
// existing query is the query made from the other dynamic variables around this one with there current values
// for e.g. k8s.namespace.name IN ["zeus", "gene"] AND doc_op_type IN ["test"]
const existingQuery = useMemo(() => {
if (!existingVariables || !variableData.dynamicVariablesAttribute) {
return '';
}
const queryParts: string[] = [];
Object.entries(existingVariables).forEach(([, variable]) => {
// Skip the current variable being processed
if (variable.id === variableData.id) {
return;
}
// Only include dynamic variables that have selected values and are not selected as ALL
if (
variable.type === 'DYNAMIC' &&
variable.dynamicVariablesAttribute &&
variable.selectedValue &&
!isEmpty(variable.selectedValue) &&
(variable.showALLOption ? !variable.allSelected : true)
) {
const attribute = variable.dynamicVariablesAttribute;
const values = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = values
.filter((value) => value !== null && value !== undefined && value !== '')
.map((value) => value.toString());
if (validValues.length > 0) {
// Format values for query - wrap strings in quotes, keep numbers as is
const formattedValues = validValues.map((value) => {
// Check if value is a number
const numValue = Number(value);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return value; // Keep as number
}
// Escape single quotes and wrap in quotes
return `'${value.replace(/'/g, "\\'")}'`;
});
if (formattedValues.length === 1) {
queryParts.push(`${attribute} = ${formattedValues[0]}`);
} else {
queryParts.push(`${attribute} IN [${formattedValues.join(', ')}]`);
}
}
}
});
return queryParts.join(' AND ');
}, [
existingVariables,
variableData.id,
variableData.dynamicVariablesAttribute,
]);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || `variable_${variableData.id}`,
dynamicVariablesKey,
minTime,
maxTime,
debouncedApiSearchText,
],
{
enabled: variableData.type === 'DYNAMIC',
queryFn: () =>
getFieldValues(
variableData.dynamicVariablesSource?.toLowerCase() === 'all telemetry'
? undefined
: (variableData.dynamicVariablesSource?.toLowerCase() as
| 'traces'
| 'logs'
| 'metrics'),
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
minTime,
maxTime,
existingQuery,
),
onSuccess: (data) => {
const newNormalizedValues = data.data?.normalizedValues || [];
const newRelatedValues = data.data?.relatedValues || [];
if (!debouncedApiSearchText) {
setOptionsData(newNormalizedValues);
setIsComplete(data.data?.complete || false);
}
setFilteredOptionsData(newNormalizedValues);
setRelatedValues(newRelatedValues);
setOriginalRelatedValues(newRelatedValues);
// Only run auto-check logic when necessary to avoid performance issues
if (variableData.allSelected && isDropdownOpen) {
// Build the latest full list from API (normalized + related)
const latestValues = [
...new Set([
...newNormalizedValues.map((v) => v.toString()),
...newRelatedValues.map((v) => v.toString()),
]),
];
// Update temp selection to exactly reflect latest API values when ALL is active
const currentStrings = Array.isArray(tempSelection)
? tempSelection.map((v) => v.toString())
: tempSelection
? [tempSelection.toString()]
: [];
const areSame =
currentStrings.length === latestValues.length &&
latestValues.every((v) => currentStrings.includes(v));
if (!areSame) {
setTempSelection(latestValues);
}
}
},
onError: (error: any) => {
if (error) {
let message = SOMETHING_WENT_WRONG;
if (error?.message) {
message = error?.message;
} else {
message =
'Please make sure configuration is valid and you have required setup and permissions';
}
setErrorMessage(message);
// Check if error is retryable (5xx) or not (4xx)
const isRetryable = checkIfRetryableError(error);
setIsRetryableError(isRetryable);
}
},
},
);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
if (variableData.name) {
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// Build union of available options shown in dropdown (normalized + related)
const allAvailableOptionStrings = [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
];
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
}
},
[variableData, onValueUpdate, optionsData, relatedValues],
);
useEffect(() => {
if (
variableData.dynamicVariablesSource &&
variableData.dynamicVariablesAttribute
) {
refetch();
}
}, [
refetch,
variableData.dynamicVariablesSource,
variableData.dynamicVariablesAttribute,
debouncedApiSearchText,
]);
// Build a memoized list of all currently available option strings (normalized + related)
const allAvailableOptionStrings = useMemo(
() => [
...new Set([
...optionsData.map((v) => v.toString()),
...relatedValues.map((v) => v.toString()),
]),
],
[optionsData, relatedValues],
);
const handleSearch = useCallback(
(text: string) => {
if (isComplete) {
if (!text) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
return;
}
const localFilteredOptionsData: (string | number | boolean)[] = [];
optionsData.forEach((option) => {
if (option.toString().toLowerCase().includes(text.toLowerCase())) {
localFilteredOptionsData.push(option);
}
});
setFilteredOptionsData(localFilteredOptionsData);
setRelatedValues(
originalRelatedValues.filter((value) =>
value.toLowerCase().includes(text.toLowerCase()),
),
);
} else {
setApiSearchText(text);
}
},
[isComplete, optionsData, originalRelatedValues],
);
const { selectedValue } = variableData;
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const selectValue =
variableData.allSelected && enableSelectAll
? ALL_SELECT_VALUE
: selectedValueStringified;
// Add a handler for tracking temporary selection changes
const handleTempChange = useCallback(
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
const sanitizedValue = uniqueValues(value);
setTempSelection(sanitizedValue);
},
[variableData.multiSelect],
);
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Update dropdown open state for auto-checking
setIsDropdownOpen(visible);
// Initialize temp selection when opening dropdown
if (visible) {
if (isUndefined(tempSelection) && selectValue === ALL_SELECT_VALUE) {
// When ALL is selected, set selection to exactly the latest available values
const latestAll = [...allAvailableOptionStrings];
setTempSelection(latestAll);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Only call handleChange if there's actually a change in the selection
const currentValue = variableData.selectedValue;
// Helper function to check if arrays have the same elements regardless of order
const areArraysEqualIgnoreOrder = (a: any[], b: any[]): boolean => {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return areArraysEqual(sortedA, sortedB);
};
// If ALL was selected before and remains ALL after, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === allAvailableOptionStrings.length &&
allAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
const hasChanged =
tempSelection !== currentValue &&
!(
Array.isArray(tempSelection) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(tempSelection, currentValue)
);
if (hasChanged) {
handleChange(tempSelection);
}
setTempSelection(undefined);
}
// Always reset filtered data when dropdown closes, regardless of tempSelection state
if (!visible) {
setFilteredOptionsData(optionsData);
setRelatedValues(originalRelatedValues);
setApiSearchText('');
}
};
useEffect(
() => (): void => {
// Cleanup on unmount
setTempSelection(undefined);
setFilteredOptionsData([]);
setRelatedValues([]);
setApiSearchText('');
},
[],
);
// eslint-disable-next-line sonarjs/cognitive-complexity
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
let value = tempSelection || selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else if (variableData.allSelected) {
// If ALL is selected but no stored values, derive from available options
// This handles the case where we don't store values in localStorage for ALL
value = allAvailableOptionStrings;
} else {
value = optionsData;
}
} else if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData?.[0];
}
}
return value;
}
if (isEmpty(selectedValue)) {
if (variableData.defaultValue) {
return variableData.defaultValue;
}
return optionsData[0]?.toString();
}
return selectedValue;
}, [
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
variableData.allSelected,
selectedValue,
tempSelection,
optionsData,
allAvailableOptionStrings,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
return (
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
<Tooltip title={variableData.description}>
<InfoCircleOutlined className="info-icon" />
</Tooltip>
)}
</Typography.Text>
<div className="variable-value">
{variableData.multiSelect ? (
<CustomMultiSelect
key={variableData.id}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
defaultValue={variableData.defaultValue}
onChange={handleTempChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
getPopupContainer={popupContainer}
value={
(tempSelection || selectValue) === ALL_SELECT_VALUE
? 'ALL'
: tempSelection || selectValue
}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => {
const maxDisplayValues = 10;
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
const hasMore = omittedValues.length > maxDisplayValues;
const tooltipText =
valuesToShow.map(({ value }) => value).join(', ') +
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
onSearch={handleSearch}
onRetry={(): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
isDynamicVariable
showRetryButton={isRetryableError}
/>
) : (
<CustomSelect
key={variableData.id}
onChange={handleChange}
bordered={false}
placeholder="Select value"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={getOptionsForDynamicVariable(
filteredOptionsData || [],
relatedValues || [],
)}
value={selectValue}
defaultValue={variableData.defaultValue}
errorMessage={errorMessage}
onSearch={handleSearch}
// eslint-disable-next-line sonarjs/no-identical-functions
onRetry={(): void => {
setErrorMessage(null);
setIsRetryableError(true);
refetch();
}}
showIncompleteDataMessage={!isComplete && filteredOptionsData.length > 0}
isDynamicVariable
showRetryButton={isRetryableError}
/>
)}
</div>
</div>
);
}
export default DynamicVariableSelection;

View File

@@ -0,0 +1,246 @@
import { memo, useCallback, useMemo, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useVariableFetchState } from 'hooks/dashboard/useVariableFetchState';
import sortValues from 'lib/dashboardVariables/sortVariableValues';
import { isArray, isEmpty, isString } from 'lodash-es';
import { AppState } from 'store/reducers';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { variablePropsToPayloadVariables } from '../utils';
import SelectVariableInput from './SelectVariableInput';
import { useDashboardVariableSelectHelper } from './useDashboardVariableSelectHelper';
import { areArraysEqual, settleVariableFetch } from './util';
import { VariableItemProps } from './VariableItem';
import { queryVariableSelectStrategy } from './variableSelectStrategy/queryVariableSelectStrategy';
type QueryVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'existingVariables' | 'onValueUpdate'
>;
function QueryVariableInput({
variableData,
existingVariables,
onValueUpdate,
}: QueryVariableInputProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [errorMessage, setErrorMessage] = useState<null | string>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const {
variableFetchCycleId,
isVariableSettled,
isVariableFetching,
hasVariableFetchedOnce,
isVariableWaitingForDependencies,
variableDependencyWaitMessage,
} = useVariableFetchState(variableData.name || '');
const {
tempSelection,
setTempSelection,
value,
defaultValue,
enableSelectAll,
onChange,
onDropdownVisibleChange,
handleClear,
applyDefaultIfNeeded,
} = useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy: queryVariableSelectStrategy,
});
const getOptions = useCallback(
// eslint-disable-next-line sonarjs/cognitive-complexity
(variablesRes: VariableResponseProps | null): void => {
try {
setErrorMessage(null);
if (
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
const newOptionsData = sortValues(
variablesRes?.variableValues,
variableData.sort,
);
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
let valueNotInList = false;
if (isArray(variableData.selectedValue)) {
variableData.selectedValue.forEach((val) => {
if (!newOptionsData.includes(val)) {
valueNotInList = true;
}
});
} else if (
isString(variableData.selectedValue) &&
!newOptionsData.includes(variableData.selectedValue)
) {
valueNotInList = true;
}
if (variableData.name && (valueNotInList || variableData.allSelected)) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(
variableData.name,
variableData.id,
newOptionsData,
true,
);
}
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(newOptionsData.map((option) => option.toString()));
}
} else {
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (
variableData.name &&
variableData.id &&
!isEmpty(variableData.selectedValue)
) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
setOptionsData(newOptionsData);
// Apply default if no value is selected (e.g., new variable, first load)
applyDefaultIfNeeded(newOptionsData);
}
}
} catch (e) {
console.error(e);
}
},
[
variableData,
optionsData,
onValueUpdate,
tempSelection,
setTempSelection,
applyDefaultIfNeeded,
],
);
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
`${minTime}`,
`${maxTime}`,
variableFetchCycleId,
],
{
/*
* enabled if
* - we're either still fetching variable options
* - OR
* - if variable is in idle state and we have already fetched options for it
**/
enabled: isVariableFetching || (isVariableSettled && hasVariableFetchedOnce),
queryFn: ({ signal }) =>
dashboardVariablesQuery(
{
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
},
signal,
),
refetchOnWindowFocus: false,
onSuccess: (response) => {
getOptions(response.payload);
settleVariableFetch(variableData.name, 'complete');
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
}
settleVariableFetch(variableData.name, 'failure');
},
},
);
const handleRetry = useCallback((): void => {
setErrorMessage(null);
refetch();
}, [refetch]);
const selectOptions = useMemo(
() =>
optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
})),
[optionsData],
);
return (
<SelectVariableInput
variableId={variableData.id}
options={selectOptions}
value={value}
onChange={onChange}
onDropdownVisibleChange={onDropdownVisibleChange}
onClear={handleClear}
enableSelectAll={enableSelectAll}
defaultValue={defaultValue}
isMultiSelect={variableData.multiSelect}
// query variable specific, API related props
loading={isLoading}
errorMessage={errorMessage}
onRetry={handleRetry}
waiting={isVariableWaitingForDependencies}
waitingMessage={variableDependencyWaitMessage}
/>
);
}
export default memo(QueryVariableInput);

View File

@@ -0,0 +1,149 @@
import { memo, useMemo } from 'react';
import { orange } from '@ant-design/colors';
import { WarningOutlined } from '@ant-design/icons';
import { Popover, Tooltip, Typography } from 'antd';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { OptionData } from 'components/NewSelect/types';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE } from '../utils';
import { SelectItemStyle } from './styles';
const errorIconStyle = { margin: '0 0.5rem' };
interface SelectVariableInputProps {
variableId: string;
options: OptionData[];
value: string | string[] | undefined;
enableSelectAll: boolean;
isMultiSelect: boolean;
onChange: (value: string | string[]) => void;
onClear: () => void;
defaultValue?: string | string[];
onDropdownVisibleChange?: (visible: boolean) => void;
loading?: boolean;
errorMessage?: string | null;
onRetry?: () => void;
isDynamicVariable?: boolean;
showRetryButton?: boolean;
showIncompleteDataMessage?: boolean;
onSearch?: (searchTerm: string) => void;
waiting?: boolean;
waitingMessage?: string;
}
const MAX_TAG_DISPLAY_VALUES = 10;
export const renderMaxTagPlaceholder = (
omittedValues: { label?: React.ReactNode; value?: string | number }[],
): JSX.Element => {
const valuesToShow = omittedValues.slice(0, MAX_TAG_DISPLAY_VALUES);
const hasMore = omittedValues.length > MAX_TAG_DISPLAY_VALUES;
const tooltipText =
valuesToShow.map(({ value: v }) => v ?? '').join(', ') +
(hasMore ? ` + ${omittedValues.length - MAX_TAG_DISPLAY_VALUES} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
};
function SelectVariableInput({
variableId,
options,
value,
onChange,
onDropdownVisibleChange,
onClear,
loading,
errorMessage,
onRetry,
enableSelectAll,
isMultiSelect,
defaultValue,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
waitingMessage,
}: SelectVariableInputProps): JSX.Element {
const commonProps = useMemo(
() => ({
// main props
key: variableId,
value,
defaultValue,
// setup props
placeholder: 'Select value',
className: 'variable-select',
popupClassName: 'dropdown-styles',
getPopupContainer: popupContainer,
showSearch: true,
bordered: false,
// changing props
'data-testid': 'variable-select',
onChange,
loading,
waitingMessage,
style: SelectItemStyle,
options,
errorMessage,
onRetry,
// dynamic variable only props
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
}),
[
variableId,
defaultValue,
onChange,
loading,
waitingMessage,
options,
value,
errorMessage,
onRetry,
isDynamicVariable,
showRetryButton,
showIncompleteDataMessage,
onSearch,
],
);
return (
<>
{isMultiSelect ? (
<CustomMultiSelect
{...commonProps}
placement="bottomLeft"
maxTagCount={2}
onDropdownVisibleChange={onDropdownVisibleChange}
maxTagPlaceholder={renderMaxTagPlaceholder}
onClear={onClear}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={value !== ALL_SELECT_VALUE}
/>
) : (
<CustomSelect {...commonProps} />
)}
{errorMessage && (
<span style={errorIconStyle}>
<Popover placement="top" content={<Typography>{errorMessage}</Typography>}>
<WarningOutlined style={{ color: orange[5] }} />
</Popover>
</span>
)}
</>
);
}
export default memo(SelectVariableInput);

View File

@@ -0,0 +1,85 @@
import { memo, useCallback, useRef, useState } from 'react';
import { Input, InputRef } from 'antd';
import { VariableItemProps } from './VariableItem';
type TextboxVariableInputProps = Pick<
VariableItemProps,
'variableData' | 'onValueUpdate'
>;
function TextboxVariableInput({
variableData,
onValueUpdate,
}: TextboxVariableInputProps): JSX.Element {
const handleChange = useCallback(
(inputValue: string | string[]): void => {
if (inputValue === variableData.selectedValue) {
return;
}
if (variableData.name) {
onValueUpdate(variableData.name, variableData.id, inputValue, false);
}
},
[
onValueUpdate,
variableData.id,
variableData.name,
variableData.selectedValue,
],
);
const textboxInputRef = useRef<InputRef>(null);
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const handleInputOnChange = useCallback(
(event: React.ChangeEvent<HTMLInputElement>) => {
setTextboxInputValue(event.target.value);
},
[setTextboxInputValue],
);
const handleInputOnBlur = useCallback(
(event: React.FocusEvent<HTMLInputElement>): void => {
const value = event.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
handleChange(variableData.defaultValue.toString());
} else {
handleChange(value);
}
},
[handleChange, variableData.defaultValue],
);
const handleInputOnKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>): void => {
if (event.key === 'Enter') {
textboxInputRef.current?.blur();
}
},
[],
);
return (
<Input
key={variableData.id}
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
onChange={handleInputOnChange}
onBlur={handleInputOnBlur}
onKeyDown={handleInputOnKeyDown}
/>
);
}
export default memo(TextboxVariableInput);

View File

@@ -47,14 +47,6 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -69,14 +61,6 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -92,14 +76,6 @@ describe('VariableItem', () => {
variableData={mockVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -133,14 +109,6 @@ describe('VariableItem', () => {
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -163,14 +131,6 @@ describe('VariableItem', () => {
variableData={customVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);
@@ -185,14 +145,6 @@ describe('VariableItem', () => {
variableData={mockCustomVariableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={(): void => {}}
dependencyData={{
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
}}
/>
</MockQueryClientProvider>,
);

View File

@@ -1,529 +1,71 @@
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable jsx-a11y/click-events-have-key-events */
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable no-nested-ternary */
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useQuery } from 'react-query';
import { useSelector } from 'react-redux';
import { orange } from '@ant-design/colors';
import { InfoCircleOutlined, WarningOutlined } from '@ant-design/icons';
import { Input, InputRef, Popover, Tooltip, Typography } from 'antd';
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
import { CustomMultiSelect, CustomSelect } from 'components/NewSelect';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { commaValuesParser } from 'lib/dashbaordVariables/customCommaValuesParser';
import sortValues from 'lib/dashbaordVariables/sortVariableValues';
import { debounce, isArray, isEmpty, isString } from 'lodash-es';
import { AppState } from 'store/reducers';
import { memo } from 'react';
import { InfoCircleOutlined } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { VariableResponseProps } from 'types/api/dashboard/variables/query';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { ALL_SELECT_VALUE, variablePropsToPayloadVariables } from '../utils';
import { SelectItemStyle } from './styles';
import { areArraysEqual, checkAPIInvocation, IDependencyData } from './util';
import CustomVariableInput from './CustomVariableInput';
import DynamicVariableInput from './DynamicVariableInput';
import QueryVariableInput from './QueryVariableInput';
import TextboxVariableInput from './TextboxVariableInput';
import './DashboardVariableSelection.styles.scss';
interface VariableItemProps {
export interface VariableItemProps {
variableData: IDashboardVariable;
existingVariables: Record<string, IDashboardVariable>;
onValueUpdate: (
name: string,
id: string,
arg1: IDashboardVariable['selectedValue'],
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
variablesToGetUpdated: string[];
setVariablesToGetUpdated: React.Dispatch<React.SetStateAction<string[]>>;
dependencyData: IDependencyData | null;
}
export const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,
): string | string[] | undefined => {
if (Array.isArray(selectedValue)) {
if (!variableData.multiSelect && selectedValue.length === 1) {
return selectedValue[0]?.toString();
}
return selectedValue.map((item) => item.toString());
}
return selectedValue?.toString();
};
// eslint-disable-next-line sonarjs/cognitive-complexity
function VariableItem({
variableData,
existingVariables,
onValueUpdate,
variablesToGetUpdated,
setVariablesToGetUpdated,
dependencyData,
existingVariables,
}: VariableItemProps): JSX.Element {
const [optionsData, setOptionsData] = useState<(string | number | boolean)[]>(
[],
);
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
// Local state for textbox input to ensure smooth editing experience
const [textboxInputValue, setTextboxInputValue] = useState<string>(
(variableData.selectedValue?.toString() ||
variableData.defaultValue?.toString()) ??
'',
);
const [isTextboxFocused, setIsTextboxFocused] = useState<boolean>(false);
const textboxInputRef = useRef<InputRef>(null);
const { maxTime, minTime } = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const validVariableUpdate = (): boolean => {
if (!variableData.name) {
return false;
}
// variableData.name is present as the top element or next in the queue - variablesToGetUpdated
return Boolean(
variablesToGetUpdated.length &&
variablesToGetUpdated[0] === variableData.name,
);
};
const [errorMessage, setErrorMessage] = useState<null | string>(null);
// eslint-disable-next-line sonarjs/cognitive-complexity
const getOptions = (variablesRes: VariableResponseProps | null): void => {
if (variablesRes && variableData.type === 'QUERY') {
try {
setErrorMessage(null);
if (
variablesRes?.variableValues &&
Array.isArray(variablesRes?.variableValues)
) {
const newOptionsData = sortValues(
variablesRes?.variableValues,
variableData.sort,
);
const oldOptionsData = sortValues(optionsData, variableData.sort) as never;
if (!areArraysEqual(newOptionsData, oldOptionsData)) {
/* eslint-disable no-useless-escape */
let valueNotInList = false;
if (isArray(variableData.selectedValue)) {
variableData.selectedValue.forEach((val) => {
const isUsed = newOptionsData.includes(val);
if (!isUsed) {
valueNotInList = true;
}
});
} else if (isString(variableData.selectedValue)) {
const isUsed = newOptionsData.includes(variableData.selectedValue);
if (!isUsed) {
valueNotInList = true;
}
}
// variablesData.allSelected is added for the case where on change of options we need to update the
// local storage
if (
variableData.type === 'QUERY' &&
variableData.name &&
(validVariableUpdate() || valueNotInList || variableData.allSelected)
) {
if (
variableData.allSelected &&
variableData.multiSelect &&
variableData.showALLOption
) {
onValueUpdate(variableData.name, variableData.id, newOptionsData, true);
// Update tempSelection to maintain ALL state when dropdown is open
if (tempSelection !== undefined) {
setTempSelection(newOptionsData.map((option) => option.toString()));
}
} else {
const value = variableData.selectedValue;
let allSelected = false;
if (variableData.multiSelect) {
const { selectedValue } = variableData;
allSelected =
newOptionsData.length > 0 &&
Array.isArray(selectedValue) &&
newOptionsData.every((option) => selectedValue.includes(option));
}
if (variableData && variableData?.name && variableData?.id) {
onValueUpdate(variableData.name, variableData.id, value, allSelected);
}
}
}
setOptionsData(newOptionsData);
} else {
setVariablesToGetUpdated((prev) =>
prev.filter((name) => name !== variableData.name),
);
}
}
} catch (e) {
console.error(e);
}
} else if (variableData.type === 'CUSTOM') {
const optionsData = sortValues(
commaValuesParser(variableData.customValue || ''),
variableData.sort,
) as never;
setOptionsData(optionsData);
}
};
const { isLoading, refetch } = useQuery(
[
REACT_QUERY_KEY.DASHBOARD_BY_ID,
variableData.name || '',
`${minTime}`,
`${maxTime}`,
JSON.stringify(dependencyData?.order),
],
{
enabled:
variableData &&
variableData.type === 'QUERY' &&
checkAPIInvocation(
variablesToGetUpdated,
variableData,
dependencyData?.parentDependencyGraph,
),
queryFn: () =>
dashboardVariablesQuery({
query: variableData.queryValue || '',
variables: variablePropsToPayloadVariables(existingVariables),
}),
refetchOnWindowFocus: false,
onSuccess: (response) => {
getOptions(response.payload);
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
onError: (error: {
details: {
error: string;
};
}) => {
const { details } = error;
if (details.error) {
let message = details.error;
if ((details.error ?? '').toString().includes('Syntax error:')) {
message =
'Please make sure query is valid and dependent variables are selected';
}
setErrorMessage(message);
}
setVariablesToGetUpdated((prev) =>
prev.filter((v) => v !== variableData.name),
);
},
},
);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
if (variableData.name) {
// Check if ALL is effectively selected by comparing with available options
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
// For ALL selection, pass null to avoid storing values
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
}
},
[
variableData.multiSelect,
variableData.selectedValue,
variableData.name,
variableData.id,
onValueUpdate,
optionsData,
variableData.showALLOption,
],
);
// Add a handler for tracking temporary selection changes
const handleTempChange = (inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(value);
};
// Handle dropdown visibility changes
const handleDropdownVisibleChange = (visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// Call handleChange with the temporarily stored selection
handleChange(tempSelection);
setTempSelection(undefined);
}
};
// do not debounce the above function as we do not need debounce in select variables
const debouncedHandleChange = debounce(handleChange, 500);
const { selectedValue } = variableData;
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const selectValue =
variableData.allSelected && enableSelectAll
? 'ALL'
: selectedValueStringified;
// Apply default value on first render if no selection exists
// eslint-disable-next-line sonarjs/cognitive-complexity
const finalSelectedValues = useMemo(() => {
if (variableData.multiSelect) {
let value = tempSelection || selectedValue;
if (isEmpty(value)) {
if (variableData.showALLOption) {
if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData;
}
} else if (variableData.defaultValue) {
value = variableData.defaultValue;
} else {
value = optionsData?.[0];
}
}
return value;
}
if (isEmpty(selectedValue)) {
if (variableData.defaultValue) {
return variableData.defaultValue;
}
return optionsData[0]?.toString();
}
return selectedValue;
}, [
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
selectedValue,
tempSelection,
optionsData,
]);
useEffect(() => {
if (
(variableData.multiSelect && !(tempSelection || selectValue)) ||
isEmpty(selectValue)
) {
handleChange(finalSelectedValues as string[] | string);
}
}, [
finalSelectedValues,
handleChange,
selectValue,
tempSelection,
variableData.multiSelect,
]);
useEffect(() => {
// Fetch options for CUSTOM Type
if (variableData.type === 'CUSTOM') {
getOptions(null);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [variableData.type, variableData.customValue]);
const { name, description, type: variableType } = variableData;
return (
<div className={`variable-item${isTextboxFocused ? ' focused' : ''}`}>
<div className="variable-item">
<Typography.Text className="variable-name" ellipsis>
${variableData.name}
{variableData.description && (
<Tooltip title={variableData.description}>
${name}
{description && (
<Tooltip title={description}>
<InfoCircleOutlined className="info-icon" />
</Tooltip>
)}
</Typography.Text>
<div className="variable-value">
{variableData.type === 'TEXTBOX' ? (
<Input
ref={textboxInputRef}
placeholder="Enter value"
data-testid={`variable-textbox-${variableData.id}`}
bordered={false}
value={textboxInputValue}
title={textboxInputValue}
onChange={(e): void => {
setTextboxInputValue(e.target.value);
}}
onFocus={(): void => {
setIsTextboxFocused(true);
}}
onBlur={(e): void => {
setIsTextboxFocused(false);
const value = e.target.value.trim();
// If empty, reset to default value
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
}}
onKeyDown={(e): void => {
if (e.key === 'Enter') {
const value = textboxInputValue.trim();
if (!value && variableData.defaultValue) {
setTextboxInputValue(variableData.defaultValue.toString());
debouncedHandleChange(variableData.defaultValue.toString());
} else {
debouncedHandleChange(value);
}
textboxInputRef.current?.blur();
}
}}
{variableType === 'TEXTBOX' && (
<TextboxVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
/>
) : (
optionsData &&
(variableData.multiSelect ? (
<CustomMultiSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleTempChange}
bordered={false}
placeholder="Select value"
placement="bottomLeft"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
maxTagCount={2}
getPopupContainer={popupContainer}
value={tempSelection || selectValue}
onDropdownVisibleChange={handleDropdownVisibleChange}
errorMessage={errorMessage}
// eslint-disable-next-line react/no-unstable-nested-components
maxTagPlaceholder={(omittedValues): JSX.Element => {
const maxDisplayValues = 10;
const valuesToShow = omittedValues.slice(0, maxDisplayValues);
const hasMore = omittedValues.length > maxDisplayValues;
const tooltipText =
valuesToShow.map(({ value }) => value).join(', ') +
(hasMore ? ` + ${omittedValues.length - maxDisplayValues} more` : '');
return (
<Tooltip title={tooltipText}>
<span>+ {omittedValues.length} </span>
</Tooltip>
);
}}
onClear={(): void => {
handleChange([]);
}}
enableAllSelection={enableSelectAll}
maxTagTextLength={30}
allowClear={selectValue !== ALL_SELECT_VALUE && selectValue !== 'ALL'}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
) : (
<CustomSelect
key={
selectValue && Array.isArray(selectValue)
? selectValue.join(' ')
: selectValue || variableData.id
}
defaultValue={variableData.defaultValue || selectValue}
onChange={handleChange}
bordered={false}
placeholder="Select value"
style={SelectItemStyle}
loading={isLoading}
showSearch
data-testid="variable-select"
className="variable-select"
popupClassName="dropdown-styles"
getPopupContainer={popupContainer}
options={optionsData.map((option) => ({
label: option.toString(),
value: option.toString(),
}))}
value={selectValue}
errorMessage={errorMessage}
onRetry={(): void => {
setErrorMessage(null);
refetch();
}}
/>
))
)}
{variableData.type !== 'TEXTBOX' && errorMessage && (
<span style={{ margin: '0 0.5rem' }}>
<Popover
placement="top"
content={<Typography>{errorMessage}</Typography>}
>
<WarningOutlined style={{ color: orange[5] }} />
</Popover>
</span>
{variableType === 'CUSTOM' && (
<CustomVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
/>
)}
{variableType === 'QUERY' && (
<QueryVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
/>
)}
{variableType === 'DYNAMIC' && (
<DynamicVariableInput
variableData={variableData}
onValueUpdate={onValueUpdate}
existingVariables={existingVariables}
/>
)}
</div>
</div>

View File

@@ -5,7 +5,20 @@ import * as ReactRedux from 'react-redux';
import { fireEvent, render, screen } from '@testing-library/react';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DynamicVariableSelection';
import DynamicVariableInput from '../DynamicVariableInput';
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
useVariableFetchState: (): Record<string, unknown> => ({
variableFetchCycleId: 0,
variableFetchState: 'loading',
isVariableSettled: false,
isVariableFetching: true,
hasVariableFetchedOnce: false,
isVariableWaitingForDependencies: false,
variableDependencyWaitMessage: '',
}),
}));
// Don't mock the components - use real ones
@@ -54,7 +67,7 @@ const mockApiResponse = {
// Mock scrollIntoView since it's not available in JSDOM
window.HTMLElement.prototype.scrollIntoView = jest.fn();
describe('DynamicVariableSelection Component', () => {
describe('DynamicVariableInput Component', () => {
const mockOnValueUpdate = jest.fn();
const mockDynamicVariableData: IDashboardVariable = {
@@ -108,18 +121,13 @@ describe('DynamicVariableSelection Component', () => {
it('renders with single select variable correctly', () => {
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify component renders correctly
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// Verify the selected value is displayed
const selectedItem = screen.getByRole('combobox');
expect(selectedItem).toBeInTheDocument();
@@ -136,18 +144,13 @@ describe('DynamicVariableSelection Component', () => {
};
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={multiSelectWithAllSelected}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify variable name is rendered
expect(
screen.getByText(`$${multiSelectWithAllSelected.name}`),
).toBeInTheDocument();
// In ALL selected mode, there should be an "ALL" text element
expect(screen.getByText('ALL')).toBeInTheDocument();
});
@@ -164,18 +167,13 @@ describe('DynamicVariableSelection Component', () => {
});
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify component renders in loading state
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// Open dropdown to see loading text
const selectElement = screen.getByRole('combobox');
fireEvent.mouseDown(selectElement);
@@ -199,18 +197,13 @@ describe('DynamicVariableSelection Component', () => {
});
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the component renders
expect(
screen.getByText(`$${mockDynamicVariableData.name}`),
).toBeInTheDocument();
// For error states, we should check that error handling is in place
// Without opening the dropdown as the error message might be handled differently
expect(ReactQuery.useQuery).toHaveBeenCalled();
@@ -219,7 +212,7 @@ describe('DynamicVariableSelection Component', () => {
it('makes API call to fetch variable values', () => {
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={mockDynamicVariableData}
existingVariables={mockExistingVariables}
onValueUpdate={mockOnValueUpdate}
@@ -235,9 +228,12 @@ describe('DynamicVariableSelection Component', () => {
'2023-01-01T00:00:00Z', // minTime from useSelector mock
'2023-01-02T00:00:00Z', // maxTime from useSelector mock
'',
'Traces',
'service.name',
0, // variableFetchCycleId
],
expect.objectContaining({
enabled: true, // Type is 'DYNAMIC'
enabled: true, // isVariableFetching is true from mock
queryFn: expect.any(Function),
onSuccess: expect.any(Function),
onError: expect.any(Function),
@@ -255,16 +251,13 @@ describe('DynamicVariableSelection Component', () => {
};
render(
<DynamicVariableSelection
<DynamicVariableInput
variableData={customVariable}
existingVariables={{ ...mockExistingVariables, custom1: customVariable }}
onValueUpdate={mockOnValueUpdate}
/>,
);
// Verify the component correctly displays the selected value
expect(screen.getByText(`$${customVariable.name}`)).toBeInTheDocument();
// Find the selection item in the component using data-testid
const selectElement = screen.getByTestId('variable-select');
expect(selectElement).toBeInTheDocument();

View File

@@ -8,14 +8,6 @@ import '@testing-library/jest-dom/extend-expect';
import VariableItem from '../VariableItem';
const mockOnValueUpdate = jest.fn();
const mockSetVariablesToGetUpdated = jest.fn();
const baseDependencyData = {
order: [],
graph: {},
parentDependencyGraph: {},
hasCycle: false,
};
const TEST_VARIABLE_ID = 'test_variable';
const VARIABLE_SELECT_TESTID = 'variable-select';
@@ -31,9 +23,6 @@ const renderVariableItem = (
variableData={variableData}
existingVariables={{}}
onValueUpdate={mockOnValueUpdate}
variablesToGetUpdated={[]}
setVariablesToGetUpdated={mockSetVariablesToGetUpdated}
dependencyData={baseDependencyData}
/>
</MockQueryClientProvider>,
);
@@ -63,10 +52,10 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('option1')).toBeInTheDocument();
expect(await screen.findByText('option1')).toBeInTheDocument();
});
test('should show placeholder when no previous and no default', async () => {
test('should auto-select first option when no previous and no default', async () => {
const variable: IDashboardVariable = {
id: TEST_VARIABLE_ID,
name: TEST_VARIABLE_NAME,
@@ -85,7 +74,8 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('Select value')).toBeInTheDocument();
// With the new variable select strategy, the first option is auto-selected
expect(await screen.findByText('option1')).toBeInTheDocument();
});
});
@@ -110,7 +100,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('ALL')).toBeInTheDocument();
expect(await screen.findByText('ALL')).toBeInTheDocument();
});
});
@@ -134,7 +124,7 @@ describe('VariableItem Default Value Selection Behavior', () => {
expect(screen.getByTestId(VARIABLE_SELECT_TESTID)).toBeInTheDocument();
});
expect(screen.getByText('Select value')).toBeInTheDocument();
expect(await screen.findByText('Select value')).toBeInTheDocument();
});
});
});

View File

@@ -2,14 +2,12 @@ import {
buildDependencies,
buildDependencyGraph,
buildParentDependencyGraph,
checkAPIInvocation,
onUpdateVariableNode,
VariableGraph,
} from '../util';
import {
buildDependenciesMock,
buildGraphMock,
checkAPIInvocationMock,
onUpdateVariableNodeMock,
} from './mock';
@@ -72,97 +70,6 @@ describe('dashboardVariables - utilities and processors', () => {
});
});
describe('checkAPIInvocation', () => {
const {
variablesToGetUpdated,
variableData,
parentDependencyGraph,
} = checkAPIInvocationMock;
const mockRootElement = {
name: 'deployment_environment',
key: '036a47cd-9ffc-47de-9f27-0329198964a8',
id: '036a47cd-9ffc-47de-9f27-0329198964a8',
modificationUUID: '5f71b591-f583-497c-839d-6a1590c3f60f',
selectedValue: 'production',
type: 'QUERY',
// ... other properties omitted for brevity
} as any;
describe('edge cases', () => {
it('should return false when variableData is empty', () => {
expect(
checkAPIInvocation(
variablesToGetUpdated,
variableData,
parentDependencyGraph,
),
).toBeFalsy();
});
it('should return true when parentDependencyGraph is empty', () => {
expect(
checkAPIInvocation(variablesToGetUpdated, variableData, {}),
).toBeFalsy();
});
});
describe('variable sequences', () => {
it('should return true for valid sequence', () => {
expect(
checkAPIInvocation(
['k8s_node_name', 'k8s_namespace_name'],
variableData,
parentDependencyGraph,
),
).toBeTruthy();
});
it('should return false for invalid sequence', () => {
expect(
checkAPIInvocation(
['k8s_cluster_name', 'k8s_node_name', 'k8s_namespace_name'],
variableData,
parentDependencyGraph,
),
).toBeFalsy();
});
it('should return false when variableData is not in sequence', () => {
expect(
checkAPIInvocation(
['deployment_environment', 'service_name', 'endpoint'],
variableData,
parentDependencyGraph,
),
).toBeFalsy();
});
});
describe('root element behavior', () => {
it('should return true for valid root element sequence', () => {
expect(
checkAPIInvocation(
[
'deployment_environment',
'service_name',
'endpoint',
'http_status_code',
],
mockRootElement,
parentDependencyGraph,
),
).toBeTruthy();
});
it('should return true for empty variablesToGetUpdated array', () => {
expect(
checkAPIInvocation([], mockRootElement, parentDependencyGraph),
).toBeTruthy();
});
});
});
describe('Graph Building Utilities', () => {
const { graph } = buildGraphMock;
const { variables } = buildDependenciesMock;
@@ -223,10 +130,86 @@ describe('dashboardVariables - utilities and processors', () => {
},
hasCycle: false,
cycleNodes: undefined,
transitiveDescendants: {
deployment_environment: ['service_name', 'endpoint', 'http_status_code'],
endpoint: ['http_status_code'],
environment: [],
http_status_code: [],
k8s_cluster_name: ['k8s_node_name', 'k8s_namespace_name'],
k8s_namespace_name: [],
k8s_node_name: ['k8s_namespace_name'],
service_name: ['endpoint', 'http_status_code'],
},
};
expect(buildDependencyGraph(graph)).toEqual(expected);
});
it('should return empty transitiveDescendants for an empty graph', () => {
const result = buildDependencyGraph({});
expect(result.transitiveDescendants).toEqual({});
expect(result.order).toEqual([]);
expect(result.hasCycle).toBe(false);
});
it('should compute transitiveDescendants for a linear chain (a -> b -> c)', () => {
const linearGraph: VariableGraph = {
a: ['b'],
b: ['c'],
c: [],
};
const result = buildDependencyGraph(linearGraph);
expect(result.transitiveDescendants).toEqual({
a: ['b', 'c'],
b: ['c'],
c: [],
});
});
it('should compute transitiveDescendants for a diamond dependency (a -> b, a -> c, b -> d, c -> d)', () => {
const diamondGraph: VariableGraph = {
a: ['b', 'c'],
b: ['d'],
c: ['d'],
d: [],
};
const result = buildDependencyGraph(diamondGraph);
expect(result.transitiveDescendants.a).toEqual(
expect.arrayContaining(['b', 'c', 'd']),
);
expect(result.transitiveDescendants.a).toHaveLength(3);
expect(result.transitiveDescendants.b).toEqual(['d']);
expect(result.transitiveDescendants.c).toEqual(['d']);
expect(result.transitiveDescendants.d).toEqual([]);
});
it('should handle disconnected components in transitiveDescendants', () => {
const disconnectedGraph: VariableGraph = {
a: ['b'],
b: [],
x: ['y'],
y: [],
};
const result = buildDependencyGraph(disconnectedGraph);
expect(result.transitiveDescendants.a).toEqual(['b']);
expect(result.transitiveDescendants.b).toEqual([]);
expect(result.transitiveDescendants.x).toEqual(['y']);
expect(result.transitiveDescendants.y).toEqual([]);
});
it('should return empty transitiveDescendants for all leaf nodes', () => {
const leafOnlyGraph: VariableGraph = {
a: [],
b: [],
c: [],
};
const result = buildDependencyGraph(leafOnlyGraph);
expect(result.transitiveDescendants).toEqual({
a: [],
b: [],
c: [],
});
});
});
describe('buildDependencies', () => {

View File

@@ -1,36 +1,3 @@
/* eslint-disable sonarjs/no-duplicate-string */
export const checkAPIInvocationMock = {
variablesToGetUpdated: [],
variableData: {
name: 'k8s_node_name',
key: '4d71d385-beaf-4434-8dbf-c62be68049fc',
allSelected: false,
customValue: '',
description: '',
id: '4d71d385-beaf-4434-8dbf-c62be68049fc',
modificationUUID: '77233d3c-96d7-4ccb-aa9d-11b04d563068',
multiSelect: false,
order: 6,
queryValue:
"SELECT JSONExtractString(labels, 'k8s_node_name') AS k8s_node_name\nFROM signoz_metrics.distributed_time_series_v4_1day\nWHERE metric_name = 'k8s_node_cpu_time' AND JSONExtractString(labels, 'k8s_cluster_name') = {{.k8s_cluster_name}}\nGROUP BY k8s_node_name",
selectedValue: 'gke-signoz-saas-si-consumer-bsc-e2sd4-a6d430fa-gvm2',
showALLOption: false,
sort: 'DISABLED',
textboxValue: '',
type: 'QUERY',
},
parentDependencyGraph: {
deployment_environment: [],
service_name: ['deployment_environment'],
endpoint: ['deployment_environment', 'service_name'],
http_status_code: ['endpoint'],
k8s_cluster_name: [],
environment: [],
k8s_node_name: ['k8s_cluster_name'],
k8s_namespace_name: ['k8s_cluster_name', 'k8s_node_name'],
},
} as any;
export const onUpdateVariableNodeMock = {
nodeToUpdate: 'deployment_environment',
graph: {

View File

@@ -0,0 +1,252 @@
import { useCallback, useMemo, useState } from 'react';
import { isEmpty } from 'lodash-es';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { ALL_SELECT_VALUE } from '../utils';
import { areArraysEqual, getSelectValue } from './util';
import { VariableSelectStrategy } from './variableSelectStrategy/variableSelectStrategyTypes';
import {
areArraysEqualIgnoreOrder,
uniqueValues,
} from './variableSelectStrategy/variableSelectStrategyUtils';
interface UseDashboardVariableSelectHelperParams {
variableData: IDashboardVariable;
optionsData: (string | number | boolean)[];
onValueUpdate: (
name: string,
id: string,
value: IDashboardVariable['selectedValue'],
allSelected: boolean,
haveCustomValuesSelected?: boolean,
) => void;
strategy: VariableSelectStrategy;
/** Override for all available option strings (default: optionsData.map(String)) */
allAvailableOptionStrings?: string[];
}
interface UseDashboardVariableSelectHelperReturn {
// State
tempSelection: string | string[] | undefined;
setTempSelection: React.Dispatch<
React.SetStateAction<string | string[] | undefined>
>;
value: string | string[] | undefined;
defaultValue: string | string[] | undefined;
// Derived values
enableSelectAll: boolean;
// Handlers
onChange: (value: string | string[]) => void;
onDropdownVisibleChange: (visible: boolean) => void;
handleClear: () => void;
// Default value helpers
applyDefaultIfNeeded: (
overrideOptions?: (string | number | boolean)[],
) => void;
}
// eslint-disable-next-line sonarjs/cognitive-complexity
export function useDashboardVariableSelectHelper({
variableData,
optionsData,
onValueUpdate,
strategy,
allAvailableOptionStrings,
}: UseDashboardVariableSelectHelperParams): UseDashboardVariableSelectHelperReturn {
const { selectedValue } = variableData;
const [tempSelection, setTempSelection] = useState<
string | string[] | undefined
>(undefined);
const selectedValueStringified = useMemo(
() => getSelectValue(selectedValue, variableData),
[selectedValue, variableData],
);
const enableSelectAll = variableData.multiSelect && variableData.showALLOption;
const effectiveAllAvailableOptionStrings = useMemo(
() => allAvailableOptionStrings ?? optionsData.map((v) => v.toString()),
[allAvailableOptionStrings, optionsData],
);
const selectValue =
variableData.allSelected && enableSelectAll
? ALL_SELECT_VALUE
: selectedValueStringified;
const getDefaultValue = useCallback(
(overrideOptions?: (string | number | boolean)[]) => {
const options = overrideOptions || optionsData;
if (variableData.multiSelect) {
if (variableData.showALLOption) {
return variableData.defaultValue || options.map((o) => o.toString());
}
return variableData.defaultValue || options?.[0]?.toString();
}
return variableData.defaultValue || options[0]?.toString();
},
[
variableData.multiSelect,
variableData.showALLOption,
variableData.defaultValue,
optionsData,
],
);
const defaultValue = useMemo(() => getDefaultValue(), [getDefaultValue]);
const handleChange = useCallback(
(inputValue: string | string[]): void => {
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
if (
value === variableData.selectedValue ||
(Array.isArray(value) &&
Array.isArray(variableData.selectedValue) &&
areArraysEqual(value, variableData.selectedValue))
) {
return;
}
strategy.handleChange({
value,
variableData,
optionsData,
allAvailableOptionStrings: effectiveAllAvailableOptionStrings,
onValueUpdate,
});
},
[
variableData,
optionsData,
effectiveAllAvailableOptionStrings,
onValueUpdate,
strategy,
],
);
const handleTempChange = useCallback(
(inputValue: string | string[]): void => {
// Store the selection in temporary state while dropdown is open
const value = variableData.multiSelect && !inputValue ? [] : inputValue;
setTempSelection(uniqueValues(value));
},
[variableData.multiSelect],
);
// Single select onChange: apply default if value is empty
const handleSingleSelectChange = useCallback(
(inputValue: string | string[]): void => {
if (isEmpty(inputValue)) {
if (defaultValue !== undefined) {
handleChange(defaultValue as string | string[]);
}
return;
}
handleChange(inputValue);
},
[handleChange, defaultValue],
);
// Handle dropdown visibility changes
const onDropdownVisibleChange = useCallback(
(visible: boolean): void => {
// Initialize temp selection when opening dropdown
if (visible) {
if (variableData.allSelected && enableSelectAll) {
// When ALL is selected, show all available options as individually checked
setTempSelection([...effectiveAllAvailableOptionStrings]);
} else {
setTempSelection(getSelectValue(variableData.selectedValue, variableData));
}
}
// Apply changes when closing dropdown
else if (!visible && tempSelection !== undefined) {
// If ALL was selected before AND all options remain selected, skip updating
const wasAllSelected = enableSelectAll && variableData.allSelected;
const isAllSelectedAfter =
enableSelectAll &&
Array.isArray(tempSelection) &&
tempSelection.length === effectiveAllAvailableOptionStrings.length &&
effectiveAllAvailableOptionStrings.every((v) => tempSelection.includes(v));
if (wasAllSelected && isAllSelectedAfter) {
setTempSelection(undefined);
return;
}
// Apply default if closing with empty selection
let valueToApply = tempSelection;
if (isEmpty(tempSelection) && defaultValue !== undefined) {
valueToApply = defaultValue as string | string[];
}
// Order-agnostic change detection
const currentValue = variableData.selectedValue;
const hasChanged =
valueToApply !== currentValue &&
!(
Array.isArray(valueToApply) &&
Array.isArray(currentValue) &&
areArraysEqualIgnoreOrder(valueToApply, currentValue)
);
if (hasChanged) {
handleChange(valueToApply);
}
setTempSelection(undefined);
}
},
[
variableData,
enableSelectAll,
effectiveAllAvailableOptionStrings,
tempSelection,
handleChange,
defaultValue,
],
);
// Explicit function for callers to apply default on mount / data load
// Pass overrideOptions when freshly-loaded options aren't in state yet (async callers)
const applyDefaultIfNeeded = useCallback(
(overrideOptions?: (string | number | boolean)[]): void => {
if (isEmpty(selectValue)) {
const defaultValueFromOptions = getDefaultValue(overrideOptions);
if (defaultValueFromOptions !== undefined) {
handleChange(defaultValueFromOptions as string | string[]);
}
}
},
[selectValue, handleChange, getDefaultValue],
);
const handleClear = useCallback((): void => {
handleChange([]);
}, [handleChange]);
const value = variableData.multiSelect
? tempSelection || selectValue
: selectValue;
const onChange = useMemo(() => {
return variableData.multiSelect ? handleTempChange : handleSingleSelectChange;
}, [variableData.multiSelect, handleTempChange, handleSingleSelectChange]);
return {
tempSelection,
setTempSelection,
enableSelectAll,
onDropdownVisibleChange,
handleClear,
value,
defaultValue,
onChange,
applyDefaultIfNeeded,
};
}

View File

@@ -3,7 +3,7 @@ import { useCallback } from 'react';
import { useAddDynamicVariableToPanels } from 'hooks/dashboard/useAddDynamicVariableToPanels';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import { v4 as uuidv4 } from 'uuid';

View File

@@ -1,6 +1,16 @@
import { OptionData } from 'components/NewSelect/types';
import { isEmpty, isNull } from 'lodash-es';
import { IDashboardVariables } from 'providers/Dashboard/store/dashboardVariablesStore';
import { SOMETHING_WENT_WRONG } from 'constants/api';
import { textContainsVariableReference } from 'lib/dashboardVariables/variableReference';
import { isEmpty } from 'lodash-es';
import {
IDashboardVariables,
IDependencyData,
} from 'providers/Dashboard/store/dashboardVariables/dashboardVariablesStoreTypes';
import {
onVariableFetchComplete,
onVariableFetchFailure,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
export function areArraysEqual(
@@ -42,30 +52,16 @@ const getDependentVariablesBasedOnVariableName = (
}
return variables
?.map((variable: any) => {
.map((variable) => {
if (variable.type === 'QUERY') {
// Combined pattern for all formats
// {{.variable_name}} - original format
// $variable_name - dollar prefix format
// [[variable_name]] - square bracket format
// {{variable_name}} - without dot format
const patterns = [
`\\{\\{\\s*?\\.${variableName}\\s*?\\}\\}`, // {{.var}}
`\\{\\{\\s*${variableName}\\s*\\}\\}`, // {{var}}
`\\$${variableName}\\b`, // $var
`\\[\\[\\s*${variableName}\\s*\\]\\]`, // [[var]]
];
const combinedRegex = new RegExp(patterns.join('|'));
const queryValue = variable.queryValue || '';
const dependVarReMatch = queryValue.match(combinedRegex);
if (dependVarReMatch !== null && dependVarReMatch.length > 0) {
if (textContainsVariableReference(queryValue, variableName)) {
return variable.name;
}
}
return null;
})
.filter((val: string | null) => !isNull(val));
.filter((val): val is string => val !== null);
};
export type VariableGraph = Record<string, string[]>;
@@ -97,14 +93,6 @@ export const buildDependencies = (
return graph;
};
export interface IDependencyData {
order: string[];
graph: VariableGraph;
parentDependencyGraph: VariableGraph;
hasCycle: boolean;
cycleNodes?: string[];
}
export const buildParentDependencyGraph = (
graph: VariableGraph,
): VariableGraph => {
@@ -251,10 +239,26 @@ export const buildDependencyGraph = (
const hasCycle = topologicalOrder.length !== Object.keys(dependencies)?.length;
// Pre-compute transitive descendants by walking topological order in reverse.
// Each node's transitive descendants = direct children + their transitive descendants.
const transitiveDescendants: VariableGraph = {};
for (let i = topologicalOrder.length - 1; i >= 0; i--) {
const node = topologicalOrder[i];
const desc = new Set<string>();
for (const child of adjList[node] || []) {
desc.add(child);
for (const d of transitiveDescendants[child] || []) {
desc.add(d);
}
}
transitiveDescendants[node] = Array.from(desc);
}
return {
order: topologicalOrder,
graph: adjList,
parentDependencyGraph: buildParentDependencyGraph(adjList),
transitiveDescendants,
hasCycle,
cycleNodes,
};
@@ -289,33 +293,6 @@ export const onUpdateVariableNode = (
});
};
export const checkAPIInvocation = (
variablesToGetUpdated: string[],
variableData: IDashboardVariable,
parentDependencyGraph?: VariableGraph,
): boolean => {
if (isEmpty(variableData.name)) {
return false;
}
if (isEmpty(parentDependencyGraph)) {
return false;
}
// if no dependency then true
const haveDependency =
parentDependencyGraph?.[variableData.name || '']?.length > 0;
if (!haveDependency) {
return true;
}
// if variable is in the list and has dependency then check if its the top element in the queue then true else false
return (
variablesToGetUpdated.length > 0 &&
variablesToGetUpdated[0] === variableData.name
);
};
export const getOptionsForDynamicVariable = (
normalizedValues: (string | number | boolean)[],
relatedValues: string[],
@@ -368,21 +345,142 @@ export const uniqueOptions = (options: OptionData[]): OptionData[] => {
return uniqueOptions;
};
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
export const getSelectValue = (
selectedValue: IDashboardVariable['selectedValue'],
variableData: IDashboardVariable,
): string | string[] | undefined => {
if (Array.isArray(selectedValue)) {
if (!variableData.multiSelect && selectedValue.length === 1) {
return selectedValue[0]?.toString();
}
return selectedValue.map((item) => item.toString());
}
return selectedValue?.toString();
};
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
/**
* Merges multiple arrays of values into a single deduplicated string array.
*/
export function mergeUniqueStrings(
...arrays: (string | number | boolean)[][]
): string[] {
return [...new Set(arrays.flatMap((arr) => arr.map((v) => v.toString())))];
}
return uniqueValues;
function isEligibleFilterVariable(
variable: IDashboardVariable,
currentVariableId: string,
): boolean {
if (variable.id === currentVariableId) {
return false;
}
if (variable.type !== 'DYNAMIC') {
return false;
}
if (!variable.dynamicVariablesAttribute) {
return false;
}
if (!variable.selectedValue || isEmpty(variable.selectedValue)) {
return false;
}
return !(variable.showALLOption && variable.allSelected);
}
function formatQueryValue(val: string): string {
const numValue = Number(val);
if (!Number.isNaN(numValue) && Number.isFinite(numValue)) {
return val;
}
return `'${val.replace(/'/g, "\\'")}'`;
}
function buildQueryPart(attribute: string, values: string[]): string {
const formatted = values.map(formatQueryValue);
if (formatted.length === 1) {
return `${attribute} = ${formatted[0]}`;
}
return `${attribute} IN [${formatted.join(', ')}]`;
}
/**
* Builds a filter query string from sibling dynamic variables' selected values.
* e.g. `k8s.namespace.name IN ['zeus', 'gene'] AND doc_op_type = 'test'`
*/
export function buildExistingDynamicVariableQuery(
existingVariables: IDashboardVariables | null,
currentVariableId: string,
hasDynamicAttribute: boolean,
): string {
if (!existingVariables || !hasDynamicAttribute) {
return '';
}
return values;
};
const queryParts: string[] = [];
for (const variable of Object.values(existingVariables)) {
// Skip the current variable being processed
if (!isEligibleFilterVariable(variable, currentVariableId)) {
continue;
}
const rawValues = Array.isArray(variable.selectedValue)
? variable.selectedValue
: [variable.selectedValue];
// Filter out empty values and convert to strings
const validValues = rawValues
.filter(
(val): val is string | number | boolean =>
val !== null && val !== undefined && val !== '',
)
.map((val) => val.toString());
if (validValues.length > 0 && variable.dynamicVariablesAttribute) {
queryParts.push(
buildQueryPart(variable.dynamicVariablesAttribute, validValues),
);
}
}
return queryParts.join(' AND ');
}
function isVariableInActiveFetchState(state: string | undefined): boolean {
return state === 'loading' || state === 'revalidating';
}
/**
* Completes or fails a variable's fetch state machine transition.
* No-ops if the variable is not currently in an active fetch state.
*/
export function settleVariableFetch(
name: string | undefined,
outcome: 'complete' | 'failure',
): void {
if (!name) {
return;
}
const currentState = variableFetchStore.getSnapshot().states[name];
if (!isVariableInActiveFetchState(currentState)) {
return;
}
if (outcome === 'complete') {
onVariableFetchComplete(name);
} else {
onVariableFetchFailure(name);
}
}
export function extractErrorMessage(
error: { message?: string } | null,
): string {
if (!error) {
return SOMETHING_WENT_WRONG;
}
return (
error.message ||
'Please make sure configuration is valid and you have required setup and permissions'
);
}

View File

@@ -1,4 +1,31 @@
import { areArraysEqual, onUpdateVariableNode, VariableGraph } from './util';
jest.mock('providers/Dashboard/store/variableFetchStore', () => ({
variableFetchStore: {
getSnapshot: jest.fn(),
},
onVariableFetchComplete: jest.fn(),
onVariableFetchFailure: jest.fn(),
}));
import {
onVariableFetchComplete,
onVariableFetchFailure,
variableFetchStore,
} from 'providers/Dashboard/store/variableFetchStore';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import {
areArraysEqual,
buildExistingDynamicVariableQuery,
extractErrorMessage,
mergeUniqueStrings,
onUpdateVariableNode,
settleVariableFetch,
VariableGraph,
} from './util';
// ────────────────────────────────────────────────────────────────
// Existing tests
// ────────────────────────────────────────────────────────────────
describe('areArraysEqual', () => {
it('should return true for equal arrays with same order', () => {
@@ -149,3 +176,348 @@ describe('onUpdateVariableNode', () => {
expect(visited).toEqual(['namespace', 'service', 'pod']);
});
});
// ────────────────────────────────────────────────────────────────
// New tests for functions added in recent commits
// ────────────────────────────────────────────────────────────────
function makeDynamicVar(
overrides: Partial<IDashboardVariable> & { id: string },
): IDashboardVariable {
return {
name: overrides.id,
description: '',
type: 'DYNAMIC',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
allSelected: false,
dynamicVariablesAttribute: 'attr',
selectedValue: 'some-value',
...overrides,
} as IDashboardVariable;
}
describe('mergeUniqueStrings', () => {
it('should merge two arrays and deduplicate', () => {
expect(mergeUniqueStrings(['a', 'b'], ['b', 'c'])).toEqual(['a', 'b', 'c']);
});
it('should convert numbers and booleans to strings', () => {
expect(mergeUniqueStrings([1, true, 'hello'], [2, false])).toEqual([
'1',
'true',
'hello',
'2',
'false',
]);
});
it('should deduplicate when number and its string form both appear', () => {
expect(mergeUniqueStrings([42], ['42'])).toEqual(['42']);
});
it('should handle a single array', () => {
expect(mergeUniqueStrings(['x', 'y', 'x'])).toEqual(['x', 'y']);
});
it('should handle three or more arrays', () => {
expect(mergeUniqueStrings(['a'], ['b'], ['c'], ['a', 'c'])).toEqual([
'a',
'b',
'c',
]);
});
it('should return empty array when no arrays are provided', () => {
expect(mergeUniqueStrings()).toEqual([]);
});
it('should return empty array when all input arrays are empty', () => {
expect(mergeUniqueStrings([], [], [])).toEqual([]);
});
it('should preserve order of first occurrence', () => {
expect(mergeUniqueStrings(['c', 'a'], ['b', 'a'])).toEqual(['c', 'a', 'b']);
});
});
describe('buildExistingDynamicVariableQuery', () => {
// --- Guard clauses ---
it('should return empty string when existingVariables is null', () => {
expect(buildExistingDynamicVariableQuery(null, 'v1', true)).toBe('');
});
it('should return empty string when hasDynamicAttribute is false', () => {
const variables = { v2: makeDynamicVar({ id: 'v2' }) };
expect(buildExistingDynamicVariableQuery(variables, 'v1', false)).toBe('');
});
// --- Eligibility filtering ---
it('should skip the current variable (same id)', () => {
const variables = {
v1: makeDynamicVar({
id: 'v1',
dynamicVariablesAttribute: 'ns',
selectedValue: 'prod',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip non-DYNAMIC variables', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', type: 'QUERY' as any }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables without dynamicVariablesAttribute', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: undefined,
selectedValue: 'val',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables with null selectedValue', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', selectedValue: null }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables with empty string selectedValue', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', selectedValue: '' }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables with empty array selectedValue', () => {
const variables = {
v2: makeDynamicVar({ id: 'v2', selectedValue: [] }),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should skip variables where showALLOption and allSelected are both true', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
showALLOption: true,
allSelected: true,
dynamicVariablesAttribute: 'ns',
selectedValue: 'prod',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
it('should include variable with showALLOption true but allSelected false', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
showALLOption: true,
allSelected: false,
dynamicVariablesAttribute: 'ns',
selectedValue: 'prod',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"ns = 'prod'",
);
});
// --- Value formatting ---
it('should quote string values in the query', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'k8s.namespace.name',
selectedValue: 'zeus',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"k8s.namespace.name = 'zeus'",
);
});
it('should leave numeric values unquoted', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'http.status_code',
selectedValue: '200',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
'http.status_code = 200',
);
});
it('should escape single quotes in string values', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'user.name',
selectedValue: "O'Brien",
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"user.name = 'O\\'Brien'",
);
});
it('should build an IN clause for array selectedValue with multiple items', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'k8s.namespace.name',
selectedValue: ['zeus', 'gene'],
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"k8s.namespace.name IN ['zeus', 'gene']",
);
});
it('should handle mix of numeric and string values in IN clause', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'http.status_code',
selectedValue: ['200', 'unknown'],
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"http.status_code IN [200, 'unknown']",
);
});
it('should filter out empty string values from array', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'region',
selectedValue: ['us-east', '', 'eu-west'],
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"region IN ['us-east', 'eu-west']",
);
});
// --- Multiple siblings ---
it('should join multiple sibling variables with AND', () => {
const variables = {
v2: makeDynamicVar({
id: 'v2',
dynamicVariablesAttribute: 'k8s.namespace.name',
selectedValue: ['zeus', 'gene'],
}),
v3: makeDynamicVar({
id: 'v3',
dynamicVariablesAttribute: 'doc_op_type',
selectedValue: 'test',
}),
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe(
"k8s.namespace.name IN ['zeus', 'gene'] AND doc_op_type = 'test'",
);
});
it('should return empty string when no variables are eligible', () => {
const variables = {
v1: makeDynamicVar({ id: 'v1' }), // same as current — skipped
v2: makeDynamicVar({ id: 'v2', type: 'QUERY' as any }), // not DYNAMIC
v3: makeDynamicVar({ id: 'v3', selectedValue: null }), // no value
};
expect(buildExistingDynamicVariableQuery(variables, 'v1', true)).toBe('');
});
});
describe('settleVariableFetch', () => {
const mockGetSnapshot = variableFetchStore.getSnapshot as jest.Mock;
const mockComplete = onVariableFetchComplete as jest.Mock;
const mockFailure = onVariableFetchFailure as jest.Mock;
beforeEach(() => {
jest.clearAllMocks();
});
it('should no-op when name is undefined', () => {
settleVariableFetch(undefined, 'complete');
expect(mockGetSnapshot).not.toHaveBeenCalled();
expect(mockComplete).not.toHaveBeenCalled();
expect(mockFailure).not.toHaveBeenCalled();
});
it.each(['idle', 'waiting', 'error', undefined] as const)(
'should no-op when variable state is %s',
(state) => {
mockGetSnapshot.mockReturnValue({ states: { myVar: state } });
settleVariableFetch('myVar', 'complete');
expect(mockComplete).not.toHaveBeenCalled();
expect(mockFailure).not.toHaveBeenCalled();
},
);
it('should call onVariableFetchComplete when state is loading and outcome is complete', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'loading' } });
settleVariableFetch('myVar', 'complete');
expect(mockComplete).toHaveBeenCalledWith('myVar');
expect(mockFailure).not.toHaveBeenCalled();
});
it('should call onVariableFetchComplete when state is revalidating and outcome is complete', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'revalidating' } });
settleVariableFetch('myVar', 'complete');
expect(mockComplete).toHaveBeenCalledWith('myVar');
expect(mockFailure).not.toHaveBeenCalled();
});
it('should call onVariableFetchFailure when state is loading and outcome is failure', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'loading' } });
settleVariableFetch('myVar', 'failure');
expect(mockFailure).toHaveBeenCalledWith('myVar');
expect(mockComplete).not.toHaveBeenCalled();
});
it('should call onVariableFetchFailure when state is revalidating and outcome is failure', () => {
mockGetSnapshot.mockReturnValue({ states: { myVar: 'revalidating' } });
settleVariableFetch('myVar', 'failure');
expect(mockFailure).toHaveBeenCalledWith('myVar');
expect(mockComplete).not.toHaveBeenCalled();
});
});
describe('extractErrorMessage', () => {
const FALLBACK_MESSAGE =
'Please make sure configuration is valid and you have required setup and permissions';
it('should return SOMETHING_WENT_WRONG when error is null', () => {
expect(extractErrorMessage(null)).toBe('Something went wrong');
});
it('should return the error message when it exists', () => {
expect(extractErrorMessage({ message: 'Query timeout' })).toBe(
'Query timeout',
);
});
it('should return fallback when error object has no message property', () => {
expect(extractErrorMessage({})).toBe(FALLBACK_MESSAGE);
});
it('should return fallback when error.message is empty string', () => {
expect(extractErrorMessage({ message: '' })).toBe(FALLBACK_MESSAGE);
});
it('should return fallback when error.message is undefined', () => {
expect(extractErrorMessage({ message: undefined })).toBe(FALLBACK_MESSAGE);
});
});

View File

@@ -0,0 +1,4 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const customVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;

View File

@@ -0,0 +1,20 @@
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const defaultVariableSelectStrategy: VariableSelectStrategy = {
handleChange({ value, variableData, optionsData, onValueUpdate }) {
if (!variableData.name) {
return;
}
const isAllSelected =
Array.isArray(value) &&
value.length > 0 &&
optionsData.every((option) => value.includes(option.toString()));
if (isAllSelected && variableData.showALLOption) {
onValueUpdate(variableData.name, variableData.id, optionsData, true);
} else {
onValueUpdate(variableData.name, variableData.id, value, false);
}
},
};

View File

@@ -0,0 +1,37 @@
import { ALL_SELECT_VALUE } from 'container/DashboardContainer/utils';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const dynamicVariableSelectStrategy: VariableSelectStrategy = {
handleChange({
value,
variableData,
allAvailableOptionStrings,
onValueUpdate,
}) {
if (!variableData.name) {
return;
}
if (
value === ALL_SELECT_VALUE ||
(Array.isArray(value) && value.includes(ALL_SELECT_VALUE))
) {
onValueUpdate(variableData.name, variableData.id, null, true);
} else {
// For ALL selection in dynamic variables, pass null to avoid storing values
// The parent component will handle this appropriately
const haveCustomValuesSelected =
Array.isArray(value) &&
!value.every((v) => allAvailableOptionStrings.includes(v.toString()));
onValueUpdate(
variableData.name,
variableData.id,
value,
allAvailableOptionStrings.every((v) => value.includes(v.toString())),
haveCustomValuesSelected,
);
}
},
};

View File

@@ -0,0 +1,4 @@
import { defaultVariableSelectStrategy } from './defaultVariableSelectStrategy';
import { VariableSelectStrategy } from './variableSelectStrategyTypes';
export const queryVariableSelectStrategy: VariableSelectStrategy = defaultVariableSelectStrategy;

View File

@@ -0,0 +1,11 @@
import { VariableItemProps } from '../VariableItem';
export interface VariableSelectStrategy {
handleChange(params: {
value: string | string[];
variableData: VariableItemProps['variableData'];
onValueUpdate: VariableItemProps['onValueUpdate'];
optionsData: (string | number | boolean)[];
allAvailableOptionStrings: string[];
}): void;
}

View File

@@ -0,0 +1,32 @@
import { isEqual } from 'lodash-es';
export const areArraysEqualIgnoreOrder = (
a: (string | number | boolean)[],
b: (string | number | boolean)[],
): boolean => {
if (a.length !== b.length) {
return false;
}
const sortedA = [...a].sort();
const sortedB = [...b].sort();
return isEqual(sortedA, sortedB);
};
export const uniqueValues = (values: string[] | string): string[] | string => {
if (Array.isArray(values)) {
const uniqueValues: string[] = [];
const seenValues = new Set<string>();
values.forEach((value) => {
if (seenValues.has(value)) {
return;
}
seenValues.add(value);
uniqueValues.push(value);
});
return uniqueValues;
}
return values;
};

View File

@@ -2,7 +2,7 @@
/* eslint-disable sonarjs/no-duplicate-string */
/* eslint-disable react/jsx-props-no-spreading */
import React from 'react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { QueryClient, QueryClientProvider, useQuery } from 'react-query';
import * as ReactRedux from 'react-redux';
import {
act,
@@ -15,13 +15,35 @@ import {
import { getFieldValues } from 'api/dynamicVariables/getFieldValues';
import { IDashboardVariable } from 'types/api/dashboard/getAll';
import DynamicVariableSelection from '../DashboardVariablesSelection/DynamicVariableSelection';
import DynamicVariableInput from '../DashboardVariablesSelection/DynamicVariableInput';
// Mock useVariableFetchState to return "fetching" state so useQuery is enabled
jest.mock('hooks/dashboard/useVariableFetchState', () => ({
useVariableFetchState: (): Record<string, unknown> => ({
variableFetchCycleId: 0,
variableFetchState: 'loading',
isVariableSettled: false,
isVariableFetching: true,
hasVariableFetchedOnce: false,
isVariableWaitingForDependencies: false,
variableDependencyWaitMessage: '',
}),
}));
// Mock the getFieldValues API
jest.mock('api/dynamicVariables/getFieldValues', () => ({
getFieldValues: jest.fn(),
}));
// Mock useQuery from react-query
jest.mock('react-query', () => {
const originalModule = jest.requireActual('react-query');
return {
...originalModule,
useQuery: jest.fn(),
};
});
describe('Dynamic Variable Default Behavior', () => {
const mockOnValueUpdate = jest.fn();
const mockApiResponse = {
@@ -59,6 +81,46 @@ describe('Dynamic Variable Default Behavior', () => {
// Mock getFieldValues API to return our test data
(getFieldValues as jest.Mock).mockResolvedValue(mockApiResponse);
// Mock useQuery implementation to avoid infinite re-renders
// and ensure onSuccess is called once
(useQuery as jest.Mock).mockImplementation((key, options) => {
const { onSuccess, enabled, queryFn } = options || {};
const variableName = key[1];
const dynamicVarsKey = key[2];
React.useEffect(() => {
if (enabled !== false) {
if (onSuccess) {
// For 'services' tests:
// 1. "Default to ALL" expectations imply empty options -> [] behavior. This happens when selectedValue is undefined (dynamicVarsKey has 'null').
// 2. "ALL Option Special Value" needs full options to render the "ALL" item in dropdown. This happens when selectedValue is defined.
if (
variableName === 'services' &&
typeof dynamicVarsKey === 'string' &&
dynamicVarsKey.includes('null')
) {
onSuccess({
...mockApiResponse,
data: { ...mockApiResponse.data, normalizedValues: [] },
});
} else {
onSuccess(mockApiResponse);
}
}
if (queryFn) {
queryFn({ signal: undefined });
}
}
}, [enabled, variableName, dynamicVarsKey]); // Only depend on enabled/keys
return {
isLoading: false,
isError: false,
data: mockApiResponse,
refetch: jest.fn(),
};
});
jest.spyOn(ReactRedux, 'useSelector').mockReturnValue({
minTime: '2023-01-01T00:00:00Z',
maxTime: '2023-01-02T00:00:00Z',
@@ -84,7 +146,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -120,7 +182,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -164,7 +226,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -172,9 +234,6 @@ describe('Dynamic Variable Default Behavior', () => {
);
});
// Component should render without errors
expect(screen.getByText('$service')).toBeInTheDocument();
// Check if the dropdown is present
const selectElement = screen.getByRole('combobox');
expect(selectElement).toBeInTheDocument();
@@ -188,6 +247,7 @@ describe('Dynamic Variable Default Behavior', () => {
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
'',
undefined, // signal
);
});
@@ -232,7 +292,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -267,7 +327,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -293,7 +353,7 @@ describe('Dynamic Variable Default Behavior', () => {
expect(screen.queryByText('backend')).not.toBeInTheDocument();
});
it('should default to ALL when no default and no previous selection', async () => {
it('sahould default to ALL when no default and no previous selection', async () => {
const variableData: IDashboardVariable = {
id: 'var21',
name: 'services',
@@ -311,7 +371,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -345,7 +405,7 @@ describe('Dynamic Variable Default Behavior', () => {
expect(mockOnValueUpdate).toHaveBeenCalledWith(
'services',
'var21',
[], // Empty array when allSelected is true
[],
true, // allSelected = true
false,
);
@@ -371,7 +431,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -408,7 +468,7 @@ describe('Dynamic Variable Default Behavior', () => {
await act(async () => {
renderWithQueryClient(
<DynamicVariableSelection
<DynamicVariableInput
variableData={variableData}
existingVariables={{ var1: variableData }}
onValueUpdate={mockOnValueUpdate}
@@ -416,9 +476,6 @@ describe('Dynamic Variable Default Behavior', () => {
);
});
// Component should render without errors
expect(screen.getByText('$services')).toBeInTheDocument();
// Check if ALL is displayed in the UI (in the main selection area)
const allTextElement = screen.getByText('ALL');
expect(allTextElement).toBeInTheDocument();
@@ -444,6 +501,7 @@ describe('Dynamic Variable Default Behavior', () => {
'2023-01-01T00:00:00Z',
'2023-01-02T00:00:00Z',
'',
undefined, // signal
);
});

View File

@@ -49,15 +49,11 @@ const mockDashboard = {
// Mock the dashboard provider with stable functions to prevent infinite loops
const mockSetSelectedDashboard = jest.fn();
const mockUpdateLocalStorageDashboardVariables = jest.fn();
const mockSetVariablesToGetUpdated = jest.fn();
jest.mock('providers/Dashboard/Dashboard', () => ({
useDashboard: (): any => ({
selectedDashboard: mockDashboard,
setSelectedDashboard: mockSetSelectedDashboard,
updateLocalStorageDashboardVariables: mockUpdateLocalStorageDashboardVariables,
variablesToGetUpdated: ['env'], // Stable initial value
setVariablesToGetUpdated: mockSetVariablesToGetUpdated,
}),
}));

View File

@@ -0,0 +1,45 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import BarChartTooltip from 'lib/uPlotV2/components/Tooltip/BarChartTooltip';
import {
BarTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { useBarChartStacking } from '../../hooks/useBarChartStacking';
import { BarChartProps } from '../types';
export default function BarChart(props: BarChartProps): JSX.Element {
const { children, isStackedBarChart, config, data, ...rest } = props;
const chartData = useBarChartStacking({
data,
isStackedBarChart,
config,
});
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
const tooltipProps: BarTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
isStackedBarChart: isStackedBarChart,
};
return <BarChartTooltip {...tooltipProps} />;
},
[rest.timezone, rest.yAxisUnit, rest.decimalPrecision, isStackedBarChart],
);
return (
<ChartWrapper
{...rest}
config={config}
data={chartData}
renderTooltip={renderTooltip}
>
{children}
</ChartWrapper>
);
}

View File

@@ -0,0 +1,104 @@
import { useCallback, useRef } from 'react';
import ChartLayout from 'container/DashboardContainer/visualization/layout/ChartLayout/ChartLayout';
import Legend from 'lib/uPlotV2/components/Legend/Legend';
import {
LegendPosition,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import UPlotChart from 'lib/uPlotV2/components/UPlotChart';
import { PlotContextProvider } from 'lib/uPlotV2/context/PlotContext';
import TooltipPlugin from 'lib/uPlotV2/plugins/TooltipPlugin/TooltipPlugin';
import noop from 'lodash-es/noop';
import uPlot from 'uplot';
import { ChartProps } from '../types';
const TOOLTIP_WIDTH_PADDING = 60;
const TOOLTIP_MIN_WIDTH = 200;
export default function ChartWrapper({
legendConfig = { position: LegendPosition.BOTTOM },
config,
data,
width: containerWidth,
height: containerHeight,
showTooltip = true,
canPinTooltip = false,
syncMode,
syncKey,
onDestroy = noop,
children,
layoutChildren,
renderTooltip,
'data-testid': testId,
}: ChartProps): JSX.Element {
const plotInstanceRef = useRef<uPlot | null>(null);
const legendComponent = useCallback(
(averageLegendWidth: number): React.ReactNode => {
return (
<Legend
config={config}
position={legendConfig.position}
averageLegendWidth={averageLegendWidth}
/>
);
},
[config, legendConfig.position],
);
const renderTooltipCallback = useCallback(
(args: TooltipRenderArgs): React.ReactNode => {
if (renderTooltip) {
return renderTooltip(args);
}
return null;
},
[renderTooltip],
);
return (
<PlotContextProvider>
<ChartLayout
config={config}
containerWidth={containerWidth}
containerHeight={containerHeight}
legendConfig={legendConfig}
legendComponent={legendComponent}
layoutChildren={layoutChildren}
>
{({ chartWidth, chartHeight, averageLegendWidth }): JSX.Element => (
<UPlotChart
config={config}
data={data}
width={chartWidth}
height={chartHeight}
plotRef={(plot): void => {
plotInstanceRef.current = plot;
}}
onDestroy={(plot: uPlot): void => {
plotInstanceRef.current = null;
onDestroy(plot);
}}
data-testid={testId}
>
{children}
{showTooltip && (
<TooltipPlugin
config={config}
canPinTooltip={canPinTooltip}
syncMode={syncMode}
maxWidth={Math.max(
TOOLTIP_MIN_WIDTH,
averageLegendWidth + TOOLTIP_WIDTH_PADDING,
)}
syncKey={syncKey}
render={renderTooltipCallback}
/>
)}
</UPlotChart>
)}
</ChartLayout>
</PlotContextProvider>
);
}

View File

@@ -0,0 +1,46 @@
import { useCallback } from 'react';
import ChartWrapper from 'container/DashboardContainer/visualization/charts/ChartWrapper/ChartWrapper';
import TimeSeriesTooltip from 'lib/uPlotV2/components/Tooltip/TimeSeriesTooltip';
import { buildTooltipContent } from 'lib/uPlotV2/components/Tooltip/utils';
import {
TimeSeriesTooltipProps,
TooltipRenderArgs,
} from 'lib/uPlotV2/components/types';
import { TimeSeriesChartProps } from '../types';
export default function TimeSeries(props: TimeSeriesChartProps): JSX.Element {
const { children, renderTooltip: customRenderTooltip, ...rest } = props;
const renderTooltip = useCallback(
(props: TooltipRenderArgs): React.ReactNode => {
if (customRenderTooltip) {
return customRenderTooltip(props);
}
const content = buildTooltipContent({
data: props.uPlotInstance.data,
series: props.uPlotInstance.series,
dataIndexes: props.dataIndexes,
activeSeriesIndex: props.seriesIndex,
uPlotInstance: props.uPlotInstance,
yAxisUnit: rest.yAxisUnit ?? '',
decimalPrecision: rest.decimalPrecision,
});
const tooltipProps: TimeSeriesTooltipProps = {
...props,
timezone: rest.timezone,
yAxisUnit: rest.yAxisUnit,
decimalPrecision: rest.decimalPrecision,
content,
};
return <TimeSeriesTooltip {...tooltipProps} />;
},
[customRenderTooltip, rest.timezone, rest.yAxisUnit, rest.decimalPrecision],
);
return (
<ChartWrapper {...rest} renderTooltip={renderTooltip}>
{children}
</ChartWrapper>
);
}

View File

@@ -0,0 +1,39 @@
import { PrecisionOption } from 'components/Graph/types';
import { LegendConfig, TooltipRenderArgs } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
interface BaseChartProps {
width: number;
height: number;
showTooltip?: boolean;
timezone: string;
canPinTooltip?: boolean;
yAxisUnit?: string;
decimalPrecision?: PrecisionOption;
renderTooltip?: (props: TooltipRenderArgs) => React.ReactNode;
'data-testid'?: string;
}
interface UPlotBasedChartProps {
config: UPlotConfigBuilder;
data: uPlot.AlignedData;
syncMode?: DashboardCursorSync;
syncKey?: string;
plotRef?: (plot: uPlot | null) => void;
onDestroy?: (plot: uPlot) => void;
children?: React.ReactNode;
layoutChildren?: React.ReactNode;
}
export interface TimeSeriesChartProps
extends BaseChartProps,
UPlotBasedChartProps {
legendConfig: LegendConfig;
}
export interface BarChartProps extends BaseChartProps, UPlotBasedChartProps {
legendConfig: LegendConfig;
isStackedBarChart?: boolean;
}
export type ChartProps = TimeSeriesChartProps | BarChartProps;

View File

@@ -5,25 +5,32 @@ export interface ChartDimensions {
height: number;
legendWidth: number;
legendHeight: number;
legendsPerSet: number;
averageLegendWidth: number;
}
const AVG_CHAR_WIDTH = 8;
const LEGEND_WIDTH_PERCENTILE = 0.85;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const LEGEND_GAP = 16;
const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 36;
const LEGEND_LINE_HEIGHT = 28;
/**
* Average text width from series labels (for legendsPerSet).
* Calculates the average width of the legend items based on the labels of the series.
* @param legends - The labels of the series.
* @returns The average width of the legend items.
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return DEFAULT_AVG_LABEL_LENGTH;
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
}
const averageLabelLength =
legends.reduce((sum, l) => sum + l.length, 0) / legends.length;
return averageLabelLength * AVG_CHAR_WIDTH;
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
const percentileLength = lengths[Math.max(0, index)];
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
}
/**
@@ -64,7 +71,7 @@ export function calculateChartDimensions({
height: 0,
legendWidth: 0,
legendHeight: 0,
legendsPerSet: 0,
averageLegendWidth: 0,
};
}
@@ -85,13 +92,15 @@ export function calculateChartDimensions({
legendWidth: rightLegendWidth,
legendHeight: containerHeight,
// Single vertical list on the right.
legendsPerSet: 1,
averageLegendWidth: rightLegendWidth,
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.min(approxLegendItemWidth, 400);
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
const legendItemsPerRow = Math.max(
1,
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
@@ -114,17 +123,11 @@ export function calculateChartDimensions({
maxAllowedLegendHeight,
);
// How many legend items per row in the Legend component.
const legendsPerSet = Math.ceil(
(containerWidth + LEGEND_GAP) /
(Math.min(MAX_LEGEND_WIDTH, approxLegendItemWidth) + LEGEND_GAP),
);
return {
width: containerWidth,
height: Math.max(0, containerHeight - bottomLegendHeight),
legendWidth: containerWidth,
legendHeight: bottomLegendHeight,
legendsPerSet,
averageLegendWidth: legendItemWidth,
};
}

View File

@@ -0,0 +1,117 @@
import { AlignedData } from 'uplot';
import { getInitialStackedBands, stack } from '../stackUtils';
describe('stackUtils', () => {
describe('stack', () => {
const neverOmit = (): boolean => false;
it('preserves time axis as first row', () => {
const data: AlignedData = [
[100, 200, 300],
[1, 2, 3],
[4, 5, 6],
];
const { data: result } = stack(data, neverOmit);
expect(result[0]).toEqual([100, 200, 300]);
});
it('stacks value series cumulatively (last = raw, first = total)', () => {
// Time, then 3 value series. Stack order: last series stays raw, then we add upward.
const data: AlignedData = [
[0, 1, 2],
[1, 2, 3], // series 1
[4, 5, 6], // series 2
[7, 8, 9], // series 3
];
const { data: result } = stack(data, neverOmit);
// result[1] = s1+s2+s3, result[2] = s2+s3, result[3] = s3
expect(result[1]).toEqual([12, 15, 18]); // 1+4+7, 2+5+8, 3+6+9
expect(result[2]).toEqual([11, 13, 15]); // 4+7, 5+8, 6+9
expect(result[3]).toEqual([7, 8, 9]);
});
it('treats null values as 0 when stacking', () => {
const data: AlignedData = [
[0, 1],
[1, null],
[null, 10],
];
const { data: result } = stack(data, neverOmit);
expect(result[1]).toEqual([1, 10]); // total
expect(result[2]).toEqual([0, 10]); // last series with null→0
});
it('copies omitted series as-is without accumulating', () => {
// Omit series 2 (index 2); series 1 and 3 are stacked.
const data: AlignedData = [
[0, 1],
[10, 20], // series 1
[100, 200], // series 2 - omitted
[1, 2], // series 3
];
const omitSeries2 = (i: number): boolean => i === 2;
const { data: result } = stack(data, omitSeries2);
// series 3 raw: [1, 2]; series 2 omitted: [100, 200] as-is; series 1 stacked with s3: [11, 22]
expect(result[1]).toEqual([11, 22]); // 10+1, 20+2
expect(result[2]).toEqual([100, 200]); // copied, not stacked
expect(result[3]).toEqual([1, 2]);
});
it('returns bands between consecutive visible series when none omitted', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
[3, 4],
[5, 6],
];
const { bands } = stack(data, neverOmit);
expect(bands).toEqual([{ series: [1, 2] }, { series: [2, 3] }]);
});
it('returns bands only between visible series when some are omitted', () => {
// 4 value series; omit index 2. Visible: 1, 3, 4. Bands: [1,3], [3,4]
const data: AlignedData = [[0], [1], [2], [3], [4]];
const omitSeries2 = (i: number): boolean => i === 2;
const { bands } = stack(data, omitSeries2);
expect(bands).toEqual([{ series: [1, 3] }, { series: [3, 4] }]);
});
it('returns empty bands when only one value series', () => {
const data: AlignedData = [
[0, 1],
[1, 2],
];
const { bands } = stack(data, neverOmit);
expect(bands).toEqual([]);
});
});
describe('getInitialStackedBands', () => {
it('returns one band between each consecutive pair for seriesCount 3', () => {
expect(getInitialStackedBands(3)).toEqual([
{ series: [1, 2] },
{ series: [2, 3] },
]);
});
it('returns empty array for seriesCount 0 or 1', () => {
expect(getInitialStackedBands(0)).toEqual([]);
expect(getInitialStackedBands(1)).toEqual([]);
});
it('returns single band for seriesCount 2', () => {
expect(getInitialStackedBands(2)).toEqual([{ series: [1, 2] }]);
});
it('returns bands [1,2], [2,3], ..., [n-1, n] for seriesCount n', () => {
const bands = getInitialStackedBands(5);
expect(bands).toEqual([
{ series: [1, 2] },
{ series: [2, 3] },
{ series: [3, 4] },
{ series: [4, 5] },
]);
});
});
});

View File

@@ -0,0 +1,116 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stackSeries(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
const stackedSeries = buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
return {
data: [timeAxis, ...stackedSeries] as AlignedData,
bands,
};
}
interface BuildStackedSeriesParams {
data: AlignedData;
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
*/
function buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
if (omit(seriesIndex)) {
stackedSeries[seriesIndex - 1] = rawValues;
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
return stackedSeries;
}
/**
* Bands define fill between consecutive visible series for stacked appearance.
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
*/
function buildFillBands(
seriesLength: number,
omit: (seriesIndex: number) => boolean,
): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
seriesLength,
seriesIndex,
omit,
);
if (nextVisibleSeriesIndex !== -1) {
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
}
}
return bands;
}
function findNextVisibleSeriesIndex(
seriesLength: number,
afterIndex: number,
omit: (seriesIndex: number) => boolean,
): number {
for (let i = afterIndex + 1; i < seriesLength; i++) {
if (!omit(i)) {
return i;
}
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -0,0 +1,116 @@
import uPlot, { AlignedData } from 'uplot';
/**
* Stack data cumulatively (top-down: first series = top, last = bottom).
* When `omit(seriesIndex)` returns true, that series is excluded from stacking.
*/
export function stack(
data: AlignedData,
omit: (seriesIndex: number) => boolean,
): { data: AlignedData; bands: uPlot.Band[] } {
const timeAxis = data[0];
const pointCount = timeAxis.length;
const valueSeriesCount = data.length - 1; // exclude time axis
const stackedSeries = buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
});
const bands = buildFillBands(valueSeriesCount + 1, omit); // +1 for 1-based series indices
return {
data: [timeAxis, ...stackedSeries] as AlignedData,
bands,
};
}
interface BuildStackedSeriesParams {
data: AlignedData;
valueSeriesCount: number;
pointCount: number;
omit: (seriesIndex: number) => boolean;
}
/**
* Accumulate from last series upward: last series = raw values, first = total.
* Omitted series are copied as-is (no accumulation).
*/
function buildStackedSeries({
data,
valueSeriesCount,
pointCount,
omit,
}: BuildStackedSeriesParams): (number | null)[][] {
const stackedSeries: (number | null)[][] = Array(valueSeriesCount);
const cumulativeSums = Array(pointCount).fill(0) as number[];
for (let seriesIndex = valueSeriesCount; seriesIndex >= 1; seriesIndex--) {
const rawValues = data[seriesIndex] as (number | null)[];
if (omit(seriesIndex)) {
stackedSeries[seriesIndex - 1] = rawValues;
} else {
stackedSeries[seriesIndex - 1] = rawValues.map((rawValue, pointIndex) => {
const numericValue = rawValue == null ? 0 : Number(rawValue);
return (cumulativeSums[pointIndex] += numericValue);
});
}
}
return stackedSeries;
}
/**
* Bands define fill between consecutive visible series for stacked appearance.
* uPlot format: [upperSeriesIdx, lowerSeriesIdx].
*/
function buildFillBands(
seriesLength: number,
omit: (seriesIndex: number) => boolean,
): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesLength; seriesIndex++) {
if (omit(seriesIndex)) {
continue;
}
const nextVisibleSeriesIndex = findNextVisibleSeriesIndex(
seriesLength,
seriesIndex,
omit,
);
if (nextVisibleSeriesIndex !== -1) {
bands.push({ series: [seriesIndex, nextVisibleSeriesIndex] });
}
}
return bands;
}
function findNextVisibleSeriesIndex(
seriesLength: number,
afterIndex: number,
omit: (seriesIndex: number) => boolean,
): number {
for (let i = afterIndex + 1; i < seriesLength; i++) {
if (!omit(i)) {
return i;
}
}
return -1;
}
/**
* Returns band indices for initial stacked state (no series omitted).
* Top-down: first series at top, band fills between consecutive series.
* uPlot band format: [upperSeriesIdx, lowerSeriesIdx].
*/
export function getInitialStackedBands(seriesCount: number): uPlot.Band[] {
const bands: uPlot.Band[] = [];
for (let seriesIndex = 1; seriesIndex < seriesCount; seriesIndex++) {
bands.push({ series: [seriesIndex, seriesIndex + 1] });
}
return bands;
}

View File

@@ -0,0 +1,21 @@
.chart-manager-container {
width: 100%;
max-height: calc(40% - 40px);
display: flex;
flex-direction: column;
gap: 16px;
.chart-manager-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
.chart-manager-actions-container {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 8px;
}
}
}

View File

@@ -0,0 +1,147 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { Button, Input } from 'antd';
import { ResizeTable } from 'components/ResizeTable';
import { getGraphManagerTableColumns } from 'container/GridCardLayout/GridCard/FullView/TableRender/GraphManagerColumns';
import { ExtendedChartDataset } from 'container/GridCardLayout/GridCard/FullView/types';
import { getDefaultTableDataSet } from 'container/GridCardLayout/GridCard/FullView/utils';
import { useNotifications } from 'hooks/useNotifications';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
import useLegendsSync from 'lib/uPlotV2/hooks/useLegendsSync';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import './ChartManager.styles.scss';
interface ChartManagerProps {
config: UPlotConfigBuilder;
alignedData: uPlot.AlignedData;
yAxisUnit?: string;
onCancel?: () => void;
}
/**
* ChartManager provides a tabular view to manage the visibility of
* individual series on a uPlot chart.
*
* It syncs with the legend state coming from the plot context and
* allows users to:
* - filter series by label
* - toggle individual series on/off
* - persist the visibility configuration to local storage.
*
* @param config - `UPlotConfigBuilder` instance used to derive chart options.
* @param alignedData - uPlot aligned data used to build the initial table dataset.
* @param yAxisUnit - Optional unit label for Y-axis values shown in the table.
* @param onCancel - Optional callback invoked when the user cancels the dialog.
*/
export default function ChartManager({
config,
alignedData,
yAxisUnit,
onCancel,
}: ChartManagerProps): JSX.Element {
const { notifications } = useNotifications();
const { legendItemsMap } = useLegendsSync({
config,
subscribeToFocusChange: false,
});
const {
onToggleSeriesOnOff,
onToggleSeriesVisibility,
syncSeriesVisibilityToLocalStorage,
} = usePlotContext();
const { isDashboardLocked } = useDashboard();
const [tableDataSet, setTableDataSet] = useState<ExtendedChartDataset[]>(() =>
getDefaultTableDataSet(config.getConfig() as uPlot.Options, alignedData),
);
const graphVisibilityState = useMemo(
() =>
Object.entries(legendItemsMap).reduce<boolean[]>((acc, [key, item]) => {
acc[Number(key)] = item.show;
return acc;
}, []),
[legendItemsMap],
);
useEffect(() => {
setTableDataSet(
getDefaultTableDataSet(config.getConfig() as uPlot.Options, alignedData),
);
}, [alignedData, config]);
const filterHandler = useCallback(
(event: React.ChangeEvent<HTMLInputElement>): void => {
const value = event.target.value.toString().toLowerCase();
const updatedDataSet = tableDataSet.map((item) => {
if (item.label?.toLocaleLowerCase().includes(value)) {
return { ...item, show: true };
}
return { ...item, show: false };
});
setTableDataSet(updatedDataSet);
},
[tableDataSet],
);
const dataSource = useMemo(
() =>
tableDataSet.filter(
(item, index) => index !== 0 && item.show, // skipping the first item as it is the x-axis
),
[tableDataSet],
);
const columns = useMemo(
() =>
getGraphManagerTableColumns({
tableDataSet,
checkBoxOnChangeHandler: (_e, index) => {
onToggleSeriesOnOff(index);
},
graphVisibilityState,
labelClickedHandler: onToggleSeriesVisibility,
yAxisUnit,
isGraphDisabled: isDashboardLocked,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[tableDataSet, graphVisibilityState, yAxisUnit, isDashboardLocked],
);
const handleSave = useCallback((): void => {
syncSeriesVisibilityToLocalStorage();
notifications.success({
message: 'The updated graphs & legends are saved',
});
if (onCancel) {
onCancel();
}
}, [syncSeriesVisibilityToLocalStorage, notifications, onCancel]);
return (
<div className="chart-manager-container">
<div className="chart-manager-header">
<Input onChange={filterHandler} placeholder="Filter Series" />
<div className="chart-manager-actions-container">
<Button type="default" onClick={onCancel}>
Cancel
</Button>
<Button type="primary" onClick={handleSave}>
Save
</Button>
</div>
</div>
<div className="chart-manager-table-container">
<ResizeTable
columns={columns}
dataSource={dataSource}
virtual
rowKey="index"
scroll={{ y: 200 }}
pagination={false}
/>
</div>
</div>
);
}

View File

@@ -0,0 +1,313 @@
import { renderHook } from '@testing-library/react';
import uPlot from 'uplot';
import type { UseBarChartStackingParams } from '../useBarChartStacking';
import { useBarChartStacking } from '../useBarChartStacking';
type MockConfig = { addHook: jest.Mock };
function asConfig(c: MockConfig): UseBarChartStackingParams['config'] {
return (c as unknown) as UseBarChartStackingParams['config'];
}
function createMockConfig(): {
config: MockConfig;
invokeSetData: (plot: uPlot) => void;
invokeSetSeries: (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
) => void;
removeSetData: jest.Mock;
removeSetSeries: jest.Mock;
} {
let setDataHandler: ((plot: uPlot) => void) | null = null;
let setSeriesHandler:
| ((plot: uPlot, seriesIndex: number | null, opts: uPlot.Series) => void)
| null = null;
const removeSetData = jest.fn();
const removeSetSeries = jest.fn();
const addHook = jest.fn(
(
hookName: string,
handler: (plot: uPlot, ...args: unknown[]) => void,
): (() => void) => {
if (hookName === 'setData') {
setDataHandler = handler as (plot: uPlot) => void;
return removeSetData;
}
if (hookName === 'setSeries') {
setSeriesHandler = handler as (
plot: uPlot,
seriesIndex: number | null,
opts: uPlot.Series,
) => void;
return removeSetSeries;
}
return jest.fn();
},
);
const config: MockConfig = { addHook };
const invokeSetData = (plot: uPlot): void => {
setDataHandler?.(plot);
};
const invokeSetSeries = (
plot: uPlot,
seriesIndex: number | null,
opts: Partial<uPlot.Series> & { focus?: boolean },
): void => {
setSeriesHandler?.(plot, seriesIndex, opts as uPlot.Series);
};
return {
config,
invokeSetData,
invokeSetSeries,
removeSetData,
removeSetSeries,
};
}
function createMockPlot(overrides: Partial<uPlot> = {}): uPlot {
return ({
data: [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
],
series: [{ show: true }, { show: true }, { show: true }],
delBand: jest.fn(),
addBand: jest.fn(),
setData: jest.fn(),
...overrides,
} as unknown) as uPlot;
}
describe('useBarChartStacking', () => {
it('returns data as-is when isStackedBarChart is false', () => {
const data: uPlot.AlignedData = [
[100, 200],
[1, 2],
[3, 4],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: null,
}),
);
expect(result.current).toBe(data);
});
it('returns data as-is when config is null and isStackedBarChart is true', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[4, 5],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
// Still returns stacked data (computed in useMemo); no hooks registered
expect(result.current[0]).toEqual([0, 1]);
expect(result.current[1]).toEqual([5, 7]); // stacked
expect(result.current[2]).toEqual([4, 5]);
});
it('returns stacked data when isStackedBarChart is true and multiple value series', () => {
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
[7, 8, 9],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current[0]).toEqual([0, 1, 2]);
expect(result.current[1]).toEqual([12, 15, 18]); // s1+s2+s3
expect(result.current[2]).toEqual([11, 13, 15]); // s2+s3
expect(result.current[3]).toEqual([7, 8, 9]);
});
it('returns data as-is when only one value series (no stacking needed)', () => {
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
];
const { result } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: null,
}),
);
expect(result.current).toEqual(data);
});
it('registers setData and setSeries hooks when isStackedBarChart and config provided', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
expect(config.addHook).toHaveBeenCalledWith('setData', expect.any(Function));
expect(config.addHook).toHaveBeenCalledWith(
'setSeries',
expect.any(Function),
);
});
it('does not register hooks when isStackedBarChart is false', () => {
const { config } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: false,
config: asConfig(config),
}),
);
expect(config.addHook).not.toHaveBeenCalled();
});
it('calls cleanup when unmounted', () => {
const { config, removeSetData, removeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const { unmount } = renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
unmount();
expect(removeSetData).toHaveBeenCalled();
expect(removeSetSeries).toHaveBeenCalled();
});
it('re-stacks and updates plot when setData hook is invoked', () => {
const { config, invokeSetData } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1, 2],
[1, 2, 3],
[4, 5, 6],
];
const plot = createMockPlot({
data: [
[0, 1, 2],
[5, 7, 9],
[4, 5, 6],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetData(plot);
expect(plot.delBand).toHaveBeenCalledWith(null);
expect(plot.addBand).toHaveBeenCalled();
expect(plot.setData).toHaveBeenCalledWith(
expect.arrayContaining([
[0, 1, 2],
expect.any(Array), // stacked row 1
expect.any(Array), // stacked row 2
]),
);
});
it('re-stacks when setSeries hook is invoked (e.g. legend toggle)', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[10, 20],
[5, 10],
];
// Plot data must match unstacked length so canApplyStacking passes
const plot = createMockPlot({
data: [
[0, 1],
[15, 30],
[5, 10],
],
});
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
invokeSetSeries(plot, 1, { show: false });
expect(plot.setData).toHaveBeenCalled();
});
it('does not re-stack when setSeries is called with focus option', () => {
const { config, invokeSetSeries } = createMockConfig();
const data: uPlot.AlignedData = [
[0, 1],
[1, 2],
[3, 4],
];
const plot = createMockPlot();
renderHook(() =>
useBarChartStacking({
data,
isStackedBarChart: true,
config: asConfig(config),
}),
);
(plot.setData as jest.Mock).mockClear();
invokeSetSeries(plot, 1, { focus: true } as uPlot.Series);
expect(plot.setData).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,125 @@
import {
MutableRefObject,
useCallback,
useLayoutEffect,
useMemo,
useRef,
} from 'react';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { has } from 'lodash-es';
import uPlot from 'uplot';
import { stackSeries } from '../charts/utils/stackSeriesUtils';
/** Returns true if the series at the given index is hidden (e.g. via legend toggle). */
function isSeriesHidden(plot: uPlot, seriesIndex: number): boolean {
return !plot.series[seriesIndex]?.show;
}
function canApplyStacking(
unstackedData: uPlot.AlignedData | null,
plot: uPlot,
isUpdating: boolean,
): boolean {
return (
!isUpdating &&
!!unstackedData &&
!!plot.data &&
unstackedData[0]?.length === plot.data[0]?.length
);
}
function setupStackingHooks(
config: UPlotConfigBuilder,
applyStackingToChart: (plot: uPlot) => void,
isUpdatingRef: MutableRefObject<boolean>,
): () => void {
const onDataChange = (plot: uPlot): void => {
if (!isUpdatingRef.current) {
applyStackingToChart(plot);
}
};
const onSeriesVisibilityChange = (
plot: uPlot,
_seriesIdx: number | null,
opts: uPlot.Series,
): void => {
if (!has(opts, 'focus')) {
applyStackingToChart(plot);
}
};
const removeSetDataHook = config.addHook('setData', onDataChange);
const removeSetSeriesHook = config.addHook(
'setSeries',
onSeriesVisibilityChange,
);
return (): void => {
removeSetDataHook?.();
removeSetSeriesHook?.();
};
}
export interface UseBarChartStackingParams {
data: uPlot.AlignedData;
isStackedBarChart?: boolean;
config: UPlotConfigBuilder | null;
}
/**
* Handles stacking for bar charts: computes initial stacked data and re-stacks
* when data or series visibility changes (e.g. legend toggles).
*/
export function useBarChartStacking({
data,
isStackedBarChart = false,
config,
}: UseBarChartStackingParams): uPlot.AlignedData {
// Store unstacked source data so uPlot hooks can access it (hooks run outside React's render cycle)
const unstackedDataRef = useRef<uPlot.AlignedData | null>(null);
unstackedDataRef.current = isStackedBarChart ? data : null;
// Prevents re-entrant calls when we update chart data (avoids infinite loop in setData hook)
const isUpdatingChartRef = useRef(false);
const chartData = useMemo((): uPlot.AlignedData => {
if (!isStackedBarChart || !data || data.length < 2) {
return data;
}
const noSeriesHidden = (): boolean => false; // include all series in initial stack
const { data: stacked } = stackSeries(data, noSeriesHidden);
return stacked;
}, [data, isStackedBarChart]);
const applyStackingToChart = useCallback((plot: uPlot): void => {
const unstacked = unstackedDataRef.current;
if (
!unstacked ||
!canApplyStacking(unstacked, plot, isUpdatingChartRef.current)
) {
return;
}
const shouldExcludeSeries = (idx: number): boolean =>
isSeriesHidden(plot, idx);
const { data: stacked, bands } = stackSeries(unstacked, shouldExcludeSeries);
plot.delBand(null);
bands.forEach((band: uPlot.Band) => plot.addBand(band));
isUpdatingChartRef.current = true;
plot.setData(stacked);
isUpdatingChartRef.current = false;
}, []);
useLayoutEffect(() => {
if (!isStackedBarChart || !config) {
return undefined;
}
return setupStackingHooks(config, applyStackingToChart, isUpdatingChartRef);
}, [isStackedBarChart, config, applyStackingToChart]);
return chartData;
}

View File

@@ -0,0 +1,120 @@
import { useCallback } from 'react';
import { UseQueryResult } from 'react-query';
import {
getTimeRangeFromStepInterval,
isApmMetric,
} from 'container/PanelWrapper/utils';
import { getUplotClickData } from 'container/QueryTable/Drilldown/drilldownUtils';
import useGraphContextMenu from 'container/QueryTable/Drilldown/useGraphContextMenu';
import {
PopoverPosition,
useCoordinates,
} from 'periscope/components/ContextMenu';
import { SuccessResponse } from 'types/api';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataSource } from 'types/common/queryBuilder';
interface UseTimeSeriesContextMenuParams {
widget: Widgets;
queryResponse: UseQueryResult<
SuccessResponse<MetricRangePayloadProps, unknown>,
Error
>;
}
export const usePanelContextMenu = ({
widget,
queryResponse,
}: UseTimeSeriesContextMenuParams): {
coordinates: { x: number; y: number } | null;
popoverPosition: PopoverPosition | null;
onClose: () => void;
menuItemsConfig: {
header?: string | React.ReactNode;
items?: React.ReactNode;
};
clickHandlerWithContextMenu: (...args: any[]) => void;
} => {
const {
coordinates,
popoverPosition,
clickedData,
onClose,
subMenu,
onClick,
setSubMenu,
} = useCoordinates();
const { menuItemsConfig } = useGraphContextMenu({
widgetId: widget.id || '',
query: widget.query,
graphData: clickedData,
onClose,
coordinates,
subMenu,
setSubMenu,
contextLinks: widget.contextLinks,
panelType: widget.panelTypes,
queryRange: queryResponse,
});
const clickHandlerWithContextMenu = useCallback(
(...args: any[]) => {
const [
xValue,
_yvalue,
_mouseX,
_mouseY,
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
axesData,
focusedSeries,
] = args;
const data = getUplotClickData({
metric,
queryData,
absoluteMouseX,
absoluteMouseY,
focusedSeries,
});
let timeRange;
if (axesData && queryData?.queryName) {
const compositeQuery = (queryResponse?.data?.params as any)?.compositeQuery;
if (compositeQuery?.queries) {
const specificQuery = compositeQuery.queries.find(
(query: any) => query.spec?.name === queryData.queryName,
);
const stepInterval = specificQuery?.spec?.stepInterval || 60;
timeRange = getTimeRangeFromStepInterval(
stepInterval,
metric?.clickedTimestamp || xValue,
specificQuery?.spec?.signal === DataSource.METRICS &&
isApmMetric(specificQuery?.spec?.aggregations[0]?.metricName),
);
}
}
if (data && data?.record?.queryName) {
onClick(data.coord, { ...data.record, label: data.label, timeRange });
}
},
[onClick, queryResponse],
);
return {
coordinates,
popoverPosition,
onClose,
menuItemsConfig,
clickHandlerWithContextMenu,
};
};

View File

@@ -11,6 +11,7 @@ export interface ChartLayoutProps {
children: (props: {
chartWidth: number;
chartHeight: number;
averageLegendWidth: number;
}) => React.ReactNode;
layoutChildren?: React.ReactNode;
containerWidth: number;
@@ -56,6 +57,7 @@ export default function ChartLayout({
{children({
chartWidth: chartDimensions.width,
chartHeight: chartDimensions.height,
averageLegendWidth: chartDimensions.averageLegendWidth,
})}
</div>
<div
@@ -65,7 +67,7 @@ export default function ChartLayout({
width: chartDimensions.legendWidth,
}}
>
{legendComponent(chartDimensions.legendsPerSet)}
{legendComponent(chartDimensions.averageLegendWidth)}
</div>
</div>
{layoutChildren}

View File

@@ -0,0 +1,160 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { PanelWrapperProps } from 'container/PanelWrapper/panelWrapper.types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendPosition } from 'lib/uPlotV2/components/types';
import ContextMenu from 'periscope/components/ContextMenu';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useTimezone } from 'providers/Timezone';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import uPlot from 'uplot';
import { getTimeRange } from 'utils/getTimeRange';
import BarChart from '../../charts/BarChart/BarChart';
import ChartManager from '../../components/ChartManager/ChartManager';
import { usePanelContextMenu } from '../../hooks/usePanelContextMenu';
import { prepareBarPanelConfig, prepareBarPanelData } from './utils';
import '../Panel.styles.scss';
function BarPanel(props: PanelWrapperProps): JSX.Element {
const {
panelMode,
queryResponse,
widget,
onDragSelect,
isFullViewMode,
onToggleModelHandler,
} = props;
const uPlotRef = useRef<uPlot | null>(null);
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
const graphRef = useRef<HTMLDivElement>(null);
const [minTimeScale, setMinTimeScale] = useState<number>();
const [maxTimeScale, setMaxTimeScale] = useState<number>();
const containerDimensions = useResizeObserver(graphRef);
const isDarkMode = useIsDarkMode();
const { timezone } = useTimezone();
useEffect(() => {
if (toScrollWidgetId === widget.id) {
graphRef.current?.scrollIntoView({
behavior: 'smooth',
block: 'center',
});
graphRef.current?.focus();
setToScrollWidgetId('');
}
}, [toScrollWidgetId, setToScrollWidgetId, widget.id]);
useEffect((): void => {
const { startTime, endTime } = getTimeRange(queryResponse);
setMinTimeScale(startTime);
setMaxTimeScale(endTime);
}, [queryResponse]);
const {
coordinates,
popoverPosition,
onClose,
menuItemsConfig,
clickHandlerWithContextMenu,
} = usePanelContextMenu({
widget,
queryResponse,
});
const config = useMemo(() => {
return prepareBarPanelConfig({
widget,
isDarkMode,
currentQuery: widget.query,
onClick: clickHandlerWithContextMenu,
onDragSelect,
apiResponse: queryResponse?.data?.payload as MetricRangePayloadProps,
timezone,
panelMode,
minTimeScale: minTimeScale,
maxTimeScale: maxTimeScale,
});
}, [
widget,
isDarkMode,
queryResponse?.data?.payload,
clickHandlerWithContextMenu,
onDragSelect,
minTimeScale,
maxTimeScale,
timezone,
panelMode,
]);
const chartData = useMemo(() => {
if (!queryResponse?.data?.payload) {
return [];
}
return prepareBarPanelData(queryResponse?.data?.payload);
}, [queryResponse?.data?.payload]);
const layoutChildren = useMemo(() => {
if (!isFullViewMode) {
return null;
}
return (
<ChartManager
config={config}
alignedData={chartData}
yAxisUnit={widget.yAxisUnit}
onCancel={onToggleModelHandler}
/>
);
}, [
isFullViewMode,
config,
chartData,
widget.yAxisUnit,
onToggleModelHandler,
]);
const onPlotDestroy = useCallback(() => {
uPlotRef.current = null;
}, []);
const onPlotRef = useCallback((plot: uPlot | null): void => {
uPlotRef.current = plot;
}, []);
return (
<div className="panel-container" ref={graphRef}>
{containerDimensions.width > 0 && containerDimensions.height > 0 && (
<BarChart
config={config}
legendConfig={{
position: widget?.legendPosition ?? LegendPosition.BOTTOM,
}}
plotRef={onPlotRef}
onDestroy={onPlotDestroy}
yAxisUnit={widget.yAxisUnit}
decimalPrecision={widget.decimalPrecision}
timezone={timezone.value}
data={chartData as uPlot.AlignedData}
width={containerDimensions.width}
height={containerDimensions.height}
layoutChildren={layoutChildren}
isStackedBarChart={widget.stackedBarChart ?? false}
>
<ContextMenu
coordinates={coordinates}
popoverPosition={popoverPosition}
title={menuItemsConfig.header as string}
items={menuItemsConfig.items}
onClose={onClose}
/>
</BarChart>
)}
</div>
);
}
export default BarPanel;

View File

@@ -0,0 +1,118 @@
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getInitialStackedBands } from 'container/DashboardContainer/visualization/charts/utils/stackSeriesUtils';
import { getLegend } from 'lib/dashboard/getQueryResults';
import getLabelName from 'lib/getLabelName';
import { OnClickPluginOpts } from 'lib/uPlotLib/plugins/onClickPlugin';
import {
DrawStyle,
LineInterpolation,
LineStyle,
VisibilityMode,
} from 'lib/uPlotV2/config/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
import { get } from 'lodash-es';
import { Widgets } from 'types/api/dashboard/getAll';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { QueryData } from 'types/api/widgets/getQuery';
import { AlignedData } from 'uplot';
import { PanelMode } from '../types';
import { fillMissingXAxisTimestamps, getXAxisTimestamps } from '../utils';
import { buildBaseConfig } from '../utils/baseConfigBuilder';
export function prepareBarPanelData(
apiResponse: MetricRangePayloadProps,
): AlignedData {
const seriesList = apiResponse?.data?.result || [];
const timestampArr = getXAxisTimestamps(seriesList);
const yAxisValuesArr = fillMissingXAxisTimestamps(timestampArr, seriesList);
return [timestampArr, ...yAxisValuesArr];
}
export function prepareBarPanelConfig({
widget,
isDarkMode,
currentQuery,
onClick,
onDragSelect,
apiResponse,
timezone,
panelMode,
minTimeScale,
maxTimeScale,
}: {
widget: Widgets;
isDarkMode: boolean;
currentQuery: Query;
onClick: OnClickPluginOpts['onClick'];
onDragSelect: (startTime: number, endTime: number) => void;
apiResponse: MetricRangePayloadProps;
timezone: Timezone;
panelMode: PanelMode;
minTimeScale?: number;
maxTimeScale?: number;
}): UPlotConfigBuilder {
const builder = buildBaseConfig({
widget,
isDarkMode,
onClick,
onDragSelect,
apiResponse,
timezone,
panelMode,
panelType: PANEL_TYPES.BAR,
minTimeScale,
maxTimeScale,
});
builder.setCursor({
focus: {
prox: 1e3,
},
});
if (widget.stackedBarChart) {
const seriesCount = (apiResponse?.data?.result?.length ?? 0) + 1; // +1 for 1-based uPlot series indices
builder.setBands(getInitialStackedBands(seriesCount));
}
const stepIntervals: Record<string, number> = get(
apiResponse,
'data.newResult.meta.stepIntervals',
{},
);
const seriesList: QueryData[] = apiResponse?.data?.result || [];
seriesList.forEach((series) => {
const baseLabelName = getLabelName(
series.metric,
series.queryName || '', // query
series.legend || '',
);
const label = currentQuery
? getLegend(series, currentQuery, baseLabelName)
: baseLabelName;
const currentStepInterval = get(stepIntervals, series.queryName, undefined);
builder.addSeries({
scaleKey: 'y',
drawStyle: DrawStyle.Bar,
panelType: PANEL_TYPES.BAR,
label: label,
colorMapping: widget.customLegendColors ?? {},
spanGaps: false,
lineStyle: LineStyle.Solid,
lineInterpolation: LineInterpolation.Spline,
showPoints: VisibilityMode.Never,
pointSize: 5,
isDarkMode,
stepInterval: currentStepInterval,
});
});
return builder;
}

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