Compare commits

...

73 Commits

Author SHA1 Message Date
Yunus M
a63a61db29 Update pull_request_template.md
Add changelog for public field
2025-12-26 11:46:03 +05:30
Abhi kumar
d218cd5733 fix: added fix for reduceTo selection based on metric type + code cleanup (#9732)
* fix: added fixes for reduce-to, auto open + metric based default value

* fix: fixed raise condition

* chore: removed unnessasary useeffect from spaceaggregation

* test: added fix for failing test in usequerybuilderoperations

* fix: pr review comments

* fix: pr review changes
2025-12-25 22:54:25 +05:30
Piyush Singariya
f6da9adb86 chore(JSON): JSON Plan Util (#9596) 2025-12-24 13:08:55 +05:30
Aditya Singh
c82f54b548 fix: update spelling (#9864) 2025-12-24 05:28:32 +00:00
Vinicius Lourenço
dba038c6e0 perf: remove typography.text that was causing style recalculation during scroll (#9785) 2025-12-23 09:01:55 +00:00
Piyush Singariya
bca761498a chore(JSON): Promote Body Paths API (#9592) 2025-12-23 14:11:52 +05:30
Abhi kumar
0e6bd90fdf fix: added fix for panel rerenders on interaction (#9855)
* fix: added fix for panel rerenders on interaction

* fix: fixed tsc issue
2025-12-23 13:59:23 +05:30
Shaheer Kochai
f3256aeac4 fix: restore click-to-copy for non-JSON body fields after #9657 (#9769)
* fix: restore click-to-copy for non-JSON body fields

* chore: add test for 'copy non-json log body' flow

* fix: remove JSON parse error state in order to fallback to non-json body rendering

* chore: restore the body field json parsing error condition

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-12-23 07:22:59 +00:00
Shaheer Kochai
c9f1526e33 fix: fix the issue of hidden related logs in span logs drawer (#9844)
* fix: fix the issue of hidden related logs in span logs drawer

* chore: minor refactor
2025-12-23 06:51:22 +00:00
Shaheer Kochai
dba536578b fix: remove key prefix when copying log body fields (#9774)
* fix: copy values without keys in log body JSON view

* chore: update the comments

---------

Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-12-23 06:30:36 +00:00
Ishan
15ceb228fa feat: updated cmdk and added shift overlay (#9814)
* feat: updated cmdk and added shift overlay

* feat: updated cmdk palette and shortcut options

* feat: updated viewer role

* feat: added respective test cases

* feat: updated respective test cases and PR fail case

* feat: removed sidebar from cmdK testcase

* feat: added try-catch block to avoid edgecase fails

* feat: removed console and added checks for keyboard keys

* feat: updated for PR fixes

* feat: updated for PR and testcase fix

* feat: updated logs icons

* feat: added AUTHOR back - consistent as code

* feat: fixed dashboard bug

* feat: updated shift overlay delay time
2025-12-23 11:44:41 +05:30
Vikrant Gupta
6b3c6fc722 fix(dashboard): index out of bounds for widget query range (#9851) 2025-12-22 15:21:06 +00:00
Abhi kumar
edcae53b64 fix: added fix for quick filter reset not working (#9824)
* fix: added fix for quick filter reset not working

* chore: added fix for quick filter reset issue

* chore: removed non-required code

* test: added tests for checkbox component

* test: added updated test for querysearch

* chore: updated querysearch test
2025-12-19 18:34:51 +05:30
Vikrant Gupta
72fda90ec2 fix(apikey): batch last seen sql update for api-key middleware (#9833)
* fix(apikey): batch last seen sql update for api-key middleware

* fix(apikey): remove debug statement

* fix(apikey): remove debug statement
2025-12-19 14:56:33 +05:30
Yunus M
8acfc3c9f7 Update CODEOWNERS (#9828)
Update codeowners for frontend repo from individuals to frontend-maintainers team
2025-12-18 23:51:36 +05:30
Shaheer Kochai
463ae443f9 feat: add support for truncating long status_message in trace details and showing expandable popover on hover (#9630)
* feat: add support for showing truncated status_message and showing expandable popover on hover

* test: add tests for status message truncation and expandable popover functionality

* test: update expand button interaction to use fireEvent for status message modal
2025-12-18 15:18:40 +00:00
Abhi kumar
f72535a15f fix: added fix for prefix units not rendering in value panel (#9750)
* fix: added fix for prefix units not rendering in value panel

* chore: updated snapshot for valuepanelwrapper

* chore: added changes to stop unit from recomputation

* chore: added support for scientific notation as well

* chore: pr comments fixes
2025-12-18 12:32:23 +00:00
Abhi kumar
e21e99ce64 fix: clear search term when closing widget header search (#9663)
* fix: clear search term when closing widget header search

* test: added test for widgetheader component

* fix: added fixes for pr comments

* chore: updated test to use mock antd

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2025-12-18 12:22:12 +00:00
Abhi kumar
d1559a3262 test: added test for testing codemirror state when switching tabs (#9766)
* test: added test for testing codemirror state when switching tabs

* chore: added test for verify query-add-on and query-aggregation being rendered
2025-12-18 12:11:57 +00:00
Abhi kumar
1ccb9bb4c2 fix: added fix for free text with quick filter issue (#9768)
* fix: added fix free text with quick filter issue

* chore: removed debug console log

* chore: pr review changes
2025-12-18 12:00:08 +00:00
Vikrant Gupta
0c059df327 feat(global): add global config support (#9826)
* feat(global): add global config support

* feat(global): revert factory name changes

* feat(global): add global config support
2025-12-18 11:40:37 +00:00
Nikhil Mantri
8a5539679c chore(metrics-explorer): API for the alerts with metric_name (#9640) 2025-12-18 10:10:51 +00:00
Ashwin Bhatkal
89b188f73d fix: unable to edit panels when dashboard path has trailing slash (#9816)
* fix: unable to edit panels when dashboard path has trailing slash

* fix: name better

* fix: add tests

* fix: resolve comment

* fix: itch - variable rename

* fix: typo
2025-12-18 09:42:43 +05:30
Pandey
bb4d6117ac test: add integration tests for preferences and add --with-web flag (#9821)
* test: add integration test for preferences

* test: add flag --with-web
2025-12-18 00:05:27 +05:30
primus-bot[bot]
1110864549 chore(release): bump to v0.105.1 (#9819)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-12-17 14:17:54 +05:30
Vikrant Gupta
5cb515cade fix(apiserver): preference routes registeration before user routes (#9818) 2025-12-17 14:01:28 +05:30
primus-bot[bot]
41d5f6a00c chore(release): bump to v0.105.0 (#9815)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-12-17 12:33:56 +05:30
shubham-signoz
61ec1ef28e chore(onboardingv2/config): python traces changes (#9808)
Signed-off-by: Shubham Dubey <shubham@signoz.io>
Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
2025-12-17 08:35:53 +05:30
Yunus M
529a9e7009 fix: handle default columns in logs and traces explorer (#9722)
* fix: handle default columns in logs and traces explorer

* fix: filter out selected columns based on signal in logs and traces explorer
2025-12-16 13:32:18 +05:30
Nikhil Mantri
b00687b43f chore(metrics-explorer): API for the dashboards with metric_name (#9638) 2025-12-16 12:08:00 +05:30
Pandey
8771919de6 feat(gen): add cobra command for generating openapi spec (#9803)
add cobra command for auto-generating openapi spec
2025-12-15 17:48:30 +05:30
Nikhil Mantri
497972f23c chore(metrics-explorer): address follow-up comments (#9730) 2025-12-15 14:59:30 +05:30
swapnil-signoz
a9e30919d1 Refactor/aws api gateway dashboard (#9763)
* refactor: updating api gateway dashboard to support multiple types of APIs i.e. REST, HTTP and Websocket.
2025-12-15 14:34:39 +05:30
Ishan
925c4c4a3d fix: UI/UX fixes on Global Actions (CMD / CTRL + K) (#9739)
* feat: command K palette , removed kbar

* chore: updated cmdk for login checks and icons

* feat: updated icons and test cases

* adding more llm monitoring sources to onbaording(frontend) (#9623)

* feat: code update, PR comment fix, package.json update

* feat: code update, removed expand icon, moved keyboard func

* feat: css variable update

* feat: removed kbar from applayout

* feat: updated cursor bot comments

* feat: updated cursor bot and test case file

* feat: scss formatted

* feat: deleted unwanted merge change

---------

Co-authored-by: gkarthi-signoz <goutham@signoz.io>
Co-authored-by: Aditya Singh <adityasinghssj1@gmail.com>
2025-12-15 08:41:00 +05:30
Piyush Singariya
e66bfe5961 feat(JSON): JSON Body Metadata (#9593)
* feat: json Body Keys

* feat: telemetry types

* feat: change ExtractBodyPaths

* chore: minor comment change

* chore: func rename, file rename

* chore: change table names

* chore: reflect changes from the overhaul

* test: fixing test 1

* fix: test TestQueryToKeys

* fix: test TestPrepareLogsQuery

* chore: remove db

* chore: go mod

* chore: changes based on review

* chore: changes based on review

* fix: in LIKE operation

* chore: addressed few changes

* revert: test file

* fix: comparison fix

* test: add TestBuildListLogsJSONIndexesQuery

* fix: in test TestBuildListLogsJSONIndexesQuery

* fix: pull promoted paths in single db call

* fix: reducing db calls

* test: fix TestBuildListLogsJSONIndexesQuery

* fix: test TestConditionForJSONBodySearch

* fix: lint try 1

* chore: review changes based on cursor

* fix: use enums only

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
Co-authored-by: Nityananda Gohain <nityanandagohain@gmail.com>
2025-12-09 20:47:26 +07:00
Nikhil Mantri
42943f72b7 chore(metric-metadata): do not delete rows, keep inserting, pick latest 2025-12-09 05:46:48 +00:00
Nikhil Mantri
7a72a209e5 chore: metric highlights API for detailed view (#9679) 2025-12-09 00:09:23 +05:30
Karan Balani
44f00943a8 fix: tokenizer cache ttls and guardrails for config (#9776) 2025-12-05 11:01:05 +05:30
Nikhil Mantri
8867e1ef38 chore: metric Metadata Seperate Attributes API (#9622) 2025-12-04 19:38:37 +00:00
Abhi kumar
c08e520941 chore: removed alertRuleProvider from global state (#9648) 2025-12-04 10:27:50 +00:00
Ishan
139cc4452d style: metrics custom function css overflow issues (#9660)
* style: metrics custom function css overflow issues

* feat: add support for recovery threshold (#9428)

* feat: created common component for overflowing input tooltip

* feat: updated function.tsx to have useMemo for debounce

* feat: removed unwanted useEffect and moved inline css to separate file

* feat: re-applied useEffect due to css issues

* feat: removed inline styling

* feat: updated mirror ref to be in common component along with css updates

* feat: reverted prom_rule.go

* feat: code cleanup - input ref/forwardref cleanup

* feat: code cleanup - updated test file

* feat: extracted mirror-ref outside of tooltip

* feat: removed unwanted css

* feat: code optmized

* feat: test file updated

* feat: snapshot update

---------

Co-authored-by: Ishan Uniyal <ishan@Ishans-MacBook-Pro.local>
Co-authored-by: Abhishek Kumar Singh <hritik6058@gmail.com>
2025-12-04 15:04:39 +05:30
Nityananda Gohain
2f3baeb302 fix: fix third party api filtering (#9770) 2025-12-03 23:36:05 +05:30
Abhishek Kumar Singh
3d42b0058e chore: Query filter extraction API (#9617) 2025-12-03 13:13:32 +00:00
Srikanth Chekuri
ed70e3c5f5 chore: update .github/CODEOWNERS (#9759) 2025-12-03 11:06:30 +00:00
Ishan
7d6918f8b6 style: updated subdomain already exists UI error on 409 (#9661)
* style: updated subdomain already exists UI error on 409

* style: updated subdomain default message

---------

Co-authored-by: Ishan Uniyal <ishan@Ishans-MacBook-Pro.local>
2025-12-03 15:12:44 +05:30
primus-bot[bot]
2885bc851e chore(release): bump to v0.104.0 (#9765)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-12-03 12:16:07 +05:30
Vishal Sharma
857258f8c3 feat: expand related search keywords (#9728)
* feat: expand related search keywords and add React Native option to onboarding configurations

* feat: enhance onboarding data source configurations
With nested questions for migration and log collection, and add new related logos.

* chore: update React Native doc links to absolute URLs and remove 404

* feat: revert datasource changes
2025-12-03 06:07:57 +00:00
Yunus M
ece5c2b7ad fix: error details container - add padding and improve typography of title and desc (#9753)
* fix: add padding to error details container

* fix: update typography of title and description
2025-12-03 05:47:30 +00:00
Shaheer Kochai
1078f98388 feat: add support for copying individual JSON tree nodes in log details (#9657)
* feat: implement copy functionality for individual JSON tree nodes in log details

* chore: add tests for individual json tree nodes in log details

* test: enhance copy button tests for BodyTitleRenderer

* feat: add support for copying any node in json tree in log details

* test: update BodyTitleRenderer tests to verify copy functionality for JSON tree nodes
2025-12-03 02:50:02 +00:00
Abhi kumar
b4e2326f38 fix: added y-axis unit selector in traces view (#9761) 2025-12-03 00:43:58 +05:30
Abhi kumar
c79b154215 fix: added fix for duplicate y-axis selector in metrics explorer (#9758) 2025-12-02 23:45:48 +05:30
Yunus M
a59c0188cc feat: enable / revoke public access to a dashboard (#9642) 2025-12-02 22:30:24 +05:30
Shaheer Kochai
3df426625a fix: remove isRoot and isEntrypoint from the list of selectable columns in the columns menu in traces explorer and traces list panel in dashboard (#9629)
* fix: hide isRoot and isEntryPoint options from columns options

* test: add tests to ensure isRoot and isEntryPoint are hidden in column options

* refactor: improve the columns exclusion logic + update test
2025-12-02 16:20:04 +00:00
Karan Balani
646f359f33 feat: add openfga instrumentation configuration (#9754) 2025-12-02 15:28:42 +00:00
Vikrant Gupta
81167c6947 fix(dashboard): send public dashboard id on create (#9755) 2025-12-02 19:57:10 +05:30
Karan Balani
bc1295b93a feat: trace span for binary marshal and unmarshal operations (#9740) 2025-12-02 10:43:11 +00:00
swapnil-signoz
3db0e1f66a feat: enhanced container insights dashboard (#9742)
* feat: adding enhanced container insights dashboard for task level ECS metrics
2025-12-02 14:41:24 +05:30
primus-bot[bot]
d52b54aeb3 chore(release): bump to v0.103.1 (#9749)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2025-12-02 13:18:22 +05:30
Abhishek Kumar Singh
c8608c18ae fix: deadlock in prom rule (#9741) 2025-12-02 12:27:08 +05:30
Tushar Vats
cde99ba1a0 fix: deprecate field kind (#9609)
This pull request refines how deprecated and new trace fields are mapped and handled within the query service, ensuring more accurate field translation and data type usage. It also updates related test cases and constant definitions to reflect these changes, improving consistency and correctness when working with trace attributes like `kind` and `kind_string`.
2025-12-02 10:07:25 +05:30
Karan Balani
a7e9d442b7 fix: setup the acs url while creating saml client (#9744) 2025-12-01 19:33:43 +00:00
Yunus M
0b0d622f6b feat: support y axis unit in timeseries view of logs and traces explorer (#9709) 2025-12-01 21:09:30 +05:30
Yunus M
127e760b00 fix: filter expression not being sent on reconnect (#9720) 2025-12-01 20:00:48 +05:30
Abhi kumar
63e333de0d fix: added fix for cancel run button flickering issue (#9738) 2025-12-01 16:28:40 +05:30
Tushar Vats
af57d11b6a fix: nil err check (#9662)
This pull request refactors error variable naming throughout the codebase for improved clarity and consistency. The main change is replacing the generic variable name err with apiErr when handling errors of type *model.ApiError. Additionally, some related function signatures and comments were updated to match this change. No business logic or behaviour is affected; this is a code quality and maintainability improvement.
2025-12-01 04:08:17 +00:00
Vikrant Gupta
8d61ee338b feat(auth-domain): add idp initiated url in auth domain (#9721) 2025-11-30 16:30:13 +05:30
Tushar Vats
5d9dc17645 fix: escape $ signs in materialised columns (#9667) 2025-11-30 02:16:52 +05:30
Nikhil Mantri
5288022ffd chore: metrics explorer summary v2 APIs (#9579) 2025-11-29 20:01:13 +00:00
Amlan Kumar Nandy
cdc18af4a2 chore: new y axis unit selector with support for ucum units (#9615) 2025-11-30 01:11:54 +05:30
gkarthi-signoz
918a90e3c1 adding more llm monitoring sources to onbaording(frontend) (#9623) 2025-11-29 03:26:31 +00:00
Karan Balani
e8ce7b22f5 feat: idp initiated saml authn (#9716)
Support IDP initiated SAML authentication.
2025-11-28 19:29:44 +00:00
shubham-signoz
b752fdd30a feat(onboarding): add Cloudflare logs configuration entry (#9673)
* feat(onboarding): add Cloudflare logs configuration entry

Addresses https://github.com/SigNoz/engineering-pod/issues/3302

Signed-off-by: Shubham Dubey <shubham@signoz.io>

* chore: use proper labels

Signed-off-by: Shubham Dubey <shubham@signoz.io>

---------

Signed-off-by: Shubham Dubey <shubham@signoz.io>
Co-authored-by: Vishal Sharma <makeavish786@gmail.com>
2025-11-28 11:53:25 +00:00
SagarRajput-7
d73b7fadab chore: fix import and consumption issues with design system component (#9694)
* chore: fix import and consumption issues with design system component

* fix: enable auto-imports for @signozhq components via explicit registry
2025-11-28 16:30:02 +05:30
362 changed files with 32871 additions and 4970 deletions

42
.github/CODEOWNERS vendored
View File

@@ -2,52 +2,12 @@
# Owners are automatically requested for review for PRs that changes code
# that they own.
/frontend/ @YounixM @aks07
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
/frontend/ @SigNoz/frontend-maintainers
# Onboarding
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
# Dashboard, Alert, Metrics, Service Map, Services
/frontend/src/container/ListOfDashboard/ @srikanthccv
/frontend/src/container/NewDashboard/ @srikanthccv
/frontend/src/pages/DashboardsListPage/ @srikanthccv
/frontend/src/pages/DashboardWidget/ @srikanthccv
/frontend/src/pages/NewDashboard/ @srikanthccv
/frontend/src/providers/Dashboard/ @srikanthccv
# Alerts
/frontend/src/container/AlertHistory/ @srikanthccv
/frontend/src/container/AllAlertChannels/ @srikanthccv
/frontend/src/container/AnomalyAlertEvaluationView/ @srikanthccv
/frontend/src/container/CreateAlertChannels/ @srikanthccv
/frontend/src/container/CreateAlertRule/ @srikanthccv
/frontend/src/container/EditAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertChannels/ @srikanthccv
/frontend/src/container/FormAlertRules/ @srikanthccv
/frontend/src/container/ListAlertRules/ @srikanthccv
/frontend/src/container/TriggeredAlerts/ @srikanthccv
/frontend/src/pages/AlertChannelCreate/ @srikanthccv
/frontend/src/pages/AlertDetails/ @srikanthccv
/frontend/src/pages/AlertHistory/ @srikanthccv
/frontend/src/pages/AlertList/ @srikanthccv
/frontend/src/pages/CreateAlert/ @srikanthccv
/frontend/src/providers/Alert.tsx @srikanthccv
# Metrics
/frontend/src/container/MetricsExplorer/ @srikanthccv
/frontend/src/pages/MetricsApplication/ @srikanthccv
/frontend/src/pages/MetricsExplorer/ @srikanthccv
# Services and Service Map
/frontend/src/container/ServiceApplication/ @srikanthccv
/frontend/src/container/ServiceTable/ @srikanthccv
/frontend/src/pages/Services/ @srikanthccv
/frontend/src/pages/ServiceTopLevelOperations/ @srikanthccv
/frontend/src/container/Home/Services/ @srikanthccv
/deploy/ @SigNoz/devops
.github @SigNoz/devops

View File

@@ -11,6 +11,20 @@
---
## 📝 Changelog
> Fill this only if the change affects users, APIs, UI, or documented behavior.
Mention as N/A for internal refactors or non-user-visible changes.
**Deployment Type:** Cloud / OSS / Enterprise
**Type:** Feature / Bug Fix / Maintenance
**Description:** Short, user-facing summary of the change
---
## 🏷️ Required: Add Relevant Labels
> ⚠️ **Manually add appropriate labels in the PR sidebar**

View File

@@ -73,3 +73,19 @@ jobs:
shell: bash
run: |
make docker-build-enterprise
openapi:
if: |
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
runs-on: ubuntu-latest
steps:
- name: self-checkout
uses: actions/checkout@v4
- name: go-install
uses: actions/setup-go@v5
with:
go-version: "1.24"
- name: generate-openapi
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)

View File

@@ -9,6 +9,29 @@ on:
- labeled
jobs:
fmtlint:
if: |
((github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))) && contains(github.event.pull_request.labels.*.name, 'safe-to-integrate')
runs-on: ubuntu-latest
steps:
- name: checkout
uses: actions/checkout@v4
- name: python
uses: actions/setup-python@v5
with:
python-version: 3.13
- name: poetry
run: |
python -m pip install poetry==2.1.2
python -m poetry config virtualenvs.in-project true
cd tests/integration && poetry install --no-root
- name: fmt
run: |
make py-fmt
- name: lint
run: |
make py-lint
test:
strategy:
fail-fast: false
@@ -21,6 +44,7 @@ jobs:
- dashboard
- querier
- ttl
- preference
sqlstore-provider:
- postgres
- sqlite

1
.gitignore vendored
View File

@@ -49,6 +49,7 @@ ee/query-service/tests/test-deploy/data/
# local data
*.backup
*.db
**/db
/deploy/docker/clickhouse-setup/data/
/deploy/docker-swarm/clickhouse-setup/data/
bin/

View File

@@ -72,6 +72,12 @@ devenv-up: devenv-clickhouse devenv-signoz-otel-collector ## Start both clickhou
@echo " - ClickHouse: http://localhost:8123"
@echo " - Signoz OTel Collector: grpc://localhost:4317, http://localhost:4318"
.PHONY: devenv-clickhouse-clean
devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
@echo "Removing ClickHouse data..."
@rm -rf .devenv/docker/clickhouse/fs/tmp/*
@echo "ClickHouse data cleaned!"
##############################################################
# go commands
##############################################################

View File

@@ -13,6 +13,7 @@ func main() {
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.RegisterGenerate(cmd.RootCmd, logger)
cmd.Execute(logger)
}

View File

@@ -1,11 +1,3 @@
FROM node:18-bullseye AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 yarn install
RUN CI=1 yarn build
FROM golang:1.24-bullseye
ARG OS="linux"
@@ -40,8 +32,6 @@ COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["/root/signoz", "server"]

View File

@@ -0,0 +1,47 @@
FROM node:18-bullseye AS build
WORKDIR /opt/
COPY ./frontend/ ./
ENV NODE_OPTIONS=--max-old-space-size=8192
RUN CI=1 yarn install
RUN CI=1 yarn build
FROM golang:1.24-bullseye
ARG OS="linux"
ARG TARGETARCH
ARG ZEUSURL
# This path is important for stacktraces
WORKDIR $GOPATH/src/github.com/signoz/signoz
WORKDIR /root
RUN set -eux; \
apt-get update; \
apt-get install -y --no-install-recommends \
g++ \
gcc \
libc6-dev \
make \
pkg-config \
; \
rm -rf /var/lib/apt/lists/*
COPY go.mod go.sum ./
RUN go mod download
COPY ./cmd/ ./cmd/
COPY ./ee/ ./ee/
COPY ./pkg/ ./pkg/
COPY ./templates/email /root/templates
COPY Makefile Makefile
RUN TARGET_DIR=/root ARCHS=${TARGETARCH} ZEUS_URL=${ZEUSURL} LICENSE_URL=${ZEUSURL}/api/v1 make go-build-enterprise-race
RUN mv /root/linux-${TARGETARCH}/signoz /root/signoz
COPY --from=build /opt/build ./web/
RUN chmod 755 /root /root/signoz
ENTRYPOINT ["/root/signoz", "server"]

View File

@@ -13,6 +13,7 @@ func main() {
// register a list of commands to the root command
registerServer(cmd.RootCmd, logger)
cmd.RegisterGenerate(cmd.RootCmd, logger)
cmd.Execute(logger)
}

21
cmd/generate.go Normal file
View File

@@ -0,0 +1,21 @@
package cmd
import (
"log/slog"
"github.com/spf13/cobra"
)
func RegisterGenerate(parentCmd *cobra.Command, logger *slog.Logger) {
var generateCmd = &cobra.Command{
Use: "generate",
Short: "Generate artifacts",
SilenceUsage: true,
SilenceErrors: true,
CompletionOptions: cobra.CompletionOptions{DisableDefaultCmd: true},
}
registerGenerateOpenAPI(generateCmd)
parentCmd.AddCommand(generateCmd)
}

41
cmd/openapi.go Normal file
View File

@@ -0,0 +1,41 @@
package cmd
import (
"context"
"log/slog"
"github.com/SigNoz/signoz/pkg/instrumentation"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/version"
"github.com/spf13/cobra"
)
func registerGenerateOpenAPI(parentCmd *cobra.Command) {
openapiCmd := &cobra.Command{
Use: "openapi",
Short: "Generate OpenAPI schema for SigNoz",
RunE: func(currCmd *cobra.Command, args []string) error {
return runGenerateOpenAPI(currCmd.Context())
},
}
parentCmd.AddCommand(openapiCmd)
}
func runGenerateOpenAPI(ctx context.Context) error {
instrumentation, err := instrumentation.New(ctx, instrumentation.Config{Logs: instrumentation.LogsConfig{Level: slog.LevelInfo}}, version.Info, "signoz")
if err != nil {
return err
}
openapi, err := signoz.NewOpenAPI(ctx, instrumentation)
if err != nil {
return err
}
if err := openapi.CreateAndWrite("docs/api/openapi.yml"); err != nil {
return err
}
return nil
}

View File

@@ -3,6 +3,13 @@
# Do not modify this file
#
##################### Global #####################
global:
# the url under which the signoz apiserver is externally reachable.
external_url: <unset>
# the url where the SigNoz backend receives telemetry data (traces, metrics, logs) from instrumented applications.
ingestion_url: <unset>
##################### Version #####################
version:
banner:

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.103.0
image: signoz/signoz:v0.105.1
command:
- --config=/root/config/prometheus.yml
ports:

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.103.0
image: signoz/signoz:v0.105.1
command:
- --config=/root/config/prometheus.yml
ports:

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.103.0}
image: signoz/signoz:${VERSION:-v0.105.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

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.103.0}
image: signoz/signoz:${VERSION:-v0.105.1}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

2428
docs/api/openapi.yml Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,179 @@
# Handler
Handlers in SigNoz are responsible for exposing module functionality over HTTP. They are thin adapters that:
- Decode incoming HTTP requests
- Call the appropriate module layer
- Return structured responses (or errors) in a consistent format
- Describe themselves for OpenAPI generation
They are **not** the place for complex business logic; that belongs in modules (for example, `pkg/modules/user`, `pkg/modules/session`, etc).
## How are handlers structured?
At a high level, a typical flow looks like this:
1. A `Handler` interface is defined in the module (for example, `user.Handler`, `session.Handler`, `organization.Handler`).
2. The `apiserver` provider wires those handlers into HTTP routes using Gorilla `mux.Router`.
Each route wraps a module handler method with the following:
- Authorization middleware (from `pkg/http/middleware`)
- A generic HTTP `handler.Handler` (from `pkg/http/handler`)
- An `OpenAPIDef` that describes the operation for OpenAPI generation
For example, in `pkg/apiserver/signozapiserver`:
```go
if err := router.Handle("/api/v1/invite", handler.New(
provider.authZ.AdminAccess(provider.userHandler.CreateInvite),
handler.OpenAPIDef{
ID: "CreateInvite",
Tags: []string{"users"},
Summary: "Create invite",
Description: "This endpoint creates an invite for a user",
Request: new(types.PostableInvite),
RequestContentType: "application/json",
Response: new(types.Invite),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
```
In this pattern:
- `provider.userHandler.CreateInvite` is a handler method.
- `provider.authZ.AdminAccess(...)` wraps that method with authorization checks and context setup.
- `handler.New` converts it into an HTTP handler and wires it to OpenAPI via the `OpenAPIDef`.
## How to write a new handler method?
When adding a new endpoint:
1. Add a method to the appropriate module `Handler` interface.
2. Implement that method in the module.
3. Register the method in `signozapiserver` with the correct route, HTTP method, auth, and `OpenAPIDef`.
### 1. Extend an existing `Handler` interface or create a new one
Find the module in `pkg/modules/<name>` and extend its `Handler` interface with a new method that receives an `http.ResponseWriter` and `*http.Request`. For example:
```go
type Handler interface {
// existing methods...
CreateThing(rw http.ResponseWriter, req *http.Request)
}
```
Keep the method focused on HTTP concerns and delegate business logic to the module.
### 2. Implement the handler method
In the module implementation, implement the new method. A typical implementation:
- Extracts authentication and organization context from `req.Context()`
- Decodes the request body into a `types.*` struct using the `binding` package
- Calls module functions
- Uses the `render` package to write responses or errors
```go
func (h *handler) CreateThing(rw http.ResponseWriter, req *http.Request) {
// Extract authentication and organization context from req.Context()
claims, err := authtypes.ClaimsFromContext(req.Context())
if err != nil {
render.Error(rw, err)
return
}
// Decode the request body into a `types.*` struct using the `binding` package
var in types.PostableThing
if err := binding.JSON.BindBody(req.Body, &in); err != nil {
render.Error(rw, err)
return
}
// Call module functions
out, err := h.module.CreateThing(req.Context(), claims.OrgID, &in)
if err != nil {
render.Error(rw, err)
return
}
// Use the `render` package to write responses or errors
render.Success(rw, http.StatusCreated, out)
}
```
### 3. Register the handler in `signozapiserver`
In `pkg/apiserver/signozapiserver`, add a route in the appropriate `add*Routes` function (`addUserRoutes`, `addSessionRoutes`, `addOrgRoutes`, etc.). The pattern is:
```go
if err := router.Handle("/api/v1/things", handler.New(
provider.authZ.AdminAccess(provider.thingHandler.CreateThing),
handler.OpenAPIDef{
ID: "CreateThing",
Tags: []string{"things"},
Summary: "Create thing",
Description: "This endpoint creates a thing",
Request: new(types.PostableThing),
RequestContentType: "application/json",
Response: new(types.GettableThing),
ResponseContentType: "application/json",
SuccessStatusCode: http.StatusCreated,
ErrorStatusCodes: []int{http.StatusBadRequest, http.StatusConflict},
Deprecated: false,
SecuritySchemes: newSecuritySchemes(types.RoleAdmin),
},
)).Methods(http.MethodPost).GetError(); err != nil {
return err
}
```
### 4. Update the OpenAPI spec
Run the following command to update the OpenAPI spec:
```bash
go run cmd/enterprise/*.go generate openapi
```
This will update the OpenAPI spec in `docs/api/openapi.yml` to reflect the new endpoint.
## How does OpenAPI integration work?
The `handler.New` function ties the HTTP handler to OpenAPI metadata via `OpenAPIDef`. This drives the generated OpenAPI document.
- **ID**: A unique identifier for the operation (used as the `operationId`).
- **Tags**: Logical grouping for the operation (for example, `"users"`, `"sessions"`, `"orgs"`).
- **Summary / Description**: Human-friendly documentation.
- **Request / RequestContentType**:
- `Request` is a Go type that describes the request body or form.
- `RequestContentType` is usually `"application/json"` or `"application/x-www-form-urlencoded"` (for callbacks like SAML).
- **Response / ResponseContentType**:
- `Response` is the Go type for the successful response payload.
- `ResponseContentType` is usually `"application/json"`; use `""` for responses without a body.
- **SuccessStatusCode**: The HTTP status for successful responses (for example, `http.StatusOK`, `http.StatusCreated`, `http.StatusNoContent`).
- **ErrorStatusCodes**: Additional error status codes beyond the standard ones automatically added by `handler.New`.
- **SecuritySchemes**: Auth mechanisms and scopes required by the operation.
The generic handler:
- Automatically appends `401`, `403`, and `500` to `ErrorStatusCodes` when appropriate.
- Registers request and response schemas with the OpenAPI reflector so they appear in `docs/api/openapi.yml`.
See existing examples in:
- `addUserRoutes` (for typical JSON request/response)
- `addSessionRoutes` (for form-encoded and redirect flows)
## What should I remember?
- **Keep handlers thin**: focus on HTTP concerns and delegate logic to modules/services.
- **Always register routes through `signozapiserver`** using `handler.New` and a complete `OpenAPIDef`.
- **Choose accurate request/response types** from the `types` packages so OpenAPI schemas are correct.

View File

@@ -129,6 +129,12 @@ func (a *AuthN) HandleCallback(ctx context.Context, query url.Values) (*authtype
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
return &authtypes.AuthNProviderInfo{
RelayStatePath: nil,
}
}
func (a *AuthN) oidcProviderAndoauth2Config(ctx context.Context, siteURL *url.URL, authDomain *authtypes.AuthDomain) (*oidc.Provider, *oauth2.Config, error) {
if authDomain.AuthDomainConfig().OIDC.IssuerAlias != "" {
ctx = oidc.InsecureIssuerURLContext(ctx, authDomain.AuthDomainConfig().OIDC.IssuerAlias)

View File

@@ -99,6 +99,14 @@ func (a *AuthN) HandleCallback(ctx context.Context, formValues url.Values) (*aut
return authtypes.NewCallbackIdentity("", email, authDomain.StorableAuthDomain().OrgID, state), nil
}
func (a *AuthN) ProviderInfo(ctx context.Context, authDomain *authtypes.AuthDomain) *authtypes.AuthNProviderInfo {
state := authtypes.NewState(&url.URL{Path: "login"}, authDomain.StorableAuthDomain().ID).URL.String()
return &authtypes.AuthNProviderInfo{
RelayStatePath: &state,
}
}
func (a *AuthN) serviceProvider(siteURL *url.URL, authDomain *authtypes.AuthDomain) (*saml2.SAMLServiceProvider, error) {
certStore, err := a.getCertificateStore(authDomain)
if err != nil {

View File

@@ -19,6 +19,7 @@ import (
"github.com/SigNoz/signoz/pkg/query-service/interfaces"
basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
@@ -60,6 +61,7 @@ func NewAPIHandler(opts APIHandlerOptions, signoz *signoz.SigNoz) (*APIHandler,
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),
})
if err != nil {
@@ -92,10 +94,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
// routes available only in ee version
router.HandleFunc("/api/v1/features", am.ViewAccess(ah.getFeatureFlags)).Methods(http.MethodGet)
// paid plans specific routes
router.HandleFunc("/api/v1/complete/saml", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionBySAMLCallback)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/complete/oidc", am.OpenAccess(ah.Signoz.Handlers.Session.CreateSessionByOIDCCallback)).Methods(http.MethodGet)
// base overrides
router.HandleFunc("/api/v1/version", am.OpenAccess(ah.getVersion)).Methods(http.MethodGet)

View File

@@ -3,13 +3,14 @@ package app
import (
"context"
"fmt"
"log/slog"
"net"
"net/http"
_ "net/http/pprof" // http profiler
"slices"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/queryparser"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation"
@@ -106,7 +107,8 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
signoz.Prometheus,
signoz.Modules.OrgGetter,
signoz.Querier,
signoz.Instrumentation.Logger(),
signoz.Instrumentation.ToProviderSettings(),
signoz.QueryParser,
)
if err != nil {
@@ -243,6 +245,11 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
apiHandler.MetricExplorerRoutes(r, am)
apiHandler.RegisterTraceFunnelsRoutes(r, am)
err := s.signoz.APIServer.AddToRouter(r)
if err != nil {
return nil, err
}
c := cors.New(cors.Options{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "DELETE", "POST", "PUT", "PATCH", "OPTIONS"},
@@ -253,7 +260,7 @@ func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*h
handler = handlers.CompressHandler(handler)
err := web.AddToRouter(r)
err = web.AddToRouter(r)
if err != nil {
return nil, err
}
@@ -348,8 +355,8 @@ func (s *Server) Stop(ctx context.Context) error {
return nil
}
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, logger *slog.Logger) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore)
func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertmanager.Alertmanager, sqlstore sqlstore.SQLStore, telemetryStore telemetrystore.TelemetryStore, prometheus prometheus.Prometheus, orgGetter organization.Getter, querier querier.Querier, providerSettings factory.ProviderSettings, queryParser queryparser.QueryParser) (*baserules.Manager, error) {
ruleStore := sqlrulestore.NewRuleStore(sqlstore, queryParser, providerSettings)
maintenanceStore := sqlrulestore.NewMaintenanceStore(sqlstore)
// create manager opts
managerOpts := &baserules.ManagerOptions{
@@ -359,7 +366,7 @@ func makeRulesManager(ch baseint.Reader, cache cache.Cache, alertmanager alertma
Logger: zap.L(),
Reader: ch,
Querier: querier,
SLogger: logger,
SLogger: providerSettings.Logger,
Cache: cache,
EvalDelay: baseconst.GetEvalDelay(),
PrepareTaskFunc: rules.PrepareTaskFunc,

View File

@@ -1,5 +1,5 @@
module.exports = {
ignorePatterns: ['src/parser/*.ts'],
ignorePatterns: ['src/parser/*.ts', 'scripts/update-registry.js'],
env: {
browser: true,
es2021: true,

View File

@@ -14,7 +14,7 @@
"jest": "jest",
"jest:coverage": "jest --coverage",
"jest:watch": "jest --watch",
"postinstall": "yarn i18n:generate-hash && (is-ci || yarn husky:configure)",
"postinstall": "yarn i18n:generate-hash && (is-ci || yarn husky:configure) && node scripts/update-registry.js",
"husky:configure": "cd .. && husky install frontend/.husky && cd frontend && chmod ug+x .husky/*",
"commitlint": "commitlint --edit $1",
"test": "jest",
@@ -47,6 +47,8 @@
"@signozhq/button": "0.0.2",
"@signozhq/calendar": "0.0.0",
"@signozhq/callout": "0.0.2",
"@signozhq/checkbox": "0.0.2",
"@signozhq/command": "0.0.0",
"@signozhq/design-tokens": "1.1.4",
"@signozhq/input": "0.0.2",
"@signozhq/popover": "0.0.0",
@@ -103,7 +105,6 @@
"i18next-http-backend": "^1.3.2",
"jest": "^27.5.1",
"js-base64": "^3.7.2",
"kbar": "0.1.0-beta.48",
"less": "^4.1.2",
"less-loader": "^10.2.0",
"lodash-es": "^4.17.21",

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" fill="currentColor" fill-rule="evenodd" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>AWS</title><path d="M6.763 11.212q.002.446.088.71c.064.176.144.368.256.576.04.063.056.127.056.183q.002.12-.152.24l-.503.335a.4.4 0 0 1-.208.072q-.12-.002-.239-.112a2.5 2.5 0 0 1-.287-.375 6 6 0 0 1-.248-.471q-.934 1.101-2.347 1.101c-.67 0-1.205-.191-1.596-.574-.39-.384-.59-.894-.59-1.533 0-.678.24-1.23.726-1.644.487-.415 1.133-.623 1.955-.623.272 0 .551.024.846.064.296.04.6.104.918.176v-.583q-.001-.908-.375-1.277c-.255-.248-.686-.367-1.3-.367-.28 0-.568.031-.863.103s-.583.16-.862.272a2 2 0 0 1-.28.104.5.5 0 0 1-.127.023q-.168.002-.168-.247v-.391c0-.128.016-.224.056-.28a.6.6 0 0 1 .224-.167 4.6 4.6 0 0 1 1.005-.36 4.8 4.8 0 0 1 1.246-.151c.95 0 1.644.216 2.091.647q.661.646.662 1.963v2.586zm-3.24 1.214c.263 0 .534-.048.822-.144a1.8 1.8 0 0 0 .758-.51 1.3 1.3 0 0 0 .272-.512c.047-.191.08-.423.08-.694v-.335a7 7 0 0 0-.735-.136 6 6 0 0 0-.75-.048c-.535 0-.926.104-1.19.32-.263.215-.39.518-.39.917 0 .375.095.655.295.846.191.2.47.296.838.296m6.41.862c-.144 0-.24-.024-.304-.08-.064-.048-.12-.16-.168-.311L7.586 6.726a1.4 1.4 0 0 1-.072-.32c0-.128.064-.2.191-.2h.783q.227-.001.31.08c.065.048.113.16.16.312l1.342 5.284 1.245-5.284q.058-.24.151-.312a.55.55 0 0 1 .32-.08h.638c.152 0 .256.025.32.08.063.048.12.16.151.312l1.261 5.348 1.381-5.348q.074-.24.16-.312a.52.52 0 0 1 .311-.08h.743c.127 0 .2.065.2.2 0 .04-.009.08-.017.128a1 1 0 0 1-.056.2l-1.923 6.17q-.072.24-.168.311a.5.5 0 0 1-.303.08h-.687c-.15 0-.255-.024-.32-.08-.063-.056-.119-.16-.15-.32L12.32 7.747l-1.23 5.14c-.04.16-.087.264-.15.32-.065.056-.177.08-.32.08zm10.256.215c-.415 0-.83-.048-1.229-.143-.399-.096-.71-.2-.918-.32-.128-.071-.215-.151-.247-.223a.6.6 0 0 1-.048-.224v-.407c0-.167.064-.247.183-.247q.072 0 .144.024c.048.016.12.048.2.08q.408.181.878.279c.32.064.63.096.95.096.502 0 .894-.088 1.165-.264a.86.86 0 0 0 .415-.758.78.78 0 0 0-.215-.559c-.144-.151-.416-.287-.807-.415l-1.157-.36c-.583-.183-1.014-.454-1.277-.813a1.9 1.9 0 0 1-.4-1.158q0-.502.216-.886c.144-.255.335-.479.575-.654.24-.184.51-.32.83-.415.32-.096.655-.136 1.006-.136.175 0 .36.008.535.032.183.024.35.056.518.088q.24.058.455.127.216.072.336.144a.7.7 0 0 1 .24.2.43.43 0 0 1 .071.263v.375q-.002.254-.184.256a.8.8 0 0 1-.303-.096 3.65 3.65 0 0 0-1.532-.311c-.455 0-.815.071-1.062.223s-.375.383-.375.71c0 .224.08.416.24.567.16.152.454.304.877.44l1.134.358c.574.184.99.44 1.237.767s.367.702.367 1.117c0 .343-.072.655-.207.926a2.2 2.2 0 0 1-.583.703c-.248.2-.543.343-.886.447-.36.111-.734.167-1.142.167"/><path fill="#f90" d="M.378 15.475c3.384 1.963 7.56 3.153 11.877 3.153 2.914 0 6.114-.607 9.06-1.852.44-.2.814.287.383.607-2.626 1.94-6.442 2.969-9.722 2.969-4.598 0-8.74-1.7-11.87-4.526-.247-.223-.024-.527.272-.351m23.531-.2c.287.36-.08 2.826-1.485 4.007-.215.184-.423.088-.327-.151l.175-.439c.343-.88.802-2.198.52-2.555-.336-.43-2.22-.207-3.074-.103-.255.032-.295-.192-.063-.36 1.5-1.053 3.967-.75 4.254-.399"/></svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 20 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>Azure</title><path fill="url(#a)" d="M7.242 1.613A1.11 1.11 0 0 1 8.295.857h6.977L8.03 22.316a1.11 1.11 0 0 1-1.052.755h-5.43a1.11 1.11 0 0 1-1.053-1.466z"/><path fill="#0078d4" d="M18.397 15.296H7.4a.51.51 0 0 0-.347.882l7.066 6.595c.206.192.477.298.758.298h6.226z"/><path fill="url(#b)" d="M15.272.857H7.497L0 23.071h7.775l1.596-4.73 5.068 4.73h6.665l-2.707-7.775h-7.998z"/><path fill="url(#c)" d="M17.193 1.613a1.11 1.11 0 0 0-1.052-.756h-7.81.035c.477 0 .9.304 1.052.756l6.748 19.992a1.11 1.11 0 0 1-1.052 1.466h-.12 7.895a1.11 1.11 0 0 0 1.052-1.466z"/><defs><linearGradient id="a" x1="8.247" x2="1.002" y1="1.626" y2="23.03" gradientUnits="userSpaceOnUse"><stop stop-color="#114a8b"/><stop offset="1" stop-color="#0669bc"/></linearGradient><linearGradient id="b" x1="14.042" x2="12.324" y1="15.302" y2="15.888" gradientUnits="userSpaceOnUse"><stop stop-opacity=".3"/><stop offset=".071" stop-opacity=".2"/><stop offset=".321" stop-opacity=".1"/><stop offset=".623" stop-opacity=".05"/><stop offset="1" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="12.841" x2="20.793" y1="1.626" y2="22.814" gradientUnits="userSpaceOnUse"><stop stop-color="#3ccbf4"/><stop offset="1" stop-color="#2892df"/></linearGradient></defs></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>CrewAI</title><path fill="#461816" d="M19.41 10.783a2.75 2.75 0 0 1 2.471 1.355c.483.806.622 1.772.385 2.68l-.136.522a10 10 0 0 1-3.156 5.058c-.605.517-1.283 1.062-2.083 1.524l-.028.017c-.402.232-.884.511-1.398.756-1.19.602-2.475.997-3.798 1.167-.854.111-1.716.155-2.577.132h-.018a8.6 8.6 0 0 1-5.046-1.87l-.012-.01-.012-.01A8.02 8.02 0 0 1 1.22 17.42a10.9 10.9 0 0 1-.102-3.779A15.6 15.6 0 0 1 2.88 8.4a21.8 21.8 0 0 1 2.432-3.678 15.4 15.4 0 0 1 3.56-3.182A10 10 0 0 1 12.44.104h.004l.003-.002c2.057-.384 3.743.374 5.024 1.26a8.3 8.3 0 0 1 2.395 2.513l.024.04.023.042a5.47 5.47 0 0 1 .508 4.012c-.239.97-.577 1.914-1.01 2.814z"/><path fill="#fff" d="M18.861 13.165a.748.748 0 0 1 1.256.031c.199.332.256.73.159 1.103l-.137.522a7.94 7.94 0 0 1-2.504 4.014c-.572.49-1.138.939-1.774 1.306-.427.247-.857.496-1.303.707a9.6 9.6 0 0 1-3.155.973 14.3 14.3 0 0 1-2.257.116 6.53 6.53 0 0 1-3.837-1.422 5.97 5.97 0 0 1-2.071-3.494 8.9 8.9 0 0 1-.085-3.08 13.6 13.6 0 0 1 1.54-4.568 19.7 19.7 0 0 1 2.212-3.348 13.4 13.4 0 0 1 3.088-2.76 7.9 7.9 0 0 1 2.832-1.14c1.307-.245 2.434.207 3.481.933a6.2 6.2 0 0 1 1.806 1.892c.423.767.536 1.668.314 2.515a12.4 12.4 0 0 1-.99 2.67l-.223.497q-.48 1.07-.97 2.137a.76.76 0 0 1-.97.467 3.39 3.39 0 0 1-2.283-2.49c-.095-.83.04-1.669.39-2.426.288-.746.61-1.477.933-2.208l.248-.563a.53.53 0 0 0-.204-.742 2.35 2.35 0 0 0-1.2.702 25 25 0 0 0-1.614 1.767 21.6 21.6 0 0 0-2.619 4.184 7.6 7.6 0 0 0-.816 2.753 7 7 0 0 0 .07 2.219 2.055 2.055 0 0 0 1.934 1.715c1.801.1 3.59-.363 5.116-1.328a19 19 0 0 0 1.675-1.294c.752-.71 1.376-1.519 1.958-2.36"/></svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 25)"><rect width="60" height="55" fill="#fcd34d" rx="6"/><path stroke="#d97706" stroke-linecap="round" stroke-linejoin="round" stroke-width="4" d="m10 40 15-15 15 8 15-23"/><rect width="35" height="55" x="65" fill="#6ee7b7" rx="6"/><rect width="20" height="6" x="73" y="10" fill="#059669" rx="3"/><rect width="15" height="6" x="73" y="22" fill="#059669" opacity=".7" rx="3"/><rect width="18" height="6" x="73" y="34" fill="#059669" opacity=".7" rx="3"/><rect width="100" height="40" y="60" fill="#a5b4fc" rx="6"/><rect width="80" height="8" x="10" y="70" fill="#4f46e5" rx="4"/><rect width="50" height="8" x="20" y="83" fill="#6366f1" rx="4"/></g></svg>

After

Width:  |  Height:  |  Size: 826 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#b31aab" d="m33.172 61.48.176 7.325 7.797 4.785-.176-7.324Zm19.031 30.504-.176-7.18-6.84-4.206c-.085-.059-.203-.145-.289-.203l.176 7.207Zm-24.355 9.688L10.012 90.715l-.438-18.367 8.758-3.746-.172-7.352-13.969 5.969c-1.074.46-1.714 1.441-1.687 2.566l.523 22.055c.032 1.125.73 2.25 1.836 2.941l21.383 13.149c.992.605 2.215.78 3.203.46a.8.8 0 0 0 .29-.113l13.124-5.593-7.129-4.383Zm0 0"/><path fill="#d163ce" d="M85.488 61.047c-.031-1.328-.843-2.625-2.125-3.43L57.38 41.672l-.813.344.176 7.726 20.57 12.63.493 20.648 7.86 4.812.433-.172ZM54.383 97.289 30.262 82.47l-.582-24.883 11-4.7-.203-8.562-17.082 7.293c-1.25.547-2.008 1.672-1.977 3l.668 29.18c.031 1.324.844 2.625 2.125 3.402l28.281 17.387c1.164.723 2.563.894 3.754.547.117-.028.234-.086.348-.145l16.703-7.12-8.32-5.102Zm0 0"/><path fill="#e13eaf" d="M122.234 40.633 85.98 18.343c-1.335-.808-2.937-1.038-4.304-.605-.145.028-.262.086-.406.145l-35.383 15.11c-1.426.605-2.297 1.902-2.27 3.429l.903 37.371c.03 1.527.96 2.996 2.445 3.89l36.254 22.262c1.336.805 2.937 1.035 4.277.606.145-.031.262-.09.406-.145l35.383-15.11c1.426-.605 2.297-1.933 2.27-3.433l-.875-37.367c-.028-1.473-.961-2.969-2.446-3.863M85.398 91.64 53.891 72.293l-.79-32.496 30.727-13.121 31.512 19.347.785 32.497Zm0 0"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 6.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.5 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><g transform="translate(25 40)"><rect width="12" height="12" fill="#059669" opacity=".8" rx="3"/><rect width="80" height="12" x="20" fill="#10b981" rx="3"/><rect width="12" height="12" y="22" fill="#059669" opacity=".8" rx="3"/><rect width="65" height="12" x="20" y="22" fill="#10b981" rx="3"/><rect width="12" height="12" y="44" fill="#059669" opacity=".8" rx="3"/><rect width="75" height="12" x="20" y="44" fill="#10b981" rx="3"/><rect width="12" height="12" y="66" fill="#059669" opacity=".8" rx="3"/><rect width="50" height="12" x="20" y="66" fill="#10b981" rx="3"/></g></svg>

After

Width:  |  Height:  |  Size: 726 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><defs><linearGradient id="a" x1="0" x2="0" y1="0" y2="1"><stop offset="0%" stop-color="#f59e0b" stop-opacity=".5"/><stop offset="100%" stop-color="#f59e0b" stop-opacity=".05"/></linearGradient></defs><g transform="translate(25 35)"><path stroke="#d1d5db" stroke-linecap="round" stroke-width="2" d="M0 80h100M0 80V0"/><path fill="url(#a)" d="M2 78c18 0 28-28 48-23s30-35 48-40v63z"/><path stroke="#d97706" stroke-linecap="round" stroke-width="5" d="M2 78c18 0 28-28 48-23s30-35 48-40"/><circle cx="50" cy="55" r="4" fill="#f59e0b"/><circle cx="98" cy="15" r="4" fill="#f59e0b"/></g></svg>

After

Width:  |  Height:  |  Size: 733 B

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24"><title>PydanticAI</title><path fill="#e72564" d="M13.223 22.86c-.605.83-1.844.83-2.448 0L5.74 15.944a1.514 1.514 0 0 1 .73-2.322l5.035-1.738c.32-.11.668-.11.988 0l5.035 1.738c.962.332 1.329 1.5.73 2.322zm-1.224-1.259 4.688-6.439-4.688-1.618-4.688 1.618L12 21.602z"/><path fill="#e723a0" d="M23.71 13.463c.604.832.221 2.01-.756 2.328l-8.133 2.652a1.514 1.514 0 0 1-1.983-1.412l-.097-5.326c-.006-.338.101-.67.305-.94l3.209-4.25a1.514 1.514 0 0 1 2.434.022l5.022 6.926zm-1.574.775L17.46 7.79l-2.988 3.958.09 4.959z"/><path fill="#e520e9" d="M18.016.591a1.514 1.514 0 0 1 1.98 1.44l.009 8.554a1.514 1.514 0 0 1-1.956 1.45l-5.095-1.554a1.5 1.5 0 0 1-.8-.58l-3.05-4.366a1.514 1.514 0 0 1 .774-2.308zm.25 1.738L10.69 4.783l2.841 4.065 4.744 1.446-.008-7.965z"/><path fill="#e520e9" d="M5.99.595a1.514 1.514 0 0 0-1.98 1.44L4 10.588a1.514 1.514 0 0 0 1.956 1.45l5.095-1.554c.323-.098.605-.303.799-.58l3.052-4.366a1.514 1.514 0 0 0-.775-2.308zm-.25 1.738 7.577 2.454-2.842 4.065-4.743 1.446.007-7.965z"/><path fill="#e723a0" d="M.29 13.461a1.514 1.514 0 0 0 .756 2.329l8.133 2.651a1.514 1.514 0 0 0 1.983-1.412l.097-5.325a1.5 1.5 0 0 0-.305-.94L7.745 6.513a1.514 1.514 0 0 0-2.434.023L.289 13.461zm1.574.776L6.54 7.788l2.988 3.959-.09 4.958z"/><path fill="#ff96d1" d="m16.942 17.751 1.316-1.806q.178-.248.245-.523l-2.63.858-1.627 2.235a1.5 1.5 0 0 0 .575-.072zm-4.196-5.78.033 1.842 1.742.602-.034-1.843-1.741-.6zm7.257-3.622-1.314-1.812a1.5 1.5 0 0 0-.419-.393l.003 2.767 1.624 2.24q.107-.261.108-.566zm-5.038 2.746-1.762-.537 1.11-1.471 1.762.537zm-2.961-1.41 1.056-1.51-1.056-1.51-1.056 1.51zM9.368 3.509c.145-.122.316-.219.51-.282l2.12-.686 2.13.69c.191.062.36.157.503.276l-2.634.853zm1.433 7.053L9.691 9.09l-1.762.537 1.11 1.47 1.762-.537zm-6.696.584L5.733 8.9l.003-2.763c-.16.1-.305.232-.425.398L4.003 8.339l-.002 2.25q.002.299.104.557m7.149.824-1.741.601-.034 1.843 1.742-.601zM9.75 18.513l-1.628-2.237-2.629-.857q.068.276.247.525l1.313 1.804 2.126.693c.192.062.385.085.571.072"/></svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="150" height="150" fill="none" viewBox="0 0 150 150"><circle cx="75" cy="75" r="70" fill="#f3f4f6"/><rect width="100" height="14" x="25" y="45" fill="#4f46e5" rx="4"/><rect width="60" height="14" x="40" y="68" fill="#6366f1" rx="4"/><rect width="20" height="14" x="105" y="91" fill="#818cf8" rx="4"/><path stroke="#c7d2fe" stroke-width="2" d="M35 59v16m5 0h-5M100 59v39m5 0h-5"/></svg>

After

Width:  |  Height:  |  Size: 431 B

View File

@@ -0,0 +1,50 @@
/* eslint-disable @typescript-eslint/no-var-requires, import/no-dynamic-require, simple-import-sort/imports, simple-import-sort/exports */
const fs = require('fs');
const path = require('path');
// 1. Define paths
const packageJsonPath = path.resolve(__dirname, '../package.json');
const registryPath = path.resolve(
__dirname,
'../src/auto-import-registry.d.ts',
);
// 2. Read package.json
const packageJson = require(packageJsonPath);
// 3. Combine dependencies and devDependencies
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
// 4. Filter for @signozhq packages
const signozPackages = Object.keys(allDeps).filter((dep) =>
dep.startsWith('@signozhq/'),
);
// 5. Generate file content
const fileContent = `// -------------------------------------------------------------------------
// AUTO-GENERATED FILE
// -------------------------------------------------------------------------
// This file is generated by scripts/update-registry.js automatically
// whenever you run 'yarn install' or 'npm install'.
//
// It forces VS Code to index these specific packages to fix auto-import
// performance issues in TypeScript 4.x.
//
// PR for reference: https://github.com/SigNoz/signoz/pull/9694
// -------------------------------------------------------------------------
${signozPackages.map((pkg) => `import '${pkg}';`).join('\n')}
`;
// 6. Write the file
try {
fs.writeFileSync(registryPath, fileContent);
console.log(
`✅ Auto-import registry updated with ${signozPackages.length} @signozhq packages.`,
);
} catch (err) {
console.error('❌ Failed to update auto-import registry:', err);
}

View File

@@ -245,6 +245,14 @@ function PrivateRoute({ children }: PrivateRouteProps): JSX.Element {
history.replace(newLocation);
return;
}
// if the current route is public dashboard then don't redirect to login
const isPublicDashboard = currentRoute?.path === ROUTES.PUBLIC_DASHBOARD;
if (isPublicDashboard) {
return;
}
// if the current route
if (currentRoute) {
const { isPrivate, key } = currentRoute;

View File

@@ -4,8 +4,9 @@ import getLocalStorageApi from 'api/browser/localstorage/get';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import AppLoading from 'components/AppLoading/AppLoading';
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
import { CmdKPalette } from 'components/cmdKPalette/cmdKPalette';
import NotFound from 'components/NotFound';
import { ShiftHoldOverlayController } from 'components/ShiftOverlay/ShiftHoldOverlayController';
import Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage';
@@ -22,12 +23,11 @@ import { StatusCodes } from 'http-status-codes';
import history from 'lib/history';
import ErrorBoundaryFallback from 'pages/ErrorBoundaryFallback/ErrorBoundaryFallback';
import posthog from 'posthog-js';
import AlertRuleProvider from 'providers/Alert';
import { useAppContext } from 'providers/App/App';
import { IUser } from 'providers/App/types';
import { CmdKProvider } from 'providers/cmdKProvider';
import { DashboardProvider } from 'providers/Dashboard/Dashboard';
import { ErrorModalProvider } from 'providers/ErrorModalProvider';
import { KBarCommandPaletteProvider } from 'providers/KBarCommandPaletteProvider';
import { PreferenceContextProvider } from 'providers/preferences/context/PreferenceContextProvider';
import { QueryBuilderProvider } from 'providers/QueryBuilder';
import { Suspense, useCallback, useEffect, useState } from 'react';
@@ -214,7 +214,10 @@ function App(): JSX.Element {
]);
useEffect(() => {
if (pathname === ROUTES.ONBOARDING) {
if (
pathname === ROUTES.ONBOARDING ||
pathname.startsWith('/public/dashboard/')
) {
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
window.Pylon('hideChatBubble');
@@ -362,35 +365,36 @@ function App(): JSX.Element {
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<KBarCommandPaletteProvider>
<KBarCommandPalette />
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{isLoggedInState && <CmdKPalette userRole={user.role} />}
{isLoggedInState && (
<ShiftHoldOverlayController userRole={user.role} />
)}
<PrivateRoute>
<ResourceProvider>
<QueryBuilderProvider>
<DashboardProvider>
<KeyboardHotkeysProvider>
<AlertRuleProvider>
<AppLayout>
<PreferenceContextProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</PreferenceContextProvider>
</AppLayout>
</AlertRuleProvider>
<AppLayout>
<PreferenceContextProvider>
<Suspense fallback={<Spinner size="large" tip="Loading..." />}>
<Switch>
{routes.map(({ path, component, exact }) => (
<Route
key={`${path}`}
exact={exact}
path={path}
component={component}
/>
))}
<Route exact path="/" component={Home} />
<Route path="*" component={NotFound} />
</Switch>
</Suspense>
</PreferenceContextProvider>
</AppLayout>
</KeyboardHotkeysProvider>
</DashboardProvider>
</QueryBuilderProvider>
@@ -398,7 +402,7 @@ function App(): JSX.Element {
</PrivateRoute>
</ErrorModalProvider>
</NotificationProvider>
</KBarCommandPaletteProvider>
</CmdKProvider>
</CompatRouter>
</Router>
</ConfigProvider>

View File

@@ -295,3 +295,10 @@ export const MetricsExplorer = Loadable(
export const ApiMonitoring = Loadable(
() => import(/* webpackChunkName: "ApiMonitoring" */ 'pages/ApiMonitoring'),
);
export const PublicDashboardPage = Loadable(
() =>
import(
/* webpackChunkName: "Public Dashboard Page" */ 'pages/PublicDashboard'
),
);

View File

@@ -34,6 +34,7 @@ import {
OrgOnboarding,
PasswordReset,
PipelinePage,
PublicDashboardPage,
ServiceMapPage,
ServiceMetricsPage,
ServicesTablePage,
@@ -169,6 +170,13 @@ const routes: AppRoutes[] = [
isPrivate: true,
key: 'DASHBOARD',
},
{
path: ROUTES.PUBLIC_DASHBOARD,
exact: false,
component: PublicDashboardPage,
isPrivate: false,
key: 'PUBLIC_DASHBOARD',
},
{
path: ROUTES.DASHBOARD_WIDGET,
exact: true,

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { CreatePublicDashboardProps } from 'types/api/dashboard/public/create';
const createPublicDashboard = async (
props: CreatePublicDashboardProps,
): Promise<SuccessResponseV2<CreatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
try {
const response = await axios.post(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default createPublicDashboard;

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardDataProps, PayloadProps,PublicDashboardDataProps } from 'types/api/dashboard/public/get';
const getPublicDashboardData = async (props: GetPublicDashboardDataProps): Promise<SuccessResponseV2<PublicDashboardDataProps>> => {
try {
const response = await axios.get<PayloadProps>(`/public/dashboards/${props.id}`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardData;

View File

@@ -0,0 +1,20 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardMetaProps, PayloadProps,PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
const getPublicDashboardMeta = async (props: GetPublicDashboardMetaProps): Promise<SuccessResponseV2<PublicDashboardMetaProps>> => {
try {
const response = await axios.get<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardMeta;

View File

@@ -0,0 +1,27 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { MetricRangePayloadV5 } from 'api/v5/v5';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { GetPublicDashboardWidgetDataProps } from 'types/api/dashboard/public/getWidgetData';
const getPublicDashboardWidgetData = async (props: GetPublicDashboardWidgetDataProps): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
try {
const response = await axios.get(`/public/dashboards/${props.id}/widgets/${props.index}/query_range`, {
params: {
startTime: props.startTime,
endTime: props.endTime,
},
});
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default getPublicDashboardWidgetData;

View File

@@ -0,0 +1,22 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { PayloadProps,RevokePublicDashboardAccessProps } from 'types/api/dashboard/public/delete';
const revokePublicDashboardAccess = async (
props: RevokePublicDashboardAccessProps,
): Promise<SuccessResponseV2<PayloadProps>> => {
try {
const response = await axios.delete<PayloadProps>(`/dashboards/${props.id}/public`);
return {
httpStatusCode: response.status,
data: response.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default revokePublicDashboardAccess;

View File

@@ -0,0 +1,28 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import { UpdatePublicDashboardProps } from 'types/api/dashboard/public/update';
const updatePublicDashboard = async (
props: UpdatePublicDashboardProps,
): Promise<SuccessResponseV2<UpdatePublicDashboardProps>> => {
const { dashboardId, timeRangeEnabled = false, defaultTimeRange = '30m' } = props;
try {
const response = await axios.put(
`/dashboards/${dashboardId}/public`,
{ timeRangeEnabled, defaultTimeRange },
);
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
};
export default updatePublicDashboard;

25
frontend/src/auto-import-registry.d.ts vendored Normal file
View File

@@ -0,0 +1,25 @@
// -------------------------------------------------------------------------
// AUTO-GENERATED FILE
// -------------------------------------------------------------------------
// This file is generated by scripts/update-registry.js automatically
// whenever you run 'yarn install' or 'npm install'.
//
// It forces VS Code to index these specific packages to fix auto-import
// performance issues in TypeScript 4.x.
//
// PR for reference: https://github.com/SigNoz/signoz/pull/9694
// -------------------------------------------------------------------------
import '@signozhq/badge';
import '@signozhq/button';
import '@signozhq/calendar';
import '@signozhq/callout';
import '@signozhq/checkbox';
import '@signozhq/command';
import '@signozhq/design-tokens';
import '@signozhq/input';
import '@signozhq/popover';
import '@signozhq/resizable';
import '@signozhq/sonner';
import '@signozhq/table';
import '@signozhq/tooltip';

View File

@@ -62,6 +62,8 @@ interface CustomTimePickerProps {
showLiveLogs?: boolean;
onGoLive?: () => void;
onExitLiveLogs?: () => void;
/** When false, hides the "Recently Used" time ranges section */
showRecentlyUsed?: boolean;
}
function CustomTimePicker({
@@ -81,6 +83,7 @@ function CustomTimePicker({
onGoLive,
onExitLiveLogs,
showLiveLogs,
showRecentlyUsed = true,
}: CustomTimePickerProps): JSX.Element {
const [
selectedTimePlaceholderValue,
@@ -395,6 +398,7 @@ function CustomTimePicker({
setActiveView={setActiveView}
setIsOpenedFromFooter={setIsOpenedFromFooter}
isOpenedFromFooter={isOpenedFromFooter}
showRecentlyUsed={showRecentlyUsed}
/>
) : (
content
@@ -464,4 +468,5 @@ CustomTimePicker.defaultProps = {
onCustomTimeStatusUpdate: noop,
onExitLiveLogs: noop,
showLiveLogs: false,
showRecentlyUsed: true,
};

View File

@@ -47,6 +47,7 @@ interface CustomTimePickerPopoverContentProps {
isOpenedFromFooter: boolean;
setIsOpenedFromFooter: Dispatch<SetStateAction<boolean>>;
onExitLiveLogs: () => void;
showRecentlyUsed: boolean;
}
interface RecentlyUsedDateTimeRange {
@@ -72,6 +73,7 @@ function CustomTimePickerPopoverContent({
isOpenedFromFooter,
setIsOpenedFromFooter,
onExitLiveLogs,
showRecentlyUsed = true,
}: CustomTimePickerPopoverContentProps): JSX.Element {
const { pathname } = useLocation();
@@ -224,33 +226,35 @@ function CustomTimePickerPopoverContent({
<div>{getTimeChips(RelativeDurationSuggestionOptions)}</div>
</div>
<div className="recently-used-container">
<div className="time-heading">RECENTLY USED</div>
<div className="recently-used-range">
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
<div
className="recently-used-range-item"
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
{showRecentlyUsed && (
<div className="recently-used-container">
<div className="time-heading">RECENTLY USED</div>
<div className="recently-used-range">
{recentlyUsedTimeRanges.map((range: RecentlyUsedDateTimeRange) => (
<div
className="recently-used-range-item"
role="button"
tabIndex={0}
onKeyDown={(e): void => {
if (e.key === 'Enter' || e.key === ' ') {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}
}}
key={range.value}
onClick={(): void => {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}
}}
key={range.value}
onClick={(): void => {
handleExitLiveLogs();
onCustomDateHandler([dayjs(range.from), dayjs(range.to)]);
setIsOpen(false);
}}
>
{range.label}
</div>
))}
}}
>
{range.label}
</div>
))}
</div>
</div>
</div>
)}
</div>
)}
</div>

View File

@@ -1,5 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { getYAxisFormattedValue, PrecisionOptionsEnum } from '../yAxisConfig';
import { PrecisionOptionsEnum } from '../types';
import { getYAxisFormattedValue } from '../yAxisConfig';
const testFullPrecisionGetYAxisFormattedValue = (
value: string,

View File

@@ -78,3 +78,18 @@ export interface ITimeRange {
minTime: number | null;
maxTime: number | null;
}
export const DEFAULT_SIGNIFICANT_DIGITS = 15;
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
export const MAX_DECIMALS = 15;
export enum PrecisionOptionsEnum {
ZERO = 0,
ONE = 1,
TWO = 2,
THREE = 3,
FOUR = 4,
FULL = 'full',
}
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;

View File

@@ -16,8 +16,12 @@ import {
} from './Plugin/IntersectionCursor';
import {
CustomChartOptions,
DEFAULT_SIGNIFICANT_DIGITS,
GraphOnClickHandler,
IAxisTimeConfig,
MAX_DECIMALS,
PrecisionOption,
PrecisionOptionsEnum,
StaticLineProps,
} from './types';
import { getToolTipValue, getYAxisFormattedValue } from './yAxisConfig';
@@ -242,3 +246,68 @@ declare module 'chart.js' {
custom: TooltipPositionerFunction<ChartType>;
}
}
/**
* Formats a number for display, preserving leading zeros after the decimal point
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
* It avoids scientific notation and removes unnecessary trailing zeros.
*
* @example
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
* formatDecimalWithLeadingZeros(5.0); // "5"
*
* @param value The number to format.
* @returns The formatted string.
*/
export const formatDecimalWithLeadingZeros = (
value: number,
precision: PrecisionOption,
): string => {
if (value === 0) {
return '0';
}
// Use toLocaleString to get a full decimal representation without scientific notation.
const numStr = value.toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 20,
});
const [integerPart, decimalPart = ''] = numStr.split('.');
// If there's no decimal part, the integer part is the result.
if (!decimalPart) {
return integerPart;
}
// Find the index of the first non-zero digit in the decimal part.
const firstNonZeroIndex = decimalPart.search(/[^0]/);
// If the decimal part consists only of zeros, return just the integer part.
if (firstNonZeroIndex === -1) {
return integerPart;
}
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
const significantDigits =
precision === PrecisionOptionsEnum.FULL
? DEFAULT_SIGNIFICANT_DIGITS
: precision;
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
// If precision is 0, we drop the decimal part entirely.
if (precision === 0) {
return integerPart;
}
// Remove any trailing zeros from the result to keep it clean.
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
// Return the integer part, or the integer and decimal parts combined.
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
};

View File

@@ -1,86 +1,17 @@
/* eslint-disable sonarjs/cognitive-complexity */
import { formattedValueToString, getValueFormat } from '@grafana/data';
import * as Sentry from '@sentry/react';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { isUniversalUnit } from 'components/YAxisUnitSelector/utils';
import { isNaN } from 'lodash-es';
const DEFAULT_SIGNIFICANT_DIGITS = 15;
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const MAX_DECIMALS = 15;
export enum PrecisionOptionsEnum {
ZERO = 0,
ONE = 1,
TWO = 2,
THREE = 3,
FOUR = 4,
FULL = 'full',
}
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;
/**
* Formats a number for display, preserving leading zeros after the decimal point
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
* It avoids scientific notation and removes unnecessary trailing zeros.
*
* @example
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
* formatDecimalWithLeadingZeros(5.0); // "5"
*
* @param value The number to format.
* @returns The formatted string.
*/
const formatDecimalWithLeadingZeros = (
value: number,
precision: PrecisionOption,
): string => {
if (value === 0) {
return '0';
}
// Use toLocaleString to get a full decimal representation without scientific notation.
const numStr = value.toLocaleString('en-US', {
useGrouping: false,
maximumFractionDigits: 20,
});
const [integerPart, decimalPart = ''] = numStr.split('.');
// If there's no decimal part, the integer part is the result.
if (!decimalPart) {
return integerPart;
}
// Find the index of the first non-zero digit in the decimal part.
const firstNonZeroIndex = decimalPart.search(/[^0]/);
// If the decimal part consists only of zeros, return just the integer part.
if (firstNonZeroIndex === -1) {
return integerPart;
}
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
const significantDigits =
precision === PrecisionOptionsEnum.FULL
? DEFAULT_SIGNIFICANT_DIGITS
: precision;
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
// If precision is 0, we drop the decimal part entirely.
if (precision === 0) {
return integerPart;
}
// Remove any trailing zeros from the result to keep it clean.
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
// Return the integer part, or the integer and decimal parts combined.
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
};
import { formatUniversalUnit } from '../YAxisUnitSelector/formatter';
import {
DEFAULT_SIGNIFICANT_DIGITS,
PrecisionOption,
PrecisionOptionsEnum,
} from './types';
import { formatDecimalWithLeadingZeros } from './utils';
/**
* Formats a Y-axis value based on a given format string.
@@ -126,6 +57,17 @@ export const getYAxisFormattedValue = (
return formatDecimalWithLeadingZeros(numValue, precision);
}
// Separate logic for universal units// Separate logic for universal units
if (format && isUniversalUnit(format)) {
const decimals = computeDecimals();
return formatUniversalUnit(
numValue,
format as UniversalYAxisUnit,
precision,
decimals,
);
}
const formatter = getValueFormat(format);
const formattedValue = formatter(numValue, computeDecimals(), undefined);
if (formattedValue.text && formattedValue.text.includes('.')) {
@@ -134,6 +76,7 @@ export const getYAxisFormattedValue = (
precision,
);
}
return formattedValueToString(formattedValue);
} catch (error) {
Sentry.captureEvent({

View File

@@ -1,152 +0,0 @@
.kbar-command-palette__positioner {
position: fixed;
inset: 0;
display: flex;
align-items: flex-start;
justify-content: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(6px);
z-index: 50;
}
.kbar-command-palette__animator {
width: 100%;
max-width: 600px;
}
.kbar-command-palette__card {
background: var(--bg-ink-500);
color: var(--text-vanilla-100);
border-radius: 3px;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
overflow: hidden;
display: flex;
flex-direction: column;
}
.kbar-command-palette__search {
padding: 12px 16px;
font-size: 13px;
border: none;
border-bottom: 1px solid var(--border-ink-200);
color: var(--text-vanilla-100);
outline: none;
background-color: var(--bg-ink-500);
}
.kbar-command-palette__section {
padding: 8px 16px 4px;
font-size: 12px;
font-weight: 600;
color: var(--text-robin-500);
font-family: 'Inter', sans-serif;
text-transform: uppercase;
letter-spacing: 0.02em;
}
.kbar-command-palette__item {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 16px;
font-size: 13px;
cursor: pointer;
transition: background 0.15s ease;
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: var(--bg-ink-400);
}
.kbar-command-palette__icon {
flex-shrink: 0;
width: 18px;
height: 18px;
color: #444;
}
.kbar-command-palette__shortcut {
margin-left: auto;
display: flex;
gap: 4px;
}
.kbar-command-palette__key {
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
background: var(--bg-ink-300);
color: var(--text-vanilla-300);
text-transform: uppercase;
font-family: 'Space Mono', monospace;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar {
width: 0.3rem;
height: 0.3rem;
}
&::-webkit-scrollbar-track {
background: transparent;
}
&::-webkit-scrollbar-thumb {
background: var(--bg-slate-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-slate-200);
}
}
}
.lightMode {
.kbar-command-palette__positioner {
background: rgba(0, 0, 0, 0.5);
}
.kbar-command-palette__card {
background: #fff;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.15);
}
.kbar-command-palette__search {
border-bottom: 1px solid #e5e5e5;
color: var(--text-ink-500);
background-color: var(--bg-vanilla-100);
}
.kbar-command-palette__item {
color: var(--text-ink-500);
}
.kbar-command-palette__item:hover,
.kbar-command-palette__item--active {
background: #f5f5f5;
}
.kbar-command-palette__icon {
color: #444;
}
.kbar-command-palette__key {
background: #eee;
color: #555;
}
.kbar-command-palette__results-container {
div {
&::-webkit-scrollbar-thumb {
background: var(--bg-vanilla-300);
}
&::-webkit-scrollbar-thumb:hover {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -1,69 +0,0 @@
import './KBarCommandPalette.scss';
import {
KBarAnimator,
KBarPortal,
KBarPositioner,
KBarResults,
KBarSearch,
useMatches,
} from 'kbar';
function Results(): JSX.Element {
const { results } = useMatches();
const renderResults = ({
item,
active,
}: {
item: any;
active: boolean;
}): JSX.Element =>
typeof item === 'string' ? (
<div className="kbar-command-palette__section">{item}</div>
) : (
<div
className={`kbar-command-palette__item ${
active ? 'kbar-command-palette__item--active' : ''
}`}
>
{item.icon}
<span>{item.name}</span>
{item.shortcut?.length ? (
<span className="kbar-command-palette__shortcut">
{item.shortcut.map((sc: string) => (
<kbd key={sc} className="kbar-command-palette__key">
{sc}
</kbd>
))}
</span>
) : null}
</div>
);
return (
<div className="kbar-command-palette__results-container">
<KBarResults items={results} onRender={renderResults} />
</div>
);
}
function KBarCommandPalette(): JSX.Element {
return (
<KBarPortal>
<KBarPositioner className="kbar-command-palette__positioner">
<KBarAnimator className="kbar-command-palette__animator">
<div className="kbar-command-palette__card">
<KBarSearch
className="kbar-command-palette__search"
placeholder="Search or type a command..."
/>
<Results />
</div>
</KBarAnimator>
</KBarPositioner>
</KBarPortal>
);
}
export default KBarCommandPalette;

View File

@@ -1,11 +1,15 @@
.log-field-key {
padding-right: 5px;
.log-field-container {
display: flex;
overflow: hidden;
width: 100%;
align-items: baseline;
}
.log-field-key,
.log-field-key-colon {
color: var(--text-vanilla-400, #c0c1c3);
font-size: 14px;
font-style: normal;
font-weight: 400;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
&.small {
font-size: 11px;
@@ -22,6 +26,20 @@
line-height: 24px;
}
}
.log-field-key {
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
white-space: nowrap;
display: inline-block;
max-width: 20vw;
text-overflow: ellipsis;
overflow: hidden;
margin: 0;
}
.log-field-key-colon {
min-width: 0.8rem;
flex-shrink: 0;
}
.log-value {
color: var(--text-vanilla-400, #c0c1c3);
font-size: 14px;
@@ -158,7 +176,8 @@
}
.lightMode {
.log-field-key {
.log-field-key,
.log-field-key-colon {
color: var(--text-slate-400);
}
.log-value {
@@ -170,3 +189,10 @@
}
}
}
.dark {
.log-field-key,
.log-field-key-colon {
color: rgba(255, 255, 255, 0.45);
}
}

View File

@@ -25,13 +25,7 @@ import LogLinesActionButtons from '../LogLinesActionButtons/LogLinesActionButton
import LogStateIndicator from '../LogStateIndicator/LogStateIndicator';
import { getLogIndicatorType } from '../LogStateIndicator/utils';
// styles
import {
Container,
LogContainer,
LogText,
Text,
TextContainer,
} from './styles';
import { Container, LogContainer, LogText } from './styles';
import { isValidLogField } from './util';
interface LogFieldProps {
@@ -58,16 +52,18 @@ function LogGeneralField({
);
return (
<TextContainer>
<Text ellipsis type="secondary" className={cx('log-field-key', fontSize)}>
{`${fieldKey} : `}
</Text>
<div className="log-field-container">
<p className={cx('log-field-key', fontSize)} title={fieldKey}>
{fieldKey}
</p>
<span className={cx('log-field-key-colon', fontSize)}>&nbsp;:&nbsp;</span>
<LogText
dangerouslySetInnerHTML={html}
className={cx('log-value', fontSize)}
title={fieldValue}
linesPerRow={linesPerRow > 1 ? linesPerRow : undefined}
/>
</TextContainer>
</div>
);
}

View File

@@ -1,5 +1,5 @@
/* eslint-disable no-nested-ternary */
import { Card, Typography } from 'antd';
import { Card } from 'antd';
import { FontSize } from 'container/OptionsMenu/types';
import styled from 'styled-components';
import { getActiveLogBackground } from 'utils/logs';
@@ -46,19 +46,6 @@ export const Container = styled(Card)<{
getActiveLogBackground($isActiveLog, $isDarkMode, $logType)}
`;
export const Text = styled(Typography.Text)`
&&& {
min-width: 2.5rem;
white-space: nowrap;
}
`;
export const TextContainer = styled.div`
display: flex;
overflow: hidden;
width: 100%;
`;
export const LogContainer = styled.div<LogContainerProps>`
margin-left: 0.5rem;
display: flex;

View File

@@ -0,0 +1,16 @@
.overflow-input {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.overflow-input-mirror {
position: absolute;
visibility: hidden;
white-space: pre;
pointer-events: none;
font: inherit;
letter-spacing: inherit;
height: 0;
overflow: hidden;
}

View File

@@ -0,0 +1,119 @@
import { render, screen, userEvent, waitFor, within } from 'tests/test-utils';
import OverflowInputToolTip from './OverflowInputToolTip';
const TOOLTIP_INNER_SELECTOR = '.ant-tooltip-inner';
// Utility to mock overflow behaviour on inputs / elements.
// Stubs HTMLElement.prototype.clientWidth, scrollWidth and offsetWidth used by component.
function mockOverflow(clientWidth: number, scrollWidth: number): void {
Object.defineProperty(HTMLElement.prototype, 'clientWidth', {
configurable: true,
value: clientWidth,
});
Object.defineProperty(HTMLElement.prototype, 'scrollWidth', {
configurable: true,
value: scrollWidth,
});
// mirror.offsetWidth is used to compute mirrorWidth = offsetWidth + 24.
// Use clientWidth so the mirror measurement aligns with the mocked client width in tests.
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
configurable: true,
value: clientWidth,
});
}
function queryTooltipInner(): HTMLElement | null {
// find element that has role="tooltip" (could be the inner itself)
const tooltip = document.querySelector<HTMLElement>('[role="tooltip"]');
if (!tooltip) return document.querySelector(TOOLTIP_INNER_SELECTOR);
// if the role element is already the inner, return it; otherwise return its descendant
if (tooltip.classList.contains('ant-tooltip-inner')) return tooltip;
return (
(tooltip.querySelector(TOOLTIP_INNER_SELECTOR) as HTMLElement) ??
document.querySelector(TOOLTIP_INNER_SELECTOR)
);
}
describe('OverflowInputToolTip', () => {
beforeEach(() => {
jest.restoreAllMocks();
});
test('shows tooltip when content overflows and input is clamped at maxAutoWidth', async () => {
mockOverflow(150, 250); // clientWidth >= maxAutoWidth (150), scrollWidth > clientWidth
render(<OverflowInputToolTip value="Very long overflowing text" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).not.toBeNull();
});
const tooltipInner = queryTooltipInner();
if (!tooltipInner) throw new Error('Tooltip inner not found');
expect(
within(tooltipInner).getByText('Very long overflowing text'),
).toBeInTheDocument();
});
test('does NOT show tooltip when content does not overflow', async () => {
mockOverflow(150, 100); // content fits (scrollWidth <= clientWidth)
render(<OverflowInputToolTip value="Short text" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('does NOT show tooltip when content overflows but input is NOT at maxAutoWidth', async () => {
mockOverflow(100, 250); // clientWidth < maxAutoWidth (150), scrollWidth > clientWidth
render(<OverflowInputToolTip value="Long but input not clamped" />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('uncontrolled input allows typing', async () => {
render(<OverflowInputToolTip defaultValue="Init" />);
const input = screen.getByRole('textbox') as HTMLInputElement;
await userEvent.type(input, 'ABC');
expect(input).toHaveValue('InitABC');
});
test('disabled input never shows tooltip even if overflowing', async () => {
mockOverflow(150, 300);
render(<OverflowInputToolTip value="Overflowing!" disabled />);
await userEvent.hover(screen.getByRole('textbox'));
await waitFor(() => {
expect(queryTooltipInner()).toBeNull();
});
});
test('renders mirror span and input correctly (structural assertions instead of snapshot)', () => {
const { container } = render(<OverflowInputToolTip value="Snapshot" />);
const mirror = container.querySelector('.overflow-input-mirror');
const input = container.querySelector('input') as HTMLInputElement | null;
expect(mirror).toBeTruthy();
expect(mirror?.textContent).toBe('Snapshot');
expect(input).toBeTruthy();
expect(input?.value).toBe('Snapshot');
// width should be set inline (component calculates width on mount)
expect(input?.getAttribute('style')).toContain('width:');
});
});

View File

@@ -0,0 +1,73 @@
/* eslint-disable react/require-default-props */
/* eslint-disable react/jsx-props-no-spreading */
import './OverflowInputToolTip.scss';
import { Input, InputProps, InputRef, Tooltip } from 'antd';
import cx from 'classnames';
import { useEffect, useRef, useState } from 'react';
export interface OverflowTooltipInputProps extends InputProps {
tooltipPlacement?: 'top' | 'bottom' | 'left' | 'right';
minAutoWidth?: number;
maxAutoWidth?: number;
}
function OverflowInputToolTip({
value,
defaultValue,
onChange,
disabled = false,
tooltipPlacement = 'top',
className,
minAutoWidth = 70,
maxAutoWidth = 150,
...rest
}: OverflowTooltipInputProps): JSX.Element {
const inputRef = useRef<InputRef>(null);
const mirrorRef = useRef<HTMLSpanElement | null>(null);
const [isOverflowing, setIsOverflowing] = useState<boolean>(false);
useEffect(() => {
const input = inputRef.current?.input;
const mirror = mirrorRef.current;
if (!input || !mirror) {
setIsOverflowing(false);
return;
}
mirror.textContent = String(value ?? '') || ' ';
const mirrorWidth = mirror.offsetWidth + 24;
const newWidth = Math.min(maxAutoWidth, Math.max(minAutoWidth, mirrorWidth));
input.style.width = `${newWidth}px`;
// consider clamped when mirrorWidth reaches maxAutoWidth (allow -5px tolerance)
const isClamped = mirrorWidth >= maxAutoWidth - 5;
const overflow = input.scrollWidth > input.clientWidth && isClamped;
setIsOverflowing(overflow);
}, [value, disabled, minAutoWidth, maxAutoWidth]);
const tooltipTitle = !disabled && isOverflowing ? String(value ?? '') : '';
return (
<>
<span ref={mirrorRef} aria-hidden className="overflow-input-mirror" />
<Tooltip title={tooltipTitle} placement={tooltipPlacement}>
<Input
{...rest}
value={value}
defaultValue={defaultValue}
onChange={onChange}
disabled={disabled}
ref={inputRef}
className={cx('overflow-input', className)}
/>
</Tooltip>
</>
);
}
OverflowInputToolTip.displayName = 'OverflowInputToolTip';
export default OverflowInputToolTip;

View File

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

View File

@@ -32,6 +32,7 @@ const ADD_ONS_KEYS = {
ORDER_BY: 'order_by',
LIMIT: 'limit',
LEGEND_FORMAT: 'legend_format',
REDUCE_TO: 'reduce_to',
};
const ADD_ONS_KEYS_TO_QUERY_PATH = {
@@ -40,13 +41,14 @@ const ADD_ONS_KEYS_TO_QUERY_PATH = {
[ADD_ONS_KEYS.ORDER_BY]: 'orderBy',
[ADD_ONS_KEYS.LIMIT]: 'limit',
[ADD_ONS_KEYS.LEGEND_FORMAT]: 'legend',
[ADD_ONS_KEYS.REDUCE_TO]: 'reduceTo',
};
const ADD_ONS = [
{
icon: <BarChart2 size={14} />,
label: 'Group By',
key: 'group_by',
key: ADD_ONS_KEYS.GROUP_BY,
description:
'Break down data by attributes like service name, endpoint, status code, or region. Essential for spotting patterns and comparing performance across different segments.',
docLink: 'https://signoz.io/docs/userguide/query-builder-v5/#grouping',
@@ -54,7 +56,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Having',
key: 'having',
key: ADD_ONS_KEYS.HAVING,
description:
'Filter grouped results based on aggregate conditions. Show only groups meeting specific criteria, like error rates > 5% or p99 latency > 500',
docLink:
@@ -63,7 +65,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Order By',
key: 'order_by',
key: ADD_ONS_KEYS.ORDER_BY,
description:
'Sort results to surface what matters most. Quickly identify slowest operations, most frequent errors, or highest resource consumers.',
docLink:
@@ -72,7 +74,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Limit',
key: 'limit',
key: ADD_ONS_KEYS.LIMIT,
description:
'Show only the top/bottom N results. Perfect for focusing on outliers, reducing noise, and improving dashboard performance.',
docLink:
@@ -81,7 +83,7 @@ const ADD_ONS = [
{
icon: <ScrollText size={14} />,
label: 'Legend format',
key: 'legend_format',
key: ADD_ONS_KEYS.LEGEND_FORMAT,
description:
'Customize series labels using variables like {{service.name}}-{{endpoint}}. Makes charts readable at a glance during incident investigation.',
docLink:
@@ -92,7 +94,7 @@ const ADD_ONS = [
const REDUCE_TO = {
icon: <ScrollText size={14} />,
label: 'Reduce to',
key: 'reduce_to',
key: ADD_ONS_KEYS.REDUCE_TO,
description:
'Apply mathematical operations like sum, average, min, max, or percentiles to reduce multiple time series into a single value.',
docLink:
@@ -218,10 +220,9 @@ function QueryAddOns({
);
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
// Filter and set selected views: add-ons that are both active and available
setSelectedViews(
ADD_ONS.filter(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
@@ -300,7 +301,7 @@ function QueryAddOns({
);
return (
<div className="query-add-ons">
<div className="query-add-ons" data-testid="query-add-ons">
{selectedViews.length > 0 && (
<div className="selected-add-ons-content">
{selectedViews.find((view) => view.key === 'group_by') && (

View File

@@ -43,7 +43,10 @@ function QueryAggregationOptions({
};
return (
<div className="query-aggregation-container">
<div
className="query-aggregation-container"
data-testid="query-aggregation-container"
>
<div className="aggregation-container">
<QueryAggregationSelect
onChange={onChange}

View File

@@ -114,9 +114,9 @@ function QuerySearch({
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const handleQueryValidation = useCallback((newQuery: string): void => {
const handleQueryValidation = useCallback((newExpression: string): void => {
try {
const validationResponse = validateQuery(newQuery);
const validationResponse = validateQuery(newExpression);
setValidation(validationResponse);
} catch (error) {
setValidation({
@@ -127,7 +127,7 @@ function QuerySearch({
}
}, []);
const getCurrentQuery = useCallback(
const getCurrentExpression = useCallback(
(): string => editorRef.current?.state.doc.toString() || '',
[],
);
@@ -167,19 +167,14 @@ function QuerySearch({
() => {
if (!isEditorReady) return;
const newQuery = queryData.filter?.expression || '';
const currentQuery = getCurrentQuery();
const newExpression = queryData.filter?.expression || '';
const currentExpression = getCurrentExpression();
/* eslint-disable-next-line sonarjs/no-collapsible-if */
if (newQuery !== currentQuery && !isFocused) {
// Prevent clearing a non-empty editor when queryData becomes empty temporarily
// Only update if newQuery has a value, or if both are empty (initial state)
if (newQuery || !currentQuery) {
updateEditorValue(newQuery, { skipOnChange: true });
if (newQuery) {
handleQueryValidation(newQuery);
}
// Do not update codemirror editor if the expression is the same
if (newExpression !== currentExpression && !isFocused) {
updateEditorValue(newExpression, { skipOnChange: true });
if (newExpression) {
handleQueryValidation(newExpression);
}
}
},
@@ -613,8 +608,8 @@ function QuerySearch({
};
const handleBlur = (): void => {
const currentQuery = getCurrentQuery();
handleQueryValidation(currentQuery);
const currentExpression = getCurrentExpression();
handleQueryValidation(currentExpression);
setIsFocused(false);
};
@@ -633,11 +628,11 @@ function QuerySearch({
const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND
const currentQuery = getCurrentQuery();
const newQuery = currentQuery
? `${currentQuery} AND ${exampleQuery}`
const currentExpression = getCurrentExpression();
const newExpression = currentExpression
? `${currentExpression} AND ${exampleQuery}`
: exampleQuery;
updateEditorValue(newQuery);
updateEditorValue(newExpression);
};
// Helper function to render a badge for the current context mode
@@ -673,9 +668,9 @@ function QuerySearch({
if (word?.from === word?.to && !context.explicit) return null;
// Get current query from editor
const currentQuery = editorRef.current?.state.doc.toString() || '';
const currentExpression = getCurrentExpression();
// Get the query context at the cursor position
const queryContext = getQueryContextAtCursor(currentQuery, cursorPos.ch);
const queryContext = getQueryContextAtCursor(currentExpression, cursorPos.ch);
// Define autocomplete options based on the context
let options: {
@@ -1171,8 +1166,8 @@ function QuerySearch({
if (queryContext.isInParenthesis) {
// Different suggestions based on the context within parenthesis or bracket
const currentQuery = editorRef.current?.state.doc.toString() || '';
const curChar = currentQuery.charAt(cursorPos.ch - 1) || '';
const currentExpression = getCurrentExpression();
const curChar = currentExpression.charAt(cursorPos.ch - 1) || '';
if (curChar === '(' || curChar === '[') {
// Right after opening parenthesis/bracket
@@ -1321,7 +1316,7 @@ function QuerySearch({
style={{
position: 'absolute',
top: 8,
right: validation.isValid === false && getCurrentQuery() ? 40 : 8, // Move left when error shown
right: validation.isValid === false && getCurrentExpression() ? 40 : 8, // Move left when error shown
cursor: 'help',
zIndex: 10,
transition: 'right 0.2s ease',
@@ -1383,7 +1378,7 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentQuery());
onRun(getCurrentExpression());
} else {
handleRunQuery();
}
@@ -1409,7 +1404,7 @@ function QuerySearch({
onBlur={handleBlur}
/>
{getCurrentQuery() && validation.isValid === false && !isFocused && (
{getCurrentExpression() && validation.isValid === false && !isFocused && (
<div
className={cx('query-status-container', {
hasErrors: validation.errors.length > 0,

View File

@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/cognitive-complexity */
/* eslint-disable import/named */
import { EditorView } from '@uiw/react-codemirror';
import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { initialQueriesMap } from 'constants/queryBuilder';
@@ -151,8 +152,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedGetKeys.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -171,8 +170,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
// Focus and type into the editor
await user.click(editor);
await user.type(editor, SAMPLE_KEY_TYPING);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_KEY_TYPING);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
@@ -187,8 +186,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedGetValues.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -204,8 +201,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
@@ -241,7 +238,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
it('calls provided onRun on Mod-Enter', async () => {
const onRun = jest.fn() as jest.MockedFunction<(q: string) => void>;
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
@@ -259,8 +255,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor);
await user.type(editor, SAMPLE_STATUS_QUERY);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_STATUS_QUERY);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
@@ -280,8 +276,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
>;
mockedHandleRunQuery.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
@@ -297,8 +291,8 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
@@ -348,4 +342,73 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
{ timeout: 3000 },
);
});
it('handles queryData.filter.expression changes without triggering onChange', async () => {
// Spy on CodeMirror's EditorView.dispatch, which is invoked when updateEditorValue
// applies a programmatic change to the editor.
const dispatchSpy = jest.spyOn(EditorView.prototype, 'dispatch');
const initialExpression = "service.name = 'frontend'";
const updatedExpression = "service.name = 'backend'";
const onChange = jest.fn() as jest.MockedFunction<(v: string) => void>;
const initialQueryData = {
...initialQueriesMap.logs.builder.queryData[0],
filter: {
expression: initialExpression,
},
};
const { rerender } = render(
<QuerySearch
onChange={onChange}
queryData={initialQueryData}
dataSource={DataSource.LOGS}
/>,
);
// Wait for CodeMirror to initialize with the initial expression
await waitFor(
() => {
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent).toBeInTheDocument();
const textContent = editorContent.textContent || '';
expect(textContent).toBe(initialExpression);
},
{ timeout: 3000 },
);
// Ensure the editor is explicitly blurred (not focused)
// Blur the actual CodeMirror editor container so that QuerySearch's onBlur handler runs.
// Note: In jsdom + CodeMirror we can't reliably assert the DOM text content changes when
// the expression is updated programmatically, but we can assert that:
// 1) The component continues to render, and
// 2) No onChange is fired for programmatic updates.
const updatedQueryData = {
...initialQueryData,
filter: {
expression: updatedExpression,
},
};
// Re-render with updated queryData.filter.expression
rerender(
<QuerySearch
onChange={onChange}
queryData={updatedQueryData}
dataSource={DataSource.LOGS}
/>,
);
// updateEditorValue should have resulted in a dispatch call + onChange should not have been called
await waitFor(() => {
expect(dispatchSpy).toHaveBeenCalled();
expect(onChange).not.toHaveBeenCalled();
});
dispatchSpy.mockRestore();
});
});

View File

@@ -1,6 +1,12 @@
/* eslint-disable */
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
within,
} from 'tests/test-utils';
import QueryAddOns from '../QueryV2/QueryAddOns/QueryAddOns';
import { PANEL_TYPES } from 'constants/queryBuilder';
@@ -55,16 +61,7 @@ jest.mock('../QueryV2/QueryAddOns/HavingFilter/HavingFilter', () => ({
),
}));
jest.mock(
'container/QueryBuilder/filters/ReduceToFilter/ReduceToFilter',
() => ({
ReduceToFilter: ({ onChange }: any) => (
<button data-testid="reduce-to" onClick={() => onChange('sum')}>
ReduceToFilter
</button>
),
}),
);
// ReduceToFilter is not mocked - we test the actual Ant Design Select component
function baseQuery(overrides: Partial<any> = {}): any {
return {
@@ -140,7 +137,7 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('order-by-content')).toBeInTheDocument();
});
it('limit input auto-opens when limit is set and changing it calls handler', () => {
it('limit input auto-opens when limit is set and changing it calls handler', async () => {
render(
<QueryAddOns
query={baseQuery({ limit: 5 })}
@@ -183,4 +180,88 @@ describe('QueryAddOns', () => {
expect(screen.getByTestId('limit-content')).toBeInTheDocument();
expect(limitInput.value).toBe('7');
});
it('shows reduce-to add-on when showReduceTo is true', () => {
render(
<QueryAddOns
query={baseQuery()}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('query-add-on-reduce_to')).toBeInTheDocument();
});
it('auto-opens reduce-to content when reduceTo is set', () => {
render(
<QueryAddOns
query={baseQuery({ reduceTo: 'sum' })}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
});
it('calls handleSetQueryData when reduce-to value changes', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const query = baseQuery({
reduceTo: 'avg',
aggregations: [{ id: 'a', operator: 'count', reduceTo: 'avg' }],
});
render(
<QueryAddOns
query={query}
version="v5"
isListViewPanel={false}
showReduceTo
panelType={PANEL_TYPES.TIME_SERIES}
index={0}
isForTraceOperator={false}
/>,
);
// Wait for the reduce-to content section to be visible (it auto-opens when reduceTo is set)
await waitFor(() => {
expect(screen.getByTestId('reduce-to-content')).toBeInTheDocument();
});
// Get the Select component by its role (combobox)
// The Select is within the reduce-to-content section
const reduceToContent = screen.getByTestId('reduce-to-content');
const selectCombobox = within(reduceToContent).getByRole('combobox');
// Open the dropdown by clicking on the combobox
await user.click(selectCombobox);
// Wait for the dropdown listbox to appear
await screen.findByRole('listbox');
// Find and click the "Sum" option
const sumOption = await screen.findByText('Sum of values in timeframe');
await user.click(sumOption);
// Verify the handler was called with the correct value
await waitFor(() => {
expect(mockHandleSetQueryData).toHaveBeenCalledWith(0, {
...query,
aggregations: [
{
...(query.aggregations?.[0] as any),
reduceTo: 'sum',
},
],
});
});
});
});

View File

@@ -163,6 +163,10 @@ function formatSingleValueForFilter(
if (trimmed === 'true' || trimmed === 'false') {
return trimmed === 'true';
}
if (isQuoted(value)) {
return unquote(value);
}
}
// Return non-string values as-is, or string values that couldn't be converted

View File

@@ -1,3 +1,4 @@
import userEvent from '@testing-library/user-event';
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
@@ -5,7 +6,7 @@ import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQue
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
import { rest, server } from 'mocks-server/server';
import { UseQueryResult } from 'react-query';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import { render, screen, waitFor } from 'tests/test-utils';
import { SuccessResponse } from 'types/api';
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
@@ -41,13 +42,15 @@ interface MockFilterConfig {
type: FiltersType;
}
const SERVICE_NAME_KEY = 'service.name';
const createMockFilter = (
overrides: Partial<MockFilterConfig> = {},
): MockFilterConfig => ({
// eslint-disable-next-line sonarjs/no-duplicate-string
title: 'Service Name',
attributeKey: {
key: 'service.name',
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
@@ -68,7 +71,7 @@ const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
? [
{
key: {
key: 'service.name',
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
@@ -188,4 +191,222 @@ describe('CheckboxFilter - User Flows', () => {
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
});
});
it('should update query filters when a checkbox is clicked', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Start with no active filters so clicking a checkbox creates one
mockUseQueryBuilder.mockReturnValue({
...createMockQueryBuilderData(false),
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// User unchecks the first value (`mq-kafka`)
await userEvent.click(checkboxes[0]);
// Composite query params (query builder data) should be updated via redirectWithQueryBuilderData
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
// When unchecking from an "all selected" state, we use a NOT_IN filter for that value
expect(updatedFilters.items[0].op).toBe('not in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should set an IN filter with only the clicked value when using Only', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka', 'otel-demo']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka', 'otel-demo'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for values to render
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Click on the value label to trigger the "Only" behavior
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
expect(updatedFilters.items).toHaveLength(1);
expect(updatedFilters.items[0].key.key).toBe(SERVICE_NAME_KEY);
expect(updatedFilters.items[0].op).toBe('in');
expect(updatedFilters.items[0].value).toBe('mq-kafka');
});
it('should clear filters for the attribute when using All', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN ['mq-kafka']
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: ['mq-kafka'],
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
await waitFor(() => {
expect(screen.getByText('mq-kafka')).toBeInTheDocument();
});
// Only one value is selected, so clicking it should switch to "All" (no filter for this key)
await userEvent.click(screen.getByText('mq-kafka'));
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const filtersForServiceName = updatedFilters.items.filter(
(item: any) => item.key?.key === SERVICE_NAME_KEY,
);
expect(filtersForServiceName).toHaveLength(0);
});
it('should extend an existing IN filter when checking an additional value', async () => {
const redirectWithQueryBuilderData = jest.fn();
// Existing filter: service.name IN 'mq-kafka'
mockUseQueryBuilder.mockReturnValue({
lastUsedQuery: 0,
currentQuery: {
builder: {
queryData: [
{
filters: {
items: [
{
key: {
key: SERVICE_NAME_KEY,
dataType: DataTypes.String,
type: 'resource',
},
op: 'in',
value: 'mq-kafka',
},
],
op: 'AND',
},
},
],
},
},
redirectWithQueryBuilderData,
} as any);
const mockFilter = createMockFilter({ defaultOpen: true });
render(
<CheckboxFilter
filter={mockFilter}
source={QuickFiltersSource.LOGS_EXPLORER}
/>,
);
// Wait for checkboxes to render
await waitFor(() => {
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
const checkboxes = screen.getAllByRole('checkbox');
// First checkbox corresponds to 'mq-kafka' (already selected),
// second will be 'otel-demo' which we now select additionally.
await userEvent.click(checkboxes[1]);
expect(redirectWithQueryBuilderData).toHaveBeenCalledTimes(1);
const [updatedQuery] = redirectWithQueryBuilderData.mock.calls[0];
const updatedFilters = updatedQuery.builder.queryData[0].filters;
const [filterForServiceName] = updatedFilters.items;
expect(filterForServiceName.key.key).toBe(SERVICE_NAME_KEY);
expect(filterForServiceName.op).toBe('in');
expect(filterForServiceName.value).toEqual(['mq-kafka', 'otel-demo']);
});
});

View File

@@ -0,0 +1,27 @@
import { createShortcutActions } from '../../constants/shortcutActions';
import { useCmdK } from '../../providers/cmdKProvider';
import { ShiftOverlay } from './ShiftOverlay';
import { useShiftHoldOverlay } from './useShiftHoldOverlay';
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function ShiftHoldOverlayController({
userRole,
}: {
userRole: UserRole;
}): JSX.Element | null {
const { open: isCmdKOpen } = useCmdK();
const noop = (): void => undefined;
const actions = createShortcutActions({
navigate: noop,
handleThemeChange: noop,
});
const visible = useShiftHoldOverlay({
isModalOpen: isCmdKOpen,
});
return (
<ShiftOverlay visible={visible} actions={actions} userRole={userRole} />
);
}

View File

@@ -0,0 +1,77 @@
import './shiftOverlay.scss';
import { useMemo } from 'react';
import ReactDOM from 'react-dom';
import { formatShortcut } from './formatShortcut';
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
roles?: UserRole[];
perform: () => void;
};
interface ShortcutProps {
label: string;
keyHint: React.ReactNode;
}
function Shortcut({ label, keyHint }: ShortcutProps): JSX.Element {
return (
<div className="shift-overlay__item">
<span className="shift-overlay__label">{label}</span>
<kbd className="shift-overlay__kbd">{keyHint}</kbd>
</div>
);
}
interface ShiftOverlayProps {
visible: boolean;
actions: CmdAction[];
userRole: UserRole;
}
export function ShiftOverlay({
visible,
actions,
userRole,
}: ShiftOverlayProps): JSX.Element | null {
const navigationActions = useMemo(() => {
// RBAC filter: show action if no roles set OR current user role is included
const permitted = actions.filter(
(a) => !a.roles || a.roles.includes(userRole),
);
// Navigation only + must have shortcut
return permitted.filter(
(a) =>
a.section?.toLowerCase() === 'navigation' &&
a.shortcut &&
a.shortcut.length > 0,
);
}, [actions, userRole]);
if (!visible || navigationActions.length === 0) {
return null;
}
return ReactDOM.createPortal(
<div className="shift-overlay">
<div className="shift-overlay__panel">
{navigationActions.map((action) => (
<Shortcut
key={action.id}
label={action.name.replace(/^Go to\s+/i, '')}
keyHint={formatShortcut(action.shortcut)}
/>
))}
</div>
</div>,
document.body,
);
}

View File

@@ -0,0 +1,102 @@
import '@testing-library/jest-dom';
import { render, screen } from '@testing-library/react';
import type { CmdAction } from '../ShiftOverlay';
import { ShiftOverlay } from '../ShiftOverlay';
jest.mock('../formatShortcut', () => ({
formatShortcut: (shortcut: string[]): string => shortcut.join('+'),
}));
const baseActions: CmdAction[] = [
{
id: '1',
name: 'Go to Traces',
section: 'navigation',
shortcut: ['Shift', 'T'],
perform: jest.fn(),
},
{
id: '2',
name: 'Go to Metrics',
section: 'navigation',
shortcut: ['Shift', 'M'],
roles: ['ADMIN'], // ✅ now UserRole[]
perform: jest.fn(),
},
{
id: '3',
name: 'Create Alert',
section: 'actions',
shortcut: ['A'],
perform: jest.fn(),
},
{
id: '4',
name: 'Go to Logs',
section: 'navigation',
perform: jest.fn(),
},
];
describe('ShiftOverlay', () => {
it('renders nothing when not visible', () => {
const { container } = render(
<ShiftOverlay visible={false} actions={baseActions} userRole="ADMIN" />,
);
expect(container.firstChild).toBeNull();
});
it('renders nothing when no navigation shortcuts exist', () => {
const { container } = render(
<ShiftOverlay
visible
actions={[
{
id: 'x',
name: 'Create Alert',
section: 'actions',
perform: jest.fn(),
},
]}
userRole="ADMIN"
/>,
);
expect(container.firstChild).toBeNull();
});
it('renders navigation shortcuts in a portal', () => {
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
expect(document.body.querySelector('.shift-overlay')).toBeInTheDocument();
expect(screen.getByText('Traces')).toBeInTheDocument();
expect(screen.getByText('Metrics')).toBeInTheDocument();
expect(screen.getByText('Shift+T')).toBeInTheDocument();
expect(screen.getByText('Shift+M')).toBeInTheDocument();
});
it('applies RBAC filtering correctly', () => {
render(<ShiftOverlay visible actions={baseActions} userRole="VIEWER" />);
expect(screen.getByText('Traces')).toBeInTheDocument();
expect(screen.queryByText('Metrics')).not.toBeInTheDocument();
});
it('strips "Go to" prefix from labels', () => {
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
expect(screen.getByText('Traces')).toBeInTheDocument();
expect(screen.queryByText('Go to Traces')).not.toBeInTheDocument();
});
it('does not render actions without shortcuts', () => {
render(<ShiftOverlay visible actions={baseActions} userRole="ADMIN" />);
expect(screen.queryByText('Logs')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,144 @@
import { act, renderHook } from '@testing-library/react';
import { useShiftHoldOverlay } from '../useShiftHoldOverlay';
jest.useFakeTimers();
function pressShift(target: EventTarget = window): void {
const event = new KeyboardEvent('keydown', {
key: 'Shift',
bubbles: true,
});
Object.defineProperty(event, 'target', { value: target });
window.dispatchEvent(event);
}
function releaseShift(): void {
window.dispatchEvent(
new KeyboardEvent('keyup', {
key: 'Shift',
bubbles: true,
}),
);
}
describe('useShiftHoldOverlay', () => {
afterEach(() => {
jest.clearAllTimers();
});
it('shows overlay after holding Shift for 600ms', () => {
const { result } = renderHook(() => useShiftHoldOverlay({}));
act(() => {
pressShift();
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(true);
});
it('does not show overlay if Shift is released early', () => {
const { result } = renderHook(() => useShiftHoldOverlay({}));
act(() => {
pressShift();
jest.advanceTimersByTime(300);
releaseShift();
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(false);
});
it('hides overlay on Shift key release', () => {
const { result } = renderHook(() => useShiftHoldOverlay({}));
act(() => {
pressShift();
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(true);
act(() => {
releaseShift();
});
expect(result.current).toBe(false);
});
it('does not activate when modal is open', () => {
const { result } = renderHook(() =>
useShiftHoldOverlay({ isModalOpen: true }),
);
act(() => {
pressShift();
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(false);
});
it('does not activate in typing context (input)', () => {
const input = document.createElement('input');
document.body.appendChild(input);
const { result } = renderHook(() => useShiftHoldOverlay({}));
act(() => {
pressShift(input);
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(false);
document.body.removeChild(input);
});
it('cleans up on window blur', () => {
const { result } = renderHook(() => useShiftHoldOverlay({}));
act(() => {
pressShift();
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(true);
act(() => {
window.dispatchEvent(new Event('blur'));
});
expect(result.current).toBe(false);
});
it('cleans up on document visibility change', () => {
const { result } = renderHook(() => useShiftHoldOverlay({}));
act(() => {
pressShift();
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(true);
act(() => {
document.dispatchEvent(new Event('visibilitychange'));
});
expect(result.current).toBe(false);
});
it('does nothing when disabled', () => {
const { result } = renderHook(() => useShiftHoldOverlay({ disabled: true }));
act(() => {
pressShift();
jest.advanceTimersByTime(600);
});
expect(result.current).toBe(false);
});
});

View File

@@ -0,0 +1,44 @@
import './shiftOverlay.scss';
import { ArrowUp, ChevronUp, Command, Option } from 'lucide-react';
import { ReactNode } from 'react';
export function formatShortcut(shortcut?: string[]): ReactNode {
if (!shortcut || shortcut.length === 0) return null;
const combo = shortcut.find((s) => typeof s === 'string' && s.trim());
if (!combo) return null;
return combo.split('+').map((key) => {
const k = key.trim().toLowerCase();
let node: ReactNode;
switch (k) {
case 'shift':
node = <ArrowUp size={14} />;
break;
case 'cmd':
case 'meta':
node = <Command size={14} />;
break;
case 'alt':
node = <Option size={14} />;
break;
case 'ctrl':
case 'control':
node = <ChevronUp size={14} />;
break;
case 'arrowup':
node = <ArrowUp size={14} />;
break;
default:
node = k.toUpperCase();
}
return (
<span key={`shortcut-${k}`} className="shift-overlay__key">
{node}
</span>
);
});
}

View File

@@ -0,0 +1,75 @@
.shift-overlay {
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
z-index: 9999;
pointer-events: none;
&__panel {
display: flex;
gap: 20px;
padding: 8px 12px;
background: var(--bg-ink-500);
color: var(--bg-vanilla-300);
border-radius: 8px;
font-size: 13px;
line-height: 1.2;
box-shadow: 0 6px 20px var(--bg-ink-500);
animation: shift-overlay-fade-in 120ms ease-out;
}
&__item {
display: flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
&__label {
opacity: 0.9;
}
&__kbd {
font-family: monospace;
font-size: 12px;
padding: 2px 6px;
display: flex;
border-radius: 4px;
background: var(--bg-slate-100);
}
&__key {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 15px;
height: 20px;
border-radius: 4px;
background-color: var(--bg-slate-100);
font-size: 12px;
font-weight: 500;
line-height: 1;
color: var(--bg-vanilla-300);
flex-shrink: 0;
}
}
@keyframes shift-overlay-fade-in {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}

View File

@@ -0,0 +1,87 @@
import { useEffect, useRef, useState } from 'react';
const HOLD_DELAY_MS = 500;
function isTypingContext(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
const tag = target.tagName;
return tag === 'INPUT' || tag === 'TEXTAREA' || target.isContentEditable;
}
interface UseShiftHoldOverlayOptions {
disabled?: boolean;
isModalOpen?: boolean;
}
export function useShiftHoldOverlay({
disabled = false,
isModalOpen = false,
}: UseShiftHoldOverlayOptions): boolean {
const [visible, setVisible] = useState<boolean>(false);
const timerRef = useRef<number | null>(null);
const isHoldingRef = useRef<boolean>(false);
useEffect((): (() => void) | void => {
if (disabled) return;
function cleanup(): void {
isHoldingRef.current = false;
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
setVisible(false);
}
function onKeyDown(e: KeyboardEvent): void {
if (e.key !== 'Shift') return;
if (e.repeat) return;
// Suppress in bad contexts
if (
isModalOpen ||
e.metaKey ||
e.ctrlKey ||
e.altKey ||
isTypingContext(e.target)
) {
return;
}
isHoldingRef.current = true;
timerRef.current = window.setTimeout(() => {
if (isHoldingRef.current) {
setVisible(true);
}
}, HOLD_DELAY_MS);
}
function onKeyUp(e: KeyboardEvent): void {
if (e.key !== 'Shift') return;
cleanup();
}
function onBlur(): void {
cleanup();
}
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur', onBlur);
document.addEventListener('visibilitychange', cleanup);
return (): void => {
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur', onBlur);
document.removeEventListener('visibilitychange', cleanup);
};
}, [disabled, isModalOpen]);
return visible;
}

View File

@@ -0,0 +1,205 @@
import { screen } from '@testing-library/react';
import { render } from 'tests/test-utils';
import ValueGraph from '../index';
import { getBackgroundColorAndThresholdCheck } from '../utils';
// Mock the utils module
jest.mock('../utils', () => ({
getBackgroundColorAndThresholdCheck: jest.fn(() => ({
threshold: {} as any,
isConflictingThresholds: false,
})),
}));
const mockGetBackgroundColorAndThresholdCheck = getBackgroundColorAndThresholdCheck as jest.MockedFunction<
typeof getBackgroundColorAndThresholdCheck
>;
const TEST_ID_VALUE_GRAPH_TEXT = 'value-graph-text';
const TEST_ID_VALUE_GRAPH_PREFIX_UNIT = 'value-graph-prefix-unit';
const TEST_ID_VALUE_GRAPH_SUFFIX_UNIT = 'value-graph-suffix-unit';
describe('ValueGraph', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders the numeric value correctly', () => {
const { getByTestId } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42');
});
it('renders value with suffix unit', () => {
const { getByTestId } = render(
<ValueGraph value="42ms" rawValue={42} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42');
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('ms');
});
it('renders value with prefix unit', () => {
const { getByTestId } = render(
<ValueGraph value="$100" rawValue={100} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('100');
expect(getByTestId(TEST_ID_VALUE_GRAPH_PREFIX_UNIT)).toHaveTextContent('$');
});
it('renders value with both prefix and suffix units', () => {
const { getByTestId } = render(
<ValueGraph value="$100USD" rawValue={100} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('100');
expect(getByTestId(TEST_ID_VALUE_GRAPH_PREFIX_UNIT)).toHaveTextContent('$');
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('USD');
});
it('renders value with K suffix', () => {
const { getByTestId } = render(
<ValueGraph value="1.5K" rawValue={1500} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1.5K');
});
it('applies text color when threshold format is Text', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Text',
thresholdColor: 'red',
} as any,
isConflictingThresholds: false,
});
const { getByTestId } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveStyle({ color: 'red' });
});
it('applies background color when threshold format is Background', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Background',
thresholdColor: 'blue',
} as any,
isConflictingThresholds: false,
});
const { container } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
const containerElement = container.querySelector('.value-graph-container');
expect(containerElement).toHaveStyle({ backgroundColor: 'blue' });
});
it('displays conflicting thresholds indicator when multiple thresholds match', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Text',
thresholdColor: 'red',
} as any,
isConflictingThresholds: true,
});
const { getByTestId } = render(
<ValueGraph value="42" rawValue={42} thresholds={[]} />,
);
expect(getByTestId('conflicting-thresholds')).toBeInTheDocument();
});
it('does not display conflicting thresholds indicator when no conflict', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {} as any,
isConflictingThresholds: false,
});
render(<ValueGraph value="42" rawValue={42} thresholds={[]} />);
expect(
screen.queryByTestId('conflicting-thresholds'),
).not.toBeInTheDocument();
});
it('applies text color to units when threshold format is Text', () => {
mockGetBackgroundColorAndThresholdCheck.mockReturnValue({
threshold: {
thresholdFormat: 'Text',
thresholdColor: 'green',
} as any,
isConflictingThresholds: false,
});
render(<ValueGraph value="42ms" rawValue={42} thresholds={[]} />);
const unitElement = screen.getByText('ms');
expect(unitElement).toHaveStyle({ color: 'green' });
});
it('renders decimal values correctly', () => {
const { getByTestId } = render(
<ValueGraph value="42.5" rawValue={42.5} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('42.5');
});
it('handles values with M suffix', () => {
const { getByTestId } = render(
<ValueGraph value="1.2M" rawValue={1200000} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1.2M');
});
it('handles values with B suffix', () => {
const { getByTestId } = render(
<ValueGraph value="2.3B" rawValue={2300000000} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('2.3B');
});
it('handles scientific notation values', () => {
const { getByTestId } = render(
<ValueGraph value="1e-9" rawValue={1e-9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e-9');
});
it('handles scientific notation with suffix unit', () => {
const { getByTestId } = render(
<ValueGraph value="1e-9%" rawValue={1e-9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e-9');
expect(getByTestId(TEST_ID_VALUE_GRAPH_SUFFIX_UNIT)).toHaveTextContent('%');
});
it('handles scientific notation with uppercase E', () => {
const { getByTestId } = render(
<ValueGraph value="1E-9" rawValue={1e-9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1E-9');
});
it('handles scientific notation with positive exponent', () => {
const { getByTestId } = render(
<ValueGraph value="1e+9" rawValue={1e9} thresholds={[]} />,
);
expect(getByTestId(TEST_ID_VALUE_GRAPH_TEXT)).toHaveTextContent('1e+9');
});
});

View File

@@ -3,11 +3,39 @@ import './ValueGraph.styles.scss';
import { ExclamationCircleFilled } from '@ant-design/icons';
import { Tooltip, Typography } from 'antd';
import { ThresholdProps } from 'container/NewWidget/RightContainer/Threshold/types';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { getBackgroundColorAndThresholdCheck } from './utils';
function Unit({
type,
unit,
threshold,
fontSize,
}: {
type: 'prefix' | 'suffix';
unit: string;
threshold: ThresholdProps;
fontSize: string;
}): JSX.Element {
return (
<Typography.Text
className="value-graph-unit"
data-testid={`value-graph-${type}-unit`}
style={{
color:
threshold.thresholdFormat === 'Text'
? threshold.thresholdColor
: undefined,
fontSize: `calc(${fontSize} * 0.7)`,
}}
>
{unit}
</Typography.Text>
);
}
function ValueGraph({
value,
rawValue,
@@ -17,10 +45,16 @@ function ValueGraph({
const containerRef = useRef<HTMLDivElement>(null);
const [fontSize, setFontSize] = useState('2.5vw');
// Parse value to separate number and unit (assuming unit is at the end)
const matches = value.match(/([\d.]+[KMB]?)(.*)$/);
const numericValue = matches?.[1] || value;
const unit = matches?.[2]?.trim() || '';
const { numericValue, prefixUnit, suffixUnit } = useMemo(() => {
const matches = value.match(
/^([^\d.]*)?([\d.]+(?:[eE][+-]?[\d]+)?[KMB]?)([^\d.]*)?$/,
);
return {
numericValue: matches?.[2] || value,
prefixUnit: matches?.[1]?.trim() || '',
suffixUnit: matches?.[3]?.trim() || '',
};
}, [value]);
// Adjust font size based on container size
useEffect(() => {
@@ -65,8 +99,17 @@ function ValueGraph({
}}
>
<div className="value-text-container">
{prefixUnit && (
<Unit
type="prefix"
unit={prefixUnit}
threshold={threshold}
fontSize={fontSize}
/>
)}
<Typography.Text
className="value-graph-text"
data-testid="value-graph-text"
style={{
color:
threshold.thresholdFormat === 'Text'
@@ -77,19 +120,13 @@ function ValueGraph({
>
{numericValue}
</Typography.Text>
{unit && (
<Typography.Text
className="value-graph-unit"
style={{
color:
threshold.thresholdFormat === 'Text'
? threshold.thresholdColor
: undefined,
fontSize: `calc(${fontSize} * 0.7)`,
}}
>
{unit}
</Typography.Text>
{suffixUnit && (
<Unit
type="suffix"
unit={suffixUnit}
threshold={threshold}
fontSize={fontSize}
/>
)}
</div>
{isConflictingThresholds && (

View File

@@ -3,9 +3,9 @@ import './styles.scss';
import { Select } from 'antd';
import { DefaultOptionType } from 'antd/es/select';
import { UniversalYAxisUnitMappings, Y_AXIS_CATEGORIES } from './constants';
import { UniversalYAxisUnitMappings } from './constants';
import { UniversalYAxisUnit, YAxisUnitSelectorProps } from './types';
import { mapMetricUnitToUniversalUnit } from './utils';
import { getYAxisCategories, mapMetricUnitToUniversalUnit } from './utils';
function YAxisUnitSelector({
value,
@@ -13,6 +13,7 @@ function YAxisUnitSelector({
placeholder = 'Please select a unit',
loading = false,
'data-testid': dataTestId,
source,
}: YAxisUnitSelectorProps): JSX.Element {
const universalUnit = mapMetricUnitToUniversalUnit(value);
@@ -37,6 +38,8 @@ function YAxisUnitSelector({
return aliases.some((alias) => alias.toLowerCase().includes(search));
};
const categories = getYAxisCategories(source);
return (
<div className="y-axis-unit-selector-component">
<Select
@@ -48,7 +51,7 @@ function YAxisUnitSelector({
loading={loading}
data-testid={dataTestId}
>
{Y_AXIS_CATEGORIES.map((category) => (
{categories.map((category) => (
<Select.OptGroup key={category.name} label={category.name}>
{category.units.map((unit) => (
<Select.Option key={unit.id} value={unit.id}>

View File

@@ -1,5 +1,6 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { YAxisSource } from '../types';
import YAxisUnitSelector from '../YAxisUnitSelector';
describe('YAxisUnitSelector', () => {
@@ -10,7 +11,13 @@ describe('YAxisUnitSelector', () => {
});
it('renders with default placeholder', () => {
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
render(
<YAxisUnitSelector
value=""
onChange={mockOnChange}
source={YAxisSource.ALERTS}
/>,
);
expect(screen.getByText('Please select a unit')).toBeInTheDocument();
});
@@ -20,13 +27,20 @@ describe('YAxisUnitSelector', () => {
value=""
onChange={mockOnChange}
placeholder="Custom placeholder"
source={YAxisSource.ALERTS}
/>,
);
expect(screen.queryByText('Custom placeholder')).toBeInTheDocument();
});
it('calls onChange when a value is selected', () => {
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
render(
<YAxisUnitSelector
value=""
onChange={mockOnChange}
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
@@ -41,18 +55,30 @@ describe('YAxisUnitSelector', () => {
});
it('filters options based on search input', () => {
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
render(
<YAxisUnitSelector
value=""
onChange={mockOnChange}
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);
const input = screen.getByRole('combobox');
fireEvent.change(input, { target: { value: 'byte' } });
fireEvent.change(input, { target: { value: 'bytes/sec' } });
expect(screen.getByText('Bytes/sec')).toBeInTheDocument();
});
it('shows all categories and their units', () => {
render(<YAxisUnitSelector value="" onChange={mockOnChange} />);
render(
<YAxisUnitSelector
value=""
onChange={mockOnChange}
source={YAxisSource.ALERTS}
/>,
);
const select = screen.getByRole('combobox');
fireEvent.mouseDown(select);

View File

@@ -0,0 +1,951 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import {
AdditionalLabelsMappingForGrafanaUnits,
UniversalUnitToGrafanaUnit,
} from '../constants';
import { formatUniversalUnit } from '../formatter';
describe('formatUniversalUnit', () => {
describe('Time', () => {
test.each([
// Days
[31, UniversalYAxisUnit.DAYS, '4.43 weeks'],
[7, UniversalYAxisUnit.DAYS, '1 week'],
[6, UniversalYAxisUnit.DAYS, '6 days'],
[1, UniversalYAxisUnit.DAYS, '1 day'],
// Hours
[25, UniversalYAxisUnit.HOURS, '1.04 days'],
[23, UniversalYAxisUnit.HOURS, '23 hour'],
[1, UniversalYAxisUnit.HOURS, '1 hour'],
// Minutes
[61, UniversalYAxisUnit.MINUTES, '1.02 hours'],
[60, UniversalYAxisUnit.MINUTES, '1 hour'],
[45, UniversalYAxisUnit.MINUTES, '45 min'],
[1, UniversalYAxisUnit.MINUTES, '1 min'],
// Seconds
[100000, UniversalYAxisUnit.SECONDS, '1.16 days'],
[10065, UniversalYAxisUnit.SECONDS, '2.8 hours'],
[61, UniversalYAxisUnit.SECONDS, '1.02 mins'],
[60, UniversalYAxisUnit.SECONDS, '1 min'],
[12, UniversalYAxisUnit.SECONDS, '12 s'],
[1, UniversalYAxisUnit.SECONDS, '1 s'],
// Milliseconds
[1006, UniversalYAxisUnit.MILLISECONDS, '1.01 s'],
[10000000, UniversalYAxisUnit.MILLISECONDS, '2.78 hours'],
[100006, UniversalYAxisUnit.MICROSECONDS, '100 ms'],
[1, UniversalYAxisUnit.MICROSECONDS, '1 µs'],
[12, UniversalYAxisUnit.MICROSECONDS, '12 µs'],
// Nanoseconds
[10000000000, UniversalYAxisUnit.NANOSECONDS, '10 s'],
[10000006, UniversalYAxisUnit.NANOSECONDS, '10 ms'],
[1006, UniversalYAxisUnit.NANOSECONDS, '1.01 µs'],
[1, UniversalYAxisUnit.NANOSECONDS, '1 ns'],
[12, UniversalYAxisUnit.NANOSECONDS, '12 ns'],
])('formats time value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Data', () => {
test.each([
// Bytes
[864, UniversalYAxisUnit.BYTES, '864 B'],
[1000, UniversalYAxisUnit.BYTES, '1 kB'],
[1020, UniversalYAxisUnit.BYTES, '1.02 kB'],
// Kilobytes
[512, UniversalYAxisUnit.KILOBYTES, '512 kB'],
[1000, UniversalYAxisUnit.KILOBYTES, '1 MB'],
[1023, UniversalYAxisUnit.KILOBYTES, '1.02 MB'],
// Megabytes
[777, UniversalYAxisUnit.MEGABYTES, '777 MB'],
[1000, UniversalYAxisUnit.MEGABYTES, '1 GB'],
[1023, UniversalYAxisUnit.MEGABYTES, '1.02 GB'],
// Gigabytes
[432, UniversalYAxisUnit.GIGABYTES, '432 GB'],
[1000, UniversalYAxisUnit.GIGABYTES, '1 TB'],
[1023, UniversalYAxisUnit.GIGABYTES, '1.02 TB'],
// Terabytes
[678, UniversalYAxisUnit.TERABYTES, '678 TB'],
[1000, UniversalYAxisUnit.TERABYTES, '1 PB'],
[1023, UniversalYAxisUnit.TERABYTES, '1.02 PB'],
// Petabytes
[845, UniversalYAxisUnit.PETABYTES, '845 PB'],
[1000, UniversalYAxisUnit.PETABYTES, '1 EB'],
[1023, UniversalYAxisUnit.PETABYTES, '1.02 EB'],
// Exabytes
[921, UniversalYAxisUnit.EXABYTES, '921 EB'],
[1000, UniversalYAxisUnit.EXABYTES, '1 ZB'],
[1023, UniversalYAxisUnit.EXABYTES, '1.02 ZB'],
// Zettabytes
[921, UniversalYAxisUnit.ZETTABYTES, '921 ZB'],
[1000, UniversalYAxisUnit.ZETTABYTES, '1 YB'],
[1023, UniversalYAxisUnit.ZETTABYTES, '1.02 YB'],
// Yottabytes
[921, UniversalYAxisUnit.YOTTABYTES, '921 YB'],
[1000, UniversalYAxisUnit.YOTTABYTES, '1000 YB'],
[1023, UniversalYAxisUnit.YOTTABYTES, '1023 YB'],
])('formats data value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Data rate', () => {
test.each([
// Bytes/second
[864, UniversalYAxisUnit.BYTES_SECOND, '864 B/s'],
[1000, UniversalYAxisUnit.BYTES_SECOND, '1 kB/s'],
[1020, UniversalYAxisUnit.BYTES_SECOND, '1.02 kB/s'],
// Kilobytes/second
[512, UniversalYAxisUnit.KILOBYTES_SECOND, '512 kB/s'],
[1000, UniversalYAxisUnit.KILOBYTES_SECOND, '1 MB/s'],
[1023, UniversalYAxisUnit.KILOBYTES_SECOND, '1.02 MB/s'],
// Megabytes/second
[777, UniversalYAxisUnit.MEGABYTES_SECOND, '777 MB/s'],
[1000, UniversalYAxisUnit.MEGABYTES_SECOND, '1 GB/s'],
[1023, UniversalYAxisUnit.MEGABYTES_SECOND, '1.02 GB/s'],
// Gigabytes/second
[432, UniversalYAxisUnit.GIGABYTES_SECOND, '432 GB/s'],
[1000, UniversalYAxisUnit.GIGABYTES_SECOND, '1 TB/s'],
[1023, UniversalYAxisUnit.GIGABYTES_SECOND, '1.02 TB/s'],
// Terabytes/second
[678, UniversalYAxisUnit.TERABYTES_SECOND, '678 TB/s'],
[1000, UniversalYAxisUnit.TERABYTES_SECOND, '1 PB/s'],
[1023, UniversalYAxisUnit.TERABYTES_SECOND, '1.02 PB/s'],
// Petabytes/second
[845, UniversalYAxisUnit.PETABYTES_SECOND, '845 PB/s'],
[1000, UniversalYAxisUnit.PETABYTES_SECOND, '1 EB/s'],
[1023, UniversalYAxisUnit.PETABYTES_SECOND, '1.02 EB/s'],
// Exabytes/second
[921, UniversalYAxisUnit.EXABYTES_SECOND, '921 EB/s'],
[1000, UniversalYAxisUnit.EXABYTES_SECOND, '1 ZB/s'],
[1023, UniversalYAxisUnit.EXABYTES_SECOND, '1.02 ZB/s'],
// Zettabytes/second
[921, UniversalYAxisUnit.ZETTABYTES_SECOND, '921 ZB/s'],
[1000, UniversalYAxisUnit.ZETTABYTES_SECOND, '1 YB/s'],
[1023, UniversalYAxisUnit.ZETTABYTES_SECOND, '1.02 YB/s'],
// Yottabytes/second
[921, UniversalYAxisUnit.YOTTABYTES_SECOND, '921 YB/s'],
[1000, UniversalYAxisUnit.YOTTABYTES_SECOND, '1000 YB/s'],
[1023, UniversalYAxisUnit.YOTTABYTES_SECOND, '1023 YB/s'],
])('formats data value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Bit', () => {
test.each([
// Bits
[1, UniversalYAxisUnit.BITS, '1 b'],
[250, UniversalYAxisUnit.BITS, '250 b'],
[1000, UniversalYAxisUnit.BITS, '1 kb'],
[1023, UniversalYAxisUnit.BITS, '1.02 kb'],
// Kilobits
[0.5, UniversalYAxisUnit.KILOBITS, '500 b'],
[375, UniversalYAxisUnit.KILOBITS, '375 kb'],
[1000, UniversalYAxisUnit.KILOBITS, '1 Mb'],
[1023, UniversalYAxisUnit.KILOBITS, '1.02 Mb'],
// Megabits
[0.5, UniversalYAxisUnit.MEGABITS, '500 kb'],
[640, UniversalYAxisUnit.MEGABITS, '640 Mb'],
[1000, UniversalYAxisUnit.MEGABITS, '1 Gb'],
[1023, UniversalYAxisUnit.MEGABITS, '1.02 Gb'],
// Gigabits
[0.5, UniversalYAxisUnit.GIGABITS, '500 Mb'],
[875, UniversalYAxisUnit.GIGABITS, '875 Gb'],
[1000, UniversalYAxisUnit.GIGABITS, '1 Tb'],
[1023, UniversalYAxisUnit.GIGABITS, '1.02 Tb'],
// Terabits
[0.5, UniversalYAxisUnit.TERABITS, '500 Gb'],
[430, UniversalYAxisUnit.TERABITS, '430 Tb'],
[1000, UniversalYAxisUnit.TERABITS, '1 Pb'],
[1023, UniversalYAxisUnit.TERABITS, '1.02 Pb'],
// Petabits
[0.5, UniversalYAxisUnit.PETABITS, '500 Tb'],
[590, UniversalYAxisUnit.PETABITS, '590 Pb'],
[1000, UniversalYAxisUnit.PETABITS, '1 Eb'],
[1023, UniversalYAxisUnit.PETABITS, '1.02 Eb'],
// Exabits
[0.5, UniversalYAxisUnit.EXABITS, '500 Pb'],
[715, UniversalYAxisUnit.EXABITS, '715 Eb'],
[1000, UniversalYAxisUnit.EXABITS, '1 Zb'],
[1023, UniversalYAxisUnit.EXABITS, '1.02 Zb'],
// Zettabits
[0.5, UniversalYAxisUnit.ZETTABITS, '500 Eb'],
[840, UniversalYAxisUnit.ZETTABITS, '840 Zb'],
[1000, UniversalYAxisUnit.ZETTABITS, '1 Yb'],
[1023, UniversalYAxisUnit.ZETTABITS, '1.02 Yb'],
// Yottabits
[0.5, UniversalYAxisUnit.YOTTABITS, '500 Zb'],
[965, UniversalYAxisUnit.YOTTABITS, '965 Yb'],
[1000, UniversalYAxisUnit.YOTTABITS, '1000 Yb'],
[1023, UniversalYAxisUnit.YOTTABITS, '1023 Yb'],
])('formats bit value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Bit rate', () => {
test.each([
// Bits/second
[512, UniversalYAxisUnit.BITS_SECOND, '512 b/s'],
[1000, UniversalYAxisUnit.BITS_SECOND, '1 kb/s'],
[1023, UniversalYAxisUnit.BITS_SECOND, '1.02 kb/s'],
// Kilobits/second
[0.5, UniversalYAxisUnit.KILOBITS_SECOND, '500 b/s'],
[512, UniversalYAxisUnit.KILOBITS_SECOND, '512 kb/s'],
[1000, UniversalYAxisUnit.KILOBITS_SECOND, '1 Mb/s'],
[1023, UniversalYAxisUnit.KILOBITS_SECOND, '1.02 Mb/s'],
// Megabits/second
[0.5, UniversalYAxisUnit.MEGABITS_SECOND, '500 kb/s'],
[512, UniversalYAxisUnit.MEGABITS_SECOND, '512 Mb/s'],
[1000, UniversalYAxisUnit.MEGABITS_SECOND, '1 Gb/s'],
[1023, UniversalYAxisUnit.MEGABITS_SECOND, '1.02 Gb/s'],
// Gigabits/second
[0.5, UniversalYAxisUnit.GIGABITS_SECOND, '500 Mb/s'],
[512, UniversalYAxisUnit.GIGABITS_SECOND, '512 Gb/s'],
[1000, UniversalYAxisUnit.GIGABITS_SECOND, '1 Tb/s'],
[1023, UniversalYAxisUnit.GIGABITS_SECOND, '1.02 Tb/s'],
// Terabits/second
[0.5, UniversalYAxisUnit.TERABITS_SECOND, '500 Gb/s'],
[512, UniversalYAxisUnit.TERABITS_SECOND, '512 Tb/s'],
[1000, UniversalYAxisUnit.TERABITS_SECOND, '1 Pb/s'],
[1023, UniversalYAxisUnit.TERABITS_SECOND, '1.02 Pb/s'],
// Petabits/second
[0.5, UniversalYAxisUnit.PETABITS_SECOND, '500 Tb/s'],
[512, UniversalYAxisUnit.PETABITS_SECOND, '512 Pb/s'],
[1000, UniversalYAxisUnit.PETABITS_SECOND, '1 Eb/s'],
[1023, UniversalYAxisUnit.PETABITS_SECOND, '1.02 Eb/s'],
// Exabits/second
[512, UniversalYAxisUnit.EXABITS_SECOND, '512 Eb/s'],
[1000, UniversalYAxisUnit.EXABITS_SECOND, '1 Zb/s'],
[1023, UniversalYAxisUnit.EXABITS_SECOND, '1.02 Zb/s'],
// Zettabits/second
[0.5, UniversalYAxisUnit.ZETTABITS_SECOND, '500 Eb/s'],
[512, UniversalYAxisUnit.ZETTABITS_SECOND, '512 Zb/s'],
[1000, UniversalYAxisUnit.ZETTABITS_SECOND, '1 Yb/s'],
[1023, UniversalYAxisUnit.ZETTABITS_SECOND, '1.02 Yb/s'],
// Yottabits/second
[0.5, UniversalYAxisUnit.YOTTABITS_SECOND, '500 Zb/s'],
[512, UniversalYAxisUnit.YOTTABITS_SECOND, '512 Yb/s'],
[1000, UniversalYAxisUnit.YOTTABITS_SECOND, '1000 Yb/s'],
[1023, UniversalYAxisUnit.YOTTABITS_SECOND, '1023 Yb/s'],
])('formats bit rate value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Count', () => {
test.each([
[100, UniversalYAxisUnit.COUNT, '100'],
[875, UniversalYAxisUnit.COUNT, '875'],
[1000, UniversalYAxisUnit.COUNT, '1 K'],
[2500, UniversalYAxisUnit.COUNT, '2.5 K'],
[10000, UniversalYAxisUnit.COUNT, '10 K'],
[25000, UniversalYAxisUnit.COUNT, '25 K'],
[100000, UniversalYAxisUnit.COUNT, '100 K'],
[1000000, UniversalYAxisUnit.COUNT, '1 Mil'],
[10000000, UniversalYAxisUnit.COUNT, '10 Mil'],
[100000000, UniversalYAxisUnit.COUNT, '100 Mil'],
[1000000000, UniversalYAxisUnit.COUNT, '1 Bil'],
[10000000000, UniversalYAxisUnit.COUNT, '10 Bil'],
[100000000000, UniversalYAxisUnit.COUNT, '100 Bil'],
[1000000000000, UniversalYAxisUnit.COUNT, '1 Tri'],
[10000000000000, UniversalYAxisUnit.COUNT, '10 Tri'],
])('formats count value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
test.each([
[100, UniversalYAxisUnit.COUNT_SECOND, '100 c/s'],
[875, UniversalYAxisUnit.COUNT_SECOND, '875 c/s'],
[1000, UniversalYAxisUnit.COUNT_SECOND, '1K c/s'],
[2500, UniversalYAxisUnit.COUNT_SECOND, '2.5K c/s'],
[10000, UniversalYAxisUnit.COUNT_SECOND, '10K c/s'],
[25000, UniversalYAxisUnit.COUNT_SECOND, '25K c/s'],
])('formats count per time value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
test.each([
[100, UniversalYAxisUnit.COUNT_MINUTE, '100 c/m'],
[875, UniversalYAxisUnit.COUNT_MINUTE, '875 c/m'],
[1000, UniversalYAxisUnit.COUNT_MINUTE, '1K c/m'],
[2500, UniversalYAxisUnit.COUNT_MINUTE, '2.5K c/m'],
[10000, UniversalYAxisUnit.COUNT_MINUTE, '10K c/m'],
[25000, UniversalYAxisUnit.COUNT_MINUTE, '25K c/m'],
])('formats count per time value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Operations units', () => {
test.each([
[780, UniversalYAxisUnit.OPS_SECOND, '780 ops/s'],
[1000, UniversalYAxisUnit.OPS_SECOND, '1K ops/s'],
[520, UniversalYAxisUnit.OPS_MINUTE, '520 ops/m'],
[1000, UniversalYAxisUnit.OPS_MINUTE, '1K ops/m'],
[2500, UniversalYAxisUnit.OPS_MINUTE, '2.5K ops/m'],
[10000, UniversalYAxisUnit.OPS_MINUTE, '10K ops/m'],
[25000, UniversalYAxisUnit.OPS_MINUTE, '25K ops/m'],
])(
'formats operations per time value %s %s as %s',
(value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
},
);
});
describe('Request units', () => {
test.each([
[615, UniversalYAxisUnit.REQUESTS_SECOND, '615 req/s'],
[1000, UniversalYAxisUnit.REQUESTS_SECOND, '1K req/s'],
[480, UniversalYAxisUnit.REQUESTS_MINUTE, '480 req/m'],
[1000, UniversalYAxisUnit.REQUESTS_MINUTE, '1K req/m'],
[2500, UniversalYAxisUnit.REQUESTS_MINUTE, '2.5K req/m'],
[10000, UniversalYAxisUnit.REQUESTS_MINUTE, '10K req/m'],
[25000, UniversalYAxisUnit.REQUESTS_MINUTE, '25K req/m'],
])('formats requests per time value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Read/Write units', () => {
test.each([
[505, UniversalYAxisUnit.READS_SECOND, '505 rd/s'],
[1000, UniversalYAxisUnit.READS_SECOND, '1K rd/s'],
[610, UniversalYAxisUnit.WRITES_SECOND, '610 wr/s'],
[1000, UniversalYAxisUnit.WRITES_SECOND, '1K wr/s'],
[715, UniversalYAxisUnit.READS_MINUTE, '715 rd/m'],
[1000, UniversalYAxisUnit.READS_MINUTE, '1K rd/m'],
[2500, UniversalYAxisUnit.READS_MINUTE, '2.5K rd/m'],
[10000, UniversalYAxisUnit.READS_MINUTE, '10K rd/m'],
[25000, UniversalYAxisUnit.READS_MINUTE, '25K rd/m'],
[830, UniversalYAxisUnit.WRITES_MINUTE, '830 wr/m'],
[1000, UniversalYAxisUnit.WRITES_MINUTE, '1K wr/m'],
[2500, UniversalYAxisUnit.WRITES_MINUTE, '2.5K wr/m'],
[10000, UniversalYAxisUnit.WRITES_MINUTE, '10K wr/m'],
[25000, UniversalYAxisUnit.WRITES_MINUTE, '25K wr/m'],
])(
'formats reads and writes per time value %s %s as %s',
(value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
},
);
});
describe('IO Operations units', () => {
test.each([
[777, UniversalYAxisUnit.IOOPS_SECOND, '777 io/s'],
[1000, UniversalYAxisUnit.IOOPS_SECOND, '1K io/s'],
[2500, UniversalYAxisUnit.IOOPS_SECOND, '2.5K io/s'],
[10000, UniversalYAxisUnit.IOOPS_SECOND, '10K io/s'],
[25000, UniversalYAxisUnit.IOOPS_SECOND, '25K io/s'],
])('formats IOPS value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Percent units', () => {
it('formats percent as-is', () => {
expect(formatUniversalUnit(456, UniversalYAxisUnit.PERCENT)).toBe('456%');
});
it('multiplies percent_unit by 100', () => {
expect(formatUniversalUnit(9, UniversalYAxisUnit.PERCENT_UNIT)).toBe('900%');
});
});
describe('None unit', () => {
it('formats as plain number', () => {
expect(formatUniversalUnit(742, UniversalYAxisUnit.NONE)).toBe('742');
});
});
describe('Time (additional)', () => {
test.each([
[900, UniversalYAxisUnit.DURATION_MS, '900 milliseconds'],
[1000, UniversalYAxisUnit.DURATION_MS, '1 second'],
[1, UniversalYAxisUnit.DURATION_MS, '1 millisecond'],
[900, UniversalYAxisUnit.DURATION_S, '15 minutes'],
[1, UniversalYAxisUnit.DURATION_HMS, '00:00:01'],
[90005, UniversalYAxisUnit.DURATION_HMS, '25:00:05'],
[90005, UniversalYAxisUnit.DURATION_DHMS, '1 d 01:00:05'],
[900, UniversalYAxisUnit.TIMETICKS, '9 s'],
[1, UniversalYAxisUnit.TIMETICKS, '10 ms'],
[900, UniversalYAxisUnit.CLOCK_MS, '900ms'],
[1, UniversalYAxisUnit.CLOCK_MS, '001ms'],
[1, UniversalYAxisUnit.CLOCK_S, '01s:000ms'],
[900, UniversalYAxisUnit.CLOCK_S, '15m:00s:000ms'],
[900, UniversalYAxisUnit.TIME_HERTZ, '900 Hz'],
[1000, UniversalYAxisUnit.TIME_HERTZ, '1 kHz'],
[1000000, UniversalYAxisUnit.TIME_HERTZ, '1 MHz'],
[1000000000, UniversalYAxisUnit.TIME_HERTZ, '1 GHz'],
[1008, UniversalYAxisUnit.TIME_HERTZ, '1.01 kHz'],
])('formats duration value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Data (IEC/Binary)', () => {
test.each([
// Bytes
[900, UniversalYAxisUnit.BYTES_IEC, '900 B'],
[1024, UniversalYAxisUnit.BYTES_IEC, '1 KiB'],
[1080, UniversalYAxisUnit.BYTES_IEC, '1.05 KiB'],
// Kibibytes
[900, UniversalYAxisUnit.KIBIBYTES, '900 KiB'],
[1024, UniversalYAxisUnit.KIBIBYTES, '1 MiB'],
[1080, UniversalYAxisUnit.KIBIBYTES, '1.05 MiB'],
// Mebibytes
[900, UniversalYAxisUnit.MEBIBYTES, '900 MiB'],
[1024, UniversalYAxisUnit.MEBIBYTES, '1 GiB'],
[1080, UniversalYAxisUnit.MEBIBYTES, '1.05 GiB'],
// Gibibytes
[900, UniversalYAxisUnit.GIBIBYTES, '900 GiB'],
[1024, UniversalYAxisUnit.GIBIBYTES, '1 TiB'],
[1080, UniversalYAxisUnit.GIBIBYTES, '1.05 TiB'],
// Tebibytes
[900, UniversalYAxisUnit.TEBIBYTES, '900 TiB'],
[1024, UniversalYAxisUnit.TEBIBYTES, '1 PiB'],
[1080, UniversalYAxisUnit.TEBIBYTES, '1.05 PiB'],
// Pebibytes
[900, UniversalYAxisUnit.PEBIBYTES, '900 PiB'],
[1024, UniversalYAxisUnit.PEBIBYTES, '1 EiB'],
[1080, UniversalYAxisUnit.PEBIBYTES, '1.05 EiB'],
// Exbibytes
[900, UniversalYAxisUnit.EXBIBYTES, '900 EiB'],
[1024, UniversalYAxisUnit.EXBIBYTES, '1 ZiB'],
[1080, UniversalYAxisUnit.EXBIBYTES, '1.05 ZiB'],
// Zebibytes
[900, UniversalYAxisUnit.ZEBIBYTES, '900 ZiB'],
[1024, UniversalYAxisUnit.ZEBIBYTES, '1 YiB'],
[1080, UniversalYAxisUnit.ZEBIBYTES, '1.05 YiB'],
// Yobibytes
[900, UniversalYAxisUnit.YOBIBYTES, '900 YiB'],
[1024, UniversalYAxisUnit.YOBIBYTES, '1024 YiB'],
])('formats IEC bytes value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Data Rate (IEC/Binary)', () => {
test.each([
// Kibibytes/second
[900, UniversalYAxisUnit.KIBIBYTES_SECOND, '900 KiB/s'],
[1024, UniversalYAxisUnit.KIBIBYTES_SECOND, '1 MiB/s'],
[1080, UniversalYAxisUnit.KIBIBYTES_SECOND, '1.05 MiB/s'],
// Mebibytes/second
[900, UniversalYAxisUnit.MEBIBYTES_SECOND, '900 MiB/s'],
[1024, UniversalYAxisUnit.MEBIBYTES_SECOND, '1 GiB/s'],
[1080, UniversalYAxisUnit.MEBIBYTES_SECOND, '1.05 GiB/s'],
// Gibibytes/second
[900, UniversalYAxisUnit.GIBIBYTES_SECOND, '900 GiB/s'],
[1024, UniversalYAxisUnit.GIBIBYTES_SECOND, '1 TiB/s'],
[1080, UniversalYAxisUnit.GIBIBYTES_SECOND, '1.05 TiB/s'],
// Tebibytes/second
[900, UniversalYAxisUnit.TEBIBYTES_SECOND, '900 TiB/s'],
[1024, UniversalYAxisUnit.TEBIBYTES_SECOND, '1 PiB/s'],
[1080, UniversalYAxisUnit.TEBIBYTES_SECOND, '1.05 PiB/s'],
// Pebibytes/second
[900, UniversalYAxisUnit.PEBIBYTES_SECOND, '900 PiB/s'],
[1024, UniversalYAxisUnit.PEBIBYTES_SECOND, '1 EiB/s'],
[1080, UniversalYAxisUnit.PEBIBYTES_SECOND, '1.05 EiB/s'],
// Exbibytes/second
[900, UniversalYAxisUnit.EXBIBYTES_SECOND, '900 EiB/s'],
[1024, UniversalYAxisUnit.EXBIBYTES_SECOND, '1 ZiB/s'],
[1080, UniversalYAxisUnit.EXBIBYTES_SECOND, '1.05 ZiB/s'],
// Zebibytes/second
[900, UniversalYAxisUnit.ZEBIBYTES_SECOND, '900 ZiB/s'],
[1024, UniversalYAxisUnit.ZEBIBYTES_SECOND, '1 YiB/s'],
[1080, UniversalYAxisUnit.ZEBIBYTES_SECOND, '1.05 YiB/s'],
// Yobibytes/second
[900, UniversalYAxisUnit.YOBIBYTES_SECOND, '900 YiB/s'],
[1024, UniversalYAxisUnit.YOBIBYTES_SECOND, '1024 YiB/s'],
[1080, UniversalYAxisUnit.YOBIBYTES_SECOND, '1080 YiB/s'],
// Packets/second
[900, UniversalYAxisUnit.DATA_RATE_PACKETS_PER_SECOND, '900 p/s'],
[1000, UniversalYAxisUnit.DATA_RATE_PACKETS_PER_SECOND, '1 kp/s'],
[1080, UniversalYAxisUnit.DATA_RATE_PACKETS_PER_SECOND, '1.08 kp/s'],
])('formats IEC byte rates value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Bits (IEC)', () => {
test.each([
[900, UniversalYAxisUnit.BITS_IEC, '900 b'],
[1024, UniversalYAxisUnit.BITS_IEC, '1 Kib'],
[1080, UniversalYAxisUnit.BITS_IEC, '1.05 Kib'],
])('formats IEC bits value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Hash Rate', () => {
test.each([
// Hashes/second
[412, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '412 H/s'],
[1000, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '1 kH/s'],
[1023, UniversalYAxisUnit.HASH_RATE_HASHES_PER_SECOND, '1.02 kH/s'],
// Kilohashes/second
[412, UniversalYAxisUnit.HASH_RATE_KILOHASHES_PER_SECOND, '412 kH/s'],
[1000, UniversalYAxisUnit.HASH_RATE_KILOHASHES_PER_SECOND, '1 MH/s'],
[1023, UniversalYAxisUnit.HASH_RATE_KILOHASHES_PER_SECOND, '1.02 MH/s'],
// Megahashes/second
[412, UniversalYAxisUnit.HASH_RATE_MEGAHASHES_PER_SECOND, '412 MH/s'],
[1000, UniversalYAxisUnit.HASH_RATE_MEGAHASHES_PER_SECOND, '1 GH/s'],
[1023, UniversalYAxisUnit.HASH_RATE_MEGAHASHES_PER_SECOND, '1.02 GH/s'],
// Gigahashes/second
[412, UniversalYAxisUnit.HASH_RATE_GIGAHASHES_PER_SECOND, '412 GH/s'],
[1000, UniversalYAxisUnit.HASH_RATE_GIGAHASHES_PER_SECOND, '1 TH/s'],
[1023, UniversalYAxisUnit.HASH_RATE_GIGAHASHES_PER_SECOND, '1.02 TH/s'],
// Terahashes/second
[412, UniversalYAxisUnit.HASH_RATE_TERAHASHES_PER_SECOND, '412 TH/s'],
[1000, UniversalYAxisUnit.HASH_RATE_TERAHASHES_PER_SECOND, '1 PH/s'],
[1023, UniversalYAxisUnit.HASH_RATE_TERAHASHES_PER_SECOND, '1.02 PH/s'],
// Petahashes/second
[412, UniversalYAxisUnit.HASH_RATE_PETAHASHES_PER_SECOND, '412 PH/s'],
[1000, UniversalYAxisUnit.HASH_RATE_PETAHASHES_PER_SECOND, '1 EH/s'],
[1023, UniversalYAxisUnit.HASH_RATE_PETAHASHES_PER_SECOND, '1.02 EH/s'],
// Exahashes/second
[412, UniversalYAxisUnit.HASH_RATE_EXAHASHES_PER_SECOND, '412 EH/s'],
[1000, UniversalYAxisUnit.HASH_RATE_EXAHASHES_PER_SECOND, '1 ZH/s'],
[1023, UniversalYAxisUnit.HASH_RATE_EXAHASHES_PER_SECOND, '1.02 ZH/s'],
])('formats hash rate value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Miscellaneous', () => {
test.each([
[742, UniversalYAxisUnit.MISC_STRING, '742'],
[688, UniversalYAxisUnit.MISC_SHORT, '688'],
[555, UniversalYAxisUnit.MISC_HUMIDITY, '555 %H'],
[812, UniversalYAxisUnit.MISC_DECIBEL, '812 dB'],
[1024, UniversalYAxisUnit.MISC_HEXADECIMAL, '400'],
[1024, UniversalYAxisUnit.MISC_HEXADECIMAL_0X, '0x400'],
[900, UniversalYAxisUnit.MISC_SCIENTIFIC_NOTATION, '9e+2'],
[678, UniversalYAxisUnit.MISC_LOCALE_FORMAT, '678'],
[444, UniversalYAxisUnit.MISC_PIXELS, '444 px'],
])('formats miscellaneous value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Acceleration', () => {
test.each([
[
875,
UniversalYAxisUnit.ACCELERATION_METERS_PER_SECOND_SQUARED,
'875 m/sec²',
],
[640, UniversalYAxisUnit.ACCELERATION_FEET_PER_SECOND_SQUARED, '640 f/sec²'],
[512, UniversalYAxisUnit.ACCELERATION_G_UNIT, '512 g'],
[
2500,
UniversalYAxisUnit.ACCELERATION_METERS_PER_SECOND_SQUARED,
'2500 m/sec²',
],
])('formats acceleration value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Angular', () => {
test.each([
[415, UniversalYAxisUnit.ANGULAR_DEGREE, '415 °'],
[732, UniversalYAxisUnit.ANGULAR_RADIAN, '732 rad'],
[128, UniversalYAxisUnit.ANGULAR_GRADIAN, '128 grad'],
[560, UniversalYAxisUnit.ANGULAR_ARC_MINUTE, '560 arcmin'],
[945, UniversalYAxisUnit.ANGULAR_ARC_SECOND, '945 arcsec'],
])('formats angular value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Area', () => {
test.each([
[210, UniversalYAxisUnit.AREA_SQUARE_METERS, '210 m²'],
[152, UniversalYAxisUnit.AREA_SQUARE_FEET, '152 ft²'],
[64, UniversalYAxisUnit.AREA_SQUARE_MILES, '64 mi²'],
])('formats area value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('FLOPs', () => {
test.each([
// FLOPS
[150, UniversalYAxisUnit.FLOPS_FLOPS, '150 FLOPS'],
[1000, UniversalYAxisUnit.FLOPS_FLOPS, '1 kFLOPS'],
[1080, UniversalYAxisUnit.FLOPS_FLOPS, '1.08 kFLOPS'],
// MFLOPS
[275, UniversalYAxisUnit.FLOPS_MFLOPS, '275 MFLOPS'],
[1000, UniversalYAxisUnit.FLOPS_MFLOPS, '1 GFLOPS'],
[1080, UniversalYAxisUnit.FLOPS_MFLOPS, '1.08 GFLOPS'],
// GFLOPS
[640, UniversalYAxisUnit.FLOPS_GFLOPS, '640 GFLOPS'],
[1000, UniversalYAxisUnit.FLOPS_GFLOPS, '1 TFLOPS'],
[1080, UniversalYAxisUnit.FLOPS_GFLOPS, '1.08 TFLOPS'],
// TFLOPS
[875, UniversalYAxisUnit.FLOPS_TFLOPS, '875 TFLOPS'],
[1000, UniversalYAxisUnit.FLOPS_TFLOPS, '1 PFLOPS'],
[1080, UniversalYAxisUnit.FLOPS_TFLOPS, '1.08 PFLOPS'],
// PFLOPS
[430, UniversalYAxisUnit.FLOPS_PFLOPS, '430 PFLOPS'],
[1000, UniversalYAxisUnit.FLOPS_PFLOPS, '1 EFLOPS'],
[1080, UniversalYAxisUnit.FLOPS_PFLOPS, '1.08 EFLOPS'],
// EFLOPS
[590, UniversalYAxisUnit.FLOPS_EFLOPS, '590 EFLOPS'],
[1000, UniversalYAxisUnit.FLOPS_EFLOPS, '1 ZFLOPS'],
[1080, UniversalYAxisUnit.FLOPS_EFLOPS, '1.08 ZFLOPS'],
// ZFLOPS
[715, UniversalYAxisUnit.FLOPS_ZFLOPS, '715 ZFLOPS'],
[1000, UniversalYAxisUnit.FLOPS_ZFLOPS, '1 YFLOPS'],
[1080, UniversalYAxisUnit.FLOPS_ZFLOPS, '1.08 YFLOPS'],
// YFLOPS
[840, UniversalYAxisUnit.FLOPS_YFLOPS, '840 YFLOPS'],
[1000, UniversalYAxisUnit.FLOPS_YFLOPS, '1000 YFLOPS'],
])('formats FLOPs value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Concentration', () => {
test.each([
[415, UniversalYAxisUnit.CONCENTRATION_PPM, '415 ppm'],
[1000, UniversalYAxisUnit.CONCENTRATION_PPM, '1000 ppm'],
[732, UniversalYAxisUnit.CONCENTRATION_PPB, '732 ppb'],
[1000, UniversalYAxisUnit.CONCENTRATION_PPB, '1000 ppb'],
[128, UniversalYAxisUnit.CONCENTRATION_NG_M3, '128 ng/m³'],
[1000, UniversalYAxisUnit.CONCENTRATION_NG_M3, '1000 ng/m³'],
[560, UniversalYAxisUnit.CONCENTRATION_NG_NORMAL_CUBIC_METER, '560 ng/Nm³'],
[
1000,
UniversalYAxisUnit.CONCENTRATION_NG_NORMAL_CUBIC_METER,
'1000 ng/Nm³',
],
[945, UniversalYAxisUnit.CONCENTRATION_UG_M3, '945 μg/m³'],
[1000, UniversalYAxisUnit.CONCENTRATION_UG_M3, '1000 μg/m³'],
[210, UniversalYAxisUnit.CONCENTRATION_UG_NORMAL_CUBIC_METER, '210 μg/Nm³'],
[
1000,
UniversalYAxisUnit.CONCENTRATION_UG_NORMAL_CUBIC_METER,
'1000 μg/Nm³',
],
[152, UniversalYAxisUnit.CONCENTRATION_MG_M3, '152 mg/m³'],
[64, UniversalYAxisUnit.CONCENTRATION_MG_NORMAL_CUBIC_METER, '64 mg/Nm³'],
[508, UniversalYAxisUnit.CONCENTRATION_G_M3, '508 g/m³'],
[1000, UniversalYAxisUnit.CONCENTRATION_G_M3, '1000 g/m³'],
[377, UniversalYAxisUnit.CONCENTRATION_G_NORMAL_CUBIC_METER, '377 g/Nm³'],
[1000, UniversalYAxisUnit.CONCENTRATION_G_NORMAL_CUBIC_METER, '1000 g/Nm³'],
[286, UniversalYAxisUnit.CONCENTRATION_MG_PER_DL, '286 mg/dL'],
[1000, UniversalYAxisUnit.CONCENTRATION_MG_PER_DL, '1000 mg/dL'],
[675, UniversalYAxisUnit.CONCENTRATION_MMOL_PER_L, '675 mmol/L'],
[1000, UniversalYAxisUnit.CONCENTRATION_MMOL_PER_L, '1000 mmol/L'],
])('formats concentration value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Currency', () => {
test.each([
[812, UniversalYAxisUnit.CURRENCY_USD, '$812'],
[645, UniversalYAxisUnit.CURRENCY_GBP, '£645'],
[731, UniversalYAxisUnit.CURRENCY_EUR, '€731'],
[508, UniversalYAxisUnit.CURRENCY_JPY, '¥508'],
[963, UniversalYAxisUnit.CURRENCY_RUB, '₽963'],
[447, UniversalYAxisUnit.CURRENCY_UAH, '₴447'],
[592, UniversalYAxisUnit.CURRENCY_BRL, 'R$592'],
[375, UniversalYAxisUnit.CURRENCY_DKK, '375kr'],
[418, UniversalYAxisUnit.CURRENCY_ISK, '418kr'],
[536, UniversalYAxisUnit.CURRENCY_NOK, '536kr'],
[689, UniversalYAxisUnit.CURRENCY_SEK, '689kr'],
[724, UniversalYAxisUnit.CURRENCY_CZK, 'czk724'],
[381, UniversalYAxisUnit.CURRENCY_CHF, 'CHF381'],
[267, UniversalYAxisUnit.CURRENCY_PLN, 'PLN267'],
[154, UniversalYAxisUnit.CURRENCY_BTC, '฿154'],
[999, UniversalYAxisUnit.CURRENCY_MBTC, 'mBTC999'],
[423, UniversalYAxisUnit.CURRENCY_UBTC, 'μBTC423'],
[611, UniversalYAxisUnit.CURRENCY_ZAR, 'R611'],
[782, UniversalYAxisUnit.CURRENCY_INR, '₹782'],
[834, UniversalYAxisUnit.CURRENCY_KRW, '₩834'],
[455, UniversalYAxisUnit.CURRENCY_IDR, 'Rp455'],
[978, UniversalYAxisUnit.CURRENCY_PHP, 'PHP978'],
[366, UniversalYAxisUnit.CURRENCY_VND, '366đ'],
])('formats currency value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Datetime', () => {
it('formats datetime units', () => {
expect(formatUniversalUnit(900, UniversalYAxisUnit.DATETIME_FROM_NOW)).toBe(
'56 years ago',
);
});
});
describe('Power/Electrical', () => {
test.each([
[715, UniversalYAxisUnit.POWER_WATT, '715 W'],
[1000, UniversalYAxisUnit.POWER_WATT, '1 kW'],
[1080, UniversalYAxisUnit.POWER_WATT, '1.08 kW'],
[438, UniversalYAxisUnit.POWER_KILOWATT, '438 kW'],
[1000, UniversalYAxisUnit.POWER_KILOWATT, '1 MW'],
[1080, UniversalYAxisUnit.POWER_KILOWATT, '1.08 MW'],
[582, UniversalYAxisUnit.POWER_MEGAWATT, '582 MW'],
[1000, UniversalYAxisUnit.POWER_MEGAWATT, '1 GW'],
[1080, UniversalYAxisUnit.POWER_MEGAWATT, '1.08 GW'],
[267, UniversalYAxisUnit.POWER_GIGAWATT, '267 GW'],
[853, UniversalYAxisUnit.POWER_MILLIWATT, '853 mW'],
[693, UniversalYAxisUnit.POWER_WATT_PER_SQUARE_METER, '693 W/m²'],
[544, UniversalYAxisUnit.POWER_VOLT_AMPERE, '544 VA'],
[812, UniversalYAxisUnit.POWER_KILOVOLT_AMPERE, '812 kVA'],
[478, UniversalYAxisUnit.POWER_VOLT_AMPERE_REACTIVE, '478 VAr'],
[365, UniversalYAxisUnit.POWER_KILOVOLT_AMPERE_REACTIVE, '365 kVAr'],
[629, UniversalYAxisUnit.POWER_WATT_HOUR, '629 Wh'],
[471, UniversalYAxisUnit.POWER_WATT_HOUR_PER_KG, '471 Wh/kg'],
[557, UniversalYAxisUnit.POWER_KILOWATT_HOUR, '557 kWh'],
[389, UniversalYAxisUnit.POWER_KILOWATT_MINUTE, '389 kW-Min'],
[642, UniversalYAxisUnit.POWER_AMPERE_HOUR, '642 Ah'],
[731, UniversalYAxisUnit.POWER_KILOAMPERE_HOUR, '731 kAh'],
[815, UniversalYAxisUnit.POWER_MILLIAMPERE_HOUR, '815 mAh'],
[963, UniversalYAxisUnit.POWER_JOULE, '963 J'],
[506, UniversalYAxisUnit.POWER_ELECTRON_VOLT, '506 eV'],
[298, UniversalYAxisUnit.POWER_AMPERE, '298 A'],
[654, UniversalYAxisUnit.POWER_KILOAMPERE, '654 kA'],
[187, UniversalYAxisUnit.POWER_MILLIAMPERE, '187 mA'],
[472, UniversalYAxisUnit.POWER_VOLT, '472 V'],
[538, UniversalYAxisUnit.POWER_KILOVOLT, '538 kV'],
[226, UniversalYAxisUnit.POWER_MILLIVOLT, '226 mV'],
[592, UniversalYAxisUnit.POWER_DECIBEL_MILLIWATT, '592 dBm'],
[333, UniversalYAxisUnit.POWER_OHM, '333 Ω'],
[447, UniversalYAxisUnit.POWER_KILOOHM, '447 kΩ'],
[781, UniversalYAxisUnit.POWER_MEGAOHM, '781 MΩ'],
[650, UniversalYAxisUnit.POWER_FARAD, '650 F'],
[512, UniversalYAxisUnit.POWER_MICROFARAD, '512 µF'],
[478, UniversalYAxisUnit.POWER_NANOFARAD, '478 nF'],
[341, UniversalYAxisUnit.POWER_PICOFARAD, '341 pF'],
[129, UniversalYAxisUnit.POWER_FEMTOFARAD, '129 fF'],
[904, UniversalYAxisUnit.POWER_HENRY, '904 H'],
[1000, UniversalYAxisUnit.POWER_HENRY, '1 kH'],
[275, UniversalYAxisUnit.POWER_MILLIHENRY, '275 mH'],
[618, UniversalYAxisUnit.POWER_MICROHENRY, '618 µH'],
[1000, UniversalYAxisUnit.POWER_MICROHENRY, '1 mH'],
[1080, UniversalYAxisUnit.POWER_MICROHENRY, '1.08 mH'],
[459, UniversalYAxisUnit.POWER_LUMENS, '459 Lm'],
[1000, UniversalYAxisUnit.POWER_LUMENS, '1 kLm'],
[1080, UniversalYAxisUnit.POWER_LUMENS, '1.08 kLm'],
])('formats power value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Flow', () => {
test.each([
[512, UniversalYAxisUnit.FLOW_GALLONS_PER_MINUTE, '512 gpm'],
[1000, UniversalYAxisUnit.FLOW_GALLONS_PER_MINUTE, '1000 gpm'],
[678, UniversalYAxisUnit.FLOW_CUBIC_METERS_PER_SECOND, '678 cms'],
[1000, UniversalYAxisUnit.FLOW_CUBIC_METERS_PER_SECOND, '1000 cms'],
[245, UniversalYAxisUnit.FLOW_CUBIC_FEET_PER_SECOND, '245 cfs'],
[389, UniversalYAxisUnit.FLOW_CUBIC_FEET_PER_MINUTE, '389 cfm'],
[1000, UniversalYAxisUnit.FLOW_CUBIC_FEET_PER_MINUTE, '1000 cfm'],
[731, UniversalYAxisUnit.FLOW_LITERS_PER_HOUR, '731 L/h'],
[1000, UniversalYAxisUnit.FLOW_LITERS_PER_HOUR, '1000 L/h'],
[864, UniversalYAxisUnit.FLOW_LITERS_PER_MINUTE, '864 L/min'],
[1000, UniversalYAxisUnit.FLOW_LITERS_PER_MINUTE, '1000 L/min'],
[150, UniversalYAxisUnit.FLOW_MILLILITERS_PER_MINUTE, '150 mL/min'],
[1000, UniversalYAxisUnit.FLOW_MILLILITERS_PER_MINUTE, '1000 mL/min'],
[947, UniversalYAxisUnit.FLOW_LUX, '947 lux'],
[1000, UniversalYAxisUnit.FLOW_LUX, '1000 lux'],
])('formats flow value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Force', () => {
test.each([
[845, UniversalYAxisUnit.FORCE_NEWTON_METERS, '845 Nm'],
[1000, UniversalYAxisUnit.FORCE_NEWTON_METERS, '1 kNm'],
[1080, UniversalYAxisUnit.FORCE_NEWTON_METERS, '1.08 kNm'],
[268, UniversalYAxisUnit.FORCE_KILONEWTON_METERS, '268 kNm'],
[1000, UniversalYAxisUnit.FORCE_KILONEWTON_METERS, '1 MNm'],
[1080, UniversalYAxisUnit.FORCE_KILONEWTON_METERS, '1.08 MNm'],
[593, UniversalYAxisUnit.FORCE_NEWTONS, '593 N'],
[1000, UniversalYAxisUnit.FORCE_KILONEWTONS, '1 MN'],
[1080, UniversalYAxisUnit.FORCE_KILONEWTONS, '1.08 MN'],
])('formats force value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Mass', () => {
test.each([
[120, UniversalYAxisUnit.MASS_MILLIGRAM, '120 mg'],
[120000, UniversalYAxisUnit.MASS_MILLIGRAM, '120 g'],
[987, UniversalYAxisUnit.MASS_GRAM, '987 g'],
[1020, UniversalYAxisUnit.MASS_GRAM, '1.02 kg'],
[456, UniversalYAxisUnit.MASS_POUND, '456 lb'],
[321, UniversalYAxisUnit.MASS_KILOGRAM, '321 kg'],
[654, UniversalYAxisUnit.MASS_METRIC_TON, '654 t'],
])('formats mass value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Length', () => {
test.each([
[88, UniversalYAxisUnit.LENGTH_MILLIMETER, '88 mm'],
[100, UniversalYAxisUnit.LENGTH_MILLIMETER, '100 mm'],
[1000, UniversalYAxisUnit.LENGTH_MILLIMETER, '1 m'],
[177, UniversalYAxisUnit.LENGTH_INCH, '177 in'],
[266, UniversalYAxisUnit.LENGTH_FOOT, '266 ft'],
[355, UniversalYAxisUnit.LENGTH_METER, '355 m'],
[355000, UniversalYAxisUnit.LENGTH_METER, '355 km'],
[444, UniversalYAxisUnit.LENGTH_KILOMETER, '444 km'],
[533, UniversalYAxisUnit.LENGTH_MILE, '533 mi'],
])('formats length value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Pressure', () => {
test.each([
[45, UniversalYAxisUnit.PRESSURE_MILLIBAR, '45 mbar'],
[1013, UniversalYAxisUnit.PRESSURE_MILLIBAR, '1.01 bar'],
[27, UniversalYAxisUnit.PRESSURE_BAR, '27 bar'],
[62, UniversalYAxisUnit.PRESSURE_KILOBAR, '62 kbar'],
[845, UniversalYAxisUnit.PRESSURE_PASCAL, '845 Pa'],
[540, UniversalYAxisUnit.PRESSURE_HECTOPASCAL, '540 hPa'],
[378, UniversalYAxisUnit.PRESSURE_KILOPASCAL, '378 kPa'],
[29, UniversalYAxisUnit.PRESSURE_INCHES_HG, '29 "Hg'],
[65, UniversalYAxisUnit.PRESSURE_PSI, '65psi'],
])('formats pressure value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Radiation', () => {
test.each([
[452, UniversalYAxisUnit.RADIATION_BECQUEREL, '452 Bq'],
[37, UniversalYAxisUnit.RADIATION_CURIE, '37 Ci'],
[128, UniversalYAxisUnit.RADIATION_GRAY, '128 Gy'],
[512, UniversalYAxisUnit.RADIATION_RAD, '512 rad'],
[256, UniversalYAxisUnit.RADIATION_SIEVERT, '256 Sv'],
[640, UniversalYAxisUnit.RADIATION_MILLISIEVERT, '640 mSv'],
[875, UniversalYAxisUnit.RADIATION_MICROSIEVERT, '875 µSv'],
[875000, UniversalYAxisUnit.RADIATION_MICROSIEVERT, '875 mSv'],
[92, UniversalYAxisUnit.RADIATION_REM, '92 rem'],
[715, UniversalYAxisUnit.RADIATION_EXPOSURE_C_PER_KG, '715 C/kg'],
[833, UniversalYAxisUnit.RADIATION_ROENTGEN, '833 R'],
[468, UniversalYAxisUnit.RADIATION_SIEVERT_PER_HOUR, '468 Sv/h'],
[590, UniversalYAxisUnit.RADIATION_MILLISIEVERT_PER_HOUR, '590 mSv/h'],
[712, UniversalYAxisUnit.RADIATION_MICROSIEVERT_PER_HOUR, '712 µSv/h'],
])('formats radiation value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Rotation Speed', () => {
test.each([
[345, UniversalYAxisUnit.ROTATION_SPEED_REVOLUTIONS_PER_MINUTE, '345 rpm'],
[789, UniversalYAxisUnit.ROTATION_SPEED_HERTZ, '789 Hz'],
[789000, UniversalYAxisUnit.ROTATION_SPEED_HERTZ, '789 kHz'],
[213, UniversalYAxisUnit.ROTATION_SPEED_RADIANS_PER_SECOND, '213 rad/s'],
[654, UniversalYAxisUnit.ROTATION_SPEED_DEGREES_PER_SECOND, '654 °/s'],
])('formats rotation speed value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Temperature', () => {
test.each([
[37, UniversalYAxisUnit.TEMPERATURE_CELSIUS, '37 °C'],
[451, UniversalYAxisUnit.TEMPERATURE_FAHRENHEIT, '451 °F'],
[310, UniversalYAxisUnit.TEMPERATURE_KELVIN, '310 K'],
])('formats temperature value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Velocity', () => {
test.each([
[900, UniversalYAxisUnit.VELOCITY_METERS_PER_SECOND, '900 m/s'],
[456, UniversalYAxisUnit.VELOCITY_KILOMETERS_PER_HOUR, '456 km/h'],
[789, UniversalYAxisUnit.VELOCITY_MILES_PER_HOUR, '789 mph'],
[222, UniversalYAxisUnit.VELOCITY_KNOT, '222 kn'],
])('formats velocity value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Volume', () => {
test.each([
[1200, UniversalYAxisUnit.VOLUME_MILLILITER, '1.2 L'],
[9000000, UniversalYAxisUnit.VOLUME_MILLILITER, '9 kL'],
[9, UniversalYAxisUnit.VOLUME_LITER, '9 L'],
[9000, UniversalYAxisUnit.VOLUME_LITER, '9 kL'],
[9000000, UniversalYAxisUnit.VOLUME_LITER, '9 ML'],
[9000000000, UniversalYAxisUnit.VOLUME_LITER, '9 GL'],
[9000000000000, UniversalYAxisUnit.VOLUME_LITER, '9 TL'],
[9000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9 PL'],
[9010000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9.01 EL'],
[9020000000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9.02 ZL'],
[9030000000000000000000000, UniversalYAxisUnit.VOLUME_LITER, '9.03 YL'],
[900, UniversalYAxisUnit.VOLUME_CUBIC_METER, '900 m³'],
[
9000000000000000000000000000000,
UniversalYAxisUnit.VOLUME_CUBIC_METER,
'9e+30 m³',
],
[900, UniversalYAxisUnit.VOLUME_NORMAL_CUBIC_METER, '900 Nm³'],
[
9000000000000000000000000000000,
UniversalYAxisUnit.VOLUME_NORMAL_CUBIC_METER,
'9e+30 Nm³',
],
[900, UniversalYAxisUnit.VOLUME_CUBIC_DECIMETER, '900 dm³'],
[
9000000000000000000000000000000,
UniversalYAxisUnit.VOLUME_CUBIC_DECIMETER,
'9e+30 dm³',
],
[900, UniversalYAxisUnit.VOLUME_GALLON, '900 gal'],
[
9000000000000000000000000000000,
UniversalYAxisUnit.VOLUME_GALLON,
'9e+30 gal',
],
])('formats volume value %s %s as %s', (value, unit, expected) => {
expect(formatUniversalUnit(value, unit)).toBe(expected);
});
});
describe('Boolean', () => {
it('formats boolean units', () => {
expect(formatUniversalUnit(1, UniversalYAxisUnit.TRUE_FALSE)).toBe('True');
expect(formatUniversalUnit(1, UniversalYAxisUnit.YES_NO)).toBe('Yes');
expect(formatUniversalUnit(1, UniversalYAxisUnit.ON_OFF)).toBe('On');
});
});
});
describe('Mapping Validator', () => {
it('validates that all units have a mapping', () => {
// Each universal unit should have a mapping to a 1:1 Grafana unit in UniversalUnitToGrafanaUnit or an additional mapping in AdditionalLabelsMappingForGrafanaUnits
const units = Object.values(UniversalYAxisUnit);
expect(
units.every((unit) => {
const hasBaseMapping = unit in UniversalUnitToGrafanaUnit;
const hasAdditionalMapping = unit in AdditionalLabelsMappingForGrafanaUnits;
const hasMapping = hasBaseMapping || hasAdditionalMapping;
if (!hasMapping) {
throw new Error(`Unit ${unit} does not have a mapping`);
}
return hasMapping;
}),
).toBe(true);
});
});

View File

@@ -1,6 +1,8 @@
import { UniversalYAxisUnit } from '../types';
import {
getUniversalNameFromMetricUnit,
mapMetricUnitToUniversalUnit,
mergeCategories,
} from '../utils';
describe('YAxisUnitSelector utils', () => {
@@ -36,4 +38,43 @@ describe('YAxisUnitSelector utils', () => {
expect(getUniversalNameFromMetricUnit('s')).toBe('Seconds (s)');
});
});
describe('mergeCategories', () => {
it('merges categories correctly', () => {
const categories1 = [
{
name: 'Data',
units: [
{ name: 'bytes', id: UniversalYAxisUnit.BYTES },
{ name: 'kilobytes', id: UniversalYAxisUnit.KILOBYTES },
],
},
];
const categories2 = [
{
name: 'Data',
units: [{ name: 'bits', id: UniversalYAxisUnit.BITS }],
},
{
name: 'Time',
units: [{ name: 'seconds', id: UniversalYAxisUnit.SECONDS }],
},
];
const mergedCategories = mergeCategories(categories1, categories2);
expect(mergedCategories).toEqual([
{
name: 'Data',
units: [
{ name: 'bytes', id: UniversalYAxisUnit.BYTES },
{ name: 'kilobytes', id: UniversalYAxisUnit.KILOBYTES },
{ name: 'bits', id: UniversalYAxisUnit.BITS },
],
},
{
name: 'Time',
units: [{ name: 'seconds', id: UniversalYAxisUnit.SECONDS }],
},
]);
});
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,90 @@
import { formattedValueToString, getValueFormat } from '@grafana/data';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import { formatDecimalWithLeadingZeros } from 'components/Graph/utils';
import {
AdditionalLabelsMappingForGrafanaUnits,
CUSTOM_SCALING_FAMILIES,
UniversalUnitToGrafanaUnit,
} from 'components/YAxisUnitSelector/constants';
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
function scaleValue(
value: number,
unit: UniversalYAxisUnit,
family: UniversalYAxisUnit[],
factor: number,
): { value: number; label: string } {
let idx = family.indexOf(unit);
// If the unit is not in the family, return the unit with the additional label
if (idx === -1) {
return { value, label: AdditionalLabelsMappingForGrafanaUnits[unit] || '' };
}
// Scale the value up or down to the nearest unit in the family
let scaled = value;
// Scale up
while (scaled >= factor && idx < family.length - 1) {
scaled /= factor;
idx += 1;
}
// Scale down
while (scaled < 1 && idx > 0) {
scaled *= factor;
idx -= 1;
}
// Return the scaled value and the label of the nearest unit in the family
return {
value: scaled,
label: AdditionalLabelsMappingForGrafanaUnits[family[idx]] || '',
};
}
export function formatUniversalUnit(
value: number,
unit: UniversalYAxisUnit,
precision: PrecisionOption = PrecisionOptionsEnum.FULL,
decimals: number | undefined = undefined,
): string {
// Check if this unit belongs to a family that needs custom scaling
const family = CUSTOM_SCALING_FAMILIES.find((family) =>
family.units.includes(unit),
);
if (family) {
const scaled = scaleValue(value, unit, family.units, family.scaleFactor);
const formatter = getValueFormat(scaled.label);
const formatted = formatter(scaled.value, decimals);
if (formatted.text && formatted.text.includes('.')) {
formatted.text = formatDecimalWithLeadingZeros(
parseFloat(formatted.text),
precision,
);
}
return `${formatted.text} ${scaled.label}`;
}
// Use Grafana formatting with custom label mappings
const grafanaFormat = UniversalUnitToGrafanaUnit[unit];
if (grafanaFormat) {
const formatter = getValueFormat(grafanaFormat);
const formatted = formatter(value, decimals);
if (formatted.text && formatted.text.includes('.')) {
formatted.text = formatDecimalWithLeadingZeros(
parseFloat(formatted.text),
precision,
);
}
return formattedValueToString(formatted);
}
// Fallback to short format for other units
const formatter = getValueFormat('short');
const formatted = formatter(value, decimals);
if (formatted.text && formatted.text.includes('.')) {
formatted.text = formatDecimalWithLeadingZeros(
parseFloat(formatted.text),
precision,
);
}
return `${formatted.text} ${unit}`;
}

View File

@@ -5,11 +5,11 @@ export interface YAxisUnitSelectorProps {
loading?: boolean;
disabled?: boolean;
'data-testid'?: string;
source: YAxisSource;
}
export enum UniversalYAxisUnit {
// Time
WEEKS = 'wk',
DAYS = 'd',
HOURS = 'h',
MINUTES = 'min',
@@ -17,6 +17,14 @@ export enum UniversalYAxisUnit {
MICROSECONDS = 'us',
MILLISECONDS = 'ms',
NANOSECONDS = 'ns',
DURATION_MS = 'dtdurationms',
DURATION_S = 'dtdurations',
DURATION_HMS = 'dthms',
DURATION_DHMS = 'dtdhms',
TIMETICKS = 'timeticks',
CLOCK_MS = 'clockms',
CLOCK_S = 'clocks',
TIME_HERTZ = 'hertz',
// Data
BYTES = 'By',
@@ -29,6 +37,17 @@ export enum UniversalYAxisUnit {
ZETTABYTES = 'ZBy',
YOTTABYTES = 'YBy',
// Binary (IEC) Data
BYTES_IEC = 'bytes',
KIBIBYTES = 'KiBy',
MEBIBYTES = 'MiBy',
GIBIBYTES = 'GiBy',
TEBIBYTES = 'TiBy',
PEBIBYTES = 'PiBy',
EXBIBYTES = 'EiBy',
ZEBIBYTES = 'ZiBy',
YOBIBYTES = 'YiBy',
// Data Rate
BYTES_SECOND = 'By/s',
KILOBYTES_SECOND = 'kBy/s',
@@ -39,9 +58,21 @@ export enum UniversalYAxisUnit {
EXABYTES_SECOND = 'EBy/s',
ZETTABYTES_SECOND = 'ZBy/s',
YOTTABYTES_SECOND = 'YBy/s',
DATA_RATE_PACKETS_PER_SECOND = 'pps',
// Binary (IEC) Data Rate
KIBIBYTES_SECOND = 'KiBy/s',
MEBIBYTES_SECOND = 'MiBy/s',
GIBIBYTES_SECOND = 'GiBy/s',
TEBIBYTES_SECOND = 'TiBy/s',
PEBIBYTES_SECOND = 'PiBy/s',
EXBIBYTES_SECOND = 'EiBy/s',
ZEBIBYTES_SECOND = 'ZiBy/s',
YOBIBYTES_SECOND = 'YiBy/s',
// Bits
BITS = 'bit',
BITS_IEC = 'bits',
KILOBITS = 'kbit',
MEGABITS = 'Mbit',
GIGABITS = 'Gbit',
@@ -62,6 +93,16 @@ export enum UniversalYAxisUnit {
ZETTABITS_SECOND = 'Zbit/s',
YOTTABITS_SECOND = 'Ybit/s',
// Binary (IEC) Bit Rate
KIBIBITS_SECOND = 'Kibit/s',
MEBIBITS_SECOND = 'Mibit/s',
GIBIBITS_SECOND = 'Gibit/s',
TEBIBITS_SECOND = 'Tibit/s',
PEBIBITS_SECOND = 'Pibit/s',
EXBIBITS_SECOND = 'Eibit/s',
ZEBIBITS_SECOND = 'Zibit/s',
YOBIBITS_SECOND = 'Yibit/s',
// Count
COUNT = '{count}',
COUNT_SECOND = '{count}/s',
@@ -87,7 +128,231 @@ export enum UniversalYAxisUnit {
// Percent
PERCENT = '%',
PERCENT_UNIT = 'percentunit',
// Boolean
TRUE_FALSE = '{bool}',
YES_NO = '{bool_yn}',
ON_OFF = 'bool_on_off',
// None
NONE = '1',
// Hash rate
HASH_RATE_HASHES_PER_SECOND = 'Hs',
HASH_RATE_KILOHASHES_PER_SECOND = 'KHs',
HASH_RATE_MEGAHASHES_PER_SECOND = 'MHs',
HASH_RATE_GIGAHASHES_PER_SECOND = 'GHs',
HASH_RATE_TERAHASHES_PER_SECOND = 'THs',
HASH_RATE_PETAHASHES_PER_SECOND = 'PHs',
HASH_RATE_EXAHASHES_PER_SECOND = 'EHs',
// Miscellaneous
MISC_STRING = 'string',
MISC_SHORT = 'short',
MISC_HUMIDITY = 'humidity',
MISC_DECIBEL = 'dB',
MISC_HEXADECIMAL = 'hex',
MISC_HEXADECIMAL_0X = 'hex0x',
MISC_SCIENTIFIC_NOTATION = 'sci',
MISC_LOCALE_FORMAT = 'locale',
MISC_PIXELS = 'pixel',
// Acceleration
ACCELERATION_METERS_PER_SECOND_SQUARED = 'accMS2',
ACCELERATION_FEET_PER_SECOND_SQUARED = 'accFS2',
ACCELERATION_G_UNIT = 'accG',
// Angular
ANGULAR_DEGREE = 'degree',
ANGULAR_RADIAN = 'radian',
ANGULAR_GRADIAN = 'grad',
ANGULAR_ARC_MINUTE = 'arcmin',
ANGULAR_ARC_SECOND = 'arcsec',
// Area
AREA_SQUARE_METERS = 'areaM2',
AREA_SQUARE_FEET = 'areaF2',
AREA_SQUARE_MILES = 'areaMI2',
// FLOPs
FLOPS_FLOPS = 'flops',
FLOPS_MFLOPS = 'mflops',
FLOPS_GFLOPS = 'gflops',
FLOPS_TFLOPS = 'tflops',
FLOPS_PFLOPS = 'pflops',
FLOPS_EFLOPS = 'eflops',
FLOPS_ZFLOPS = 'zflops',
FLOPS_YFLOPS = 'yflops',
// Concentration
CONCENTRATION_PPM = 'ppm',
CONCENTRATION_PPB = 'conppb',
CONCENTRATION_NG_M3 = 'conngm3',
CONCENTRATION_NG_NORMAL_CUBIC_METER = 'conngNm3',
CONCENTRATION_UG_M3 = 'conμgm3',
CONCENTRATION_UG_NORMAL_CUBIC_METER = 'conμgNm3',
CONCENTRATION_MG_M3 = 'conmgm3',
CONCENTRATION_MG_NORMAL_CUBIC_METER = 'conmgNm3',
CONCENTRATION_G_M3 = 'congm3',
CONCENTRATION_G_NORMAL_CUBIC_METER = 'congNm3',
CONCENTRATION_MG_PER_DL = 'conmgdL',
CONCENTRATION_MMOL_PER_L = 'conmmolL',
// Currency
CURRENCY_USD = 'currencyUSD',
CURRENCY_GBP = 'currencyGBP',
CURRENCY_EUR = 'currencyEUR',
CURRENCY_JPY = 'currencyJPY',
CURRENCY_RUB = 'currencyRUB',
CURRENCY_UAH = 'currencyUAH',
CURRENCY_BRL = 'currencyBRL',
CURRENCY_DKK = 'currencyDKK',
CURRENCY_ISK = 'currencyISK',
CURRENCY_NOK = 'currencyNOK',
CURRENCY_SEK = 'currencySEK',
CURRENCY_CZK = 'currencyCZK',
CURRENCY_CHF = 'currencyCHF',
CURRENCY_PLN = 'currencyPLN',
CURRENCY_BTC = 'currencyBTC',
CURRENCY_MBTC = 'currencymBTC',
CURRENCY_UBTC = 'currencyμBTC',
CURRENCY_ZAR = 'currencyZAR',
CURRENCY_INR = 'currencyINR',
CURRENCY_KRW = 'currencyKRW',
CURRENCY_IDR = 'currencyIDR',
CURRENCY_PHP = 'currencyPHP',
CURRENCY_VND = 'currencyVND',
// Datetime
DATETIME_ISO = 'dateTimeAsIso',
DATETIME_ISO_NO_DATE_IF_TODAY = 'dateTimeAsIsoNoDateIfToday',
DATETIME_US = 'dateTimeAsUS',
DATETIME_US_NO_DATE_IF_TODAY = 'dateTimeAsUSNoDateIfToday',
DATETIME_LOCAL = 'dateTimeAsLocal',
DATETIME_LOCAL_NO_DATE_IF_TODAY = 'dateTimeAsLocalNoDateIfToday',
DATETIME_SYSTEM = 'dateTimeAsSystem',
DATETIME_FROM_NOW = 'dateTimeFromNow',
// Power/Electrical
POWER_WATT = 'watt',
POWER_KILOWATT = 'kwatt',
POWER_MEGAWATT = 'megwatt',
POWER_GIGAWATT = 'gwatt',
POWER_MILLIWATT = 'mwatt',
POWER_WATT_PER_SQUARE_METER = 'Wm2',
POWER_VOLT_AMPERE = 'voltamp',
POWER_KILOVOLT_AMPERE = 'kvoltamp',
POWER_VOLT_AMPERE_REACTIVE = 'voltampreact',
POWER_KILOVOLT_AMPERE_REACTIVE = 'kvoltampreact',
POWER_WATT_HOUR = 'watth',
POWER_WATT_HOUR_PER_KG = 'watthperkg',
POWER_KILOWATT_HOUR = 'kwatth',
POWER_KILOWATT_MINUTE = 'kwattm',
POWER_AMPERE_HOUR = 'amph',
POWER_KILOAMPERE_HOUR = 'kamph',
POWER_MILLIAMPERE_HOUR = 'mamph',
POWER_JOULE = 'joule',
POWER_ELECTRON_VOLT = 'ev',
POWER_AMPERE = 'amp',
POWER_KILOAMPERE = 'kamp',
POWER_MILLIAMPERE = 'mamp',
POWER_VOLT = 'volt',
POWER_KILOVOLT = 'kvolt',
POWER_MILLIVOLT = 'mvolt',
POWER_DECIBEL_MILLIWATT = 'dBm',
POWER_OHM = 'ohm',
POWER_KILOOHM = 'kohm',
POWER_MEGAOHM = 'Mohm',
POWER_FARAD = 'farad',
POWER_MICROFARAD = 'µfarad',
POWER_NANOFARAD = 'nfarad',
POWER_PICOFARAD = 'pfarad',
POWER_FEMTOFARAD = 'ffarad',
POWER_HENRY = 'henry',
POWER_MILLIHENRY = 'mhenry',
POWER_MICROHENRY = 'µhenry',
POWER_LUMENS = 'lumens',
// Flow
FLOW_GALLONS_PER_MINUTE = 'flowgpm',
FLOW_CUBIC_METERS_PER_SECOND = 'flowcms',
FLOW_CUBIC_FEET_PER_SECOND = 'flowcfs',
FLOW_CUBIC_FEET_PER_MINUTE = 'flowcfm',
FLOW_LITERS_PER_HOUR = 'litreh',
FLOW_LITERS_PER_MINUTE = 'flowlpm',
FLOW_MILLILITERS_PER_MINUTE = 'flowmlpm',
FLOW_LUX = 'lux',
// Force
FORCE_NEWTON_METERS = 'forceNm',
FORCE_KILONEWTON_METERS = 'forcekNm',
FORCE_NEWTONS = 'forceN',
FORCE_KILONEWTONS = 'forcekN',
// Mass
MASS_MILLIGRAM = 'massmg',
MASS_GRAM = 'massg',
MASS_POUND = 'masslb',
MASS_KILOGRAM = 'masskg',
MASS_METRIC_TON = 'masst',
// Length
LENGTH_MILLIMETER = 'lengthmm',
LENGTH_INCH = 'lengthin',
LENGTH_FOOT = 'lengthft',
LENGTH_METER = 'lengthm',
LENGTH_KILOMETER = 'lengthkm',
LENGTH_MILE = 'lengthmi',
// Pressure
PRESSURE_MILLIBAR = 'pressurembar',
PRESSURE_BAR = 'pressurebar',
PRESSURE_KILOBAR = 'pressurekbar',
PRESSURE_PASCAL = 'pressurepa',
PRESSURE_HECTOPASCAL = 'pressurehpa',
PRESSURE_KILOPASCAL = 'pressurekpa',
PRESSURE_INCHES_HG = 'pressurehg',
PRESSURE_PSI = 'pressurepsi',
// Radiation
RADIATION_BECQUEREL = 'radbq',
RADIATION_CURIE = 'radci',
RADIATION_GRAY = 'radgy',
RADIATION_RAD = 'radrad',
RADIATION_SIEVERT = 'radsv',
RADIATION_MILLISIEVERT = 'radmsv',
RADIATION_MICROSIEVERT = 'radusv',
RADIATION_REM = 'radrem',
RADIATION_EXPOSURE_C_PER_KG = 'radexpckg',
RADIATION_ROENTGEN = 'radr',
RADIATION_SIEVERT_PER_HOUR = 'radsvh',
RADIATION_MILLISIEVERT_PER_HOUR = 'radmsvh',
RADIATION_MICROSIEVERT_PER_HOUR = 'radusvh',
// Rotation speed
ROTATION_SPEED_REVOLUTIONS_PER_MINUTE = 'rotrpm',
ROTATION_SPEED_HERTZ = 'rothz',
ROTATION_SPEED_RADIANS_PER_SECOND = 'rotrads',
ROTATION_SPEED_DEGREES_PER_SECOND = 'rotdegs',
// Temperature
TEMPERATURE_CELSIUS = 'celsius',
TEMPERATURE_FAHRENHEIT = 'fahrenheit',
TEMPERATURE_KELVIN = 'kelvin',
// Velocity
VELOCITY_METERS_PER_SECOND = 'velocityms',
VELOCITY_KILOMETERS_PER_HOUR = 'velocitykmh',
VELOCITY_MILES_PER_HOUR = 'velocitymph',
VELOCITY_KNOT = 'velocityknot',
// Volume
VOLUME_MILLILITER = 'mlitre',
VOLUME_LITER = 'litre',
VOLUME_CUBIC_METER = 'm3',
VOLUME_NORMAL_CUBIC_METER = 'Nm3',
VOLUME_CUBIC_DECIMETER = 'dm3',
VOLUME_GALLON = 'gallons',
}
export enum YAxisUnit {
@@ -293,6 +558,15 @@ export enum YAxisUnit {
UCUM_PEBIBYTES = 'PiBy',
OPEN_METRICS_PEBIBYTES = 'pebibytes',
UCUM_EXBIBYTES = 'EiBy',
OPEN_METRICS_EXBIBYTES = 'exbibytes',
UCUM_ZEBIBYTES = 'ZiBy',
OPEN_METRICS_ZEBIBYTES = 'zebibytes',
UCUM_YOBIBYTES = 'YiBy',
OPEN_METRICS_YOBIBYTES = 'yobibytes',
UCUM_KIBIBYTES_SECOND = 'KiBy/s',
OPEN_METRICS_KIBIBYTES_SECOND = 'kibibytes_per_second',
@@ -323,6 +597,24 @@ export enum YAxisUnit {
UCUM_PEBIBITS_SECOND = 'Pibit/s',
OPEN_METRICS_PEBIBITS_SECOND = 'pebibits_per_second',
UCUM_EXBIBYTES_SECOND = 'EiBy/s',
OPEN_METRICS_EXBIBYTES_SECOND = 'exbibytes_per_second',
UCUM_EXBIBITS_SECOND = 'Eibit/s',
OPEN_METRICS_EXBIBITS_SECOND = 'exbibits_per_second',
UCUM_ZEBIBYTES_SECOND = 'ZiBy/s',
OPEN_METRICS_ZEBIBYTES_SECOND = 'zebibytes_per_second',
UCUM_ZEBIBITS_SECOND = 'Zibit/s',
OPEN_METRICS_ZEBIBITS_SECOND = 'zebibits_per_second',
UCUM_YOBIBYTES_SECOND = 'YiBy/s',
OPEN_METRICS_YOBIBYTES_SECOND = 'yobibytes_per_second',
UCUM_YOBIBITS_SECOND = 'Yibit/s',
OPEN_METRICS_YOBIBITS_SECOND = 'yobibits_per_second',
UCUM_TRUE_FALSE = '{bool}',
OPEN_METRICS_TRUE_FALSE = 'boolean_true_false',
@@ -364,3 +656,27 @@ export enum YAxisUnit {
OPEN_METRICS_PERCENT_UNIT = 'percentunit',
}
export interface ScaledValue {
value: number;
label: string;
}
export interface UnitFamilyConfig {
units: UniversalYAxisUnit[];
scaleFactor: number;
}
export interface YAxisCategory {
name: string;
units: {
name: string;
id: UniversalYAxisUnit;
}[];
}
export enum YAxisSource {
ALERTS = 'alerts',
DASHBOARDS = 'dashboards',
EXPLORER = 'explorer',
}

View File

@@ -1,5 +1,11 @@
import { UniversalYAxisUnitMappings, Y_AXIS_UNIT_NAMES } from './constants';
import { UniversalYAxisUnit, YAxisUnit } from './types';
import { ADDITIONAL_Y_AXIS_CATEGORIES, BASE_Y_AXIS_CATEGORIES } from './data';
import {
UniversalYAxisUnit,
YAxisCategory,
YAxisSource,
YAxisUnit,
} from './types';
export const mapMetricUnitToUniversalUnit = (
unit: string | undefined,
@@ -9,7 +15,7 @@ export const mapMetricUnitToUniversalUnit = (
}
const universalUnit = Object.values(UniversalYAxisUnit).find(
(u) => UniversalYAxisUnitMappings[u].has(unit as YAxisUnit) || unit === u,
(u) => UniversalYAxisUnitMappings[u]?.has(unit as YAxisUnit) || unit === u,
);
return universalUnit || (unit as UniversalYAxisUnit) || null;
@@ -31,3 +37,44 @@ export const getUniversalNameFromMetricUnit = (
return universalName || unit || '-';
};
export function isUniversalUnit(format: string): boolean {
return Object.values(UniversalYAxisUnit).includes(
format as UniversalYAxisUnit,
);
}
export function mergeCategories(
categories1: YAxisCategory[],
categories2: YAxisCategory[],
): YAxisCategory[] {
const mapOfCategories = new Map<string, YAxisCategory>();
categories1.forEach((category) => {
mapOfCategories.set(category.name, category);
});
categories2.forEach((category) => {
if (mapOfCategories.has(category.name)) {
mapOfCategories.set(category.name, {
name: category.name,
units: [
...(mapOfCategories.get(category.name)?.units ?? []),
...category.units,
],
});
} else {
mapOfCategories.set(category.name, category);
}
});
return Array.from(mapOfCategories.values());
}
export function getYAxisCategories(source: YAxisSource): YAxisCategory[] {
if (source !== YAxisSource.DASHBOARDS) {
return BASE_Y_AXIS_CATEGORIES;
}
return mergeCategories(BASE_Y_AXIS_CATEGORIES, ADDITIONAL_Y_AXIS_CATEGORIES);
}

View File

@@ -0,0 +1,207 @@
/**
* src/components/cmdKPalette/__test__/cmdkPalette.test.tsx
*/
import '@testing-library/jest-dom/extend-expect';
// ---- Mocks (must run BEFORE importing the component) ----
import ROUTES from 'constants/routes';
import history from 'lib/history';
import { render, screen, userEvent } from 'tests/test-utils';
import { CmdKPalette } from '../cmdKPalette';
const HOME_LABEL = 'Go to Home';
beforeAll(() => {
Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: jest.fn(),
});
});
afterAll(() => {
// restore
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
delete (HTMLElement.prototype as any).scrollIntoView;
});
// mock history.push / replace / go / location
jest.mock('lib/history', () => {
const location = { pathname: '/', search: '', hash: '' };
const stack: { pathname: string; search: string }[] = [
{ pathname: '/', search: '' },
];
const push = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
stack.push({ pathname, search });
return undefined;
});
const replace = jest.fn((path: string) => {
const [rawPath, rawQuery] = path.split('?');
const pathname = rawPath || '/';
const search = path.includes('?') ? `?${rawQuery || ''}` : '';
location.pathname = pathname;
location.search = search;
if (stack.length > 0) {
stack[stack.length - 1] = { pathname, search };
} else {
stack.push({ pathname, search });
}
return undefined;
});
const listen = jest.fn();
const go = jest.fn((n: number) => {
if (n < 0 && stack.length > 1) {
stack.pop();
}
const top = stack[stack.length - 1] || { pathname: '/', search: '' };
location.pathname = top.pathname;
location.search = top.search;
});
return {
push,
replace,
listen,
go,
location,
__stack: stack,
};
});
// Mock ResizeObserver for Jest/jsdom
class ResizeObserver {
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
observe() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
unobserve() {}
// eslint-disable-next-line @typescript-eslint/explicit-function-return-type, class-methods-use-this
disconnect() {}
}
(global as any).ResizeObserver = ResizeObserver;
// mock cmdK provider hook (open state + setter)
const mockSetOpen = jest.fn();
jest.mock('providers/cmdKProvider', (): unknown => ({
useCmdK: (): {
open: boolean;
setOpen: jest.Mock;
openCmdK: jest.Mock;
closeCmdK: jest.Mock;
} => ({
open: true,
setOpen: mockSetOpen,
openCmdK: jest.fn(),
closeCmdK: jest.fn(),
}),
}));
// mock notifications hook
jest.mock('hooks/useNotifications', (): unknown => ({
useNotifications: (): { notifications: [] } => ({ notifications: [] }),
}));
// mock theme hook
jest.mock('hooks/useDarkMode', (): unknown => ({
useThemeMode: (): {
setAutoSwitch: jest.Mock;
setTheme: jest.Mock;
theme: string;
} => ({
setAutoSwitch: jest.fn(),
setTheme: jest.fn(),
theme: 'dark',
}),
}));
// mock updateUserPreference API and react-query mutation
jest.mock('api/v1/user/preferences/name/update', (): jest.Mock => jest.fn());
jest.mock('react-query', (): unknown => {
const actual = jest.requireActual('react-query');
return {
...actual,
useMutation: (): { mutate: jest.Mock } => ({ mutate: jest.fn() }),
};
});
// mock other side-effecty modules
jest.mock('api/common/logEvent', () => jest.fn());
jest.mock('api/browser/localstorage/set', () => jest.fn());
jest.mock('utils/error', () => ({ showErrorNotification: jest.fn() }));
// ---- Tests ----
describe('CmdKPalette', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('renders navigation and settings groups and items', () => {
render(<CmdKPalette userRole="ADMIN" />);
expect(screen.getByText('Navigation')).toBeInTheDocument();
expect(screen.getByText('Settings')).toBeInTheDocument();
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
expect(screen.getByText('Go to Dashboards')).toBeInTheDocument();
expect(screen.getByText('Switch to Dark Mode')).toBeInTheDocument();
});
test('clicking a navigation item calls history.push with correct route', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const homeItem = screen.getByText(HOME_LABEL);
await userEvent.click(homeItem);
expect(history.push).toHaveBeenCalledWith(ROUTES.HOME);
});
test('role-based filtering (basic smoke)', () => {
render(<CmdKPalette userRole="VIEWER" />);
// VIEWER still sees basic navigation items
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('keyboard shortcut opens palette via setOpen', () => {
render(<CmdKPalette userRole="ADMIN" />);
const event = new KeyboardEvent('keydown', { key: 'k', ctrlKey: true });
window.dispatchEvent(event);
expect(mockSetOpen).toHaveBeenCalledWith(true);
});
test('items render with icons when provided', () => {
render(<CmdKPalette userRole="ADMIN" />);
const iconHolders = document.querySelectorAll('.cmd-item-icon');
expect(iconHolders.length).toBeGreaterThan(0);
expect(screen.getByText(HOME_LABEL)).toBeInTheDocument();
});
test('closing the palette via handleInvoke sets open to false', async () => {
render(<CmdKPalette userRole="ADMIN" />);
const dashItem = screen.getByText('Go to Dashboards');
await userEvent.click(dashItem);
// last call from handleInvoke should set open to false
expect(mockSetOpen).toHaveBeenCalledWith(false);
});
});

View File

@@ -0,0 +1,55 @@
/* Overlay stays below content */
[data-slot='dialog-overlay'] {
z-index: 50;
}
/* Dialog content always above overlay */
[data-slot='dialog-content'] {
position: fixed;
z-index: 60;
}
.cmdk-section-heading [cmdk-group-heading] {
text-transform: uppercase;
color: var(--bg-slate-100);
}
/* Hide scrollbar but keep scroll */
.cmdk-list-scroll {
scrollbar-width: none; /* Firefox */
}
.cmdk-list-scroll::-webkit-scrollbar {
display: none; /* Chrome, Safari, Edge */
}
.cmdk-list-scroll {
-webkit-overflow-scrolling: touch;
}
.cmdk-input-wrapper {
margin-left: 8px;
}
.cmdk-item-light:hover {
cursor: pointer;
background-color: var(--bg-vanilla-200) !important;
}
.cmdk-item-light[data-selected='true'] {
background-color: var(--bg-vanilla-300) !important;
color: var(--bg-ink-500);
}
.cmdk-item {
cursor: pointer;
}
[cmdk-item] svg {
width: auto;
height: auto;
}
.cmd-item-icon {
margin-right: 8px;
}

View File

@@ -0,0 +1,149 @@
import './cmdKPalette.scss';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@signozhq/command';
import logEvent from 'api/common/logEvent';
import { useThemeMode } from 'hooks/useDarkMode';
import history from 'lib/history';
import React, { useEffect } from 'react';
import { createShortcutActions } from '../../constants/shortcutActions';
import { useCmdK } from '../../providers/cmdKProvider';
type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: UserRole[];
perform: () => void;
};
type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export function CmdKPalette({
userRole,
}: {
userRole: UserRole;
}): JSX.Element | null {
const { open, setOpen } = useCmdK();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
// toggle palette with ⌘/Ctrl+K
function handleGlobalCmdK(
e: KeyboardEvent,
setOpen: React.Dispatch<React.SetStateAction<boolean>>,
): void {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') {
e.preventDefault();
setOpen(true);
}
}
const cmdKEffect = (): void | (() => void) => {
const listener = (e: KeyboardEvent): void => {
handleGlobalCmdK(e, setOpen);
};
window.addEventListener('keydown', listener);
return (): void => {
window.removeEventListener('keydown', listener);
setOpen(false);
};
};
useEffect(cmdKEffect, [setOpen]);
function handleThemeChange(value: string): void {
logEvent('Account Settings: Theme Changed', { theme: value });
if (value === 'auto') {
setAutoSwitch(true);
} else {
setAutoSwitch(false);
setTheme(value);
}
}
function onClickHandler(key: string): void {
history.push(key);
}
const actions = createShortcutActions({
navigate: onClickHandler,
handleThemeChange,
});
// RBAC filter: show action if no roles set OR current user role is included
const permitted = actions.filter(
(a) => !a.roles || a.roles.includes(userRole),
);
// group permitted actions by section
const grouped: [string, CmdAction[]][] = ((): [string, CmdAction[]][] => {
const map = new Map<string, CmdAction[]>();
permitted.forEach((a) => {
const section = a.section ?? 'Other';
const existing = map.get(section);
if (existing) {
existing.push(a);
} else {
map.set(section, [a]);
}
});
return Array.from(map.entries());
})();
const handleInvoke = (action: CmdAction): void => {
try {
action.perform();
} catch (e) {
console.error('Error invoking action', e);
} finally {
setOpen(false);
}
};
return (
<CommandDialog open={open} onOpenChange={setOpen} position="top" offset={110}>
<CommandInput placeholder="Search…" className="cmdk-input-wrapper" />
<CommandList className="cmdk-list-scroll">
<CommandEmpty>No results</CommandEmpty>
{grouped.map(([section, items]) => (
<CommandGroup
key={section}
heading={section}
className="cmdk-section-heading"
>
{items.map((it) => (
<CommandItem
key={it.id}
onSelect={(): void => handleInvoke(it)}
value={it.name}
className={theme === 'light' ? 'cmdk-item-light' : 'cmdk-item'}
>
<span className="cmd-item-icon">{it.icon}</span>
{it.name}
{it.shortcut && it.shortcut.length > 0 && (
<CommandShortcut>{it.shortcut.join(' • ')}</CommandShortcut>
)}
</CommandItem>
))}
</CommandGroup>
))}
</CommandList>
</CommandDialog>
);
}

View File

@@ -1,4 +1,7 @@
export const REACT_QUERY_KEY = {
GET_PUBLIC_DASHBOARD: 'GET_PUBLIC_DASHBOARD',
GET_PUBLIC_DASHBOARD_META: 'GET_PUBLIC_DASHBOARD_META',
GET_PUBLIC_DASHBOARD_WIDGET_DATA: 'GET_PUBLIC_DASHBOARD_WIDGET_DATA',
GET_ALL_LICENCES: 'GET_ALL_LICENCES',
GET_QUERY_RANGE: 'GET_QUERY_RANGE',
GET_ALL_DASHBOARDS: 'GET_ALL_DASHBOARDS',

View File

@@ -81,6 +81,7 @@ const ROUTES = {
METER_EXPLORER: '/meter/explorer',
METER_EXPLORER_VIEWS: '/meter/explorer/views',
HOME_PAGE: '/',
PUBLIC_DASHBOARD: '/public/dashboard/:dashboardId',
} as const;
export default ROUTES;

View File

@@ -0,0 +1,263 @@
import ROUTES from 'constants/routes';
import { GlobalShortcutsName } from 'constants/shortcuts/globalShortcuts';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import {
BarChart2,
BellDot,
BugIcon,
Compass,
DraftingCompass,
Expand,
HardDrive,
Home,
LayoutGrid,
ListMinus,
ScrollText,
Settings,
TowerControl,
Workflow,
} from 'lucide-react';
import React from 'react';
export type UserRole = 'ADMIN' | 'EDITOR' | 'AUTHOR' | 'VIEWER';
export type CmdAction = {
id: string;
name: string;
shortcut?: string[];
keywords?: string;
section?: string;
icon?: React.ReactNode;
roles?: UserRole[];
perform: () => void;
};
type ActionDeps = {
navigate: (path: string) => void;
handleThemeChange: (mode: string) => void;
};
export function createShortcutActions(deps: ActionDeps): CmdAction[] {
const { navigate, handleThemeChange } = deps;
return [
{
id: 'home',
name: 'Go to Home',
shortcut: [GlobalShortcutsName.NavigateToHome],
keywords: 'home',
section: 'Navigation',
icon: <Home size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.HOME),
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: [GlobalShortcutsName.NavigateToDashboards],
keywords: 'dashboards',
section: 'Navigation',
icon: <LayoutGrid size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.ALL_DASHBOARD),
},
{
id: 'services',
name: 'Go to Services',
shortcut: [GlobalShortcutsName.NavigateToServices],
keywords: 'services monitoring',
section: 'Navigation',
icon: <HardDrive size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.APPLICATION),
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: [GlobalShortcutsName.NavigateToAlerts],
keywords: 'alerts',
section: 'Navigation',
icon: <BellDot size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.LIST_ALL_ALERT),
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: [GlobalShortcutsName.NavigateToExceptions],
keywords: 'exceptions errors',
section: 'Navigation',
icon: <BugIcon size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.ALL_ERROR),
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: [GlobalShortcutsName.NavigateToMessagingQueues],
keywords: 'messaging queues mq',
section: 'Navigation',
icon: <ListMinus size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.MESSAGING_QUEUES_OVERVIEW),
},
// logs
{
id: 'logs',
name: 'Go to Logs',
shortcut: [GlobalShortcutsName.NavigateToLogs],
keywords: 'logs',
section: 'Logs',
icon: <ScrollText size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.LOGS),
},
{
id: 'logs',
name: 'Go to Logs Pipelines',
shortcut: [GlobalShortcutsName.NavigateToLogsPipelines],
keywords: 'logs pipelines',
section: 'Logs',
icon: <Workflow size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.LOGS_PIPELINES),
},
{
id: 'logs',
name: 'Go to Logs Views',
shortcut: [GlobalShortcutsName.NavigateToLogsViews],
keywords: 'logs views',
section: 'Logs',
icon: <TowerControl size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.LOGS_SAVE_VIEWS),
},
// metrics
{
id: 'metrics-summary',
name: 'Go to Metrics Summary',
shortcut: [GlobalShortcutsName.NavigateToMetricsSummary],
keywords: 'metrics summary',
section: 'Metrics',
icon: <BarChart2 size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.METRICS_EXPLORER),
},
{
id: 'metrics-explorer',
name: 'Go to Metrics Explorer',
shortcut: [GlobalShortcutsName.NavigateToMetricsExplorer],
keywords: 'metrics explorer',
section: 'Metrics',
icon: <Compass size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_EXPLORER),
},
{
id: 'metrics-views',
name: 'Go to Metrics Views',
shortcut: [GlobalShortcutsName.NavigateToMetricsViews],
keywords: 'metrics views',
section: 'Metrics',
icon: <TowerControl size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.METRICS_EXPLORER_VIEWS),
},
// Traces
{
id: 'traces',
name: 'Go to Traces',
shortcut: [GlobalShortcutsName.NavigateToTraces],
keywords: 'traces',
section: 'Traces',
icon: <DraftingCompass size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.TRACES_EXPLORER),
},
{
id: 'traces-funnel',
name: 'Go to Traces Funnels',
shortcut: [GlobalShortcutsName.NavigateToTracesFunnel],
keywords: 'traces funnel',
section: 'Traces',
icon: <DraftingCompass size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.TRACES_FUNNELS),
},
// Common actions
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Common',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.DARK),
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Common',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Common',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
},
// settings sub-pages
{
id: 'my-settings',
name: 'Go to Account Settings',
shortcut: [GlobalShortcutsName.NavigateToSettings],
keywords: 'account settings',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'VIEWER'],
perform: (): void => navigate(ROUTES.MY_SETTINGS),
},
{
id: 'my-settings-ingestion',
name: 'Go to Account Settings Ingestion',
shortcut: [GlobalShortcutsName.NavigateToSettingsIngestion],
keywords: 'account settings',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR'],
perform: (): void => navigate(ROUTES.INGESTION_SETTINGS),
},
{
id: 'my-settings-billing',
name: 'Go to Account Settings Billing',
shortcut: [GlobalShortcutsName.NavigateToSettingsBilling],
keywords: 'account settings billing',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR'],
perform: (): void => navigate(ROUTES.BILLING),
},
{
id: 'my-settings-api-keys',
name: 'Go to Account Settings API Keys',
shortcut: [GlobalShortcutsName.NavigateToSettingsAPIKeys],
keywords: 'account settings api keys',
section: 'Settings',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR'],
perform: (): void => navigate(ROUTES.API_KEYS),
},
];
}

View File

@@ -1,25 +1,57 @@
export const GlobalShortcuts = {
NavigateToServices: 's+shift',
NavigateToTraces: 't+shift',
NavigateToLogs: 'l+shift',
NavigateToDashboards: 'd+shift',
NavigateToAlerts: 'a+shift',
NavigateToExceptions: 'e+shift',
NavigateToMessagingQueues: 'm+shift',
ToggleSidebar: 'b+shift',
NavigateToHome: 'h+shift',
NavigateToServices: 'shift+s',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
NavigateToHome: 'shift+h',
// logs
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
// traces
NavigateToTraces: 'shift+t',
NavigateToTracesFunnel: 'shift+t+f',
NavigateToTracesViews: 'shift+t+v',
// metrics
NavigateToMetricsSummary: 'shift+m',
NavigateToMetricsExplorer: 'shift+m+e',
NavigateToMetricsViews: 'shift+m+v',
// settings
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsAPIKeys: 'shift+g+k',
NavigateToSettingsNotificationChannels: 'shift+g+n',
};
export const GlobalShortcutsName = {
NavigateToServices: 'shift+s',
NavigateToTraces: 'shift+t',
NavigateToLogs: 'shift+l',
NavigateToDashboards: 'shift+d',
NavigateToAlerts: 'shift+a',
NavigateToExceptions: 'shift+e',
NavigateToMessagingQueues: 'shift+m',
NavigateToMessagingQueues: 'shift+q',
ToggleSidebar: 'shift+b',
NavigateToHome: 'shift+h',
NavigateToTracesFunnel: 'shift+t+f',
NavigateToTracesViews: 'shift+t+v',
NavigateToMetricsSummary: 'shift+m',
NavigateToMetricsExplorer: 'shift+m+e',
NavigateToMetricsViews: 'shift+m+v',
NavigateToSettings: 'shift+g',
NavigateToSettingsIngestion: 'shift+g+i',
NavigateToSettingsBilling: 'shift+g+b',
NavigateToSettingsAPIKeys: 'shift+g+k',
NavigateToSettingsNotificationChannels: 'shift+g+n',
NavigateToLogs: 'shift+l',
NavigateToLogsPipelines: 'shift+l+p',
NavigateToLogsViews: 'shift+l+v',
};
export const GlobalShortcutsDescription = {
@@ -32,4 +64,17 @@ export const GlobalShortcutsDescription = {
NavigateToExceptions: 'Navigate to Exceptions List',
NavigateToMessagingQueues: 'Navigate to Messaging Queues',
ToggleSidebar: 'Toggle sidebar visibility',
NavigateToTracesFunnel: 'Navigate to Traces Funnel',
NavigateToTracesViews: 'Navigate to Traces Views',
NavigateToMetricsSummary: 'Navigate to Metrics Summary',
NavigateToMetricsExplorer: 'Navigate to Metrics Explorer',
NavigateToMetricsViews: 'Navigate to Metrics Views',
NavigateToSettings: 'Navigate to Settings',
NavigateToSettingsIngestion: 'Navigate to Ingestion Settings',
NavigateToSettingsBilling: 'Navigate to Billing Settings',
NavigateToSettingsAPIKeys: 'Navigate to API Keys Settings',
NavigateToSettingsNotificationChannels:
'Navigate to Notification Channels Settings',
NavigateToLogsPipelines: 'Navigate to Logs Pipelines',
NavigateToLogsViews: 'Navigate to Logs Views',
};

View File

@@ -10,6 +10,20 @@ import {
import { QueryClient, QueryClientProvider } from 'react-query';
// Mock dependencies
jest.mock('providers/cmdKProvider', () => ({
useCmdK: (): {
open: boolean;
setOpen: React.Dispatch<React.SetStateAction<boolean>>;
openCmdK: () => void;
closeCmdK: () => void;
} => ({
open: false,
setOpen: jest.fn(),
openCmdK: jest.fn(),
closeCmdK: jest.fn(),
}),
}));
jest.mock('api/common/logEvent', () => jest.fn());
// Mock the AppContext
@@ -63,7 +77,7 @@ describe('Sidebar Toggle Shortcut', () => {
describe('Global Shortcuts Constants', () => {
it('should have the correct shortcut key combination', () => {
expect(GlobalShortcuts.ToggleSidebar).toBe('b+shift');
expect(GlobalShortcuts.ToggleSidebar).toBe('shift+b');
});
});

View File

@@ -391,6 +391,9 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
const pageTitle = t(routeKey);
const isPublicDashboard = pathname.startsWith('/public/dashboard/');
const renderFullScreen =
pathname === ROUTES.GET_STARTED ||
pathname === ROUTES.ONBOARDING ||
@@ -399,7 +402,8 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
pathname === ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING ||
pathname === ROUTES.GET_STARTED_LOGS_MANAGEMENT ||
pathname === ROUTES.GET_STARTED_AWS_MONITORING ||
pathname === ROUTES.GET_STARTED_AZURE_MONITORING;
pathname === ROUTES.GET_STARTED_AZURE_MONITORING ||
isPublicDashboard;
const [showTrialExpiryBanner, setShowTrialExpiryBanner] = useState(false);

View File

@@ -1,7 +1,8 @@
import { Button, Flex, Switch, Typography } from 'antd';
import { BaseOptionType, DefaultOptionType, SelectProps } from 'antd/es/select';
import { getInvolvedQueriesInTraceOperator } from 'components/QueryBuilderV2/QueryV2/TraceOperator/utils/utils';
import { Y_AXIS_CATEGORIES } from 'components/YAxisUnitSelector/constants';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getYAxisCategories } from 'components/YAxisUnitSelector/utils';
import ROUTES from 'constants/routes';
import {
AlertThresholdMatchType,
@@ -39,7 +40,8 @@ export function getQueryNames(currentQuery: Query): BaseOptionType[] {
}
export function getCategoryByOptionId(id: string): string | undefined {
return Y_AXIS_CATEGORIES.find((category) =>
const categories = getYAxisCategories(YAxisSource.ALERTS);
return categories.find((category) =>
category.units.some((unit) => unit.id === id),
)?.name;
}
@@ -47,14 +49,15 @@ export function getCategoryByOptionId(id: string): string | undefined {
export function getCategorySelectOptionByName(
name: string,
): DefaultOptionType[] {
const categories = getYAxisCategories(YAxisSource.ALERTS);
return (
Y_AXIS_CATEGORIES.find((category) => category.name === name)?.units.map(
(unit) => ({
categories
.find((category) => category.name === name)
?.units.map((unit) => ({
label: unit.name,
value: unit.id,
'data-testid': `threshold-unit-select-option-${unit.id}`,
}),
) || []
})) || []
);
}

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