Compare commits

...

45 Commits

Author SHA1 Message Date
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
271 changed files with 26873 additions and 4435 deletions

40
.github/CODEOWNERS vendored
View File

@@ -3,51 +3,11 @@
# that they own.
/frontend/ @YounixM @aks07
/frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
# 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

@@ -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

@@ -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

@@ -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

@@ -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.104.0
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.104.0
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.104.0}
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.104.0}
container_name: signoz
command:
- --config=/root/config/prometheus.yml

2293
docs/api/openapi.yml Normal file

File diff suppressed because it is too large Load Diff

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

@@ -243,6 +243,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 +258,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
}

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,7 +4,7 @@ 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 Spinner from 'components/Spinner';
import { FeatureKeys } from 'constants/features';
@@ -22,12 +22,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 +213,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 +364,33 @@ function App(): JSX.Element {
<ConfigProvider theme={themeConfig}>
<Router history={history}>
<CompatRouter>
<KBarCommandPaletteProvider>
<KBarCommandPalette />
<CmdKProvider>
<NotificationProvider>
<ErrorModalProvider>
{isLoggedInState && <CmdKPalette 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 +398,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

@@ -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

@@ -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,208 @@
/**
* 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('Open Sidebar')).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,336 @@
import './cmdKPalette.scss';
import {
CommandDialog,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandShortcut,
} from '@signozhq/command';
import setLocalStorageApi from 'api/browser/localstorage/set';
import logEvent from 'api/common/logEvent';
import updateUserPreference from 'api/v1/user/preferences/name/update';
import { AxiosError } from 'axios';
import ROUTES from 'constants/routes';
import { USER_PREFERENCES } from 'constants/userPreferences';
import { useThemeMode } from 'hooks/useDarkMode';
import { THEME_MODE } from 'hooks/useDarkMode/constant';
import { useNotifications } from 'hooks/useNotifications';
import history from 'lib/history';
import {
BellDot,
BugIcon,
DraftingCompass,
Expand,
HardDrive,
Home,
LayoutGrid,
ListMinus,
ScrollText,
Settings,
} from 'lucide-react';
import React, { useEffect } from 'react';
import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import { useAppContext } from '../../providers/App/App';
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 { updateUserPreferenceInContext } = useAppContext();
const { notifications } = useNotifications();
const { setAutoSwitch, setTheme, theme } = useThemeMode();
const { mutate: updateUserPreferenceMutation } = useMutation(
updateUserPreference,
{
onError: (error) => {
showErrorNotification(notifications, error as AxiosError);
},
},
);
// 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);
}
function handleOpenSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'true');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: true };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: true,
});
}
function handleCloseSidebar(): void {
setLocalStorageApi(USER_PREFERENCES.SIDENAV_PINNED, 'false');
const save = { name: USER_PREFERENCES.SIDENAV_PINNED, value: false };
updateUserPreferenceInContext(save as UserPreference);
updateUserPreferenceMutation({
name: USER_PREFERENCES.SIDENAV_PINNED,
value: false,
});
}
const actions: CmdAction[] = [
{
id: 'home',
name: 'Go to Home',
shortcut: ['shift + h'],
keywords: 'home',
section: 'Navigation',
icon: <Home size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.HOME),
},
{
id: 'dashboards',
name: 'Go to Dashboards',
shortcut: ['shift + d'],
keywords: 'dashboards',
section: 'Navigation',
icon: <LayoutGrid size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_DASHBOARD),
},
{
id: 'services',
name: 'Go to Services',
shortcut: ['shift + s'],
keywords: 'services monitoring',
section: 'Navigation',
icon: <HardDrive size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.APPLICATION),
},
{
id: 'traces',
name: 'Go to Traces',
shortcut: ['shift + t'],
keywords: 'traces',
section: 'Navigation',
icon: <DraftingCompass size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.TRACES_EXPLORER),
},
{
id: 'logs',
name: 'Go to Logs',
shortcut: ['shift + l'],
keywords: 'logs',
section: 'Navigation',
icon: <ScrollText size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LOGS),
},
{
id: 'alerts',
name: 'Go to Alerts',
shortcut: ['shift + a'],
keywords: 'alerts',
section: 'Navigation',
icon: <BellDot size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.LIST_ALL_ALERT),
},
{
id: 'exceptions',
name: 'Go to Exceptions',
shortcut: ['shift + e'],
keywords: 'exceptions errors',
section: 'Navigation',
icon: <BugIcon size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.ALL_ERROR),
},
{
id: 'messaging-queues',
name: 'Go to Messaging Queues',
shortcut: ['shift + m'],
keywords: 'messaging queues mq',
section: 'Navigation',
icon: <ListMinus size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MESSAGING_QUEUES_OVERVIEW),
},
{
id: 'my-settings',
name: 'Go to Account Settings',
keywords: 'account settings',
section: 'Navigation',
icon: <Settings size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => onClickHandler(ROUTES.MY_SETTINGS),
},
// Settings
{
id: 'open-sidebar',
name: 'Open Sidebar',
keywords: 'sidebar navigation menu expand',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleOpenSidebar(),
},
{
id: 'collapse-sidebar',
name: 'Collapse Sidebar',
keywords: 'sidebar navigation menu collapse',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleCloseSidebar(),
},
{
id: 'dark-mode',
name: 'Switch to Dark Mode',
keywords: 'theme dark mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.DARK),
},
{
id: 'light-mode',
name: 'Switch to Light Mode [Beta]',
keywords: 'theme light mode appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.LIGHT),
},
{
id: 'system-theme',
name: 'Switch to System Theme',
keywords: 'system theme appearance',
section: 'Settings',
icon: <Expand size={14} />,
roles: ['ADMIN', 'EDITOR', 'AUTHOR', 'VIEWER'],
perform: (): void => handleThemeChange(THEME_MODE.SYSTEM),
},
];
// 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

@@ -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}`,
}),
) || []
})) || []
);
}

View File

@@ -1,4 +1,5 @@
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useCreateAlertState } from 'container/CreateAlertV2/context';
import ChartPreviewComponent from 'container/FormAlertRules/ChartPreview';
@@ -37,6 +38,7 @@ function ChartPreview({ alertDef }: ChartPreviewProps): JSX.Element {
onChange={(value): void => {
setAlertState({ type: 'SET_Y_AXIS_UNIT', payload: value });
}}
source={YAxisSource.ALERTS}
/>
</div>
);

View File

@@ -266,7 +266,10 @@ export default function CustomDomainSettings(): JSX.Element {
<div className="custom-domain-settings-modal-error">
{updateDomainError.status === 409 ? (
<Alert
message="Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance."
message={
(updateDomainError?.response?.data as { error?: string })?.error ||
'Youve already updated the custom domain once today. To make further changes, please contact our support team for assistance.'
}
type="warning"
className="update-limit-reached-error"
/>

View File

@@ -138,9 +138,9 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
const { formatTimezoneAdjustedTimestamp } = useTimezone();
return (
<>
<Typography>{errorDetail.exceptionType}</Typography>
<Typography>{errorDetail.exceptionMessage}</Typography>
<div className="error-details-container">
<Typography.Title level={4}>{errorDetail.exceptionType}</Typography.Title>
<Typography.Text>{errorDetail.exceptionMessage}</Typography.Text>
<Divider />
<EventContainer>
@@ -200,7 +200,7 @@ function ErrorDetails(props: ErrorDetailsProps): JSX.Element {
<ResizeTable columns={columns} tableLayout="fixed" dataSource={data} />
</Space>
</EditorContainer>
</>
</div>
);
}

View File

@@ -1,3 +1,7 @@
.error-details-container {
padding: 16px;
}
.error-container {
height: 50vh;
}

View File

@@ -393,15 +393,21 @@ function ExplorerOptions({
backwardCompatibleOptions = omit(options, 'version');
}
// Use the correct default columns based on the current data source
const defaultColumns =
sourcepage === DataSource.TRACES
? defaultTraceSelectedColumns
: defaultLogsSelectedColumns;
if (extraData.selectColumns?.length) {
handleOptionsChange({
...backwardCompatibleOptions,
selectColumns: extraData.selectColumns,
});
} else if (!isEqual(defaultTraceSelectedColumns, options.selectColumns)) {
} else if (!isEqual(defaultColumns, options.selectColumns)) {
handleOptionsChange({
...backwardCompatibleOptions,
selectColumns: defaultTraceSelectedColumns,
selectColumns: defaultColumns,
});
}
};

View File

@@ -304,14 +304,19 @@ function WidgetHeader({
data-testid="widget-header-search"
/>
)}
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<MoreOutlined
data-testid="widget-header-options"
className={`widget-header-more-options ${
parentHover ? 'widget-header-hover' : ''
} ${globalSearchAvailable ? 'widget-header-more-options-visible' : ''}`}
/>
</Dropdown>
{menu && Array.isArray(menu.items) && menu.items.length > 0 && (
<Dropdown menu={menu} trigger={['hover']} placement="bottomRight">
<MoreOutlined
data-testid="widget-header-options"
className={`widget-header-more-options ${
parentHover ? 'widget-header-hover' : ''
} ${
globalSearchAvailable ? 'widget-header-more-options-visible' : ''
}`}
/>
</Dropdown>
)}
</div>
</>
)}

View File

@@ -1,5 +1,5 @@
import { TableProps } from 'antd';
import { PrecisionOption } from 'components/Graph/yAxisConfig';
import { PrecisionOption } from 'components/Graph/types';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
import {

View File

@@ -175,7 +175,18 @@ function LiveLogsContainer(): JSX.Element {
if (isConnectionError && reconnectDueToError) {
// Small delay to prevent immediate reconnection attempts
const reconnectTimer = setTimeout(() => {
handleStartNewConnection();
const fallbackFilterExpression =
prevFilterExpressionRef.current ||
currentQuery?.builder.queryData[0]?.filter?.expression?.trim() ||
null;
const validationResult = validateQuery(fallbackFilterExpression || '');
if (validationResult.isValid) {
handleStartNewConnection(fallbackFilterExpression);
} else {
handleStartNewConnection(null);
}
}, 1000);
return (): void => clearTimeout(reconnectTimer);
@@ -186,6 +197,7 @@ function LiveLogsContainer(): JSX.Element {
reconnectDueToError,
compositeQuery,
handleStartNewConnection,
currentQuery,
]);
// clean up the connection when the component unmounts

View File

@@ -7,6 +7,9 @@ import {
QUERY_BUILDER_FUNCTIONS,
} from 'constants/antlrQueryConstants';
import { useActiveLog } from 'hooks/logs/useActiveLog';
import { useNotifications } from 'hooks/useNotifications';
import { useCallback } from 'react';
import { useCopyToClipboard } from 'react-use';
import { TitleWrapper } from './BodyTitleRenderer.styles';
import { DROPDOWN_KEY } from './constant';
@@ -24,6 +27,8 @@ function BodyTitleRenderer({
value,
}: BodyTitleRendererProps): JSX.Element {
const { onAddToQuery } = useActiveLog();
const [, setCopy] = useCopyToClipboard();
const { notifications } = useNotifications();
const filterHandler = (isFilterIn: boolean) => (): void => {
if (parentIsArray) {
@@ -75,18 +80,53 @@ function BodyTitleRenderer({
onClick: onClickHandler,
};
const handleTextSelection = (e: React.MouseEvent): void => {
// Prevent tree node click when user is trying to select text
e.stopPropagation();
};
const handleNodeClick = useCallback(
(e: React.MouseEvent): void => {
// Prevent tree node expansion/collapse
e.stopPropagation();
const cleanedKey = removeObjectFromString(nodeKey);
let copyText: string;
// Check if value is an object or array
const isObject = typeof value === 'object' && value !== null;
if (isObject) {
// For objects/arrays, stringify the entire structure
copyText = `"${cleanedKey}": ${JSON.stringify(value, null, 2)}`;
} else if (parentIsArray) {
// For array elements, copy just the value
copyText = `"${cleanedKey}": ${value}`;
} else {
// For primitive values, format as JSON key-value pair
const valueStr = typeof value === 'string' ? `"${value}"` : String(value);
copyText = `"${cleanedKey}": ${valueStr}`;
}
setCopy(copyText);
if (copyText) {
const notificationMessage = isObject
? `${cleanedKey} object copied to clipboard`
: `${cleanedKey} copied to clipboard`;
notifications.success({
message: notificationMessage,
key: notificationMessage,
});
}
},
[nodeKey, parentIsArray, setCopy, value, notifications],
);
return (
<TitleWrapper onMouseDown={handleTextSelection}>
<Dropdown menu={menu} trigger={['click']}>
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
</Dropdown>
<TitleWrapper onClick={handleNodeClick}>
{typeof value !== 'object' && (
<Dropdown menu={menu} trigger={['click']}>
<SettingOutlined style={{ marginRight: 8 }} className="hover-reveal" />
</Dropdown>
)}
{title.toString()}{' '}
{!parentIsArray && (
{!parentIsArray && typeof value !== 'object' && (
<span>
: <span style={{ color: orange[6] }}>{`${value}`}</span>
</span>

View File

@@ -202,9 +202,7 @@ export default function TableViewActions(
if (record.field === 'body') {
return (
<div className={cx('value-field', isOpen ? 'open-popover' : '')}>
<CopyClipboardHOC entityKey={fieldFilterKey} textToCopy={textToCopy}>
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
</CopyClipboardHOC>
<BodyContent fieldData={fieldData} record={record} bodyHtml={bodyHtml} />
{!isListViewPanel && !RESTRICTED_SELECTED_FIELDS.includes(fieldFilterKey) && (
<span className="action-btn">
<Tooltip title="Filter for value">

View File

@@ -0,0 +1,109 @@
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import BodyTitleRenderer from '../BodyTitleRenderer';
let mockSetCopy: jest.Mock;
const mockNotification = jest.fn();
jest.mock('hooks/logs/useActiveLog', () => ({
useActiveLog: (): any => ({
onAddToQuery: jest.fn(),
}),
}));
jest.mock('react-use', () => ({
useCopyToClipboard: (): any => {
mockSetCopy = jest.fn();
return [{ value: null }, mockSetCopy];
},
}));
jest.mock('hooks/useNotifications', () => ({
useNotifications: (): any => ({
notifications: {
success: mockNotification,
error: jest.fn(),
info: jest.fn(),
warning: jest.fn(),
open: jest.fn(),
destroy: jest.fn(),
},
}),
}));
describe('BodyTitleRenderer', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should copy primitive value when node is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<BodyTitleRenderer
title="name"
nodeKey="user.name"
value="John"
parentIsArray={false}
/>,
);
await user.click(screen.getByText('name'));
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('"user.name": "John"');
expect(mockNotification).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('user.name'),
}),
);
});
});
it('should copy array element value when clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(
<BodyTitleRenderer
title="0"
nodeKey="items[*].0"
value="arrayElement"
parentIsArray
/>,
);
await user.click(screen.getByText('0'));
await waitFor(() => {
expect(mockSetCopy).toHaveBeenCalledWith('"items[*].0": arrayElement');
});
});
it('should copy entire object when object node is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
const testObject = { id: 123, active: true };
render(
<BodyTitleRenderer
title="metadata"
nodeKey="user.metadata"
value={testObject}
parentIsArray={false}
/>,
);
await user.click(screen.getByText('metadata'));
await waitFor(() => {
const callArg = mockSetCopy.mock.calls[0][0];
expect(callArg).toContain('"user.metadata":');
expect(callArg).toContain('"id": 123');
expect(callArg).toContain('"active": true');
expect(mockNotification).toHaveBeenCalledWith(
expect.objectContaining({
message: expect.stringContaining('object copied'),
}),
);
});
});
});

View File

@@ -39,9 +39,17 @@ export const computeDataNode = (
valueIsArray: boolean,
value: unknown,
nodeKey: string,
parentIsArray: boolean,
): DataNode => ({
key: uniqueId(),
title: `${key} ${valueIsArray ? '[...]' : ''}`,
title: (
<BodyTitleRenderer
title={`${key} ${valueIsArray ? '[...]' : ''}`}
nodeKey={nodeKey}
value={value}
parentIsArray={parentIsArray}
/>
),
// eslint-disable-next-line @typescript-eslint/no-use-before-define
children: jsonToDataNodes(
value as Record<string, unknown>,
@@ -67,7 +75,7 @@ export function jsonToDataNodes(
if (parentIsArray) {
if (typeof value === 'object' && value !== null) {
return computeDataNode(key, valueIsArray, value, nodeKey);
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
}
return {
@@ -85,7 +93,7 @@ export function jsonToDataNodes(
}
if (typeof value === 'object' && value !== null) {
return computeDataNode(key, valueIsArray, value, nodeKey);
return computeDataNode(key, valueIsArray, value, nodeKey, parentIsArray);
}
return {
key: uniqueId(),

View File

@@ -209,6 +209,15 @@
}
}
}
.time-series-view-container {
.time-series-view-container-header {
display: flex;
justify-content: flex-start;
align-items: center;
padding: 12px;
}
}
}
}

View File

@@ -22,6 +22,7 @@ import {
getListQuery,
getQueryByPanelType,
} from 'container/LogsExplorerViews/explorerUtils';
import { BuilderUnitsFilter } from 'container/QueryBuilder/filters/BuilderUnitsFilter';
import TimeSeriesView from 'container/TimeSeriesView/TimeSeriesView';
import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
import { useGetExplorerQueryRange } from 'hooks/queryBuilder/useGetExplorerQueryRange';
@@ -110,6 +111,8 @@ function LogsExplorerViewsContainer({
const [orderBy, setOrderBy] = useState<string>('timestamp:desc');
const [yAxisUnit, setYAxisUnit] = useState<string>('');
const listQuery = useMemo(() => getListQuery(stagedQuery) || null, [
stagedQuery,
]);
@@ -350,6 +353,10 @@ function LogsExplorerViewsContainer({
orderBy,
]);
const onUnitChangeHandler = useCallback((value: string): void => {
setYAxisUnit(value);
}, []);
const chartData = useMemo(() => {
if (!stagedQuery) return [];
@@ -457,15 +464,24 @@ function LogsExplorerViewsContainer({
)}
{selectedPanelType === PANEL_TYPES.TIME_SERIES && !showLiveLogs && (
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
/>
<div className="time-series-view-container">
<div className="time-series-view-container-header">
<BuilderUnitsFilter
onChange={onUnitChangeHandler}
yAxisUnit={yAxisUnit}
/>
</div>
<TimeSeriesView
isLoading={isLoading || isFetching}
data={data}
isError={isError}
error={error as APIError}
yAxisUnit={yAxisUnit}
isFilterApplied={!isEmpty(listQuery?.filters?.items)}
dataSource={DataSource.LOGS}
setWarning={setWarning}
/>
</div>
)}
{selectedPanelType === PANEL_TYPES.TABLE && !showLiveLogs && (

View File

@@ -124,7 +124,7 @@
.builder-units-filter-label {
margin-bottom: 0px !important;
font-size: 13px;
font-size: 12px;
}
}
}

View File

@@ -6,6 +6,7 @@ import { MetricType } from 'api/metricsExplorer/getMetricsList';
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
import { ResizeTable } from 'components/ResizeTable';
import YAxisUnitSelector from 'components/YAxisUnitSelector';
import { YAxisSource } from 'components/YAxisUnitSelector/types';
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
import { DataType } from 'container/LogDetailedView/TableView';
@@ -120,6 +121,7 @@ function Metadata({
setMetricMetadata((prev) => ({ ...prev, unit: value }));
}}
data-testid="unit-select"
source={YAxisSource.EXPLORER}
/>
);
}

View File

@@ -68,7 +68,7 @@
display: flex;
gap: 6px;
align-items: center;
max-width: 100%;
max-width: 80%;
.dashboard-btn {
display: flex;
@@ -130,7 +130,6 @@
.left-section {
display: flex;
align-items: center;
gap: 8px;
width: 45%;
@@ -148,16 +147,17 @@
font-weight: 500;
line-height: 24px; /* 150% */
letter-spacing: -0.08px;
flex-shrink: 0;
flex: 1;
min-width: fit-content;
max-width: 80%;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-line-clamp: 1;
-webkit-box-orient: vertical;
overflow: hidden;
}
.public-dashboard-icon {
margin-right: 4px;
}
}
.right-section {

View File

@@ -17,7 +17,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
setVisible(true);
};
const onClose = (): void => {
const handleClose = (): void => {
setVisible(false);
variableViewModeRef?.current?.();
};
@@ -38,7 +38,7 @@ function SettingsDrawer({ drawerTitle }: { drawerTitle: string }): JSX.Element {
title={drawerTitle}
placement="right"
width="50%"
onClose={onClose}
onClose={handleClose}
open={visible}
rootClassName="settings-container-root"
>

View File

@@ -17,6 +17,7 @@ import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
import useComponentPermission from 'hooks/useComponentPermission';
import { useNotifications } from 'hooks/useNotifications';
@@ -29,6 +30,7 @@ import {
FileJson,
FolderKanban,
Fullscreen,
Globe,
LayoutGrid,
LockKeyhole,
PenLine,
@@ -128,6 +130,8 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
false,
);
const [isPublicDashboard, setIsPublicDashboard] = useState<boolean>(false);
let isAuthor = false;
if (selectedDashboard && user && user.email) {
@@ -297,6 +301,38 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
safeNavigate(generatedUrl);
}
const {
data: publicDashboardResponse,
// refetch: refetchPublicDashboardData,
isLoading: isLoadingPublicDashboardData,
isFetching: isFetchingPublicDashboardData,
error: errorPublicDashboardData,
isError: isErrorPublicDashboardData,
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
useEffect(() => {
if (!isLoadingPublicDashboardData && !isFetchingPublicDashboardData) {
if (isErrorPublicDashboardData) {
const errorDetails = errorPublicDashboardData?.getErrorDetails();
if (errorDetails?.error?.code === 'public_dashboard_not_found') {
setIsPublicDashboard(false);
}
} else {
const publicDashboardData = publicDashboardResponse?.data;
if (publicDashboardData?.publicPath) {
setIsPublicDashboard(true);
}
}
}
}, [
isLoadingPublicDashboardData,
isFetchingPublicDashboardData,
isErrorPublicDashboardData,
errorPublicDashboardData,
publicDashboardResponse?.data,
]);
return (
<Card className="dashboard-description-container">
<div className="dashboard-header">
@@ -333,11 +369,21 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
className="dashboard-title"
data-testid="dashboard-title"
>
{' '}
{title}
</Typography.Text>
</Tooltip>
{isDashboardLocked && <LockKeyhole size={14} />}
{isPublicDashboard && (
<Tooltip title="This dashboard is publicly accessible">
<Globe size={14} className="public-dashboard-icon" />
</Tooltip>
)}
{isDashboardLocked && (
<Tooltip title="This dashboard is locked">
<LockKeyhole size={14} className="lock-dashboard-icon" />
</Tooltip>
)}
</div>
<div className="right-section">
<DateTimeSelectionV2 showAutoRefresh hideShareModal />

View File

@@ -1,6 +1,5 @@
.settings-tabs {
.ant-tabs-nav-list {
width: 228px;
height: 32px;
flex-shrink: 0;
border-radius: 2px;
@@ -13,6 +12,10 @@
margin: 0px;
}
.ant-tabs-tab:not(:last-child) {
border-right: 1px solid var(--bg-slate-400) !important;
}
.overview-btn {
width: 114px;
display: flex;
@@ -27,6 +30,13 @@
justify-content: center;
}
.public-dashboard-btn {
width: 150px;
display: flex;
align-items: center;
justify-content: center;
}
.ant-tabs-ink-bar {
display: none;
}
@@ -41,6 +51,11 @@
border-radius: 2px 0px 0px 2px;
background: var(--bg-slate-400);
}
.public-dashboard-btn {
border-radius: 2px 0px 0px 2px;
background: var(--bg-slate-400);
}
}
}
@@ -63,6 +78,10 @@
.variables-btn {
background: var(--bg-vanilla-300);
}
.public-dashboard-btn {
background: var(--bg-vanilla-300);
}
}
}
}

View File

@@ -0,0 +1,132 @@
.public-dashboard-setting-container {
border-radius: 3px;
border: 1px solid var(--bg-slate-500);
padding: 16px !important;
.public-dashboard-setting-content {
display: flex;
flex-direction: column;
gap: 8px;
.public-dashboard-setting-content-title {
margin-bottom: 16px;
}
.timerange-enabled-checkbox {
margin-bottom: 8px;
}
.default-time-range-select {
margin-bottom: 8px;
.default-time-range-select-label {
margin-bottom: 4px;
.default-time-range-select-label-text {
font-size: 12px;
font-weight: 500;
}
}
.ant-select-selector {
display: flex;
height: 32px;
padding: 6px;
justify-content: center;
align-items: center;
border-radius: 2px 0px 0px 2px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
.ant-select-selection-item {
display: flex;
align-items: center;
.list-item-image {
height: 16px;
width: 16px;
}
}
}
}
.public-dashboard-url {
.url-label-container {
margin-bottom: 4px;
.url-label {
font-size: 12px;
font-weight: 500;
}
}
.url-container {
display: flex;
align-items: center;
gap: 8px;
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
border-radius: 4px;
padding: 0px 4px;
.url-text {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
}
}
.default-time-range-select-dropdown {
width: 200px;
}
.public-dashboard-setting-callout {
margin-top: 12px;
background: color-mix(in srgb, var(--bg-robin-500) 10%, transparent);
padding: 12px 8px;
border-radius: 3px;
.public-dashboard-setting-callout-text {
display: flex;
align-items: center;
gap: 8px;
font-size: 11px;
font-weight: 400;
color: var(--text-robin-300);
}
}
}
.public-dashboard-setting-actions {
margin-top: 32px;
display: flex;
gap: 8px;
justify-content: flex-end;
}
}
.lightMode {
.public-dashboard-setting-container {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.public-dashboard-setting-content {
.default-time-range-select {
.ant-select-selector {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
.public-dashboard-url {
.url-container {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
}
}
}
}
}

View File

@@ -0,0 +1,382 @@
import { toast } from '@signozhq/sonner';
import { fireEvent, within } from '@testing-library/react';
import { StatusCodes } from 'http-status-codes';
import {
publishedPublicDashboardMeta,
unpublishedPublicDashboardMeta,
} from 'mocks-server/__mockdata__/publicDashboard';
import { rest, server } from 'mocks-server/server';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCopyToClipboard } from 'react-use';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import PublicDashboardSetting from '../index';
// Mock dependencies
jest.mock('providers/Dashboard/Dashboard');
jest.mock('react-use', () => ({
...jest.requireActual('react-use'),
useCopyToClipboard: jest.fn(),
}));
jest.mock('@signozhq/sonner', () => ({
toast: {
success: jest.fn(),
error: jest.fn(),
},
}));
const mockUseDashboard = jest.mocked(useDashboard);
const mockUseCopyToClipboard = jest.mocked(useCopyToClipboard);
const mockToast = jest.mocked(toast);
// Test constants
const MOCK_DASHBOARD_ID = 'test-dashboard-id';
const MOCK_PUBLIC_PATH = '/public/dashboard/test-dashboard-id';
const DEFAULT_TIME_RANGE = '30m';
const DASHBOARD_VARIABLES_WARNING =
"Dashboard variables won't work in public dashboards";
// Use wildcard pattern to match both relative and absolute URLs in MSW
const publicDashboardURL = `*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`;
const mockSelectedDashboard = {
id: MOCK_DASHBOARD_ID,
data: {
title: 'Test Dashboard',
widgets: [],
layout: [],
panelMap: {},
variables: {},
},
};
beforeAll(() => {
server.listen();
});
afterAll(() => {
server.close();
});
const mockSetCopyPublicDashboardURL = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
// Mock window.open
window.open = jest.fn();
// Mock useDashboard
mockUseDashboard.mockReturnValue(({
selectedDashboard: mockSelectedDashboard,
} as unknown) as ReturnType<typeof useDashboard>);
// Mock useCopyToClipboard
mockUseCopyToClipboard.mockReturnValue(([
undefined,
mockSetCopyPublicDashboardURL,
] as unknown) as ReturnType<typeof useCopyToClipboard>);
});
afterEach(() => {
server.resetHandlers();
jest.clearAllMocks();
});
describe('PublicDashboardSetting', () => {
describe('Unpublished Dashboard', () => {
it('Unpublished dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(
ctx.status(StatusCodes.NOT_FOUND),
ctx.json(unpublishedPublicDashboardMeta),
),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is private. Publish it to make it accessible to anyone with the link./i,
),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('checkbox', { name: /enable time range/i }),
).toBeInTheDocument();
});
await waitFor(() => {
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
});
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(
screen.getByText(new RegExp(DASHBOARD_VARIABLES_WARNING, 'i')),
).toBeInTheDocument();
});
expect(
screen.getByRole('button', { name: /publish dashboard/i }),
).toBeInTheDocument();
});
});
describe('Published Dashboard', () => {
it('Published dashboard should be handled correctly', async () => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
render(<PublicDashboardSetting />);
await waitFor(() => {
expect(
screen.getByText(
/This dashboard is publicly accessible. Anyone with the link can view it./i,
),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('checkbox', { name: /enable time range/i }),
).toBeChecked();
});
await waitFor(() => {
expect(screen.getByText(/default time range/i)).toBeInTheDocument();
});
expect(screen.getByText(/Last 30 minutes/i)).toBeInTheDocument();
await waitFor(() => {
expect(screen.getByText(/Public Dashboard URL/i)).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('button', { name: /update published dashboard/i }),
).toBeInTheDocument();
});
await waitFor(() => {
expect(
screen.getByRole('button', { name: /unpublish dashboard/i }),
).toBeInTheDocument();
});
});
});
describe('Time Range Settings', () => {
beforeEach(() => {
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
);
});
it('should toggle time range enabled when checkbox is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
render(<PublicDashboardSetting />);
// Wait for checkbox to be rendered and verify initial state
const checkbox = await screen.findByRole('checkbox', {
name: /enable time range/i,
});
expect(checkbox).toBeChecked();
await user.click(checkbox);
await waitFor(() => {
expect(checkbox).not.toBeChecked();
});
});
it('should update default time range when select value changes', async () => {
render(<PublicDashboardSetting />);
const selectContainer = await screen.findByTestId(
'default-time-range-select-dropdown',
);
const combobox = within(selectContainer).getByRole('combobox');
fireEvent.mouseDown(combobox);
await screen.findByRole('listbox');
const option = await screen.findByText(/Last 1 hour/i, {
selector: '.ant-select-item-option-content',
});
fireEvent.click(option);
await waitFor(() => {
expect(
within(selectContainer).getByText(/Last 1 hour/i),
).toBeInTheDocument();
});
});
});
describe('Create Public Dashboard', () => {
it('should call create API when publish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let createApiCalled = false;
server.use(
rest.get(publicDashboardURL, (_req, res, ctx) =>
res(
ctx.status(StatusCodes.OK),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: '',
},
}),
),
),
rest.post(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
createApiCalled = true;
expect(body).toEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
return res(
ctx.status(StatusCodes.CREATED),
ctx.json({
data: {
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
publicPath: MOCK_PUBLIC_PATH,
},
}),
);
}),
);
render(<PublicDashboardSetting />);
// Find and click publish button
const publishButton = await screen.findByRole('button', {
name: /publish dashboard/i,
});
await user.click(publishButton);
await waitFor(() => {
expect(createApiCalled).toBe(true);
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard created successfully',
);
});
});
});
describe('Update Public Dashboard', () => {
it('should call update API when update button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let updateApiCalled = false;
let capturedRequestBody: {
timeRangeEnabled: boolean;
defaultTimeRange: string;
} | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.put(publicDashboardURL, async (req, res, ctx) => {
const body = await req.json();
updateApiCalled = true;
capturedRequestBody = body;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const updateButton = await screen.findByRole(
'button',
{ name: /update published dashboard/i },
{ timeout: 5000 },
);
await user.click(updateButton);
await waitFor(() => {
expect(updateApiCalled).toBe(true);
expect(capturedRequestBody).toEqual({
timeRangeEnabled: true,
defaultTimeRange: DEFAULT_TIME_RANGE,
});
expect(mockToast.success).toHaveBeenCalledWith(
'Public dashboard updated successfully',
);
});
});
});
describe('Revoke Public Dashboard Access', () => {
it('should call revoke API when unpublish button is clicked', async () => {
const user = userEvent.setup({ pointerEventsCheck: 0 });
let revokeApiCalled = false;
let capturedDashboardId: string | null = null;
server.use(
rest.get(
`*/api/v1/dashboards/${MOCK_DASHBOARD_ID}/public`,
(_req, res, ctx) =>
res(ctx.status(StatusCodes.OK), ctx.json(publishedPublicDashboardMeta)),
),
rest.delete(publicDashboardURL, (req, res, ctx) => {
revokeApiCalled = true;
// Extract dashboard ID from URL: /api/v1/dashboards/{id}/public
const urlMatch = req.url.pathname.match(
/\/api\/v1\/dashboards\/([^/]+)\/public/,
);
capturedDashboardId = urlMatch ? urlMatch[1] : null;
return res(ctx.status(StatusCodes.NO_CONTENT), ctx.json({}));
}),
);
render(<PublicDashboardSetting />);
// Wait for API response and component update
const unpublishButton = await screen.findByRole(
'button',
{ name: /unpublish dashboard/i },
{ timeout: 5000 },
);
await user.click(unpublishButton);
await waitFor(() => {
expect(revokeApiCalled).toBe(true);
expect(capturedDashboardId).toBe(MOCK_DASHBOARD_ID);
expect(mockToast.success).toHaveBeenCalledWith(
'Dashboard unpublished successfully',
);
});
});
});
});

View File

@@ -0,0 +1,338 @@
/* eslint-disable import/no-extraneous-dependencies */
import './PublicDashboard.styles.scss';
import { Checkbox } from '@signozhq/checkbox';
import { toast } from '@signozhq/sonner';
import { Button, Select, Typography } from 'antd';
import createPublicDashboardAPI from 'api/dashboard/public/createPublicDashboard';
import revokePublicDashboardAccessAPI from 'api/dashboard/public/revokePublicDashboardAccess';
import updatePublicDashboardAPI from 'api/dashboard/public/updatePublicDashboard';
import { useGetPublicDashboardMeta } from 'hooks/dashboard/useGetPublicDashboardMeta';
import { Copy, ExternalLink, Globe, Info, Loader2, Trash } from 'lucide-react';
import { useDashboard } from 'providers/Dashboard/Dashboard';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useMutation } from 'react-query';
import { useCopyToClipboard } from 'react-use';
import { PublicDashboardMetaProps } from 'types/api/dashboard/public/getMeta';
export const TIME_RANGE_PRESETS_OPTIONS = [
{
label: 'Last 5 minutes',
value: '5m',
},
{
label: 'Last 15 minutes',
value: '15m',
},
{
label: 'Last 30 minutes',
value: '30m',
},
{
label: 'Last 1 hour',
value: '1h',
},
{
label: 'Last 6 hours',
value: '6h',
},
{
label: 'Last 1 day',
value: '24h',
},
];
function PublicDashboardSetting(): JSX.Element {
const [publicDashboardData, setPublicDashboardData] = useState<
PublicDashboardMetaProps | undefined
>(undefined);
const [timeRangeEnabled, setTimeRangeEnabled] = useState(true);
const [defaultTimeRange, setDefaultTimeRange] = useState('30m');
const [, setCopyPublicDashboardURL] = useCopyToClipboard();
const { selectedDashboard } = useDashboard();
const handleDefaultTimeRange = useCallback((value: string): void => {
setDefaultTimeRange(value);
}, []);
const handleTimeRangeEnabled = useCallback((): void => {
setTimeRangeEnabled((prev) => !prev);
}, []);
const {
data: publicDashboardResponse,
isLoading: isLoadingPublicDashboard,
isFetching: isFetchingPublicDashboard,
refetch: refetchPublicDashboard,
error: errorPublicDashboard,
} = useGetPublicDashboardMeta(selectedDashboard?.id || '');
const isPublicDashboardEnabled = !!publicDashboardData?.publicPath;
useEffect(() => {
if (publicDashboardResponse?.data) {
setPublicDashboardData(publicDashboardResponse?.data);
}
if (errorPublicDashboard) {
console.error('Error getting public dashboard', errorPublicDashboard);
setPublicDashboardData(undefined);
setTimeRangeEnabled(true);
setDefaultTimeRange('30m');
}
}, [publicDashboardResponse, errorPublicDashboard]);
useEffect(() => {
if (publicDashboardResponse?.data) {
setTimeRangeEnabled(
publicDashboardResponse?.data?.timeRangeEnabled || false,
);
setDefaultTimeRange(
publicDashboardResponse?.data?.defaultTimeRange || '30m',
);
}
}, [publicDashboardResponse]);
const {
mutate: createPublicDashboard,
isLoading: isLoadingCreatePublicDashboard,
data: createPublicDashboardResponse,
} = useMutation(createPublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard created successfully');
},
onError: () => {
toast.error('Failed to create public dashboard');
},
});
const {
mutate: updatePublicDashboard,
isLoading: isLoadingUpdatePublicDashboard,
data: updatePublicDashboardResponse,
} = useMutation(updatePublicDashboardAPI, {
onSuccess: () => {
toast.success('Public dashboard updated successfully');
},
onError: () => {
toast.error('Failed to update public dashboard');
},
});
const {
mutate: revokePublicDashboardAccess,
isLoading: isLoadingRevokePublicDashboardAccess,
data: revokePublicDashboardAccessResponse,
} = useMutation(revokePublicDashboardAccessAPI, {
onSuccess: () => {
toast.success('Dashboard unpublished successfully');
},
onError: () => {
toast.error('Failed to unpublish dashboard');
},
});
const handleCreatePublicDashboard = (): void => {
if (!selectedDashboard) return;
createPublicDashboard({
dashboardId: selectedDashboard.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleUpdatePublicDashboard = (): void => {
if (!selectedDashboard) return;
updatePublicDashboard({
dashboardId: selectedDashboard.id,
timeRangeEnabled,
defaultTimeRange,
});
};
const handleRevokePublicDashboardAccess = (): void => {
if (!selectedDashboard) return;
revokePublicDashboardAccess({
id: selectedDashboard.id,
});
};
useEffect(() => {
if (
(createPublicDashboardResponse &&
createPublicDashboardResponse.httpStatusCode === 201) ||
(updatePublicDashboardResponse &&
updatePublicDashboardResponse.httpStatusCode === 204) ||
(revokePublicDashboardAccessResponse &&
revokePublicDashboardAccessResponse.httpStatusCode === 204)
) {
refetchPublicDashboard();
}
}, [
createPublicDashboardResponse,
updatePublicDashboardResponse,
revokePublicDashboardAccessResponse,
refetchPublicDashboard,
]);
const handleCopyPublicDashboardURL = (): void => {
if (!publicDashboardResponse?.data?.publicPath) return;
try {
setCopyPublicDashboardURL(
`${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
);
toast.success('Copied Public Dashboard URL successfully');
} catch (error) {
console.error('Error copying public dashboard URL', error);
}
};
const publicDashboardURL = useMemo(
() => `${window.location.origin}${publicDashboardResponse?.data?.publicPath}`,
[publicDashboardResponse],
);
const isLoading =
isLoadingCreatePublicDashboard ||
isLoadingUpdatePublicDashboard ||
isLoadingRevokePublicDashboardAccess ||
isLoadingPublicDashboard;
return (
<div className="public-dashboard-setting-container">
<div className="public-dashboard-setting-content">
<Typography.Title
level={5}
className="public-dashboard-setting-content-title"
>
{isPublicDashboardEnabled
? 'This dashboard is publicly accessible. Anyone with the link can view it.'
: 'This dashboard is private. Publish it to make it accessible to anyone with the link.'}
</Typography.Title>
<div className="timerange-enabled-checkbox">
<Checkbox
id="enable-time-range"
checked={timeRangeEnabled}
onCheckedChange={handleTimeRangeEnabled}
labelName="Enable time range"
/>
</div>
<div className="default-time-range-select">
<div className="default-time-range-select-label">
<Typography.Text className="default-time-range-select-label-text">
Default time range
</Typography.Text>
</div>
<Select
placeholder="Select default time range"
options={TIME_RANGE_PRESETS_OPTIONS}
value={defaultTimeRange}
onChange={handleDefaultTimeRange}
data-testid="default-time-range-select-dropdown"
className="default-time-range-select-dropdown"
/>
</div>
{isPublicDashboardEnabled && (
<div className="public-dashboard-url">
<div className="url-label-container">
<Typography.Text className="url-label">
Public Dashboard URL
</Typography.Text>
</div>
<div className="url-container">
<Typography.Text className="url-text">
{publicDashboardURL}
</Typography.Text>
<Button
type="link"
className="url-copy-btn periscope-btn ghost"
icon={<Copy size={12} />}
onClick={handleCopyPublicDashboardURL}
/>
<Button
type="link"
className="periscope-btn ghost"
icon={<ExternalLink size={12} />}
onClick={(): void => {
if (publicDashboardURL) {
window.open(publicDashboardURL, '_blank');
}
}}
/>
</div>
</div>
)}
<div className="public-dashboard-setting-callout">
<Typography.Text className="public-dashboard-setting-callout-text">
<Info size={12} className="public-dashboard-setting-callout-icon" />{' '}
Dashboard variables won&apos;t work in public dashboards
</Typography.Text>
</div>
<div className="public-dashboard-setting-actions">
{!isPublicDashboardEnabled ? (
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading}
onClick={handleCreatePublicDashboard}
loading={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard
}
icon={
isLoadingCreatePublicDashboard ||
isFetchingPublicDashboard ||
isLoadingPublicDashboard ? (
<Loader2 className="animate-spin" size={14} />
) : (
<Globe size={14} />
)
}
>
Publish dashboard
</Button>
) : (
<>
<Button
type="default"
className="periscope-btn secondary"
disabled={isLoading}
onClick={handleRevokePublicDashboardAccess}
loading={isLoadingRevokePublicDashboardAccess}
icon={<Trash size={14} />}
>
Unpublish dashboard
</Button>
<Button
type="primary"
className="create-public-dashboard-btn periscope-btn primary"
disabled={isLoading}
onClick={handleUpdatePublicDashboard}
loading={isLoadingUpdatePublicDashboard}
icon={<Globe size={14} />}
>
Update published dashboard
</Button>
</>
)}
</div>
</div>
</div>
);
}
export default PublicDashboardSetting;

View File

@@ -1,9 +1,10 @@
import './DashboardSettingsContent.styles.scss';
import { Button, Tabs } from 'antd';
import { Braces, Table } from 'lucide-react';
import { Braces, Globe, Table } from 'lucide-react';
import GeneralDashboardSettings from './General';
import PublicDashboardSetting from './PublicDashboard';
import VariablesSetting from './Variables';
function DashboardSettingsContent({
@@ -30,6 +31,19 @@ function DashboardSettingsContent({
key: 'variables',
children: <VariablesSetting variableViewModeRef={variableViewModeRef} />,
},
{
label: (
<Button
type="text"
icon={<Globe size={14} />}
className="public-dashboard-btn"
>
Publish
</Button>
),
key: 'public-dashboard',
children: <PublicDashboardSetting />,
},
];
return <Tabs items={items} animated className="settings-tabs" />;

View File

@@ -1,12 +1,17 @@
import { Checkbox, Empty } from 'antd';
import { AxiosResponse } from 'axios';
import Spinner from 'components/Spinner';
import { EXCLUDED_COLUMNS } from 'container/OptionsMenu/constants';
import { QueryKeySuggestionsResponseProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder';
type ExplorerAttributeColumnsProps = {
isLoading: boolean;
data: any;
data: AxiosResponse<QueryKeySuggestionsResponseProps> | undefined;
searchText: string;
isAttributeKeySelected: (key: string) => boolean;
handleCheckboxChange: (key: string) => void;
dataSource: DataSource;
};
function ExplorerAttributeColumns({
@@ -15,6 +20,7 @@ function ExplorerAttributeColumns({
searchText,
isAttributeKeySelected,
handleCheckboxChange,
dataSource,
}: ExplorerAttributeColumnsProps): JSX.Element {
if (isLoading) {
return (
@@ -27,8 +33,10 @@ function ExplorerAttributeColumns({
const filteredAttributeKeys =
Object.values(data?.data?.data?.keys || {})
?.flat()
?.filter((attributeKey: any) =>
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()),
?.filter(
(attributeKey) =>
attributeKey.name.toLowerCase().includes(searchText.toLowerCase()) &&
!EXCLUDED_COLUMNS[dataSource].includes(attributeKey.name),
) || [];
if (filteredAttributeKeys.length === 0) {
return (

View File

@@ -183,6 +183,7 @@ function ExplorerColumnsRenderer({
searchText={searchText}
isAttributeKeySelected={isAttributeKeySelected}
handleCheckboxChange={handleCheckboxChange}
dataSource={initialDataSource}
/>
),
},

View File

@@ -450,4 +450,58 @@ describe('ExplorerColumnsRenderer', () => {
}
});
});
it('does not show isRoot or isEntryPoint in add column dropdown (traces, dashboard table panel)', async () => {
(useQueryBuilder as jest.Mock).mockReturnValue({
currentQuery: {
builder: {
queryData: [
{
dataSource: DataSource.TRACES,
aggregateOperator: 'count',
},
],
},
},
});
(useGetQueryKeySuggestions as jest.Mock).mockReturnValue({
data: {
data: {
data: {
keys: {
attributeKeys: [
{ name: 'isRoot', dataType: 'bool', type: '' },
{ name: 'isEntryPoint', dataType: 'bool', type: '' },
{ name: 'duration', dataType: 'number', type: '' },
{ name: 'serviceName', dataType: 'string', type: '' },
],
},
},
},
},
isLoading: false,
isError: false,
});
render(
<Wrapper>
<ExplorerColumnsRenderer
selectedLogFields={[]}
setSelectedLogFields={mockSetSelectedLogFields}
selectedTracesFields={[]}
setSelectedTracesFields={mockSetSelectedTracesFields}
/>
</Wrapper>,
);
await userEvent.click(screen.getByTestId('add-columns-button'));
// Visible columns should appear
expect(screen.getByText('duration')).toBeInTheDocument();
expect(screen.getByText('serviceName')).toBeInTheDocument();
// Hidden columns should NOT appear
expect(screen.queryByText('isRoot')).not.toBeInTheDocument();
expect(screen.queryByText('isEntryPoint')).not.toBeInTheDocument();
});
});

View File

@@ -12,10 +12,7 @@ import {
Switch,
Typography,
} from 'antd';
import {
PrecisionOption,
PrecisionOptionsEnum,
} from 'components/Graph/yAxisConfig';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import TimePreference from 'components/TimePreferenceDropDown';
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
import GraphTypes, {

View File

@@ -4,10 +4,7 @@ import './NewWidget.styles.scss';
import { WarningOutlined } from '@ant-design/icons';
import { Button, Flex, Modal, Space, Typography } from 'antd';
import logEvent from 'api/common/logEvent';
import {
PrecisionOption,
PrecisionOptionsEnum,
} from 'components/Graph/yAxisConfig';
import { PrecisionOption, PrecisionOptionsEnum } from 'components/Graph/types';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { adjustQueryForV5 } from 'components/QueryBuilderV2/utils';
import { QueryParams } from 'constants/query';

View File

@@ -1,6 +1,6 @@
import { DefaultOptionType } from 'antd/es/select';
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
import { PrecisionOptionsEnum } from 'components/Graph/yAxisConfig';
import { PrecisionOptionsEnum } from 'components/Graph/types';
import {
initialQueryBuilderFormValuesMap,
PANEL_TYPES,

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