Compare commits

..

16 Commits

Author SHA1 Message Date
aniketio-ctrl
2a7b18e4d4 Merge branch 'main' into fix/delete-rule 2025-10-31 13:07:09 +05:30
aniketio-ctrl
a3af7bf2c6 Merge branch 'main' into fix/delete-rule 2025-10-30 16:56:52 +05:30
aniket
66c7dc9bdc Merge branch 'fix/delete-rule' of github.com:SigNoz/signoz into fix/delete-rule 2025-10-23 01:47:03 +05:30
aniket
e0a654182e fix: added silence_all column 2025-10-23 01:33:45 +05:30
aniketio-ctrl
7df6881a52 Merge branch 'main' into fix/delete-rule 2025-10-23 01:21:56 +05:30
aniket
55d4ba3ab7 fix: added silence_all column 2025-10-23 01:20:48 +05:30
aniket
6aa9601fe4 fix: added silence_all column 2025-10-23 01:00:12 +05:30
aniket
3f11ba9409 Merge branch 'main' of github.com:SigNoz/signoz into fix/delete-rule 2025-10-21 19:51:18 +05:30
aniketio-ctrl
88ff32d0bf Merge branch 'main' into fix/delete-rule 2025-10-08 17:32:15 +05:30
aniket
0634a88d80 fix: corrected test cases 2025-10-06 14:48:08 +05:30
aniket
cebc4df68c Merge branch 'fix/delete-rule' of github.com:SigNoz/signoz into fix/delete-rule 2025-10-06 14:32:27 +05:30
aniket
1a680579a6 fix: delte rule from planned maintainance 2025-10-06 14:31:58 +05:30
aniketio-ctrl
485f032155 Merge branch 'main' into fix/delete-rule 2025-10-06 14:24:05 +05:30
aniket
150efdecf1 fix: delte rule from planned maintainance 2025-10-06 14:23:25 +05:30
aniket
f73929ee00 fix: delte rule from planned maintainance 2025-10-06 14:22:40 +05:30
aniket
13884cc753 fix: delete rule in planned maiantainance 2025-10-03 18:45:21 +05:30
468 changed files with 49696 additions and 62752 deletions

View File

@@ -1,6 +1,6 @@
services: services:
clickhouse: clickhouse:
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
container_name: clickhouse container_name: clickhouse
volumes: volumes:
- ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml - ${PWD}/fs/etc/clickhouse-server/config.d/config.xml:/etc/clickhouse-server/config.d/config.xml
@@ -42,7 +42,7 @@ services:
timeout: 5s timeout: 5s
retries: 3 retries: 3
schema-migrator-sync: schema-migrator-sync:
image: signoz/signoz-schema-migrator:v0.129.12 image: signoz/signoz-schema-migrator:v0.129.7
container_name: schema-migrator-sync container_name: schema-migrator-sync
command: command:
- sync - sync
@@ -55,7 +55,7 @@ services:
condition: service_healthy condition: service_healthy
restart: on-failure restart: on-failure
schema-migrator-async: schema-migrator-async:
image: signoz/signoz-schema-migrator:v0.129.12 image: signoz/signoz-schema-migrator:v0.129.7
container_name: schema-migrator-async container_name: schema-migrator-async
command: command:
- async - async

4
.github/CODEOWNERS vendored
View File

@@ -6,10 +6,6 @@
/frontend/src/container/MetricsApplication @srikanthccv /frontend/src/container/MetricsApplication @srikanthccv
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv /frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
# Onboarding
/frontend/src/container/OnboardingV2Container/onboarding-configs/onboarding-config-with-links.json @makeavish
/frontend/src/container/OnboardingV2Container/AddDataSource/AddDataSource.tsx @makeavish
# Dashboard, Alert, Metrics, Service Map, Services # Dashboard, Alert, Metrics, Service Map, Services
/frontend/src/container/ListOfDashboard/ @srikanthccv /frontend/src/container/ListOfDashboard/ @srikanthccv
/frontend/src/container/NewDashboard/ @srikanthccv /frontend/src/container/NewDashboard/ @srikanthccv

View File

@@ -69,7 +69,6 @@ jobs:
echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> frontend/.env
echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> frontend/.env
echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> frontend/.env
echo 'PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> frontend/.env
- name: cache-dotenv - name: cache-dotenv
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -108,6 +107,7 @@ jobs:
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }} -X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud -X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1 -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr' -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}' DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'

View File

@@ -68,7 +68,6 @@ jobs:
echo 'TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env echo 'TUNNEL_DOMAIN="${{ secrets.NP_TUNNEL_DOMAIN }}"' >> frontend/.env
echo 'PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env echo 'PYLON_APP_ID="${{ secrets.NP_PYLON_APP_ID }}"' >> frontend/.env
echo 'APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env echo 'APPCUES_APP_ID="${{ secrets.NP_APPCUES_APP_ID }}"' >> frontend/.env
echo 'PYLON_IDENTITY_SECRET="${{ secrets.NP_PYLON_IDENTITY_SECRET }}"' >> frontend/.env
- name: cache-dotenv - name: cache-dotenv
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@@ -107,6 +106,7 @@ jobs:
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }} -X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud -X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.staging.signoz.cloud -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.staging.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.staging.signoz.cloud
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1 -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr' -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}' DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'

View File

@@ -35,7 +35,6 @@ jobs:
echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env echo 'POSTHOG_KEY="${{ secrets.POSTHOG_KEY }}"' >> .env
echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env echo 'PYLON_APP_ID="${{ secrets.PYLON_APP_ID }}"' >> .env
echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env echo 'APPCUES_APP_ID="${{ secrets.APPCUES_APP_ID }}"' >> .env
echo 'PYLON_IDENTITY_SECRET="${{ secrets.PYLON_IDENTITY_SECRET }}"' >> .env
- name: build-frontend - name: build-frontend
run: make js-build run: make js-build
- name: upload-frontend-artifact - name: upload-frontend-artifact

View File

@@ -17,8 +17,6 @@ jobs:
- bootstrap - bootstrap
- passwordauthn - passwordauthn
- callbackauthn - callbackauthn
- cloudintegrations
- dashboard
- querier - querier
- ttl - ttl
sqlstore-provider: sqlstore-provider:

1
.gitignore vendored
View File

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

View File

@@ -72,12 +72,6 @@ devenv-up: devenv-clickhouse devenv-signoz-otel-collector ## Start both clickhou
@echo " - ClickHouse: http://localhost:8123" @echo " - ClickHouse: http://localhost:8123"
@echo " - Signoz OTel Collector: grpc://localhost:4317, http://localhost:4318" @echo " - Signoz OTel Collector: grpc://localhost:4317, http://localhost:4318"
.PHONY: devenv-clickhouse-clean
devenv-clickhouse-clean: ## Clean all ClickHouse data from filesystem
@echo "Removing ClickHouse data..."
@rm -rf .devenv/docker/clickhouse/fs/tmp/*
@echo "ClickHouse data cleaned!"
############################################################## ##############################################################
# go commands # go commands
############################################################## ##############################################################
@@ -90,9 +84,10 @@ go-run-enterprise: ## Runs the enterprise go backend server
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \ SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \ SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \ SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \
go run -race \ go run -race \
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go server $(GO_BUILD_CONTEXT_ENTERPRISE)/*.go \
--config ./conf/prometheus.yml \
--cluster cluster
.PHONY: go-test .PHONY: go-test
go-test: ## Runs go unit tests go-test: ## Runs go unit tests
@@ -107,9 +102,10 @@ go-run-community: ## Runs the community go backend server
SIGNOZ_ALERTMANAGER_PROVIDER=signoz \ SIGNOZ_ALERTMANAGER_PROVIDER=signoz \
SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \ SIGNOZ_TELEMETRYSTORE_PROVIDER=clickhouse \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \ SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_DSN=tcp://127.0.0.1:9000 \
SIGNOZ_TELEMETRYSTORE_CLICKHOUSE_CLUSTER=cluster \
go run -race \ go run -race \
$(GO_BUILD_CONTEXT_COMMUNITY)/*.go server $(GO_BUILD_CONTEXT_COMMUNITY)/*.go server \
--config ./conf/prometheus.yml \
--cluster cluster
.PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY) .PHONY: go-build-community $(GO_BUILD_ARCHS_COMMUNITY)
go-build-community: ## Builds the go backend server for community go-build-community: ## Builds the go backend server for community
@@ -212,4 +208,4 @@ py-lint: ## Run lint for integration tests
.PHONY: py-test .PHONY: py-test
py-test: ## Runs integration tests py-test: ## Runs integration tests
@cd tests/integration && poetry run pytest --basetemp=./tmp/ -vv --capture=no src/ @cd tests/integration && poetry run pytest --basetemp=./tmp/ -vv --capture=no src/

View File

@@ -5,12 +5,9 @@ import (
"log/slog" "log/slog"
"github.com/SigNoz/signoz/cmd" "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/ee/sqlstore/postgressqlstore"
"github.com/SigNoz/signoz/pkg/analytics" "github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn" "github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/licensing/nooplicensing" "github.com/SigNoz/signoz/pkg/licensing/nooplicensing"
@@ -79,9 +76,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) { func(ctx context.Context, providerSettings factory.ProviderSettings, store authtypes.AuthNStore, licensing licensing.Licensing) (map[authtypes.AuthNProvider]authn.AuthN, error) {
return signoz.NewAuthNs(ctx, providerSettings, store, licensing) return signoz.NewAuthNs(ctx, providerSettings, store, licensing)
}, },
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
) )
if err != nil { if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err) logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -31,6 +31,7 @@ builds:
- -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }} - -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }}
- -X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud - -X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
- -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io - -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
- -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
- -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1 - -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
- -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr - -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr
mod_timestamp: "{{ .CommitTimestamp }}" mod_timestamp: "{{ .CommitTimestamp }}"

View File

@@ -8,8 +8,6 @@ import (
"github.com/SigNoz/signoz/cmd" "github.com/SigNoz/signoz/cmd"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn" "github.com/SigNoz/signoz/ee/authn/callbackauthn/oidccallbackauthn"
"github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn" "github.com/SigNoz/signoz/ee/authn/callbackauthn/samlcallbackauthn"
"github.com/SigNoz/signoz/ee/authz/openfgaauthz"
"github.com/SigNoz/signoz/ee/authz/openfgaschema"
enterpriselicensing "github.com/SigNoz/signoz/ee/licensing" enterpriselicensing "github.com/SigNoz/signoz/ee/licensing"
"github.com/SigNoz/signoz/ee/licensing/httplicensing" "github.com/SigNoz/signoz/ee/licensing/httplicensing"
enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app" enterpriseapp "github.com/SigNoz/signoz/ee/query-service/app"
@@ -19,7 +17,6 @@ import (
"github.com/SigNoz/signoz/ee/zeus/httpzeus" "github.com/SigNoz/signoz/ee/zeus/httpzeus"
"github.com/SigNoz/signoz/pkg/analytics" "github.com/SigNoz/signoz/pkg/analytics"
"github.com/SigNoz/signoz/pkg/authn" "github.com/SigNoz/signoz/pkg/authn"
"github.com/SigNoz/signoz/pkg/authz"
"github.com/SigNoz/signoz/pkg/factory" "github.com/SigNoz/signoz/pkg/factory"
"github.com/SigNoz/signoz/pkg/licensing" "github.com/SigNoz/signoz/pkg/licensing"
"github.com/SigNoz/signoz/pkg/modules/organization" "github.com/SigNoz/signoz/pkg/modules/organization"
@@ -108,9 +105,6 @@ func runServer(ctx context.Context, config signoz.Config, logger *slog.Logger) e
return authNs, nil return authNs, nil
}, },
func(ctx context.Context, sqlstore sqlstore.SQLStore) factory.ProviderFactory[authz.AuthZ, authz.Config] {
return openfgaauthz.NewProviderFactory(sqlstore, openfgaschema.NewSchema().Get(ctx))
},
) )
if err != nil { if err != nil {
logger.ErrorContext(ctx, "failed to create signoz", "error", err) logger.ErrorContext(ctx, "failed to create signoz", "error", err)

View File

@@ -47,10 +47,10 @@ cache:
provider: memory provider: memory
# memory: Uses in-memory caching. # memory: Uses in-memory caching.
memory: memory:
# Max items for the in-memory cache (10x the entries) # Time-to-live for cache entries in memory. Specify the duration in ns
num_counters: 100000 ttl: 60000000000
# Total cost in bytes allocated bounded cache # The interval at which the cache will be cleaned up
max_cost: 67108864 cleanup_interval: 1m
# redis: Uses Redis as the caching backend. # redis: Uses Redis as the caching backend.
redis: redis:
# The hostname or IP address of the Redis server. # The hostname or IP address of the Redis server.

View File

@@ -11,7 +11,7 @@ x-common: &common
max-file: "3" max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common !!merge <<: *common
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
tty: true tty: true
deploy: deploy:
labels: labels:
@@ -65,7 +65,7 @@ x-db-depend: &db-depend
services: services:
init-clickhouse: init-clickhouse:
!!merge <<: *common !!merge <<: *common
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
command: command:
- bash - bash
- -c - -c
@@ -176,7 +176,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.103.0 image: signoz/signoz:v0.99.0
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
ports: ports:
@@ -209,7 +209,7 @@ services:
retries: 3 retries: 3
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.12 image: signoz/signoz-otel-collector:v0.129.7
command: command:
- --config=/etc/otel-collector-config.yaml - --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml - --manager-config=/etc/manager-config.yaml
@@ -233,7 +233,7 @@ services:
- signoz - signoz
schema-migrator: schema-migrator:
!!merge <<: *common !!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.12 image: signoz/signoz-schema-migrator:v0.129.7
deploy: deploy:
restart_policy: restart_policy:
condition: on-failure condition: on-failure

View File

@@ -11,7 +11,7 @@ x-common: &common
max-file: "3" max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common !!merge <<: *common
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
tty: true tty: true
deploy: deploy:
labels: labels:
@@ -62,7 +62,7 @@ x-db-depend: &db-depend
services: services:
init-clickhouse: init-clickhouse:
!!merge <<: *common !!merge <<: *common
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
command: command:
- bash - bash
- -c - -c
@@ -117,7 +117,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:v0.103.0 image: signoz/signoz:v0.99.0
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
ports: ports:
@@ -150,7 +150,7 @@ services:
retries: 3 retries: 3
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:v0.129.12 image: signoz/signoz-otel-collector:v0.129.7
command: command:
- --config=/etc/otel-collector-config.yaml - --config=/etc/otel-collector-config.yaml
- --manager-config=/etc/manager-config.yaml - --manager-config=/etc/manager-config.yaml
@@ -176,7 +176,7 @@ services:
- signoz - signoz
schema-migrator: schema-migrator:
!!merge <<: *common !!merge <<: *common
image: signoz/signoz-schema-migrator:v0.129.12 image: signoz/signoz-schema-migrator:v0.129.7
deploy: deploy:
restart_policy: restart_policy:
condition: on-failure condition: on-failure

View File

@@ -10,7 +10,7 @@ x-common: &common
x-clickhouse-defaults: &clickhouse-defaults x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common !!merge <<: *common
# addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab # addding non LTS version due to this fix https://github.com/ClickHouse/ClickHouse/commit/32caf8716352f45c1b617274c7508c86b7d1afab
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
tty: true tty: true
labels: labels:
signoz.io/scrape: "true" signoz.io/scrape: "true"
@@ -67,7 +67,7 @@ x-db-depend: &db-depend
services: services:
init-clickhouse: init-clickhouse:
!!merge <<: *common !!merge <<: *common
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
container_name: signoz-init-clickhouse container_name: signoz-init-clickhouse
command: command:
- bash - bash
@@ -179,7 +179,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.103.0} image: signoz/signoz:${VERSION:-v0.99.0}
container_name: signoz container_name: signoz
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
@@ -213,7 +213,7 @@ services:
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing? # TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.12} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
container_name: signoz-otel-collector container_name: signoz-otel-collector
command: command:
- --config=/etc/otel-collector-config.yaml - --config=/etc/otel-collector-config.yaml
@@ -239,7 +239,7 @@ services:
condition: service_healthy condition: service_healthy
schema-migrator-sync: schema-migrator-sync:
!!merge <<: *common !!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12} image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-sync container_name: schema-migrator-sync
command: command:
- sync - sync
@@ -250,7 +250,7 @@ services:
condition: service_healthy condition: service_healthy
schema-migrator-async: schema-migrator-async:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12} image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-async container_name: schema-migrator-async
command: command:
- async - async

View File

@@ -9,7 +9,7 @@ x-common: &common
max-file: "3" max-file: "3"
x-clickhouse-defaults: &clickhouse-defaults x-clickhouse-defaults: &clickhouse-defaults
!!merge <<: *common !!merge <<: *common
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
tty: true tty: true
labels: labels:
signoz.io/scrape: "true" signoz.io/scrape: "true"
@@ -62,7 +62,7 @@ x-db-depend: &db-depend
services: services:
init-clickhouse: init-clickhouse:
!!merge <<: *common !!merge <<: *common
image: clickhouse/clickhouse-server:25.10.1 image: clickhouse/clickhouse-server:25.5.6
container_name: signoz-init-clickhouse container_name: signoz-init-clickhouse
command: command:
- bash - bash
@@ -111,7 +111,7 @@ services:
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml # - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
signoz: signoz:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz:${VERSION:-v0.103.0} image: signoz/signoz:${VERSION:-v0.99.0}
container_name: signoz container_name: signoz
command: command:
- --config=/root/config/prometheus.yml - --config=/root/config/prometheus.yml
@@ -144,7 +144,7 @@ services:
retries: 3 retries: 3
otel-collector: otel-collector:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.12} image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
container_name: signoz-otel-collector container_name: signoz-otel-collector
command: command:
- --config=/etc/otel-collector-config.yaml - --config=/etc/otel-collector-config.yaml
@@ -166,7 +166,7 @@ services:
condition: service_healthy condition: service_healthy
schema-migrator-sync: schema-migrator-sync:
!!merge <<: *common !!merge <<: *common
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12} image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-sync container_name: schema-migrator-sync
command: command:
- sync - sync
@@ -178,7 +178,7 @@ services:
restart: on-failure restart: on-failure
schema-migrator-async: schema-migrator-async:
!!merge <<: *db-depend !!merge <<: *db-depend
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12} image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
container_name: schema-migrator-async container_name: schema-migrator-async
command: command:
- async - async

View File

@@ -103,19 +103,9 @@ Remember to replace the region and ingestion key with proper values as obtained
Both SigNoz and OTel demo app [frontend-proxy service, to be accurate] share common port allocation at 8080. To prevent port allocation conflicts, modify the OTel demo application config to use port 8081 as the `ENVOY_PORT` value as shown below, and run docker compose command. Both SigNoz and OTel demo app [frontend-proxy service, to be accurate] share common port allocation at 8080. To prevent port allocation conflicts, modify the OTel demo application config to use port 8081 as the `ENVOY_PORT` value as shown below, and run docker compose command.
Also, both SigNoz and OTel Demo App have the same `PROMETHEUS_PORT` configured, by default both of them try to start at `9090`, which may cause either of them to fail depending upon which one acquires it first. To prevent this, we need to mofify the value of `PROMETHEUS_PORT` too.
```sh ```sh
ENVOY_PORT=8081 PROMETHEUS_PORT=9091 docker compose up -d ENVOY_PORT=8081 docker compose up -d
``` ```
Alternatively, we can modify these values using the `.env` file too, which reduces the command as just:
```sh
docker compose up -d
```
This spins up multiple microservices, with OpenTelemetry instrumentation enabled. you can verify this by, This spins up multiple microservices, with OpenTelemetry instrumentation enabled. you can verify this by,
```sh ```sh
docker compose ps -a docker compose ps -a

View File

@@ -48,26 +48,7 @@ func (provider *provider) Check(ctx context.Context, tuple *openfgav1.TupleKey)
} }
func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, _ authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error { func (provider *provider) CheckWithTupleCreation(ctx context.Context, claims authtypes.Claims, orgID valuer.UUID, relation authtypes.Relation, _ authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil) subject, err := authtypes.NewSubject(authtypes.TypeUser, claims.UserID, authtypes.Relation{})
if err != nil {
return err
}
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
if err != nil {
return err
}
err = provider.BatchCheck(ctx, tuples)
if err != nil {
return err
}
return nil
}
func (provider *provider) CheckWithTupleCreationWithoutClaims(ctx context.Context, orgID valuer.UUID, relation authtypes.Relation, _ authtypes.Relation, typeable authtypes.Typeable, selectors []authtypes.Selector) error {
subject, err := authtypes.NewSubject(authtypes.TypeableAnonymous, authtypes.AnonymousUser.String(), orgID, nil)
if err != nil { if err != nil {
return err return err
} }

View File

@@ -15,18 +15,18 @@ type anonymous
type role type role
relations relations
define assignee: [user, anonymous] define assignee: [user]
define read: [user, role#assignee] define read: [user, role#assignee]
define update: [user, role#assignee] define update: [user, role#assignee]
define delete: [user, role#assignee] define delete: [user, role#assignee]
type metaresources type resources
relations relations
define create: [user, role#assignee] define create: [user, role#assignee]
define list: [user, role#assignee] define list: [user, role#assignee]
type metaresource type resource
relations relations
define read: [user, anonymous, role#assignee] define read: [user, anonymous, role#assignee]
define update: [user, role#assignee] define update: [user, role#assignee]
@@ -35,6 +35,6 @@ type metaresource
define block: [user, role#assignee] define block: [user, role#assignee]
type telemetryresource type telemetry
relations relations
define read: [user, role#assignee] define read: [user, anonymous, role#assignee]

View File

@@ -20,10 +20,6 @@ import (
basemodel "github.com/SigNoz/signoz/pkg/query-service/model" basemodel "github.com/SigNoz/signoz/pkg/query-service/model"
rules "github.com/SigNoz/signoz/pkg/query-service/rules" rules "github.com/SigNoz/signoz/pkg/query-service/rules"
"github.com/SigNoz/signoz/pkg/signoz" "github.com/SigNoz/signoz/pkg/signoz"
"github.com/SigNoz/signoz/pkg/types"
"github.com/SigNoz/signoz/pkg/types/authtypes"
"github.com/SigNoz/signoz/pkg/types/dashboardtypes"
"github.com/SigNoz/signoz/pkg/valuer"
"github.com/SigNoz/signoz/pkg/version" "github.com/SigNoz/signoz/pkg/version"
"github.com/gorilla/mux" "github.com/gorilla/mux"
) )
@@ -103,39 +99,6 @@ func (ah *APIHandler) RegisterRoutes(router *mux.Router, am *middleware.AuthZ) {
router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet) router.HandleFunc("/api/v1/billing", am.AdminAccess(ah.getBilling)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost) router.HandleFunc("/api/v1/portal", am.AdminAccess(ah.LicensingAPI.Portal)).Methods(http.MethodPost)
// dashboards
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.CreatePublic)).Methods(http.MethodPost)
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.GetPublic)).Methods(http.MethodGet)
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.UpdatePublic)).Methods(http.MethodPut)
router.HandleFunc("/api/v1/dashboards/{id}/public", am.AdminAccess(ah.Signoz.Handlers.Dashboard.DeletePublic)).Methods(http.MethodDelete)
// public access for dashboards
router.HandleFunc("/api/v1/public/dashboards/{id}", am.CheckWithoutClaims(
ah.Signoz.Handlers.Dashboard.GetPublicData,
authtypes.RelationRead, authtypes.RelationRead,
dashboardtypes.TypeableMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return ah.Signoz.Modules.Dashboard.GetPublicDashboardOrgAndSelectors(req.Context(), id, orgs)
})).Methods(http.MethodGet)
router.HandleFunc("/api/v1/public/dashboards/{id}/widgets/{index}/query_range", am.CheckWithoutClaims(
ah.Signoz.Handlers.Dashboard.GetPublicWidgetQueryRange,
authtypes.RelationRead, authtypes.RelationRead,
dashboardtypes.TypeableMetaResourcePublicDashboard,
func(req *http.Request, orgs []*types.Organization) ([]authtypes.Selector, valuer.UUID, error) {
id, err := valuer.NewUUID(mux.Vars(req)["id"])
if err != nil {
return nil, valuer.UUID{}, err
}
return ah.Signoz.Modules.Dashboard.GetPublicDashboardOrgAndSelectors(req.Context(), id, orgs)
})).Methods(http.MethodGet)
// v3 // v3
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost) router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Activate)).Methods(http.MethodPost)
router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut) router.HandleFunc("/api/v3/licenses", am.AdminAccess(ah.LicensingAPI.Refresh)).Methods(http.MethodPut)

View File

@@ -10,6 +10,7 @@ import (
"strings" "strings"
"time" "time"
"github.com/SigNoz/signoz/ee/query-service/constants"
"github.com/SigNoz/signoz/pkg/errors" "github.com/SigNoz/signoz/pkg/errors"
"github.com/SigNoz/signoz/pkg/http/render" "github.com/SigNoz/signoz/pkg/http/render"
"github.com/SigNoz/signoz/pkg/modules/user" "github.com/SigNoz/signoz/pkg/modules/user"
@@ -76,7 +77,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
return return
} }
ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key) ingestionUrl, signozApiUrl, apiErr := getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
if apiErr != nil { if apiErr != nil {
RespondError(w, basemodel.WrapApiError( RespondError(w, basemodel.WrapApiError(
apiErr, "couldn't deduce ingestion url and signoz api url", apiErr, "couldn't deduce ingestion url and signoz api url",
@@ -185,37 +186,48 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
return cloudIntegrationUser, nil return cloudIntegrationUser, nil
} }
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) ( func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
string, string, *basemodel.ApiError, string, string, *basemodel.ApiError,
) { ) {
// TODO: remove this struct from here url := fmt.Sprintf(
"%s%s",
strings.TrimSuffix(constants.ZeusURL, "/"),
"/v2/deployments/me",
)
type deploymentResponse struct { type deploymentResponse struct {
Name string `json:"name"` Status string `json:"status"`
ClusterInfo struct { Error string `json:"error"`
Region struct { Data struct {
DNS string `json:"dns"` Name string `json:"name"`
} `json:"region"`
} `json:"cluster"` ClusterInfo struct {
Region struct {
DNS string `json:"dns"`
} `json:"region"`
} `json:"cluster"`
} `json:"data"`
} }
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey) resp, apiErr := requestAndParseResponse[deploymentResponse](
if err != nil { ctx, url, map[string]string{"X-Signoz-Cloud-Api-Key": licenseKey}, nil,
)
if apiErr != nil {
return "", "", basemodel.WrapApiError(
apiErr, "couldn't query for deployment info",
)
}
if resp.Status != "success" {
return "", "", basemodel.InternalError(fmt.Errorf( return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't query for deployment info: error: %w", err, "couldn't query for deployment info: status: %s, error: %s",
resp.Status, resp.Error,
)) ))
} }
resp := new(deploymentResponse) regionDns := resp.Data.ClusterInfo.Region.DNS
deploymentName := resp.Data.Name
err = json.Unmarshal(respBytes, resp)
if err != nil {
return "", "", basemodel.InternalError(fmt.Errorf(
"couldn't unmarshal deployment info response: error: %w", err,
))
}
regionDns := resp.ClusterInfo.Region.DNS
deploymentName := resp.Name
if len(regionDns) < 1 || len(deploymentName) < 1 { if len(regionDns) < 1 || len(deploymentName) < 1 {
// Fail early if actual response structure and expectation here ever diverge // Fail early if actual response structure and expectation here ever diverge

View File

@@ -9,7 +9,6 @@ import (
_ "net/http/pprof" // http profiler _ "net/http/pprof" // http profiler
"slices" "slices"
"github.com/SigNoz/signoz/pkg/cache/memorycache"
"github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore" "github.com/SigNoz/signoz/pkg/ruler/rulestore/sqlrulestore"
"go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux" "go.opentelemetry.io/contrib/instrumentation/github.com/gorilla/mux/otelmux"
"go.opentelemetry.io/otel/propagation" "go.opentelemetry.io/otel/propagation"
@@ -75,26 +74,13 @@ func NewServer(config signoz.Config, signoz *signoz.SigNoz) (*Server, error) {
return nil, err return nil, err
} }
cacheForTraceDetail, err := memorycache.New(context.TODO(), signoz.Instrumentation.ToProviderSettings(), cache.Config{
Provider: "memory",
Memory: cache.Memory{
NumCounters: 10 * 10000,
MaxCost: 1 << 27, // 128 MB
},
})
if err != nil {
return nil, err
}
reader := clickhouseReader.NewReader( reader := clickhouseReader.NewReader(
signoz.SQLStore, signoz.SQLStore,
signoz.TelemetryStore, signoz.TelemetryStore,
signoz.Prometheus, signoz.Prometheus,
signoz.TelemetryStore.Cluster(), signoz.TelemetryStore.Cluster(),
config.Querier.FluxInterval, config.Querier.FluxInterval,
cacheForTraceDetail,
signoz.Cache, signoz.Cache,
nil,
) )
rm, err := makeRulesManager( rm, err := makeRulesManager(
@@ -206,7 +192,7 @@ func (s Server) HealthCheckStatus() chan healthcheck.Status {
func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) { func (s *Server) createPublicServer(apiHandler *api.APIHandler, web web.Web) (*http.Server, error) {
r := baseapp.NewRouter() r := baseapp.NewRouter()
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz) am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger())
r.Use(otelmux.Middleware( r.Use(otelmux.Middleware(
"apiserver", "apiserver",

View File

@@ -9,7 +9,9 @@ var LicenseAPIKey = GetOrDefaultEnv("SIGNOZ_LICENSE_API_KEY", "")
var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "") var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false") var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false")
var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL") var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL")
var BodyJSONQueryEnabled = GetOrDefaultEnv("BODY_JSON_QUERY_ENABLED", "false") == "true"
// this is set via build time variable
var ZeusURL = "https://api.signoz.cloud"
func GetOrDefaultEnv(key string, fallback string) string { func GetOrDefaultEnv(key string, fallback string) string {
v := os.Getenv(key) v := os.Getenv(key)

View File

@@ -246,9 +246,7 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
continue continue
} }
} }
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{ results, err := r.Threshold.ShouldAlert(*series, r.Unit())
ActiveAlerts: r.ActiveAlertsLabelFP(),
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -298,9 +296,7 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
continue continue
} }
} }
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{ results, err := r.Threshold.ShouldAlert(*series, r.Unit())
ActiveAlerts: r.ActiveAlertsLabelFP(),
})
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -414,7 +410,6 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
GeneratorURL: r.GeneratorURL(), GeneratorURL: r.GeneratorURL(),
Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]], Receivers: ruleReceiverMap[lbs.Map()[ruletypes.LabelThresholdName]],
Missing: smpl.IsMissing, Missing: smpl.IsMissing,
IsRecovering: smpl.IsRecovering,
} }
} }
@@ -427,9 +422,6 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
alert.Value = a.Value alert.Value = a.Value
alert.Annotations = a.Annotations alert.Annotations = a.Annotations
// Update the recovering and missing state of existing alert
alert.IsRecovering = a.IsRecovering
alert.Missing = a.Missing
if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok { if v, ok := alert.Labels.Map()[ruletypes.LabelThresholdName]; ok {
alert.Receivers = ruleReceiverMap[v] alert.Receivers = ruleReceiverMap[v]
} }
@@ -488,30 +480,6 @@ func (r *AnomalyRule) Eval(ctx context.Context, ts time.Time) (interface{}, erro
Value: a.Value, Value: a.Value,
}) })
} }
// We need to change firing alert to recovering if the returned sample meets recovery threshold
changeFiringToRecovering := a.State == model.StateFiring && a.IsRecovering
// We need to change recovering alerts to firing if the returned sample meets target threshold
changeRecoveringToFiring := a.State == model.StateRecovering && !a.IsRecovering && !a.Missing
// in any of the above case we need to update the status of alert
if changeFiringToRecovering || changeRecoveringToFiring {
state := model.StateRecovering
if changeRecoveringToFiring {
state = model.StateFiring
}
a.State = state
r.logger.DebugContext(ctx, "converting alert state", "name", r.Name(), "state", state)
itemsToAdd = append(itemsToAdd, model.RuleStateHistory{
RuleID: r.ID(),
RuleName: r.Name(),
State: state,
StateChanged: true,
UnixMilli: ts.UnixMilli(),
Labels: model.LabelsString(labelsJSON),
Fingerprint: a.QueryResultLables.Hash(),
Value: a.Value,
})
}
} }
currentState := r.State() currentState := r.State()

View File

@@ -30,8 +30,6 @@ func (formatter Formatter) DataTypeOf(dataType string) sqlschema.DataType {
return sqlschema.DataTypeBoolean return sqlschema.DataTypeBoolean
case "VARCHAR", "CHARACTER VARYING", "CHARACTER": case "VARCHAR", "CHARACTER VARYING", "CHARACTER":
return sqlschema.DataTypeText return sqlschema.DataTypeText
case "BYTEA":
return sqlschema.DataTypeBytea
} }
return formatter.Formatter.DataTypeOf(dataType) return formatter.Formatter.DataTypeOf(dataType)

View File

@@ -1,153 +0,0 @@
package postgressqlstore
import (
"strings"
"github.com/SigNoz/signoz/pkg/sqlstore"
"github.com/uptrace/bun/schema"
)
type formatter struct {
bunf schema.Formatter
}
func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
return &formatter{bunf: schema.NewFormatter(dialect)}
}
func (f *formatter) JSONExtractString(column, path string) []byte {
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgres(path)...)
return sql
}
func (f *formatter) JSONType(column, path string) []byte {
var sql []byte
sql = append(sql, "jsonb_typeof("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ')')
return sql
}
func (f *formatter) JSONIsArray(column, path string) []byte {
var sql []byte
sql = append(sql, f.JSONType(column, path)...)
sql = append(sql, " = "...)
sql = schema.Append(f.bunf, sql, "array")
return sql
}
func (f *formatter) JSONArrayElements(column, path, alias string) ([]byte, []byte) {
var sql []byte
sql = append(sql, "jsonb_array_elements("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, []byte(alias)
}
func (f *formatter) JSONArrayOfStrings(column, path, alias string) ([]byte, []byte) {
var sql []byte
sql = append(sql, "jsonb_array_elements_text("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, append([]byte(alias), "::text"...)
}
func (f *formatter) JSONKeys(column, path, alias string) ([]byte, []byte) {
var sql []byte
sql = append(sql, "jsonb_each("...)
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
sql = append(sql, ") AS "...)
sql = f.bunf.AppendIdent(sql, alias)
return sql, append([]byte(alias), ".key"...)
}
func (f *formatter) JSONArrayAgg(expression string) []byte {
var sql []byte
sql = append(sql, "jsonb_agg("...)
sql = append(sql, expression...)
sql = append(sql, ')')
return sql
}
func (f *formatter) JSONArrayLiteral(values ...string) []byte {
var sql []byte
sql = append(sql, "jsonb_build_array("...)
for idx, value := range values {
if idx > 0 {
sql = append(sql, ", "...)
}
sql = schema.Append(f.bunf, sql, value)
}
sql = append(sql, ')')
return sql
}
func (f *formatter) TextToJsonColumn(column string) []byte {
var sql []byte
sql = f.bunf.AppendIdent(sql, column)
sql = append(sql, "::jsonb"...)
return sql
}
func (f *formatter) convertJSONPathToPostgres(jsonPath string) []byte {
return f.convertJSONPathToPostgresWithMode(jsonPath, true)
}
func (f *formatter) convertJSONPathToPostgresWithMode(jsonPath string, asText bool) []byte {
path := strings.TrimPrefix(strings.TrimPrefix(jsonPath, "$"), ".")
if path == "" {
return nil
}
parts := strings.Split(path, ".")
var validParts []string
for _, part := range parts {
if part != "" {
validParts = append(validParts, part)
}
}
if len(validParts) == 0 {
return nil
}
var result []byte
for idx, part := range validParts {
if idx == len(validParts)-1 {
if asText {
result = append(result, "->>"...)
} else {
result = append(result, "->"...)
}
result = schema.Append(f.bunf, result, part)
return result
}
result = append(result, "->"...)
result = schema.Append(f.bunf, result, part)
}
return result
}
func (f *formatter) LowerExpression(expression string) []byte {
var sql []byte
sql = append(sql, "lower("...)
sql = append(sql, expression...)
sql = append(sql, ')')
return sql
}

View File

@@ -1,500 +0,0 @@
package postgressqlstore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/uptrace/bun/dialect/pgdialect"
)
func TestJSONExtractString(t *testing.T) {
tests := []struct {
name string
column string
path string
expected string
}{
{
name: "simple path",
column: "data",
path: "$.field",
expected: `"data"->>'field'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.name",
expected: `"metadata"->'user'->>'name'`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.level1.level2.level3",
expected: `"json_col"->'level1'->'level2'->>'level3'`,
},
{
name: "root path",
column: "json_col",
path: "$",
expected: `"json_col"`,
},
{
name: "empty path",
column: "data",
path: "",
expected: `"data"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONExtractString(tt.column, tt.path))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONType(t *testing.T) {
tests := []struct {
name string
column string
path string
expected string
}{
{
name: "simple path",
column: "data",
path: "$.field",
expected: `jsonb_typeof("data"->'field')`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.age",
expected: `jsonb_typeof("metadata"->'user'->'age')`,
},
{
name: "root path",
column: "json_col",
path: "$",
expected: `jsonb_typeof("json_col")`,
},
{
name: "empty path",
column: "data",
path: "",
expected: `jsonb_typeof("data")`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONType(tt.column, tt.path))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONIsArray(t *testing.T) {
tests := []struct {
name string
column string
path string
expected string
}{
{
name: "simple path",
column: "data",
path: "$.items",
expected: `jsonb_typeof("data"->'items') = 'array'`,
},
{
name: "nested path",
column: "metadata",
path: "$.user.tags",
expected: `jsonb_typeof("metadata"->'user'->'tags') = 'array'`,
},
{
name: "root path",
column: "json_col",
path: "$",
expected: `jsonb_typeof("json_col") = 'array'`,
},
{
name: "empty path",
column: "data",
path: "",
expected: `jsonb_typeof("data") = 'array'`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONIsArray(tt.column, tt.path))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONArrayElements(t *testing.T) {
tests := []struct {
name string
column string
path string
alias string
expected string
}{
{
name: "root path with dollar sign",
column: "data",
path: "$",
alias: "elem",
expected: `jsonb_array_elements("data") AS "elem"`,
},
{
name: "root path empty",
column: "data",
path: "",
alias: "elem",
expected: `jsonb_array_elements("data") AS "elem"`,
},
{
name: "nested path",
column: "metadata",
path: "$.items",
alias: "item",
expected: `jsonb_array_elements("metadata"->'items') AS "item"`,
},
{
name: "deeply nested path",
column: "json_col",
path: "$.user.tags",
alias: "tag",
expected: `jsonb_array_elements("json_col"->'user'->'tags') AS "tag"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got, _ := f.JSONArrayElements(tt.column, tt.path, tt.alias)
assert.Equal(t, tt.expected, string(got))
})
}
}
func TestJSONArrayOfStrings(t *testing.T) {
tests := []struct {
name string
column string
path string
alias string
expected string
}{
{
name: "root path with dollar sign",
column: "data",
path: "$",
alias: "str",
expected: `jsonb_array_elements_text("data") AS "str"`,
},
{
name: "root path empty",
column: "data",
path: "",
alias: "str",
expected: `jsonb_array_elements_text("data") AS "str"`,
},
{
name: "nested path",
column: "metadata",
path: "$.strings",
alias: "s",
expected: `jsonb_array_elements_text("metadata"->'strings') AS "s"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got, _ := f.JSONArrayOfStrings(tt.column, tt.path, tt.alias)
assert.Equal(t, tt.expected, string(got))
})
}
}
func TestJSONKeys(t *testing.T) {
tests := []struct {
name string
column string
path string
alias string
expected string
}{
{
name: "root path with dollar sign",
column: "data",
path: "$",
alias: "k",
expected: `jsonb_each("data") AS "k"`,
},
{
name: "root path empty",
column: "data",
path: "",
alias: "k",
expected: `jsonb_each("data") AS "k"`,
},
{
name: "nested path",
column: "metadata",
path: "$.object",
alias: "key",
expected: `jsonb_each("metadata"->'object') AS "key"`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got, _ := f.JSONKeys(tt.column, tt.path, tt.alias)
assert.Equal(t, tt.expected, string(got))
})
}
}
func TestJSONArrayAgg(t *testing.T) {
tests := []struct {
name string
expression string
expected string
}{
{
name: "simple column",
expression: "id",
expected: "jsonb_agg(id)",
},
{
name: "expression with function",
expression: "DISTINCT name",
expected: "jsonb_agg(DISTINCT name)",
},
{
name: "complex expression",
expression: "data->>'field'",
expected: "jsonb_agg(data->>'field')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONArrayAgg(tt.expression))
assert.Equal(t, tt.expected, got)
})
}
}
func TestJSONArrayLiteral(t *testing.T) {
tests := []struct {
name string
values []string
expected string
}{
{
name: "empty array",
values: []string{},
expected: "jsonb_build_array()",
},
{
name: "single value",
values: []string{"value1"},
expected: "jsonb_build_array('value1')",
},
{
name: "multiple values",
values: []string{"value1", "value2", "value3"},
expected: "jsonb_build_array('value1', 'value2', 'value3')",
},
{
name: "values with special characters",
values: []string{"test", "with space", "with-dash"},
expected: "jsonb_build_array('test', 'with space', 'with-dash')",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.JSONArrayLiteral(tt.values...))
assert.Equal(t, tt.expected, got)
})
}
}
func TestConvertJSONPathToPostgresWithMode(t *testing.T) {
tests := []struct {
name string
jsonPath string
asText bool
expected string
}{
{
name: "simple path as text",
jsonPath: "$.field",
asText: true,
expected: "->>'field'",
},
{
name: "simple path as json",
jsonPath: "$.field",
asText: false,
expected: "->'field'",
},
{
name: "nested path as text",
jsonPath: "$.user.name",
asText: true,
expected: "->'user'->>'name'",
},
{
name: "nested path as json",
jsonPath: "$.user.name",
asText: false,
expected: "->'user'->'name'",
},
{
name: "deeply nested as text",
jsonPath: "$.a.b.c.d",
asText: true,
expected: "->'a'->'b'->'c'->>'d'",
},
{
name: "root path",
jsonPath: "$",
asText: true,
expected: "",
},
{
name: "empty path",
jsonPath: "",
asText: true,
expected: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New()).(*formatter)
got := string(f.convertJSONPathToPostgresWithMode(tt.jsonPath, tt.asText))
assert.Equal(t, tt.expected, got)
})
}
}
func TestTextToJsonColumn(t *testing.T) {
tests := []struct {
name string
column string
expected string
}{
{
name: "simple column name",
column: "data",
expected: `"data"::jsonb`,
},
{
name: "column with underscore",
column: "user_data",
expected: `"user_data"::jsonb`,
},
{
name: "column with special characters",
column: "json-col",
expected: `"json-col"::jsonb`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.TextToJsonColumn(tt.column))
assert.Equal(t, tt.expected, got)
})
}
}
func TestLowerExpression(t *testing.T) {
tests := []struct {
name string
expr string
expected string
}{
{
name: "simple column name",
expr: "name",
expected: "lower(name)",
},
{
name: "quoted column identifier",
expr: `"column_name"`,
expected: `lower("column_name")`,
},
{
name: "jsonb text extraction",
expr: "data->>'field'",
expected: "lower(data->>'field')",
},
{
name: "nested jsonb extraction",
expr: "metadata->'user'->>'name'",
expected: "lower(metadata->'user'->>'name')",
},
{
name: "jsonb_typeof expression",
expr: "jsonb_typeof(data->'field')",
expected: "lower(jsonb_typeof(data->'field'))",
},
{
name: "string concatenation",
expr: "first_name || ' ' || last_name",
expected: "lower(first_name || ' ' || last_name)",
},
{
name: "CAST expression",
expr: "CAST(value AS TEXT)",
expected: "lower(CAST(value AS TEXT))",
},
{
name: "COALESCE expression",
expr: "COALESCE(name, 'default')",
expected: "lower(COALESCE(name, 'default'))",
},
{
name: "subquery column",
expr: "users.email",
expected: "lower(users.email)",
},
{
name: "quoted identifier with special chars",
expr: `"user-name"`,
expected: `lower("user-name")`,
},
{
name: "jsonb to text cast",
expr: "data::text",
expected: "lower(data::text)",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := newFormatter(pgdialect.New())
got := string(f.LowerExpression(tt.expr))
assert.Equal(t, tt.expected, got)
})
}
}

View File

@@ -15,11 +15,10 @@ import (
) )
type provider struct { type provider struct {
settings factory.ScopedProviderSettings settings factory.ScopedProviderSettings
sqldb *sql.DB sqldb *sql.DB
bundb *sqlstore.BunDB bundb *sqlstore.BunDB
dialect *dialect dialect *dialect
formatter sqlstore.SQLFormatter
} }
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] { func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
@@ -56,14 +55,11 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
sqldb := stdlib.OpenDBFromPool(pool) sqldb := stdlib.OpenDBFromPool(pool)
pgDialect := pgdialect.New()
bunDB := sqlstore.NewBunDB(settings, sqldb, pgDialect, hooks)
return &provider{ return &provider{
settings: settings, settings: settings,
sqldb: sqldb, sqldb: sqldb,
bundb: bunDB, bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks),
dialect: new(dialect), dialect: new(dialect),
formatter: newFormatter(bunDB.Dialect()),
}, nil }, nil
} }
@@ -79,10 +75,6 @@ func (provider *provider) Dialect() sqlstore.SQLDialect {
return provider.dialect return provider.dialect
} }
func (provider *provider) Formatter() sqlstore.SQLFormatter {
return provider.formatter
}
func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB { func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
return provider.bundb.BunDBCtx(ctx) return provider.bundb.BunDBCtx(ctx)
} }

View File

@@ -3,6 +3,5 @@ BUNDLE_ANALYSER="true"
FRONTEND_API_ENDPOINT="http://localhost:8080/" FRONTEND_API_ENDPOINT="http://localhost:8080/"
PYLON_APP_ID="pylon-app-id" PYLON_APP_ID="pylon-app-id"
APPCUES_APP_ID="appcess-app-id" APPCUES_APP_ID="appcess-app-id"
PYLON_IDENTITY_SECRET="pylon-identity-secret"
CI="1" CI="1"

View File

@@ -38,7 +38,7 @@
"@mdx-js/loader": "2.3.0", "@mdx-js/loader": "2.3.0",
"@mdx-js/react": "2.3.0", "@mdx-js/react": "2.3.0",
"@monaco-editor/react": "^4.3.1", "@monaco-editor/react": "^4.3.1",
"@playwright/test": "1.55.1", "@playwright/test": "1.54.1",
"@radix-ui/react-tabs": "1.0.4", "@radix-ui/react-tabs": "1.0.4",
"@radix-ui/react-tooltip": "1.0.7", "@radix-ui/react-tooltip": "1.0.7",
"@sentry/react": "8.41.0", "@sentry/react": "8.41.0",
@@ -83,7 +83,6 @@
"color": "^4.2.1", "color": "^4.2.1",
"color-alpha": "1.1.3", "color-alpha": "1.1.3",
"cross-env": "^7.0.3", "cross-env": "^7.0.3",
"crypto-js": "4.2.0",
"css-loader": "5.0.0", "css-loader": "5.0.0",
"css-minimizer-webpack-plugin": "5.0.1", "css-minimizer-webpack-plugin": "5.0.1",
"d3-hierarchy": "3.1.2", "d3-hierarchy": "3.1.2",
@@ -113,7 +112,7 @@
"overlayscrollbars": "^2.8.1", "overlayscrollbars": "^2.8.1",
"overlayscrollbars-react": "^0.5.6", "overlayscrollbars-react": "^0.5.6",
"papaparse": "5.4.1", "papaparse": "5.4.1",
"posthog-js": "1.298.0", "posthog-js": "1.215.5",
"rc-tween-one": "3.0.6", "rc-tween-one": "3.0.6",
"react": "18.2.0", "react": "18.2.0",
"react-addons-update": "15.6.3", "react-addons-update": "15.6.3",
@@ -150,6 +149,7 @@
"tsconfig-paths-webpack-plugin": "^3.5.1", "tsconfig-paths-webpack-plugin": "^3.5.1",
"typescript": "^4.0.5", "typescript": "^4.0.5",
"uplot": "1.6.31", "uplot": "1.6.31",
"userpilot": "1.3.9",
"uuid": "^8.3.2", "uuid": "^8.3.2",
"web-vitals": "^0.2.4", "web-vitals": "^0.2.4",
"webpack": "5.94.0", "webpack": "5.94.0",
@@ -186,7 +186,6 @@
"@types/color": "^3.0.3", "@types/color": "^3.0.3",
"@types/compression-webpack-plugin": "^9.0.0", "@types/compression-webpack-plugin": "^9.0.0",
"@types/copy-webpack-plugin": "^8.0.1", "@types/copy-webpack-plugin": "^8.0.1",
"@types/crypto-js": "4.2.2",
"@types/dompurify": "^2.4.0", "@types/dompurify": "^2.4.0",
"@types/event-source-polyfill": "^1.0.0", "@types/event-source-polyfill": "^1.0.0",
"@types/fontfaceobserver": "2.1.0", "@types/fontfaceobserver": "2.1.0",
@@ -281,7 +280,6 @@
"got": "11.8.5", "got": "11.8.5",
"form-data": "4.0.4", "form-data": "4.0.4",
"brace-expansion": "^2.0.2", "brace-expansion": "^2.0.2",
"on-headers": "^1.1.0", "on-headers": "^1.1.0"
"tmp": "0.2.4"
} }
} }

View File

@@ -7,12 +7,11 @@ import AppLoading from 'components/AppLoading/AppLoading';
import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette'; import KBarCommandPalette from 'components/KBarCommandPalette/KBarCommandPalette';
import NotFound from 'components/NotFound'; import NotFound from 'components/NotFound';
import Spinner from 'components/Spinner'; import Spinner from 'components/Spinner';
import UserpilotRouteTracker from 'components/UserpilotRouteTracker/UserpilotRouteTracker';
import { FeatureKeys } from 'constants/features'; import { FeatureKeys } from 'constants/features';
import { LOCALSTORAGE } from 'constants/localStorage'; import { LOCALSTORAGE } from 'constants/localStorage';
import ROUTES from 'constants/routes'; import ROUTES from 'constants/routes';
import AppLayout from 'container/AppLayout'; import AppLayout from 'container/AppLayout';
import Hex from 'crypto-js/enc-hex';
import HmacSHA256 from 'crypto-js/hmac-sha256';
import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys'; import { KeyboardHotkeysProvider } from 'hooks/hotkeys/useKeyboardHotkeys';
import { useThemeConfig } from 'hooks/useDarkMode'; import { useThemeConfig } from 'hooks/useDarkMode';
import { useGetTenantLicense } from 'hooks/useGetTenantLicense'; import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
@@ -34,6 +33,7 @@ import { Suspense, useCallback, useEffect, useState } from 'react';
import { Route, Router, Switch } from 'react-router-dom'; import { Route, Router, Switch } from 'react-router-dom';
import { CompatRouter } from 'react-router-dom-v5-compat'; import { CompatRouter } from 'react-router-dom-v5-compat';
import { LicenseStatus } from 'types/api/licensesV3/getActive'; import { LicenseStatus } from 'types/api/licensesV3/getActive';
import { Userpilot } from 'userpilot';
import { extractDomain } from 'utils/app'; import { extractDomain } from 'utils/app';
import { Home } from './pageComponents'; import { Home } from './pageComponents';
@@ -84,9 +84,9 @@ function App(): JSX.Element {
email, email,
name: displayName, name: displayName,
company_name: orgName, company_name: orgName,
deployment_name: hostNameParts[0], tenant_id: hostNameParts[0],
data_region: hostNameParts[1], data_region: hostNameParts[1],
deployment_url: hostname, tenant_url: hostname,
company_domain: domain, company_domain: domain,
source: 'signoz-ui', source: 'signoz-ui',
role, role,
@@ -94,9 +94,9 @@ function App(): JSX.Element {
const groupTraits = { const groupTraits = {
name: orgName, name: orgName,
deployment_name: hostNameParts[0], tenant_id: hostNameParts[0],
data_region: hostNameParts[1], data_region: hostNameParts[1],
deployment_url: hostname, tenant_url: hostname,
company_domain: domain, company_domain: domain,
source: 'signoz-ui', source: 'signoz-ui',
}; };
@@ -111,23 +111,37 @@ function App(): JSX.Element {
if (window && window.Appcues) { if (window && window.Appcues) {
window.Appcues.identify(id, { window.Appcues.identify(id, {
name: displayName, name: displayName,
deployment_name: hostNameParts[0],
tenant_id: hostNameParts[0],
data_region: hostNameParts[1], data_region: hostNameParts[1],
deployment_url: hostname, tenant_url: hostname,
company_domain: domain, company_domain: domain,
companyName: orgName, companyName: orgName,
email, email,
paidUser: !!trialInfo?.trialConvertedToSubscription, paidUser: !!trialInfo?.trialConvertedToSubscription,
}); });
} }
Userpilot.identify(email, {
email,
name: displayName,
orgName,
tenant_id: hostNameParts[0],
data_region: hostNameParts[1],
tenant_url: hostname,
company_domain: domain,
source: 'signoz-ui',
isPaidUser: !!trialInfo?.trialConvertedToSubscription,
});
posthog?.identify(id, { posthog?.identify(id, {
email, email,
name: displayName, name: displayName,
orgName, orgName,
deployment_name: hostNameParts[0], tenant_id: hostNameParts[0],
data_region: hostNameParts[1], data_region: hostNameParts[1],
deployment_url: hostname, tenant_url: hostname,
company_domain: domain, company_domain: domain,
source: 'signoz-ui', source: 'signoz-ui',
isPaidUser: !!trialInfo?.trialConvertedToSubscription, isPaidUser: !!trialInfo?.trialConvertedToSubscription,
@@ -135,9 +149,9 @@ function App(): JSX.Element {
posthog?.group('company', orgId, { posthog?.group('company', orgId, {
name: orgName, name: orgName,
deployment_name: hostNameParts[0], tenant_id: hostNameParts[0],
data_region: hostNameParts[1], data_region: hostNameParts[1],
deployment_url: hostname, tenant_url: hostname,
company_domain: domain, company_domain: domain,
source: 'signoz-ui', source: 'signoz-ui',
isPaidUser: !!trialInfo?.trialConvertedToSubscription, isPaidUser: !!trialInfo?.trialConvertedToSubscription,
@@ -256,20 +270,11 @@ function App(): JSX.Element {
!showAddCreditCardModal && !showAddCreditCardModal &&
(isCloudUser || isEnterpriseSelfHostedUser) (isCloudUser || isEnterpriseSelfHostedUser)
) { ) {
const email = user.email || '';
const secret = process.env.PYLON_IDENTITY_SECRET || '';
let emailHash = '';
if (email && secret) {
emailHash = HmacSHA256(email, Hex.parse(secret)).toString(Hex);
}
window.pylon = { window.pylon = {
chat_settings: { chat_settings: {
app_id: process.env.PYLON_APP_ID, app_id: process.env.PYLON_APP_ID,
email: user.email, email: user.email,
name: user.displayName || user.email, name: user.displayName,
email_hash: emailHash,
}, },
}; };
} }
@@ -303,6 +308,10 @@ function App(): JSX.Element {
}); });
} }
if (process.env.USERPILOT_KEY) {
Userpilot.initialize(process.env.USERPILOT_KEY);
}
if (!isSentryInitialized) { if (!isSentryInitialized) {
Sentry.init({ Sentry.init({
dsn: process.env.SENTRY_DSN, dsn: process.env.SENTRY_DSN,
@@ -363,6 +372,7 @@ function App(): JSX.Element {
<Router history={history}> <Router history={history}>
<CompatRouter> <CompatRouter>
<KBarCommandPaletteProvider> <KBarCommandPaletteProvider>
<UserpilotRouteTracker />
<KBarCommandPalette /> <KBarCommandPalette />
<NotificationProvider> <NotificationProvider>
<ErrorModalProvider> <ErrorModalProvider>

View File

@@ -1,8 +1,6 @@
import { LogEventAxiosInstance as axios } from 'api'; import { ApiBaseInstance as axios } from 'api';
import getLocalStorageApi from 'api/browser/localstorage/get';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { LOCALSTORAGE } from 'constants/localStorage';
import { ErrorResponse, SuccessResponse } from 'types/api'; import { ErrorResponse, SuccessResponse } from 'types/api';
import { EventSuccessPayloadProps } from 'types/api/events/types'; import { EventSuccessPayloadProps } from 'types/api/events/types';
@@ -13,14 +11,9 @@ const logEvent = async (
rateLimited?: boolean, rateLimited?: boolean,
): Promise<SuccessResponse<EventSuccessPayloadProps> | ErrorResponse> => { ): Promise<SuccessResponse<EventSuccessPayloadProps> | ErrorResponse> => {
try { try {
// add deployment_url and user_email to attributes // add tenant_url to attributes
const { hostname } = window.location; const { hostname } = window.location;
const userEmail = getLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL); const updatedAttributes = { ...attributes, tenant_url: hostname };
const updatedAttributes = {
...attributes,
deployment_url: hostname,
user_email: userEmail,
};
const response = await axios.post('/event', { const response = await axios.post('/event', {
eventName, eventName,
attributes: updatedAttributes, attributes: updatedAttributes,

View File

@@ -1,11 +1,13 @@
/* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable sonarjs/no-duplicate-string */
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { getFieldKeys } from '../getFieldKeys'; import { getFieldKeys } from '../getFieldKeys';
// Mock the API instance // Mock the API instance
jest.mock('api', () => ({ jest.mock('api', () => ({
get: jest.fn(), ApiBaseInstance: {
get: jest.fn(),
},
})); }));
describe('getFieldKeys API', () => { describe('getFieldKeys API', () => {
@@ -29,33 +31,33 @@ describe('getFieldKeys API', () => {
it('should call API with correct parameters when no args provided', async () => { it('should call API with correct parameters when no args provided', async () => {
// Mock successful API response // Mock successful API response
(axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call function with no parameters // Call function with no parameters
await getFieldKeys(); await getFieldKeys();
// Verify API was called correctly with empty params object // Verify API was called correctly with empty params object
expect(axios.get).toHaveBeenCalledWith('/fields/keys', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: {}, params: {},
}); });
}); });
it('should call API with signal parameter when provided', async () => { it('should call API with signal parameter when provided', async () => {
// Mock successful API response // Mock successful API response
(axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call function with signal parameter // Call function with signal parameter
await getFieldKeys('traces'); await getFieldKeys('traces');
// Verify API was called with signal parameter // Verify API was called with signal parameter
expect(axios.get).toHaveBeenCalledWith('/fields/keys', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { signal: 'traces' }, params: { signal: 'traces' },
}); });
}); });
it('should call API with name parameter when provided', async () => { it('should call API with name parameter when provided', async () => {
// Mock successful API response // Mock successful API response
(axios.get as jest.Mock).mockResolvedValueOnce({ (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200, status: 200,
data: { data: {
status: 'success', status: 'success',
@@ -70,14 +72,14 @@ describe('getFieldKeys API', () => {
await getFieldKeys(undefined, 'service'); await getFieldKeys(undefined, 'service');
// Verify API was called with name parameter // Verify API was called with name parameter
expect(axios.get).toHaveBeenCalledWith('/fields/keys', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { name: 'service' }, params: { name: 'service' },
}); });
}); });
it('should call API with both signal and name when provided', async () => { it('should call API with both signal and name when provided', async () => {
// Mock successful API response // Mock successful API response
(axios.get as jest.Mock).mockResolvedValueOnce({ (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200, status: 200,
data: { data: {
status: 'success', status: 'success',
@@ -92,14 +94,14 @@ describe('getFieldKeys API', () => {
await getFieldKeys('logs', 'service'); await getFieldKeys('logs', 'service');
// Verify API was called with both parameters // Verify API was called with both parameters
expect(axios.get).toHaveBeenCalledWith('/fields/keys', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
params: { signal: 'logs', name: 'service' }, params: { signal: 'logs', name: 'service' },
}); });
}); });
it('should return properly formatted response', async () => { it('should return properly formatted response', async () => {
// Mock API to return our response // Mock API to return our response
(axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse); (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
// Call the function // Call the function
const result = await getFieldKeys('traces'); const result = await getFieldKeys('traces');

View File

@@ -1,11 +1,13 @@
/* eslint-disable sonarjs/no-duplicate-string */ /* eslint-disable sonarjs/no-duplicate-string */
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { getFieldValues } from '../getFieldValues'; import { getFieldValues } from '../getFieldValues';
// Mock the API instance // Mock the API instance
jest.mock('api', () => ({ jest.mock('api', () => ({
get: jest.fn(), ApiBaseInstance: {
get: jest.fn(),
},
})); }));
describe('getFieldValues API', () => { describe('getFieldValues API', () => {
@@ -15,7 +17,7 @@ describe('getFieldValues API', () => {
it('should call the API with correct parameters (no options)', async () => { it('should call the API with correct parameters (no options)', async () => {
// Mock API response // Mock API response
(axios.get as jest.Mock).mockResolvedValueOnce({ (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200, status: 200,
data: { data: {
status: 'success', status: 'success',
@@ -32,14 +34,14 @@ describe('getFieldValues API', () => {
await getFieldValues(); await getFieldValues();
// Verify API was called correctly with empty params // Verify API was called correctly with empty params
expect(axios.get).toHaveBeenCalledWith('/fields/values', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: {}, params: {},
}); });
}); });
it('should call the API with signal parameter', async () => { it('should call the API with signal parameter', async () => {
// Mock API response // Mock API response
(axios.get as jest.Mock).mockResolvedValueOnce({ (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200, status: 200,
data: { data: {
status: 'success', status: 'success',
@@ -56,14 +58,14 @@ describe('getFieldValues API', () => {
await getFieldValues('traces'); await getFieldValues('traces');
// Verify API was called with signal parameter // Verify API was called with signal parameter
expect(axios.get).toHaveBeenCalledWith('/fields/values', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { signal: 'traces' }, params: { signal: 'traces' },
}); });
}); });
it('should call the API with name parameter', async () => { it('should call the API with name parameter', async () => {
// Mock API response // Mock API response
(axios.get as jest.Mock).mockResolvedValueOnce({ (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200, status: 200,
data: { data: {
status: 'success', status: 'success',
@@ -80,14 +82,14 @@ describe('getFieldValues API', () => {
await getFieldValues(undefined, 'service.name'); await getFieldValues(undefined, 'service.name');
// Verify API was called with name parameter // Verify API was called with name parameter
expect(axios.get).toHaveBeenCalledWith('/fields/values', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { name: 'service.name' }, params: { name: 'service.name' },
}); });
}); });
it('should call the API with value parameter', async () => { it('should call the API with value parameter', async () => {
// Mock API response // Mock API response
(axios.get as jest.Mock).mockResolvedValueOnce({ (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200, status: 200,
data: { data: {
status: 'success', status: 'success',
@@ -104,14 +106,14 @@ describe('getFieldValues API', () => {
await getFieldValues(undefined, 'service.name', 'front'); await getFieldValues(undefined, 'service.name', 'front');
// Verify API was called with value parameter // Verify API was called with value parameter
expect(axios.get).toHaveBeenCalledWith('/fields/values', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { name: 'service.name', searchText: 'front' }, params: { name: 'service.name', searchText: 'front' },
}); });
}); });
it('should call the API with time range parameters', async () => { it('should call the API with time range parameters', async () => {
// Mock API response // Mock API response
(axios.get as jest.Mock).mockResolvedValueOnce({ (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
status: 200, status: 200,
data: { data: {
status: 'success', status: 'success',
@@ -136,7 +138,7 @@ describe('getFieldValues API', () => {
); );
// Verify API was called with time range parameters (converted to milliseconds) // Verify API was called with time range parameters (converted to milliseconds)
expect(axios.get).toHaveBeenCalledWith('/fields/values', { expect(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
params: { params: {
signal: 'logs', signal: 'logs',
name: 'service.name', name: 'service.name',
@@ -163,7 +165,7 @@ describe('getFieldValues API', () => {
}, },
}; };
(axios.get as jest.Mock).mockResolvedValueOnce(mockResponse); (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse);
// Call the function // Call the function
const result = await getFieldValues('traces', 'mixed.values'); const result = await getFieldValues('traces', 'mixed.values');
@@ -194,7 +196,7 @@ describe('getFieldValues API', () => {
}; };
// Mock API to return our response // Mock API to return our response
(axios.get as jest.Mock).mockResolvedValueOnce(mockApiResponse); (ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
// Call the function // Call the function
const result = await getFieldValues('traces', 'service.name'); const result = await getFieldValues('traces', 'service.name');

View File

@@ -1,4 +1,4 @@
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
@@ -24,7 +24,7 @@ export const getFieldKeys = async (
} }
try { try {
const response = await axios.get('/fields/keys', { params }); const response = await ApiBaseInstance.get('/fields/keys', { params });
return { return {
httpStatusCode: response.status, httpStatusCode: response.status,

View File

@@ -1,5 +1,5 @@
/* eslint-disable sonarjs/cognitive-complexity */ /* eslint-disable sonarjs/cognitive-complexity */
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
@@ -47,7 +47,7 @@ export const getFieldValues = async (
} }
try { try {
const response = await axios.get('/fields/values', { params }); const response = await ApiBaseInstance.get('/fields/values', { params });
// Normalize values from different types (stringValues, boolValues, etc.) // Normalize values from different types (stringValues, boolValues, etc.)
if (response.data?.data?.values) { if (response.data?.data?.values) {

View File

@@ -86,9 +86,8 @@ const interceptorRejected = async (
if ( if (
response.status === 401 && response.status === 401 &&
// if the session rotate call or the create session errors out with 401 or the delete sessions call returns 401 then we do not retry! // if the session rotate call errors out with 401 or the delete sessions call returns 401 then we do not retry!
response.config.url !== '/sessions/rotate' && response.config.url !== '/sessions/rotate' &&
response.config.url !== '/sessions/email_password' &&
!( !(
response.config.url === '/sessions' && response.config.method === 'delete' response.config.url === '/sessions' && response.config.method === 'delete'
) )
@@ -200,15 +199,15 @@ ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
// //
// axios Base // axios Base
export const LogEventAxiosInstance = axios.create({ export const ApiBaseInstance = axios.create({
baseURL: `${ENVIRONMENT.baseURL}${apiV1}`, baseURL: `${ENVIRONMENT.baseURL}${apiV1}`,
}); });
LogEventAxiosInstance.interceptors.response.use( ApiBaseInstance.interceptors.response.use(
interceptorsResponse, interceptorsResponse,
interceptorRejectedBase, interceptorRejectedBase,
); );
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse); ApiBaseInstance.interceptors.request.use(interceptorsRequestResponse);
// //
// gateway Api V1 // gateway Api V1

View File

@@ -1,4 +1,4 @@
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError, AxiosResponse } from 'axios'; import { AxiosError, AxiosResponse } from 'axios';
import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder'; import { baseAutoCompleteIdKeysOrder } from 'constants/queryBuilder';
@@ -17,7 +17,7 @@ export const getHostAttributeKeys = async (
try { try {
const response: AxiosResponse<{ const response: AxiosResponse<{
data: IQueryAutocompleteResponse; data: IQueryAutocompleteResponse;
}> = await axios.get( }> = await ApiBaseInstance.get(
`/${entity}/attribute_keys?dataSource=metrics&searchText=${searchText}`, `/${entity}/attribute_keys?dataSource=metrics&searchText=${searchText}`,
{ {
params: { params: {

View File

@@ -1,4 +1,4 @@
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { SOMETHING_WENT_WRONG } from 'constants/api'; import { SOMETHING_WENT_WRONG } from 'constants/api';
@@ -20,7 +20,7 @@ const getOnboardingStatus = async (props: {
}): Promise<SuccessResponse<OnboardingStatusResponse> | ErrorResponse> => { }): Promise<SuccessResponse<OnboardingStatusResponse> | ErrorResponse> => {
const { endpointService, ...rest } = props; const { endpointService, ...rest } = props;
try { try {
const response = await axios.post( const response = await ApiBaseInstance.post(
`/messaging-queues/kafka/onboarding/${endpointService || 'consumers'}`, `/messaging-queues/kafka/onboarding/${endpointService || 'consumers'}`,
rest, rest,
); );

View File

@@ -1,20 +1,13 @@
import { ApiV2Instance } from 'api'; import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { PayloadProps, Props } from 'types/api/metrics/getService'; import { PayloadProps, Props } from 'types/api/metrics/getService';
const getService = async (props: Props): Promise<PayloadProps> => { const getService = async (props: Props): Promise<PayloadProps> => {
try { const response = await axios.post(`/services`, {
const response = await ApiV2Instance.post(`/services`, { start: `${props.start}`,
start: `${props.start}`, end: `${props.end}`,
end: `${props.end}`, tags: props.selectedTags,
tags: props.selectedTags, });
}); return response.data;
return response.data.data;
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
}
}; };
export default getService; export default getService;

View File

@@ -1,27 +1,22 @@
import { ApiV2Instance } from 'api'; import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp } from 'types/api';
import { PayloadProps, Props } from 'types/api/metrics/getTopOperations'; import { PayloadProps, Props } from 'types/api/metrics/getTopOperations';
const getTopOperations = async (props: Props): Promise<PayloadProps> => { const getTopOperations = async (props: Props): Promise<PayloadProps> => {
try { const endpoint = props.isEntryPoint
const endpoint = props.isEntryPoint ? '/service/entry_point_operations'
? '/service/entry_point_operations' : '/service/top_operations';
: '/service/top_operations';
const response = await ApiV2Instance.post(endpoint, { const response = await axios.post(endpoint, {
start: `${props.start}`, start: `${props.start}`,
end: `${props.end}`, end: `${props.end}`,
service: props.service, service: props.service,
tags: props.selectedTags, tags: props.selectedTags,
limit: 5000, });
});
if (props.isEntryPoint) {
return response.data.data; return response.data.data;
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
} }
return response.data;
}; };
export default getTopOperations; export default getTopOperations;

View File

@@ -1,4 +1,4 @@
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { ErrorResponseHandler } from 'api/ErrorResponseHandler'; import { ErrorResponseHandler } from 'api/ErrorResponseHandler';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { ErrorResponse, SuccessResponse } from 'types/api'; import { ErrorResponse, SuccessResponse } from 'types/api';
@@ -9,7 +9,7 @@ const getCustomFilters = async (
): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => { ): Promise<SuccessResponse<PayloadProps> | ErrorResponse> => {
const { signal } = props; const { signal } = props;
try { try {
const response = await axios.get(`/orgs/me/filters/${signal}`); const response = await ApiBaseInstance.get(`orgs/me/filters/${signal}`);
return { return {
statusCode: 200, statusCode: 200,

View File

@@ -1,4 +1,4 @@
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { SuccessResponse } from 'types/api'; import { SuccessResponse } from 'types/api';
import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters'; import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFilters';
@@ -6,7 +6,7 @@ import { UpdateCustomFiltersProps } from 'types/api/quickFilters/updateCustomFil
const updateCustomFiltersAPI = async ( const updateCustomFiltersAPI = async (
props: UpdateCustomFiltersProps, props: UpdateCustomFiltersProps,
): Promise<SuccessResponse<void> | AxiosError> => ): Promise<SuccessResponse<void> | AxiosError> =>
axios.put(`/orgs/me/filters`, { ApiBaseInstance.put(`orgs/me/filters`, {
...props.data, ...props.data,
}); });

View File

@@ -1,4 +1,4 @@
import axios from 'api'; import { ApiBaseInstance } from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2'; import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios'; import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api'; import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
@@ -9,12 +9,15 @@ const listOverview = async (
): Promise<SuccessResponseV2<PayloadProps>> => { ): Promise<SuccessResponseV2<PayloadProps>> => {
const { start, end, show_ip: showIp, filter } = props; const { start, end, show_ip: showIp, filter } = props;
try { try {
const response = await axios.post(`/third-party-apis/overview/list`, { const response = await ApiBaseInstance.post(
start, `/third-party-apis/overview/list`,
end, {
show_ip: showIp, start,
filter, end,
}); show_ip: showIp,
filter,
},
);
return { return {
httpStatusCode: response.status, httpStatusCode: response.status,

View File

@@ -1,28 +0,0 @@
import axios from 'api';
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
import { AxiosError } from 'axios';
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
import {
GetSpanPercentilesProps,
GetSpanPercentilesResponseDataProps,
} from 'types/api/trace/getSpanPercentiles';
const getSpanPercentiles = async (
props: GetSpanPercentilesProps,
): Promise<SuccessResponseV2<GetSpanPercentilesResponseDataProps>> => {
try {
const response = await axios.post('/span_percentile', {
...props,
});
return {
httpStatusCode: response.status,
data: response.data.data,
};
} catch (error) {
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
throw error;
}
};
export default getSpanPercentiles;

View File

@@ -11,7 +11,7 @@ import {
export const getQueryRangeV5 = async ( export const getQueryRangeV5 = async (
props: QueryRangePayloadV5, props: QueryRangePayloadV5,
version: string, version: string,
signal?: AbortSignal, signal: AbortSignal,
headers?: Record<string, string>, headers?: Record<string, string>,
): Promise<SuccessResponseV2<MetricRangePayloadV5>> => { ): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
try { try {

View File

@@ -1,30 +1,30 @@
interface ConfigureIconProps { interface ConfigureIconProps {
width?: number; width?: number;
height?: number; height?: number;
color?: string; fill?: string;
} }
function ConfigureIcon({ function ConfigureIcon({
width, width,
height, height,
color, fill,
}: ConfigureIconProps): JSX.Element { }: ConfigureIconProps): JSX.Element {
return ( return (
<svg <svg
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width={width} width={width}
height={height} height={height}
fill="none" fill={fill}
> >
<path <path
stroke={color} stroke="#C0C1C3"
strokeLinecap="round" strokeLinecap="round"
strokeLinejoin="round" strokeLinejoin="round"
strokeWidth="1.333" strokeWidth="1.333"
d="M9.71 4.745a.576.576 0 000 .806l.922.922a.576.576 0 00.806 0l2.171-2.171a3.455 3.455 0 01-4.572 4.572l-3.98 3.98a1.222 1.222 0 11-1.727-1.728l3.98-3.98a3.455 3.455 0 014.572-4.572L9.717 4.739l-.006.006z" d="M9.71 4.745a.576.576 0 000 .806l.922.922a.576.576 0 00.806 0l2.171-2.171a3.455 3.455 0 01-4.572 4.572l-3.98 3.98a1.222 1.222 0 11-1.727-1.728l3.98-3.98a3.455 3.455 0 014.572-4.572L9.717 4.739l-.006.006z"
/> />
<path <path
stroke={color} stroke="#C0C1C3"
strokeLinecap="round" strokeLinecap="round"
strokeWidth="1.333" strokeWidth="1.333"
d="M4 7L2.527 5.566a1.333 1.333 0 01-.013-1.898l.81-.81a1.333 1.333 0 011.991.119L5.333 3m5.417 7.988l1.179 1.178m0 0l-.138.138a.833.833 0 00.387 1.397v0a.833.833 0 00.792-.219l.446-.446a.833.833 0 00.176-.917v0a.833.833 0 00-1.355-.261l-.308.308z" d="M4 7L2.527 5.566a1.333 1.333 0 01-.013-1.898l.81-.81a1.333 1.333 0 011.991.119L5.333 3m5.417 7.988l1.179 1.178m0 0l-.138.138a.833.833 0 00.387 1.397v0a.833.833 0 00.792-.219l.446-.446a.833.833 0 00.176-.917v0a.833.833 0 00-1.355-.261l-.308.308z"
@@ -36,6 +36,6 @@ function ConfigureIcon({
ConfigureIcon.defaultProps = { ConfigureIcon.defaultProps = {
width: 16, width: 16,
height: 16, height: 16,
color: 'currentColor', fill: 'none',
}; };
export default ConfigureIcon; export default ConfigureIcon;

View File

@@ -232,7 +232,7 @@ describe('getYAxisFormattedValue - units (full precision legacy assertions)', ()
).toBe('1%'); ).toBe('1%');
expect( expect(
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'percent'), testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'percent'),
).toBe('1.005555555595959%'); ).toBe('1.005555555595958%');
}); });
test('ratio', () => { test('ratio', () => {
@@ -359,7 +359,7 @@ describe('getYAxisFormattedValue - precision option tests', () => {
's', 's',
PrecisionOptionsEnum.FULL, PrecisionOptionsEnum.FULL,
), ),
).toBe('26.254299141484417 µs'); ).toBe('26254299141484417000000 µs');
expect( expect(
getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL), getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL),

View File

@@ -149,7 +149,6 @@ export const getGraphOptions = (
scales: { scales: {
x: { x: {
stacked: isStacked, stacked: isStacked,
offset: false,
grid: { grid: {
display: true, display: true,
color: getGridColor(), color: getGridColor(),

View File

@@ -101,10 +101,19 @@ export const getYAxisFormattedValue = (
if (numValue === Infinity) return '∞'; if (numValue === Infinity) return '∞';
if (numValue === -Infinity) return '-∞'; if (numValue === -Infinity) return '-∞';
const decimalPlaces = value.split('.')[1]?.length || undefined;
// Use custom formatter for the 'none' format honoring precision
if (format === 'none') {
return formatDecimalWithLeadingZeros(numValue, precision);
}
// For all other standard formats, delegate to grafana/data's built-in formatter. // For all other standard formats, delegate to grafana/data's built-in formatter.
const computeDecimals = (): number | undefined => { const computeDecimals = (): number | undefined => {
if (precision === PrecisionOptionsEnum.FULL) { if (precision === PrecisionOptionsEnum.FULL) {
return DEFAULT_SIGNIFICANT_DIGITS; return decimalPlaces && decimalPlaces >= DEFAULT_SIGNIFICANT_DIGITS
? decimalPlaces
: DEFAULT_SIGNIFICANT_DIGITS;
} }
return precision; return precision;
}; };
@@ -121,11 +130,6 @@ export const getYAxisFormattedValue = (
}; };
try { try {
// Use custom formatter for the 'none' format honoring precision
if (format === 'none') {
return formatDecimalWithLeadingZeros(numValue, precision);
}
const formatter = getValueFormat(format); const formatter = getValueFormat(format);
const formattedValue = formatter(numValue, computeDecimals(), undefined); const formattedValue = formatter(numValue, computeDecimals(), undefined);
if (formattedValue.text && formattedValue.text.includes('.')) { if (formattedValue.text && formattedValue.text.includes('.')) {

View File

@@ -37,6 +37,7 @@
border-radius: 2px 0px 0px 2px; border-radius: 2px 0px 0px 2px;
border: 1px solid var(--bg-slate-400); border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
border-right: none; border-right: none;
border-left: none; border-left: none;
@@ -44,12 +45,6 @@
border-bottom-right-radius: 0px; border-bottom-right-radius: 0px;
border-top-left-radius: 0px; border-top-left-radius: 0px;
border-bottom-left-radius: 0px; border-bottom-left-radius: 0px;
font-size: 12px !important;
line-height: 27px;
&::placeholder {
color: var(--bg-vanilla-400) !important;
font-size: 12px !important;
}
} }
.close-btn { .close-btn {

View File

@@ -132,9 +132,9 @@
justify-content: center; justify-content: center;
} }
.log-detail-drawer__actions { .json-action-btn {
display: flex; display: flex;
gap: 4px; gap: 8px;
} }
} }

View File

@@ -319,35 +319,31 @@ function LogDetailInner({
</Radio.Button> </Radio.Button>
</Radio.Group> </Radio.Group>
<div className="log-detail-drawer__actions"> {selectedView === VIEW_TYPES.JSON && (
{selectedView === VIEW_TYPES.CONTEXT && ( <div className="json-action-btn">
<Tooltip
title="Show Filters"
placement="topLeft"
aria-label="Show Filters"
>
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
</Tooltip>
)}
<Tooltip
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
placement="topLeft"
aria-label={
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
}
>
<Button <Button
className="action-btn" className="action-btn"
icon={<Copy size={16} />} icon={<Copy size={16} />}
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy} onClick={handleJSONCopy}
/> />
</Tooltip> </div>
</div> )}
{selectedView === VIEW_TYPES.CONTEXT && (
<Button
className="action-btn"
icon={<Filter size={16} />}
onClick={handleFilterVisible}
/>
)}
<Tooltip title="Copy Log Link" placement="left" aria-label="Copy Log Link">
<Button
className="action-btn"
icon={<Copy size={16} />}
onClick={onLogCopy}
/>
</Tooltip>
</div> </div>
{isFilterVisible && contextQuery?.builder.queryData[0] && ( {isFilterVisible && contextQuery?.builder.queryData[0] && (
<div className="log-detail-drawer-query-container"> <div className="log-detail-drawer-query-container">
@@ -387,8 +383,7 @@ function LogDetailInner({
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''} podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''} nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''} hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
timestamp={log.timestamp.toString()} logLineTimestamp={log.timestamp.toString()}
dataSource={DataSource.LOGS}
/> />
)} )}
</Drawer> </Drawer>

View File

@@ -6,7 +6,6 @@ import { useCopyToClipboard } from 'react-use';
function CopyClipboardHOC({ function CopyClipboardHOC({
entityKey, entityKey,
textToCopy, textToCopy,
tooltipText = 'Copy to clipboard',
children, children,
}: CopyClipboardHOCProps): JSX.Element { }: CopyClipboardHOCProps): JSX.Element {
const [value, setCopy] = useCopyToClipboard(); const [value, setCopy] = useCopyToClipboard();
@@ -32,7 +31,7 @@ function CopyClipboardHOC({
<span onClick={onClick} role="presentation" tabIndex={-1}> <span onClick={onClick} role="presentation" tabIndex={-1}>
<Popover <Popover
placement="top" placement="top"
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>} content={<span style={{ fontSize: '0.9rem' }}>Copy to clipboard</span>}
> >
{children} {children}
</Popover> </Popover>
@@ -43,11 +42,7 @@ function CopyClipboardHOC({
interface CopyClipboardHOCProps { interface CopyClipboardHOCProps {
entityKey: string | undefined; entityKey: string | undefined;
textToCopy: string; textToCopy: string;
tooltipText?: string;
children: ReactNode; children: ReactNode;
} }
export default CopyClipboardHOC; export default CopyClipboardHOC;
CopyClipboardHOC.defaultProps = {
tooltipText: 'Copy to clipboard',
};

View File

@@ -471,13 +471,11 @@ function LogsFormatOptionsMenu({
rootClassName="format-options-popover" rootClassName="format-options-popover"
destroyTooltipOnHide destroyTooltipOnHide
> >
<Tooltip title="Options"> <Button
<Button className="periscope-btn ghost"
className="periscope-btn ghost" icon={<Sliders size={14} />}
icon={<Sliders size={14} />} data-testid="periscope-btn-format-options"
data-testid="periscope-btn-format-options" />
/>
</Tooltip>
</Popover> </Popover>
); );
} }

View File

@@ -251,10 +251,6 @@
.ant-input-group-addon { .ant-input-group-addon {
border-top-left-radius: 0px !important; border-top-left-radius: 0px !important;
border-top-right-radius: 0px !important; border-top-right-radius: 0px !important;
background: var(--bg-ink-300);
color: var(--bg-vanilla-400);
font-size: 12px;
font-weight: 300;
} }
.ant-input { .ant-input {
@@ -300,10 +296,6 @@
} }
} }
.qb-trace-operator-button-container { .qb-trace-operator-button-container {
display: flex;
align-items: center;
gap: 8px;
&-text { &-text {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -406,7 +398,7 @@
} }
.qb-search-container { .qb-search-container {
.metrics-container { .metrics-select-container {
margin-bottom: 12px; margin-bottom: 12px;
} }
} }

View File

@@ -22,8 +22,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
showOnlyWhereClause = false, showOnlyWhereClause = false,
showTraceOperator = false, showTraceOperator = false,
version, version,
onSignalSourceChange,
signalSourceChangeEnabled = false,
}: QueryBuilderProps): JSX.Element { }: QueryBuilderProps): JSX.Element {
const { const {
currentQuery, currentQuery,
@@ -177,9 +175,6 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'} queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause} showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel} isListViewPanel={isListViewPanel}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={1}
/> />
) : ( ) : (
currentQuery.builder.queryData.map((query, index) => ( currentQuery.builder.queryData.map((query, index) => (
@@ -198,10 +193,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
queryVariant={config?.queryVariant || 'dropdown'} queryVariant={config?.queryVariant || 'dropdown'}
showOnlyWhereClause={showOnlyWhereClause} showOnlyWhereClause={showOnlyWhereClause}
isListViewPanel={isListViewPanel} isListViewPanel={isListViewPanel}
signalSource={query.source as 'meter' | ''} signalSource={config?.signalSource || ''}
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
signalSourceChangeEnabled={signalSourceChangeEnabled}
queriesCount={currentQuery.builder.queryData.length}
/> />
)) ))
)} )}

View File

@@ -98,13 +98,6 @@
border-radius: 2px; border-radius: 2px;
border: 1.005px solid var(--Slate-400, #1d212d); border: 1.005px solid var(--Slate-400, #1d212d);
background: var(--Ink-300, #16181d); background: var(--Ink-300, #16181d);
color: var(--bg-vanilla-400);
font-family: 'Geist Mono';
font-size: 13px;
font-style: normal;
font-weight: 400;
line-height: 20px;
letter-spacing: -0.07px;
} }
.input-with-label { .input-with-label {

View File

@@ -1,23 +1,5 @@
.metrics-source-select-container { .metrics-select-container {
margin-bottom: 8px; margin-bottom: 8px;
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 8px;
width: 100%;
.ant-select-selection-search-input {
font-size: 12px !important;
line-height: 27px;
&::placeholder {
color: var(--bg-vanilla-400) !important;
font-size: 12px !important;
}
}
.source-selector {
width: 120px;
}
.ant-select-selector { .ant-select-selector {
width: 100%; width: 100%;
@@ -31,11 +13,6 @@
font-weight: 400; font-weight: 400;
line-height: 20px; /* 142.857% */ line-height: 20px; /* 142.857% */
min-height: 36px; min-height: 36px;
.ant-select-selection-placeholder {
color: var(--bg-vanilla-400) !important;
font-size: 12px !important;
}
} }
.ant-select-dropdown { .ant-select-dropdown {
@@ -65,7 +42,7 @@
} }
.lightMode { .lightMode {
.metrics-source-select-container { .metrics-select-container {
.ant-select-selector { .ant-select-selector {
border: 1px solid var(--bg-vanilla-300) !important; border: 1px solid var(--bg-vanilla-300) !important;
background: var(--bg-vanilla-100); background: var(--bg-vanilla-100);

View File

@@ -1,39 +1,21 @@
import './MetricsSelect.styles.scss'; import './MetricsSelect.styles.scss';
import { Select } from 'antd';
import {
initialQueriesMap,
initialQueryMeterWithType,
PANEL_TYPES,
} from 'constants/queryBuilder';
import { AggregatorFilter } from 'container/QueryBuilder/filters'; import { AggregatorFilter } from 'container/QueryBuilder/filters';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { memo, useCallback, useMemo, useState } from 'react'; import { memo, useCallback, useState } from 'react';
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { DataSource } from 'types/common/queryBuilder';
import { SelectOption } from 'types/common/select';
export const SOURCE_OPTIONS: SelectOption<string, string>[] = [
{ value: 'metrics', label: 'Metrics' },
{ value: 'meter', label: 'Meter' },
];
export const MetricsSelect = memo(function MetricsSelect({ export const MetricsSelect = memo(function MetricsSelect({
query, query,
index, index,
version, version,
signalSource, signalSource,
onSignalSourceChange,
signalSourceChangeEnabled = false,
}: { }: {
query: IBuilderQuery; query: IBuilderQuery;
index: number; index: number;
version: string; version: string;
signalSource: 'meter' | ''; signalSource: 'meter' | '';
onSignalSourceChange: (value: string) => void;
signalSourceChangeEnabled: boolean;
}): JSX.Element { }): JSX.Element {
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]); const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
@@ -49,67 +31,8 @@ export const MetricsSelect = memo(function MetricsSelect({
}, },
[handleChangeAggregatorAttribute, attributeKeys], [handleChangeAggregatorAttribute, attributeKeys],
); );
const { updateAllQueriesOperators, handleSetQueryData } = useQueryBuilder();
const source = useMemo(
() => (signalSource === 'meter' ? 'meter' : 'metrics'),
[signalSource],
);
const defaultMeterQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueryMeterWithType,
PANEL_TYPES.BAR,
DataSource.METRICS,
'meter' as 'meter' | '',
),
[updateAllQueriesOperators],
);
const defaultMetricsQuery = useMemo(
() =>
updateAllQueriesOperators(
initialQueriesMap.metrics,
PANEL_TYPES.BAR,
DataSource.METRICS,
'',
),
[updateAllQueriesOperators],
);
const handleSignalSourceChange = (value: string): void => {
onSignalSourceChange(value);
handleSetQueryData(
index,
value === 'meter'
? {
...defaultMeterQuery.builder.queryData[0],
source: 'meter',
queryName: query.queryName,
}
: {
...defaultMetricsQuery.builder.queryData[0],
source: '',
queryName: query.queryName,
},
);
};
return ( return (
<div className="metrics-source-select-container"> <div className="metrics-select-container">
{signalSourceChangeEnabled && (
<Select
className="source-selector"
placeholder="Source"
options={SOURCE_OPTIONS}
value={source}
defaultValue="metrics"
onChange={handleSignalSourceChange}
/>
)}
<AggregatorFilter <AggregatorFilter
onChange={handleAggregatorAttributeChange} onChange={handleAggregatorAttributeChange}
query={query} query={query}

View File

@@ -236,10 +236,6 @@
background: var(--bg-ink-100) !important; background: var(--bg-ink-100) !important;
opacity: 0.5 !important; opacity: 0.5 !important;
} }
.cm-activeLine > span {
font-size: 12px !important;
}
} }
} }
@@ -275,9 +271,6 @@
box-sizing: border-box; box-sizing: border-box;
position: relative; position: relative;
.cm-placeholder {
font-size: 12px !important;
}
} }
} }

View File

@@ -20,8 +20,6 @@
border-radius: 2px; border-radius: 2px;
flex: 1; flex: 1;
min-width: 0; min-width: 0;
font-size: 12px;
color: var(--bg-vanilla-400) !important;
&.error { &.error {
.cm-editor { .cm-editor {
@@ -233,9 +231,6 @@
.query-aggregation-interval-input { .query-aggregation-interval-input {
input { input {
max-width: 120px; max-width: 120px;
&::placeholder {
color: var(--bg-vanilla-400);
}
} }
} }
} }

View File

@@ -1,7 +0,0 @@
.add-trace-operator-button,
.add-new-query-button,
.add-formula-button {
border: 1px solid var(--bg-slate-400);
background: var(--bg-ink-300);
box-shadow: 0px 0px 8px 0px rgba(0, 0, 0, 0.1);
}

View File

@@ -1,75 +1,7 @@
import './QueryFooter.styles.scss';
/* eslint-disable react/require-default-props */ /* eslint-disable react/require-default-props */
import { Button, Tooltip, Typography } from 'antd'; import { Button, Tooltip, Typography } from 'antd';
import WarningPopover from 'components/WarningPopover/WarningPopover';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { DraftingCompass, Plus, Sigma } from 'lucide-react'; import { DraftingCompass, Plus, Sigma } from 'lucide-react';
import BetaTag from 'periscope/components/BetaTag/BetaTag'; import BetaTag from 'periscope/components/BetaTag/BetaTag';
import { useMemo } from 'react';
function TraceOperatorSection({
addTraceOperator,
}: {
addTraceOperator?: () => void;
}): JSX.Element {
const { currentQuery, panelType } = useQueryBuilder();
const showTraceOperatorWarning = useMemo(() => {
const isListViewPanel =
panelType === PANEL_TYPES.LIST || panelType === PANEL_TYPES.TRACE;
const hasMultipleQueries = currentQuery.builder.queryData.length > 1;
const hasTraceOperator =
currentQuery.builder.queryTraceOperator &&
currentQuery.builder.queryTraceOperator.length > 0;
return isListViewPanel && hasMultipleQueries && !hasTraceOperator;
}, [
currentQuery?.builder?.queryData,
currentQuery?.builder?.queryTraceOperator,
panelType,
]);
const traceOperatorWarning = useMemo(() => {
if (currentQuery.builder.queryData.length === 0) return '';
const firstQuery = currentQuery.builder.queryData[0];
return `Currently, you are only seeing results from query ${firstQuery.queryName}. Add a trace operator to combine results of multiple queries.`;
}, [currentQuery]);
return (
<div className="qb-trace-operator-button-container">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Add Trace Matching
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<Button
className="add-trace-operator-button periscope-btn"
icon={<DraftingCompass size={16} />}
onClick={(): void => addTraceOperator?.()}
>
<div className="qb-trace-operator-button-container-text">
Add Trace Matching
<BetaTag />
</div>
</Button>
</Tooltip>
{showTraceOperatorWarning && (
<WarningPopover message={traceOperatorWarning} />
)}
</div>
);
}
export default function QueryFooter({ export default function QueryFooter({
addNewBuilderQuery, addNewBuilderQuery,
@@ -90,7 +22,8 @@ export default function QueryFooter({
<div className="qb-add-new-query"> <div className="qb-add-new-query">
<Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}> <Tooltip title={<div style={{ textAlign: 'center' }}>Add New Query</div>}>
<Button <Button
className="add-new-query-button periscope-btn " className="add-new-query-button periscope-btn secondary"
type="text"
icon={<Plus size={16} />} icon={<Plus size={16} />}
onClick={addNewBuilderQuery} onClick={addNewBuilderQuery}
/> />
@@ -116,7 +49,7 @@ export default function QueryFooter({
} }
> >
<Button <Button
className="add-formula-button periscope-btn " className="add-formula-button periscope-btn secondary"
icon={<Sigma size={16} />} icon={<Sigma size={16} />}
onClick={addNewFormula} onClick={addNewFormula}
> >
@@ -126,7 +59,35 @@ export default function QueryFooter({
</div> </div>
)} )}
{showAddTraceOperator && ( {showAddTraceOperator && (
<TraceOperatorSection addTraceOperator={addTraceOperator} /> <div className="qb-trace-operator-button-container">
<Tooltip
title={
<div style={{ textAlign: 'center' }}>
Add Trace Matching
<Typography.Link
href="https://signoz.io/docs/userguide/query-builder-v5/#multi-query-analysis-trace-operators"
target="_blank"
style={{ textDecoration: 'underline' }}
>
{' '}
<br />
Learn more
</Typography.Link>
</div>
}
>
<Button
className="add-trace-operator-button periscope-btn secondary"
icon={<DraftingCompass size={16} />}
onClick={(): void => addTraceOperator?.()}
>
<div className="qb-trace-operator-button-container-text">
Add Trace Matching
<BetaTag />
</div>
</Button>
</Tooltip>
</div>
)} )}
</div> </div>
</div> </div>

View File

@@ -12,7 +12,6 @@ import {
startCompletion, startCompletion,
} from '@codemirror/autocomplete'; } from '@codemirror/autocomplete';
import { javascript } from '@codemirror/lang-javascript'; import { javascript } from '@codemirror/lang-javascript';
import * as Sentry from '@sentry/react';
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { copilot } from '@uiw/codemirror-theme-copilot'; import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github'; import { githubLight } from '@uiw/codemirror-theme-github';
@@ -80,16 +79,6 @@ const stopEventsExtension = EditorView.domEventHandlers({
}, },
}); });
interface QuerySearchProps {
placeholder?: string;
onChange: (value: string) => void;
queryData: IBuilderQuery;
dataSource: DataSource;
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
}
function QuerySearch({ function QuerySearch({
placeholder, placeholder,
onChange, onChange,
@@ -98,8 +87,17 @@ function QuerySearch({
onRun, onRun,
signalSource, signalSource,
hardcodedAttributeKeys, hardcodedAttributeKeys,
}: QuerySearchProps): JSX.Element { }: {
placeholder?: string;
onChange: (value: string) => void;
queryData: IBuilderQuery;
dataSource: DataSource;
signalSource?: string;
hardcodedAttributeKeys?: QueryKeyDataSuggestionsProps[];
onRun?: (query: string) => void;
}): JSX.Element {
const isDarkMode = useIsDarkMode(); const isDarkMode = useIsDarkMode();
const [query, setQuery] = useState<string>(queryData.filter?.expression || '');
const [valueSuggestions, setValueSuggestions] = useState<any[]>([]); const [valueSuggestions, setValueSuggestions] = useState<any[]>([]);
const [activeKey, setActiveKey] = useState<string>(''); const [activeKey, setActiveKey] = useState<string>('');
const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false); const [isLoadingSuggestions, setIsLoadingSuggestions] = useState(false);
@@ -109,12 +107,8 @@ function QuerySearch({
message: '', message: '',
errors: [], errors: [],
}); });
const isProgrammaticChangeRef = useRef(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const [isFocused, setIsFocused] = useState(false);
const editorRef = useRef<EditorView | null>(null);
const handleQueryValidation = useCallback((newQuery: string): void => { const handleQueryValidation = (newQuery: string): void => {
try { try {
const validationResponse = validateQuery(newQuery); const validationResponse = validateQuery(newQuery);
setValidation(validationResponse); setValidation(validationResponse);
@@ -125,67 +119,29 @@ function QuerySearch({
errors: [error as IDetailedError], errors: [error as IDetailedError],
}); });
} }
}, []); };
const getCurrentQuery = useCallback( // Track if the query was changed externally (from queryData) vs internally (user input)
(): string => editorRef.current?.state.doc.toString() || '', const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
[], const [lastExternalQuery, setLastExternalQuery] = useState<string>('');
);
const updateEditorValue = useCallback( useEffect(() => {
(value: string, options: { skipOnChange?: boolean } = {}): void => { const newQuery = queryData.filter?.expression || '';
const view = editorRef.current; // Only mark as external change if the query actually changed from external source
if (!view) return; if (newQuery !== lastExternalQuery) {
setQuery(newQuery);
setIsExternalQueryChange(true);
setLastExternalQuery(newQuery);
}
}, [queryData.filter?.expression, lastExternalQuery]);
const currentValue = view.state.doc.toString(); // Validate query when it changes externally (from queryData)
if (currentValue === value) return; useEffect(() => {
if (isExternalQueryChange && query) {
if (options.skipOnChange) { handleQueryValidation(query);
isProgrammaticChangeRef.current = true; setIsExternalQueryChange(false);
} }
}, [isExternalQueryChange, query]);
view.dispatch({
changes: {
from: 0,
to: currentValue.length,
insert: value,
},
selection: {
anchor: value.length,
},
});
},
[],
);
const handleEditorCreate = useCallback((view: EditorView): void => {
editorRef.current = view;
setIsEditorReady(true);
}, []);
useEffect(
() => {
if (!isEditorReady) return;
const newQuery = queryData.filter?.expression || '';
const currentQuery = getCurrentQuery();
/* eslint-disable-next-line sonarjs/no-collapsible-if */
if (newQuery !== currentQuery && !isFocused) {
// Prevent clearing a non-empty editor when queryData becomes empty temporarily
// Only update if newQuery has a value, or if both are empty (initial state)
if (newQuery || !currentQuery) {
updateEditorValue(newQuery, { skipOnChange: true });
if (newQuery) {
handleQueryValidation(newQuery);
}
}
}
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[isEditorReady, queryData.filter?.expression, isFocused],
);
const [keySuggestions, setKeySuggestions] = useState< const [keySuggestions, setKeySuggestions] = useState<
QueryKeyDataSuggestionsProps[] | null QueryKeyDataSuggestionsProps[] | null
@@ -194,6 +150,7 @@ function QuerySearch({
const [showExamples] = useState(false); const [showExamples] = useState(false);
const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 }); const [cursorPos, setCursorPos] = useState({ line: 0, ch: 0 });
const [isFocused, setIsFocused] = useState(false);
const [ const [
isFetchingCompleteValuesList, isFetchingCompleteValuesList,
@@ -202,6 +159,8 @@ function QuerySearch({
const lastPosRef = useRef<{ line: number; ch: number }>({ line: 0, ch: 0 }); const lastPosRef = useRef<{ line: number; ch: number }>({ line: 0, ch: 0 });
// Reference to the editor view for programmatic autocompletion
const editorRef = useRef<EditorView | null>(null);
const lastKeyRef = useRef<string>(''); const lastKeyRef = useRef<string>('');
const lastFetchedKeyRef = useRef<string>(''); const lastFetchedKeyRef = useRef<string>('');
const lastValueRef = useRef<string>(''); const lastValueRef = useRef<string>('');
@@ -547,7 +506,6 @@ function QuerySearch({
if (!editorRef.current) { if (!editorRef.current) {
editorRef.current = viewUpdate.view; editorRef.current = viewUpdate.view;
setIsEditorReady(true);
} }
const selection = viewUpdate.view.state.selection.main; const selection = viewUpdate.view.state.selection.main;
@@ -563,15 +521,7 @@ function QuerySearch({
const lastPos = lastPosRef.current; const lastPos = lastPosRef.current;
if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) { if (newPos.line !== lastPos.line || newPos.ch !== lastPos.ch) {
setCursorPos((lastPos) => { setCursorPos(newPos);
if (newPos.ch !== lastPos.ch && newPos.ch === 0) {
Sentry.captureEvent({
message: `Cursor jumped to start of line from ${lastPos.ch} to ${newPos.ch}`,
level: 'warning',
});
}
return newPos;
});
lastPosRef.current = newPos; lastPosRef.current = newPos;
if (doc) { if (doc) {
@@ -604,17 +554,16 @@ function QuerySearch({
}, []); }, []);
const handleChange = (value: string): void => { const handleChange = (value: string): void => {
if (isProgrammaticChangeRef.current) { setQuery(value);
isProgrammaticChangeRef.current = false;
return;
}
onChange(value); onChange(value);
// Mark as internal change to avoid triggering external validation
setIsExternalQueryChange(false);
// Update lastExternalQuery to prevent external validation trigger
setLastExternalQuery(value);
}; };
const handleBlur = (): void => { const handleBlur = (): void => {
const currentQuery = getCurrentQuery(); handleQueryValidation(query);
handleQueryValidation(currentQuery);
setIsFocused(false); setIsFocused(false);
}; };
@@ -633,11 +582,12 @@ function QuerySearch({
const handleExampleClick = (exampleQuery: string): void => { const handleExampleClick = (exampleQuery: string): void => {
// If there's an existing query, append the example with AND // If there's an existing query, append the example with AND
const currentQuery = getCurrentQuery(); const newQuery = query ? `${query} AND ${exampleQuery}` : exampleQuery;
const newQuery = currentQuery setQuery(newQuery);
? `${currentQuery} AND ${exampleQuery}` // Mark as internal change to avoid triggering external validation
: exampleQuery; setIsExternalQueryChange(false);
updateEditorValue(newQuery); // Update lastExternalQuery to prevent external validation trigger
setLastExternalQuery(newQuery);
}; };
// Helper function to render a badge for the current context mode // Helper function to render a badge for the current context mode
@@ -672,10 +622,8 @@ function QuerySearch({
const word = context.matchBefore(/[a-zA-Z0-9_.:/?&=#%\-\[\]]*/); const word = context.matchBefore(/[a-zA-Z0-9_.:/?&=#%\-\[\]]*/);
if (word?.from === word?.to && !context.explicit) return null; if (word?.from === word?.to && !context.explicit) return null;
// Get current query from editor
const currentQuery = editorRef.current?.state.doc.toString() || '';
// Get the query context at the cursor position // Get the query context at the cursor position
const queryContext = getQueryContextAtCursor(currentQuery, cursorPos.ch); const queryContext = getQueryContextAtCursor(query, cursorPos.ch);
// Define autocomplete options based on the context // Define autocomplete options based on the context
let options: { let options: {
@@ -1171,8 +1119,7 @@ function QuerySearch({
if (queryContext.isInParenthesis) { if (queryContext.isInParenthesis) {
// Different suggestions based on the context within parenthesis or bracket // Different suggestions based on the context within parenthesis or bracket
const currentQuery = editorRef.current?.state.doc.toString() || ''; const curChar = query.charAt(cursorPos.ch - 1) || '';
const curChar = currentQuery.charAt(cursorPos.ch - 1) || '';
if (curChar === '(' || curChar === '[') { if (curChar === '(' || curChar === '[') {
// Right after opening parenthesis/bracket // Right after opening parenthesis/bracket
@@ -1321,7 +1268,7 @@ function QuerySearch({
style={{ style={{
position: 'absolute', position: 'absolute',
top: 8, top: 8,
right: validation.isValid === false && getCurrentQuery() ? 40 : 8, // Move left when error shown right: validation.isValid === false && query ? 40 : 8, // Move left when error shown
cursor: 'help', cursor: 'help',
zIndex: 10, zIndex: 10,
transition: 'right 0.2s ease', transition: 'right 0.2s ease',
@@ -1342,10 +1289,10 @@ function QuerySearch({
</Tooltip> </Tooltip>
<CodeMirror <CodeMirror
value={query}
theme={isDarkMode ? copilot : githubLight} theme={isDarkMode ? copilot : githubLight}
onChange={handleChange} onChange={handleChange}
onUpdate={handleUpdate} onUpdate={handleUpdate}
onCreateEditor={handleEditorCreate}
className={cx('query-where-clause-editor', { className={cx('query-where-clause-editor', {
isValid: validation.isValid === true, isValid: validation.isValid === true,
hasErrors: validation.errors.length > 0, hasErrors: validation.errors.length > 0,
@@ -1383,7 +1330,7 @@ function QuerySearch({
// Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS // Mod-Enter is usually Ctrl-Enter or Cmd-Enter based on OS
run: (): boolean => { run: (): boolean => {
if (onRun && typeof onRun === 'function') { if (onRun && typeof onRun === 'function') {
onRun(getCurrentQuery()); onRun(query);
} else { } else {
handleRunQuery(); handleRunQuery();
} }
@@ -1409,7 +1356,7 @@ function QuerySearch({
onBlur={handleBlur} onBlur={handleBlur}
/> />
{getCurrentQuery() && validation.isValid === false && !isFocused && ( {query && validation.isValid === false && !isFocused && (
<div <div
className={cx('query-status-container', { className={cx('query-status-container', {
hasErrors: validation.errors.length > 0, hasErrors: validation.errors.length > 0,

View File

@@ -9,13 +9,7 @@ import SpanScopeSelector from 'container/QueryBuilder/filters/QueryBuilderSearch
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder'; import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations'; import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
import { Copy, Ellipsis, Trash } from 'lucide-react'; import { Copy, Ellipsis, Trash } from 'lucide-react';
import { import { memo, useCallback, useMemo, useState } from 'react';
ForwardedRef,
forwardRef,
useCallback,
useMemo,
useState,
} from 'react';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import { HandleChangeQueryDataV5 } from 'types/common/operations.types'; import { HandleChangeQueryDataV5 } from 'types/common/operations.types';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
@@ -26,29 +20,20 @@ import QueryAddOns from './QueryAddOns/QueryAddOns';
import QueryAggregation from './QueryAggregation/QueryAggregation'; import QueryAggregation from './QueryAggregation/QueryAggregation';
import QuerySearch from './QuerySearch/QuerySearch'; import QuerySearch from './QuerySearch/QuerySearch';
export const QueryV2 = forwardRef(function QueryV2( export const QueryV2 = memo(function QueryV2({
{ ref,
index, index,
queryVariant, queryVariant,
query, query,
filterConfigs, filterConfigs,
isListViewPanel = false, isListViewPanel = false,
showTraceOperator = false, showTraceOperator = false,
hasTraceOperator = false, hasTraceOperator = false,
version, version,
showOnlyWhereClause = false, showOnlyWhereClause = false,
signalSource = '', signalSource = '',
isMultiQueryAllowed = false, isMultiQueryAllowed = false,
onSignalSourceChange, }: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
signalSourceChangeEnabled = false,
queriesCount = 1,
}: QueryProps & {
onSignalSourceChange: (value: string) => void;
signalSourceChangeEnabled: boolean;
queriesCount: number;
},
ref: ForwardedRef<HTMLDivElement>,
): JSX.Element {
const { cloneQuery, panelType } = useQueryBuilder(); const { cloneQuery, panelType } = useQueryBuilder();
const showFunctions = query?.functions?.length > 0; const showFunctions = query?.functions?.length > 0;
@@ -201,16 +186,12 @@ export const QueryV2 = forwardRef(function QueryV2(
icon: <Copy size={14} />, icon: <Copy size={14} />,
onClick: handleCloneEntity, onClick: handleCloneEntity,
}, },
...(queriesCount && queriesCount > 1 {
? [ label: 'Delete',
{ key: 'delete-query',
label: 'Delete', icon: <Trash size={14} />,
key: 'delete-query', onClick: handleDeleteQuery,
icon: <Trash size={14} />, },
onClick: handleDeleteQuery,
},
]
: []),
], ],
}} }}
placement="bottomRight" placement="bottomRight"
@@ -226,14 +207,12 @@ export const QueryV2 = forwardRef(function QueryV2(
<div className="qb-elements-container"> <div className="qb-elements-container">
<div className="qb-search-container"> <div className="qb-search-container">
{dataSource === DataSource.METRICS && ( {dataSource === DataSource.METRICS && (
<div className="metrics-container"> <div className="metrics-select-container">
<MetricsSelect <MetricsSelect
query={query} query={query}
index={index} index={index}
version={ENTITY_VERSION_V5} version={ENTITY_VERSION_V5}
signalSource={signalSource as 'meter' | ''} signalSource={signalSource as 'meter' | ''}
onSignalSourceChange={onSignalSourceChange}
signalSourceChangeEnabled={signalSourceChangeEnabled}
/> />
</div> </div>
)} )}
@@ -279,7 +258,7 @@ export const QueryV2 = forwardRef(function QueryV2(
panelType={panelType} panelType={panelType}
query={query} query={query}
index={index} index={index}
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`} key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}`}
version="v4" version="v4"
signalSource={signalSource as 'meter' | ''} signalSource={signalSource as 'meter' | ''}
/> />
@@ -302,5 +281,3 @@ export const QueryV2 = forwardRef(function QueryV2(
</div> </div>
); );
}); });
QueryV2.displayName = 'QueryV2';

View File

@@ -92,9 +92,6 @@
.qb-trace-operator-editor-container { .qb-trace-operator-editor-container {
flex: 1; flex: 1;
.cm-activeLine > span {
font-size: 12px;
}
} }
&.arrow-left { &.arrow-left {
@@ -116,8 +113,6 @@
text-overflow: ellipsis; text-overflow: ellipsis;
padding: 0px 8px; padding: 0px 8px;
border-right: 1px solid var(--bg-slate-400); border-right: 1px solid var(--bg-slate-400);
font-size: 12px;
font-weight: 300;
} }
} }
} }

View File

@@ -68,7 +68,7 @@ export default function TraceOperator({
!isListViewPanel && 'qb-trace-operator-arrow', !isListViewPanel && 'qb-trace-operator-arrow',
)} )}
> >
<Typography.Text className="label">Trace Operator</Typography.Text> <Typography.Text className="label">TRACE OPERATOR</Typography.Text>
<div className="qb-trace-operator-editor-container"> <div className="qb-trace-operator-editor-container">
<TraceOperatorEditor <TraceOperatorEditor
value={traceOperator?.expression || ''} value={traceOperator?.expression || ''}

View File

@@ -5,85 +5,13 @@ import { getKeySuggestions } from 'api/querySuggestions/getKeySuggestions';
import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion'; import { getValueSuggestions } from 'api/querySuggestions/getValueSuggestion';
import { initialQueriesMap } from 'constants/queryBuilder'; import { initialQueriesMap } from 'constants/queryBuilder';
import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder'; import * as UseQBModule from 'hooks/queryBuilder/useQueryBuilder';
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils'; import React from 'react';
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types'; import type { QueryKeyDataSuggestionsProps } from 'types/api/querySuggestions/types';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import QuerySearch from '../QuerySearch/QuerySearch'; import QuerySearch from '../QuerySearch/QuerySearch';
const CM_EDITOR_SELECTOR = '.cm-editor .cm-content';
// Mock DOM APIs that CodeMirror needs
beforeAll(() => {
// Mock getClientRects and getBoundingClientRect for Range objects
const mockRect: DOMRect = {
width: 100,
height: 20,
top: 0,
left: 0,
right: 100,
bottom: 20,
x: 0,
y: 0,
toJSON: (): DOMRect => mockRect,
} as DOMRect;
// Create a minimal Range mock with only what CodeMirror actually uses
const createMockRange = (): Range => {
let startContainer: Node = document.createTextNode('');
let endContainer: Node = document.createTextNode('');
let startOffset = 0;
let endOffset = 0;
const mockRange = {
// CodeMirror uses these for text measurement
getClientRects: (): DOMRectList =>
(({
length: 1,
item: (index: number): DOMRect | null => (index === 0 ? mockRect : null),
0: mockRect,
*[Symbol.iterator](): Generator<DOMRect> {
yield mockRect;
},
} as unknown) as DOMRectList),
getBoundingClientRect: (): DOMRect => mockRect,
// CodeMirror calls these to set up text ranges
setStart: (node: Node, offset: number): void => {
startContainer = node;
startOffset = offset;
},
setEnd: (node: Node, offset: number): void => {
endContainer = node;
endOffset = offset;
},
// Minimal Range properties (TypeScript requires these)
get startContainer(): Node {
return startContainer;
},
get endContainer(): Node {
return endContainer;
},
get startOffset(): number {
return startOffset;
},
get endOffset(): number {
return endOffset;
},
get collapsed(): boolean {
return startContainer === endContainer && startOffset === endOffset;
},
commonAncestorContainer: document.body,
};
return (mockRange as unknown) as Range;
};
// Mock document.createRange to return a new Range instance each time
document.createRange = (): Range => createMockRange();
// Mock getBoundingClientRect for elements
Element.prototype.getBoundingClientRect = (): DOMRect => mockRect;
});
jest.mock('hooks/useDarkMode', () => ({ jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => false, useIsDarkMode: (): boolean => false,
})); }));
@@ -103,6 +31,24 @@ jest.mock('hooks/queryBuilder/useQueryBuilder', () => {
}; };
}); });
jest.mock('@codemirror/autocomplete', () => ({
autocompletion: (): Record<string, unknown> => ({}),
closeCompletion: (): boolean => true,
completionKeymap: [] as unknown[],
startCompletion: (): boolean => true,
}));
jest.mock('@codemirror/lang-javascript', () => ({
javascript: (): Record<string, unknown> => ({}),
}));
jest.mock('@uiw/codemirror-theme-copilot', () => ({
copilot: {},
}));
jest.mock('@uiw/codemirror-theme-github', () => ({
githubLight: {},
}));
jest.mock('api/querySuggestions/getKeySuggestions', () => ({ jest.mock('api/querySuggestions/getKeySuggestions', () => ({
getKeySuggestions: jest.fn().mockResolvedValue({ getKeySuggestions: jest.fn().mockResolvedValue({
data: { data: {
@@ -117,19 +63,153 @@ jest.mock('api/querySuggestions/getValueSuggestion', () => ({
}), }),
})); }));
// Note: We're NOT mocking CodeMirror here - using the real component // Mock CodeMirror to a simple textarea to make it testable and call onUpdate
// This provides integration testing with the actual CodeMirror editor jest.mock(
'@uiw/react-codemirror',
(): Record<string, unknown> => {
// Minimal EditorView shape used by the component
class EditorViewMock {}
(EditorViewMock as any).domEventHandlers = (): unknown => ({} as unknown);
(EditorViewMock as any).lineWrapping = {} as unknown;
(EditorViewMock as any).editable = { of: () => ({}) } as unknown;
const keymap = { of: (arr: unknown) => arr } as unknown;
const Prec = { highest: (ext: unknown) => ext } as unknown;
type CodeMirrorProps = {
value?: string;
onChange?: (v: string) => void;
onFocus?: () => void;
onBlur?: () => void;
placeholder?: string;
onCreateEditor?: (view: unknown) => unknown;
onUpdate?: (arg: {
view: {
state: {
selection: { main: { head: number } };
doc: {
toString: () => string;
lineAt: (
_pos: number,
) => { number: number; from: number; to: number; text: string };
};
};
};
}) => void;
'data-testid'?: string;
extensions?: unknown[];
};
function CodeMirrorMock({
value,
onChange,
onFocus,
onBlur,
placeholder,
onCreateEditor,
onUpdate,
'data-testid': dataTestId,
extensions,
}: CodeMirrorProps): JSX.Element {
const [localValue, setLocalValue] = React.useState<string>(value ?? '');
// Provide a fake editor instance
React.useEffect(() => {
if (onCreateEditor) {
onCreateEditor(new EditorViewMock() as any);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Call onUpdate whenever localValue changes to simulate cursor and doc
React.useEffect(() => {
if (onUpdate) {
const text = String(localValue ?? '');
const head = text.length;
onUpdate({
view: {
state: {
selection: { main: { head } },
doc: {
toString: (): string => text,
lineAt: () => ({
number: 1,
from: 0,
to: text.length,
text,
}),
},
},
},
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [localValue]);
const handleKeyDown = (
e: React.KeyboardEvent<HTMLTextAreaElement>,
): void => {
const isModEnter = e.key === 'Enter' && (e.metaKey || e.ctrlKey);
if (!isModEnter) return;
const exts: unknown[] = Array.isArray(extensions) ? extensions : [];
const flat: unknown[] = exts.flatMap((x: unknown) =>
Array.isArray(x) ? x : [x],
);
const keyBindings = flat.filter(
(x) =>
Boolean(x) &&
typeof x === 'object' &&
'key' in (x as Record<string, unknown>),
) as Array<{ key?: string; run?: () => boolean | void }>;
keyBindings
.filter((b) => b.key === 'Mod-Enter' && typeof b.run === 'function')
.forEach((b) => {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
b.run!();
});
};
return (
<textarea
data-testid={dataTestId || 'query-where-clause-editor'}
placeholder={placeholder}
value={localValue}
onChange={(e): void => {
setLocalValue(e.target.value);
if (onChange) {
onChange(e.target.value);
}
}}
onFocus={onFocus}
onBlur={onBlur}
onKeyDown={handleKeyDown}
style={{ width: '100%', minHeight: 80 }}
/>
);
}
return {
__esModule: true,
default: CodeMirrorMock,
EditorView: EditorViewMock,
keymap,
Prec,
};
},
);
const handleRunQueryMock = ((UseQBModule as unknown) as { const handleRunQueryMock = ((UseQBModule as unknown) as {
handleRunQuery: jest.MockedFunction<() => void>; handleRunQuery: jest.MockedFunction<() => void>;
}).handleRunQuery; }).handleRunQuery;
const PLACEHOLDER_TEXT =
"Enter your filter query (e.g., http.status_code >= 500 AND service.name = 'frontend')";
const TESTID_EDITOR = 'query-where-clause-editor';
const SAMPLE_KEY_TYPING = 'http.'; const SAMPLE_KEY_TYPING = 'http.';
const SAMPLE_VALUE_TYPING_INCOMPLETE = "service.name = '"; const SAMPLE_VALUE_TYPING_INCOMPLETE = " service.name = '";
const SAMPLE_VALUE_TYPING_COMPLETE = "service.name = 'frontend'"; const SAMPLE_VALUE_TYPING_COMPLETE = " service.name = 'frontend'";
const SAMPLE_STATUS_QUERY = "http.status_code = '200'"; const SAMPLE_STATUS_QUERY = " status_code = '200'";
describe('QuerySearch (Integration with Real CodeMirror)', () => { describe('QuerySearch', () => {
it('renders with placeholder', () => { it('renders with placeholder', () => {
render( render(
<QuerySearch <QuerySearch
@@ -139,19 +219,21 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>, />,
); );
// CodeMirror renders a contenteditable div, so we check for the container expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
const editorContainer = document.querySelector('.query-where-clause-editor');
expect(editorContainer).toBeInTheDocument();
}); });
it('fetches key suggestions when typing a key (debounced)', async () => { it('fetches key suggestions when typing a key (debounced)', async () => {
// Use real timers for CodeMirror integration tests jest.useFakeTimers();
const advance = (ms: number): void => {
jest.advanceTimersByTime(ms);
};
const user = userEvent.setup({
advanceTimers: advance,
pointerEventsCheck: 0,
});
const mockedGetKeys = getKeySuggestions as jest.MockedFunction< const mockedGetKeys = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions typeof getKeySuggestions
>; >;
mockedGetKeys.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render( render(
<QuerySearch <QuerySearch
@@ -161,33 +243,28 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>, />,
); );
// Wait for CodeMirror to initialize const editor = screen.getByTestId(TESTID_EDITOR);
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
// Find the CodeMirror editor contenteditable element
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
// Focus and type into the editor
await user.click(editor);
await user.type(editor, SAMPLE_KEY_TYPING); await user.type(editor, SAMPLE_KEY_TYPING);
advance(1000);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), { await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
timeout: 2000, timeout: 3000,
}); });
jest.useRealTimers();
}); });
it('fetches value suggestions when editing value context', async () => { it('fetches value suggestions when editing value context', async () => {
// Use real timers for CodeMirror integration tests jest.useFakeTimers();
const advance = (ms: number): void => {
jest.advanceTimersByTime(ms);
};
const user = userEvent.setup({
advanceTimers: advance,
pointerEventsCheck: 0,
});
const mockedGetValues = getValueSuggestions as jest.MockedFunction< const mockedGetValues = getValueSuggestions as jest.MockedFunction<
typeof getValueSuggestions typeof getValueSuggestions
>; >;
mockedGetValues.mockClear();
const user = userEvent.setup({ pointerEventsCheck: 0 });
render( render(
<QuerySearch <QuerySearch
@@ -197,28 +274,21 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>, />,
); );
// Wait for CodeMirror to initialize const editor = screen.getByTestId(TESTID_EDITOR);
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE); await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
advance(1000);
// Wait for debounced API call (300ms debounce + some buffer)
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), { await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
timeout: 2000, timeout: 3000,
}); });
jest.useRealTimers();
}); });
it('fetches key suggestions on mount for LOGS', async () => { it('fetches key suggestions on mount for LOGS', async () => {
// Use real timers for CodeMirror integration tests jest.useFakeTimers();
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction< const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
typeof getKeySuggestions typeof getKeySuggestions
>; >;
mockedGetKeysOnMount.mockClear();
render( render(
<QuerySearch <QuerySearch
@@ -228,15 +298,17 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>, />,
); );
// Wait for debounced API call (300ms debounce + some buffer) jest.advanceTimersByTime(1000);
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), { await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
timeout: 2000, timeout: 3000,
}); });
const lastArgs = mockedGetKeysOnMount.mock.calls[ const lastArgs = mockedGetKeysOnMount.mock.calls[
mockedGetKeysOnMount.mock.calls.length - 1 mockedGetKeysOnMount.mock.calls.length - 1
]?.[0] as { signal: unknown; searchText: string }; ]?.[0] as { signal: unknown; searchText: string };
expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' }); expect(lastArgs).toMatchObject({ signal: DataSource.LOGS, searchText: '' });
jest.useRealTimers();
}); });
it('calls provided onRun on Mod-Enter', async () => { it('calls provided onRun on Mod-Enter', async () => {
@@ -252,26 +324,12 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>, />,
); );
// Wait for CodeMirror to initialize const editor = screen.getByTestId(TESTID_EDITOR);
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor); await user.click(editor);
await user.type(editor, SAMPLE_STATUS_QUERY); await user.type(editor, SAMPLE_STATUS_QUERY);
await user.keyboard('{Meta>}{Enter}{/Meta}');
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror await waitFor(() => expect(onRun).toHaveBeenCalled());
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
fireEvent.keyDown(editor, {
key: 'Enter',
code: 'Enter',
[modKey]: true,
keyCode: 13,
});
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
}); });
it('calls handleRunQuery when Mod-Enter without onRun', async () => { it('calls handleRunQuery when Mod-Enter without onRun', async () => {
@@ -290,62 +348,11 @@ describe('QuerySearch (Integration with Real CodeMirror)', () => {
/>, />,
); );
// Wait for CodeMirror to initialize const editor = screen.getByTestId(TESTID_EDITOR);
await waitFor(() => {
const editor = document.querySelector(CM_EDITOR_SELECTOR);
expect(editor).toBeInTheDocument();
});
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
await user.click(editor); await user.click(editor);
await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE); await user.type(editor, SAMPLE_VALUE_TYPING_COMPLETE);
await user.keyboard('{Meta>}{Enter}{/Meta}');
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled());
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'";
const queryDataWithExpression = {
...initialQueriesMap.logs.builder.queryData[0],
filter: {
expression: testExpression,
},
};
render(
<QuerySearch
onChange={jest.fn() as jest.MockedFunction<(v: string) => void>}
queryData={queryDataWithExpression}
dataSource={DataSource.LOGS}
/>,
);
// Wait for CodeMirror to initialize and the expression to be set
await waitFor(
() => {
// CodeMirror stores content in .cm-content, check the text content
const editorContent = document.querySelector(
CM_EDITOR_SELECTOR,
) as HTMLElement;
expect(editorContent).toBeInTheDocument();
// CodeMirror may render the text in multiple ways, check if it contains our expression
const textContent = editorContent.textContent || '';
expect(textContent).toContain('http.status_code');
expect(textContent).toContain('service.name');
},
{ timeout: 3000 },
);
}); });
}); });

View File

@@ -13,7 +13,6 @@ import {
convertAggregationToExpression, convertAggregationToExpression,
convertFiltersToExpression, convertFiltersToExpression,
convertFiltersToExpressionWithExistingQuery, convertFiltersToExpressionWithExistingQuery,
formatValueForExpression,
removeKeysFromExpression, removeKeysFromExpression,
} from '../utils'; } from '../utils';
@@ -1194,220 +1193,3 @@ describe('removeKeysFromExpression', () => {
}); });
}); });
}); });
describe('formatValueForExpression', () => {
beforeEach(() => {
jest.clearAllMocks();
});
describe('Variable values', () => {
it('should return variable values as-is', () => {
expect(formatValueForExpression('$variable')).toBe('$variable');
expect(formatValueForExpression('$env')).toBe('$env');
expect(formatValueForExpression(' $variable ')).toBe(' $variable ');
});
it('should return variable arrays as-is', () => {
expect(formatValueForExpression(['$var1', '$var2'])).toBe('$var1,$var2');
});
});
describe('Numeric string values', () => {
it('should return numeric strings with quotes', () => {
expect(formatValueForExpression('123')).toBe("'123'");
expect(formatValueForExpression('0')).toBe("'0'");
expect(formatValueForExpression('100000')).toBe("'100000'");
expect(formatValueForExpression('-42')).toBe("'-42'");
expect(formatValueForExpression('3.14')).toBe("'3.14'");
expect(formatValueForExpression(' 456 ')).toBe("' 456 '");
});
it('should handle numeric strings with IN operator', () => {
expect(formatValueForExpression('123', 'IN')).toBe("['123']");
expect(formatValueForExpression(['123', '456'], 'IN')).toBe(
"['123', '456']",
);
});
});
describe('Quoted string values', () => {
it('should return already quoted strings as-is', () => {
expect(formatValueForExpression("'quoted'")).toBe("'quoted'");
expect(formatValueForExpression('"double-quoted"')).toBe('"double-quoted"');
expect(formatValueForExpression('`backticked`')).toBe('`backticked`');
expect(formatValueForExpression("'100000'")).toBe("'100000'");
});
it('should preserve quoted strings in arrays', () => {
expect(formatValueForExpression(["'value1'", "'value2'"])).toBe(
"['value1', 'value2']",
);
expect(formatValueForExpression(["'100000'", "'200000'"], 'IN')).toBe(
"['100000', '200000']",
);
});
});
describe('Regular string values', () => {
it('should wrap regular strings in single quotes', () => {
expect(formatValueForExpression('hello')).toBe("'hello'");
expect(formatValueForExpression('api-gateway')).toBe("'api-gateway'");
expect(formatValueForExpression('test value')).toBe("'test value'");
});
it('should escape single quotes in strings', () => {
expect(formatValueForExpression("user's data")).toBe("'user\\'s data'");
expect(formatValueForExpression("John's")).toBe("'John\\'s'");
expect(formatValueForExpression("it's a test")).toBe("'it\\'s a test'");
});
it('should handle empty strings', () => {
expect(formatValueForExpression('')).toBe("''");
});
it('should handle strings with special characters', () => {
expect(formatValueForExpression('/api/v1/users')).toBe("'/api/v1/users'");
expect(formatValueForExpression('user@example.com')).toBe(
"'user@example.com'",
);
expect(formatValueForExpression('Contains "quotes"')).toBe(
'\'Contains "quotes"\'',
);
});
});
describe('Number values', () => {
it('should convert numbers to strings without quotes', () => {
expect(formatValueForExpression(123)).toBe('123');
expect(formatValueForExpression(0)).toBe('0');
expect(formatValueForExpression(-42)).toBe('-42');
expect(formatValueForExpression(100000)).toBe('100000');
expect(formatValueForExpression(3.14)).toBe('3.14');
});
it('should handle numbers with IN operator', () => {
expect(formatValueForExpression(123, 'IN')).toBe('[123]');
expect(formatValueForExpression([100, 200] as any, 'IN')).toBe('[100, 200]');
});
});
describe('Boolean values', () => {
it('should convert booleans to strings without quotes', () => {
expect(formatValueForExpression(true)).toBe('true');
expect(formatValueForExpression(false)).toBe('false');
});
it('should handle booleans with IN operator', () => {
expect(formatValueForExpression(true, 'IN')).toBe('[true]');
expect(formatValueForExpression([true, false] as any, 'IN')).toBe(
'[true, false]',
);
});
});
describe('Array values', () => {
it('should format array of strings', () => {
expect(formatValueForExpression(['a', 'b', 'c'])).toBe("['a', 'b', 'c']");
expect(formatValueForExpression(['service1', 'service2'])).toBe(
"['service1', 'service2']",
);
});
it('should format array of numeric strings', () => {
expect(formatValueForExpression(['123', '456', '789'])).toBe(
"['123', '456', '789']",
);
});
it('should format array of numbers', () => {
expect(formatValueForExpression([1, 2, 3] as any)).toBe('[1, 2, 3]');
expect(formatValueForExpression([100, 200, 300] as any)).toBe(
'[100, 200, 300]',
);
});
it('should format mixed array types', () => {
expect(formatValueForExpression(['hello', 123, true] as any)).toBe(
"['hello', 123, true]",
);
});
it('should format array with quoted values', () => {
expect(formatValueForExpression(["'quoted'", 'regular'])).toBe(
"['quoted', 'regular']",
);
});
it('should format array with empty strings', () => {
expect(formatValueForExpression(['', 'value'])).toBe("['', 'value']");
});
});
describe('IN and NOT IN operators', () => {
it('should format single value as array for IN operator', () => {
expect(formatValueForExpression('value', 'IN')).toBe("['value']");
expect(formatValueForExpression(123, 'IN')).toBe('[123]');
expect(formatValueForExpression('123', 'IN')).toBe("['123']");
});
it('should format array for IN operator', () => {
expect(formatValueForExpression(['a', 'b'], 'IN')).toBe("['a', 'b']");
expect(formatValueForExpression(['123', '456'], 'IN')).toBe(
"['123', '456']",
);
});
it('should format single value as array for NOT IN operator', () => {
expect(formatValueForExpression('value', 'NOT IN')).toBe("['value']");
expect(formatValueForExpression('value', 'not in')).toBe("['value']");
});
it('should format array for NOT IN operator', () => {
expect(formatValueForExpression(['a', 'b'], 'NOT IN')).toBe("['a', 'b']");
});
});
describe('Edge cases', () => {
it('should handle strings that look like numbers but have quotes', () => {
expect(formatValueForExpression("'123'")).toBe("'123'");
expect(formatValueForExpression('"456"')).toBe('"456"');
expect(formatValueForExpression('`789`')).toBe('`789`');
});
it('should handle strings with leading/trailing whitespace', () => {
expect(formatValueForExpression(' hello ')).toBe("' hello '");
expect(formatValueForExpression(' 123 ')).toBe("' 123 '");
});
it('should handle very large numbers', () => {
expect(formatValueForExpression('999999999')).toBe("'999999999'");
expect(formatValueForExpression(999999999)).toBe('999999999');
});
it('should handle decimal numbers', () => {
expect(formatValueForExpression('123.456')).toBe("'123.456'");
expect(formatValueForExpression(123.456)).toBe('123.456');
});
it('should handle negative numbers', () => {
expect(formatValueForExpression('-100')).toBe("'-100'");
expect(formatValueForExpression(-100)).toBe('-100');
});
it('should handle strings that are not valid numbers', () => {
expect(formatValueForExpression('123abc')).toBe("'123abc'");
expect(formatValueForExpression('abc123')).toBe("'abc123'");
expect(formatValueForExpression('12.34.56')).toBe("'12.34.56'");
});
it('should handle empty array', () => {
expect(formatValueForExpression([])).toBe('[]');
expect(formatValueForExpression([], 'IN')).toBe('[]');
});
it('should handle array with single element', () => {
expect(formatValueForExpression(['single'])).toBe("['single']");
expect(formatValueForExpression([123] as any)).toBe('[123]');
});
});
});

View File

@@ -24,7 +24,7 @@ import {
import { EQueryType } from 'types/common/dashboard'; import { EQueryType } from 'types/common/dashboard';
import { DataSource, ReduceOperators } from 'types/common/queryBuilder'; import { DataSource, ReduceOperators } from 'types/common/queryBuilder';
import { extractQueryPairs } from 'utils/queryContextUtils'; import { extractQueryPairs } from 'utils/queryContextUtils';
import { isQuoted, unquote } from 'utils/stringUtils'; import { unquote } from 'utils/stringUtils';
import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils'; import { isFunctionOperator, isNonValueOperator } from 'utils/tokenUtils';
import { v4 as uuid } from 'uuid'; import { v4 as uuid } from 'uuid';
@@ -38,57 +38,49 @@ const isArrayOperator = (operator: string): boolean => {
return arrayOperators.includes(operator); return arrayOperators.includes(operator);
}; };
const isVariable = ( const isVariable = (value: string | string[] | number | boolean): boolean => {
value: (string | number | boolean)[] | string | number | boolean,
): boolean => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.some((v) => typeof v === 'string' && v.trim().startsWith('$')); return value.some((v) => typeof v === 'string' && v.trim().startsWith('$'));
} }
return typeof value === 'string' && value.trim().startsWith('$'); return typeof value === 'string' && value.trim().startsWith('$');
}; };
/**
* Formats a single value for use in expression strings.
* Strings are quoted and escaped, while numbers and booleans are converted to strings.
*/
const formatSingleValue = (v: string | number | boolean): string => {
if (typeof v === 'string') {
// Preserve already-quoted strings
if (isQuoted(v)) {
return v;
}
// Quote and escape single quotes in strings
return `'${v.replace(/'/g, "\\'")}'`;
}
// Convert numbers and booleans to strings without quotes
return String(v);
};
/** /**
* Format a value for the expression string * Format a value for the expression string
* @param value - The value to format * @param value - The value to format
* @param operator - The operator being used (to determine if array is needed) * @param operator - The operator being used (to determine if array is needed)
* @returns Formatted value string * @returns Formatted value string
*/ */
export const formatValueForExpression = ( const formatValueForExpression = (
value: (string | number | boolean)[] | string | number | boolean, value: string[] | string | number | boolean,
operator?: string, operator?: string,
): string => { ): string => {
if (isVariable(value)) { if (isVariable(value)) {
return String(value); return String(value);
} }
// For IN operators, ensure value is always an array
if (isArrayOperator(operator || '')) { if (isArrayOperator(operator || '')) {
const arrayValue = Array.isArray(value) ? value : [value]; const arrayValue = Array.isArray(value) ? value : [value];
return `[${arrayValue.map(formatSingleValue).join(', ')}]`; return `[${arrayValue
.map((v) =>
typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : String(v),
)
.join(', ')}]`;
} }
if (Array.isArray(value)) { if (Array.isArray(value)) {
return `[${value.map(formatSingleValue).join(', ')}]`; // Handle array values (e.g., for IN operations)
return `[${value
.map((v) =>
typeof v === 'string' ? `'${v.replace(/'/g, "\\'")}'` : String(v),
)
.join(', ')}]`;
} }
if (typeof value === 'string') { if (typeof value === 'string') {
return formatSingleValue(value); // Add single quotes around all string values and escape internal single quotes
return `'${value.replace(/'/g, "\\'")}'`;
} }
return String(value); return String(value);
@@ -144,43 +136,14 @@ export const convertFiltersToExpression = (
}; };
}; };
/** const formatValuesForFilter = (value: string | string[]): string | string[] => {
* Converts a string value to its appropriate type (number, boolean, or string)
* for use in filter objects. This is the inverse of formatSingleValue.
*/
function formatSingleValueForFilter(
value: string | number | boolean,
): string | number | boolean {
if (typeof value === 'string') {
const trimmed = value.trim();
// Try to convert numeric strings to numbers
if (trimmed !== '' && !Number.isNaN(Number(trimmed))) {
return Number(trimmed);
}
// Convert boolean strings to booleans
if (trimmed === 'true' || trimmed === 'false') {
return trimmed === 'true';
}
}
// Return non-string values as-is, or string values that couldn't be converted
return value;
}
/**
* Formats values for filter objects, converting string representations
* to their proper types (numbers, booleans) when appropriate.
*/
const formatValuesForFilter = (
value: (string | number | boolean)[] | number | boolean | string,
): (string | number | boolean)[] | number | boolean | string => {
if (Array.isArray(value)) { if (Array.isArray(value)) {
return value.map(formatSingleValueForFilter); return value.map((v) => (typeof v === 'string' ? unquote(v) : String(v)));
} }
if (typeof value === 'string') {
return formatSingleValueForFilter(value); return unquote(value);
}
return String(value);
}; };
export const convertExpressionToFilters = ( export const convertExpressionToFilters = (
@@ -261,7 +224,7 @@ export const convertFiltersToExpressionWithExistingQuery = (
const visitedPairs: Set<string> = new Set(); // Set to track visited query pairs const visitedPairs: Set<string> = new Set(); // Set to track visited query pairs
// Map extracted query pairs to key-specific pair information for faster access // Map extracted query pairs to key-specific pair information for faster access
let queryPairsMap = getQueryPairsMap(existingQuery); let queryPairsMap = getQueryPairsMap(existingQuery.trim());
filters?.items?.forEach((filter) => { filters?.items?.forEach((filter) => {
const { key, op, value } = filter; const { key, op, value } = filter;
@@ -346,7 +309,7 @@ export const convertFiltersToExpressionWithExistingQuery = (
)}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice( )}${OPERATORS.IN} ${formattedValue} ${modifiedQuery.slice(
notInPair.position.valueEnd + 1, notInPair.position.valueEnd + 1,
)}`; )}`;
queryPairsMap = getQueryPairsMap(modifiedQuery); queryPairsMap = getQueryPairsMap(modifiedQuery.trim());
} }
shouldAddToNonExisting = false; // Don't add this to non-existing filters shouldAddToNonExisting = false; // Don't add this to non-existing filters
} else if ( } else if (

View File

@@ -178,7 +178,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
if (SELECTED_OPERATORS.includes(filterSync.op)) { if (SELECTED_OPERATORS.includes(filterSync.op)) {
if (isArray(filterSync.value)) { if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => { filterSync.value.forEach((val) => {
filterState[String(val)] = true; filterState[val] = true;
}); });
} else if (typeof filterSync.value === 'string') { } else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = true; filterState[filterSync.value] = true;
@@ -191,7 +191,7 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
filterState = setDefaultValues(attributeValues, true); filterState = setDefaultValues(attributeValues, true);
if (isArray(filterSync.value)) { if (isArray(filterSync.value)) {
filterSync.value.forEach((val) => { filterSync.value.forEach((val) => {
filterState[String(val)] = false; filterState[val] = false;
}); });
} else if (typeof filterSync.value === 'string') { } else if (typeof filterSync.value === 'string') {
filterState[filterSync.value] = false; filterState[filterSync.value] = false;

View File

@@ -0,0 +1,223 @@
import { render } from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import { MemoryRouter } from 'react-router-dom';
import { Userpilot } from 'userpilot';
import UserpilotRouteTracker from './UserpilotRouteTracker';
// Mock constants
const INITIAL_PATH = '/initial';
const TIMER_DELAY = 100;
// Mock the userpilot module
jest.mock('userpilot', () => ({
Userpilot: {
reload: jest.fn(),
},
}));
// Mock location state
let mockLocation = {
pathname: INITIAL_PATH,
search: '',
hash: '',
state: null,
};
// Mock react-router-dom
jest.mock('react-router-dom', () => {
const originalModule = jest.requireActual('react-router-dom');
return {
...originalModule,
useLocation: jest.fn(() => mockLocation),
};
});
describe('UserpilotRouteTracker', () => {
beforeEach(() => {
jest.clearAllMocks();
// Reset timers
jest.useFakeTimers();
// Reset error mock implementation
(Userpilot.reload as jest.Mock).mockImplementation(() => {});
// Reset location to initial state
mockLocation = {
pathname: INITIAL_PATH,
search: '',
hash: '',
state: null,
};
});
afterEach(() => {
jest.useRealTimers();
});
it('calls Userpilot.reload on initial render', () => {
render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward timer to trigger the setTimeout in reloadUserpilot
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
});
it('calls Userpilot.reload when pathname changes', () => {
const { rerender } = render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward initial render timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
jest.clearAllMocks();
// Create a new location object with different pathname
const newLocation = {
...mockLocation,
pathname: '/new-path',
};
// Update the mock location with new path and trigger re-render
act(() => {
mockLocation = newLocation;
// Force a component update with the new location
rerender(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
});
// Fast-forward timer to allow the setTimeout to execute
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
});
it('calls Userpilot.reload when search parameters change', () => {
const { rerender } = render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward initial render timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
jest.clearAllMocks();
// Create a new location object with different search params
const newLocation = {
...mockLocation,
search: '?param=value',
};
// Update the mock location with new search and trigger re-render
// eslint-disable-next-line sonarjs/no-identical-functions
act(() => {
mockLocation = newLocation;
// Force a component update with the new location
rerender(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
});
// Fast-forward timer to allow the setTimeout to execute
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
expect(Userpilot.reload).toHaveBeenCalledTimes(1);
});
it('handles errors in Userpilot.reload gracefully', () => {
// Mock console.error to prevent test output noise and capture calls
const consoleErrorSpy = jest
.spyOn(console, 'error')
.mockImplementation(() => {});
// Instead of using the component, we test the error handling behavior directly
const errorMsg = 'Error message';
// Set up a function that has the same error handling behavior as in component
const testErrorHandler = (): void => {
try {
if (typeof Userpilot !== 'undefined' && Userpilot.reload) {
Userpilot.reload();
}
} catch (error) {
console.error('[Userpilot] Error reloading on route change:', error);
}
};
// Make Userpilot.reload throw an error
(Userpilot.reload as jest.Mock).mockImplementation(() => {
throw new Error(errorMsg);
});
// Execute the function that should handle errors
testErrorHandler();
// Verify error was logged
expect(consoleErrorSpy).toHaveBeenCalledWith(
'[Userpilot] Error reloading on route change:',
expect.any(Error),
);
// Restore console mock
consoleErrorSpy.mockRestore();
});
it('does not call Userpilot.reload when same route is rendered again', () => {
const { rerender } = render(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
// Fast-forward initial render timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
jest.clearAllMocks();
act(() => {
mockLocation = {
pathname: mockLocation.pathname,
search: mockLocation.search,
hash: mockLocation.hash,
state: mockLocation.state,
};
// Force a component update with the same location
rerender(
<MemoryRouter>
<UserpilotRouteTracker />
</MemoryRouter>,
);
});
// Fast-forward timer
act(() => {
jest.advanceTimersByTime(TIMER_DELAY);
});
// Should not call reload since path and search are the same
expect(Userpilot.reload).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,60 @@
import { useCallback, useEffect, useRef } from 'react';
import { useLocation } from 'react-router-dom';
import { Userpilot } from 'userpilot';
/**
* UserpilotRouteTracker - A component that tracks route changes and calls Userpilot.reload
* on actual page changes (pathname changes or significant query parameter changes).
*
* This component renders nothing and is designed to be placed once high in the component tree.
*/
function UserpilotRouteTracker(): null {
const location = useLocation();
const prevPathRef = useRef<string>(location.pathname);
const prevSearchRef = useRef<string>(location.search);
const isFirstRenderRef = useRef<boolean>(true);
// Function to reload Userpilot safely - using useCallback to avoid dependency issues
const reloadUserpilot = useCallback((): void => {
try {
if (typeof Userpilot !== 'undefined' && Userpilot.reload) {
setTimeout(() => {
Userpilot.reload();
}, 100);
}
} catch (error) {
console.error('[Userpilot] Error reloading on route change:', error);
}
}, []);
// Handle first render
useEffect(() => {
if (isFirstRenderRef.current) {
isFirstRenderRef.current = false;
reloadUserpilot();
}
}, [reloadUserpilot]);
// Handle route/query changes
useEffect(() => {
// Skip first render as it's handled by the effect above
if (isFirstRenderRef.current) {
return;
}
// Check if the path has changed or if significant query params have changed
const pathChanged = location.pathname !== prevPathRef.current;
const searchChanged = location.search !== prevSearchRef.current;
if (pathChanged || searchChanged) {
// Update refs
prevPathRef.current = location.pathname;
prevSearchRef.current = location.search;
reloadUserpilot();
}
}, [location.pathname, location.search, reloadUserpilot]);
return null;
}
export default UserpilotRouteTracker;

View File

@@ -7,7 +7,7 @@ import ErrorIcon from 'assets/Error';
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar'; import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
import { BookOpenText, ChevronsDown, TriangleAlert } from 'lucide-react'; import { BookOpenText, ChevronsDown, TriangleAlert } from 'lucide-react';
import KeyValueLabel from 'periscope/components/KeyValueLabel'; import KeyValueLabel from 'periscope/components/KeyValueLabel';
import { ReactNode, useMemo } from 'react'; import { ReactNode } from 'react';
import { Warning } from 'types/api'; import { Warning } from 'types/api';
interface WarningContentProps { interface WarningContentProps {
@@ -106,51 +106,19 @@ export function WarningContent({ warning }: WarningContentProps): JSX.Element {
); );
} }
function PopoverMessage({
message,
}: {
message: string | ReactNode;
}): JSX.Element {
return (
<section className="warning-content">
<section className="warning-content__summary-section">
<header className="warning-content__summary">
<div className="warning-content__summary-left">
<div className="warning-content__summary-text">
<p className="warning-content__warning-message">{message}</p>
</div>
</div>
</header>
</section>
</section>
);
}
interface WarningPopoverProps extends PopoverProps { interface WarningPopoverProps extends PopoverProps {
children?: ReactNode; children?: ReactNode;
warningData?: Warning; warningData: Warning;
message?: string | ReactNode;
} }
function WarningPopover({ function WarningPopover({
children, children,
warningData, warningData,
message = '',
...popoverProps ...popoverProps
}: WarningPopoverProps): JSX.Element { }: WarningPopoverProps): JSX.Element {
const content = useMemo(() => {
if (message) {
return <PopoverMessage message={message} />;
}
if (warningData) {
return <WarningContent warning={warningData} />;
}
return null;
}, [message, warningData]);
return ( return (
<Popover <Popover
content={content} content={<WarningContent warning={warningData} />}
overlayStyle={{ padding: 0, maxWidth: '600px' }} overlayStyle={{ padding: 0, maxWidth: '600px' }}
overlayInnerStyle={{ padding: 0 }} overlayInnerStyle={{ padding: 0 }}
autoAdjustOverflow autoAdjustOverflow
@@ -169,8 +137,6 @@ function WarningPopover({
WarningPopover.defaultProps = { WarningPopover.defaultProps = {
children: undefined, children: undefined,
warningData: null,
message: null,
}; };
export default WarningPopover; export default WarningPopover;

View File

@@ -24,7 +24,6 @@ export const DATE_TIME_FORMATS = {
TIME_SECONDS: 'HH:mm:ss', TIME_SECONDS: 'HH:mm:ss',
TIME_UTC: 'HH:mm:ss (UTC Z)', TIME_UTC: 'HH:mm:ss (UTC Z)',
TIME_UTC_MS: 'HH:mm:ss.SSS (UTC Z)', TIME_UTC_MS: 'HH:mm:ss.SSS (UTC Z)',
TIME_SPAN_PERCENTILE: 'HH:mm:ss MMM DD',
// Short date formats // Short date formats
DATE_SHORT: 'MM/DD', DATE_SHORT: 'MM/DD',

View File

@@ -90,7 +90,4 @@ export const REACT_QUERY_KEY = {
// Routing Policies Query Keys // Routing Policies Query Keys
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES', GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
// Span Percentiles Query Keys
GET_SPAN_PERCENTILES: 'GET_SPAN_PERCENTILES',
} as const; } as const;

View File

@@ -34,7 +34,7 @@ const themeColors = {
cyan: '#00FFFF', cyan: '#00FFFF',
}, },
chartcolors: { chartcolors: {
radicalRed: '#FF1A66', robin: '#3F5ECC',
dodgerBlue: '#2F80ED', dodgerBlue: '#2F80ED',
mediumOrchid: '#BB6BD9', mediumOrchid: '#BB6BD9',
seaBuckthorn: '#F2994A', seaBuckthorn: '#F2994A',
@@ -58,7 +58,7 @@ const themeColors = {
oliveDrab: '#66991A', oliveDrab: '#66991A',
lavenderRose: '#FF99E6', lavenderRose: '#FF99E6',
electricLime: '#CCFF1A', electricLime: '#CCFF1A',
robin: '#3F5ECC', radicalRed: '#FF1A66',
harleyOrange: '#E6331A', harleyOrange: '#E6331A',
turquoise: '#33FFCC', turquoise: '#33FFCC',
gladeGreen: '#66994D', gladeGreen: '#66994D',
@@ -80,7 +80,7 @@ const themeColors = {
maroon: '#800000', maroon: '#800000',
navy: '#000080', navy: '#000080',
aquamarine: '#7FFFD4', aquamarine: '#7FFFD4',
darkSeaGreen: '#8FBC8F', gold: '#FFD700',
gray: '#808080', gray: '#808080',
skyBlue: '#87CEEB', skyBlue: '#87CEEB',
indigo: '#4B0082', indigo: '#4B0082',
@@ -105,7 +105,7 @@ const themeColors = {
lawnGreen: '#7CFC00', lawnGreen: '#7CFC00',
mediumSeaGreen: '#3CB371', mediumSeaGreen: '#3CB371',
lightCoral: '#F08080', lightCoral: '#F08080',
gold: '#FFD700', darkSeaGreen: '#8FBC8F',
sandyBrown: '#F4A460', sandyBrown: '#F4A460',
darkKhaki: '#BDB76B', darkKhaki: '#BDB76B',
cornflowerBlue: '#6495ED', cornflowerBlue: '#6495ED',
@@ -113,7 +113,7 @@ const themeColors = {
paleGreen: '#98FB98', paleGreen: '#98FB98',
}, },
lightModeColor: { lightModeColor: {
radicalRed: '#FF1A66', robin: '#3F5ECC',
dodgerBlueDark: '#0C6EED', dodgerBlueDark: '#0C6EED',
steelgrey: '#2f4b7c', steelgrey: '#2f4b7c',
steelpurple: '#665191', steelpurple: '#665191',
@@ -143,7 +143,7 @@ const themeColors = {
oliveDrab: '#66991A', oliveDrab: '#66991A',
lavenderRoseDark: '#F024BD', lavenderRoseDark: '#F024BD',
electricLimeDark: '#84A800', electricLimeDark: '#84A800',
robin: '#3F5ECC', radicalRed: '#FF1A66',
harleyOrange: '#E6331A', harleyOrange: '#E6331A',
gladeGreen: '#66994D', gladeGreen: '#66994D',
hemlock: '#66664D', hemlock: '#66664D',
@@ -181,7 +181,7 @@ const themeColors = {
darkOrchid: '#9932CC', darkOrchid: '#9932CC',
mediumSeaGreenDark: '#109E50', mediumSeaGreenDark: '#109E50',
lightCoralDark: '#F85959', lightCoralDark: '#F85959',
gold: '#FFD700', darkSeaGreenDark: '#509F50',
sandyBrownDark: '#D97117', sandyBrownDark: '#D97117',
darkKhakiDark: '#99900A', darkKhakiDark: '#99900A',
cornflowerBlueDark: '#3371E6', cornflowerBlueDark: '#3371E6',

View File

@@ -3,5 +3,4 @@ export const USER_PREFERENCES = {
NAV_SHORTCUTS: 'nav_shortcuts', NAV_SHORTCUTS: 'nav_shortcuts',
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version', LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes', SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
}; };

View File

@@ -1,5 +1,4 @@
import { Select } from 'antd'; import { Select } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder'; import { initialQueriesMap } from 'constants/queryBuilder';
import { import {
getAllEndpointsWidgetData, getAllEndpointsWidgetData,
@@ -265,7 +264,6 @@ function AllEndPoints({
customOnDragSelect={(): void => {}} customOnDragSelect={(): void => {}}
customTimeRange={timeRange} customTimeRange={timeRange}
customOnRowClick={onRowClick} customOnRowClick={onRowClick}
version={ENTITY_VERSION_V5}
/> />
</div> </div>
</div> </div>

View File

@@ -244,10 +244,6 @@
} }
} }
} }
// Add border-bottom to table cells when pagination is not present
.ant-spin-container:not(:has(.ant-pagination)) .ant-table-cell {
border-bottom: 1px solid var(--bg-slate-500) !important;
}
.endpoints-table-container { .endpoints-table-container {
display: flex; display: flex;
@@ -426,28 +422,30 @@
gap: 8px; gap: 8px;
.endpoint-meta-data-pill { .endpoint-meta-data-pill {
display: flex; display: flex;
align-items: flex-start;
border-radius: 4px; border-radius: 4px;
border: 1px solid var(--bg-slate-300); border: 1px solid var(--bg-slate-300);
overflow: hidden; width: fit-content;
box-sizing: content-box;
.endpoint-meta-data-label { .endpoint-meta-data-label {
display: flex;
padding: 6px 8px; padding: 6px 8px;
align-items: center;
gap: 4px;
border-right: 1px solid var(--bg-slate-300); border-right: 1px solid var(--bg-slate-300);
color: var(--text-vanilla-100); color: var(--text-vanilla-100);
font-size: 14px;
line-height: 18px; /* 128.571% */
letter-spacing: -0.07px;
padding: 6px 8px;
background: var(--bg-slate-500); background: var(--bg-slate-500);
height: calc(100% - 12px);
} }
.endpoint-meta-data-value { .endpoint-meta-data-value {
display: flex;
padding: 6px 8px; padding: 6px 8px;
justify-content: center;
align-items: center;
gap: 10px;
color: var(--text-vanilla-400); color: var(--text-vanilla-400);
background: var(--bg-slate-400); background: var(--bg-slate-400);
font-size: 14px; height: calc(100% - 12px);
line-height: 18px;
letter-spacing: -0.07px;
} }
} }
} }
@@ -455,23 +453,9 @@
.endpoint-details-filters-container { .endpoint-details-filters-container {
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center;
border: 1px solid var(--bg-slate-500);
height: 36px;
box-sizing: content-box;
.ant-select-selector {
border: none !important;
}
.endpoint-details-filters-container-dropdown { .endpoint-details-filters-container-dropdown {
width: 120px; width: 120px;
border-right: 1px solid var(--bg-slate-500);
height: 36px;
display: flex;
align-items: center;
.ant-select-single {
height: 32px;
}
} }
.endpoint-details-filters-container-search { .endpoint-details-filters-container-search {
@@ -1012,6 +996,7 @@
.lightMode { .lightMode {
.ant-drawer-header { .ant-drawer-header {
border-bottom: 1px solid var(--bg-vanilla-400);
background: var(--bg-vanilla-100); background: var(--bg-vanilla-100);
} }
@@ -1022,25 +1007,6 @@
} }
.domain-detail-drawer { .domain-detail-drawer {
.endpoint-details-card,
.status-code-table-container,
.endpoint-details-filters-container,
.endpoint-details-filters-container-dropdown,
.ant-radio-button-wrapper,
.views-tabs-container,
.ant-btn-default.tab,
.tab::before,
.endpoint-meta-data-pill,
.endpoint-meta-data-label,
.endpoints-table-container,
.group-by-label,
.ant-select-selector,
.ant-drawer-header {
border-color: var(--bg-vanilla-300) !important;
}
.views-tabs .tab::before {
background: var(--bg-vanilla-300);
}
.title { .title {
color: var(--text-ink-300); color: var(--text-ink-300);
} }
@@ -1065,6 +1031,7 @@
.selected_view { .selected_view {
background: var(--bg-vanilla-300); background: var(--bg-vanilla-300);
border: 1px solid var(--bg-slate-300);
color: var(--text-ink-400); color: var(--text-ink-400);
} }
@@ -1193,11 +1160,7 @@
} }
} }
.top-services-content {
border-color: var(--bg-vanilla-300);
}
.dependent-services-container { .dependent-services-container {
border: none;
padding: 10px 12px; padding: 10px 12px;
.top-services-item { .top-services-item {
display: flex; display: flex;
@@ -1224,31 +1187,11 @@
} }
.top-services-item-progress-bar { .top-services-item-progress-bar {
background-color: var(--bg-vanilla-200); background-color: var(--bg-vanilla-300);
border: 1px solid var(--bg-vanilla-300); border: 1px solid var(--bg-slate-300);
} }
} }
} }
.ant-table {
.ant-table-thead > tr > th {
color: var(--text-ink-300);
}
.ant-table-cell {
&,
&:has(.top-services-item-latency) {
background: var(--bg-vanilla-100);
}
color: var(--text-ink-300);
}
.ant-table-tbody > tr:hover > td {
background: var(--bg-vanilla-200);
}
.table-row-dark {
background: var(--bg-vanilla-300);
}
}
.top-services-item-percentage { .top-services-item-percentage {
color: var(--text-ink-300); color: var(--text-ink-300);
@@ -1282,8 +1225,4 @@
} }
} }
} }
// Add border-bottom to table cells when pagination is not present
.ant-spin-container:not(:has(.ant-pagination)) .ant-table-cell {
border-bottom: 1px solid var(--bg-vanilla-300) !important;
}
} }

View File

@@ -1,6 +1,5 @@
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app'; import { ENTITY_VERSION_V4 } from 'constants/app';
import { initialQueriesMap } from 'constants/queryBuilder'; import { initialQueriesMap } from 'constants/queryBuilder';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams'; import { useApiMonitoringParams } from 'container/ApiMonitoring/queryParams';
import { import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY, END_POINT_DETAILS_QUERY_KEYS_ARRAY,
@@ -179,33 +178,18 @@ function EndPointDetails({
[domainName, filters, minTime, maxTime], [domainName, filters, minTime, maxTime],
); );
const V5_QUERIES = [
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_STATUS_CODE_LATENCY_BAR_CHARTS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_METRICS_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DEPENDENT_SERVICES_DATA,
REACT_QUERY_KEY.GET_ENDPOINT_DROPDOWN_DATA,
] as const;
const endPointDetailsDataQueries = useQueries( const endPointDetailsDataQueries = useQueries(
endPointDetailsQueryPayload.map((payload, index) => { endPointDetailsQueryPayload.map((payload, index) => ({
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index]; queryKey: [
const version = (V5_QUERIES as readonly string[]).includes(queryKey) END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
? ENTITY_VERSION_V5 payload,
: ENTITY_VERSION_V4; filters?.items, // Include filters.items in queryKey for better caching
return { ENTITY_VERSION_V4,
queryKey: [ ],
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index], queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
payload, GetMetricQueryRange(payload, ENTITY_VERSION_V4),
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching enabled: !!payload,
version, })),
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, version),
enabled: !!payload,
};
}),
); );
const [ const [

View File

@@ -1,10 +1,8 @@
import { LoadingOutlined } from '@ant-design/icons'; import { LoadingOutlined } from '@ant-design/icons';
import { Spin, Switch, Table, Tooltip, Typography } from 'antd'; import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer'; import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { withErrorBoundary } from 'components/ErrorBoundaryHOC'; import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
import { ENTITY_VERSION_V5 } from 'constants/app'; import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { import {
END_POINT_DETAILS_QUERY_KEYS_ARRAY, END_POINT_DETAILS_QUERY_KEYS_ARRAY,
@@ -13,12 +11,13 @@ import {
getTopErrorsColumnsConfig, getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters, getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload, getTopErrorsQueryPayload,
TopErrorsResponseRow,
} from 'container/ApiMonitoring/utils'; } from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults'; import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { Info } from 'lucide-react'; import { Info } from 'lucide-react';
import { useMemo, useState } from 'react'; import { useMemo, useState } from 'react';
import { QueryFunctionContext, useQueries, useQuery } from 'react-query'; import { useQueries } from 'react-query';
import { SuccessResponse, SuccessResponseV2 } from 'types/api'; import { SuccessResponse } from 'types/api';
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange'; import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse'; import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData'; import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
@@ -47,7 +46,7 @@ function TopErrors({
true, true,
); );
const queryPayload = useMemo( const queryPayloads = useMemo(
() => () =>
getTopErrorsQueryPayload( getTopErrorsQueryPayload(
domainName, domainName,
@@ -56,10 +55,6 @@ function TopErrors({
{ {
items: endPointName items: endPointName
? [ ? [
// Remove any existing http.url filters from initialFilters to avoid duplicates
...(initialFilters?.items?.filter(
(item) => item.key?.key !== SPAN_ATTRIBUTES.URL_PATH,
) || []),
{ {
id: '92b8a1c1', id: '92b8a1c1',
key: { key: {
@@ -70,6 +65,7 @@ function TopErrors({
op: '=', op: '=',
value: endPointName, value: endPointName,
}, },
...(initialFilters?.items || []),
] ]
: [...(initialFilters?.items || [])], : [...(initialFilters?.items || [])],
op: 'AND', op: 'AND',
@@ -86,34 +82,37 @@ function TopErrors({
], ],
); );
const topErrorsDataQueries = useQueries(
queryPayloads.map((payload) => ({
queryKey: [
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
payload,
DEFAULT_ENTITY_VERSION,
showStatusCodeErrors,
],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, DEFAULT_ENTITY_VERSION),
enabled: !!payload,
staleTime: 0,
cacheTime: 0,
})),
);
const topErrorsDataQuery = topErrorsDataQueries[0];
const { const {
data: topErrorsData, data: topErrorsData,
isLoading, isLoading,
isRefetching, isRefetching,
isError, isError,
refetch, refetch,
} = useQuery({ } = topErrorsDataQuery;
queryKey: [
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
queryPayload,
ENTITY_VERSION_V5,
showStatusCodeErrors,
],
queryFn: ({
signal,
}: QueryFunctionContext): Promise<SuccessResponseV2<MetricRangePayloadV5>> =>
getQueryRangeV5(queryPayload, ENTITY_VERSION_V5, signal),
enabled: !!queryPayload,
staleTime: 0,
cacheTime: 0,
});
const topErrorsColumnsConfig = useMemo(() => getTopErrorsColumnsConfig(), []); const topErrorsColumnsConfig = useMemo(() => getTopErrorsColumnsConfig(), []);
const formattedTopErrorsData = useMemo( const formattedTopErrorsData = useMemo(
() => () =>
formatTopErrorsDataForTable( formatTopErrorsDataForTable(
topErrorsData?.data?.data?.data?.results[0] as ScalarData, topErrorsData?.payload?.data?.result as TopErrorsResponseRow[],
), ),
[topErrorsData], [topErrorsData],
); );
@@ -131,12 +130,12 @@ function TopErrors({
const endPointDropDownDataQueries = useQueries( const endPointDropDownDataQueries = useQueries(
endPointDropDownQueryPayload.map((payload) => ({ endPointDropDownQueryPayload.map((payload) => ({
queryKey: [ queryKey: [
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2], END_POINT_DETAILS_QUERY_KEYS_ARRAY[4],
payload, payload,
ENTITY_VERSION_V5, ENTITY_VERSION_V4,
], ],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> => queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5), GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload, enabled: !!payload,
staleTime: 60 * 1000, staleTime: 60 * 1000,
})), })),

View File

@@ -1,337 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable prefer-destructuring */
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen, waitFor } from '@testing-library/react';
import { TraceAggregation } from 'api/v5/v5';
import { ENTITY_VERSION_V5 } from 'constants/app';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { QueryClient, QueryClientProvider } from 'react-query';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
import DomainMetrics from './DomainMetrics';
// Mock the API call
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
// Mock ErrorState component
jest.mock('./ErrorState', () => ({
__esModule: true,
default: jest.fn(({ refetch }) => (
<div data-testid="error-state">
<button type="button" onClick={refetch} data-testid="retry-button">
Retry
</button>
</div>
)),
}));
describe('DomainMetrics - V5 Query Payload Tests', () => {
let queryClient: QueryClient;
const mockProps = {
domainName: '0.0.0.0',
timeRange: {
startTime: 1758259531000,
endTime: 1758261331000,
},
domainListFilters: {
items: [],
op: 'AND' as const,
} as IBuilderQuery['filters'],
};
const mockSuccessResponse = {
statusCode: 200,
error: null,
payload: {
data: {
result: [
{
table: {
rows: [
{
data: {
A: '150',
B: '125000000',
D: '2021-01-01T23:00:00Z',
F1: '5.5',
},
},
],
},
},
],
},
},
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
jest.clearAllMocks();
});
afterEach(() => {
queryClient.clear();
});
const renderComponent = (props = mockProps): ReturnType<typeof render> =>
render(
<QueryClientProvider client={queryClient}>
<DomainMetrics {...props} />
</QueryClientProvider>,
);
describe('1. V5 Query Payload with Filters', () => {
it('sends correct V5 payload structure with domain name filters', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
renderComponent();
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalledTimes(1);
});
const [payload, version] = (GetMetricQueryRange as jest.Mock).mock.calls[0];
// Verify it's using V5
expect(version).toBe(ENTITY_VERSION_V5);
// Verify time range
expect(payload.start).toBe(1758259531000);
expect(payload.end).toBe(1758261331000);
// Verify V3 payload structure (getDomainMetricsQueryPayload returns V3 format)
expect(payload.query).toBeDefined();
expect(payload.query.builder).toBeDefined();
expect(payload.query.builder.queryData).toBeDefined();
const queryData = payload.query.builder.queryData;
// Verify Query A - count with URL filter
const queryA = queryData.find((q: any) => q.queryName === 'A');
expect(queryA).toBeDefined();
expect(queryA.dataSource).toBe('traces');
expect(queryA.aggregations?.[0]).toBeDefined();
expect((queryA.aggregations?.[0] as TraceAggregation)?.expression).toBe(
'count()',
);
// Verify exact domain filter expression structure
expect(queryA.filter.expression).toContain(
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
);
expect(queryA.filter.expression).toContain(
'url.full EXISTS OR http.url EXISTS',
);
// Verify Query B - p99 latency
const queryB = queryData.find((q: any) => q.queryName === 'B');
expect(queryB).toBeDefined();
expect(queryB.aggregateOperator).toBe('p99');
expect(queryB.aggregations?.[0]).toBeDefined();
expect((queryB.aggregations?.[0] as TraceAggregation)?.expression).toBe(
'p99(duration_nano)',
);
// Verify exact domain filter expression structure
expect(queryB.filter.expression).toContain(
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
);
// Verify Query C - error count (disabled)
const queryC = queryData.find((q: any) => q.queryName === 'C');
expect(queryC).toBeDefined();
expect(queryC.disabled).toBe(true);
expect(queryC.filter.expression).toContain(
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
);
expect(queryC.aggregations?.[0]).toBeDefined();
expect((queryC.aggregations?.[0] as TraceAggregation)?.expression).toBe(
'count()',
);
expect(queryC.filter.expression).toContain('has_error = true');
// Verify Query D - max timestamp
const queryD = queryData.find((q: any) => q.queryName === 'D');
expect(queryD).toBeDefined();
expect(queryD.aggregateOperator).toBe('max');
expect(queryD.aggregations?.[0]).toBeDefined();
expect((queryD.aggregations?.[0] as TraceAggregation)?.expression).toBe(
'max(timestamp)',
);
// Verify exact domain filter expression structure
expect(queryD.filter.expression).toContain(
"(net.peer.name = '0.0.0.0' OR server.address = '0.0.0.0')",
);
// Verify Formula F1 - error rate calculation
const formulas = payload.query.builder.queryFormulas;
expect(formulas).toBeDefined();
expect(formulas.length).toBeGreaterThan(0);
const formulaF1 = formulas.find((f: any) => f.queryName === 'F1');
expect(formulaF1).toBeDefined();
expect(formulaF1.expression).toBe('(C/A)*100');
});
it('includes custom filters in filter expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'my-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND' as const,
};
renderComponent({
...mockProps,
domainListFilters: customFilters,
});
await waitFor(() => {
expect(GetMetricQueryRange).toHaveBeenCalled();
});
const [payload] = (GetMetricQueryRange as jest.Mock).mock.calls[0];
const queryData = payload.query.builder.queryData;
// Verify all queries include the custom filters
queryData.forEach((query: any) => {
if (query.filter && query.filter.expression) {
expect(query.filter.expression).toContain('service.name');
expect(query.filter.expression).toContain('my-service');
expect(query.filter.expression).toContain('deployment.environment');
expect(query.filter.expression).toContain('production');
}
});
});
});
describe('2. Data Display State', () => {
it('displays metrics when data is successfully loaded', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
renderComponent();
// Wait for skeletons to disappear
await waitFor(() => {
const skeletons = document.querySelectorAll('.ant-skeleton-button');
expect(skeletons.length).toBe(0);
});
// Verify all metric labels are displayed
expect(screen.getByText('EXTERNAL API')).toBeInTheDocument();
expect(screen.getByText('AVERAGE LATENCY')).toBeInTheDocument();
expect(screen.getByText('ERROR %')).toBeInTheDocument();
expect(screen.getByText('LAST USED')).toBeInTheDocument();
// Verify metric values are displayed
expect(screen.getByText('150')).toBeInTheDocument();
expect(screen.getByText('0.125s')).toBeInTheDocument();
});
});
describe('3. Empty/Missing Data State', () => {
it('displays "-" for missing data values', async () => {
const emptyResponse = {
statusCode: 200,
error: null,
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
};
(GetMetricQueryRange as jest.Mock).mockResolvedValue(emptyResponse);
renderComponent();
await waitFor(() => {
const skeletons = document.querySelectorAll('.ant-skeleton-button');
expect(skeletons.length).toBe(0);
});
// When no data, all values should show "-"
const dashValues = screen.getAllByText('-');
expect(dashValues.length).toBeGreaterThan(0);
});
});
describe('4. Error State', () => {
it('displays error state when API call fails', async () => {
(GetMetricQueryRange as jest.Mock).mockRejectedValue(new Error('API Error'));
renderComponent();
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
expect(screen.getByTestId('retry-button')).toBeInTheDocument();
});
it('retries API call when retry button is clicked', async () => {
let callCount = 0;
(GetMetricQueryRange as jest.Mock).mockImplementation(() => {
callCount += 1;
if (callCount === 1) {
return Promise.reject(new Error('API Error'));
}
return Promise.resolve(mockSuccessResponse);
});
renderComponent();
// Wait for error state
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
// Click retry
const retryButton = screen.getByTestId('retry-button');
retryButton.click();
// Wait for successful load
await waitFor(() => {
expect(screen.getByText('150')).toBeInTheDocument();
});
expect(callCount).toBe(2);
});
});
});

View File

@@ -1,6 +1,6 @@
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Progress, Skeleton, Tooltip, Typography } from 'antd'; import { Progress, Skeleton, Tooltip, Typography } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app'; import { ENTITY_VERSION_V4 } from 'constants/app';
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { import {
DomainMetricsResponseRow, DomainMetricsResponseRow,
@@ -44,10 +44,10 @@ function DomainMetrics({
queryKey: [ queryKey: [
REACT_QUERY_KEY.GET_DOMAIN_METRICS_DATA, REACT_QUERY_KEY.GET_DOMAIN_METRICS_DATA,
payload, payload,
ENTITY_VERSION_V5, ENTITY_VERSION_V4,
], ],
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> => queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
GetMetricQueryRange(payload, ENTITY_VERSION_V5), GetMetricQueryRange(payload, ENTITY_VERSION_V4),
enabled: !!payload, enabled: !!payload,
staleTime: 60 * 1000, // 1 minute stale time : optimize this part staleTime: 60 * 1000, // 1 minute stale time : optimize this part
})), })),
@@ -132,9 +132,7 @@ function DomainMetrics({
) : ( ) : (
<Tooltip title={formattedDomainMetricsData.latency}> <Tooltip title={formattedDomainMetricsData.latency}>
<span className="round-metric-tag"> <span className="round-metric-tag">
{formattedDomainMetricsData.latency !== '-' {(Number(formattedDomainMetricsData.latency) / 1000).toFixed(3)}s
? `${(Number(formattedDomainMetricsData.latency) / 1000).toFixed(3)}s`
: '-'}
</span> </span>
</Tooltip> </Tooltip>
)} )}
@@ -145,27 +143,23 @@ function DomainMetrics({
<Skeleton.Button active size="small" /> <Skeleton.Button active size="small" />
) : ( ) : (
<Tooltip title={formattedDomainMetricsData.errorRate}> <Tooltip title={formattedDomainMetricsData.errorRate}>
{formattedDomainMetricsData.errorRate !== '-' ? ( <Progress
<Progress status="active"
status="active" percent={Number(
percent={Number( Number(formattedDomainMetricsData.errorRate).toFixed(2),
)}
strokeLinecap="butt"
size="small"
strokeColor={((): string => {
const errorRatePercent = Number(
Number(formattedDomainMetricsData.errorRate).toFixed(2), Number(formattedDomainMetricsData.errorRate).toFixed(2),
)} );
strokeLinecap="butt" if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
size="small" if (errorRatePercent >= 60) return Color.BG_AMBER_500;
strokeColor={((): string => { return Color.BG_FOREST_500;
const errorRatePercent = Number( })()}
Number(formattedDomainMetricsData.errorRate).toFixed(2), className="progress-bar"
); />
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
return Color.BG_FOREST_500;
})()}
className="progress-bar"
/>
) : (
'-'
)}
</Tooltip> </Tooltip>
)} )}
</Typography.Text> </Typography.Text>

View File

@@ -1,419 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable react/jsx-props-no-spreading */
/* eslint-disable prefer-destructuring */
/* eslint-disable sonarjs/no-duplicate-string */
import { render, screen, waitFor } from '@testing-library/react';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
import { QueryClient, QueryClientProvider, UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api';
import EndPointMetrics from './EndPointMetrics';
// Mock the API call
jest.mock('lib/dashboard/getQueryResults', () => ({
GetMetricQueryRange: jest.fn(),
}));
// Mock ErrorState component
jest.mock('./ErrorState', () => ({
__esModule: true,
default: jest.fn(({ refetch }) => (
<div data-testid="error-state">
<button type="button" onClick={refetch} data-testid="retry-button">
Retry
</button>
</div>
)),
}));
describe('EndPointMetrics - V5 Query Payload Tests', () => {
let queryClient: QueryClient;
const mockSuccessResponse = {
statusCode: 200,
error: null,
payload: {
data: {
result: [
{
table: {
rows: [
{
data: {
A: '85.5',
B: '245000000',
D: '2021-01-01T22:30:00Z',
F1: '3.2',
},
},
],
},
},
],
},
},
};
beforeEach(() => {
queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
jest.clearAllMocks();
});
afterEach(() => {
queryClient.clear();
});
// Helper to create mock query result
const createMockQueryResult = (
response: any,
overrides?: Partial<UseQueryResult<SuccessResponse<any>, unknown>>,
): UseQueryResult<SuccessResponse<any>, unknown> =>
({
data: response,
error: null,
isError: false,
isIdle: false,
isLoading: false,
isLoadingError: false,
isRefetchError: false,
isRefetching: false,
isStale: true,
isSuccess: true,
status: 'success' as const,
dataUpdatedAt: Date.now(),
errorUpdateCount: 0,
errorUpdatedAt: 0,
failureCount: 0,
isFetched: true,
isFetchedAfterMount: true,
isFetching: false,
isPlaceholderData: false,
isPreviousData: false,
refetch: jest.fn(),
remove: jest.fn(),
...overrides,
} as UseQueryResult<SuccessResponse<any>, unknown>);
const renderComponent = (
endPointMetricsDataQuery: UseQueryResult<SuccessResponse<any>, unknown>,
): ReturnType<typeof render> =>
render(
<QueryClientProvider client={queryClient}>
<EndPointMetrics endPointMetricsDataQuery={endPointMetricsDataQuery} />
</QueryClientProvider>,
);
// eslint-disable-next-line sonarjs/cognitive-complexity
describe('1. V5 Query Payload with Filters', () => {
// eslint-disable-next-line sonarjs/cognitive-complexity
it('sends correct V5 payload structure with domain and endpoint filters', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const domainName = 'api.example.com';
const startTime = 1758259531000;
const endTime = 1758261331000;
const filters = {
items: [],
op: 'AND' as const,
};
// Get the actual payload that would be generated
const payloads = getEndPointDetailsQueryPayload(
domainName,
startTime,
endTime,
filters,
);
// First payload is for endpoint metrics
const metricsPayload = payloads[0];
// Verify it's using the correct structure (V3 format for V5 API)
expect(metricsPayload.query).toBeDefined();
expect(metricsPayload.query.builder).toBeDefined();
expect(metricsPayload.query.builder.queryData).toBeDefined();
const queryData = metricsPayload.query.builder.queryData;
// Verify Query A - rate with domain and client kind filters
const queryA = queryData.find((q: any) => q.queryName === 'A');
expect(queryA).toBeDefined();
if (queryA) {
expect(queryA.dataSource).toBe('traces');
expect(queryA.aggregateOperator).toBe('rate');
expect(queryA.timeAggregation).toBe('rate');
// Verify exact domain filter expression structure
if (queryA.filter) {
expect(queryA.filter.expression).toContain(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
);
expect(queryA.filter.expression).toContain("kind_string = 'Client'");
}
}
// Verify Query B - p99 latency with duration_nano
const queryB = queryData.find((q: any) => q.queryName === 'B');
expect(queryB).toBeDefined();
if (queryB) {
expect(queryB.aggregateOperator).toBe('p99');
if (queryB.aggregateAttribute) {
expect(queryB.aggregateAttribute.key).toBe('duration_nano');
}
expect(queryB.timeAggregation).toBe('p99');
// Verify exact domain filter expression structure
if (queryB.filter) {
expect(queryB.filter.expression).toContain(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
);
expect(queryB.filter.expression).toContain("kind_string = 'Client'");
}
}
// Verify Query C - error count (disabled)
const queryC = queryData.find((q: any) => q.queryName === 'C');
expect(queryC).toBeDefined();
if (queryC) {
expect(queryC.disabled).toBe(true);
expect(queryC.aggregateOperator).toBe('count');
if (queryC.filter) {
expect(queryC.filter.expression).toContain(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
);
expect(queryC.filter.expression).toContain("kind_string = 'Client'");
expect(queryC.filter.expression).toContain('has_error = true');
}
}
// Verify Query D - max timestamp for last used
const queryD = queryData.find((q: any) => q.queryName === 'D');
expect(queryD).toBeDefined();
if (queryD) {
expect(queryD.aggregateOperator).toBe('max');
if (queryD.aggregateAttribute) {
expect(queryD.aggregateAttribute.key).toBe('timestamp');
}
expect(queryD.timeAggregation).toBe('max');
// Verify exact domain filter expression structure
if (queryD.filter) {
expect(queryD.filter.expression).toContain(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
);
expect(queryD.filter.expression).toContain("kind_string = 'Client'");
}
}
// Verify Query E - total count (disabled)
const queryE = queryData.find((q: any) => q.queryName === 'E');
expect(queryE).toBeDefined();
if (queryE) {
expect(queryE.disabled).toBe(true);
expect(queryE.aggregateOperator).toBe('count');
if (queryE.aggregateAttribute) {
expect(queryE.aggregateAttribute.key).toBe('span_id');
}
if (queryE.filter) {
expect(queryE.filter.expression).toContain(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com')",
);
expect(queryE.filter.expression).toContain("kind_string = 'Client'");
}
}
// Verify Formula F1 - error rate calculation
const formulas = metricsPayload.query.builder.queryFormulas;
expect(formulas).toBeDefined();
expect(formulas.length).toBeGreaterThan(0);
const formulaF1 = formulas.find((f: any) => f.queryName === 'F1');
expect(formulaF1).toBeDefined();
if (formulaF1) {
expect(formulaF1.expression).toBe('(C/E)*100');
expect(formulaF1.disabled).toBe(false);
expect(formulaF1.legend).toBe('error percentage');
}
});
it('includes custom domainListFilters in all query expressions', async () => {
(GetMetricQueryRange as jest.Mock).mockResolvedValue(mockSuccessResponse);
const customFilters = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'payment-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'staging',
},
],
op: 'AND' as const,
};
const payloads = getEndPointDetailsQueryPayload(
'api.internal.com',
1758259531000,
1758261331000,
customFilters,
);
const queryData = payloads[0].query.builder.queryData;
// Verify ALL queries (A, B, C, D, E) include the custom filters
const allQueryNames = ['A', 'B', 'C', 'D', 'E'];
allQueryNames.forEach((queryName) => {
const query = queryData.find((q: any) => q.queryName === queryName);
expect(query).toBeDefined();
if (query && query.filter && query.filter.expression) {
// Check for exact filter inclusion
expect(query.filter.expression).toContain('service.name');
expect(query.filter.expression).toContain('payment-service');
expect(query.filter.expression).toContain('deployment.environment');
expect(query.filter.expression).toContain('staging');
// Also verify domain filter is still present
expect(query.filter.expression).toContain(
"(net.peer.name = 'api.internal.com' OR server.address = 'api.internal.com')",
);
// Verify client kind filter is present
expect(query.filter.expression).toContain("kind_string = 'Client'");
}
});
});
});
describe('2. Data Display State', () => {
it('displays metrics when data is successfully loaded', async () => {
const mockQuery = createMockQueryResult(mockSuccessResponse);
renderComponent(mockQuery);
// Wait for skeletons to disappear
await waitFor(() => {
const skeletons = document.querySelectorAll('.ant-skeleton-button');
expect(skeletons.length).toBe(0);
});
// Verify all metric labels are displayed
expect(screen.getByText('Rate')).toBeInTheDocument();
expect(screen.getByText('AVERAGE LATENCY')).toBeInTheDocument();
expect(screen.getByText('ERROR %')).toBeInTheDocument();
expect(screen.getByText('LAST USED')).toBeInTheDocument();
// Verify metric values are displayed
expect(screen.getByText('85.5 ops/sec')).toBeInTheDocument();
expect(screen.getByText('245ms')).toBeInTheDocument();
});
});
describe('3. Empty/Missing Data State', () => {
it("displays '-' for missing data values", async () => {
const emptyResponse = {
statusCode: 200,
error: null,
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
};
const mockQuery = createMockQueryResult(emptyResponse);
renderComponent(mockQuery);
await waitFor(() => {
const skeletons = document.querySelectorAll('.ant-skeleton-button');
expect(skeletons.length).toBe(0);
});
// When no data, all values should show "-"
const dashValues = screen.getAllByText('-');
// Should have at least 2 dashes (rate and last used - latency shows "-", error % shows progress bar)
expect(dashValues.length).toBeGreaterThanOrEqual(2);
});
});
describe('4. Error State', () => {
it('displays error state when API call fails', async () => {
const mockQuery = createMockQueryResult(null, {
isError: true,
isSuccess: false,
status: 'error',
error: new Error('API Error'),
});
renderComponent(mockQuery);
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
expect(screen.getByTestId('retry-button')).toBeInTheDocument();
});
it('retries API call when retry button is clicked', async () => {
const refetch = jest.fn().mockResolvedValue(mockSuccessResponse);
// Start with error state
const mockQuery = createMockQueryResult(null, {
isError: true,
isSuccess: false,
status: 'error',
error: new Error('API Error'),
refetch,
});
const { rerender } = renderComponent(mockQuery);
// Wait for error state
await waitFor(() => {
expect(screen.getByTestId('error-state')).toBeInTheDocument();
});
// Click retry
const retryButton = screen.getByTestId('retry-button');
retryButton.click();
// Verify refetch was called
expect(refetch).toHaveBeenCalledTimes(1);
// Simulate successful refetch by rerendering with success state
const successQuery = createMockQueryResult(mockSuccessResponse);
rerender(
<QueryClientProvider client={queryClient}>
<EndPointMetrics endPointMetricsDataQuery={successQuery} />
</QueryClientProvider>,
);
// Wait for successful load
await waitFor(() => {
expect(screen.getByText('85.5 ops/sec')).toBeInTheDocument();
});
});
});
});

View File

@@ -1,16 +1,12 @@
import { Color } from '@signozhq/design-tokens'; import { Color } from '@signozhq/design-tokens';
import { Progress, Skeleton, Tooltip, Typography } from 'antd'; import { Progress, Skeleton, Tooltip, Typography } from 'antd';
import { import { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
getDisplayValue,
getFormattedEndPointMetricsData,
} from 'container/ApiMonitoring/utils';
import { useMemo } from 'react'; import { useMemo } from 'react';
import { UseQueryResult } from 'react-query'; import { UseQueryResult } from 'react-query';
import { SuccessResponse } from 'types/api'; import { SuccessResponse } from 'types/api';
import ErrorState from './ErrorState'; import ErrorState from './ErrorState';
// eslint-disable-next-line sonarjs/cognitive-complexity
function EndPointMetrics({ function EndPointMetrics({
endPointMetricsDataQuery, endPointMetricsDataQuery,
}: { }: {
@@ -74,9 +70,7 @@ function EndPointMetrics({
<Skeleton.Button active size="small" /> <Skeleton.Button active size="small" />
) : ( ) : (
<Tooltip title={metricsData?.rate}> <Tooltip title={metricsData?.rate}>
<span className="round-metric-tag"> <span className="round-metric-tag">{metricsData?.rate} ops/sec</span>
{metricsData?.rate !== '-' ? `${metricsData?.rate} ops/sec` : '-'}
</span>
</Tooltip> </Tooltip>
)} )}
</Typography.Text> </Typography.Text>
@@ -85,7 +79,7 @@ function EndPointMetrics({
<Skeleton.Button active size="small" /> <Skeleton.Button active size="small" />
) : ( ) : (
<Tooltip title={metricsData?.latency}> <Tooltip title={metricsData?.latency}>
{metricsData?.latency !== '-' ? `${metricsData?.latency}ms` : '-'} <span className="round-metric-tag">{metricsData?.latency}ms</span>
</Tooltip> </Tooltip>
)} )}
</Typography.Text> </Typography.Text>
@@ -94,25 +88,21 @@ function EndPointMetrics({
<Skeleton.Button active size="small" /> <Skeleton.Button active size="small" />
) : ( ) : (
<Tooltip title={metricsData?.errorRate}> <Tooltip title={metricsData?.errorRate}>
{metricsData?.errorRate !== '-' ? ( <Progress
<Progress status="active"
status="active" percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))} strokeLinecap="butt"
strokeLinecap="butt" size="small"
size="small" strokeColor={((): string => {
strokeColor={((): string => { const errorRatePercent = Number(
const errorRatePercent = Number( Number(metricsData?.errorRate ?? 0).toFixed(2),
Number(metricsData?.errorRate ?? 0).toFixed(2), );
); if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
if (errorRatePercent >= 90) return Color.BG_SAKURA_500; if (errorRatePercent >= 60) return Color.BG_AMBER_500;
if (errorRatePercent >= 60) return Color.BG_AMBER_500; return Color.BG_FOREST_500;
return Color.BG_FOREST_500; })()}
})()} className="progress-bar"
className="progress-bar" />
/>
) : (
'-'
)}
</Tooltip> </Tooltip>
)} )}
</Typography.Text> </Typography.Text>
@@ -120,9 +110,7 @@ function EndPointMetrics({
{isLoading || isRefetching ? ( {isLoading || isRefetching ? (
<Skeleton.Button active size="small" /> <Skeleton.Button active size="small" />
) : ( ) : (
<Tooltip title={metricsData?.lastUsed}> <Tooltip title={metricsData?.lastUsed}>{metricsData?.lastUsed}</Tooltip>
{getDisplayValue(metricsData?.lastUsed)}
</Tooltip>
)} )}
</Typography.Text> </Typography.Text>
</div> </div>

View File

@@ -1,5 +1,4 @@
import { Card } from 'antd'; import { Card } from 'antd';
import { ENTITY_VERSION_V5 } from 'constants/app';
import GridCard from 'container/GridCardLayout/GridCard'; import GridCard from 'container/GridCardLayout/GridCard';
import { Widgets } from 'types/api/dashboard/getAll'; import { Widgets } from 'types/api/dashboard/getAll';
@@ -23,7 +22,6 @@ function MetricOverTimeGraph({
customOnDragSelect={(): void => {}} customOnDragSelect={(): void => {}}
customTimeRange={timeRange} customTimeRange={timeRange}
customTimeRangeWindowForCoRelation="5m" customTimeRangeWindowForCoRelation="5m"
version={ENTITY_VERSION_V5}
/> />
</div> </div>
</Card> </Card>

View File

@@ -8,14 +8,23 @@ import {
endPointStatusCodeColumns, endPointStatusCodeColumns,
extractPortAndEndpoint, extractPortAndEndpoint,
formatDataForTable, formatDataForTable,
formatTopErrorsDataForTable,
getAllEndpointsWidgetData,
getCustomFiltersForBarChart, getCustomFiltersForBarChart,
getEndPointDetailsQueryPayload,
getFormattedDependentServicesData,
getFormattedEndPointDropDownData, getFormattedEndPointDropDownData,
getFormattedEndPointMetricsData,
getFormattedEndPointStatusCodeChartData, getFormattedEndPointStatusCodeChartData,
getFormattedEndPointStatusCodeData, getFormattedEndPointStatusCodeData,
getGroupByFiltersFromGroupByValues, getGroupByFiltersFromGroupByValues,
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
getStatusCodeBarChartWidgetData, getStatusCodeBarChartWidgetData,
getTopErrorsColumnsConfig, getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters, getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
TopErrorsResponseRow,
} from '../utils'; } from '../utils';
import { APIMonitoringColumnsMock } from './mock'; import { APIMonitoringColumnsMock } from './mock';
@@ -43,13 +52,119 @@ jest.mock('../utils', () => {
}); });
describe('API Monitoring Utils', () => { describe('API Monitoring Utils', () => {
describe('getAllEndpointsWidgetData', () => {
it('should create a widget with correct configuration', () => {
// Arrange
const groupBy = [
{
dataType: DataTypes.String,
// eslint-disable-next-line sonarjs/no-duplicate-string
key: 'http.method',
type: '',
},
];
// eslint-disable-next-line sonarjs/no-duplicate-string
const domainName = 'test-domain';
const filters = {
items: [
{
// eslint-disable-next-line sonarjs/no-duplicate-string
id: 'test-filter',
key: {
dataType: DataTypes.String,
key: 'test-key',
type: '',
},
op: '=',
// eslint-disable-next-line sonarjs/no-duplicate-string
value: 'test-value',
},
],
op: 'AND',
};
// Act
const result = getAllEndpointsWidgetData(
groupBy as BaseAutocompleteData[],
domainName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
expect(result.id).toBeDefined();
// Title is a React component, not a string
expect(result.title).toBeDefined();
expect(result.panelTypes).toBe(PANEL_TYPES.TABLE);
// Check that each query includes the domainName filter
result.query.builder.queryData.forEach((query) => {
const serverNameFilter = query.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
);
expect(serverNameFilter).toBeDefined();
expect(serverNameFilter?.value).toBe(domainName);
// Check that the custom filters were included
const testFilter = query.filters?.items?.find(
(item) => item.id === 'test-filter',
);
expect(testFilter).toBeDefined();
});
// Verify groupBy was included in queries
if (result.query.builder.queryData[0].groupBy) {
const hasCustomGroupBy = result.query.builder.queryData[0].groupBy.some(
(item) => item && item.key === 'http.method',
);
expect(hasCustomGroupBy).toBe(true);
}
});
it('should handle empty groupBy correctly', () => {
// Arrange
const groupBy: any[] = [];
const domainName = 'test-domain';
const filters = { items: [], op: 'AND' };
// Act
const result = getAllEndpointsWidgetData(groupBy, domainName, filters);
// Assert
expect(result).toBeDefined();
// Should only include default groupBy
if (result.query.builder.queryData[0].groupBy) {
expect(result.query.builder.queryData[0].groupBy.length).toBeGreaterThan(0);
// Check that it doesn't have extra group by fields (only defaults)
const defaultGroupByLength =
result.query.builder.queryData[0].groupBy.length;
const resultWithCustomGroupBy = getAllEndpointsWidgetData(
[
{
dataType: DataTypes.String,
key: 'custom.field',
type: '',
},
] as BaseAutocompleteData[],
domainName,
filters,
);
// Custom groupBy should have more fields than default
if (resultWithCustomGroupBy.query.builder.queryData[0].groupBy) {
expect(
resultWithCustomGroupBy.query.builder.queryData[0].groupBy.length,
).toBeGreaterThan(defaultGroupByLength);
}
}
});
});
// New tests for formatDataForTable // New tests for formatDataForTable
describe('formatDataForTable', () => { describe('formatDataForTable', () => {
it('should format rows correctly with valid data', () => { it('should format rows correctly with valid data', () => {
const columns = APIMonitoringColumnsMock; const columns = APIMonitoringColumnsMock;
const data = [ const data = [
[ [
// eslint-disable-next-line sonarjs/no-duplicate-string
'test-domain', // domainName 'test-domain', // domainName
'10', // endpoints '10', // endpoints
'25', // rps '25', // rps
@@ -107,7 +222,6 @@ describe('API Monitoring Utils', () => {
const groupBy = [ const groupBy = [
{ {
id: 'group-by-1', id: 'group-by-1',
// eslint-disable-next-line sonarjs/no-duplicate-string
key: 'http.method', key: 'http.method',
dataType: DataTypes.String, dataType: DataTypes.String,
type: '', type: '',
@@ -230,6 +344,49 @@ describe('API Monitoring Utils', () => {
}); });
}); });
describe('formatTopErrorsDataForTable', () => {
it('should format top errors data correctly', () => {
// Arrange
const inputData = [
{
metric: {
[SPAN_ATTRIBUTES.URL_PATH]: '/api/test',
[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]: '500',
status_message: 'Internal Server Error',
},
values: [[1000000100, '10']],
queryName: 'A',
legend: 'Test Legend',
},
];
// Act
const result = formatTopErrorsDataForTable(
inputData as TopErrorsResponseRow[],
);
// Assert
expect(result).toBeDefined();
expect(result.length).toBe(1);
// Check first item is formatted correctly
expect(result[0].endpointName).toBe('/api/test');
expect(result[0].statusCode).toBe('500');
expect(result[0].statusMessage).toBe('Internal Server Error');
expect(result[0].count).toBe('10');
expect(result[0].key).toBeDefined();
});
it('should handle empty input', () => {
// Act
const result = formatTopErrorsDataForTable(undefined);
// Assert
expect(result).toBeDefined();
expect(result).toEqual([]);
});
});
describe('getTopErrorsColumnsConfig', () => { describe('getTopErrorsColumnsConfig', () => {
it('should return column configuration with expected fields', () => { it('should return column configuration with expected fields', () => {
// Act // Act
@@ -296,6 +453,72 @@ describe('API Monitoring Utils', () => {
}); });
}); });
describe('getTopErrorsQueryPayload', () => {
it('should create correct query payload with filters', () => {
// Arrange
const domainName = 'test-domain';
const start = 1000000000;
const end = 1000010000;
const filters = {
items: [
{
id: 'test-filter',
key: {
dataType: DataTypes.String,
key: 'test-key',
type: '',
},
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
// Act
const result = getTopErrorsQueryPayload(
domainName,
start,
end,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
expect(result.length).toBeGreaterThan(0);
// Verify query params
expect(result[0].start).toBe(start);
expect(result[0].end).toBe(end);
// Verify correct structure
expect(result[0].graphType).toBeDefined();
expect(result[0].query).toBeDefined();
expect(result[0].query.builder).toBeDefined();
expect(result[0].query.builder.queryData).toBeDefined();
// Verify domain filter is included
const queryData = result[0].query.builder.queryData[0];
expect(queryData.filters).toBeDefined();
// Check for domain filter
const domainFilter = queryData.filters?.items?.find(
// eslint-disable-next-line sonarjs/no-identical-functions
(item) =>
item.key &&
item.key.key === SPAN_ATTRIBUTES.SERVER_NAME &&
item.value === domainName,
);
expect(domainFilter).toBeDefined();
// Check that custom filters were included
const testFilter = queryData.filters?.items?.find(
(item) => item.id === 'test-filter',
);
expect(testFilter).toBeDefined();
});
});
// Add new tests for EndPointDetails utility functions // Add new tests for EndPointDetails utility functions
describe('extractPortAndEndpoint', () => { describe('extractPortAndEndpoint', () => {
it('should extract port and endpoint from a valid URL', () => { it('should extract port and endpoint from a valid URL', () => {
@@ -341,6 +564,243 @@ describe('API Monitoring Utils', () => {
}); });
}); });
describe('getEndPointDetailsQueryPayload', () => {
it('should generate proper query payload with all parameters', () => {
// Arrange
const domainName = 'test-domain';
const startTime = 1609459200000; // 2021-01-01
const endTime = 1609545600000; // 2021-01-02
const filters = {
items: [
{
id: 'test-filter',
key: {
dataType: 'string',
key: 'test.key',
type: '',
},
op: '=',
value: 'test-value',
},
],
op: 'AND',
};
// Act
const result = getEndPointDetailsQueryPayload(
domainName,
startTime,
endTime,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toHaveLength(6); // Should return 6 queries
// Check that each query includes proper parameters
result.forEach((query) => {
expect(query).toHaveProperty('start', startTime);
expect(query).toHaveProperty('end', endTime);
// Should have query property with builder data
expect(query).toHaveProperty('query');
expect(query.query).toHaveProperty('builder');
// All queries should include the domain filter
const {
query: {
builder: { queryData },
},
} = query;
queryData.forEach((qd) => {
if (qd.filters && qd.filters.items) {
const serverNameFilter = qd.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
);
expect(serverNameFilter).toBeDefined();
// Only check if the serverNameFilter exists, as the actual value might vary
// depending on implementation details or domain defaults
if (serverNameFilter) {
expect(typeof serverNameFilter.value).toBe('string');
}
}
// Should include our custom filter
const customFilter = qd.filters?.items?.find(
(item) => item.id === 'test-filter',
);
expect(customFilter).toBeDefined();
});
});
});
});
describe('getRateOverTimeWidgetData', () => {
it('should generate widget configuration for rate over time', () => {
// Arrange
const domainName = 'test-domain';
const endPointName = '/api/test';
const filters = { items: [], op: 'AND' };
// Act
const result = getRateOverTimeWidgetData(
domainName,
endPointName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
expect(result).toHaveProperty('title', 'Rate Over Time');
// Check only title since description might vary
// Check query configuration
expect(result).toHaveProperty('query');
// eslint-disable-next-line sonarjs/no-duplicate-string
expect(result).toHaveProperty('query.builder.queryData');
const queryData = result.query.builder.queryData[0];
// Should have domain filter
const domainFilter = queryData.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
);
expect(domainFilter).toBeDefined();
if (domainFilter) {
expect(typeof domainFilter.value).toBe('string');
}
// Should have 'rate' time aggregation
expect(queryData).toHaveProperty('timeAggregation', 'rate');
// Should have proper legend that includes endpoint info
expect(queryData).toHaveProperty('legend');
expect(
typeof queryData.legend === 'string' ? queryData.legend : '',
).toContain('/api/test');
});
it('should handle case without endpoint name', () => {
// Arrange
const domainName = 'test-domain';
const endPointName = '';
const filters = { items: [], op: 'AND' };
// Act
const result = getRateOverTimeWidgetData(
domainName,
endPointName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
const queryData = result.query.builder.queryData[0];
// Legend should be domain name only
expect(queryData).toHaveProperty('legend', domainName);
});
});
describe('getLatencyOverTimeWidgetData', () => {
it('should generate widget configuration for latency over time', () => {
// Arrange
const domainName = 'test-domain';
const endPointName = '/api/test';
const filters = { items: [], op: 'AND' };
// Act
const result = getLatencyOverTimeWidgetData(
domainName,
endPointName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
expect(result).toHaveProperty('title', 'Latency Over Time');
// Check only title since description might vary
// Check query configuration
expect(result).toHaveProperty('query');
expect(result).toHaveProperty('query.builder.queryData');
const queryData = result.query.builder.queryData[0];
// Should have domain filter
const domainFilter = queryData.filters?.items?.find(
(item) => item.key && item.key.key === SPAN_ATTRIBUTES.SERVER_NAME,
);
expect(domainFilter).toBeDefined();
if (domainFilter) {
expect(typeof domainFilter.value).toBe('string');
}
// Should use duration_nano as the aggregate attribute
expect(queryData.aggregateAttribute).toHaveProperty('key', 'duration_nano');
// Should have 'p99' time aggregation
expect(queryData).toHaveProperty('timeAggregation', 'p99');
});
it('should handle case without endpoint name', () => {
// Arrange
const domainName = 'test-domain';
const endPointName = '';
const filters = { items: [], op: 'AND' };
// Act
const result = getLatencyOverTimeWidgetData(
domainName,
endPointName,
filters as IBuilderQuery['filters'],
);
// Assert
expect(result).toBeDefined();
const queryData = result.query.builder.queryData[0];
// Legend should be domain name only
expect(queryData).toHaveProperty('legend', domainName);
});
// Changed approach to verify end-to-end behavior for URL with port
it('should format legends appropriately for complete URLs with ports', () => {
// Arrange
const domainName = 'test-domain';
const endPointName = 'http://example.com:8080/api/test';
const filters = { items: [], op: 'AND' };
// Extract what we expect the function to extract
const expectedParts = extractPortAndEndpoint(endPointName);
// Act
const result = getLatencyOverTimeWidgetData(
domainName,
endPointName,
filters as IBuilderQuery['filters'],
);
// Assert
const queryData = result.query.builder.queryData[0];
// Check that legend is present and is a string
expect(queryData).toHaveProperty('legend');
expect(typeof queryData.legend).toBe('string');
// If the URL has a port and endpoint, the legend should reflect that appropriately
// (Testing the integration rather than the exact formatting)
if (expectedParts.port !== '-') {
// Verify that both components are incorporated into the legend in some way
// This tests the behavior without relying on the exact implementation details
const legendStr = queryData.legend as string;
expect(legendStr).not.toBe(domainName); // Legend should be different when URL has port/endpoint
}
});
});
describe('getFormattedEndPointDropDownData', () => { describe('getFormattedEndPointDropDownData', () => {
it('should format endpoint dropdown data correctly', () => { it('should format endpoint dropdown data correctly', () => {
// Arrange // Arrange
@@ -350,7 +810,6 @@ describe('API Monitoring Utils', () => {
data: { data: {
// eslint-disable-next-line sonarjs/no-duplicate-string // eslint-disable-next-line sonarjs/no-duplicate-string
[URL_PATH_KEY]: '/api/users', [URL_PATH_KEY]: '/api/users',
'url.full': 'http://example.com/api/users',
A: 150, // count or other metric A: 150, // count or other metric
}, },
}, },
@@ -358,7 +817,6 @@ describe('API Monitoring Utils', () => {
data: { data: {
// eslint-disable-next-line sonarjs/no-duplicate-string // eslint-disable-next-line sonarjs/no-duplicate-string
[URL_PATH_KEY]: '/api/orders', [URL_PATH_KEY]: '/api/orders',
'url.full': 'http://example.com/api/orders',
A: 75, A: 75,
}, },
}, },
@@ -442,6 +900,87 @@ describe('API Monitoring Utils', () => {
}); });
}); });
describe('getFormattedEndPointMetricsData', () => {
it('should format endpoint metrics data correctly', () => {
// Arrange
const mockData = [
{
data: {
A: '50', // rate
B: '15000000', // latency in nanoseconds
C: '5', // required by type
D: '1640995200000000', // timestamp in nanoseconds
F1: '5.5', // error rate
},
},
];
// Act
const result = getFormattedEndPointMetricsData(mockData as any);
// Assert
expect(result).toBeDefined();
expect(result.key).toBeDefined();
expect(result.rate).toBe('50');
expect(result.latency).toBe(15); // Should be converted from ns to ms
expect(result.errorRate).toBe(5.5);
expect(typeof result.lastUsed).toBe('string'); // Time formatting is tested elsewhere
});
// eslint-disable-next-line sonarjs/no-duplicate-string
it('should handle undefined values in data', () => {
// Arrange
const mockData = [
{
data: {
A: undefined,
B: 'n/a',
C: '', // required by type
D: undefined,
F1: 'n/a',
},
},
];
// Act
const result = getFormattedEndPointMetricsData(mockData as any);
// Assert
expect(result).toBeDefined();
expect(result.rate).toBe('-');
expect(result.latency).toBe('-');
expect(result.errorRate).toBe(0);
expect(result.lastUsed).toBe('-');
});
it('should handle empty input array', () => {
// Act
const result = getFormattedEndPointMetricsData([]);
// Assert
expect(result).toBeDefined();
expect(result.rate).toBe('-');
expect(result.latency).toBe('-');
expect(result.errorRate).toBe(0);
expect(result.lastUsed).toBe('-');
});
it('should handle undefined input', () => {
// Arrange
const undefinedInput = undefined as any;
// Act
const result = getFormattedEndPointMetricsData(undefinedInput);
// Assert
expect(result).toBeDefined();
expect(result.rate).toBe('-');
expect(result.latency).toBe('-');
expect(result.errorRate).toBe(0);
expect(result.lastUsed).toBe('-');
});
});
describe('getFormattedEndPointStatusCodeData', () => { describe('getFormattedEndPointStatusCodeData', () => {
it('should format status code data correctly', () => { it('should format status code data correctly', () => {
// Arrange // Arrange
@@ -578,6 +1117,139 @@ describe('API Monitoring Utils', () => {
}); });
}); });
describe('getFormattedDependentServicesData', () => {
it('should format dependent services data correctly', () => {
// Arrange
const mockData = [
{
data: {
// eslint-disable-next-line sonarjs/no-duplicate-string
'service.name': 'auth-service',
A: '500', // count
B: '120000000', // latency in nanoseconds
C: '15', // rate
F1: '2.5', // error percentage
},
},
{
data: {
'service.name': 'db-service',
A: '300',
B: '80000000',
C: '10',
F1: '1.2',
},
},
];
// Act
const result = getFormattedDependentServicesData(mockData as any);
// Assert
expect(result).toBeDefined();
expect(result.length).toBe(2);
// Check first service
expect(result[0].key).toBeDefined();
expect(result[0].serviceData.serviceName).toBe('auth-service');
expect(result[0].serviceData.count).toBe(500);
expect(typeof result[0].serviceData.percentage).toBe('number');
expect(result[0].latency).toBe(120); // Should be converted from ns to ms
expect(result[0].rate).toBe('15');
expect(result[0].errorPercentage).toBe('2.5');
// Check second service
expect(result[1].serviceData.serviceName).toBe('db-service');
expect(result[1].serviceData.count).toBe(300);
expect(result[1].latency).toBe(80);
expect(result[1].rate).toBe('10');
expect(result[1].errorPercentage).toBe('1.2');
// Verify percentage calculation
const totalCount = 500 + 300;
expect(result[0].serviceData.percentage).toBeCloseTo(
(500 / totalCount) * 100,
2,
);
expect(result[1].serviceData.percentage).toBeCloseTo(
(300 / totalCount) * 100,
2,
);
});
it('should handle undefined values in data', () => {
// Arrange
const mockData = [
{
data: {
'service.name': 'auth-service',
A: 'n/a',
B: undefined,
C: 'n/a',
F1: undefined,
},
},
];
// Act
const result = getFormattedDependentServicesData(mockData as any);
// Assert
expect(result).toBeDefined();
expect(result.length).toBe(1);
expect(result[0].serviceData.serviceName).toBe('auth-service');
expect(result[0].serviceData.count).toBe('-');
expect(result[0].serviceData.percentage).toBe(0);
expect(result[0].latency).toBe('-');
expect(result[0].rate).toBe('-');
expect(result[0].errorPercentage).toBe(0);
});
it('should handle empty input array', () => {
// Act
const result = getFormattedDependentServicesData([]);
// Assert
expect(result).toBeDefined();
expect(result).toEqual([]);
});
it('should handle undefined input', () => {
// Arrange
const undefinedInput = undefined as any;
// Act
const result = getFormattedDependentServicesData(undefinedInput);
// Assert
expect(result).toBeDefined();
expect(result).toEqual([]);
});
it('should handle missing service name', () => {
// Arrange
const mockData = [
{
data: {
// Missing service.name
A: '200',
B: '50000000',
C: '8',
F1: '0.5',
},
},
];
// Act
const result = getFormattedDependentServicesData(mockData as any);
// Assert
expect(result).toBeDefined();
expect(result.length).toBe(1);
expect(result[0].serviceData.serviceName).toBe('-');
});
});
describe('getFormattedEndPointStatusCodeChartData', () => { describe('getFormattedEndPointStatusCodeChartData', () => {
afterEach(() => { afterEach(() => {
jest.resetAllMocks(); jest.resetAllMocks();

View File

@@ -1,221 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/no-duplicate-string */
/**
* V5 Migration Tests for All Endpoints Widget (Endpoint Overview)
*
* These tests validate the migration from V4 to V5 format for getAllEndpointsWidgetData:
* - Filter format change: filters.items[] → filter.expression
* - Aggregation format: aggregateAttribute → aggregations[] array
* - Domain filter: (net.peer.name OR server.address)
* - Kind filter: kind_string = 'Client'
* - Four queries: A (count), B (p99 latency), C (max timestamp), D (error count - disabled)
* - GroupBy: Both http.url AND url.full with type 'attribute'
*/
import { getAllEndpointsWidgetData } from 'container/ApiMonitoring/utils';
import {
BaseAutocompleteData,
DataTypes,
} from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('AllEndpointsWidget - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
const emptyGroupBy: BaseAutocompleteData[] = [];
describe('1. V5 Format Migration - All Four Queries', () => {
it('all queries use filter.expression format (not filters.items)', () => {
const widget = getAllEndpointsWidgetData(
emptyGroupBy,
mockDomainName,
emptyFilters,
);
const { queryData } = widget.query.builder;
// All 4 queries must use V5 filter.expression format
queryData.forEach((query) => {
expect(query.filter).toBeDefined();
expect(query.filter?.expression).toBeDefined();
expect(typeof query.filter?.expression).toBe('string');
// OLD V4 format should NOT exist
expect(query).not.toHaveProperty('filters');
});
// Verify we have exactly 4 queries
expect(queryData).toHaveLength(4);
});
it('all queries use aggregations array format (not aggregateAttribute)', () => {
const widget = getAllEndpointsWidgetData(
emptyGroupBy,
mockDomainName,
emptyFilters,
);
const [queryA, queryB, queryC, queryD] = widget.query.builder.queryData;
// Query A: count()
expect(queryA.aggregations).toBeDefined();
expect(Array.isArray(queryA.aggregations)).toBe(true);
expect(queryA.aggregations).toEqual([{ expression: 'count()' }]);
expect(queryA).not.toHaveProperty('aggregateAttribute');
// Query B: p99(duration_nano)
expect(queryB.aggregations).toBeDefined();
expect(Array.isArray(queryB.aggregations)).toBe(true);
expect(queryB.aggregations).toEqual([{ expression: 'p99(duration_nano)' }]);
expect(queryB).not.toHaveProperty('aggregateAttribute');
// Query C: max(timestamp)
expect(queryC.aggregations).toBeDefined();
expect(Array.isArray(queryC.aggregations)).toBe(true);
expect(queryC.aggregations).toEqual([{ expression: 'max(timestamp)' }]);
expect(queryC).not.toHaveProperty('aggregateAttribute');
// Query D: count() (disabled, for errors)
expect(queryD.aggregations).toBeDefined();
expect(Array.isArray(queryD.aggregations)).toBe(true);
expect(queryD.aggregations).toEqual([{ expression: 'count()' }]);
expect(queryD).not.toHaveProperty('aggregateAttribute');
});
it('all queries have correct base filter expressions', () => {
const widget = getAllEndpointsWidgetData(
emptyGroupBy,
mockDomainName,
emptyFilters,
);
const [queryA, queryB, queryC, queryD] = widget.query.builder.queryData;
const baseExpression = `(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}') AND kind_string = 'Client'`;
// Queries A, B, C have identical base filter
expect(queryA.filter?.expression).toBe(
`${baseExpression} AND (http.url EXISTS OR url.full EXISTS)`,
);
expect(queryB.filter?.expression).toBe(
`${baseExpression} AND (http.url EXISTS OR url.full EXISTS)`,
);
expect(queryC.filter?.expression).toBe(
`${baseExpression} AND (http.url EXISTS OR url.full EXISTS)`,
);
// Query D has additional has_error filter
expect(queryD.filter?.expression).toBe(
`${baseExpression} AND has_error = true AND (http.url EXISTS OR url.full EXISTS)`,
);
});
});
describe('2. GroupBy Structure', () => {
it('default groupBy includes both http.url and url.full with type attribute', () => {
const widget = getAllEndpointsWidgetData(
emptyGroupBy,
mockDomainName,
emptyFilters,
);
const { queryData } = widget.query.builder;
// All queries should have the same default groupBy
queryData.forEach((query) => {
expect(query.groupBy).toHaveLength(2);
// http.url
expect(query.groupBy).toContainEqual({
dataType: DataTypes.String,
isColumn: false,
isJSON: false,
key: 'http.url',
type: 'attribute',
});
// url.full
expect(query.groupBy).toContainEqual({
dataType: DataTypes.String,
isColumn: false,
isJSON: false,
key: 'url.full',
type: 'attribute',
});
});
});
it('custom groupBy is appended after defaults', () => {
const customGroupBy: BaseAutocompleteData[] = [
{
dataType: DataTypes.String,
key: 'service.name',
type: 'resource',
},
{
dataType: DataTypes.String,
key: 'deployment.environment',
type: 'resource',
},
];
const widget = getAllEndpointsWidgetData(
customGroupBy,
mockDomainName,
emptyFilters,
);
const { queryData } = widget.query.builder;
// All queries should have defaults + custom groupBy
queryData.forEach((query) => {
expect(query.groupBy).toHaveLength(4); // 2 defaults + 2 custom
// First two should be defaults (http.url, url.full)
expect(query.groupBy[0].key).toBe('http.url');
expect(query.groupBy[1].key).toBe('url.full');
// Last two should be custom (matching subset of properties)
expect(query.groupBy[2]).toMatchObject({
dataType: DataTypes.String,
key: 'service.name',
type: 'resource',
});
expect(query.groupBy[3]).toMatchObject({
dataType: DataTypes.String,
key: 'deployment.environment',
type: 'resource',
});
});
});
});
describe('3. Query-Specific Validations', () => {
it('query D has has_error filter and is disabled', () => {
const widget = getAllEndpointsWidgetData(
emptyGroupBy,
mockDomainName,
emptyFilters,
);
const [queryA, queryB, queryC, queryD] = widget.query.builder.queryData;
// Query D should be disabled
expect(queryD.disabled).toBe(true);
// Queries A, B, C should NOT be disabled
expect(queryA.disabled).toBe(false);
expect(queryB.disabled).toBe(false);
expect(queryC.disabled).toBe(false);
// Query D should have has_error in filter
expect(queryD.filter?.expression).toContain('has_error = true');
// Queries A, B, C should NOT have has_error
expect(queryA.filter?.expression).not.toContain('has_error');
expect(queryB.filter?.expression).not.toContain('has_error');
expect(queryC.filter?.expression).not.toContain('has_error');
});
});
});

View File

@@ -0,0 +1,211 @@
import { render, screen } from '@testing-library/react';
import { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
import { SuccessResponse } from 'types/api';
import EndPointMetrics from '../Explorer/Domains/DomainDetails/components/EndPointMetrics';
import ErrorState from '../Explorer/Domains/DomainDetails/components/ErrorState';
// Create a partial mock of the UseQueryResult interface for testing
interface MockQueryResult {
isLoading: boolean;
isRefetching: boolean;
isError: boolean;
data?: any;
refetch: () => void;
}
// Mock the utils function
jest.mock('container/ApiMonitoring/utils', () => ({
getFormattedEndPointMetricsData: jest.fn(),
}));
// Mock the ErrorState component
jest.mock('../Explorer/Domains/DomainDetails/components/ErrorState', () => ({
__esModule: true,
default: jest.fn().mockImplementation(({ refetch }) => (
<div data-testid="error-state-mock">
<button type="button" data-testid="refetch-button" onClick={refetch}>
Retry
</button>
</div>
)),
}));
// Mock antd components
jest.mock('antd', () => {
const originalModule = jest.requireActual('antd');
return {
...originalModule,
Progress: jest
.fn()
.mockImplementation(() => <div data-testid="progress-bar-mock" />),
Skeleton: {
Button: jest
.fn()
.mockImplementation(() => <div data-testid="skeleton-button-mock" />),
},
Tooltip: jest
.fn()
.mockImplementation(({ children }) => (
<div data-testid="tooltip-mock">{children}</div>
)),
Typography: {
Text: jest.fn().mockImplementation(({ children, className }) => (
<div data-testid={`typography-${className}`} className={className}>
{children}
</div>
)),
},
};
});
describe('EndPointMetrics', () => {
// Common metric data to use in tests
const mockMetricsData = {
key: 'test-key',
rate: '42',
latency: 99,
errorRate: 5.5,
lastUsed: '5 minutes ago',
};
// Basic props for tests
const refetchFn = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
(getFormattedEndPointMetricsData as jest.Mock).mockReturnValue(
mockMetricsData,
);
});
it('renders loading state correctly', () => {
const mockQuery: MockQueryResult = {
isLoading: true,
isRefetching: false,
isError: false,
data: undefined,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify skeleton loaders are visible
const skeletonElements = screen.getAllByTestId('skeleton-button-mock');
expect(skeletonElements.length).toBe(4);
// Verify labels are visible even during loading
expect(screen.getByText('Rate')).toBeInTheDocument();
expect(screen.getByText('AVERAGE LATENCY')).toBeInTheDocument();
expect(screen.getByText('ERROR %')).toBeInTheDocument();
expect(screen.getByText('LAST USED')).toBeInTheDocument();
});
it('renders error state correctly', () => {
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: true,
data: undefined,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify error state is shown
expect(screen.getByTestId('error-state-mock')).toBeInTheDocument();
expect(ErrorState).toHaveBeenCalledWith(
{ refetch: expect.any(Function) },
expect.anything(),
);
});
it('renders data correctly when loaded', () => {
const mockData = {
payload: {
data: {
result: [
{
table: {
rows: [
{ data: { A: '42', B: '99000000', D: '1609459200000000', F1: '5.5' } },
],
},
},
],
},
},
} as SuccessResponse<any>;
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify the utils function was called with the data
expect(getFormattedEndPointMetricsData).toHaveBeenCalledWith(
mockData.payload.data.result[0].table.rows,
);
// Verify data is displayed
expect(
screen.getByText(`${mockMetricsData.rate} ops/sec`),
).toBeInTheDocument();
expect(screen.getByText(`${mockMetricsData.latency}ms`)).toBeInTheDocument();
expect(screen.getByText(mockMetricsData.lastUsed)).toBeInTheDocument();
expect(screen.getByTestId('progress-bar-mock')).toBeInTheDocument(); // For error rate
});
it('handles refetching state correctly', () => {
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: true,
isError: false,
data: undefined,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Verify skeleton loaders are visible during refetching
const skeletonElements = screen.getAllByTestId('skeleton-button-mock');
expect(skeletonElements.length).toBe(4);
});
it('handles null metrics data gracefully', () => {
// Mock the utils function to return null to simulate missing data
(getFormattedEndPointMetricsData as jest.Mock).mockReturnValue(null);
const mockData = {
payload: {
data: {
result: [
{
table: {
rows: [],
},
},
],
},
},
} as SuccessResponse<any>;
const mockQuery: MockQueryResult = {
isLoading: false,
isRefetching: false,
isError: false,
data: mockData,
refetch: refetchFn,
};
render(<EndPointMetrics endPointMetricsDataQuery={mockQuery as any} />);
// Even with null data, the component should render without crashing
expect(screen.getByText('Rate')).toBeInTheDocument();
});
});

View File

@@ -1,173 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/no-duplicate-string */
/**
* V5 Migration Tests for Endpoint Dropdown Query
*
* These tests validate the migration from V4 to V5 format for the third payload
* in getEndPointDetailsQueryPayload (endpoint dropdown data):
* - Filter format change: filters.items[] → filter.expression
* - Domain handling: (net.peer.name OR server.address)
* - Kind filter: kind_string = 'Client'
* - Existence check: (http.url EXISTS OR url.full EXISTS)
* - Aggregation: count() expression
* - GroupBy: Both http.url AND url.full with type 'attribute'
*/
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('EndpointDropdown - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration - Structure and Base Filters', () => {
it('migrates to V5 format with correct filter expression structure, aggregations, and groupBy', () => {
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
// Third payload is the endpoint dropdown query (index 2)
const dropdownQuery = payload[2];
const queryA = dropdownQuery.query.builder.queryData[0];
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
expect(queryA.filter).toBeDefined();
expect(queryA.filter?.expression).toBeDefined();
expect(typeof queryA.filter?.expression).toBe('string');
expect(queryA).not.toHaveProperty('filters');
// Base filter 1: Domain (net.peer.name OR server.address)
expect(queryA.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);
// Base filter 2: Kind
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
// Base filter 3: Existence check
expect(queryA.filter?.expression).toContain(
'(http.url EXISTS OR url.full EXISTS)',
);
// V5 Aggregation format: aggregations array (not aggregateAttribute)
expect(queryA.aggregations).toBeDefined();
expect(Array.isArray(queryA.aggregations)).toBe(true);
expect(queryA.aggregations?.[0]).toEqual({
expression: 'count()',
});
expect(queryA).not.toHaveProperty('aggregateAttribute');
// GroupBy: Both http.url and url.full
expect(queryA.groupBy).toHaveLength(2);
expect(queryA.groupBy).toContainEqual({
key: 'http.url',
dataType: 'string',
type: 'attribute',
});
expect(queryA.groupBy).toContainEqual({
key: 'url.full',
dataType: 'string',
type: 'attribute',
});
});
});
describe('2. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
customFilters,
);
const dropdownQuery = payload[2];
const expression =
dropdownQuery.query.builder.queryData[0].filter?.expression;
// Exact filter expression with custom filters merged
expect(expression).toBe(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com') AND kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) service.name = 'user-service' AND deployment.environment = 'production'",
);
});
});
describe('3. HTTP URL Filter Special Handling', () => {
it('converts http.url filter to (http.url OR url.full) expression', () => {
const filtersWithHttpUrl: IBuilderQuery['filters'] = {
items: [
{
id: 'http-url-filter',
key: {
key: 'http.url',
dataType: 'string' as any,
type: 'tag',
},
op: '=',
value: '/api/users',
},
{
id: 'service-filter',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
filtersWithHttpUrl,
);
const dropdownQuery = payload[2];
const expression =
dropdownQuery.query.builder.queryData[0].filter?.expression;
// CRITICAL: Exact filter expression with http.url converted to OR logic
expect(expression).toBe(
"(net.peer.name = 'api.example.com' OR server.address = 'api.example.com') AND kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) service.name = 'user-service' AND (http.url = '/api/users' OR url.full = '/api/users')",
);
});
});
});

View File

@@ -1,173 +0,0 @@
import {
getLatencyOverTimeWidgetData,
getRateOverTimeWidgetData,
} from 'container/ApiMonitoring/utils';
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('MetricOverTime - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
// eslint-disable-next-line sonarjs/no-duplicate-string
const mockEndpointName = '/api/users';
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. Rate Over Time - V5 Payload Structure', () => {
it('generates V5 filter expression format (not V3 filters.items)', () => {
const widget = getRateOverTimeWidgetData(
mockDomainName,
mockEndpointName,
emptyFilters,
);
const queryData = widget.query.builder.queryData[0];
// CRITICAL: Must use V5 format (filter.expression), not V3 format (filters.items)
expect(queryData.filter).toBeDefined();
expect(queryData?.filter?.expression).toBeDefined();
expect(typeof queryData?.filter?.expression).toBe('string');
// OLD V3 format should NOT exist
expect(queryData).not.toHaveProperty('filters.items');
});
it('uses new domain filter format: (net.peer.name OR server.address)', () => {
const widget = getRateOverTimeWidgetData(
mockDomainName,
mockEndpointName,
emptyFilters,
);
const queryData = widget.query.builder.queryData[0];
// Verify EXACT new filter format with OR operator
expect(queryData?.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);
// Endpoint name is used in legend, not filter
expect(queryData.legend).toContain('/api/users');
});
it('merges custom filters into filter expression', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
// eslint-disable-next-line sonarjs/no-duplicate-string
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
// eslint-disable-next-line sonarjs/no-duplicate-string
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const widget = getRateOverTimeWidgetData(
mockDomainName,
mockEndpointName,
customFilters,
);
const queryData = widget.query.builder.queryData[0];
// Verify domain filter is present
expect(queryData?.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);
// Verify custom filters are merged into the expression
expect(queryData?.filter?.expression).toContain('service.name');
expect(queryData?.filter?.expression).toContain('user-service');
expect(queryData?.filter?.expression).toContain('deployment.environment');
expect(queryData?.filter?.expression).toContain('production');
});
});
describe('2. Latency Over Time - V5 Payload Structure', () => {
it('generates V5 filter expression format (not V3 filters.items)', () => {
const widget = getLatencyOverTimeWidgetData(
mockDomainName,
mockEndpointName,
emptyFilters,
);
const queryData = widget.query.builder.queryData[0];
// CRITICAL: Must use V5 format (filter.expression), not V3 format (filters.items)
expect(queryData.filter).toBeDefined();
expect(queryData?.filter?.expression).toBeDefined();
expect(typeof queryData?.filter?.expression).toBe('string');
// OLD V3 format should NOT exist
expect(queryData).not.toHaveProperty('filters.items');
});
it('uses new domain filter format: (net.peer.name OR server.address)', () => {
const widget = getLatencyOverTimeWidgetData(
mockDomainName,
mockEndpointName,
emptyFilters,
);
const queryData = widget.query.builder.queryData[0];
// Verify EXACT new filter format with OR operator
expect(queryData.filter).toBeDefined();
expect(queryData?.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);
// Endpoint name is used in legend, not filter
expect(queryData.legend).toContain('/api/users');
});
it('merges custom filters into filter expression', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: DataTypes.String,
type: 'resource',
},
op: '=',
value: 'user-service',
},
],
op: 'AND',
};
const widget = getLatencyOverTimeWidgetData(
mockDomainName,
mockEndpointName,
customFilters,
);
const queryData = widget.query.builder.queryData[0];
// Verify domain filter is present
expect(queryData?.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}') service.name = 'user-service'`,
);
});
});
});

View File

@@ -1,237 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/no-duplicate-string */
/**
* V5 Migration Tests for Status Code Bar Chart Queries
*
* These tests validate the migration to V5 format for the bar chart payloads
* in getEndPointDetailsQueryPayload (5th and 6th payloads):
* - Number of Calls Chart (count aggregation)
* - Latency Chart (p99 aggregation)
*
* V5 Changes:
* - Filter format change: filters.items[] → filter.expression
* - Domain filter: (net.peer.name OR server.address)
* - Kind filter: kind_string = 'Client'
* - stepInterval: 60 → null
* - Grouped by response_status_code
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeBarCharts - V5 Migration Validation', () => {
const mockDomainName = '0.0.0.0';
const mockStartTime = 1762573673000;
const mockEndTime = 1762832873000;
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. Number of Calls Chart - V5 Payload Structure', () => {
it('generates correct V5 payload for count aggregation grouped by status code', () => {
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
// 5th payload (index 4) is the number of calls bar chart
const callsChartQuery = payload[4];
const queryA = callsChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
expect(queryA.filter).toBeDefined();
expect(queryA.filter?.expression).toBeDefined();
expect(typeof queryA.filter?.expression).toBe('string');
expect(queryA).not.toHaveProperty('filters.items');
// Base filter 1: Domain (net.peer.name OR server.address)
expect(queryA.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);
// Base filter 2: Kind
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
// Aggregation: count
expect(queryA.queryName).toBe('A');
expect(queryA.aggregateOperator).toBe('count');
expect(queryA.disabled).toBe(false);
// Grouped by response_status_code
expect(queryA.groupBy).toContainEqual(
expect.objectContaining({
key: 'response_status_code',
dataType: 'string',
type: 'span',
}),
);
// V5 critical: stepInterval should be null
expect(queryA.stepInterval).toBeNull();
// Time aggregation
expect(queryA.timeAggregation).toBe('rate');
});
});
describe('2. Latency Chart - V5 Payload Structure', () => {
it('generates correct V5 payload for p99 aggregation grouped by status code', () => {
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
// 6th payload (index 5) is the latency bar chart
const latencyChartQuery = payload[5];
const queryA = latencyChartQuery.query.builder.queryData[0];
// V5 format: filter.expression (not filters.items)
expect(queryA.filter).toBeDefined();
expect(queryA.filter?.expression).toBeDefined();
expect(typeof queryA.filter?.expression).toBe('string');
expect(queryA).not.toHaveProperty('filters.items');
// Base filter 1: Domain (net.peer.name OR server.address)
expect(queryA.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);
// Base filter 2: Kind
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
// Aggregation: p99 on duration_nano
expect(queryA.queryName).toBe('A');
expect(queryA.aggregateOperator).toBe('p99');
expect(queryA.aggregations?.[0]).toBeDefined();
expect((queryA.aggregations?.[0] as TraceAggregation)?.expression).toBe(
'p99(duration_nano)',
);
expect(queryA.disabled).toBe(false);
// Grouped by response_status_code
expect(queryA.groupBy).toContainEqual(
expect.objectContaining({
key: 'response_status_code',
dataType: 'string',
type: 'span',
}),
);
// V5 critical: stepInterval should be null
expect(queryA.stepInterval).toBeNull();
// Time aggregation
expect(queryA.timeAggregation).toBe('p99');
});
});
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression for both charts', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
customFilters,
);
const callsChartQuery = payload[4];
const latencyChartQuery = payload[5];
const callsExpression =
callsChartQuery.query.builder.queryData[0].filter?.expression;
const latencyExpression =
latencyChartQuery.query.builder.queryData[0].filter?.expression;
// Both charts should have the same filter expression
expect(callsExpression).toBe(latencyExpression);
// Verify base filters
expect(callsExpression).toContain('net.peer.name');
expect(callsExpression).toContain("kind_string = 'Client'");
// Verify custom filters are merged
expect(callsExpression).toContain('service.name');
expect(callsExpression).toContain('user-service');
expect(callsExpression).toContain('deployment.environment');
expect(callsExpression).toContain('production');
});
});
describe('4. HTTP URL Filter Handling', () => {
it('converts http.url filter to (http.url OR url.full) expression in both charts', () => {
const filtersWithHttpUrl: IBuilderQuery['filters'] = {
items: [
{
id: 'http-url-filter',
key: {
key: 'http.url',
dataType: 'string' as any,
type: 'tag',
},
op: '=',
value: '/api/metrics',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
filtersWithHttpUrl,
);
const callsChartQuery = payload[4];
const latencyChartQuery = payload[5];
const callsExpression =
callsChartQuery.query.builder.queryData[0].filter?.expression;
const latencyExpression =
latencyChartQuery.query.builder.queryData[0].filter?.expression;
// CRITICAL: http.url converted to OR logic
expect(callsExpression).toContain(
"(http.url = '/api/metrics' OR url.full = '/api/metrics')",
);
expect(latencyExpression).toContain(
"(http.url = '/api/metrics' OR url.full = '/api/metrics')",
);
// Base filters still present
expect(callsExpression).toContain('net.peer.name');
expect(callsExpression).toContain("kind_string = 'Client'");
});
});
});

View File

@@ -1,226 +0,0 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
/* eslint-disable sonarjs/no-duplicate-string */
/**
* V5 Migration Tests for Status Code Table Query
*
* These tests validate the migration from V4 to V5 format for the second payload
* in getEndPointDetailsQueryPayload (status code table data):
* - Filter format change: filters.items[] → filter.expression
* - URL handling: Special logic for (http.url OR url.full)
* - Domain filter: (net.peer.name OR server.address)
* - Kind filter: kind_string = 'Client'
* - Kind filter: response_status_code EXISTS
* - Three queries: A (count), B (p99 latency), C (rate)
* - All grouped by response_status_code
*/
import { TraceAggregation } from 'api/v5/v5';
import { getEndPointDetailsQueryPayload } from 'container/ApiMonitoring/utils';
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
describe('StatusCodeTable - V5 Migration Validation', () => {
const mockDomainName = 'api.example.com';
const mockStartTime = 1000;
const mockEndTime = 2000;
const emptyFilters: IBuilderQuery['filters'] = {
items: [],
op: 'AND',
};
describe('1. V5 Format Migration with Base Filters', () => {
it('migrates to V5 format with correct filter expression structure and base filters', () => {
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
// Second payload is the status code table query
const statusCodeQuery = payload[1];
const queryA = statusCodeQuery.query.builder.queryData[0];
// CRITICAL V5 MIGRATION: filter.expression (not filters.items)
expect(queryA.filter).toBeDefined();
expect(queryA.filter?.expression).toBeDefined();
expect(typeof queryA.filter?.expression).toBe('string');
expect(queryA).not.toHaveProperty('filters.items');
// Base filter 1: Domain (net.peer.name OR server.address)
expect(queryA.filter?.expression).toContain(
`(net.peer.name = '${mockDomainName}' OR server.address = '${mockDomainName}')`,
);
// Base filter 2: Kind
expect(queryA.filter?.expression).toContain("kind_string = 'Client'");
// Base filter 3: response_status_code EXISTS
expect(queryA.filter?.expression).toContain('response_status_code EXISTS');
});
});
describe('2. Three Queries Structure and Consistency', () => {
it('generates three queries (count, p99, rate) all grouped by response_status_code with identical filters', () => {
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
emptyFilters,
);
const statusCodeQuery = payload[1];
const [queryA, queryB, queryC] = statusCodeQuery.query.builder.queryData;
// Query A: Count
expect(queryA.queryName).toBe('A');
expect(queryA.aggregateOperator).toBe('count');
expect(queryA.aggregations?.[0]).toBeDefined();
expect((queryA.aggregations?.[0] as TraceAggregation)?.expression).toBe(
'count(span_id)',
);
expect(queryA.disabled).toBe(false);
// Query B: P99 Latency
expect(queryB.queryName).toBe('B');
expect(queryB.aggregateOperator).toBe('p99');
expect((queryB.aggregations?.[0] as TraceAggregation)?.expression).toBe(
'p99(duration_nano)',
);
expect(queryB.disabled).toBe(false);
// Query C: Rate
expect(queryC.queryName).toBe('C');
expect(queryC.aggregateOperator).toBe('rate');
expect(queryC.disabled).toBe(false);
// All group by response_status_code
[queryA, queryB, queryC].forEach((query) => {
expect(query.groupBy).toContainEqual(
expect.objectContaining({
key: 'response_status_code',
dataType: 'string',
type: 'span',
}),
);
});
// CRITICAL: All have identical filter expressions
expect(queryA.filter?.expression).toBe(queryB.filter?.expression);
expect(queryB.filter?.expression).toBe(queryC.filter?.expression);
});
});
describe('3. Custom Filters Integration', () => {
it('merges custom filters into filter expression with AND logic', () => {
const customFilters: IBuilderQuery['filters'] = {
items: [
{
id: 'test-1',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
{
id: 'test-2',
key: {
key: 'deployment.environment',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'production',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
customFilters,
);
const statusCodeQuery = payload[1];
const expression =
statusCodeQuery.query.builder.queryData[0].filter?.expression;
// Base filters present
expect(expression).toContain('net.peer.name');
expect(expression).toContain("kind_string = 'Client'");
expect(expression).toContain('response_status_code EXISTS');
// Custom filters merged
expect(expression).toContain('service.name');
expect(expression).toContain('user-service');
expect(expression).toContain('deployment.environment');
expect(expression).toContain('production');
// All three queries have the same merged expression
const queries = statusCodeQuery.query.builder.queryData;
expect(queries[0].filter?.expression).toBe(queries[1].filter?.expression);
expect(queries[1].filter?.expression).toBe(queries[2].filter?.expression);
});
});
describe('4. HTTP URL Filter Handling', () => {
it('converts http.url filter to (http.url OR url.full) expression', () => {
const filtersWithHttpUrl: IBuilderQuery['filters'] = {
items: [
{
id: 'http-url-filter',
key: {
key: 'http.url',
dataType: 'string' as any,
type: 'tag',
},
op: '=',
value: '/api/users',
},
{
id: 'service-filter',
key: {
key: 'service.name',
dataType: 'string' as any,
type: 'resource',
},
op: '=',
value: 'user-service',
},
],
op: 'AND',
};
const payload = getEndPointDetailsQueryPayload(
mockDomainName,
mockStartTime,
mockEndTime,
filtersWithHttpUrl,
);
const statusCodeQuery = payload[1];
const expression =
statusCodeQuery.query.builder.queryData[0].filter?.expression;
// CRITICAL: http.url converted to OR logic
expect(expression).toContain(
"(http.url = '/api/users' OR url.full = '/api/users')",
);
// Other filters still present
expect(expression).toContain('service.name');
expect(expression).toContain('user-service');
// Base filters present
expect(expression).toContain('net.peer.name');
expect(expression).toContain("kind_string = 'Client'");
expect(expression).toContain('response_status_code EXISTS');
// All ANDed together (at least 2 ANDs: domain+kind, custom filter, url condition)
expect(expression?.match(/AND/g)?.length).toBeGreaterThanOrEqual(2);
});
});
});

View File

@@ -1,11 +1,17 @@
import { BuilderQuery } from 'api/v5/v5'; import { fireEvent, render, screen, within } from '@testing-library/react';
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer'; import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
import { rest, server } from 'mocks-server/server'; import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils'; import {
formatTopErrorsDataForTable,
getEndPointDetailsQueryPayload,
getTopErrorsColumnsConfig,
getTopErrorsCoRelationQueryFilters,
getTopErrorsQueryPayload,
} from 'container/ApiMonitoring/utils';
import { useQueries } from 'react-query';
import { DataSource } from 'types/common/queryBuilder'; import { DataSource } from 'types/common/queryBuilder';
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors'; import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
import { getTopErrorsQueryPayload } from '../utils';
// Mock the EndPointsDropDown component to avoid issues // Mock the EndPointsDropDown component to avoid issues
jest.mock( jest.mock(
@@ -29,14 +35,26 @@ jest.mock(
}), }),
); );
// Mock dependencies
jest.mock('react-query', () => ({
...jest.requireActual('react-query'),
useQueries: jest.fn(),
}));
jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({ jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
useNavigateToExplorer: jest.fn(), useNavigateToExplorer: jest.fn(),
})); }));
describe('TopErrors', () => { jest.mock('container/ApiMonitoring/utils', () => ({
const TABLE_BODY_SELECTOR = '.ant-table-tbody'; END_POINT_DETAILS_QUERY_KEYS_ARRAY: ['key1', 'key2', 'key3', 'key4', 'key5'],
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range'; formatTopErrorsDataForTable: jest.fn(),
getEndPointDetailsQueryPayload: jest.fn(),
getTopErrorsColumnsConfig: jest.fn(),
getTopErrorsCoRelationQueryFilters: jest.fn(),
getTopErrorsQueryPayload: jest.fn(),
}));
describe('TopErrors', () => {
const mockProps = { const mockProps = {
// eslint-disable-next-line sonarjs/no-duplicate-string // eslint-disable-next-line sonarjs/no-duplicate-string
domainName: 'test-domain', domainName: 'test-domain',
@@ -50,72 +68,75 @@ describe('TopErrors', () => {
}, },
}; };
// Helper function to wait for table data to load // Setup basic mocks
const waitForTableDataToLoad = async (
container: HTMLElement,
): Promise<void> => {
await waitFor(() => {
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
expect(tableBody).not.toBeNull();
if (tableBody) {
expect(
within(tableBody as HTMLElement).queryByText('/api/test'),
).toBeInTheDocument();
}
});
};
beforeEach(() => { beforeEach(() => {
jest.clearAllMocks(); jest.clearAllMocks();
// Mock useNavigateToExplorer // Mock getTopErrorsColumnsConfig
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn()); (getTopErrorsColumnsConfig as jest.Mock).mockReturnValue([
{
title: 'Endpoint',
dataIndex: 'endpointName',
key: 'endpointName',
},
{
title: 'Status Code',
dataIndex: 'statusCode',
key: 'statusCode',
},
{
title: 'Status Message',
dataIndex: 'statusMessage',
key: 'statusMessage',
},
{
title: 'Count',
dataIndex: 'count',
key: 'count',
},
]);
// Mock V5 API endpoint for top errors // Mock useQueries
server.use( (useQueries as jest.Mock).mockImplementation((queryConfigs) => {
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) => // For topErrorsDataQueries
res( if (
ctx.status(200), queryConfigs.length === 1 &&
ctx.json({ queryConfigs[0].queryKey &&
queryConfigs[0].queryKey[0] === REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN
) {
return [
{
data: { data: {
data: { payload: {
results: [ data: {
{ result: [
columns: [ {
{ metric: {
name: 'http.url', 'http.url': '/api/test',
fieldDataType: 'string', status_code: '500',
fieldContext: 'attribute', // eslint-disable-next-line sonarjs/no-duplicate-string
status_message: 'Internal Server Error',
}, },
{ values: [[1000000100, '10']],
name: 'response_status_code', queryName: 'A',
fieldDataType: 'string', legend: 'Test Legend',
fieldContext: 'span', },
}, ],
{ },
name: 'status_message',
fieldDataType: 'string',
fieldContext: 'span',
},
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
],
// eslint-disable-next-line sonarjs/no-duplicate-string
data: [['/api/test', '500', 'Internal Server Error', 10]],
},
],
}, },
}, },
}), isLoading: false,
), isRefetching: false,
), isError: false,
); refetch: jest.fn(),
},
];
}
// Mock V4 API endpoint for dropdown data // For endPointDropDownDataQueries
server.use( return [
rest.post('*/api/v1/query_range', (_req, res, ctx) => {
res( data: {
ctx.status(200),
ctx.json({
payload: { payload: {
data: { data: {
result: [ result: [
@@ -132,13 +153,62 @@ describe('TopErrors', () => {
], ],
}, },
}, },
}), },
), isLoading: false,
), isRefetching: false,
); isError: false,
},
];
});
// Mock formatTopErrorsDataForTable
(formatTopErrorsDataForTable as jest.Mock).mockReturnValue([
{
key: '1',
endpointName: '/api/test',
statusCode: '500',
statusMessage: 'Internal Server Error',
count: 10,
},
]);
// Mock getTopErrorsQueryPayload
(getTopErrorsQueryPayload as jest.Mock).mockReturnValue([
{
queryName: 'TopErrorsQuery',
start: mockProps.timeRange.startTime,
end: mockProps.timeRange.endTime,
step: 60,
},
]);
// Mock getEndPointDetailsQueryPayload
(getEndPointDetailsQueryPayload as jest.Mock).mockReturnValue([
{},
{},
{
queryName: 'EndpointDropdownQuery',
start: mockProps.timeRange.startTime,
end: mockProps.timeRange.endTime,
step: 60,
},
]);
// Mock useNavigateToExplorer
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
// Mock getTopErrorsCoRelationQueryFilters
(getTopErrorsCoRelationQueryFilters as jest.Mock).mockReturnValue({
items: [
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
],
op: 'AND',
});
}); });
it('renders component correctly', async () => { it('renders component correctly', () => {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
const { container } = render(<TopErrors {...mockProps} />); const { container } = render(<TopErrors {...mockProps} />);
@@ -146,11 +216,10 @@ describe('TopErrors', () => {
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument(); expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
expect(screen.getByText('Status Message Exists')).toBeInTheDocument(); expect(screen.getByText('Status Message Exists')).toBeInTheDocument();
// Wait for data to load
await waitForTableDataToLoad(container);
// Find the table row and verify content // Find the table row and verify content
const tableBody = container.querySelector(TABLE_BODY_SELECTOR); const tableBody = container.querySelector('.ant-table-tbody');
expect(tableBody).not.toBeNull();
if (tableBody) { if (tableBody) {
const row = within(tableBody as HTMLElement).getByRole('row'); const row = within(tableBody as HTMLElement).getByRole('row');
expect(within(row).getByText('/api/test')).toBeInTheDocument(); expect(within(row).getByText('/api/test')).toBeInTheDocument();
@@ -159,40 +228,35 @@ describe('TopErrors', () => {
} }
}); });
it('renders error state when API fails', async () => { it('renders error state when isError is true', () => {
// Mock API to return error // Mock useQueries to return isError: true
server.use( (useQueries as jest.Mock).mockImplementationOnce(() => [
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) => {
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })), isError: true,
), refetch: jest.fn(),
); },
]);
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />); render(<TopErrors {...mockProps} />);
// Wait for error state // Error state should be shown with the actual text displayed in the UI
await waitFor(() => { expect(
expect( screen.getByText('Uh-oh :/ We ran into an error.'),
screen.getByText('Uh-oh :/ We ran into an error.'), ).toBeInTheDocument();
).toBeInTheDocument();
});
expect(screen.getByText('Please refresh this panel.')).toBeInTheDocument(); expect(screen.getByText('Please refresh this panel.')).toBeInTheDocument();
expect(screen.getByText('Refresh this panel')).toBeInTheDocument(); expect(screen.getByText('Refresh this panel')).toBeInTheDocument();
}); });
it('handles row click correctly', async () => { it('handles row click correctly', () => {
const navigateMock = jest.fn(); const navigateMock = jest.fn();
(useNavigateToExplorer as jest.Mock).mockReturnValue(navigateMock); (useNavigateToExplorer as jest.Mock).mockReturnValue(navigateMock);
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
const { container } = render(<TopErrors {...mockProps} />); const { container } = render(<TopErrors {...mockProps} />);
// Wait for data to load
await waitForTableDataToLoad(container);
// Find and click on the table cell containing the endpoint // Find and click on the table cell containing the endpoint
const tableBody = container.querySelector(TABLE_BODY_SELECTOR); const tableBody = container.querySelector('.ant-table-tbody');
expect(tableBody).not.toBeNull(); expect(tableBody).not.toBeNull();
if (tableBody) { if (tableBody) {
@@ -203,28 +267,11 @@ describe('TopErrors', () => {
// Check if navigateToExplorer was called with correct params // Check if navigateToExplorer was called with correct params
expect(navigateMock).toHaveBeenCalledWith({ expect(navigateMock).toHaveBeenCalledWith({
filters: expect.arrayContaining([ filters: [
expect.objectContaining({ { id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
key: expect.objectContaining({ key: 'http.url' }), { id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
op: '=', { id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
value: '/api/test', ],
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'has_error' }),
op: '=',
value: 'true',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'net.peer.name' }),
op: '=',
value: 'test-domain',
}),
expect.objectContaining({
key: expect.objectContaining({ key: 'response_status_code' }),
op: '=',
value: '500',
}),
]),
dataSource: DataSource.TRACES, dataSource: DataSource.TRACES,
startTime: mockProps.timeRange.startTime, startTime: mockProps.timeRange.startTime,
endTime: mockProps.timeRange.endTime, endTime: mockProps.timeRange.endTime,
@@ -232,34 +279,24 @@ describe('TopErrors', () => {
}); });
}); });
it('updates endpoint filter when dropdown value changes', async () => { it('updates endpoint filter when dropdown value changes', () => {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />); render(<TopErrors {...mockProps} />);
// Wait for initial load
await waitFor(() => {
expect(screen.getByRole('combobox')).toBeInTheDocument();
});
// Find the dropdown // Find the dropdown
const dropdown = screen.getByRole('combobox'); const dropdown = screen.getByRole('combobox');
// Mock the change // Mock the change
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } }); fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
// Component should re-render with new filter // Check if getTopErrorsQueryPayload was called with updated parameters
expect(dropdown).toBeInTheDocument(); expect(getTopErrorsQueryPayload).toHaveBeenCalled();
}); });
it('handles status message toggle correctly', async () => { it('handles status message toggle correctly', () => {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />); render(<TopErrors {...mockProps} />);
// Wait for initial load
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
});
// Find the toggle switch // Find the toggle switch
const toggle = screen.getByRole('switch'); const toggle = screen.getByRole('switch');
expect(toggle).toBeInTheDocument(); expect(toggle).toBeInTheDocument();
@@ -270,71 +307,69 @@ describe('TopErrors', () => {
// Click the toggle to turn it off // Click the toggle to turn it off
fireEvent.click(toggle); fireEvent.click(toggle);
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=false
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
mockProps.domainName,
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.any(Object),
false,
);
// Title should change // Title should change
await waitFor(() => { expect(screen.getByText('All Errors')).toBeInTheDocument();
expect(screen.getByText('All Errors')).toBeInTheDocument();
});
// Click the toggle to turn it back on // Click the toggle to turn it back on
fireEvent.click(toggle); fireEvent.click(toggle);
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=true
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
mockProps.domainName,
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
expect.any(Object),
true,
);
// Title should change back // Title should change back
await waitFor(() => { expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
});
}); });
it('includes toggle state in query key for cache busting', async () => { it('includes toggle state in query key for cache busting', () => {
// eslint-disable-next-line react/jsx-props-no-spreading // eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />); render(<TopErrors {...mockProps} />);
// Wait for initial load
await waitFor(() => {
expect(screen.getByRole('switch')).toBeInTheDocument();
});
const toggle = screen.getByRole('switch'); const toggle = screen.getByRole('switch');
// Initial query should include showStatusCodeErrors=true
expect(useQueries).toHaveBeenCalledWith(
expect.arrayContaining([
expect.objectContaining({
queryKey: expect.arrayContaining([
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
expect.any(Object),
expect.any(String),
true,
]),
}),
]),
);
// Click toggle // Click toggle
fireEvent.click(toggle); fireEvent.click(toggle);
// Wait for title to change, indicating query was refetched with new key // Query should be called with showStatusCodeErrors=false in key
await waitFor(() => { expect(useQueries).toHaveBeenCalledWith(
expect(screen.getByText('All Errors')).toBeInTheDocument(); expect.arrayContaining([
}); expect.objectContaining({
queryKey: expect.arrayContaining([
// The fact that data refetches when toggle changes proves the query key includes the toggle state REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
expect(toggle).toBeInTheDocument(); expect.any(Object),
}); expect.any(String),
false,
it('sends query_range v5 API call with required filters including has_error', async () => { ]),
// let capturedRequest: any; }),
]),
const topErrorsPayload = getTopErrorsQueryPayload(
'test-domain',
mockProps.timeRange.startTime,
mockProps.timeRange.endTime,
{ items: [], op: 'AND' },
false,
);
// eslint-disable-next-line react/jsx-props-no-spreading
render(<TopErrors {...mockProps} />);
// Wait for the API call to be made
await waitFor(() => {
expect(topErrorsPayload).toBeDefined();
});
// Extract the filter expression from the captured request
// getTopErrorsQueryPayload returns a builder_query with TraceBuilderQuery spec
const builderQuery = topErrorsPayload.compositeQuery.queries[0]
.spec as BuilderQuery;
const filterExpression = builderQuery.filter?.expression;
// Verify all required filters are present
expect(filterExpression).toContain(
`kind_string = 'Client' AND (http.url EXISTS OR url.full EXISTS) AND (net.peer.name = 'test-domain' OR server.address = 'test-domain') AND has_error = true`,
); );
}); });
}); });

File diff suppressed because it is too large Load Diff

View File

@@ -112,8 +112,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
setShowPaymentFailedWarning, setShowPaymentFailedWarning,
] = useState<boolean>(false); ] = useState<boolean>(false);
const errorBoundaryRef = useRef<Sentry.ErrorBoundary>(null);
const [showSlowApiWarning, setShowSlowApiWarning] = useState(false); const [showSlowApiWarning, setShowSlowApiWarning] = useState(false);
const [slowApiWarningShown, setSlowApiWarningShown] = useState(false); const [slowApiWarningShown, setSlowApiWarningShown] = useState(false);
@@ -380,13 +378,6 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
getChangelogByVersionResponse.isSuccess, getChangelogByVersionResponse.isSuccess,
]); ]);
// reset error boundary on route change
useEffect(() => {
if (errorBoundaryRef.current) {
errorBoundaryRef.current.resetErrorBoundary();
}
}, [pathname]);
const isToDisplayLayout = isLoggedIn; const isToDisplayLayout = isLoggedIn;
const routeKey = useMemo(() => getRouteKey(pathname), [pathname]); const routeKey = useMemo(() => getRouteKey(pathname), [pathname]);
@@ -845,10 +836,7 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
})} })}
data-overlayscrollbars-initialize data-overlayscrollbars-initialize
> >
<Sentry.ErrorBoundary <Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
fallback={<ErrorBoundaryFallback />}
ref={errorBoundaryRef}
>
<LayoutContent data-overlayscrollbars-initialize> <LayoutContent data-overlayscrollbars-initialize>
<OverlayScrollbar> <OverlayScrollbar>
<ChildrenContainer> <ChildrenContainer>

View File

@@ -57,8 +57,7 @@ describe('Request AWS integration', () => {
expect(capturedPayload.attributes).toEqual({ expect(capturedPayload.attributes).toEqual({
screen: 'AWS integration details', screen: 'AWS integration details',
integration: 's3 sync', integration: 's3 sync',
deployment_url: 'localhost', tenant_url: 'localhost',
user_email: null,
}); });
}); });
}); });

View File

@@ -11,14 +11,12 @@ import { v4 } from 'uuid';
import { useCreateAlertState } from '../context'; import { useCreateAlertState } from '../context';
import { import {
INITIAL_EVALUATION_WINDOW_STATE,
INITIAL_INFO_THRESHOLD, INITIAL_INFO_THRESHOLD,
INITIAL_RANDOM_THRESHOLD, INITIAL_RANDOM_THRESHOLD,
INITIAL_WARNING_THRESHOLD, INITIAL_WARNING_THRESHOLD,
THRESHOLD_MATCH_TYPE_OPTIONS, THRESHOLD_MATCH_TYPE_OPTIONS,
THRESHOLD_OPERATOR_OPTIONS, THRESHOLD_OPERATOR_OPTIONS,
} from '../context/constants'; } from '../context/constants';
import { AlertThresholdMatchType } from '../context/types';
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings'; import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
import ThresholdItem from './ThresholdItem'; import ThresholdItem from './ThresholdItem';
import { AnomalyAndThresholdProps, UpdateThreshold } from './types'; import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
@@ -40,12 +38,12 @@ function AlertThreshold({
alertState, alertState,
thresholdState, thresholdState,
setThresholdState, setThresholdState,
setEvaluationWindow,
notificationSettings, notificationSettings,
setNotificationSettings, setNotificationSettings,
} = useCreateAlertState(); } = useCreateAlertState();
const { currentQuery } = useQueryBuilder(); const { currentQuery } = useQueryBuilder();
const queryNames = getQueryNames(currentQuery); const queryNames = getQueryNames(currentQuery);
useEffect(() => { useEffect(() => {
@@ -162,54 +160,6 @@ function AlertThreshold({
}), }),
); );
const handleSetEvaluationDetailsForMeter = (): void => {
setEvaluationWindow({
type: 'SET_INITIAL_STATE_FOR_METER',
});
setThresholdState({
type: 'SET_MATCH_TYPE',
payload: AlertThresholdMatchType.IN_TOTAL,
});
};
const handleSelectedQueryChange = (value: string): void => {
// loop through currenttQuery and find the query that matches the selected query
const query = currentQuery?.builder?.queryData.find(
(query) => query.queryName === value,
);
const currentSelectedQuery = currentQuery?.builder?.queryData.find(
(query) => query.queryName === thresholdState.selectedQuery,
);
const newSelectedQuerySource = query?.source || '';
const currentSelectedQuerySource = currentSelectedQuery?.source || '';
if (newSelectedQuerySource === currentSelectedQuerySource) {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
return;
}
if (newSelectedQuerySource === 'meter') {
handleSetEvaluationDetailsForMeter();
} else {
setEvaluationWindow({
type: 'SET_INITIAL_STATE',
payload: INITIAL_EVALUATION_WINDOW_STATE,
});
}
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
};
return ( return (
<div <div
className={classNames( className={classNames(
@@ -225,7 +175,12 @@ function AlertThreshold({
</Typography.Text> </Typography.Text>
<Select <Select
value={thresholdState.selectedQuery} value={thresholdState.selectedQuery}
onChange={handleSelectedQueryChange} onChange={(value): void => {
setThresholdState({
type: 'SET_SELECTED_QUERY',
payload: value,
});
}}
style={{ width: 80 }} style={{ width: 80 }}
options={queryNames} options={queryNames}
data-testid="alert-threshold-query-select" data-testid="alert-threshold-query-select"

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