Compare commits

..

23 Commits

Author SHA1 Message Date
Nikhil Soni
18b542b8c5 fix: use absolute path for commit smg lint
If project dir is present inside another repo in user's local
setup, using relative path can create problem in finding the
.git path since this command also changes the directory to
frontend.
Also removes --edit flag since it's already present in
yarn command commitlint in frontend/package.json
2026-01-27 13:51:54 +05:30
Nikhil Soni
8a8550ac85 fix: change clickhouse volume path on host to docker
Clickhouse tries to own the /var/lib/clickhouse dir and
if it's mounted on a host machine path, the chown command
in clickhouse startup fails with below error:
chown: changing ownership of '/var/lib/clickhouse/': Permission denied

This change uses docker managed volume where permissions are auto managed.
I'm using colima instead of docker desktop on mac, so problem might
be specific to colima setup but fix should be generic.
https://github.com/abiosoft/colima
2026-01-27 13:51:54 +05:30
Yunus M
8629c959f0 chore: move types, constants to separate files, delete unused code (#10026)
* chore: move types, constants to separate files, delete unused code

* chore: fix import error
2026-01-21 08:42:11 +00:00
Yunus M
10760e6e1b Update pull_request_template.md (#10064)
Update PR template to include 

Before / After Screenshots
Issues closed by PR
2026-01-21 13:52:15 +05:30
primus-bot[bot]
4f45645b32 chore(release): bump to v0.108.0 (#10065)
Co-authored-by: primus-bot[bot] <171087277+primus-bot[bot]@users.noreply.github.com>
2026-01-21 12:13:18 +05:30
Karan Balani
1417e22ae4 fix: use reliable selenium methods for actions in integration tests (#10061) 2026-01-21 06:09:42 +00:00
Pandey
3051d442c0 fix: move ee references out of cmd/community (#10063)
- move ee references out of cmd/community
- add check in commitci
2026-01-21 09:22:40 +05:30
Karan Balani
ea15ce4e04 feat: sso stats reporting (#10062)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
2026-01-20 18:57:35 +00:00
Ashwin Bhatkal
865a7a5a31 fix: promql and clickhouse query based panels refresh on dynamic variable change (#10060)
* fix: promql and clickhouse query based panels refresh on dynamic variable change

* chore: add test
2026-01-20 17:19:58 +00:00
swapnil-signoz
de4ca50a40 refactor: using global config's ingestion URL (#10019)
* refactor: using global config's ingestion URL

* refactor: add global ingestion URL to configuration

---------

Co-authored-by: Vikrant Gupta <vikrant@signoz.io>
2026-01-20 17:05:56 +00:00
Amlan Kumar Nandy
8cabaafc58 fix: handle threshold unit scaling issues (#10020) 2026-01-20 16:22:49 +00:00
Ashwin Bhatkal
e9d66b8094 chore: update yarn.lock (#10051) 2026-01-20 16:07:05 +00:00
Karan Balani
26d3d6b1e4 feat: gateway apis (#10010) 2026-01-20 15:46:46 +00:00
Ashwin Bhatkal
36d6debeab chore: update .gitignore with only settings.json of .vscode folder (#10058) 2026-01-20 13:54:58 +00:00
Pandey
445b0cace8 chore: add codeowners for scaffold (#10055) 2026-01-20 12:19:14 +00:00
Pandey
132f10f8a3 feat(binding): add support for query params (#10053)
- add support for query params in the binding package.
2026-01-20 11:59:12 +00:00
Srikanth Chekuri
14011bc277 fix: do not sort in descending locally if the other is explicitly spe… (#10033) 2026-01-20 11:17:08 +00:00
aniketio-ctrl
f17a332c23 feat(license-section): add section to view license key (#10039)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
* feat(license-section): added section to view license key

* feat(license-key): add license key page

* feat(license-section): added section to view license key

* feat(license-section): added section to view license key

* feat(license-section): added section to view license key

* feat(license-section): added section to view license key

* feat(license-section): resoved comments

* feat(license-section): resoved fmt error
2026-01-20 16:05:57 +05:30
Aditya Singh
5ae7a464e6 Fix Cmd/ctrl + Enter in qb to run query (#10048)
* feat: cmd enter in qb

* feat: cnd enter test added

* feat: update test case

* feat: update test case

* feat: minor refactor

---------

Co-authored-by: Srikanth Chekuri <srikanth.chekuri92@gmail.com>
2026-01-20 13:30:54 +05:30
Pandey
51c3628f6e fix(signoz): remove version check at the beginning (#10046) 2026-01-20 06:56:06 +00:00
Aditya Singh
6a69076828 Add attribute action in common col of span details drawer (#10017)
* feat: add attribute action in common col of span details drawer

* feat: added test case
2026-01-20 05:44:31 +00:00
Aditya Singh
edd04e2f07 fix: fix auto collapse fields when emptied (#9988)
* fix: fix auto collapse fields when emptied

* fix: revert mocks

* fix: minor fix

* fix: add check for key count change to setSelectedView

* feat: revert change

* feat: instead of count check actual keys
2026-01-20 05:32:13 +00:00
Srikanth Chekuri
ee734cf78c chore: return original error message with hints for invalid promql query (#10034)
Some checks failed
build-staging / prepare (push) Has been cancelled
build-staging / js-build (push) Has been cancelled
build-staging / go-build (push) Has been cancelled
build-staging / staging (push) Has been cancelled
Release Drafter / update_release_draft (push) Has been cancelled
one step towards better experience for https://github.com/SigNoz/signoz/issues/9764
2026-01-20 03:04:59 +05:30
150 changed files with 5003 additions and 1636 deletions

View File

@@ -5,8 +5,8 @@ services:
volumes:
- ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml
- ${PWD}/fs/etc/clickhouse-server/users.d/users.xml:/etc/clickhouse-server/users.d/users.xml
- ${PWD}/fs/tmp/var/lib/clickhouse/:/var/lib/clickhouse/
- ${PWD}/fs/tmp/var/lib/clickhouse/user_scripts/:/var/lib/clickhouse/user_scripts/
- clickhouse_data:/var/lib/clickhouse/
ports:
- '127.0.0.1:8123:8123'
- '127.0.0.1:9000:9000'
@@ -69,3 +69,5 @@ services:
schema-migrator-sync:
condition: service_completed_successfully
restart: on-failure
volumes:
clickhouse_data:

9
.github/CODEOWNERS vendored
View File

@@ -55,7 +55,6 @@
/pkg/telemetrymetrics/ @srikanthccv
/pkg/telemetrytraces/ @srikanthccv
# Metrics
/pkg/types/metrictypes/ @srikanthccv
@@ -91,6 +90,14 @@
# AuthN / AuthZ Owners
/pkg/authz/ @vikrantgupta25
/ee/authz/ @vikrantgupta25
/pkg/authn/ @vikrantgupta25
/ee/authn/ @vikrantgupta25
/pkg/modules/user/ @vikrantgupta25
/pkg/modules/session/ @vikrantgupta25
/pkg/modules/organization/ @vikrantgupta25
/pkg/modules/authdomain/ @vikrantgupta25
/pkg/modules/role/ @vikrantgupta25
# Integration tests

View File

@@ -6,6 +6,15 @@
> Why does this change exist?
> What problem does it solve, and why is this the right approach?
#### Screenshots / Screen Recordings (if applicable)
> Include screenshots or screen recordings that clearly show the behavior before the change and the result after the change. This helps reviewers quickly understand the impact and verify the update.
#### Issues closed by this PR
> Reference issues using `Closes #issue-number` to enable automatic closure on merge.
---
### ✅ Change Type

View File

@@ -25,3 +25,10 @@ jobs:
else
echo "No references to 'ee' packages found in 'pkg' directory"
fi
if grep -R --include="*.go" '.*/ee/.*' cmd/community/; then
echo "Error: Found references to 'ee' packages in 'cmd/community' directory"
exit 1
else
echo "No references to 'ee' packages found in 'cmd/community' directory"
fi

View File

@@ -84,8 +84,11 @@ jobs:
sudo rm /etc/apt/sources.list.d/google-chrome.list
export CHROMEDRIVER_VERSION=`curl -s https://googlechromelabs.github.io/chrome-for-testing/LATEST_RELEASE_${CHROME_MAJOR_VERSION%%.*}`
curl -L -O "https://storage.googleapis.com/chrome-for-testing-public/${CHROMEDRIVER_VERSION}/linux64/chromedriver-linux64.zip"
unzip chromedriver-linux64.zip && chmod +x chromedriver && sudo mv chromedriver /usr/local/bin
unzip chromedriver-linux64.zip
chmod +x chromedriver-linux64/chromedriver
sudo mv chromedriver-linux64/chromedriver /usr/local/bin/chromedriver
chromedriver -version
google-chrome-stable --version
- name: run
run: |
cd tests/integration && \

4
.gitignore vendored
View File

@@ -1,6 +1,9 @@
node_modules
.vscode
!.vscode/settings.json
deploy/docker/environment_tiny/common_test
frontend/node_modules
frontend/.pnp
@@ -104,7 +107,6 @@ dist/
downloads/
eggs/
.eggs/
lib/
lib64/
parts/
sdist/

62
.vscode/launch.json vendored
View File

@@ -1,62 +0,0 @@
{
"configurations": [
{
"name": "enterprise",
"type": "go",
"request": "launch",
"mode": "auto",
"buildFlags": [
"-race",
"-ldflags=-X github.com/SigNoz/signoz/pkg/version.version=dev -X github.com/SigNoz/signoz/pkg/version.variant=enterprise -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud"
],
"program": "${workspaceFolder}/cmd/enterprise/",
"args": ["server"],
"env": {
"SIGNOZ_VERSION_BANNER_ENABLED": "true",
"SIGNOZ_INSTRUMENTATION_LOGS_LEVEL": "debug",
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
"SIGNOZ_SQLSTORE_SQLITE_PATH": "${workspaceFolder}/.dev/data/sqlite/enterprise.db",
"SIGNOZ_WEB_ENABLED": "false",
"SIGNOZ_SQLMIGRATOR_LOCK_INTERVAL": "1m",
"SIGNOZ_ALERTMANAGER_PROVIDER": "signoz",
"SIGNOZ_TELEMETRYSTORE_PROVIDER": "clickhouse",
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": "tcp://0.0.0.0:9001",
"SIGNOZ_PROMETHEUS_ACTIVE__QUERY__TRACKER_ENABLED": "false",
"SIGNOZ_EMAILING_ENABLED": "false",
"DOT_METRICS_ENABLED": "true",
"SIGNOZ_GLOBAL_INGESTION__URL": "http://localhost:3001",
"SIGNOZ_TOKENIZER_PROVIDER": "opaque"
}
},
{
"name": "community",
"type": "go",
"request": "launch",
"mode": "auto",
"buildFlags": [
"-race",
"-ldflags=-X github.com/SigNoz/signoz/pkg/version.version=dev -X github.com/SigNoz/signoz/pkg/version.variant=community -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud"
],
"program": "${workspaceFolder}/cmd/community/",
"args": ["server"],
"env": {
"SIGNOZ_VERSION_BANNER_ENABLED": "true",
"SIGNOZ_INSTRUMENTATION_LOGS_LEVEL": "debug",
"SIGNOZ_SQLSTORE_PROVIDER": "sqlite",
"SIGNOZ_SQLSTORE_SQLITE_PATH": "${workspaceFolder}/.dev/data/sqlite/community.db",
"SIGNOZ_WEB_ENABLED": "false",
"SIGNOZ_SQLMIGRATOR_LOCK_INTERVAL": "1m",
"SIGNOZ_ALERTMANAGER_PROVIDER": "signoz",
"SIGNOZ_TELEMETRYSTORE_PROVIDER": "clickhouse",
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER": "cluster",
"SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN": "tcp://0.0.0.0:9001",
"SIGNOZ_PROMETHEUS_ACTIVE__QUERY__TRACKER_ENABLED": "false",
"SIGNOZ_EMAILING_ENABLED": "false",
"DOT_METRICS_ENABLED": "true",
"SIGNOZ_GLOBAL_INGESTION__URL": "http://localhost:3001",
"SIGNOZ_TOKENIZER_PROVIDER": "opaque"
}
}
]
}

View File

@@ -5,13 +5,14 @@ import (
"log/slog"
"github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/authz/openfgaauthz"
"github.com/SigNoz/signoz/pkg/authz/openfgaschema"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/gateway/noopgateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
@@ -24,7 +25,6 @@ import (
"github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/sqlschema"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/SigNoz/signoz/pkg/sqlstore/sqlstorehook"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/version"
"github.com/SigNoz/signoz/pkg/zeus"
@@ -57,13 +57,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
// print the version
version.Info.PrettyPrint(config.Version)
// add enterprise sqlstore factories to the community sqlstore factories
sqlstoreFactories := signoz.NewSQLStoreProviderFactories()
if err := sqlstoreFactories.Add(postgressqlstore.NewFactory(sqlstorehook.NewLoggingFactory())); err != nil {
logger.ErrorContext(ctx, "failed to add postgressqlstore factory", "error", err)
return err
}
signoz, err := signoz.New(
ctx,
config,
@@ -90,6 +83,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, _ role.Module, queryParser queryparser.QueryParser, _ querier.Querier, _ licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(impldashboard.NewStore(store), settings, analytics, orgGetter, queryParser)
},
func(_ licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return noopgateway.NewProviderFactory()
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
"github.com/SigNoz/signoz/ee/gateway/httpgateway"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing"
"github.com/SigNoz/signoz/ee/modules/dashboard/impldashboard"
@@ -22,6 +23,7 @@ import (
"github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/dashboard"
pkgimpldashboard "github.com/SigNoz/signoz/pkg/modules/dashboard/impldashboard"
@@ -120,6 +122,9 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(store sqlstore.SQLStore, settings factory.ProviderSettings, analytics analytics.Analytics, orgGetter organization.Getter, role role.Module, queryParser queryparser.QueryParser, querier querier.Querier, licensing licensing.Licensing) dashboard.Module {
return impldashboard.NewModule(pkgimpldashboard.NewStore(store), settings, analytics, orgGetter, role, queryParser, querier, licensing)
},
func(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return httpgateway.NewProviderFactory(licensing)
},
)
if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err)

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

View File

@@ -2067,6 +2067,361 @@ paths:
summary: Get features
tags:
- features
/api/v2/gateway/ingestion_keys:
get:
deprecated: false
description: This endpoint returns the ingestion keys for a workspace
operationId: GetIngestionKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableIngestionKeys'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Get ingestion keys for workspace
tags:
- gateway
post:
deprecated: false
description: This endpoint creates an ingestion key for the workspace
operationId: CreateIngestionKey
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKey'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableCreatedIngestionKey'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create ingestion key for workspace
tags:
- gateway
/api/v2/gateway/ingestion_keys/{keyId}:
delete:
deprecated: false
description: This endpoint deletes an ingestion key for the workspace
operationId: DeleteIngestionKey
parameters:
- in: path
name: keyId
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete ingestion key for workspace
tags:
- gateway
patch:
deprecated: false
description: This endpoint updates an ingestion key for the workspace
operationId: UpdateIngestionKey
parameters:
- in: path
name: keyId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKey'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update ingestion key for workspace
tags:
- gateway
/api/v2/gateway/ingestion_keys/{keyId}/limits:
post:
deprecated: false
description: This endpoint creates an ingestion key limit
operationId: CreateIngestionKeyLimit
parameters:
- in: path
name: keyId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesPostableIngestionKeyLimit'
responses:
"201":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableCreatedIngestionKeyLimit'
status:
type: string
type: object
description: Created
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Create limit for the ingestion key
tags:
- gateway
/api/v2/gateway/ingestion_keys/limits/{limitId}:
delete:
deprecated: false
description: This endpoint deletes an ingestion key limit
operationId: DeleteIngestionKeyLimit
parameters:
- in: path
name: limitId
required: true
schema:
type: string
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Delete limit for the ingestion key
tags:
- gateway
patch:
deprecated: false
description: This endpoint updates an ingestion key limit
operationId: UpdateIngestionKeyLimit
parameters:
- in: path
name: limitId
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/GatewaytypesUpdatableIngestionKeyLimit'
responses:
"204":
description: No Content
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Update limit for the ingestion key
tags:
- gateway
/api/v2/gateway/ingestion_keys/search:
get:
deprecated: false
description: This endpoint returns the ingestion keys for a workspace
operationId: SearchIngestionKeys
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/GatewaytypesGettableIngestionKeys'
status:
type: string
type: object
description: OK
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- ADMIN
- tokenizer:
- ADMIN
summary: Search ingestion keys for workspace
tags:
- gateway
/api/v2/metric/alerts:
get:
deprecated: false
@@ -3051,6 +3406,160 @@ components:
nullable: true
type: object
type: object
GatewaytypesGettableCreatedIngestionKey:
properties:
id:
type: string
value:
type: string
type: object
GatewaytypesGettableCreatedIngestionKeyLimit:
properties:
id:
type: string
type: object
GatewaytypesGettableIngestionKeys:
properties:
_pagination:
$ref: '#/components/schemas/GatewaytypesPagination'
keys:
items:
$ref: '#/components/schemas/GatewaytypesIngestionKey'
nullable: true
type: array
type: object
GatewaytypesIngestionKey:
properties:
created_at:
format: date-time
type: string
expires_at:
format: date-time
type: string
id:
type: string
limits:
items:
$ref: '#/components/schemas/GatewaytypesLimit'
nullable: true
type: array
name:
type: string
tags:
items:
type: string
nullable: true
type: array
updated_at:
format: date-time
type: string
value:
type: string
workspace_id:
type: string
type: object
GatewaytypesLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
created_at:
format: date-time
type: string
id:
type: string
key_id:
type: string
metric:
$ref: '#/components/schemas/GatewaytypesLimitMetric'
signal:
type: string
tags:
items:
type: string
nullable: true
type: array
updated_at:
format: date-time
type: string
type: object
GatewaytypesLimitConfig:
properties:
day:
$ref: '#/components/schemas/GatewaytypesLimitValue'
second:
$ref: '#/components/schemas/GatewaytypesLimitValue'
type: object
GatewaytypesLimitMetric:
properties:
day:
$ref: '#/components/schemas/GatewaytypesLimitMetricValue'
second:
$ref: '#/components/schemas/GatewaytypesLimitMetricValue'
type: object
GatewaytypesLimitMetricValue:
properties:
count:
format: int64
type: integer
size:
format: int64
type: integer
type: object
GatewaytypesLimitValue:
properties:
count:
format: int64
type: integer
size:
format: int64
type: integer
type: object
GatewaytypesPagination:
properties:
page:
type: integer
pages:
type: integer
per_page:
type: integer
total:
type: integer
type: object
GatewaytypesPostableIngestionKey:
properties:
expires_at:
format: date-time
type: string
name:
type: string
tags:
items:
type: string
nullable: true
type: array
type: object
GatewaytypesPostableIngestionKeyLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
signal:
type: string
tags:
items:
type: string
nullable: true
type: array
type: object
GatewaytypesUpdatableIngestionKeyLimit:
properties:
config:
$ref: '#/components/schemas/GatewaytypesLimitConfig'
tags:
items:
type: string
nullable: true
type: array
type: object
MetricsexplorertypesMetricAlert:
properties:
alertId:

View File

@@ -0,0 +1,282 @@
package httpgateway
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/url"
"strconv"
"time"
"github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/gateway"
"github.com/SigNoz/signoz/pkg/http/client"
"github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/types/gatewaytypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/tidwall/gjson"
)
type Provider struct {
settings factory.ScopedProviderSettings
config gateway.Config
httpClient *client.Client
licensing licensing.Licensing
}
func NewProviderFactory(licensing licensing.Licensing) factory.ProviderFactory[gateway.Gateway, gateway.Config] {
return factory.NewProviderFactory(factory.MustNewName("http"), func(ctx context.Context, ps factory.ProviderSettings, c gateway.Config) (gateway.Gateway, error) {
return New(ctx, ps, c, licensing)
})
}
func New(ctx context.Context, providerSettings factory.ProviderSettings, config gateway.Config, licensing licensing.Licensing) (gateway.Gateway, error) {
settings := factory.NewScopedProviderSettings(providerSettings, "github.com/SigNoz/signoz/ee/gateway/httpgateway")
httpClient, err := client.New(
settings.Logger(),
providerSettings.TracerProvider,
providerSettings.MeterProvider,
client.WithRequestResponseLog(true),
client.WithRetryCount(3),
)
if err != nil {
return nil, err
}
return &Provider{
settings: settings,
config: config,
httpClient: httpClient,
licensing: licensing,
}, nil
}
func (provider *Provider) GetIngestionKeys(ctx context.Context, orgID valuer.UUID, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error) {
qParams := url.Values{}
qParams.Add("page", strconv.Itoa(page))
qParams.Add("per_page", strconv.Itoa(perPage))
responseBody, err := provider.do(ctx, orgID, http.MethodGet, "/v1/workspaces/me/keys", qParams, nil)
if err != nil {
return nil, err
}
var ingestionKeys []gatewaytypes.IngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &ingestionKeys); err != nil {
return nil, err
}
var pagination gatewaytypes.Pagination
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "_pagination").String()), &pagination); err != nil {
return nil, err
}
return &gatewaytypes.GettableIngestionKeys{
Keys: ingestionKeys,
Pagination: pagination,
}, nil
}
func (provider *Provider) SearchIngestionKeysByName(ctx context.Context, orgID valuer.UUID, name string, page, perPage int) (*gatewaytypes.GettableIngestionKeys, error) {
qParams := url.Values{}
qParams.Add("name", name)
qParams.Add("page", strconv.Itoa(page))
qParams.Add("per_page", strconv.Itoa(perPage))
responseBody, err := provider.do(ctx, orgID, http.MethodGet, "/v1/workspaces/me/keys/search", qParams, nil)
if err != nil {
return nil, err
}
var ingestionKeys []gatewaytypes.IngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &ingestionKeys); err != nil {
return nil, err
}
var pagination gatewaytypes.Pagination
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "_pagination").String()), &pagination); err != nil {
return nil, err
}
return &gatewaytypes.GettableIngestionKeys{
Keys: ingestionKeys,
Pagination: pagination,
}, nil
}
func (provider *Provider) CreateIngestionKey(ctx context.Context, orgID valuer.UUID, name string, tags []string, expiresAt time.Time) (*gatewaytypes.GettableCreatedIngestionKey, error) {
requestBody := gatewaytypes.PostableIngestionKey{
Name: name,
Tags: tags,
ExpiresAt: expiresAt,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
responseBody, err := provider.do(ctx, orgID, http.MethodPost, "/v1/workspaces/me/keys", nil, requestBodyBytes)
if err != nil {
return nil, err
}
var createdKeyResponse gatewaytypes.GettableCreatedIngestionKey
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &createdKeyResponse); err != nil {
return nil, err
}
return &createdKeyResponse, nil
}
func (provider *Provider) UpdateIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string, name string, tags []string, expiresAt time.Time) error {
requestBody := gatewaytypes.PostableIngestionKey{
Name: name,
Tags: tags,
ExpiresAt: expiresAt,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return err
}
_, err = provider.do(ctx, orgID, http.MethodPatch, "/v1/workspaces/me/keys/"+keyID, nil, requestBodyBytes)
if err != nil {
return err
}
return nil
}
func (provider *Provider) DeleteIngestionKey(ctx context.Context, orgID valuer.UUID, keyID string) error {
_, err := provider.do(ctx, orgID, http.MethodDelete, "/v1/workspaces/me/keys/"+keyID, nil, nil)
if err != nil {
return err
}
return nil
}
func (provider *Provider) CreateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, keyID string, signal string, limitConfig gatewaytypes.LimitConfig, tags []string) (*gatewaytypes.GettableCreatedIngestionKeyLimit, error) {
requestBody := gatewaytypes.PostableIngestionKeyLimit{
Signal: signal,
Config: limitConfig,
Tags: tags,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return nil, err
}
responseBody, err := provider.do(ctx, orgID, http.MethodPost, "/v1/workspaces/me/keys/"+keyID+"/limits", nil, requestBodyBytes)
if err != nil {
return nil, err
}
var createdIngestionKeyLimitResponse gatewaytypes.GettableCreatedIngestionKeyLimit
if err := json.Unmarshal([]byte(gjson.GetBytes(responseBody, "data").String()), &createdIngestionKeyLimitResponse); err != nil {
return nil, err
}
return &createdIngestionKeyLimitResponse, nil
}
func (provider *Provider) UpdateIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string, limitConfig gatewaytypes.LimitConfig, tags []string) error {
requestBody := gatewaytypes.UpdatableIngestionKeyLimit{
Config: limitConfig,
Tags: tags,
}
requestBodyBytes, err := json.Marshal(requestBody)
if err != nil {
return err
}
_, err = provider.do(ctx, orgID, http.MethodPatch, "/v1/workspaces/me/limits/"+limitID, nil, requestBodyBytes)
if err != nil {
return err
}
return nil
}
func (provider *Provider) DeleteIngestionKeyLimit(ctx context.Context, orgID valuer.UUID, limitID string) error {
_, err := provider.do(ctx, orgID, http.MethodDelete, "/v1/workspaces/me/limits/"+limitID, nil, nil)
if err != nil {
return err
}
return nil
}
func (provider *Provider) do(ctx context.Context, orgID valuer.UUID, method string, path string, queryParams url.Values, body []byte) ([]byte, error) {
license, err := provider.licensing.GetActive(ctx, orgID)
if err != nil {
return nil, errors.New(errors.TypeLicenseUnavailable, errors.CodeLicenseUnavailable, "no valid license found").WithAdditional("this feature requires a valid license").WithAdditional(err.Error())
}
// build url
requestURL := provider.config.URL.JoinPath(path)
// add query params to the url
if queryParams != nil {
requestURL.RawQuery = queryParams.Encode()
}
// build request
request, err := http.NewRequestWithContext(ctx, method, requestURL.String(), bytes.NewBuffer(body))
if err != nil {
return nil, err
}
// add headers needed to call gateway
request.Header.Set("Content-Type", "application/json")
request.Header.Set("X-Signoz-Cloud-Api-Key", license.Key)
request.Header.Set("X-Consumer-Username", "lid:00000000-0000-0000-0000-000000000000")
request.Header.Set("X-Consumer-Groups", "ns:default")
// execute request
response, err := provider.httpClient.Do(request)
if err != nil {
return nil, err
}
// read response
defer response.Body.Close()
responseBody, err := io.ReadAll(response.Body)
if err != nil {
return nil, err
}
// only 2XX
if response.StatusCode/100 == 2 {
return responseBody, nil
}
errorMessage := gjson.GetBytes(responseBody, "error").String()
if errorMessage == "" {
errorMessage = "an unknown error occurred"
}
// return error for non 2XX
return nil, provider.errFromStatusCode(response.StatusCode, errorMessage)
}
func (provider *Provider) errFromStatusCode(code int, errorMessage string) error {
switch code {
case http.StatusBadRequest:
return errors.New(errors.TypeInvalidInput, errors.CodeInvalidInput, errorMessage)
case http.StatusUnauthorized:
return errors.New(errors.TypeUnauthenticated, errors.CodeUnauthenticated, errorMessage)
case http.StatusForbidden:
return errors.New(errors.TypeForbidden, errors.CodeForbidden, errorMessage)
case http.StatusNotFound:
return errors.New(errors.TypeNotFound, errors.CodeNotFound, errorMessage)
case http.StatusConflict:
return errors.New(errors.TypeAlreadyExists, errors.CodeAlreadyExists, errorMessage)
}
return errors.New(errors.TypeInternal, errors.CodeInternal, errorMessage)
}

View File

@@ -10,6 +10,7 @@ import (
"github.com/SigNoz/signoz/ee/query-service/usage"
"github.com/SigNoz/signoz/pkg/alertmanager"
"github.com/SigNoz/signoz/pkg/apis/fields"
"github.com/SigNoz/signoz/pkg/global"
"github.com/SigNoz/signoz/pkg/http/middleware"
querierAPI "github.com/SigNoz/signoz/pkg/querier"
baseapp "github.com/SigNoz/signoz/pkg/query-service/app"
@@ -36,6 +37,7 @@ type APIHandlerOptions struct {
GatewayUrl string
// Querier Influx Interval
FluxInterval time.Duration
GlobalConfig global.Config
}
type APIHandler struct {

View File

@@ -76,7 +76,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
if apiErr != nil {
RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't deduce ingestion url and signoz api url",
@@ -84,7 +84,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return
}
result.IngestionUrl = ingestionUrl
result.IngestionUrl = ah.opts.GlobalConfig.IngestionURL.String()
result.SigNozAPIUrl = signozApiUrl
gatewayUrl := ah.opts.GatewayUrl
@@ -186,7 +186,7 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
}
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
string, string, *basemodel.ApiError,
string, *basemodel.ApiError,
) {
// TODO: remove this struct from here
type deploymentResponse struct {
@@ -200,7 +200,7 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
if err != nil {
return "", "", basemodel.InternalError(fmt.Errorf(
return "", basemodel.InternalError(fmt.Errorf(
"couldn't query for deployment info: error: %w", err,
))
}
@@ -209,7 +209,7 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
err = json.Unmarshal(respBytes, resp)
if err != nil {
return "", "", basemodel.InternalError(fmt.Errorf(
return "", basemodel.InternalError(fmt.Errorf(
"couldn't unmarshal deployment info response: error: %w", err,
))
}
@@ -219,16 +219,14 @@ func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licens
if len(regionDns) < 1 || len(deploymentName) < 1 {
// Fail early if actual response structure and expectation here ever diverge
return "", "", basemodel.InternalError(fmt.Errorf(
return "", basemodel.InternalError(fmt.Errorf(
"deployment info response not in expected shape. couldn't determine region dns and deployment name",
))
}
ingestionUrl := fmt.Sprintf("https://ingest.%s", regionDns)
signozApiUrl := fmt.Sprintf("https://%s.%s", deploymentName, regionDns)
return ingestionUrl, signozApiUrl, nil
return signozApiUrl, nil
}
type ingestionKey struct {

View File

@@ -172,6 +172,7 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
FluxInterval: config.Querier.FluxInterval,
Gateway: gatewayProxy,
GatewayUrl: config.Gateway.URL.String(),
GlobalConfig: config.Global,
}
apiHandler, err := api.NewAPIHandler(apiOpts, signoz)

View File

@@ -1,7 +1,10 @@
#!/bin/sh
. "$(dirname "$0")/_/husky.sh"
cd frontend && yarn run commitlint --edit $1
# Convert $1 to absolute path before cd
commit_msg_file="$(realpath "$1")"
cd frontend && yarn run commitlint "$commit_msg_file"
branch="$(git rev-parse --abbrev-ref HEAD)"

View File

@@ -12,7 +12,7 @@ import {
FixedDurationSuggestionOptions,
Options,
RelativeDurationSuggestionOptions,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/constants';
import dayjs from 'dayjs';
import { isValidShortHandDateTimeFormat } from 'lib/getMinMax';
import { defaultTo, isFunction, noop } from 'lodash-es';

View File

@@ -8,11 +8,11 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import { DateTimeRangeType } from 'container/TopNav/CustomDateTimeModal';
import { RelativeDurationSuggestionOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import {
LexicalContext,
Option,
RelativeDurationSuggestionOptions,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import dayjs from 'dayjs';
import { Clock, PenLine, TriangleAlertIcon } from 'lucide-react';
import { useTimezone } from 'providers/Timezone';

View File

@@ -8,7 +8,7 @@ import {
CustomTimeType,
LexicalContext,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import dayjs, { Dayjs } from 'dayjs';
import { useTimezone } from 'providers/Timezone';
import { Dispatch, SetStateAction, useMemo } from 'react';

View File

@@ -13,7 +13,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';

View File

@@ -24,7 +24,7 @@ import { INFRA_MONITORING_K8S_PARAMS_KEYS } from 'container/InfraMonitoringK8s/c
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -5,7 +5,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useMemo } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -12,7 +12,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';

View File

@@ -4,7 +4,7 @@ import { OPERATORS, PANEL_TYPES } from 'constants/queryBuilder';
import { Formula } from 'container/QueryBuilder/components/Formula';
import { QueryBuilderProps } from 'container/QueryBuilder/QueryBuilder.interfaces';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { memo, useEffect, useMemo, useRef } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import { IBuilderTraceOperator } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
@@ -33,6 +33,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
addTraceOperator,
panelType,
initialDataSource,
handleRunQuery,
} = useQueryBuilder();
const containerRef = useRef(null);
@@ -157,10 +158,29 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
[showTraceOperator, traceOperator, hasAtLeastOneTraceQuery],
);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLDivElement>): void => {
const target = e.target as HTMLElement | null;
const tagName = target?.tagName || '';
const isInputElement =
['INPUT', 'TEXTAREA', 'SELECT'].includes(tagName) ||
(target?.getAttribute('contenteditable') || '').toLowerCase() === 'true';
// Allow input elements in qb to run the query when Cmd/Ctrl + Enter is pressed
if (isInputElement && (e.metaKey || e.ctrlKey) && e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
handleRunQuery();
}
},
[handleRunQuery],
);
return (
<QueryBuilderV2Provider>
<div className="query-builder-v2">
<div className="qb-content-container">
<div className="qb-content-container" onKeyDownCapture={handleKeyDown}>
{!isMultiQueryAllowed ? (
<QueryV2
ref={containerRef}

View File

@@ -11,7 +11,7 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { get, isEmpty } from 'lodash-es';
import { BarChart2, ChevronUp, ExternalLink, ScrollText } from 'lucide-react';
import { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { MetricAggregation } from 'types/api/v5/queryRange';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
@@ -171,6 +171,9 @@ function QueryAddOns({
const [selectedViews, setSelectedViews] = useState<AddOn[]>([]);
const initializedRef = useRef(false);
const prevAvailableKeysRef = useRef<Set<string> | null>(null);
const { handleChangeQueryData } = useQueryOperations({
index,
query,
@@ -213,23 +216,41 @@ function QueryAddOns({
}
setAddOns(filteredAddOns);
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
const availableAddOnKeys = new Set(filteredAddOns.map((a) => a.key));
const previousKeys = prevAvailableKeysRef.current;
const hasAvailabilityItemsChanged =
previousKeys !== null &&
(previousKeys.size !== availableAddOnKeys.size ||
[...availableAddOnKeys].some((key) => !previousKeys.has(key)));
prevAvailableKeysRef.current = availableAddOnKeys;
const availableAddOnKeys = new Set(filteredAddOns.map((addOn) => addOn.key));
// Filter and set selected views: add-ons that are both active and available
setSelectedViews(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
if (!initializedRef.current || hasAvailabilityItemsChanged) {
initializedRef.current = true;
const activeAddOnKeys = new Set(
Object.entries(ADD_ONS_KEYS_TO_QUERY_PATH)
.filter(([, path]) => hasValue(get(query, path)))
.map(([key]) => key),
);
// Initial seeding from query values on mount
setSelectedViews(
filteredAddOns.filter(
(addOn) =>
activeAddOnKeys.has(addOn.key) && availableAddOnKeys.has(addOn.key),
),
);
return;
}
setSelectedViews((prev) =>
prev.filter((view) =>
filteredAddOns.some((addOn) => addOn.key === view.key),
),
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [panelType, isListViewPanel, query]);
}, [panelType, isListViewPanel, query, showReduceTo]);
const handleOptionClick = (e: RadioChangeEvent): void => {
if (selectedViews.find((view) => view.key === e.target.value.key)) {

View File

@@ -1379,8 +1379,6 @@ function QuerySearch({
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(getCurrentExpression());
} else {
handleRunQuery();
}
return true;
},

View File

@@ -410,8 +410,6 @@ function TraceOperatorEditor({
run: (): boolean => {
if (onRun && typeof onRun === 'function') {
onRun(value);
} else {
handleRunQuery();
}
return true;
},

View File

@@ -270,44 +270,6 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
});
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
const mockedHandleRunQuery = handleRunQueryMock as jest.MockedFunction<
() => void
>;
mockedHandleRunQuery.mockClear();
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={initialQueriesMap.logs.builder.queryData[0]}
dataSource={DataSource.LOGS}
/>,
);
// Wait for CodeMirror to initialize
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
await userEvent.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
fireEvent.keyDown(editor, {
key: 'Enter',
code: 'Enter',
[modKey]: true,
keyCode: 13,
});
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled(), {
timeout: 2000,
});
});
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
const testExpression =
"http.status_code >= 500 AND service.name = 'frontend'";

View File

@@ -3,14 +3,21 @@
import '@testing-library/jest-dom';
import { jest } from '@jest/globals';
import { fireEvent, waitFor } from '@testing-library/react';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { render, screen } from 'tests/test-utils';
import { Having, IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { render, screen, userEvent } from 'tests/test-utils';
import {
Having,
IBuilderQuery,
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { UseQueryOperations } from 'types/common/operations.types';
import { DataSource, QueryBuilderContextType } from 'types/common/queryBuilder';
import { QueryV2 } from '../QueryV2';
import { QueryBuilderV2 } from '../../QueryBuilderV2';
// Local mocks for domain-specific heavy child components
jest.mock(
@@ -36,16 +43,87 @@ const mockedUseQueryOperations = jest.mocked(
useQueryOperations,
) as jest.MockedFunction<UseQueryOperations>;
describe('QueryV2 - base render', () => {
describe('QueryBuilderV2 + QueryV2 - base render', () => {
let handleRunQueryMock: jest.MockedFunction<() => void>;
beforeEach(() => {
const mockCloneQuery = jest.fn() as jest.MockedFunction<
(type: string, q: IBuilderQuery) => void
>;
handleRunQueryMock = jest.fn() as jest.MockedFunction<() => void>;
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
const currentQueryObj: Query = {
id: 'test',
unit: undefined,
queryType: EQueryType.CLICKHOUSE,
promql: [],
clickhouse_sql: [],
builder: {
queryData: [baseQuery],
queryFormulas: [],
queryTraceOperator: [],
},
};
const updateAllQueriesOperators: QueryBuilderContextType['updateAllQueriesOperators'] = (
q,
) => q;
const updateQueriesData: QueryBuilderContextType['updateQueriesData'] = (q) =>
q;
mockedUseQueryBuilder.mockReturnValue(({
// Only fields used by QueryV2
currentQuery: currentQueryObj,
stagedQuery: null,
lastUsedQuery: null,
setLastUsedQuery: jest.fn(),
supersetQuery: currentQueryObj,
setSupersetQuery: jest.fn(),
initialDataSource: null,
panelType: PANEL_TYPES.TABLE,
isEnabledQuery: true,
handleSetQueryData: jest.fn(),
handleSetTraceOperatorData: jest.fn(),
handleSetFormulaData: jest.fn(),
handleSetQueryItemData: jest.fn(),
handleSetConfig: jest.fn(),
removeQueryBuilderEntityByIndex: jest.fn(),
removeAllQueryBuilderEntities: jest.fn(),
removeQueryTypeItemByIndex: jest.fn(),
addNewBuilderQuery: jest.fn(),
addNewFormula: jest.fn(),
removeTraceOperator: jest.fn(),
addTraceOperator: jest.fn(),
cloneQuery: mockCloneQuery,
panelType: null,
addNewQueryItem: jest.fn(),
redirectWithQueryBuilderData: jest.fn(),
handleRunQuery: handleRunQueryMock,
resetQuery: jest.fn(),
handleOnUnitsChange: jest.fn(),
updateAllQueriesOperators,
updateQueriesData,
initQueryBuilderData: jest.fn(),
isStagedQueryUpdated: jest.fn(() => false),
isDefaultQuery: jest.fn(() => false),
} as unknown) as QueryBuilderContextType);
mockedUseQueryOperations.mockReturnValue({
@@ -71,40 +149,7 @@ describe('QueryV2 - base render', () => {
});
it('renders limit input when dataSource is logs', () => {
const baseQuery: IBuilderQuery = {
queryName: 'A',
dataSource: DataSource.LOGS,
aggregateOperator: '',
aggregations: [],
timeAggregation: '',
spaceAggregation: '',
temporality: '',
functions: [],
filter: undefined,
filters: { items: [], op: 'AND' },
groupBy: [],
expression: '',
disabled: false,
having: [] as Having[],
limit: 10,
stepInterval: null,
orderBy: [],
legend: 'A',
};
render(
<QueryV2
index={0}
isAvailableToDisable
query={baseQuery}
version="v4"
onSignalSourceChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
signalSourceChangeEnabled={false}
queriesCount={1}
showTraceOperator={false}
hasTraceOperator={false}
/>,
);
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
// Ensure the Limit add-on input is present and is of type number
const limitInput = screen.getByPlaceholderText(
@@ -115,4 +160,43 @@ describe('QueryV2 - base render', () => {
expect(limitInput).toHaveAttribute('name', 'limit');
expect(limitInput).toHaveAttribute('data-testid', 'input-Limit');
});
it('Cmd+Enter on an input triggers handleRunQuery via container handler', async () => {
render(<QueryBuilderV2 panelType={PANEL_TYPES.TABLE} version="v4" />);
const limitInput = screen.getByPlaceholderText('Enter limit');
fireEvent.keyDown(limitInput, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
const legendInput = screen.getByPlaceholderText('Write legend format');
fireEvent.keyDown(legendInput, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
// Wait for CodeMirror to initialize
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await userEvent.click(editor);
fireEvent.keyDown(editor, {
key: 'Enter',
code: 'Enter',
metaKey: true,
});
expect(handleRunQueryMock).toHaveBeenCalled();
});
});

View File

@@ -7,7 +7,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';
import { ArrowDown, ArrowUp, X } from 'lucide-react';

View File

@@ -13,7 +13,7 @@ import QueryBuilderSearchV2 from 'container/QueryBuilder/filters/QueryBuilderSea
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useQueries } from 'react-query';

View File

@@ -9,7 +9,7 @@ import {
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueries } from 'react-query';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import {

View File

@@ -37,10 +37,7 @@ function ThresholdItem({
);
if (units.length === 0) {
component = (
<Tooltip
trigger="hover"
title="Please select a Y-axis unit for the query first"
>
<Tooltip trigger="hover" title="No compatible units available">
<Select
placeholder="Unit"
value={threshold.unit ? threshold.unit : null}

View File

@@ -47,9 +47,17 @@ export function getCategoryByOptionId(id: string): string | undefined {
}
export function getCategorySelectOptionByName(
name: string,
name: string | undefined,
): DefaultOptionType[] {
if (!name) {
return [];
}
const categories = getYAxisCategories(YAxisSource.ALERTS);
if (!categories.length) {
return [];
}
return (
categories
.find((category) => category.name === name)

View File

@@ -15,11 +15,10 @@ import GridPanelSwitch from 'container/GridPanelSwitch';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { getFormatNameByOptionId } from 'container/NewWidget/RightContainer/alertFomatCategories';
import { timePreferenceType } from 'container/NewWidget/RightContainer/timeItems';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import {
CustomTimeType,
Time as TimeV2,
} from 'container/TopNav/DateTimeSelectionV2/config';
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
@@ -60,7 +59,7 @@ export interface ChartPreviewProps {
query: Query | null;
graphType?: PANEL_TYPES;
selectedTime?: timePreferenceType;
selectedInterval?: Time | TimeV2 | CustomTimeType;
selectedInterval?: Time | CustomTimeType;
headline?: JSX.Element;
alertDef?: AlertDef;
userQueryKey?: string;

View File

@@ -4,3 +4,7 @@
gap: 8px;
align-items: center;
}
.rule-unit-selector {
width: 150px;
}

View File

@@ -15,8 +15,7 @@ import { DefaultOptionType } from 'antd/es/select';
import {
getCategoryByOptionId,
getCategorySelectOptionByName,
} from 'container/NewWidget/RightContainer/alertFomatCategories';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
} from 'container/CreateAlertV2/AlertCondition/utils';
import { useTranslation } from 'react-i18next';
import {
AlertDef,
@@ -43,10 +42,10 @@ function RuleOptions({
setAlertDef,
queryCategory,
queryOptions,
yAxisUnit,
}: RuleOptionsProps): JSX.Element {
// init namespace for translations
const { t } = useTranslation('alerts');
const { currentQuery } = useQueryBuilder();
const { ruleType } = alertDef;
@@ -365,11 +364,9 @@ function RuleOptions({
</InlineSelect>
);
const selectedCategory = getCategoryByOptionId(currentQuery?.unit || '');
const selectedCategory = getCategoryByOptionId(yAxisUnit);
const categorySelectOptions = getCategorySelectOptionByName(
selectedCategory?.name,
);
const categorySelectOptions = getCategorySelectOptionByName(selectedCategory);
const step3Label = alertDef.alertType === 'METRIC_BASED_ALERT' ? '3' : '2';
@@ -402,6 +399,7 @@ function RuleOptions({
<Form.Item noStyle>
<Select
className="rule-unit-selector"
getPopupContainer={popupContainer}
allowClear
showSearch
@@ -515,5 +513,6 @@ interface RuleOptionsProps {
setAlertDef: (a: AlertDef) => void;
queryCategory: EQueryType;
queryOptions: DefaultOptionType[];
yAxisUnit: string;
}
export default RuleOptions;

View File

@@ -914,6 +914,7 @@ function FormAlertRules({
alertDef={alertDef}
setAlertDef={setAlertDef}
queryOptions={queryOptions}
yAxisUnit={yAxisUnit || ''}
/>
{renderBasicInfo()}

View File

@@ -1,5 +1,5 @@
import { SelectProps } from 'antd';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import getStartEndRangeTime from 'lib/getStartEndRangeTime';
import getStep from 'lib/getStep';
import {

View File

@@ -3,7 +3,7 @@ import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V5 } from 'constants/app';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { populateMultipleResults } from 'container/NewWidget/LeftContainer/WidgetGraph/util';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import { useIntersectionObserver } from 'hooks/useIntersectionObserver';
import { getDashboardVariables } from 'lib/dashbaordVariables/getDashboardVariables';

View File

@@ -26,7 +26,7 @@ import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/util
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -21,7 +21,7 @@ import {
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -26,7 +26,7 @@ import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/util
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -16,7 +16,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { isArray } from 'lodash-es';

View File

@@ -4,7 +4,7 @@ import { fireEvent, render, screen } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/config';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';

View File

@@ -7,7 +7,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useMemo } from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -14,7 +14,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useResizeObserver } from 'hooks/useDimensions';

View File

@@ -2,7 +2,7 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen } from '@testing-library/react';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/config';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';

View File

@@ -15,7 +15,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { PER_PAGE_OPTIONS } from 'container/TracesExplorer/ListView/configs';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';

View File

@@ -3,7 +3,7 @@
import { render, screen } from '@testing-library/react';
import { initialQueriesMap } from 'constants/queryBuilder';
import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/config';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import * as useQueryBuilderHooks from 'hooks/queryBuilder/useQueryBuilder';
import * as appContextHooks from 'providers/App/App';
import { LicenseEvent } from 'types/api/licensesV3/getActive';

View File

@@ -21,7 +21,7 @@ import {
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -23,7 +23,7 @@ import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/util
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -26,7 +26,7 @@ import NodeEvents from 'container/InfraMonitoringK8s/EntityDetailsUtils/EntityEv
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -27,7 +27,7 @@ import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/util
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -26,7 +26,7 @@ import { QUERY_KEYS } from 'container/InfraMonitoringK8s/EntityDetailsUtils/util
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';

View File

@@ -9,7 +9,7 @@ import { K8sCategory } from 'container/InfraMonitoringK8s/constants';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';
import { X } from 'lucide-react';

View File

@@ -4,7 +4,7 @@ import NewExplorerCTA from 'container/NewExplorerCTA';
import { FileText } from 'lucide-react';
import { useLocation } from 'react-use';
import DateTimeSelector from '../TopNav/DateTimeSelection';
import DateTimeSelector from '../TopNav/DateTimeSelectionV2';
import { Container } from './styles';
import { LocalTopNavProps } from './types';
@@ -37,7 +37,7 @@ function LocalTopNav({
{actions}
{renderPermissions?.isDateTimeEnabled && (
<div>
<DateTimeSelector />
<DateTimeSelector showAutoRefresh={false} />
</div>
)}
</Space>

View File

@@ -4,9 +4,9 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import Controls from 'container/Controls';
import Download from 'container/Download/Download';
import { getGlobalTime } from 'container/LogsSearchFilter/utils';
import { getMinMax } from 'container/TopNav/AutoRefresh/config';
import dayjs from 'dayjs';
import { Pagination } from 'hooks/queryPagination';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import { FlatLogData } from 'lib/logs/flatLogData';
import { OrderPreferenceItems } from 'pages/Logs/config';
import { memo, useMemo } from 'react';
@@ -50,7 +50,7 @@ function LogControls(): JSX.Element | null {
};
const handleGoToLatest = (): void => {
const { maxTime, minTime } = getMinMax(
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,

View File

@@ -1,6 +1,6 @@
import { QueryParams } from 'constants/query';
import { getMinMax } from 'container/TopNav/AutoRefresh/config';
import useUrlQuery from 'hooks/useUrlQuery';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import history from 'lib/history';
import { parseQuery } from 'lib/logql';
import isEqual from 'lodash-es/isEqual';
@@ -44,7 +44,7 @@ export function useSearchParser(): {
search: `?${QueryParams.q}=${updatedQueryString}&${QueryParams.order}=${order}`,
});
const globalTime = getMinMax(selectedTime, minTime, maxTime);
const globalTime = getMinMaxForSelectedTime(selectedTime, minTime, maxTime);
dispatch({
type: SET_SEARCH_QUERY_STRING,

View File

@@ -1,12 +1,11 @@
import { Time } from 'container/TopNav/DateTimeSelection/config';
import {
CustomTimeType,
Time as TimeV2,
} from 'container/TopNav/DateTimeSelectionV2/config';
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { GetMinMaxPayload } from 'lib/getMinMax';
export const getGlobalTime = (
selectedTime: Time | TimeV2 | CustomTimeType,
selectedTime: Time | CustomTimeType,
globalTime: GetMinMaxPayload,
): GetMinMaxPayload | undefined => {
if (selectedTime === 'custom') {

View File

@@ -0,0 +1,84 @@
.license-section {
display: flex;
flex-direction: column;
gap: 16px;
.license-section-header {
display: flex;
flex-direction: column;
gap: 4px;
.license-section-title {
color: #fff;
font-family: Inter;
font-size: 16px;
font-style: normal;
line-height: 24px;
letter-spacing: -0.08px;
}
}
.license-section-content {
display: flex;
flex-direction: column;
gap: 16px;
.license-section-content-item {
padding: 16px;
border: 1px solid var(--Slate-500, #161922);
background: var(--Ink-400, #121317);
border-radius: 3px;
.license-section-content-item-title-action {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
color: var(--Vanilla-300, #eee);
font-family: Inter;
font-size: 14px;
font-style: normal;
font-weight: 500;
line-height: normal;
letter-spacing: -0.07px;
margin-bottom: 8px;
}
.license-section-content-item-description {
color: var(--Vanilla-400, #c0c1c3);
font-family: Inter;
font-size: 12px;
font-style: normal;
line-height: 20px;
letter-spacing: -0.07px;
}
}
}
}
.lightMode {
.license-section {
.license-section-header {
.license-section-title {
color: var(--bg-ink-400);
}
}
.license-section-content {
.license-section-content-item {
border: 1px solid var(--bg-vanilla-300);
background: var(--bg-vanilla-100);
.license-section-content-item-title-action {
color: var(--bg-ink-400);
}
.license-section-content-item-description {
color: var(--bg-ink-300);
}
}
}
}
}

View File

@@ -0,0 +1,65 @@
import './LicenseSection.styles.scss';
import { Button } from '@signozhq/button';
import { Typography } from 'antd';
import { useNotifications } from 'hooks/useNotifications';
import { Copy } from 'lucide-react';
import { useAppContext } from 'providers/App/App';
import { useCopyToClipboard } from 'react-use';
function LicenseSection(): JSX.Element | null {
const { activeLicense } = useAppContext();
const { notifications } = useNotifications();
const [, handleCopyToClipboard] = useCopyToClipboard();
const getMaskedKey = (key: string): string => {
if (!key || key.length < 4) return key || 'N/A';
return `${key.substring(0, 2)}********${key
.substring(key.length - 2)
.trim()}`;
};
const handleCopyKey = (text: string): void => {
handleCopyToClipboard(text);
notifications.success({
message: 'Copied to clipboard',
});
};
if (!activeLicense?.key) {
return <></>;
}
return (
<div className="license-section">
<div className="license-section-header">
<div className="license-section-title">License</div>
</div>
<div className="license-section-content">
<div className="license-section-content-item">
<div className="license-section-content-item-title-action">
<span>License key</span>
<span style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<Typography.Text code>{getMaskedKey(activeLicense.key)}</Typography.Text>
<Button
variant="ghost"
aria-label="Copy license key"
data-testid="license-key-copy-btn"
onClick={(): void => handleCopyKey(activeLicense.key)}
>
<Copy size={14} />
</Button>
</span>
</div>
<div className="license-section-content-item-description">
Your SigNoz license key.
</div>
</div>
</div>
</div>
);
}
export default LicenseSection;

View File

@@ -0,0 +1 @@
export { default } from './LicenseSection';

View File

@@ -1,8 +1,31 @@
import userEvent from '@testing-library/user-event';
import MySettingsContainer from 'container/MySettings';
import { act, fireEvent, render, screen, waitFor } from 'tests/test-utils';
import {
act,
fireEvent,
render,
screen,
waitFor,
within,
} from 'tests/test-utils';
const toggleThemeFunction = jest.fn();
const logEventFunction = jest.fn();
const copyToClipboardFn = jest.fn();
const editUserFn = jest.fn();
jest.mock('react-use', () => ({
__esModule: true,
useCopyToClipboard: (): [unknown, (text: string) => void] => [
null,
copyToClipboardFn,
],
}));
jest.mock('api/v1/user/id/update', () => ({
__esModule: true,
default: (...args: unknown[]): Promise<unknown> => editUserFn(...args),
}));
jest.mock('hooks/useDarkMode', () => ({
__esModule: true,
@@ -44,6 +67,7 @@ const PASSWORD_VALIDATION_MESSAGE_TEST_ID = 'password-validation-message';
describe('MySettings Flows', () => {
beforeEach(() => {
jest.clearAllMocks();
editUserFn.mockResolvedValue({});
render(<MySettingsContainer />);
});
@@ -215,4 +239,71 @@ describe('MySettings Flows', () => {
expect(submitButton).not.toBeDisabled();
});
});
describe('License section', () => {
it('Should render license section content when license key exists', () => {
expect(screen.getByText('License')).toBeInTheDocument();
expect(screen.getByText('License key')).toBeInTheDocument();
expect(screen.getByText('Your SigNoz license key.')).toBeInTheDocument();
});
it('Should not render license section when license key is missing', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: null,
},
});
const scoped = within(container);
expect(scoped.queryByText('License')).not.toBeInTheDocument();
expect(scoped.queryByText('License key')).not.toBeInTheDocument();
expect(
scoped.queryByText('Your SigNoz license key.'),
).not.toBeInTheDocument();
});
it('Should mask license key in the UI', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'abcd',
} as any,
},
});
expect(within(container).getByText('ab********cd')).toBeInTheDocument();
});
it('Should not mask license key if it is too short', () => {
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'abc',
} as any,
},
});
expect(within(container).getByText('abc')).toBeInTheDocument();
});
it('Should copy license key and show success toast', async () => {
const user = userEvent.setup();
const { container } = render(<MySettingsContainer />, undefined, {
appContextOverrides: {
activeLicense: {
key: 'test-license-key-12345',
} as any,
},
});
await user.click(within(container).getByTestId('license-key-copy-btn'));
await waitFor(() => {
expect(copyToClipboardFn).toHaveBeenCalledWith('test-license-key-12345');
expect(successNotification).toHaveBeenCalledWith({
message: 'Copied to clipboard',
});
});
});
});
});

View File

@@ -15,6 +15,7 @@ import { useMutation } from 'react-query';
import { UserPreference } from 'types/api/preferences/preference';
import { showErrorNotification } from 'utils/error';
import LicenseSection from './LicenseSection';
import TimezoneAdaptation from './TimezoneAdaptation/TimezoneAdaptation';
import UserInfo from './UserInfo';
@@ -230,6 +231,8 @@ function MySettings(): JSX.Element {
</div>
</div>
</div>
<LicenseSection />
</div>
);
}

View File

@@ -6,7 +6,7 @@ import { handleGraphClick } from 'container/GridCardLayout/GridCard/utils';
import { useGraphClickToShowButton } from 'container/GridCardLayout/useGraphClickToShowButton';
import useNavigateToExplorerPages from 'container/GridCardLayout/useNavigateToExplorerPages';
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { useNotifications } from 'hooks/useNotifications';
import { useSafeNavigate } from 'hooks/useSafeNavigate';

View File

@@ -1,6 +1,6 @@
import './styles.scss';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelection/config';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useState } from 'react';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';

View File

@@ -1,6 +1,6 @@
import './styles.scss';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelection/config';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import { useState } from 'react';
import { PipelineData } from 'types/api/pipeline/def';

View File

@@ -6,7 +6,7 @@ import {
initialQueriesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import cloneDeep from 'lodash-es/cloneDeep';
import { useMemo } from 'react';

View File

@@ -1,10 +1,8 @@
import './styles.scss';
import { Select } from 'antd';
import {
RelativeDurationOptions,
Time,
} from 'container/TopNav/DateTimeSelection/config';
import { RelativeDurationOptions } from 'container/TopNav/DateTimeSelectionV2/constants';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
import LogsCountInInterval from './components/LogsCountInInterval';

View File

@@ -4,7 +4,7 @@ import {
initialQueriesMap,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import { Time } from 'container/TopNav/DateTimeSelectionV2/types';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';
import cloneDeep from 'lodash-es/cloneDeep';
import { useMemo } from 'react';

View File

@@ -9,7 +9,7 @@ import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import {
CustomTimeType,
Time,
} from 'container/TopNav/DateTimeSelectionV2/config';
} from 'container/TopNav/DateTimeSelectionV2/types';
import dayjs from 'dayjs';
import { useIsDarkMode } from 'hooks/useDarkMode';
import GetMinMax from 'lib/getMinMax';

View File

@@ -1,9 +1,8 @@
import { ServiceDataProps } from 'api/metrics/getTopLevelOperations';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import {
CustomTimeType,
Time as TimeV2,
} from 'container/TopNav/DateTimeSelectionV2/config';
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import { GetQueryResultsProps } from 'lib/dashboard/getQueryResults';
import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
@@ -26,7 +25,7 @@ export interface ServiceMetricsTableProps {
export interface GetQueryRangeRequestDataProps {
topLevelOperations: [keyof ServiceDataProps, string[]][];
globalSelectedInterval: Time | TimeV2 | CustomTimeType;
globalSelectedInterval: Time | CustomTimeType;
dotMetricsEnabled: boolean;
}

View File

@@ -20,13 +20,17 @@ interface AttributeRecord {
interface AttributeActionsProps {
record: AttributeRecord;
isPinned?: boolean;
onTogglePin: (fieldKey: string) => void;
onTogglePin?: (fieldKey: string) => void;
showPinned?: boolean;
showCopyOptions?: boolean;
}
export default function AttributeActions({
record,
isPinned,
onTogglePin,
showPinned = true,
showCopyOptions = true,
}: AttributeActionsProps): JSX.Element {
const [isOpen, setIsOpen] = useState<boolean>(false);
const [isFilterInLoading, setIsFilterInLoading] = useState<boolean>(false);
@@ -91,7 +95,7 @@ export default function AttributeActions({
}, [onCopyFieldValue, textToCopy]);
const handleTogglePin = useCallback((): void => {
onTogglePin(record.field);
onTogglePin?.(record.field);
}, [onTogglePin, record.field]);
const moreActionsContent = (
@@ -105,35 +109,41 @@ export default function AttributeActions({
>
Group By Attribute
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
{showCopyOptions && (
<>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldName}
block
>
Copy Field Name
</Button>
<Button
type="text"
icon={<Copy size={14} />}
onClick={handleCopyFieldValue}
block
>
Copy Field Value
</Button>
</>
)}
</div>
);
return (
<div className={cx('action-btn', { 'action-btn--is-open': isOpen })}>
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
{showPinned && (
<Tooltip title={isPinned ? 'Unpin attribute' : 'Pin attribute'}>
<Button
className={`filter-btn periscope-btn ${isPinned ? 'pinned' : ''}`}
aria-label={isPinned ? 'Unpin attribute' : 'Pin attribute'}
icon={<Pin size={14} fill={isPinned ? 'currentColor' : 'none'} />}
onClick={handleTogglePin}
/>
</Tooltip>
)}
<Tooltip title="Filter for value">
<Button
className="filter-btn periscope-btn"
@@ -184,4 +194,7 @@ export default function AttributeActions({
AttributeActions.defaultProps = {
isPinned: false,
showPinned: true,
showCopyOptions: true,
onTogglePin: undefined,
};

View File

@@ -47,15 +47,56 @@
.description {
display: flex;
flex-direction: column;
gap: 16px;
padding: 10px 12px;
padding: 10px 0px;
.item {
padding: 8px 12px;
&,
.attribute-container {
display: flex;
flex-direction: column;
gap: 8px;
position: relative; // ensure absolutely-positioned children anchor to the row
}
// Show attribute actions on hover for hardcoded rows
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
// style the action button group
.action-btn {
display: flex;
gap: 4px;
}
.filter-btn {
display: flex;
align-items: center;
border: none;
box-shadow: none;
border-radius: 2px;
background: var(--bg-slate-400);
padding: 4px;
gap: 3px;
height: 24px;
width: 24px;
&:hover {
background: var(--bg-slate-300);
}
}
}
&:hover {
background-color: var(--bg-slate-500);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
@@ -646,6 +687,29 @@
.description {
.item {
.attribute-actions-wrapper {
display: none;
gap: 8px;
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
border-radius: 4px;
padding: 2px;
.filter-btn {
background: var(--bg-vanilla-200);
&:hover {
background: var(--bg-vanilla-100);
}
}
}
&:hover {
background-color: var(--bg-vanilla-300);
.attribute-actions-wrapper {
display: flex;
}
}
.span-name-wrapper {
.span-percentile-value-container {
&.span-percentile-value-container-open {

View File

@@ -21,6 +21,7 @@ import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { themeColors } from 'constants/theme';
import { USER_PREFERENCES } from 'constants/userPreferences';
import AttributeActions from 'container/SpanDetailsDrawer/Attributes/AttributeActions';
import dayjs from 'dayjs';
import useClickOutside from 'hooks/useClickOutside';
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
@@ -103,6 +104,10 @@ interface IResourceAttribute {
const DEFAULT_RESOURCE_ATTRIBUTES = {
serviceName: 'service.name',
name: 'name',
spanId: 'span_id',
spanKind: 'kind_string',
statusCodeString: 'status_code_string',
statusMessage: 'status_message',
};
// eslint-disable-next-line sonarjs/cognitive-complexity
@@ -835,6 +840,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.spanId}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.spanId,
value: selectedSpan.spanId,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
<div className="item">
<Typography.Text className="attribute-key">start time</Typography.Text>
@@ -863,6 +878,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
</Typography.Text>
</Tooltip>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.serviceName,
value: selectedSpan.serviceName,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
</div>
<div className="item">
@@ -872,6 +897,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.spanKind}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.spanKind,
value: selectedSpan.spanKind,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
<div className="item">
<Typography.Text className="attribute-key">
@@ -882,6 +917,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
{selectedSpan.statusCodeString}
</Typography.Text>
</div>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.statusCodeString,
value: selectedSpan.statusCodeString,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
{selectedSpan.statusMessage && (
@@ -891,6 +936,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
attributeValue={selectedSpan.statusMessage}
onExpand={showStatusMessageModal}
/>
<div className="attribute-actions-wrapper">
<AttributeActions
record={{
field: DEFAULT_RESOURCE_ATTRIBUTES.statusMessage,
value: selectedSpan.statusMessage,
}}
showPinned={false}
showCopyOptions={false}
/>
</div>
</div>
)}
<div className="item">

View File

@@ -0,0 +1,247 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import AttributeActions from '../Attributes/AttributeActions';
// Mock only Popover from antd to simplify hover/open behavior while keeping other components real
jest.mock('antd', () => {
const actual = jest.requireActual('antd');
const MockPopover = ({
content,
children,
open,
onOpenChange,
...rest
}: any): JSX.Element => (
<div
data-testid="mock-popover-wrapper"
onMouseEnter={(): void => onOpenChange?.(true)}
{...rest}
>
{children}
{open ? <div data-testid="mock-popover-content">{content}</div> : null}
</div>
);
return { ...actual, Popover: MockPopover };
});
// Mock getAggregateKeys API used inside useTraceActions to resolve autocomplete keys
jest.mock('api/queryBuilder/getAttributeKeys', () => ({
getAggregateKeys: jest.fn().mockResolvedValue({
payload: {
attributeKeys: [
{
key: 'http.method',
dataType: 'string',
type: 'tag',
isColumn: true,
},
],
},
}),
}));
const record = { field: 'http.method', value: 'GET' };
describe('AttributeActions (unit)', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('renders core action buttons (pin, filter in/out, more)', async () => {
render(<AttributeActions record={record} isPinned={false} />);
expect(
screen.getByRole('button', { name: 'Pin attribute' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter for value' }),
).toBeInTheDocument();
expect(
screen.getByRole('button', { name: 'Filter out value' }),
).toBeInTheDocument();
// more actions (ellipsis) button
expect(
document.querySelector('.lucide-ellipsis')?.closest('button'),
).toBeInTheDocument();
});
it('applies "Filter for" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterForBtn = screen.getByRole('button', { name: 'Filter for value' });
await userEvent.click(filterForBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('applies "Filter out" and calls redirectWithQueryBuilderData with correct query', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const filterOutBtn = screen.getByRole('button', { name: 'Filter out value' });
await userEvent.click(filterOutBtn);
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
filters: expect.objectContaining({
items: expect.arrayContaining([
expect.objectContaining({
key: expect.objectContaining({ key: 'http.method' }),
op: '!=',
value: 'GET',
}),
]),
}),
}),
]),
}),
}),
{},
expect.any(String),
);
});
});
it('opens more actions on hover and calls Group By handler; closes after click', async () => {
const redirectWithQueryBuilderData = jest.fn();
const currentQuery = {
builder: {
queryData: [
{
aggregateOperator: 'count',
aggregateAttribute: { key: 'signoz_span_duration' },
filters: { items: [], op: 'AND' },
filter: { expression: '' },
groupBy: [],
},
],
},
} as any;
render(<AttributeActions record={record} />, undefined, {
queryBuilderOverrides: { currentQuery, redirectWithQueryBuilderData },
});
const ellipsisBtn = document
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
expect(ellipsisBtn).toBeInTheDocument();
// hover to trigger Popover open via mock
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
// content appears
await waitFor(() =>
expect(screen.getByText('Group By Attribute')).toBeInTheDocument(),
);
await userEvent.click(screen.getByText('Group By Attribute'));
await waitFor(() => {
expect(redirectWithQueryBuilderData).toHaveBeenCalledWith(
expect.objectContaining({
builder: expect.objectContaining({
queryData: expect.arrayContaining([
expect.objectContaining({
groupBy: expect.arrayContaining([
expect.objectContaining({ key: 'http.method' }),
]),
}),
]),
}),
}),
{},
expect.any(String),
);
});
// After clicking group by, popover should close
await waitFor(() =>
expect(screen.queryByTestId('mock-popover-content')).not.toBeInTheDocument(),
);
});
it('hides pin button when showPinned=false', async () => {
render(<AttributeActions record={record} showPinned={false} />);
expect(
screen.queryByRole('button', { name: /pin attribute/i }),
).not.toBeInTheDocument();
});
it('hides copy options when showCopyOptions=false', async () => {
render(<AttributeActions record={record} showCopyOptions={false} />);
const ellipsisBtn = document
.querySelector('.lucide-ellipsis')
?.closest('button') as HTMLElement;
fireEvent.mouseEnter(ellipsisBtn.parentElement as Element);
await waitFor(() =>
expect(screen.queryByText('Copy Field Name')).not.toBeInTheDocument(),
);
expect(screen.queryByText('Copy Field Value')).not.toBeInTheDocument();
});
});

View File

@@ -11,7 +11,7 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
import EmptyMetricsSearch from 'container/MetricsExplorer/Explorer/EmptyMetricsSearch';
import { MetricsLoading } from 'container/MetricsExplorer/MetricsLoading/MetricsLoading';
import NoLogs from 'container/NoLogs/NoLogs';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import { TracesLoading } from 'container/TracesExplorer/TraceLoading/TraceLoading';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';

View File

@@ -1,77 +0,0 @@
import GetMinMax, { GetMinMaxPayload } from 'lib/getMinMax';
import { Time } from '../DateTimeSelection/config';
import { CustomTimeType, Time as TimeV2 } from '../DateTimeSelectionV2/config';
export const options: IOptions[] = [
{
label: 'off',
key: 'off',
value: 0,
},
{
label: '5s',
key: '5s',
value: 5000,
},
{
label: '10s',
key: '10s',
value: 10000,
},
{
label: '30s',
key: '30s',
value: 30000,
},
{
label: '1m',
key: '1m',
value: 60000,
},
{
label: '5m',
key: '5m',
value: 300000,
},
{
label: '10m',
key: '10m',
value: 600000,
},
{
label: '30m',
key: '30m',
value: 1800000,
},
{
label: '1h',
key: '1h',
value: 3600000,
},
{
label: '2h',
key: '2h',
value: 7200000,
},
{
label: '1d',
key: '1d',
value: 86400000,
},
];
export interface IOptions {
label: string;
key: string;
value: number;
}
export const getMinMax = (
selectedTime: Time | TimeV2 | CustomTimeType,
minTime: number,
maxTime: number,
): GetMinMaxPayload =>
selectedTime !== 'custom'
? GetMinMax(selectedTime)
: GetMinMax(selectedTime, [minTime, maxTime]);

View File

@@ -1,202 +0,0 @@
import { CaretDownFilled } from '@ant-design/icons';
import {
Checkbox,
Divider,
Popover,
Radio,
RadioChangeEvent,
Space,
Typography,
} from 'antd';
import { CheckboxChangeEvent } from 'antd/lib/checkbox';
import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import useUrlQuery from 'hooks/useUrlQuery';
import _omit from 'lodash-es/omit';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import { useLocation } from 'react-router-dom';
import { useInterval } from 'react-use';
import { Dispatch } from 'redux';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import {
UPDATE_AUTO_REFRESH_INTERVAL,
UPDATE_TIME_INTERVAL,
} from 'types/actions/globalTime';
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { getMinMax, options } from './config';
import { ButtonContainer, Container } from './styles';
function AutoRefresh({
disabled = false,
showAutoRefreshBtnPrimary = true,
}: AutoRefreshProps): JSX.Element {
const globalTime = useSelector<AppState, GlobalReducer>(
(state) => state.globalTime,
);
const { pathname } = useLocation();
const isDisabled = useMemo(
() =>
disabled ||
globalTime.isAutoRefreshDisabled ||
globalTime.selectedTime === 'custom',
[globalTime.isAutoRefreshDisabled, disabled, globalTime.selectedTime],
);
const localStorageData = JSON.parse(get(DASHBOARD_TIME_IN_DURATION) || '{}');
const localStorageValue = useMemo(() => localStorageData[pathname], [
pathname,
localStorageData,
]);
const [isAutoRefreshEnabled, setIsAutoRefreshfreshEnabled] = useState<boolean>(
Boolean(localStorageValue),
);
const dispatch = useDispatch<Dispatch<AppActions>>();
useEffect(() => {
const isAutoRefreshEnabled = Boolean(localStorageValue);
dispatch({
type: UPDATE_AUTO_REFRESH_INTERVAL,
payload: localStorageValue,
});
setIsAutoRefreshfreshEnabled(isAutoRefreshEnabled);
}, [localStorageValue, dispatch]);
const params = useUrlQuery();
const [selectedOption, setSelectedOption] = useState<string>(
localStorageValue || options[0].key,
);
useEffect(() => {
setSelectedOption(localStorageValue || options[0].key);
}, [localStorageValue, params]);
const getOption = useMemo(
() => options.find((option) => option.key === selectedOption),
[selectedOption],
);
useInterval(() => {
const selectedValue = getOption?.value;
if (isDisabled || !isAutoRefreshEnabled) {
return;
}
if (selectedOption !== 'off' && selectedValue) {
const { maxTime, minTime } = getMinMax(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
);
dispatch({
type: UPDATE_TIME_INTERVAL,
payload: {
maxTime,
minTime,
selectedTime: globalTime.selectedTime,
},
});
}
}, getOption?.value || 0);
const onChangeHandler = useCallback(
(event: RadioChangeEvent) => {
const selectedValue = event.target.value;
setSelectedOption(selectedValue);
params.set(DASHBOARD_TIME_IN_DURATION, selectedValue);
set(
DASHBOARD_TIME_IN_DURATION,
JSON.stringify({ ...localStorageData, [pathname]: selectedValue }),
);
setIsAutoRefreshfreshEnabled(true);
},
[params, pathname, localStorageData],
);
const onChangeAutoRefreshHandler = useCallback(
(event: CheckboxChangeEvent) => {
const { checked } = event.target;
if (!checked) {
// remove the path from localstorage
set(
DASHBOARD_TIME_IN_DURATION,
JSON.stringify(_omit(localStorageData, pathname)),
);
}
setIsAutoRefreshfreshEnabled(checked);
},
[localStorageData, pathname],
);
if (globalTime.selectedTime === 'custom') {
// eslint-disable-next-line react/jsx-no-useless-fragment
return <></>;
}
return (
<Popover
getPopupContainer={popupContainer}
placement="bottomLeft"
trigger={['click']}
content={
<Container>
<Checkbox
onChange={onChangeAutoRefreshHandler}
checked={isAutoRefreshEnabled}
disabled={isDisabled}
>
Auto Refresh
</Checkbox>
<Divider />
<Typography.Paragraph disabled={isDisabled}>
Refresh Interval
</Typography.Paragraph>
<Radio.Group onChange={onChangeHandler} value={selectedOption}>
<Space direction="vertical">
{options
.filter((e) => e.label !== 'off')
.map((option) => (
<Radio disabled={isDisabled} key={option.key} value={option.key}>
{option.label}
</Radio>
))}
</Space>
</Radio.Group>
</Container>
}
>
<ButtonContainer
title="Set auto refresh"
type={showAutoRefreshBtnPrimary ? 'primary' : 'default'}
>
<CaretDownFilled />
</ButtonContainer>
</Popover>
);
}
interface AutoRefreshProps {
disabled?: boolean;
showAutoRefreshBtnPrimary?: boolean;
}
AutoRefresh.defaultProps = {
disabled: false,
showAutoRefreshBtnPrimary: true,
};
export default AutoRefresh;

View File

@@ -1,13 +0,0 @@
import { Button } from 'antd';
import styled from 'styled-components';
export const Container = styled.div`
min-width: 8rem;
`;
export const ButtonContainer = styled(Button)`
&&& {
padding-left: 0.5rem;
padding-right: 0.5rem;
}
`;

View File

@@ -1,9 +1,10 @@
import GetMinMax, { GetMinMaxPayload } from 'lib/getMinMax';
export interface IOptions {
label: string;
key: string;
value: number;
}
import { Time } from '../DateTimeSelection/config';
import { CustomTimeType, Time as TimeV2 } from '../DateTimeSelectionV2/config';
export const options: IOptions[] = [
export const refreshIntervalOptions: IOptions[] = [
{
label: 'off',
key: 'off',
@@ -60,18 +61,3 @@ export const options: IOptions[] = [
value: 86400000,
},
];
export interface IOptions {
label: string;
key: string;
value: number;
}
export const getMinMax = (
selectedTime: Time | TimeV2 | CustomTimeType,
minTime: number,
maxTime: number,
): GetMinMaxPayload =>
selectedTime !== 'custom'
? GetMinMax(selectedTime)
: GetMinMax(selectedTime, [minTime, maxTime]);

View File

@@ -7,6 +7,7 @@ import get from 'api/browser/localstorage/get';
import set from 'api/browser/localstorage/set';
import { DASHBOARD_TIME_IN_DURATION } from 'constants/app';
import useUrlQuery from 'hooks/useUrlQuery';
import { getMinMaxForSelectedTime } from 'lib/getMinMax';
import _omit from 'lodash-es/omit';
import { Check } from 'lucide-react';
import { useCallback, useEffect, useMemo, useState } from 'react';
@@ -23,7 +24,7 @@ import {
import { GlobalReducer } from 'types/reducer/globalTime';
import { popupContainer } from 'utils/selectPopupContainer';
import { getMinMax, options } from './config';
import { refreshIntervalOptions } from './constants';
import { ButtonContainer } from './styles';
const DEFAULT_REFRESH_INTERVAL = '30s';
@@ -70,20 +71,23 @@ function AutoRefresh({
const params = useUrlQuery();
const defaultOption = useMemo(
() => options.find((option) => option.key === DEFAULT_REFRESH_INTERVAL),
() =>
refreshIntervalOptions.find(
(option) => option.key === DEFAULT_REFRESH_INTERVAL,
),
[],
);
const [selectedOption, setSelectedOption] = useState<string>(
localStorageValue || options[0].key,
localStorageValue || refreshIntervalOptions[0].key,
);
useEffect(() => {
setSelectedOption(localStorageValue || options[0].key);
setSelectedOption(localStorageValue || refreshIntervalOptions[0].key);
}, [localStorageValue, params, defaultOption]);
const getOption = useMemo(
() => options.find((option) => option.key === selectedOption),
() => refreshIntervalOptions.find((option) => option.key === selectedOption),
[selectedOption],
);
@@ -95,7 +99,7 @@ function AutoRefresh({
}
if (selectedOption !== 'off' && selectedValue) {
const { maxTime, minTime } = getMinMax(
const { maxTime, minTime } = getMinMaxForSelectedTime(
globalTime.selectedTime,
globalTime.minTime,
globalTime.maxTime,
@@ -175,7 +179,7 @@ function AutoRefresh({
>
Refresh Interval
</Typography.Paragraph>
{options
{refreshIntervalOptions
.filter((e) => e.label !== 'off')
.map((option) => (
<Button

View File

@@ -1,3 +0,0 @@
.date-time-selection-container {
margin-bottom: 16px;
}

View File

@@ -1,36 +0,0 @@
import { useEffect, useState } from 'react';
import { RefreshTextContainer, Typography } from './styles';
function RefreshText({
onLastRefreshHandler,
refreshButtonHidden,
}: RefreshTextProps): JSX.Element {
const [refreshText, setRefreshText] = useState<string>('');
// this is to update the refresh text
useEffect(() => {
const interval = setInterval(() => {
const text = onLastRefreshHandler();
if (refreshText !== text) {
setRefreshText(text);
}
}, 2000);
return (): void => {
clearInterval(interval);
};
}, [onLastRefreshHandler, refreshText]);
return (
<RefreshTextContainer refreshButtonHidden={refreshButtonHidden}>
<Typography>{refreshText}</Typography>
</RefreshTextContainer>
);
}
interface RefreshTextProps {
onLastRefreshHandler: () => string;
refreshButtonHidden: boolean;
}
export default RefreshText;

View File

@@ -1,143 +0,0 @@
import ROUTES from 'constants/routes';
type FiveMin = '5m';
type TenMin = '10m';
type FifteenMin = '15m';
type ThirtyMin = '30m';
type OneMin = '1m';
type SixHour = '6h';
type OneHour = '1h';
type FourHour = '4h';
type ThreeHour = '3h';
type TwelveHour = '12h';
type OneDay = '1d';
type ThreeDay = '3d';
type OneWeek = '1w';
type Custom = 'custom';
export type Time =
| FiveMin
| TenMin
| FifteenMin
| ThirtyMin
| OneMin
| FourHour
| SixHour
| OneHour
| ThreeHour
| Custom
| OneWeek
| OneDay
| TwelveHour
| ThreeDay;
export const Options: Option[] = [
{ value: '5m', label: 'Last 5 min' },
{ value: '15m', label: 'Last 15 min' },
{ value: '30m', label: 'Last 30 min' },
{ value: '1h', label: 'Last 1 hour' },
{ value: '6h', label: 'Last 6 hour' },
{ value: '1d', label: 'Last 1 day' },
{ value: '3d', label: 'Last 3 days' },
{ value: '1w', label: 'Last 1 week' },
{ value: 'custom', label: 'Custom' },
];
type TimeFrame = {
'5min': string;
'15min': string;
'30min': string;
'1hr': string;
'6hr': string;
'1day': string;
'3days': string;
'1week': string;
[key: string]: string; // Index signature to allow any string as index
};
export const RelativeTimeMap: TimeFrame = {
'5min': '5m',
'15min': '15m',
'30min': '30m',
'1hr': '1h',
'6hr': '6h',
'1day': '1d',
'3days': '3d',
'1week': '1w',
};
export interface Option {
value: Time;
label: string;
}
export const RelativeDurationOptions: Option[] = [
{ value: '5m', label: 'Last 5 min' },
{ value: '15m', label: 'Last 15 min' },
{ value: '30m', label: 'Last 30 min' },
{ value: '1h', label: 'Last 1 hour' },
{ value: '6h', label: 'Last 6 hour' },
{ value: '1d', label: 'Last 1 day' },
{ value: '3d', label: 'Last 3 days' },
{ value: '1w', label: 'Last 1 week' },
];
export const getDefaultOption = (route: string): Time => {
if (route === ROUTES.SERVICE_MAP) {
return RelativeDurationOptions[2].value;
}
if (route === ROUTES.APPLICATION) {
return Options[2].value;
}
return Options[2].value;
};
export const getOptions = (routes: string): Option[] => {
if (routes === ROUTES.SERVICE_MAP) {
return RelativeDurationOptions;
}
return Options;
};
export const routesToHideBreadCrumbs = [ROUTES.SUPPORT, ROUTES.ALL_DASHBOARD];
export const routesToSkip = [
ROUTES.HOME,
ROUTES.SETTINGS,
ROUTES.LIST_ALL_ALERT,
ROUTES.TRACE_DETAIL,
ROUTES.ALL_CHANNELS,
ROUTES.USAGE_EXPLORER,
ROUTES.GET_STARTED,
ROUTES.GET_STARTED_WITH_CLOUD,
ROUTES.GET_STARTED_APPLICATION_MONITORING,
ROUTES.GET_STARTED_INFRASTRUCTURE_MONITORING,
ROUTES.GET_STARTED_LOGS_MANAGEMENT,
ROUTES.GET_STARTED_AWS_MONITORING,
ROUTES.GET_STARTED_AZURE_MONITORING,
ROUTES.VERSION,
ROUTES.ALL_DASHBOARD,
ROUTES.ORG_SETTINGS,
ROUTES.INGESTION_SETTINGS,
ROUTES.ERROR_DETAIL,
ROUTES.LOGS_PIPELINES,
ROUTES.BILLING,
ROUTES.SUPPORT,
ROUTES.WORKSPACE_LOCKED,
ROUTES.WORKSPACE_SUSPENDED,
ROUTES.LOGS,
ROUTES.MY_SETTINGS,
ROUTES.LIST_LICENSES,
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
export interface LocalStorageTimeRange {
localstorageStartTime: string | null;
localstorageEndTime: string | null;
}
export interface TimeRange {
startTime: string;
endTime: string;
}

View File

@@ -1,425 +0,0 @@
import './DateTimeSelection.styles.scss';
import { SyncOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import getLocalStorageKey from 'api/browser/localstorage/get';
import setLocalStorageKey from 'api/browser/localstorage/set';
import CustomTimePicker from 'components/CustomTimePicker/CustomTimePicker';
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
import { LOCALSTORAGE } from 'constants/localStorage';
import { QueryParams } from 'constants/query';
import ROUTES from 'constants/routes';
import dayjs, { Dayjs } from 'dayjs';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { updateStepInterval } from 'hooks/queryBuilder/useStepInterval';
import useUrlQuery from 'hooks/useUrlQuery';
import GetMinMax from 'lib/getMinMax';
import getTimeString from 'lib/getTimeString';
import history from 'lib/history';
import { isObject } from 'lodash-es';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { connect, useSelector } from 'react-redux';
import { RouteComponentProps, withRouter } from 'react-router-dom';
import { bindActionCreators, Dispatch } from 'redux';
import { ThunkDispatch } from 'redux-thunk';
import { GlobalTimeLoading, UpdateTimeInterval } from 'store/actions';
import { AppState } from 'store/reducers';
import AppActions from 'types/actions';
import { GlobalReducer } from 'types/reducer/globalTime';
import AutoRefresh from '../AutoRefresh';
import CustomDateTimeModal, { DateTimeRangeType } from '../CustomDateTimeModal';
import { CustomTimeType, Time as TimeV2 } from '../DateTimeSelectionV2/config';
import {
getDefaultOption,
getOptions,
LocalStorageTimeRange,
Time,
TimeRange,
} from './config';
import RefreshText from './Refresh';
import { Form, FormContainer, FormItem } from './styles';
function DateTimeSelection({
location,
updateTimeInterval,
globalTimeLoading,
}: Props): JSX.Element {
const [formSelector] = Form.useForm();
const [hasSelectedTimeError, setHasSelectedTimeError] = useState(false);
const [isOpen, setIsOpen] = useState<boolean>(false);
const urlQuery = useUrlQuery();
const searchStartTime = urlQuery.get('startTime');
const searchEndTime = urlQuery.get('endTime');
const {
localstorageStartTime,
localstorageEndTime,
} = ((): LocalStorageTimeRange => {
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[location.pathname];
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
}
if (isObject(parsedSelectedTime)) {
return {
localstorageStartTime: parsedSelectedTime.startTime,
localstorageEndTime: parsedSelectedTime.endTime,
};
}
return { localstorageStartTime: null, localstorageEndTime: null };
}
}
return { localstorageStartTime: null, localstorageEndTime: null };
})();
const getTime = useCallback((): [number, number] | undefined => {
if (searchEndTime && searchStartTime) {
const startDate = dayjs(
new Date(parseInt(getTimeString(searchStartTime), 10)),
);
const endDate = dayjs(new Date(parseInt(getTimeString(searchEndTime), 10)));
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
}
if (localstorageStartTime && localstorageEndTime) {
const startDate = dayjs(localstorageStartTime);
const endDate = dayjs(localstorageEndTime);
return [startDate.toDate().getTime() || 0, endDate.toDate().getTime() || 0];
}
return undefined;
}, [
localstorageEndTime,
localstorageStartTime,
searchEndTime,
searchStartTime,
]);
const [options, setOptions] = useState(getOptions(location.pathname));
const [refreshButtonHidden, setRefreshButtonHidden] = useState<boolean>(false);
const [customDateTimeVisible, setCustomDTPickerVisible] = useState<boolean>(
false,
);
const { stagedQuery, initQueryBuilderData } = useQueryBuilder();
const { maxTime, minTime, selectedTime } = useSelector<
AppState,
GlobalReducer
>((state) => state.globalTime);
const getInputLabel = (
startTime?: Dayjs,
endTime?: Dayjs,
timeInterval: Time | TimeV2 | CustomTimeType = '15m',
): string | Time => {
if (startTime && endTime && timeInterval === 'custom') {
const format = DATE_TIME_FORMATS.SLASH_DATETIME;
const startString = startTime.format(format);
const endString = endTime.format(format);
return `${startString} - ${endString}`;
}
return timeInterval;
};
useEffect(() => {
if (selectedTime === 'custom') {
setRefreshButtonHidden(true);
} else {
setRefreshButtonHidden(false);
}
}, [selectedTime]);
const getDefaultTime = (pathName: string): Time => {
const defaultSelectedOption = getDefaultOption(pathName);
const routes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (routes !== null) {
const routesObject = JSON.parse(routes || '{}');
const selectedTime = routesObject[pathName];
if (selectedTime) {
let parsedSelectedTime: TimeRange;
try {
parsedSelectedTime = JSON.parse(selectedTime);
} catch {
parsedSelectedTime = selectedTime;
}
if (isObject(parsedSelectedTime)) {
return 'custom';
}
return selectedTime;
}
}
return defaultSelectedOption;
};
const updateLocalStorageForRoutes = (value: Time | TimeV2 | string): void => {
const preRoutes = getLocalStorageKey(LOCALSTORAGE.METRICS_TIME_IN_DURATION);
if (preRoutes !== null) {
const preRoutesObject = JSON.parse(preRoutes);
const preRoute = {
...preRoutesObject,
};
preRoute[location.pathname] = value;
setLocalStorageKey(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify(preRoute),
);
}
};
const onLastRefreshHandler = useCallback(() => {
const currentTime = dayjs();
const lastRefresh = dayjs(
selectedTime === 'custom' ? minTime / 1000000 : maxTime / 1000000,
);
const secondsDiff = currentTime.diff(lastRefresh, 'seconds');
const minutedDiff = currentTime.diff(lastRefresh, 'minutes');
const hoursDiff = currentTime.diff(lastRefresh, 'hours');
const daysDiff = currentTime.diff(lastRefresh, 'days');
const monthsDiff = currentTime.diff(lastRefresh, 'months');
if (monthsDiff > 0) {
return `Last refresh -${monthsDiff} months ago`;
}
if (daysDiff > 0) {
return `Last refresh - ${daysDiff} days ago`;
}
if (hoursDiff > 0) {
return `Last refresh - ${hoursDiff} hrs ago`;
}
if (minutedDiff > 0) {
return `Last refresh - ${minutedDiff} mins ago`;
}
return `Last refresh - ${secondsDiff} sec ago`;
}, [maxTime, minTime, selectedTime]);
const isLogsExplorerPage = useMemo(
() => location.pathname === ROUTES.LOGS_EXPLORER,
[location.pathname],
);
const onSelectHandler = (value: Time | TimeV2 | CustomTimeType): void => {
if (value !== 'custom') {
updateTimeInterval(value);
updateLocalStorageForRoutes(value);
if (refreshButtonHidden) {
setRefreshButtonHidden(false);
}
} else {
setRefreshButtonHidden(true);
setCustomDTPickerVisible(true);
}
if (!isLogsExplorerPage) {
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
}
if (!stagedQuery) {
return;
}
initQueryBuilderData(updateStepInterval(stagedQuery));
};
const onRefreshHandler = (): void => {
onSelectHandler(selectedTime);
onLastRefreshHandler();
};
const onCustomDateHandler = (dateTimeRange: DateTimeRangeType): void => {
if (dateTimeRange !== null) {
const [startTimeMoment, endTimeMoment] = dateTimeRange;
if (startTimeMoment && endTimeMoment) {
updateTimeInterval('custom', [
startTimeMoment?.toDate().getTime() || 0,
endTimeMoment?.toDate().getTime() || 0,
]);
updateLocalStorageForRoutes(
JSON.stringify({ startTime: startTimeMoment, endTime: endTimeMoment }),
);
if (!isLogsExplorerPage) {
urlQuery.set(
QueryParams.startTime,
startTimeMoment?.toDate().getTime().toString(),
);
urlQuery.set(
QueryParams.endTime,
endTimeMoment?.toDate().getTime().toString(),
);
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.push(generatedUrl);
}
}
}
};
// this is triggred when we change the routes and based on that we are changing the default options
useEffect(() => {
const metricsTimeDuration = getLocalStorageKey(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
);
if (metricsTimeDuration === null) {
setLocalStorageKey(
LOCALSTORAGE.METRICS_TIME_IN_DURATION,
JSON.stringify({}),
);
}
const currentRoute = location.pathname;
const time = getDefaultTime(currentRoute);
const currentOptions = getOptions(currentRoute);
setOptions(currentOptions);
const getCustomOrIntervalTime = (time: Time): Time => {
if (searchEndTime !== null && searchStartTime !== null) {
return 'custom';
}
if (
(localstorageEndTime === null || localstorageStartTime === null) &&
time === 'custom'
) {
return getDefaultOption(currentRoute);
}
return time;
};
const updatedTime = getCustomOrIntervalTime(time);
const [preStartTime = 0, preEndTime = 0] = getTime() || [];
setRefreshButtonHidden(updatedTime === 'custom');
updateTimeInterval(updatedTime, [preStartTime, preEndTime]);
if (updatedTime !== 'custom') {
const { minTime, maxTime } = GetMinMax(updatedTime);
urlQuery.set(QueryParams.startTime, minTime.toString());
urlQuery.set(QueryParams.endTime, maxTime.toString());
} else {
urlQuery.set(QueryParams.startTime, preStartTime.toString());
urlQuery.set(QueryParams.endTime, preEndTime.toString());
}
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
history.replace(generatedUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [location.pathname, updateTimeInterval, globalTimeLoading]);
return (
<div className="date-time-selection-container">
<Form
form={formSelector}
layout="inline"
initialValues={{ interval: selectedTime }}
>
<FormContainer>
<CustomTimePicker
open={isOpen}
setOpen={setIsOpen}
onSelect={(value: unknown): void => {
onSelectHandler(value as Time);
}}
onError={(hasError: boolean): void => {
setHasSelectedTimeError(hasError);
}}
selectedTime={selectedTime}
onValidCustomDateChange={(dateTime): void =>
onCustomDateHandler(dateTime.time as DateTimeRangeType)
}
selectedValue={getInputLabel(
dayjs(minTime / 1000000),
dayjs(maxTime / 1000000),
selectedTime,
)}
data-testid="dropDown"
items={options}
minTime={minTime}
maxTime={maxTime}
/>
<FormItem hidden={refreshButtonHidden}>
<Button
icon={<SyncOutlined />}
type="primary"
onClick={onRefreshHandler}
/>
</FormItem>
<FormItem>
<AutoRefresh disabled={refreshButtonHidden} />
</FormItem>
</FormContainer>
</Form>
{!hasSelectedTimeError && selectedTime !== 'custom' && (
<RefreshText
{...{
onLastRefreshHandler,
}}
refreshButtonHidden={refreshButtonHidden}
/>
)}
<CustomDateTimeModal
visible={customDateTimeVisible}
onCreate={onCustomDateHandler}
onCancel={(): void => {
setCustomDTPickerVisible(false);
}}
setCustomDTPickerVisible={setCustomDTPickerVisible}
/>
</div>
);
}
interface DispatchProps {
updateTimeInterval: (
interval: Time | TimeV2 | CustomTimeType,
dateTimeRange?: [number, number],
) => (dispatch: Dispatch<AppActions>) => void;
globalTimeLoading: () => void;
}
const mapDispatchToProps = (
dispatch: ThunkDispatch<unknown, unknown, AppActions>,
): DispatchProps => ({
updateTimeInterval: bindActionCreators(UpdateTimeInterval, dispatch),
globalTimeLoading: bindActionCreators(GlobalTimeLoading, dispatch),
});
type Props = DispatchProps & RouteComponentProps;
export default connect(null, mapDispatchToProps)(withRouter(DateTimeSelection));

View File

@@ -1,34 +0,0 @@
import { Form as FormComponent, Typography as TypographyComponent } from 'antd';
import styled from 'styled-components';
export const Form = styled(FormComponent)`
&&& {
justify-content: flex-end;
}
`;
export const Typography = styled(TypographyComponent)`
&&& {
text-align: right;
}
`;
export const FormItem = styled(Form.Item)`
&&& {
margin: 0;
}
`;
interface Props {
refreshButtonHidden: boolean;
}
export const RefreshTextContainer = styled.div<Props>`
visibility: ${({ refreshButtonHidden }): string =>
refreshButtonHidden ? 'hidden' : 'visible'};
`;
export const FormContainer = styled.div`
display: flex;
gap: 0.1rem;
`;

View File

@@ -1,54 +1,6 @@
/* eslint-disable sonarjs/no-duplicate-string */
import ROUTES from 'constants/routes';
type FiveMin = '5m';
type TenMin = '10m';
type FifteenMin = '15m';
type ThirtyMin = '30m';
type FortyFiveMin = '45m';
type OneMin = '1m';
type ThreeHour = '3h';
type SixHour = '6h';
type OneHour = '1h';
type FourHour = '4h';
type TwelveHour = '12h';
type OneDay = '1d';
type ThreeDay = '3d';
type FourDay = '4d';
type TenDay = '10d';
type OneWeek = '1w';
type TwoWeek = '2w';
type SixWeek = '6w';
type OneMonth = '1month';
type TwoMonths = '2months';
type Custom = 'custom';
export type Time =
| FiveMin
| TenMin
| FifteenMin
| ThirtyMin
| OneMin
| ThreeHour
| FourHour
| SixHour
| OneHour
| Custom
| OneWeek
| SixWeek
| OneDay
| FourDay
| ThreeDay
| FortyFiveMin
| TwelveHour
| TenDay
| TwoWeek
| OneMonth
| TwoMonths;
export type TimeUnit = 'm' | 'h' | 'd' | 'w';
export type CustomTimeType = `${string}${TimeUnit}`;
import { CustomTimeType, Option, Time, TimeFrame } from './types';
export const Options: Option[] = [
{ value: '5m', label: 'Last 5 minutes' },
@@ -63,10 +15,16 @@ export const Options: Option[] = [
{ value: 'custom', label: 'Custom Date Range' },
];
export interface Option {
value: Time;
label: string;
}
export const RelativeTimeMap: TimeFrame = {
'5min': '5m',
'15min': '15m',
'30min': '30m',
'1hr': '1h',
'6hr': '6h',
'1day': '1d',
'3days': '3d',
'1week': '1w',
};
export const OLD_RELATIVE_TIME_VALUES = [
'1min',
@@ -244,18 +202,3 @@ export const routesToSkip = [
];
export const routesToDisable = [ROUTES.LOGS_EXPLORER, ROUTES.LIVE_LOGS];
export interface LocalStorageTimeRange {
localstorageStartTime: string | null;
localstorageEndTime: string | null;
}
export interface TimeRange {
startTime: string;
endTime: string;
}
export enum LexicalContext {
CUSTOM_DATE_PICKER = 'customDatePicker',
CUSTOM_DATE_TIME_INPUT = 'customDateTimeInput',
}

View File

@@ -35,19 +35,21 @@ import { v4 as uuid } from 'uuid';
import AutoRefresh from '../AutoRefreshV2';
import { DateTimeRangeType } from '../CustomDateTimeModal';
import { RelativeTimeMap } from '../DateTimeSelection/config';
import {
convertOldTimeToNewValidCustomTimeFormat,
CustomTimeType,
getDefaultOption,
getOptions,
LocalStorageTimeRange,
OLD_RELATIVE_TIME_VALUES,
Time,
TimeRange,
} from './config';
RelativeTimeMap,
} from './constants';
import RefreshText from './Refresh';
import { Form, FormContainer, FormItem } from './styles';
import {
CustomTimeType,
LocalStorageTimeRange,
Time,
TimeRange,
} from './types';
function DateTimeSelection({
showAutoRefresh,

View File

@@ -0,0 +1,80 @@
type FiveMin = '5m';
type TenMin = '10m';
type FifteenMin = '15m';
type ThirtyMin = '30m';
type FortyFiveMin = '45m';
type OneMin = '1m';
type ThreeHour = '3h';
type SixHour = '6h';
type OneHour = '1h';
type FourHour = '4h';
type TwelveHour = '12h';
type OneDay = '1d';
type ThreeDay = '3d';
type FourDay = '4d';
type TenDay = '10d';
type OneWeek = '1w';
type TwoWeek = '2w';
type SixWeek = '6w';
type OneMonth = '1month';
type TwoMonths = '2months';
type Custom = 'custom';
export type Time =
| FiveMin
| TenMin
| FifteenMin
| ThirtyMin
| OneMin
| ThreeHour
| FourHour
| SixHour
| OneHour
| Custom
| OneWeek
| SixWeek
| OneDay
| FourDay
| ThreeDay
| FortyFiveMin
| TwelveHour
| TenDay
| TwoWeek
| OneMonth
| TwoMonths;
export type TimeUnit = 'm' | 'h' | 'd' | 'w';
export type CustomTimeType = `${string}${TimeUnit}`;
export interface Option {
value: Time;
label: string;
}
export type TimeFrame = {
'5min': string;
'15min': string;
'30min': string;
'1hr': string;
'6hr': string;
'1day': string;
'3days': string;
'1week': string;
[key: string]: string; // Index signature to allow any string as index
};
export interface LocalStorageTimeRange {
localstorageStartTime: string | null;
localstorageEndTime: string | null;
}
export interface TimeRange {
startTime: string;
endTime: string;
}
export enum LexicalContext {
CUSTOM_DATE_PICKER = 'customDatePicker',
CUSTOM_DATE_TIME_INPUT = 'customDateTimeInput',
}

View File

@@ -7,7 +7,7 @@ import { matchPath, useHistory } from 'react-router-dom';
import NewExplorerCTA from '../NewExplorerCTA';
import DateTimeSelector from './DateTimeSelectionV2';
import { routesToDisable, routesToSkip } from './DateTimeSelectionV2/config';
import { routesToDisable, routesToSkip } from './DateTimeSelectionV2/constants';
function TopNav(): JSX.Element | null {
const { location } = useHistory();

View File

@@ -12,7 +12,7 @@ import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
import NoLogs from 'container/NoLogs/NoLogs';
import { useOptionsMenu } from 'container/OptionsMenu';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/config';
import { CustomTimeType } from 'container/TopNav/DateTimeSelectionV2/types';
import TraceExplorerControls from 'container/TracesExplorer/Controls';
import { getListViewQuery } from 'container/TracesExplorer/explorerUtils';
import { useGetQueryRange } from 'hooks/queryBuilder/useGetQueryRange';

View File

@@ -0,0 +1,183 @@
/* eslint-disable sonarjs/no-duplicate-string */
import {
initialClickHouseData,
initialQueryBuilderFormValuesMap,
initialQueryPromQLData,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { IDashboardVariable, Widgets } from 'types/api/dashboard/getAll';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { Query } from 'types/api/queryBuilder/queryBuilderData';
import { EQueryType } from 'types/common/dashboard';
import { DataSource } from 'types/common/queryBuilder';
import { createDynamicVariableToWidgetsMap } from './utils';
const createMockDynamicVariable = (
overrides: Partial<IDashboardVariable> = {},
): IDashboardVariable => ({
id: 'var-1',
name: 'testVar',
description: '',
type: 'DYNAMIC',
sort: 'DISABLED',
multiSelect: false,
showALLOption: false,
dynamicVariablesAttribute: 'service.name',
...overrides,
});
const createBaseWidget = (id: string, query: Query): Widgets => ({
id,
title: 'Test Widget',
description: '',
panelTypes: PANEL_TYPES.TIME_SERIES,
opacity: '1',
nullZeroValues: '',
timePreferance: 'GLOBAL_TIME',
softMin: null,
softMax: null,
selectedLogFields: null,
selectedTracesFields: null,
query,
});
const createMockPromQLWidget = (
id: string,
queries: {
query: string;
name?: string;
legend?: string;
disabled?: boolean;
}[],
): Widgets => {
const promqlQueries = queries.map((q) => ({
...initialQueryPromQLData,
query: q.query,
name: q.name || 'A',
legend: q.legend || '',
disabled: q.disabled ?? false,
}));
const query: Query = {
queryType: EQueryType.PROM,
promql: promqlQueries,
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: 'query-1',
};
return createBaseWidget(id, query);
};
const createMockClickHouseWidget = (
id: string,
queries: {
query: string;
name?: string;
legend?: string;
disabled?: boolean;
}[],
): Widgets => {
const clickhouseQueries = queries.map((q) => ({
...initialClickHouseData,
query: q.query,
name: q.name || 'A',
legend: q.legend || '',
disabled: q.disabled ?? false,
}));
const query: Query = {
queryType: EQueryType.CLICKHOUSE,
promql: [],
builder: {
queryData: [],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: clickhouseQueries,
id: 'query-1',
};
return createBaseWidget(id, query);
};
const createMockQueryBuilderWidget = (
id: string,
filters: { key: string; value: string | string[]; op?: string }[],
): Widgets => {
const queryData = {
...initialQueryBuilderFormValuesMap[DataSource.LOGS],
queryName: 'A',
filters: {
items: filters.map((f, index) => ({
id: `filter-${index}`,
key: { key: f.key, dataType: DataTypes.String, type: '', id: f.key },
op: f.op || '=',
value: f.value,
})),
op: 'AND',
},
};
const query: Query = {
queryType: EQueryType.QUERY_BUILDER,
promql: [],
builder: {
queryData: [queryData],
queryFormulas: [],
queryTraceOperator: [],
},
clickhouse_sql: [],
id: 'query-1',
};
return createBaseWidget(id, query);
};
describe('createDynamicVariableToWidgetsMap', () => {
it('should handle widgets with different query types', () => {
const dynamicVariables = [
createMockDynamicVariable({
id: 'var-1',
name: 'service.name123',
dynamicVariablesAttribute: 'service.name',
}),
];
const widgets = [
createMockPromQLWidget('widget-promql-pass', [
{ query: 'up{service="$service.name123"}' },
]),
createMockPromQLWidget('widget-promql-fail', [
{ query: 'up{service="$service.name"}' },
]),
createMockClickHouseWidget('widget-clickhouse-pass', [
{ query: "SELECT * FROM logs WHERE service_name = '$service.name123'" },
]),
createMockClickHouseWidget('widget-clickhouse-fail', [
{ query: "SELECT * FROM logs WHERE service_name = '$service.name'" },
]),
createMockQueryBuilderWidget('widget-builder-pass', [
{ key: 'service.name', value: '$service.name123' },
]),
createMockQueryBuilderWidget('widget-builder-fail', [
{ key: 'service.name', value: '$service.name' },
]),
];
const result = createDynamicVariableToWidgetsMap(dynamicVariables, widgets);
expect(result['var-1']).toContain('widget-promql-pass');
expect(result['var-1']).toContain('widget-clickhouse-pass');
expect(result['var-1']).toContain('widget-builder-pass');
expect(result['var-1']).not.toContain('widget-promql-fail');
expect(result['var-1']).not.toContain('widget-clickhouse-fail');
expect(result['var-1']).not.toContain('widget-builder-fail');
});
});

View File

@@ -104,10 +104,9 @@ export const createDynamicVariableToWidgetsMap = (
// Check each widget for usage of dynamic variables
if (Array.isArray(widgets)) {
widgets.forEach((widget) => {
if (
widget.query?.builder?.queryData &&
widget.query?.queryType === EQueryType.QUERY_BUILDER
) {
if (widget.query?.queryType === EQueryType.QUERY_BUILDER) {
if (!Array.isArray(widget.query.builder.queryData)) return;
widget.query.builder.queryData.forEach((queryData: IBuilderQuery) => {
// Check filter items for dynamic variables
queryData.filters?.items?.forEach((filter: TagFilterItem) => {
@@ -139,6 +138,34 @@ export const createDynamicVariableToWidgetsMap = (
});
}
});
} else if (widget.query?.queryType === EQueryType.PROM) {
if (!Array.isArray(widget.query.promql)) return;
widget.query.promql.forEach((promqlQuery) => {
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
promqlQuery.query?.includes(`$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
});
} else if (widget.query?.queryType === EQueryType.CLICKHOUSE) {
if (!Array.isArray(widget.query.clickhouse_sql)) return;
widget.query.clickhouse_sql.forEach((clickhouseQuery) => {
dynamicVariables.forEach((variable) => {
if (
variable.dynamicVariablesAttribute &&
clickhouseQuery.query?.includes(`$${variable.name}`) &&
!dynamicVariableToWidgetsMap[variable.id].includes(widget.id)
) {
dynamicVariableToWidgetsMap[variable.id].push(widget.id);
}
});
});
}
});
}

View File

@@ -1,10 +1,9 @@
import getService from 'api/metrics/getService';
import { AxiosError } from 'axios';
import { Time } from 'container/TopNav/DateTimeSelection/config';
import {
CustomTimeType,
Time as TimeV2,
} from 'container/TopNav/DateTimeSelectionV2/config';
Time,
} from 'container/TopNav/DateTimeSelectionV2/types';
import {
QueryKey,
useQuery,
@@ -30,7 +29,7 @@ export const useQueryService = ({
interface UseQueryServiceProps {
minTime: number;
maxTime: number;
selectedTime: Time | TimeV2 | CustomTimeType;
selectedTime: Time | CustomTimeType;
selectedTags: Tags[];
options?: UseQueryOptions<PayloadProps, AxiosError, PayloadProps, QueryKey>;
}

View File

@@ -0,0 +1,132 @@
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
import { convertValue, getFormattedUnit } from 'lib/getConvertedValue';
describe('getFormattedUnit', () => {
it('should return the grafana unit for universal unit if it exists', () => {
const formattedUnit = getFormattedUnit(UniversalYAxisUnit.KILOBYTES);
expect(formattedUnit).toBe('deckbytes');
});
it('should return the unit directly if it is not a universal unit', () => {
const formattedUnit = getFormattedUnit('{reason}');
expect(formattedUnit).toBe('{reason}');
});
it('should return the universal unit directly if it does not have a grafana equivalent', () => {
const formattedUnit = getFormattedUnit(UniversalYAxisUnit.EXABYTES);
expect(formattedUnit).toBe(UniversalYAxisUnit.EXABYTES);
});
});
describe('convertValue', () => {
describe('data', () => {
it('should convert bytes (IEC) to kilobytes', () => {
expect(
convertValue(
1000,
UniversalYAxisUnit.BYTES_IEC,
UniversalYAxisUnit.KILOBYTES,
),
).toBe(1);
});
it('should convert bytes (SI) to kilobytes', () => {
expect(
convertValue(1000, UniversalYAxisUnit.BYTES, UniversalYAxisUnit.KILOBYTES),
).toBe(1);
});
it('should convert kilobytes to bytes', () => {
expect(
convertValue(1, UniversalYAxisUnit.KILOBYTES, UniversalYAxisUnit.BYTES),
).toBe(1000);
});
it('should convert megabytes to kilobytes', () => {
expect(convertValue(1, 'mbytes', 'kbytes')).toBe(1024);
});
it('should convert gigabytes to megabytes', () => {
expect(convertValue(1, 'gbytes', 'mbytes')).toBe(1024);
});
it('should convert kilobytes to megabytes', () => {
expect(convertValue(1024, 'kbytes', 'mbytes')).toBe(1);
});
it('should convert bits to gigabytes', () => {
// 12 GB = 103079215104 bits
expect(convertValue(103079215104, 'bits', 'gbytes')).toBe(12);
});
});
describe('time', () => {
it('should convert milliseconds to seconds', () => {
expect(convertValue(1000, 'ms', 's')).toBe(1);
});
it('should convert seconds to milliseconds', () => {
expect(convertValue(1, 's', 'ms')).toBe(1000);
});
it('should convert nanoseconds to milliseconds', () => {
expect(convertValue(1000000, 'ns', 'ms')).toBe(1);
});
it('should convert seconds to minutes', () => {
expect(convertValue(60, 's', 'm')).toBe(1);
});
it('should convert minutes to hours', () => {
expect(convertValue(60, 'm', 'h')).toBe(1);
});
});
describe('data rate', () => {
it('should convert bytes/sec to kibibytes/sec', () => {
expect(convertValue(1024, 'binBps', 'KiBs')).toBe(1);
});
it('should convert kibibytes/sec to bytes/sec', () => {
expect(convertValue(1, 'KiBs', 'binBps')).toBe(1024);
});
});
describe('throughput', () => {
it('should convert counts per second to counts per minute', () => {
expect(convertValue(1, 'cps', 'cpm')).toBe(1 / 60);
});
it('should convert operations per second to operations per minute', () => {
expect(convertValue(1, 'ops', 'opm')).toBe(1 / 60);
});
it('should convert counts per minute to counts per second', () => {
expect(convertValue(1, 'cpm', 'cps')).toBe(60);
});
it('should convert operations per minute to operations per second', () => {
expect(convertValue(1, 'opm', 'ops')).toBe(60);
});
});
describe('percent', () => {
it('should convert percentunit to percent', () => {
expect(convertValue(0.5, 'percentunit', 'percent')).toBe(50);
});
it('should convert percent to percentunit', () => {
expect(convertValue(50, 'percent', 'percentunit')).toBe(0.5);
});
});
describe('invalid values', () => {
it('should return null when currentUnit is invalid', () => {
expect(convertValue(100, 'invalidUnit', 'bytes')).toBeNull();
});
it('should return null when targetUnit is invalid', () => {
expect(convertValue(100, 'bytes', 'invalidUnit')).toBeNull();
});
});
});

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