Compare commits
56 Commits
tvats-hand
...
v0.103.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ffa5a9725e | ||
|
|
92cab8e049 | ||
|
|
7b9e6e3cbb | ||
|
|
4837ddb601 | ||
|
|
9c818955af | ||
|
|
134a051196 | ||
|
|
c904ab5d99 | ||
|
|
d53f9a7e16 | ||
|
|
1b01b61026 | ||
|
|
95a26cecba | ||
|
|
15af828005 | ||
|
|
e5b99703ac | ||
|
|
f0941c7b2e | ||
|
|
12c9b921a7 | ||
|
|
52228bc6c4 | ||
|
|
79988b448f | ||
|
|
4bfd7ba3d7 | ||
|
|
3349158213 | ||
|
|
1c9f4efb9f | ||
|
|
fd839ff1db | ||
|
|
09cbe4aa0d | ||
|
|
096e38ee91 | ||
|
|
48590c03e2 | ||
|
|
38af897bcc | ||
|
|
2b79678e63 | ||
|
|
a4f54baf1f | ||
|
|
4e6c42dd17 | ||
|
|
39bd169b89 | ||
|
|
c7c2d2a7ef | ||
|
|
0cfb809605 | ||
|
|
6a378ed7b4 | ||
|
|
8e41847523 | ||
|
|
779df62093 | ||
|
|
3763794531 | ||
|
|
e9fa68e1f3 | ||
|
|
7bd3e1c453 | ||
|
|
a48455b2b3 | ||
|
|
fbb66f14ba | ||
|
|
54b67d9cfd | ||
|
|
1a193015a7 | ||
|
|
245179cbf7 | ||
|
|
dbb6b333c8 | ||
|
|
56f8e53d88 | ||
|
|
2f4e371dac | ||
|
|
db75ec56bc | ||
|
|
02755a6527 | ||
|
|
9f089e0784 | ||
|
|
fb9a7ad3cd | ||
|
|
ad631d70b6 | ||
|
|
c44efeab33 | ||
|
|
e9743fa7ac | ||
|
|
b7ece08d3e | ||
|
|
e5f4f5cc72 | ||
|
|
4437630127 | ||
|
|
89639b239e | ||
|
|
785ae9f0bd |
@@ -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.8
|
image: signoz/signoz-schema-migrator:v0.129.12
|
||||||
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.8
|
image: signoz/signoz-schema-migrator:v0.129.12
|
||||||
container_name: schema-migrator-async
|
container_name: schema-migrator-async
|
||||||
command:
|
command:
|
||||||
- async
|
- async
|
||||||
|
|||||||
1
.github/workflows/build-enterprise.yaml
vendored
1
.github/workflows/build-enterprise.yaml
vendored
@@ -69,6 +69,7 @@ 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:
|
||||||
|
|||||||
1
.github/workflows/build-staging.yaml
vendored
1
.github/workflows/build-staging.yaml
vendored
@@ -68,6 +68,7 @@ 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:
|
||||||
|
|||||||
1
.github/workflows/gor-signoz.yaml
vendored
1
.github/workflows/gor-signoz.yaml
vendored
@@ -35,6 +35,7 @@ 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
|
||||||
|
|||||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -18,6 +18,7 @@ jobs:
|
|||||||
- passwordauthn
|
- passwordauthn
|
||||||
- callbackauthn
|
- callbackauthn
|
||||||
- cloudintegrations
|
- cloudintegrations
|
||||||
|
- dashboard
|
||||||
- querier
|
- querier
|
||||||
- ttl
|
- ttl
|
||||||
sqlstore-provider:
|
sqlstore-provider:
|
||||||
|
|||||||
12
Makefile
12
Makefile
@@ -84,10 +84,9 @@ 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 \
|
$(GO_BUILD_CONTEXT_ENTERPRISE)/*.go server
|
||||||
--config ./conf/prometheus.yml \
|
|
||||||
--cluster cluster
|
|
||||||
|
|
||||||
.PHONY: go-test
|
.PHONY: go-test
|
||||||
go-test: ## Runs go unit tests
|
go-test: ## Runs go unit tests
|
||||||
@@ -102,10 +101,9 @@ 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
|
||||||
@@ -208,4 +206,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/
|
||||||
|
|||||||
@@ -5,9 +5,12 @@ 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"
|
||||||
@@ -76,6 +79,9 @@ 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)
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ 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"
|
||||||
@@ -17,6 +19,7 @@ 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"
|
||||||
@@ -105,6 +108,9 @@ 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)
|
||||||
|
|||||||
@@ -47,10 +47,10 @@ cache:
|
|||||||
provider: memory
|
provider: memory
|
||||||
# memory: Uses in-memory caching.
|
# memory: Uses in-memory caching.
|
||||||
memory:
|
memory:
|
||||||
# Time-to-live for cache entries in memory. Specify the duration in ns
|
# Max items for the in-memory cache (10x the entries)
|
||||||
ttl: 60000000000
|
num_counters: 100000
|
||||||
# The interval at which the cache will be cleaned up
|
# Total cost in bytes allocated bounded cache
|
||||||
cleanup_interval: 1m
|
max_cost: 67108864
|
||||||
# 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.
|
||||||
|
|||||||
@@ -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.100.1
|
image: signoz/signoz:v0.103.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.8
|
image: signoz/signoz-otel-collector:v0.129.12
|
||||||
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.8
|
image: signoz/signoz-schema-migrator:v0.129.12
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
|||||||
@@ -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.100.1
|
image: signoz/signoz:v0.103.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.8
|
image: signoz/signoz-otel-collector:v0.129.12
|
||||||
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.8
|
image: signoz/signoz-schema-migrator:v0.129.12
|
||||||
deploy:
|
deploy:
|
||||||
restart_policy:
|
restart_policy:
|
||||||
condition: on-failure
|
condition: on-failure
|
||||||
|
|||||||
@@ -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.100.1}
|
image: signoz/signoz:${VERSION:-v0.103.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.8}
|
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.12}
|
||||||
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.8}
|
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12}
|
||||||
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.8}
|
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12}
|
||||||
container_name: schema-migrator-async
|
container_name: schema-migrator-async
|
||||||
command:
|
command:
|
||||||
- async
|
- async
|
||||||
|
|||||||
@@ -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.100.1}
|
image: signoz/signoz:${VERSION:-v0.103.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.8}
|
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.12}
|
||||||
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.8}
|
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12}
|
||||||
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.8}
|
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.12}
|
||||||
container_name: schema-migrator-async
|
container_name: schema-migrator-async
|
||||||
command:
|
command:
|
||||||
- async
|
- async
|
||||||
|
|||||||
@@ -103,9 +103,19 @@ 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 docker compose up -d
|
ENVOY_PORT=8081 PROMETHEUS_PORT=9091 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
|
||||||
|
|||||||
@@ -48,7 +48,26 @@ 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.TypeUser, claims.UserID, authtypes.Relation{})
|
subject, err := authtypes.NewSubject(authtypes.TypeableUser, claims.UserID, orgID, nil)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
tuples, err := typeable.Tuples(subject, relation, selectors, orgID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
err = provider.BatchCheck(ctx, tuples)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (provider *provider) 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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,18 +15,18 @@ type anonymous
|
|||||||
|
|
||||||
type role
|
type role
|
||||||
relations
|
relations
|
||||||
define assignee: [user]
|
define assignee: [user, anonymous]
|
||||||
|
|
||||||
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 resources
|
type metaresources
|
||||||
relations
|
relations
|
||||||
define create: [user, role#assignee]
|
define create: [user, role#assignee]
|
||||||
define list: [user, role#assignee]
|
define list: [user, role#assignee]
|
||||||
|
|
||||||
type resource
|
type metaresource
|
||||||
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 resource
|
|||||||
define block: [user, role#assignee]
|
define block: [user, role#assignee]
|
||||||
|
|
||||||
|
|
||||||
type telemetry
|
type telemetryresource
|
||||||
relations
|
relations
|
||||||
define read: [user, anonymous, role#assignee]
|
define read: [user, role#assignee]
|
||||||
|
|||||||
@@ -20,6 +20,10 @@ 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"
|
||||||
)
|
)
|
||||||
@@ -99,6 +103,39 @@ 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)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ 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"
|
||||||
@@ -74,13 +75,26 @@ 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(
|
||||||
@@ -192,7 +206,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())
|
am := middleware.NewAuthZ(s.signoz.Instrumentation.Logger(), s.signoz.Modules.OrgGetter, s.signoz.Authz)
|
||||||
|
|
||||||
r.Use(otelmux.Middleware(
|
r.Use(otelmux.Middleware(
|
||||||
"apiserver",
|
"apiserver",
|
||||||
|
|||||||
@@ -246,7 +246,9 @@ func (r *AnomalyRule) buildAndRunQuery(ctx context.Context, orgID valuer.UUID, t
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
|
||||||
|
ActiveAlerts: r.ActiveAlertsLabelFP(),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -296,7 +298,9 @@ func (r *AnomalyRule) buildAndRunQueryV5(ctx context.Context, orgID valuer.UUID,
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
results, err := r.Threshold.ShouldAlert(*series, r.Unit())
|
results, err := r.Threshold.Eval(*series, r.Unit(), ruletypes.EvalData{
|
||||||
|
ActiveAlerts: r.ActiveAlertsLabelFP(),
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -410,6 +414,7 @@ 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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,6 +427,9 @@ 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]
|
||||||
}
|
}
|
||||||
@@ -480,6 +488,30 @@ 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()
|
||||||
|
|||||||
@@ -30,6 +30,8 @@ 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)
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ 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"
|
||||||
@@ -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.54.1",
|
"@playwright/test": "1.55.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,6 +83,7 @@
|
|||||||
"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",
|
||||||
@@ -112,7 +113,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.215.5",
|
"posthog-js": "1.298.0",
|
||||||
"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",
|
||||||
@@ -149,7 +150,6 @@
|
|||||||
"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,6 +186,7 @@
|
|||||||
"@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",
|
||||||
@@ -280,6 +281,7 @@
|
|||||||
"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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ 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';
|
||||||
@@ -33,7 +34,6 @@ 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,
|
||||||
tenant_id: hostNameParts[0],
|
deployment_name: hostNameParts[0],
|
||||||
data_region: hostNameParts[1],
|
data_region: hostNameParts[1],
|
||||||
tenant_url: hostname,
|
deployment_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,
|
||||||
tenant_id: hostNameParts[0],
|
deployment_name: hostNameParts[0],
|
||||||
data_region: hostNameParts[1],
|
data_region: hostNameParts[1],
|
||||||
tenant_url: hostname,
|
deployment_url: hostname,
|
||||||
company_domain: domain,
|
company_domain: domain,
|
||||||
source: 'signoz-ui',
|
source: 'signoz-ui',
|
||||||
};
|
};
|
||||||
@@ -111,37 +111,23 @@ 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],
|
||||||
tenant_url: hostname,
|
deployment_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,
|
||||||
tenant_id: hostNameParts[0],
|
deployment_name: hostNameParts[0],
|
||||||
data_region: hostNameParts[1],
|
data_region: hostNameParts[1],
|
||||||
tenant_url: hostname,
|
deployment_url: hostname,
|
||||||
company_domain: domain,
|
company_domain: domain,
|
||||||
source: 'signoz-ui',
|
source: 'signoz-ui',
|
||||||
isPaidUser: !!trialInfo?.trialConvertedToSubscription,
|
isPaidUser: !!trialInfo?.trialConvertedToSubscription,
|
||||||
@@ -149,9 +135,9 @@ function App(): JSX.Element {
|
|||||||
|
|
||||||
posthog?.group('company', orgId, {
|
posthog?.group('company', orgId, {
|
||||||
name: orgName,
|
name: orgName,
|
||||||
tenant_id: hostNameParts[0],
|
deployment_name: hostNameParts[0],
|
||||||
data_region: hostNameParts[1],
|
data_region: hostNameParts[1],
|
||||||
tenant_url: hostname,
|
deployment_url: hostname,
|
||||||
company_domain: domain,
|
company_domain: domain,
|
||||||
source: 'signoz-ui',
|
source: 'signoz-ui',
|
||||||
isPaidUser: !!trialInfo?.trialConvertedToSubscription,
|
isPaidUser: !!trialInfo?.trialConvertedToSubscription,
|
||||||
@@ -270,11 +256,20 @@ 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,
|
name: user.displayName || user.email,
|
||||||
|
email_hash: emailHash,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -308,10 +303,6 @@ 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,
|
||||||
@@ -372,7 +363,6 @@ function App(): JSX.Element {
|
|||||||
<Router history={history}>
|
<Router history={history}>
|
||||||
<CompatRouter>
|
<CompatRouter>
|
||||||
<KBarCommandPaletteProvider>
|
<KBarCommandPaletteProvider>
|
||||||
<UserpilotRouteTracker />
|
|
||||||
<KBarCommandPalette />
|
<KBarCommandPalette />
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
<ErrorModalProvider>
|
<ErrorModalProvider>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import { ApiBaseInstance as axios } from 'api';
|
import { LogEventAxiosInstance 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';
|
||||||
|
|
||||||
@@ -11,9 +13,14 @@ const logEvent = async (
|
|||||||
rateLimited?: boolean,
|
rateLimited?: boolean,
|
||||||
): Promise<SuccessResponse<EventSuccessPayloadProps> | ErrorResponse> => {
|
): Promise<SuccessResponse<EventSuccessPayloadProps> | ErrorResponse> => {
|
||||||
try {
|
try {
|
||||||
// add tenant_url to attributes
|
// add deployment_url and user_email to attributes
|
||||||
const { hostname } = window.location;
|
const { hostname } = window.location;
|
||||||
const updatedAttributes = { ...attributes, tenant_url: hostname };
|
const userEmail = getLocalStorageApi(LOCALSTORAGE.LOGGED_IN_USER_EMAIL);
|
||||||
|
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,
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
/* eslint-disable sonarjs/no-duplicate-string */
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
import { ApiBaseInstance } from 'api';
|
import axios from 'api';
|
||||||
|
|
||||||
import { getFieldKeys } from '../getFieldKeys';
|
import { getFieldKeys } from '../getFieldKeys';
|
||||||
|
|
||||||
// Mock the API instance
|
// Mock the API instance
|
||||||
jest.mock('api', () => ({
|
jest.mock('api', () => ({
|
||||||
ApiBaseInstance: {
|
get: jest.fn(),
|
||||||
get: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('getFieldKeys API', () => {
|
describe('getFieldKeys API', () => {
|
||||||
@@ -31,33 +29,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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
(axios.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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
(axios.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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -72,14 +70,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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -94,14 +92,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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/keys', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
(axios.get as jest.Mock).mockResolvedValueOnce(mockSuccessResponse);
|
||||||
|
|
||||||
// Call the function
|
// Call the function
|
||||||
const result = await getFieldKeys('traces');
|
const result = await getFieldKeys('traces');
|
||||||
|
|||||||
@@ -1,13 +1,11 @@
|
|||||||
/* eslint-disable sonarjs/no-duplicate-string */
|
/* eslint-disable sonarjs/no-duplicate-string */
|
||||||
import { ApiBaseInstance } from 'api';
|
import axios from 'api';
|
||||||
|
|
||||||
import { getFieldValues } from '../getFieldValues';
|
import { getFieldValues } from '../getFieldValues';
|
||||||
|
|
||||||
// Mock the API instance
|
// Mock the API instance
|
||||||
jest.mock('api', () => ({
|
jest.mock('api', () => ({
|
||||||
ApiBaseInstance: {
|
get: jest.fn(),
|
||||||
get: jest.fn(),
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
describe('getFieldValues API', () => {
|
describe('getFieldValues API', () => {
|
||||||
@@ -17,7 +15,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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -34,14 +32,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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -58,14 +56,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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -82,14 +80,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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -106,14 +104,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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
expect(axios.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
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce({
|
(axios.get as jest.Mock).mockResolvedValueOnce({
|
||||||
status: 200,
|
status: 200,
|
||||||
data: {
|
data: {
|
||||||
status: 'success',
|
status: 'success',
|
||||||
@@ -138,7 +136,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(ApiBaseInstance.get).toHaveBeenCalledWith('/fields/values', {
|
expect(axios.get).toHaveBeenCalledWith('/fields/values', {
|
||||||
params: {
|
params: {
|
||||||
signal: 'logs',
|
signal: 'logs',
|
||||||
name: 'service.name',
|
name: 'service.name',
|
||||||
@@ -165,7 +163,7 @@ describe('getFieldValues API', () => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockResponse);
|
(axios.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');
|
||||||
@@ -196,7 +194,7 @@ describe('getFieldValues API', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Mock API to return our response
|
// Mock API to return our response
|
||||||
(ApiBaseInstance.get as jest.Mock).mockResolvedValueOnce(mockApiResponse);
|
(axios.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');
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiBaseInstance } from 'api';
|
import axios 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 ApiBaseInstance.get('/fields/keys', { params });
|
const response = await axios.get('/fields/keys', { params });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
httpStatusCode: response.status,
|
httpStatusCode: response.status,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/* eslint-disable sonarjs/cognitive-complexity */
|
/* eslint-disable sonarjs/cognitive-complexity */
|
||||||
import { ApiBaseInstance } from 'api';
|
import axios 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 ApiBaseInstance.get('/fields/values', { params });
|
const response = await axios.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) {
|
||||||
|
|||||||
@@ -86,8 +86,9 @@ const interceptorRejected = async (
|
|||||||
|
|
||||||
if (
|
if (
|
||||||
response.status === 401 &&
|
response.status === 401 &&
|
||||||
// if the session rotate call errors out with 401 or the delete sessions call returns 401 then we do not retry!
|
// 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!
|
||||||
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'
|
||||||
)
|
)
|
||||||
@@ -199,15 +200,15 @@ ApiV5Instance.interceptors.request.use(interceptorsRequestResponse);
|
|||||||
//
|
//
|
||||||
|
|
||||||
// axios Base
|
// axios Base
|
||||||
export const ApiBaseInstance = axios.create({
|
export const LogEventAxiosInstance = axios.create({
|
||||||
baseURL: `${ENVIRONMENT.baseURL}${apiV1}`,
|
baseURL: `${ENVIRONMENT.baseURL}${apiV1}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
ApiBaseInstance.interceptors.response.use(
|
LogEventAxiosInstance.interceptors.response.use(
|
||||||
interceptorsResponse,
|
interceptorsResponse,
|
||||||
interceptorRejectedBase,
|
interceptorRejectedBase,
|
||||||
);
|
);
|
||||||
ApiBaseInstance.interceptors.request.use(interceptorsRequestResponse);
|
LogEventAxiosInstance.interceptors.request.use(interceptorsRequestResponse);
|
||||||
//
|
//
|
||||||
|
|
||||||
// gateway Api V1
|
// gateway Api V1
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiBaseInstance } from 'api';
|
import axios 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 ApiBaseInstance.get(
|
}> = await axios.get(
|
||||||
`/${entity}/attribute_keys?dataSource=metrics&searchText=${searchText}`,
|
`/${entity}/attribute_keys?dataSource=metrics&searchText=${searchText}`,
|
||||||
{
|
{
|
||||||
params: {
|
params: {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiBaseInstance } from 'api';
|
import axios 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 ApiBaseInstance.post(
|
const response = await axios.post(
|
||||||
`/messaging-queues/kafka/onboarding/${endpointService || 'consumers'}`,
|
`/messaging-queues/kafka/onboarding/${endpointService || 'consumers'}`,
|
||||||
rest,
|
rest,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,20 @@
|
|||||||
import axios from 'api';
|
import { ApiV2Instance } 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> => {
|
||||||
const response = await axios.post(`/services`, {
|
try {
|
||||||
start: `${props.start}`,
|
const response = await ApiV2Instance.post(`/services`, {
|
||||||
end: `${props.end}`,
|
start: `${props.start}`,
|
||||||
tags: props.selectedTags,
|
end: `${props.end}`,
|
||||||
});
|
tags: props.selectedTags,
|
||||||
return response.data;
|
});
|
||||||
|
return response.data.data;
|
||||||
|
} catch (error) {
|
||||||
|
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export default getService;
|
export default getService;
|
||||||
|
|||||||
@@ -1,22 +1,27 @@
|
|||||||
import axios from 'api';
|
import { ApiV2Instance } 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> => {
|
||||||
const endpoint = props.isEntryPoint
|
try {
|
||||||
? '/service/entry_point_operations'
|
const endpoint = props.isEntryPoint
|
||||||
: '/service/top_operations';
|
? '/service/entry_point_operations'
|
||||||
|
: '/service/top_operations';
|
||||||
|
|
||||||
const response = await axios.post(endpoint, {
|
const response = await ApiV2Instance.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;
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiBaseInstance } from 'api';
|
import axios 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 ApiBaseInstance.get(`orgs/me/filters/${signal}`);
|
const response = await axios.get(`/orgs/me/filters/${signal}`);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
statusCode: 200,
|
statusCode: 200,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiBaseInstance } from 'api';
|
import axios 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> =>
|
||||||
ApiBaseInstance.put(`orgs/me/filters`, {
|
axios.put(`/orgs/me/filters`, {
|
||||||
...props.data,
|
...props.data,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiBaseInstance } from 'api';
|
import axios 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,15 +9,12 @@ 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 ApiBaseInstance.post(
|
const response = await axios.post(`/third-party-apis/overview/list`, {
|
||||||
`/third-party-apis/overview/list`,
|
start,
|
||||||
{
|
end,
|
||||||
start,
|
show_ip: showIp,
|
||||||
end,
|
filter,
|
||||||
show_ip: showIp,
|
});
|
||||||
filter,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
httpStatusCode: response.status,
|
httpStatusCode: response.status,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { ApiBaseInstance } from 'api';
|
import axios 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';
|
||||||
@@ -11,7 +11,7 @@ const getSpanPercentiles = async (
|
|||||||
props: GetSpanPercentilesProps,
|
props: GetSpanPercentilesProps,
|
||||||
): Promise<SuccessResponseV2<GetSpanPercentilesResponseDataProps>> => {
|
): Promise<SuccessResponseV2<GetSpanPercentilesResponseDataProps>> => {
|
||||||
try {
|
try {
|
||||||
const response = await ApiBaseInstance.post('/span_percentile', {
|
const response = await axios.post('/span_percentile', {
|
||||||
...props,
|
...props,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,30 +1,30 @@
|
|||||||
interface ConfigureIconProps {
|
interface ConfigureIconProps {
|
||||||
width?: number;
|
width?: number;
|
||||||
height?: number;
|
height?: number;
|
||||||
fill?: string;
|
color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConfigureIcon({
|
function ConfigureIcon({
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
fill,
|
color,
|
||||||
}: 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={fill}
|
fill="none"
|
||||||
>
|
>
|
||||||
<path
|
<path
|
||||||
stroke="#C0C1C3"
|
stroke={color}
|
||||||
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="#C0C1C3"
|
stroke={color}
|
||||||
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,
|
||||||
fill: 'none',
|
color: 'currentColor',
|
||||||
};
|
};
|
||||||
export default ConfigureIcon;
|
export default ConfigureIcon;
|
||||||
|
|||||||
@@ -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.005555555595958%');
|
).toBe('1.005555555595959%');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('ratio', () => {
|
test('ratio', () => {
|
||||||
@@ -359,7 +359,7 @@ describe('getYAxisFormattedValue - precision option tests', () => {
|
|||||||
's',
|
's',
|
||||||
PrecisionOptionsEnum.FULL,
|
PrecisionOptionsEnum.FULL,
|
||||||
),
|
),
|
||||||
).toBe('26254299141484417000000 µs');
|
).toBe('26.254299141484417 µs');
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL),
|
getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL),
|
||||||
|
|||||||
@@ -149,6 +149,7 @@ export const getGraphOptions = (
|
|||||||
scales: {
|
scales: {
|
||||||
x: {
|
x: {
|
||||||
stacked: isStacked,
|
stacked: isStacked,
|
||||||
|
offset: false,
|
||||||
grid: {
|
grid: {
|
||||||
display: true,
|
display: true,
|
||||||
color: getGridColor(),
|
color: getGridColor(),
|
||||||
|
|||||||
@@ -101,19 +101,10 @@ 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 decimalPlaces && decimalPlaces >= DEFAULT_SIGNIFICANT_DIGITS
|
return DEFAULT_SIGNIFICANT_DIGITS;
|
||||||
? decimalPlaces
|
|
||||||
: DEFAULT_SIGNIFICANT_DIGITS;
|
|
||||||
}
|
}
|
||||||
return precision;
|
return precision;
|
||||||
};
|
};
|
||||||
@@ -130,6 +121,11 @@ 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('.')) {
|
||||||
|
|||||||
@@ -10,10 +10,6 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
|||||||
import { FontSize } from 'container/OptionsMenu/types';
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
||||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
import {
|
|
||||||
LOG_FIELD_BODY_KEY,
|
|
||||||
LOG_FIELD_TIMESTAMP_KEY,
|
|
||||||
} from 'lib/logs/flatLogData';
|
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
@@ -89,15 +85,11 @@ function HostMetricsLogs({ timeRange, filters }: Props): JSX.Element {
|
|||||||
dataType: 'string',
|
dataType: 'string',
|
||||||
type: '',
|
type: '',
|
||||||
name: 'body',
|
name: 'body',
|
||||||
displayName: 'Body',
|
|
||||||
key: LOG_FIELD_BODY_KEY,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dataType: 'string',
|
dataType: 'string',
|
||||||
type: '',
|
type: '',
|
||||||
name: 'timestamp',
|
name: 'timestamp',
|
||||||
displayName: 'Timestamp',
|
|
||||||
key: LOG_FIELD_TIMESTAMP_KEY,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,7 +37,6 @@
|
|||||||
|
|
||||||
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;
|
||||||
@@ -45,6 +44,12 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ 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();
|
||||||
@@ -31,7 +32,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' }}>Copy to clipboard</span>}
|
content={<span style={{ fontSize: '0.9rem' }}>{tooltipText}</span>}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Popover>
|
</Popover>
|
||||||
@@ -42,7 +43,11 @@ 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',
|
||||||
|
};
|
||||||
|
|||||||
@@ -13,10 +13,6 @@ import { useCopyLogLink } from 'hooks/logs/useCopyLogLink';
|
|||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
// utils
|
// utils
|
||||||
import { FlatLogData } from 'lib/logs/flatLogData';
|
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||||
import {
|
|
||||||
LOG_FIELD_BODY_KEY,
|
|
||||||
LOG_FIELD_TIMESTAMP_KEY,
|
|
||||||
} from 'lib/logs/flatLogData';
|
|
||||||
import { useTimezone } from 'providers/Timezone';
|
import { useTimezone } from 'providers/Timezone';
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
// interfaces
|
// interfaces
|
||||||
@@ -46,9 +42,7 @@ interface LogFieldProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
|
type LogSelectedFieldProps = Omit<LogFieldProps, 'linesPerRow'> &
|
||||||
Pick<AddToQueryHOCProps, 'onAddToQuery'> & {
|
Pick<AddToQueryHOCProps, 'onAddToQuery'>;
|
||||||
fieldKeyDisplay: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
function LogGeneralField({
|
function LogGeneralField({
|
||||||
fieldKey,
|
fieldKey,
|
||||||
@@ -80,7 +74,6 @@ function LogGeneralField({
|
|||||||
function LogSelectedField({
|
function LogSelectedField({
|
||||||
fieldKey = '',
|
fieldKey = '',
|
||||||
fieldValue = '',
|
fieldValue = '',
|
||||||
fieldKeyDisplay = '',
|
|
||||||
onAddToQuery,
|
onAddToQuery,
|
||||||
fontSize,
|
fontSize,
|
||||||
}: LogSelectedFieldProps): JSX.Element {
|
}: LogSelectedFieldProps): JSX.Element {
|
||||||
@@ -97,7 +90,7 @@ function LogSelectedField({
|
|||||||
style={{ color: blue[4] }}
|
style={{ color: blue[4] }}
|
||||||
className={cx('selected-log-field-key', fontSize)}
|
className={cx('selected-log-field-key', fontSize)}
|
||||||
>
|
>
|
||||||
{fieldKeyDisplay}
|
{fieldKey}
|
||||||
</span>
|
</span>
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</AddToQueryHOC>
|
</AddToQueryHOC>
|
||||||
@@ -169,7 +162,7 @@ function ListLogView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const updatedSelecedFields = useMemo(
|
const updatedSelecedFields = useMemo(
|
||||||
() => selectedFields.filter((e) => e.key !== 'id'),
|
() => selectedFields.filter((e) => e.name !== 'id'),
|
||||||
[selectedFields],
|
[selectedFields],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -177,16 +170,16 @@ function ListLogView({
|
|||||||
|
|
||||||
const timestampValue = useMemo(
|
const timestampValue = useMemo(
|
||||||
() =>
|
() =>
|
||||||
typeof flattenLogData[LOG_FIELD_TIMESTAMP_KEY] === 'string'
|
typeof flattenLogData.timestamp === 'string'
|
||||||
? formatTimezoneAdjustedTimestamp(
|
? formatTimezoneAdjustedTimestamp(
|
||||||
flattenLogData[LOG_FIELD_TIMESTAMP_KEY],
|
flattenLogData.timestamp,
|
||||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||||
)
|
)
|
||||||
: formatTimezoneAdjustedTimestamp(
|
: formatTimezoneAdjustedTimestamp(
|
||||||
flattenLogData[LOG_FIELD_TIMESTAMP_KEY] / 1e6,
|
flattenLogData.timestamp / 1e6,
|
||||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||||
),
|
),
|
||||||
[flattenLogData, formatTimezoneAdjustedTimestamp],
|
[flattenLogData.timestamp, formatTimezoneAdjustedTimestamp],
|
||||||
);
|
);
|
||||||
|
|
||||||
const logType = getLogIndicatorType(logData);
|
const logType = getLogIndicatorType(logData);
|
||||||
@@ -222,12 +215,10 @@ function ListLogView({
|
|||||||
/>
|
/>
|
||||||
<div>
|
<div>
|
||||||
<LogContainer fontSize={fontSize}>
|
<LogContainer fontSize={fontSize}>
|
||||||
{updatedSelecedFields.some(
|
{updatedSelecedFields.some((field) => field.name === 'body') && (
|
||||||
(field) => field.key === LOG_FIELD_BODY_KEY,
|
|
||||||
) && (
|
|
||||||
<LogGeneralField
|
<LogGeneralField
|
||||||
fieldKey="Log"
|
fieldKey="Log"
|
||||||
fieldValue={flattenLogData[LOG_FIELD_BODY_KEY]}
|
fieldValue={flattenLogData.body}
|
||||||
linesPerRow={linesPerRow}
|
linesPerRow={linesPerRow}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
/>
|
/>
|
||||||
@@ -239,9 +230,7 @@ function ListLogView({
|
|||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{updatedSelecedFields.some(
|
{updatedSelecedFields.some((field) => field.name === 'timestamp') && (
|
||||||
(field) => field.key === LOG_FIELD_TIMESTAMP_KEY,
|
|
||||||
) && (
|
|
||||||
<LogGeneralField
|
<LogGeneralField
|
||||||
fieldKey="Timestamp"
|
fieldKey="Timestamp"
|
||||||
fieldValue={timestampValue}
|
fieldValue={timestampValue}
|
||||||
@@ -250,17 +239,13 @@ function ListLogView({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{updatedSelecedFields
|
{updatedSelecedFields
|
||||||
.filter(
|
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||||
(field) =>
|
|
||||||
![LOG_FIELD_TIMESTAMP_KEY, LOG_FIELD_BODY_KEY].includes(field.key),
|
|
||||||
)
|
|
||||||
.map((field) =>
|
.map((field) =>
|
||||||
isValidLogField(flattenLogData[field.key] as never) ? (
|
isValidLogField(flattenLogData[field.name] as never) ? (
|
||||||
<LogSelectedField
|
<LogSelectedField
|
||||||
key={field.key}
|
key={field.name}
|
||||||
fieldKey={field.key}
|
fieldKey={field.name}
|
||||||
fieldKeyDisplay={field.displayName}
|
fieldValue={flattenLogData[field.name] as never}
|
||||||
fieldValue={flattenLogData[field.key] as never}
|
|
||||||
onAddToQuery={onAddToQuery}
|
onAddToQuery={onAddToQuery}
|
||||||
fontSize={fontSize}
|
fontSize={fontSize}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -73,25 +73,16 @@ function RawLogView({
|
|||||||
);
|
);
|
||||||
|
|
||||||
const attributesValues = updatedSelecedFields
|
const attributesValues = updatedSelecedFields
|
||||||
.filter(
|
.filter((field) => !['timestamp', 'body'].includes(field.name))
|
||||||
(field) => !['log.timestamp:string', 'log.body:string'].includes(field.key),
|
.map((field) => flattenLogData[field.name])
|
||||||
)
|
.filter((attribute) => {
|
||||||
.map((field) => {
|
|
||||||
const value = flattenLogData[field.key];
|
|
||||||
const label = field.displayName;
|
|
||||||
|
|
||||||
// loadash isEmpty doesnot work with numbers
|
// loadash isEmpty doesnot work with numbers
|
||||||
if (isNumber(value)) {
|
if (isNumber(attribute)) {
|
||||||
return `${label}: ${value}`;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isUndefined(value) && !isEmpty(value)) {
|
return !isUndefined(attribute) && !isEmpty(attribute);
|
||||||
return `${label}: ${value}`;
|
});
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((attribute) => attribute !== null);
|
|
||||||
|
|
||||||
let attributesText = attributesValues.join(' | ');
|
let attributesText = attributesValues.join(' | ');
|
||||||
|
|
||||||
|
|||||||
@@ -6,11 +6,7 @@ import cx from 'classnames';
|
|||||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||||
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
import { getSanitizedLogBody } from 'container/LogDetailedView/utils';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import {
|
import { FlatLogData } from 'lib/logs/flatLogData';
|
||||||
FlatLogData,
|
|
||||||
LOG_FIELD_BODY_KEY,
|
|
||||||
LOG_FIELD_TIMESTAMP_KEY,
|
|
||||||
} from 'lib/logs/flatLogData';
|
|
||||||
import { useTimezone } from 'providers/Timezone';
|
import { useTimezone } from 'providers/Timezone';
|
||||||
import { useMemo } from 'react';
|
import { useMemo } from 'react';
|
||||||
|
|
||||||
@@ -55,33 +51,28 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
|||||||
|
|
||||||
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
|
const columns: ColumnsType<Record<string, unknown>> = useMemo(() => {
|
||||||
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
|
const fieldColumns: ColumnsType<Record<string, unknown>> = fields
|
||||||
.filter(
|
.filter((e) => !['id', 'body', 'timestamp'].includes(e.name))
|
||||||
(e) => !['id', LOG_FIELD_BODY_KEY, LOG_FIELD_TIMESTAMP_KEY].includes(e.key),
|
.map(({ name }) => ({
|
||||||
)
|
title: name,
|
||||||
.map((field) => ({
|
dataIndex: name,
|
||||||
title: field.displayName,
|
accessorKey: name,
|
||||||
dataIndex: field.key,
|
id: name.toLowerCase().replace(/\./g, '_'),
|
||||||
accessorKey: field.key,
|
key: name,
|
||||||
id: field.key.toLowerCase().replace(/\./g, '_').replace(/:/g, '_'),
|
render: (field): ColumnTypeRender<Record<string, unknown>> => ({
|
||||||
key: field.key,
|
props: {
|
||||||
render: (fieldValue, record): ColumnTypeRender<Record<string, unknown>> => {
|
style: isListViewPanel
|
||||||
const value = record[field.key] || fieldValue;
|
? defaultListViewPanelStyle
|
||||||
return {
|
: getDefaultCellStyle(isDarkMode),
|
||||||
props: {
|
},
|
||||||
style: isListViewPanel
|
children: (
|
||||||
? defaultListViewPanelStyle
|
<Typography.Paragraph
|
||||||
: getDefaultCellStyle(isDarkMode),
|
ellipsis={{ rows: linesPerRow }}
|
||||||
},
|
className={cx('paragraph', fontSize)}
|
||||||
children: (
|
>
|
||||||
<Typography.Paragraph
|
{field}
|
||||||
ellipsis={{ rows: linesPerRow }}
|
</Typography.Paragraph>
|
||||||
className={cx('paragraph', fontSize)}
|
),
|
||||||
>
|
}),
|
||||||
{value}
|
|
||||||
</Typography.Paragraph>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
if (isListViewPanel) {
|
if (isListViewPanel) {
|
||||||
@@ -109,29 +100,26 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
|||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
...(fields.some((field) => field.key === LOG_FIELD_TIMESTAMP_KEY)
|
...(fields.some((field) => field.name === 'timestamp')
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: 'timestamp',
|
title: 'timestamp',
|
||||||
dataIndex: LOG_FIELD_TIMESTAMP_KEY,
|
dataIndex: 'timestamp',
|
||||||
key: 'timestamp',
|
key: 'timestamp',
|
||||||
accessorKey: LOG_FIELD_TIMESTAMP_KEY,
|
accessorKey: 'timestamp',
|
||||||
id: 'timestamp',
|
id: 'timestamp',
|
||||||
// https://github.com/ant-design/ant-design/discussions/36886
|
// https://github.com/ant-design/ant-design/discussions/36886
|
||||||
render: (
|
render: (
|
||||||
field: string | number,
|
field: string | number,
|
||||||
record: Record<string, unknown>,
|
|
||||||
): ColumnTypeRender<Record<string, unknown>> => {
|
): ColumnTypeRender<Record<string, unknown>> => {
|
||||||
const timestampValue =
|
|
||||||
(record[LOG_FIELD_TIMESTAMP_KEY] as string | number) || field;
|
|
||||||
const date =
|
const date =
|
||||||
typeof timestampValue === 'string'
|
typeof field === 'string'
|
||||||
? formatTimezoneAdjustedTimestamp(
|
? formatTimezoneAdjustedTimestamp(
|
||||||
timestampValue,
|
field,
|
||||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||||
)
|
)
|
||||||
: formatTimezoneAdjustedTimestamp(
|
: formatTimezoneAdjustedTimestamp(
|
||||||
timestampValue / 1e6,
|
field / 1e6,
|
||||||
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
DATE_TIME_FORMATS.ISO_DATETIME_MS,
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
@@ -148,37 +136,33 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
|||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
...(appendTo === 'center' ? fieldColumns : []),
|
...(appendTo === 'center' ? fieldColumns : []),
|
||||||
...(fields.some((field) => field.key === LOG_FIELD_BODY_KEY)
|
...(fields.some((field) => field.name === 'body')
|
||||||
? [
|
? [
|
||||||
{
|
{
|
||||||
title: 'body',
|
title: 'body',
|
||||||
dataIndex: LOG_FIELD_BODY_KEY,
|
dataIndex: 'body',
|
||||||
key: 'body',
|
key: 'body',
|
||||||
accessorKey: LOG_FIELD_BODY_KEY,
|
accessorKey: 'body',
|
||||||
id: 'body',
|
id: 'body',
|
||||||
render: (
|
render: (
|
||||||
field: string | number,
|
field: string | number,
|
||||||
record: Record<string, unknown>,
|
): ColumnTypeRender<Record<string, unknown>> => ({
|
||||||
): ColumnTypeRender<Record<string, unknown>> => {
|
props: {
|
||||||
const bodyValue = (record[LOG_FIELD_BODY_KEY] as string) || '';
|
style: bodyColumnStyle,
|
||||||
return {
|
},
|
||||||
props: {
|
children: (
|
||||||
style: bodyColumnStyle,
|
<TableBodyContent
|
||||||
},
|
dangerouslySetInnerHTML={{
|
||||||
children: (
|
__html: getSanitizedLogBody(field as string, {
|
||||||
<TableBodyContent
|
shouldEscapeHtml: true,
|
||||||
dangerouslySetInnerHTML={{
|
}),
|
||||||
__html: getSanitizedLogBody(bodyValue, {
|
}}
|
||||||
shouldEscapeHtml: true,
|
fontSize={fontSize}
|
||||||
}),
|
linesPerRow={linesPerRow}
|
||||||
}}
|
isDarkMode={isDarkMode}
|
||||||
fontSize={fontSize}
|
/>
|
||||||
linesPerRow={linesPerRow}
|
),
|
||||||
isDarkMode={isDarkMode}
|
}),
|
||||||
/>
|
|
||||||
),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
: []),
|
: []),
|
||||||
|
|||||||
@@ -416,21 +416,18 @@ function OptionsMenu({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="column-format">
|
<div className="column-format">
|
||||||
{addColumn?.value?.map((column) => (
|
{addColumn?.value?.map(({ name }) => (
|
||||||
<div className="column-name" key={column.key}>
|
<div className="column-name" key={name}>
|
||||||
<div className="name">
|
<div className="name">
|
||||||
<Tooltip
|
<Tooltip placement="left" title={name}>
|
||||||
placement="left"
|
{name}
|
||||||
title={column.displayName || column.name}
|
|
||||||
>
|
|
||||||
{column.displayName || column.name}
|
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
{addColumn?.value?.length > 1 && (
|
{addColumn?.value?.length > 1 && (
|
||||||
<X
|
<X
|
||||||
className="delete-btn"
|
className="delete-btn"
|
||||||
size={14}
|
size={14}
|
||||||
onClick={(): void => addColumn.onRemove(column.key)}
|
onClick={(): void => addColumn.onRemove(name)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -474,11 +471,13 @@ function LogsFormatOptionsMenu({
|
|||||||
rootClassName="format-options-popover"
|
rootClassName="format-options-popover"
|
||||||
destroyTooltipOnHide
|
destroyTooltipOnHide
|
||||||
>
|
>
|
||||||
<Button
|
<Tooltip title="Options">
|
||||||
className="periscope-btn ghost"
|
<Button
|
||||||
icon={<Sliders size={14} />}
|
className="periscope-btn ghost"
|
||||||
data-testid="periscope-btn-format-options"
|
icon={<Sliders size={14} />}
|
||||||
/>
|
data-testid="periscope-btn-format-options"
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
</Popover>
|
</Popover>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -251,6 +251,10 @@
|
|||||||
.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 {
|
||||||
@@ -296,6 +300,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.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;
|
||||||
|
|||||||
@@ -179,6 +179,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
isListViewPanel={isListViewPanel}
|
isListViewPanel={isListViewPanel}
|
||||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||||
|
queriesCount={1}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
currentQuery.builder.queryData.map((query, index) => (
|
currentQuery.builder.queryData.map((query, index) => (
|
||||||
@@ -200,6 +201,7 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
|||||||
signalSource={query.source as 'meter' | ''}
|
signalSource={query.source as 'meter' | ''}
|
||||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||||
|
queriesCount={currentQuery.builder.queryData.length}
|
||||||
/>
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -98,6 +98,13 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -6,6 +6,15 @@
|
|||||||
gap: 8px;
|
gap: 8px;
|
||||||
width: 100%;
|
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 {
|
.source-selector {
|
||||||
width: 120px;
|
width: 120px;
|
||||||
}
|
}
|
||||||
@@ -22,6 +31,11 @@
|
|||||||
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 {
|
||||||
|
|||||||
@@ -236,6 +236,10 @@
|
|||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +275,9 @@
|
|||||||
|
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
.cm-placeholder {
|
||||||
|
font-size: 12px !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
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 {
|
||||||
@@ -231,6 +233,9 @@
|
|||||||
.query-aggregation-interval-input {
|
.query-aggregation-interval-input {
|
||||||
input {
|
input {
|
||||||
max-width: 120px;
|
max-width: 120px;
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--bg-vanilla-400);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
.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);
|
||||||
|
}
|
||||||
@@ -1,7 +1,75 @@
|
|||||||
|
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,
|
||||||
@@ -22,8 +90,7 @@ 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 secondary"
|
className="add-new-query-button periscope-btn "
|
||||||
type="text"
|
|
||||||
icon={<Plus size={16} />}
|
icon={<Plus size={16} />}
|
||||||
onClick={addNewBuilderQuery}
|
onClick={addNewBuilderQuery}
|
||||||
/>
|
/>
|
||||||
@@ -49,7 +116,7 @@ export default function QueryFooter({
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
className="add-formula-button periscope-btn secondary"
|
className="add-formula-button periscope-btn "
|
||||||
icon={<Sigma size={16} />}
|
icon={<Sigma size={16} />}
|
||||||
onClick={addNewFormula}
|
onClick={addNewFormula}
|
||||||
>
|
>
|
||||||
@@ -59,35 +126,7 @@ export default function QueryFooter({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{showAddTraceOperator && (
|
{showAddTraceOperator && (
|
||||||
<div className="qb-trace-operator-button-container">
|
<TraceOperatorSection addTraceOperator={addTraceOperator} />
|
||||||
<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>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ 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';
|
||||||
@@ -79,6 +80,16 @@ 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,
|
||||||
@@ -87,17 +98,8 @@ 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);
|
||||||
@@ -107,8 +109,12 @@ 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 = (newQuery: string): void => {
|
const handleQueryValidation = useCallback((newQuery: string): void => {
|
||||||
try {
|
try {
|
||||||
const validationResponse = validateQuery(newQuery);
|
const validationResponse = validateQuery(newQuery);
|
||||||
setValidation(validationResponse);
|
setValidation(validationResponse);
|
||||||
@@ -119,29 +125,67 @@ function QuerySearch({
|
|||||||
errors: [error as IDetailedError],
|
errors: [error as IDetailedError],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
// Track if the query was changed externally (from queryData) vs internally (user input)
|
const getCurrentQuery = useCallback(
|
||||||
const [isExternalQueryChange, setIsExternalQueryChange] = useState(false);
|
(): string => editorRef.current?.state.doc.toString() || '',
|
||||||
const [lastExternalQuery, setLastExternalQuery] = useState<string>('');
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
const updateEditorValue = useCallback(
|
||||||
const newQuery = queryData.filter?.expression || '';
|
(value: string, options: { skipOnChange?: boolean } = {}): void => {
|
||||||
// Only mark as external change if the query actually changed from external source
|
const view = editorRef.current;
|
||||||
if (newQuery !== lastExternalQuery) {
|
if (!view) return;
|
||||||
setQuery(newQuery);
|
|
||||||
setIsExternalQueryChange(true);
|
|
||||||
setLastExternalQuery(newQuery);
|
|
||||||
}
|
|
||||||
}, [queryData.filter?.expression, lastExternalQuery]);
|
|
||||||
|
|
||||||
// Validate query when it changes externally (from queryData)
|
const currentValue = view.state.doc.toString();
|
||||||
useEffect(() => {
|
if (currentValue === value) return;
|
||||||
if (isExternalQueryChange && query) {
|
|
||||||
handleQueryValidation(query);
|
if (options.skipOnChange) {
|
||||||
setIsExternalQueryChange(false);
|
isProgrammaticChangeRef.current = true;
|
||||||
}
|
}
|
||||||
}, [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
|
||||||
@@ -150,7 +194,6 @@ 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,
|
||||||
@@ -159,8 +202,6 @@ 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>('');
|
||||||
@@ -506,6 +547,7 @@ 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;
|
||||||
@@ -521,7 +563,15 @@ 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(newPos);
|
setCursorPos((lastPos) => {
|
||||||
|
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) {
|
||||||
@@ -554,16 +604,17 @@ function QuerySearch({
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChange = (value: string): void => {
|
const handleChange = (value: string): void => {
|
||||||
setQuery(value);
|
if (isProgrammaticChangeRef.current) {
|
||||||
|
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 => {
|
||||||
handleQueryValidation(query);
|
const currentQuery = getCurrentQuery();
|
||||||
|
handleQueryValidation(currentQuery);
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -582,12 +633,11 @@ 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 newQuery = query ? `${query} AND ${exampleQuery}` : exampleQuery;
|
const currentQuery = getCurrentQuery();
|
||||||
setQuery(newQuery);
|
const newQuery = currentQuery
|
||||||
// Mark as internal change to avoid triggering external validation
|
? `${currentQuery} AND ${exampleQuery}`
|
||||||
setIsExternalQueryChange(false);
|
: exampleQuery;
|
||||||
// Update lastExternalQuery to prevent external validation trigger
|
updateEditorValue(newQuery);
|
||||||
setLastExternalQuery(newQuery);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Helper function to render a badge for the current context mode
|
// Helper function to render a badge for the current context mode
|
||||||
@@ -622,8 +672,10 @@ 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(query, cursorPos.ch);
|
const queryContext = getQueryContextAtCursor(currentQuery, cursorPos.ch);
|
||||||
|
|
||||||
// Define autocomplete options based on the context
|
// Define autocomplete options based on the context
|
||||||
let options: {
|
let options: {
|
||||||
@@ -1119,7 +1171,8 @@ 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 curChar = query.charAt(cursorPos.ch - 1) || '';
|
const currentQuery = editorRef.current?.state.doc.toString() || '';
|
||||||
|
const curChar = currentQuery.charAt(cursorPos.ch - 1) || '';
|
||||||
|
|
||||||
if (curChar === '(' || curChar === '[') {
|
if (curChar === '(' || curChar === '[') {
|
||||||
// Right after opening parenthesis/bracket
|
// Right after opening parenthesis/bracket
|
||||||
@@ -1268,7 +1321,7 @@ function QuerySearch({
|
|||||||
style={{
|
style={{
|
||||||
position: 'absolute',
|
position: 'absolute',
|
||||||
top: 8,
|
top: 8,
|
||||||
right: validation.isValid === false && query ? 40 : 8, // Move left when error shown
|
right: validation.isValid === false && getCurrentQuery() ? 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',
|
||||||
@@ -1289,10 +1342,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,
|
||||||
@@ -1330,7 +1383,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(query);
|
onRun(getCurrentQuery());
|
||||||
} else {
|
} else {
|
||||||
handleRunQuery();
|
handleRunQuery();
|
||||||
}
|
}
|
||||||
@@ -1356,7 +1409,7 @@ function QuerySearch({
|
|||||||
onBlur={handleBlur}
|
onBlur={handleBlur}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{query && validation.isValid === false && !isFocused && (
|
{getCurrentQuery() && 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,
|
||||||
|
|||||||
@@ -9,7 +9,13 @@ 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 { memo, useCallback, useMemo, useState } from 'react';
|
import {
|
||||||
|
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';
|
||||||
@@ -20,26 +26,29 @@ 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 = memo(function QueryV2({
|
export const QueryV2 = forwardRef(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,
|
onSignalSourceChange,
|
||||||
signalSourceChangeEnabled = false,
|
signalSourceChangeEnabled = false,
|
||||||
}: QueryProps & {
|
queriesCount = 1,
|
||||||
ref: React.RefObject<HTMLDivElement>;
|
}: QueryProps & {
|
||||||
onSignalSourceChange: (value: string) => void;
|
onSignalSourceChange: (value: string) => void;
|
||||||
signalSourceChangeEnabled: boolean;
|
signalSourceChangeEnabled: boolean;
|
||||||
}): JSX.Element {
|
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;
|
||||||
@@ -192,12 +201,16 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
icon: <Copy size={14} />,
|
icon: <Copy size={14} />,
|
||||||
onClick: handleCloneEntity,
|
onClick: handleCloneEntity,
|
||||||
},
|
},
|
||||||
{
|
...(queriesCount && queriesCount > 1
|
||||||
label: 'Delete',
|
? [
|
||||||
key: 'delete-query',
|
{
|
||||||
icon: <Trash size={14} />,
|
label: 'Delete',
|
||||||
onClick: handleDeleteQuery,
|
key: 'delete-query',
|
||||||
},
|
icon: <Trash size={14} />,
|
||||||
|
onClick: handleDeleteQuery,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
],
|
],
|
||||||
}}
|
}}
|
||||||
placement="bottomRight"
|
placement="bottomRight"
|
||||||
@@ -289,3 +302,5 @@ export const QueryV2 = memo(function QueryV2({
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
QueryV2.displayName = 'QueryV2';
|
||||||
|
|||||||
@@ -92,6 +92,9 @@
|
|||||||
|
|
||||||
.qb-trace-operator-editor-container {
|
.qb-trace-operator-editor-container {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
.cm-activeLine > span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.arrow-left {
|
&.arrow-left {
|
||||||
@@ -113,6 +116,8 @@
|
|||||||
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 || ''}
|
||||||
|
|||||||
@@ -5,13 +5,85 @@ 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 React from 'react';
|
import { fireEvent, render, userEvent, waitFor } from 'tests/test-utils';
|
||||||
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,
|
||||||
}));
|
}));
|
||||||
@@ -31,24 +103,6 @@ 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: {
|
||||||
@@ -63,153 +117,19 @@ jest.mock('api/querySuggestions/getValueSuggestion', () => ({
|
|||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Mock CodeMirror to a simple textarea to make it testable and call onUpdate
|
// Note: We're NOT mocking CodeMirror here - using the real component
|
||||||
jest.mock(
|
// This provides integration testing with the actual CodeMirror editor
|
||||||
'@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 = " status_code = '200'";
|
const SAMPLE_STATUS_QUERY = "http.status_code = '200'";
|
||||||
|
|
||||||
describe('QuerySearch', () => {
|
describe('QuerySearch (Integration with Real CodeMirror)', () => {
|
||||||
it('renders with placeholder', () => {
|
it('renders with placeholder', () => {
|
||||||
render(
|
render(
|
||||||
<QuerySearch
|
<QuerySearch
|
||||||
@@ -219,21 +139,19 @@ describe('QuerySearch', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.getByPlaceholderText(PLACEHOLDER_TEXT)).toBeInTheDocument();
|
// CodeMirror renders a contenteditable div, so we check for the container
|
||||||
|
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 () => {
|
||||||
jest.useFakeTimers();
|
// Use real timers for CodeMirror integration tests
|
||||||
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
|
||||||
@@ -243,28 +161,33 @@ describe('QuerySearch', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
// Wait for CodeMirror to initialize
|
||||||
await user.type(editor, SAMPLE_KEY_TYPING);
|
await waitFor(() => {
|
||||||
advance(1000);
|
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
|
});
|
||||||
timeout: 3000,
|
|
||||||
|
// 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);
|
||||||
|
|
||||||
|
// Wait for debounced API call (300ms debounce + some buffer)
|
||||||
|
await waitFor(() => expect(mockedGetKeys).toHaveBeenCalled(), {
|
||||||
|
timeout: 2000,
|
||||||
});
|
});
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches value suggestions when editing value context', async () => {
|
it('fetches value suggestions when editing value context', async () => {
|
||||||
jest.useFakeTimers();
|
// Use real timers for CodeMirror integration tests
|
||||||
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
|
||||||
@@ -274,21 +197,28 @@ describe('QuerySearch', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
// Wait for CodeMirror to initialize
|
||||||
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
await waitFor(() => {
|
||||||
advance(1000);
|
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
});
|
||||||
timeout: 3000,
|
|
||||||
|
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||||
|
await user.click(editor);
|
||||||
|
await user.type(editor, SAMPLE_VALUE_TYPING_INCOMPLETE);
|
||||||
|
|
||||||
|
// Wait for debounced API call (300ms debounce + some buffer)
|
||||||
|
await waitFor(() => expect(mockedGetValues).toHaveBeenCalled(), {
|
||||||
|
timeout: 2000,
|
||||||
});
|
});
|
||||||
jest.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('fetches key suggestions on mount for LOGS', async () => {
|
it('fetches key suggestions on mount for LOGS', async () => {
|
||||||
jest.useFakeTimers();
|
// Use real timers for CodeMirror integration tests
|
||||||
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
|
const mockedGetKeysOnMount = getKeySuggestions as jest.MockedFunction<
|
||||||
typeof getKeySuggestions
|
typeof getKeySuggestions
|
||||||
>;
|
>;
|
||||||
|
mockedGetKeysOnMount.mockClear();
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<QuerySearch
|
<QuerySearch
|
||||||
@@ -298,17 +228,15 @@ describe('QuerySearch', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
jest.advanceTimersByTime(1000);
|
// Wait for debounced API call (300ms debounce + some buffer)
|
||||||
|
|
||||||
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
|
await waitFor(() => expect(mockedGetKeysOnMount).toHaveBeenCalled(), {
|
||||||
timeout: 3000,
|
timeout: 2000,
|
||||||
});
|
});
|
||||||
|
|
||||||
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 () => {
|
||||||
@@ -324,12 +252,26 @@ describe('QuerySearch', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
// Wait for CodeMirror to initialize
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||||
await 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}');
|
|
||||||
|
|
||||||
await waitFor(() => expect(onRun).toHaveBeenCalled());
|
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
|
||||||
|
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
|
||||||
|
fireEvent.keyDown(editor, {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
[modKey]: true,
|
||||||
|
keyCode: 13,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(onRun).toHaveBeenCalled(), { timeout: 2000 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
|
it('calls handleRunQuery when Mod-Enter without onRun', async () => {
|
||||||
@@ -348,11 +290,62 @@ describe('QuerySearch', () => {
|
|||||||
/>,
|
/>,
|
||||||
);
|
);
|
||||||
|
|
||||||
const editor = screen.getByTestId(TESTID_EDITOR);
|
// Wait for CodeMirror to initialize
|
||||||
|
await waitFor(() => {
|
||||||
|
const editor = document.querySelector(CM_EDITOR_SELECTOR);
|
||||||
|
expect(editor).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
const editor = document.querySelector(CM_EDITOR_SELECTOR) as HTMLElement;
|
||||||
await 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}');
|
|
||||||
|
|
||||||
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled());
|
// Use fireEvent for keyboard shortcuts as userEvent might not work well with CodeMirror
|
||||||
|
const modKey = navigator.platform.includes('Mac') ? 'metaKey' : 'ctrlKey';
|
||||||
|
fireEvent.keyDown(editor, {
|
||||||
|
key: 'Enter',
|
||||||
|
code: 'Enter',
|
||||||
|
[modKey]: true,
|
||||||
|
keyCode: 13,
|
||||||
|
});
|
||||||
|
|
||||||
|
await waitFor(() => expect(mockedHandleRunQuery).toHaveBeenCalled(), {
|
||||||
|
timeout: 2000,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes CodeMirror with expression from queryData.filter.expression on mount', async () => {
|
||||||
|
const testExpression =
|
||||||
|
"http.status_code >= 500 AND service.name = 'frontend'";
|
||||||
|
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 },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -224,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.trim());
|
let queryPairsMap = getQueryPairsMap(existingQuery);
|
||||||
|
|
||||||
filters?.items?.forEach((filter) => {
|
filters?.items?.forEach((filter) => {
|
||||||
const { key, op, value } = filter;
|
const { key, op, value } = filter;
|
||||||
@@ -309,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.trim());
|
queryPairsMap = getQueryPairsMap(modifiedQuery);
|
||||||
}
|
}
|
||||||
shouldAddToNonExisting = false; // Don't add this to non-existing filters
|
shouldAddToNonExisting = false; // Don't add this to non-existing filters
|
||||||
} else if (
|
} else if (
|
||||||
|
|||||||
@@ -1,223 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,60 +0,0 @@
|
|||||||
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;
|
|
||||||
@@ -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 } from 'react';
|
import { ReactNode, useMemo } from 'react';
|
||||||
import { Warning } from 'types/api';
|
import { Warning } from 'types/api';
|
||||||
|
|
||||||
interface WarningContentProps {
|
interface WarningContentProps {
|
||||||
@@ -106,19 +106,51 @@ 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={<WarningContent warning={warningData} />}
|
content={content}
|
||||||
overlayStyle={{ padding: 0, maxWidth: '600px' }}
|
overlayStyle={{ padding: 0, maxWidth: '600px' }}
|
||||||
overlayInnerStyle={{ padding: 0 }}
|
overlayInnerStyle={{ padding: 0 }}
|
||||||
autoAdjustOverflow
|
autoAdjustOverflow
|
||||||
@@ -137,6 +169,8 @@ function WarningPopover({
|
|||||||
|
|
||||||
WarningPopover.defaultProps = {
|
WarningPopover.defaultProps = {
|
||||||
children: undefined,
|
children: undefined,
|
||||||
|
warningData: null,
|
||||||
|
message: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default WarningPopover;
|
export default WarningPopover;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ const themeColors = {
|
|||||||
cyan: '#00FFFF',
|
cyan: '#00FFFF',
|
||||||
},
|
},
|
||||||
chartcolors: {
|
chartcolors: {
|
||||||
robin: '#3F5ECC',
|
radicalRed: '#FF1A66',
|
||||||
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',
|
||||||
radicalRed: '#FF1A66',
|
robin: '#3F5ECC',
|
||||||
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',
|
||||||
gold: '#FFD700',
|
darkSeaGreen: '#8FBC8F',
|
||||||
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',
|
||||||
darkSeaGreen: '#8FBC8F',
|
gold: '#FFD700',
|
||||||
sandyBrown: '#F4A460',
|
sandyBrown: '#F4A460',
|
||||||
darkKhaki: '#BDB76B',
|
darkKhaki: '#BDB76B',
|
||||||
cornflowerBlue: '#6495ED',
|
cornflowerBlue: '#6495ED',
|
||||||
@@ -113,7 +113,7 @@ const themeColors = {
|
|||||||
paleGreen: '#98FB98',
|
paleGreen: '#98FB98',
|
||||||
},
|
},
|
||||||
lightModeColor: {
|
lightModeColor: {
|
||||||
robin: '#3F5ECC',
|
radicalRed: '#FF1A66',
|
||||||
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',
|
||||||
radicalRed: '#FF1A66',
|
robin: '#3F5ECC',
|
||||||
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',
|
||||||
darkSeaGreenDark: '#509F50',
|
gold: '#FFD700',
|
||||||
sandyBrownDark: '#D97117',
|
sandyBrownDark: '#D97117',
|
||||||
darkKhakiDark: '#99900A',
|
darkKhakiDark: '#99900A',
|
||||||
cornflowerBlueDark: '#3371E6',
|
cornflowerBlueDark: '#3371E6',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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,
|
||||||
@@ -264,6 +265,7 @@ function AllEndPoints({
|
|||||||
customOnDragSelect={(): void => {}}
|
customOnDragSelect={(): void => {}}
|
||||||
customTimeRange={timeRange}
|
customTimeRange={timeRange}
|
||||||
customOnRowClick={onRowClick}
|
customOnRowClick={onRowClick}
|
||||||
|
version={ENTITY_VERSION_V5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -244,6 +244,10 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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;
|
||||||
@@ -422,30 +426,28 @@
|
|||||||
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);
|
||||||
width: fit-content;
|
overflow: hidden;
|
||||||
|
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);
|
||||||
height: calc(100% - 12px);
|
font-size: 14px;
|
||||||
|
line-height: 18px;
|
||||||
|
letter-spacing: -0.07px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -453,9 +455,23 @@
|
|||||||
.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 {
|
||||||
@@ -996,7 +1012,6 @@
|
|||||||
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1007,6 +1022,25 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
@@ -1031,7 +1065,6 @@
|
|||||||
|
|
||||||
.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);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1160,7 +1193,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.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;
|
||||||
@@ -1187,11 +1224,31 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.top-services-item-progress-bar {
|
.top-services-item-progress-bar {
|
||||||
background-color: var(--bg-vanilla-300);
|
background-color: var(--bg-vanilla-200);
|
||||||
border: 1px solid var(--bg-slate-300);
|
border: 1px solid var(--bg-vanilla-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);
|
||||||
@@ -1225,4 +1282,8 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { ENTITY_VERSION_V4 } from 'constants/app';
|
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } 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,
|
||||||
@@ -178,18 +179,33 @@ 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) => {
|
||||||
queryKey: [
|
const queryKey = END_POINT_DETAILS_QUERY_KEYS_ARRAY[index];
|
||||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
|
const version = (V5_QUERIES as readonly string[]).includes(queryKey)
|
||||||
payload,
|
? ENTITY_VERSION_V5
|
||||||
filters?.items, // Include filters.items in queryKey for better caching
|
: ENTITY_VERSION_V4;
|
||||||
ENTITY_VERSION_V4,
|
return {
|
||||||
],
|
queryKey: [
|
||||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
END_POINT_DETAILS_QUERY_KEYS_ARRAY[index],
|
||||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
|
payload,
|
||||||
enabled: !!payload,
|
...(filters?.items?.length ? filters.items : []), // Include filters.items in queryKey for better caching
|
||||||
})),
|
version,
|
||||||
|
],
|
||||||
|
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||||
|
GetMetricQueryRange(payload, version),
|
||||||
|
enabled: !!payload,
|
||||||
|
};
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
|
|||||||
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
|
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_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
import { ENTITY_VERSION_V5 } 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,
|
||||||
@@ -56,6 +56,10 @@ 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: {
|
||||||
@@ -66,7 +70,6 @@ function TopErrors({
|
|||||||
op: '=',
|
op: '=',
|
||||||
value: endPointName,
|
value: endPointName,
|
||||||
},
|
},
|
||||||
...(initialFilters?.items || []),
|
|
||||||
]
|
]
|
||||||
: [...(initialFilters?.items || [])],
|
: [...(initialFilters?.items || [])],
|
||||||
op: 'AND',
|
op: 'AND',
|
||||||
@@ -128,12 +131,12 @@ function TopErrors({
|
|||||||
const endPointDropDownDataQueries = useQueries(
|
const endPointDropDownDataQueries = useQueries(
|
||||||
endPointDropDownQueryPayload.map((payload) => ({
|
endPointDropDownQueryPayload.map((payload) => ({
|
||||||
queryKey: [
|
queryKey: [
|
||||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY[4],
|
END_POINT_DETAILS_QUERY_KEYS_ARRAY[2],
|
||||||
payload,
|
payload,
|
||||||
ENTITY_VERSION_V4,
|
ENTITY_VERSION_V5,
|
||||||
],
|
],
|
||||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
|
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
|
||||||
enabled: !!payload,
|
enabled: !!payload,
|
||||||
staleTime: 60 * 1000,
|
staleTime: 60 * 1000,
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -0,0 +1,337 @@
|
|||||||
|
/* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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_V4 } from 'constants/app';
|
import { ENTITY_VERSION_V5 } 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_V4,
|
ENTITY_VERSION_V5,
|
||||||
],
|
],
|
||||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||||
GetMetricQueryRange(payload, ENTITY_VERSION_V4),
|
GetMetricQueryRange(payload, ENTITY_VERSION_V5),
|
||||||
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,7 +132,9 @@ function DomainMetrics({
|
|||||||
) : (
|
) : (
|
||||||
<Tooltip title={formattedDomainMetricsData.latency}>
|
<Tooltip title={formattedDomainMetricsData.latency}>
|
||||||
<span className="round-metric-tag">
|
<span className="round-metric-tag">
|
||||||
{(Number(formattedDomainMetricsData.latency) / 1000).toFixed(3)}s
|
{formattedDomainMetricsData.latency !== '-'
|
||||||
|
? `${(Number(formattedDomainMetricsData.latency) / 1000).toFixed(3)}s`
|
||||||
|
: '-'}
|
||||||
</span>
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
@@ -143,23 +145,27 @@ function DomainMetrics({
|
|||||||
<Skeleton.Button active size="small" />
|
<Skeleton.Button active size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title={formattedDomainMetricsData.errorRate}>
|
<Tooltip title={formattedDomainMetricsData.errorRate}>
|
||||||
<Progress
|
{formattedDomainMetricsData.errorRate !== '-' ? (
|
||||||
status="active"
|
<Progress
|
||||||
percent={Number(
|
status="active"
|
||||||
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
percent={Number(
|
||||||
)}
|
|
||||||
strokeLinecap="butt"
|
|
||||||
size="small"
|
|
||||||
strokeColor={((): string => {
|
|
||||||
const errorRatePercent = Number(
|
|
||||||
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
||||||
);
|
)}
|
||||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
strokeLinecap="butt"
|
||||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
size="small"
|
||||||
return Color.BG_FOREST_500;
|
strokeColor={((): string => {
|
||||||
})()}
|
const errorRatePercent = Number(
|
||||||
className="progress-bar"
|
Number(formattedDomainMetricsData.errorRate).toFixed(2),
|
||||||
/>
|
);
|
||||||
|
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>
|
||||||
|
|||||||
@@ -0,0 +1,419 @@
|
|||||||
|
/* 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,12 +1,16 @@
|
|||||||
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 { getFormattedEndPointMetricsData } from 'container/ApiMonitoring/utils';
|
import {
|
||||||
|
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,
|
||||||
}: {
|
}: {
|
||||||
@@ -70,7 +74,9 @@ 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">{metricsData?.rate} ops/sec</span>
|
<span className="round-metric-tag">
|
||||||
|
{metricsData?.rate !== '-' ? `${metricsData?.rate} ops/sec` : '-'}
|
||||||
|
</span>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -79,7 +85,7 @@ function EndPointMetrics({
|
|||||||
<Skeleton.Button active size="small" />
|
<Skeleton.Button active size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title={metricsData?.latency}>
|
<Tooltip title={metricsData?.latency}>
|
||||||
<span className="round-metric-tag">{metricsData?.latency}ms</span>
|
{metricsData?.latency !== '-' ? `${metricsData?.latency}ms` : '-'}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -88,21 +94,25 @@ function EndPointMetrics({
|
|||||||
<Skeleton.Button active size="small" />
|
<Skeleton.Button active size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title={metricsData?.errorRate}>
|
<Tooltip title={metricsData?.errorRate}>
|
||||||
<Progress
|
{metricsData?.errorRate !== '-' ? (
|
||||||
status="active"
|
<Progress
|
||||||
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
|
status="active"
|
||||||
strokeLinecap="butt"
|
percent={Number(Number(metricsData?.errorRate ?? 0).toFixed(2))}
|
||||||
size="small"
|
strokeLinecap="butt"
|
||||||
strokeColor={((): string => {
|
size="small"
|
||||||
const errorRatePercent = Number(
|
strokeColor={((): string => {
|
||||||
Number(metricsData?.errorRate ?? 0).toFixed(2),
|
const errorRatePercent = Number(
|
||||||
);
|
Number(metricsData?.errorRate ?? 0).toFixed(2),
|
||||||
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
);
|
||||||
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
if (errorRatePercent >= 90) return Color.BG_SAKURA_500;
|
||||||
return Color.BG_FOREST_500;
|
if (errorRatePercent >= 60) return Color.BG_AMBER_500;
|
||||||
})()}
|
return Color.BG_FOREST_500;
|
||||||
className="progress-bar"
|
})()}
|
||||||
/>
|
className="progress-bar"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
'-'
|
||||||
|
)}
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
@@ -110,7 +120,9 @@ function EndPointMetrics({
|
|||||||
{isLoading || isRefetching ? (
|
{isLoading || isRefetching ? (
|
||||||
<Skeleton.Button active size="small" />
|
<Skeleton.Button active size="small" />
|
||||||
) : (
|
) : (
|
||||||
<Tooltip title={metricsData?.lastUsed}>{metricsData?.lastUsed}</Tooltip>
|
<Tooltip title={metricsData?.lastUsed}>
|
||||||
|
{getDisplayValue(metricsData?.lastUsed)}
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
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';
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ function MetricOverTimeGraph({
|
|||||||
customOnDragSelect={(): void => {}}
|
customOnDragSelect={(): void => {}}
|
||||||
customTimeRange={timeRange}
|
customTimeRange={timeRange}
|
||||||
customTimeRangeWindowForCoRelation="5m"
|
customTimeRangeWindowForCoRelation="5m"
|
||||||
|
version={ENTITY_VERSION_V5}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -8,17 +8,11 @@ import {
|
|||||||
endPointStatusCodeColumns,
|
endPointStatusCodeColumns,
|
||||||
extractPortAndEndpoint,
|
extractPortAndEndpoint,
|
||||||
formatDataForTable,
|
formatDataForTable,
|
||||||
getAllEndpointsWidgetData,
|
|
||||||
getCustomFiltersForBarChart,
|
getCustomFiltersForBarChart,
|
||||||
getEndPointDetailsQueryPayload,
|
|
||||||
getFormattedDependentServicesData,
|
|
||||||
getFormattedEndPointDropDownData,
|
getFormattedEndPointDropDownData,
|
||||||
getFormattedEndPointMetricsData,
|
|
||||||
getFormattedEndPointStatusCodeChartData,
|
getFormattedEndPointStatusCodeChartData,
|
||||||
getFormattedEndPointStatusCodeData,
|
getFormattedEndPointStatusCodeData,
|
||||||
getGroupByFiltersFromGroupByValues,
|
getGroupByFiltersFromGroupByValues,
|
||||||
getLatencyOverTimeWidgetData,
|
|
||||||
getRateOverTimeWidgetData,
|
|
||||||
getStatusCodeBarChartWidgetData,
|
getStatusCodeBarChartWidgetData,
|
||||||
getTopErrorsColumnsConfig,
|
getTopErrorsColumnsConfig,
|
||||||
getTopErrorsCoRelationQueryFilters,
|
getTopErrorsCoRelationQueryFilters,
|
||||||
@@ -49,119 +43,13 @@ 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
|
||||||
@@ -219,6 +107,7 @@ 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: '',
|
||||||
@@ -452,243 +341,6 @@ 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
|
||||||
@@ -698,6 +350,7 @@ 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
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -705,6 +358,7 @@ 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,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -788,87 +442,6 @@ 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
|
||||||
@@ -1005,139 +578,6 @@ 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();
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
/* 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,211 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
/* 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')",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
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'`,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/* 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'");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,226 @@
|
|||||||
|
/* 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { BuilderQuery } from 'api/v5/v5';
|
||||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||||
import { rest, server } from 'mocks-server/server';
|
import { rest, server } from 'mocks-server/server';
|
||||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||||
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(
|
||||||
@@ -36,6 +38,7 @@ describe('TopErrors', () => {
|
|||||||
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range';
|
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range';
|
||||||
|
|
||||||
const mockProps = {
|
const mockProps = {
|
||||||
|
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||||
domainName: 'test-domain',
|
domainName: 'test-domain',
|
||||||
timeRange: {
|
timeRange: {
|
||||||
startTime: 1000000000,
|
startTime: 1000000000,
|
||||||
@@ -305,45 +308,14 @@ describe('TopErrors', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('sends query_range v5 API call with required filters including has_error', async () => {
|
it('sends query_range v5 API call with required filters including has_error', async () => {
|
||||||
let capturedRequest: any;
|
// let capturedRequest: any;
|
||||||
|
|
||||||
// Override the v5 API mock to capture the request
|
const topErrorsPayload = getTopErrorsQueryPayload(
|
||||||
server.use(
|
'test-domain',
|
||||||
rest.post(V5_QUERY_RANGE_API_PATH, async (req, res, ctx) => {
|
mockProps.timeRange.startTime,
|
||||||
capturedRequest = await req.json();
|
mockProps.timeRange.endTime,
|
||||||
return res(
|
{ items: [], op: 'AND' },
|
||||||
ctx.status(200),
|
false,
|
||||||
ctx.json({
|
|
||||||
data: {
|
|
||||||
data: {
|
|
||||||
results: [
|
|
||||||
{
|
|
||||||
columns: [
|
|
||||||
{
|
|
||||||
name: 'http.url',
|
|
||||||
fieldDataType: 'string',
|
|
||||||
fieldContext: 'attribute',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'response_status_code',
|
|
||||||
fieldDataType: 'string',
|
|
||||||
fieldContext: 'span',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'status_message',
|
|
||||||
fieldDataType: 'string',
|
|
||||||
fieldContext: 'span',
|
|
||||||
},
|
|
||||||
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
|
|
||||||
],
|
|
||||||
data: [['/api/test', '500', 'Internal Server Error', 10]],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||||
@@ -351,20 +323,18 @@ describe('TopErrors', () => {
|
|||||||
|
|
||||||
// Wait for the API call to be made
|
// Wait for the API call to be made
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(capturedRequest).toBeDefined();
|
expect(topErrorsPayload).toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Extract the filter expression from the captured request
|
// Extract the filter expression from the captured request
|
||||||
const filterExpression =
|
// getTopErrorsQueryPayload returns a builder_query with TraceBuilderQuery spec
|
||||||
capturedRequest.compositeQuery.queries[0].spec.filter.expression;
|
const builderQuery = topErrorsPayload.compositeQuery.queries[0]
|
||||||
|
.spec as BuilderQuery;
|
||||||
|
const filterExpression = builderQuery.filter?.expression;
|
||||||
|
|
||||||
// Verify all required filters are present
|
// Verify all required filters are present
|
||||||
expect(filterExpression).toContain(`kind_string = 'Client'`);
|
|
||||||
expect(filterExpression).toContain(`(http.url EXISTS OR url.full EXISTS)`);
|
|
||||||
expect(filterExpression).toContain(
|
expect(filterExpression).toContain(
|
||||||
`(net.peer.name = 'test-domain' OR server.address = 'test-domain')`,
|
`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`,
|
||||||
);
|
);
|
||||||
expect(filterExpression).toContain(`has_error = true`);
|
|
||||||
expect(filterExpression).toContain(`status_message EXISTS`); // toggle is on by default
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -112,6 +112,8 @@ 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);
|
||||||
|
|
||||||
@@ -378,6 +380,13 @@ 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]);
|
||||||
@@ -836,7 +845,10 @@ function AppLayout(props: AppLayoutProps): JSX.Element {
|
|||||||
})}
|
})}
|
||||||
data-overlayscrollbars-initialize
|
data-overlayscrollbars-initialize
|
||||||
>
|
>
|
||||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
<Sentry.ErrorBoundary
|
||||||
|
fallback={<ErrorBoundaryFallback />}
|
||||||
|
ref={errorBoundaryRef}
|
||||||
|
>
|
||||||
<LayoutContent data-overlayscrollbars-initialize>
|
<LayoutContent data-overlayscrollbars-initialize>
|
||||||
<OverlayScrollbar>
|
<OverlayScrollbar>
|
||||||
<ChildrenContainer>
|
<ChildrenContainer>
|
||||||
|
|||||||
@@ -57,7 +57,8 @@ 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',
|
||||||
tenant_url: 'localhost',
|
deployment_url: 'localhost',
|
||||||
|
user_email: null,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,3 +3,6 @@ export const THRESHOLD_TAB_TOOLTIP =
|
|||||||
|
|
||||||
export const ANOMALY_TAB_TOOLTIP =
|
export const ANOMALY_TAB_TOOLTIP =
|
||||||
'An alert is triggered whenever the metric deviates from an expected pattern.';
|
'An alert is triggered whenever the metric deviates from an expected pattern.';
|
||||||
|
|
||||||
|
export const ROUTING_POLICIES_ROUTE =
|
||||||
|
'/alerts?tab=Configuration&subTab=routing-policies';
|
||||||
|
|||||||
@@ -289,6 +289,21 @@
|
|||||||
border: 1px solid var(--bg-robin-500);
|
border: 1px solid var(--bg-robin-500);
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
|
|
||||||
|
.routing-policies-info-banner-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.view-routing-policies-button {
|
||||||
|
color: var(--bg-robin-500);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.ant-typography {
|
.ant-typography {
|
||||||
color: var(--bg-robin-500);
|
color: var(--bg-robin-500);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
AlertThresholdOperator,
|
AlertThresholdOperator,
|
||||||
} from 'container/CreateAlertV2/context/types';
|
} from 'container/CreateAlertV2/context/types';
|
||||||
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
import { getSelectedQueryOptions } from 'container/FormAlertRules/utils';
|
||||||
|
import { ArrowRight } from 'lucide-react';
|
||||||
import { IUser } from 'providers/App/types';
|
import { IUser } from 'providers/App/types';
|
||||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||||
import { EQueryType } from 'types/common/dashboard';
|
import { EQueryType } from 'types/common/dashboard';
|
||||||
import { USER_ROLES } from 'types/roles';
|
import { USER_ROLES } from 'types/roles';
|
||||||
|
|
||||||
|
import { ROUTING_POLICIES_ROUTE } from './constants';
|
||||||
import { RoutingPolicyBannerProps } from './types';
|
import { RoutingPolicyBannerProps } from './types';
|
||||||
|
|
||||||
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
export function getQueryNames(currentQuery: Query): BaseOptionType[] {
|
||||||
@@ -400,16 +402,27 @@ export function RoutingPolicyBanner({
|
|||||||
<Typography.Text>
|
<Typography.Text>
|
||||||
Use <strong>Routing Policies</strong> for dynamic routing
|
Use <strong>Routing Policies</strong> for dynamic routing
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
<Switch
|
<div className="routing-policies-info-banner-right">
|
||||||
checked={notificationSettings.routingPolicies}
|
<Switch
|
||||||
data-testid="routing-policies-switch"
|
checked={notificationSettings.routingPolicies}
|
||||||
onChange={(value): void => {
|
data-testid="routing-policies-switch"
|
||||||
setNotificationSettings({
|
onChange={(value): void => {
|
||||||
type: 'SET_ROUTING_POLICIES',
|
setNotificationSettings({
|
||||||
payload: value,
|
type: 'SET_ROUTING_POLICIES',
|
||||||
});
|
payload: value,
|
||||||
}}
|
});
|
||||||
/>
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
href={ROUTING_POLICIES_ROUTE}
|
||||||
|
type="link"
|
||||||
|
className="view-routing-policies-button"
|
||||||
|
data-testid="view-routing-policies-button"
|
||||||
|
>
|
||||||
|
View Routing Policies
|
||||||
|
<ArrowRight size={14} />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -137,7 +137,7 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
|
|
||||||
&::placeholder {
|
&::placeholder {
|
||||||
color: #888;
|
color: var(--bg-vanilla-400);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus,
|
&:focus,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { toast } from '@signozhq/sonner';
|
|||||||
import { Button, Tooltip, Typography } from 'antd';
|
import { Button, Tooltip, Typography } from 'antd';
|
||||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||||
import { Check, Send, X } from 'lucide-react';
|
import { Check, Loader, Send, X } from 'lucide-react';
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo } from 'react';
|
||||||
|
|
||||||
import { useCreateAlertState } from '../context';
|
import { useCreateAlertState } from '../context';
|
||||||
@@ -150,7 +150,11 @@ function Footer(): JSX.Element {
|
|||||||
onClick={handleSaveAlert}
|
onClick={handleSaveAlert}
|
||||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||||
>
|
>
|
||||||
<Check size={14} />
|
{isCreatingAlertRule || isUpdatingAlertRule ? (
|
||||||
|
<Loader size={14} />
|
||||||
|
) : (
|
||||||
|
<Check size={14} />
|
||||||
|
)}
|
||||||
<Typography.Text>Save Alert Rule</Typography.Text>
|
<Typography.Text>Save Alert Rule</Typography.Text>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -158,7 +162,13 @@ function Footer(): JSX.Element {
|
|||||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||||
}
|
}
|
||||||
return button;
|
return button;
|
||||||
}, [alertValidationMessage, disableButtons, handleSaveAlert]);
|
}, [
|
||||||
|
alertValidationMessage,
|
||||||
|
disableButtons,
|
||||||
|
handleSaveAlert,
|
||||||
|
isCreatingAlertRule,
|
||||||
|
isUpdatingAlertRule,
|
||||||
|
]);
|
||||||
|
|
||||||
const testAlertButton = useMemo(() => {
|
const testAlertButton = useMemo(() => {
|
||||||
let button = (
|
let button = (
|
||||||
@@ -167,7 +177,7 @@ function Footer(): JSX.Element {
|
|||||||
onClick={handleTestNotification}
|
onClick={handleTestNotification}
|
||||||
disabled={disableButtons || Boolean(alertValidationMessage)}
|
disabled={disableButtons || Boolean(alertValidationMessage)}
|
||||||
>
|
>
|
||||||
<Send size={14} />
|
{isTestingAlertRule ? <Loader size={14} /> : <Send size={14} />}
|
||||||
<Typography.Text>Test Notification</Typography.Text>
|
<Typography.Text>Test Notification</Typography.Text>
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
@@ -175,7 +185,12 @@ function Footer(): JSX.Element {
|
|||||||
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
button = <Tooltip title={alertValidationMessage}>{button}</Tooltip>;
|
||||||
}
|
}
|
||||||
return button;
|
return button;
|
||||||
}, [alertValidationMessage, disableButtons, handleTestNotification]);
|
}, [
|
||||||
|
alertValidationMessage,
|
||||||
|
disableButtons,
|
||||||
|
handleTestNotification,
|
||||||
|
isTestingAlertRule,
|
||||||
|
]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="create-alert-v2-footer">
|
<div className="create-alert-v2-footer">
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ const SAVE_ALERT_RULE_TEXT = 'Save Alert Rule';
|
|||||||
const TEST_NOTIFICATION_TEXT = 'Test Notification';
|
const TEST_NOTIFICATION_TEXT = 'Test Notification';
|
||||||
const DISCARD_TEXT = 'Discard';
|
const DISCARD_TEXT = 'Discard';
|
||||||
|
|
||||||
|
const LOADER_ICON_SELECTOR = 'svg.lucide-loader';
|
||||||
|
const CHECK_ICON_SELECTOR = 'svg.lucide-check';
|
||||||
|
const PLAY_ICON_SELECTOR = 'svg.lucide-play';
|
||||||
|
|
||||||
describe('Footer', () => {
|
describe('Footer', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
useQueryBuilder.mockReturnValue({
|
useQueryBuilder.mockReturnValue({
|
||||||
@@ -245,4 +249,61 @@ describe('Footer', () => {
|
|||||||
).toBeEnabled();
|
).toBeEnabled();
|
||||||
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
|
expect(screen.getByRole('button', { name: /discard/i })).toBeEnabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should show loader icon on test notification button when testing alert rule', () => {
|
||||||
|
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||||
|
...mockAlertContextState,
|
||||||
|
isTestingAlertRule: true,
|
||||||
|
});
|
||||||
|
const { container } = render(<Footer />);
|
||||||
|
|
||||||
|
// When testing alert rule, the play icon is replaced with a loader icon
|
||||||
|
const playIconForTestNotificationButton = container.querySelector(
|
||||||
|
PLAY_ICON_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(playIconForTestNotificationButton).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const loaderIconForTestNotificationButton = container.querySelector(
|
||||||
|
LOADER_ICON_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(loaderIconForTestNotificationButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show check icon on save alert rule button when updating alert rule', () => {
|
||||||
|
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||||
|
...mockAlertContextState,
|
||||||
|
isUpdatingAlertRule: true,
|
||||||
|
});
|
||||||
|
const { container } = render(<Footer />);
|
||||||
|
|
||||||
|
// When updating alert rule, the check icon is replaced with a loader icon
|
||||||
|
const checkIconForSaveAlertRuleButton = container.querySelector(
|
||||||
|
CHECK_ICON_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(checkIconForSaveAlertRuleButton).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const loaderIconForSaveAlertRuleButton = container.querySelector(
|
||||||
|
LOADER_ICON_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(loaderIconForSaveAlertRuleButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not show check icon on save alert rule button when creating alert rule', () => {
|
||||||
|
jest.spyOn(createAlertState, 'useCreateAlertState').mockReturnValueOnce({
|
||||||
|
...mockAlertContextState,
|
||||||
|
isCreatingAlertRule: true,
|
||||||
|
});
|
||||||
|
const { container } = render(<Footer />);
|
||||||
|
|
||||||
|
// When creating alert rule, the check icon is replaced with a loader icon
|
||||||
|
const checkIconForSaveAlertRuleButton = container.querySelector(
|
||||||
|
CHECK_ICON_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(checkIconForSaveAlertRuleButton).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
const loaderIconForSaveAlertRuleButton = container.querySelector(
|
||||||
|
LOADER_ICON_SELECTOR,
|
||||||
|
);
|
||||||
|
expect(loaderIconForSaveAlertRuleButton).toBeInTheDocument();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
.query-section-tabs {
|
.query-section-tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-left: 12px;
|
margin-left: 8px;
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
|
|
||||||
.query-section-query-actions {
|
.query-section-query-actions {
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ function ExplorerOptionWrapper({
|
|||||||
isOneChartPerQuery,
|
isOneChartPerQuery,
|
||||||
splitedQueries,
|
splitedQueries,
|
||||||
signalSource,
|
signalSource,
|
||||||
|
handleChangeSelectedView,
|
||||||
}: ExplorerOptionsWrapperProps): JSX.Element {
|
}: ExplorerOptionsWrapperProps): JSX.Element {
|
||||||
const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false);
|
const [isExplorerOptionHidden, setIsExplorerOptionHidden] = useState(false);
|
||||||
|
|
||||||
@@ -38,6 +39,7 @@ function ExplorerOptionWrapper({
|
|||||||
setIsExplorerOptionHidden={setIsExplorerOptionHidden}
|
setIsExplorerOptionHidden={setIsExplorerOptionHidden}
|
||||||
isOneChartPerQuery={isOneChartPerQuery}
|
isOneChartPerQuery={isOneChartPerQuery}
|
||||||
splitedQueries={splitedQueries}
|
splitedQueries={splitedQueries}
|
||||||
|
handleChangeSelectedView={handleChangeSelectedView}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -72,10 +72,11 @@ import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
|||||||
import { ViewProps } from 'types/api/saveViews/types';
|
import { ViewProps } from 'types/api/saveViews/types';
|
||||||
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
import { DataSource, StringOperators } from 'types/common/queryBuilder';
|
||||||
import { USER_ROLES } from 'types/roles';
|
import { USER_ROLES } from 'types/roles';
|
||||||
|
import { panelTypeToExplorerView } from 'utils/explorerUtils';
|
||||||
|
|
||||||
import { PreservedViewsTypes } from './constants';
|
import { PreservedViewsTypes } from './constants';
|
||||||
import ExplorerOptionsHideArea from './ExplorerOptionsHideArea';
|
import ExplorerOptionsHideArea from './ExplorerOptionsHideArea';
|
||||||
import { PreservedViewsInLocalStorage } from './types';
|
import { ChangeViewFunctionType, PreservedViewsInLocalStorage } from './types';
|
||||||
import {
|
import {
|
||||||
DATASOURCE_VS_ROUTES,
|
DATASOURCE_VS_ROUTES,
|
||||||
generateRGBAFromHex,
|
generateRGBAFromHex,
|
||||||
@@ -98,6 +99,7 @@ function ExplorerOptions({
|
|||||||
setIsExplorerOptionHidden,
|
setIsExplorerOptionHidden,
|
||||||
isOneChartPerQuery = false,
|
isOneChartPerQuery = false,
|
||||||
splitedQueries = [],
|
splitedQueries = [],
|
||||||
|
handleChangeSelectedView,
|
||||||
}: ExplorerOptionsProps): JSX.Element {
|
}: ExplorerOptionsProps): JSX.Element {
|
||||||
const [isExport, setIsExport] = useState<boolean>(false);
|
const [isExport, setIsExport] = useState<boolean>(false);
|
||||||
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
const [isSaveModalOpen, setIsSaveModalOpen] = useState(false);
|
||||||
@@ -412,13 +414,22 @@ function ExplorerOptions({
|
|||||||
if (!currentViewDetails) return;
|
if (!currentViewDetails) return;
|
||||||
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
const { query, name, id, panelType: currentPanelType } = currentViewDetails;
|
||||||
|
|
||||||
handleExplorerTabChange(currentPanelType, {
|
if (handleChangeSelectedView) {
|
||||||
query,
|
handleChangeSelectedView(panelTypeToExplorerView[currentPanelType], {
|
||||||
name,
|
query,
|
||||||
id,
|
name,
|
||||||
});
|
id,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// to remove this after traces cleanup
|
||||||
|
handleExplorerTabChange(currentPanelType, {
|
||||||
|
query,
|
||||||
|
name,
|
||||||
|
id,
|
||||||
|
});
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[viewsData, handleExplorerTabChange],
|
[viewsData, handleExplorerTabChange, handleChangeSelectedView],
|
||||||
);
|
);
|
||||||
|
|
||||||
const updatePreservedViewInLocalStorage = (option: {
|
const updatePreservedViewInLocalStorage = (option: {
|
||||||
@@ -524,6 +535,10 @@ function ExplorerOptions({
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (handleChangeSelectedView) {
|
||||||
|
handleChangeSelectedView(panelTypeToExplorerView[PANEL_TYPES.LIST]);
|
||||||
|
}
|
||||||
|
|
||||||
history.replace(DATASOURCE_VS_ROUTES[sourcepage]);
|
history.replace(DATASOURCE_VS_ROUTES[sourcepage]);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1020,6 +1035,7 @@ export interface ExplorerOptionsProps {
|
|||||||
setIsExplorerOptionHidden?: Dispatch<SetStateAction<boolean>>;
|
setIsExplorerOptionHidden?: Dispatch<SetStateAction<boolean>>;
|
||||||
isOneChartPerQuery?: boolean;
|
isOneChartPerQuery?: boolean;
|
||||||
splitedQueries?: Query[];
|
splitedQueries?: Query[];
|
||||||
|
handleChangeSelectedView?: ChangeViewFunctionType;
|
||||||
}
|
}
|
||||||
|
|
||||||
ExplorerOptions.defaultProps = {
|
ExplorerOptions.defaultProps = {
|
||||||
@@ -1029,6 +1045,7 @@ ExplorerOptions.defaultProps = {
|
|||||||
isOneChartPerQuery: false,
|
isOneChartPerQuery: false,
|
||||||
splitedQueries: [],
|
splitedQueries: [],
|
||||||
signalSource: '',
|
signalSource: '',
|
||||||
|
handleChangeSelectedView: undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ExplorerOptions;
|
export default ExplorerOptions;
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { NotificationInstance } from 'antd/es/notification/interface';
|
|||||||
import { AxiosResponse } from 'axios';
|
import { AxiosResponse } from 'axios';
|
||||||
import { SaveViewWithNameProps } from 'components/ExplorerCard/types';
|
import { SaveViewWithNameProps } from 'components/ExplorerCard/types';
|
||||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||||
|
import { ICurrentQueryData } from 'hooks/useHandleExplorerTabChange';
|
||||||
|
import { ExplorerViews } from 'pages/LogsExplorer/utils';
|
||||||
import { Dispatch, SetStateAction } from 'react';
|
import { Dispatch, SetStateAction } from 'react';
|
||||||
import { UseMutateAsyncFunction } from 'react-query';
|
import { UseMutateAsyncFunction } from 'react-query';
|
||||||
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
import { ICompositeMetricQuery } from 'types/api/alerts/compositeQuery';
|
||||||
@@ -38,3 +40,8 @@ export type PreservedViewType =
|
|||||||
export type PreservedViewsInLocalStorage = Partial<
|
export type PreservedViewsInLocalStorage = Partial<
|
||||||
Record<PreservedViewType, { key: string; value: string }>
|
Record<PreservedViewType, { key: string; value: string }>
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
export type ChangeViewFunctionType = (
|
||||||
|
view: ExplorerViews,
|
||||||
|
querySearchParameters?: ICurrentQueryData,
|
||||||
|
) => void;
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
|
import { CaretDownFilled, CaretRightFilled } from '@ant-design/icons';
|
||||||
import { Col, Typography } from 'antd';
|
import { Col, Typography } from 'antd';
|
||||||
import { StyledCol, StyledRow } from 'components/Styled';
|
import { StyledCol, StyledRow } from 'components/Styled';
|
||||||
import { IIntervalUnit } from 'container/TraceDetail/utils';
|
import {
|
||||||
|
IIntervalUnit,
|
||||||
|
SPAN_DETAILS_LEFT_COL_WIDTH,
|
||||||
|
} from 'container/TraceDetail/utils';
|
||||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||||
import { SPAN_DETAILS_LEFT_COL_WIDTH } from 'pages/TraceDetail/constants';
|
|
||||||
import {
|
import {
|
||||||
Dispatch,
|
Dispatch,
|
||||||
MouseEventHandler,
|
MouseEventHandler,
|
||||||
|
|||||||
@@ -49,17 +49,29 @@ function GridTableComponent({
|
|||||||
panelType,
|
panelType,
|
||||||
queryRangeRequest,
|
queryRangeRequest,
|
||||||
decimalPrecision,
|
decimalPrecision,
|
||||||
|
hiddenColumns = [],
|
||||||
...props
|
...props
|
||||||
}: GridTableComponentProps): JSX.Element {
|
}: GridTableComponentProps): JSX.Element {
|
||||||
const { t } = useTranslation(['valueGraph']);
|
const { t } = useTranslation(['valueGraph']);
|
||||||
|
|
||||||
// create columns and dataSource in the ui friendly structure
|
// create columns and dataSource in the ui friendly structure
|
||||||
// use the query from the widget here to extract the legend information
|
// use the query from the widget here to extract the legend information
|
||||||
const { columns, dataSource: originalDataSource } = useMemo(
|
const { columns: allColumns, dataSource: originalDataSource } = useMemo(
|
||||||
() => createColumnsAndDataSource((data as unknown) as TableData, query),
|
() => createColumnsAndDataSource((data as unknown) as TableData, query),
|
||||||
[query, data],
|
[query, data],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Filter out hidden columns from being displayed
|
||||||
|
const columns = useMemo(
|
||||||
|
() =>
|
||||||
|
allColumns.filter(
|
||||||
|
(column) =>
|
||||||
|
!('dataIndex' in column) ||
|
||||||
|
!hiddenColumns.includes(column.dataIndex as string),
|
||||||
|
),
|
||||||
|
[allColumns, hiddenColumns],
|
||||||
|
);
|
||||||
|
|
||||||
const createDataInCorrectFormat = useCallback(
|
const createDataInCorrectFormat = useCallback(
|
||||||
(dataSource: RowData[]): RowData[] =>
|
(dataSource: RowData[]): RowData[] =>
|
||||||
dataSource.map((d) => {
|
dataSource.map((d) => {
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ export type GridTableComponentProps = {
|
|||||||
contextLinks?: ContextLinksData;
|
contextLinks?: ContextLinksData;
|
||||||
panelType?: PANEL_TYPES;
|
panelType?: PANEL_TYPES;
|
||||||
queryRangeRequest?: QueryRangeRequestV5;
|
queryRangeRequest?: QueryRangeRequestV5;
|
||||||
|
hiddenColumns?: string[];
|
||||||
} & Pick<LogsExplorerTableProps, 'data'> &
|
} & Pick<LogsExplorerTableProps, 'data'> &
|
||||||
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
Omit<TableProps<RowData>, 'columns' | 'dataSource'>;
|
||||||
|
|
||||||
|
|||||||
@@ -11,10 +11,6 @@ import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
|||||||
import { FontSize } from 'container/OptionsMenu/types';
|
import { FontSize } from 'container/OptionsMenu/types';
|
||||||
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
import { useHandleLogsPagination } from 'hooks/infraMonitoring/useHandleLogsPagination';
|
||||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||||
import {
|
|
||||||
LOG_FIELD_BODY_KEY,
|
|
||||||
LOG_FIELD_TIMESTAMP_KEY,
|
|
||||||
} from 'lib/logs/flatLogData';
|
|
||||||
import { useCallback, useEffect, useMemo } from 'react';
|
import { useCallback, useEffect, useMemo } from 'react';
|
||||||
import { useQuery } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
@@ -79,15 +75,11 @@ function EntityLogs({
|
|||||||
dataType: 'string',
|
dataType: 'string',
|
||||||
type: '',
|
type: '',
|
||||||
name: 'body',
|
name: 'body',
|
||||||
displayName: 'Body',
|
|
||||||
key: LOG_FIELD_BODY_KEY,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
dataType: 'string',
|
dataType: 'string',
|
||||||
type: '',
|
type: '',
|
||||||
name: 'timestamp',
|
name: 'timestamp',
|
||||||
displayName: 'Timestamp',
|
|
||||||
key: LOG_FIELD_TIMESTAMP_KEY,
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user