Compare commits
34 Commits
v0.99.0
...
v0.101.0-r
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
89639b239e | ||
|
|
785ae9f0bd | ||
|
|
8752022cef | ||
|
|
c7e4a9c45d | ||
|
|
bf92c92204 | ||
|
|
bd63633be7 | ||
|
|
1158e1199b | ||
|
|
0a60c49314 | ||
|
|
c25e3beb81 | ||
|
|
c9e0f2b9ca | ||
|
|
6d831849c1 | ||
|
|
83eeb46f99 | ||
|
|
287558dc9d | ||
|
|
83aad793c2 | ||
|
|
3eff689c85 | ||
|
|
f5bcd65e2e | ||
|
|
e7772d93af | ||
|
|
bbf987ebd7 | ||
|
|
105c3a3b8c | ||
|
|
c1a4a5b8db | ||
|
|
c9591f4341 | ||
|
|
fd216fdee1 | ||
|
|
f5bf4293a1 | ||
|
|
155a44a25d | ||
|
|
4b21c9d5f9 | ||
|
|
5ef0a18867 | ||
|
|
c8266d1aec | ||
|
|
adfd16ce1b | ||
|
|
6db74a5585 | ||
|
|
f8e0db0085 | ||
|
|
01e0b36d62 | ||
|
|
e90bb016f7 | ||
|
|
bdecbfb7f5 | ||
|
|
3dced2b082 |
@@ -42,7 +42,7 @@ services:
|
||||
timeout: 5s
|
||||
retries: 3
|
||||
schema-migrator-sync:
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -55,7 +55,7 @@ services:
|
||||
condition: service_healthy
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -2,7 +2,7 @@
|
||||
# Owners are automatically requested for review for PRs that changes code
|
||||
# that they own.
|
||||
|
||||
/frontend/ @SigNoz/frontend @YounixM
|
||||
/frontend/ @YounixM @aks07
|
||||
/frontend/src/container/MetricsApplication @srikanthccv
|
||||
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
|
||||
|
||||
|
||||
1
.github/workflows/build-enterprise.yaml
vendored
1
.github/workflows/build-enterprise.yaml
vendored
@@ -107,7 +107,6 @@ jobs:
|
||||
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
|
||||
1
.github/workflows/build-staging.yaml
vendored
1
.github/workflows/build-staging.yaml
vendored
@@ -106,7 +106,6 @@ jobs:
|
||||
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
|
||||
1
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
- bootstrap
|
||||
- passwordauthn
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- querier
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
|
||||
@@ -1,43 +1,63 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: standard
|
||||
default: none
|
||||
enable:
|
||||
- bodyclose
|
||||
- depguard
|
||||
- errcheck
|
||||
- forbidigo
|
||||
- govet
|
||||
- iface
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nilnil
|
||||
- sloglint
|
||||
- depguard
|
||||
- iface
|
||||
- unparam
|
||||
- forbidigo
|
||||
|
||||
linters-settings:
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
kv-only: true
|
||||
no-global: all
|
||||
context: all
|
||||
static-msg: true
|
||||
msg-style: lowercased
|
||||
key-naming-case: snake
|
||||
depguard:
|
||||
rules:
|
||||
nozap:
|
||||
deny:
|
||||
- pkg: "go.uber.org/zap"
|
||||
desc: "Do not use zap logger. Use slog instead."
|
||||
noerrors:
|
||||
deny:
|
||||
- pkg: "errors"
|
||||
desc: "Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead."
|
||||
iface:
|
||||
enable:
|
||||
- identical
|
||||
forbidigo:
|
||||
forbid:
|
||||
- fmt.Errorf
|
||||
- ^(fmt\.Print.*|print|println)$
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- "pkg/query-service"
|
||||
- "ee/query-service"
|
||||
- "scripts/"
|
||||
- unused
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
noerrors:
|
||||
deny:
|
||||
- pkg: errors
|
||||
desc: Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead.
|
||||
nozap:
|
||||
deny:
|
||||
- pkg: go.uber.org/zap
|
||||
desc: Do not use zap logger. Use slog instead.
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: fmt.Errorf
|
||||
- pattern: ^(fmt\.Print.*|print|println)$
|
||||
iface:
|
||||
enable:
|
||||
- identical
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
kv-only: true
|
||||
no-global: all
|
||||
context: all
|
||||
static-msg: true
|
||||
key-naming-case: snake
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- pkg/query-service
|
||||
- ee/query-service
|
||||
- scripts/
|
||||
- tmp/
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
@@ -31,7 +31,6 @@ builds:
|
||||
- -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }}
|
||||
- -X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
|
||||
- -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
|
||||
- -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
|
||||
- -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
|
||||
- -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.99.0
|
||||
image: signoz/signoz:v0.100.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -209,7 +209,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.7
|
||||
image: signoz/signoz-otel-collector:v0.129.8
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -233,7 +233,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.99.0
|
||||
image: signoz/signoz:v0.100.1
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
@@ -150,7 +150,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:v0.129.7
|
||||
image: signoz/signoz-otel-collector:v0.129.8
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
- --manager-config=/etc/manager-config.yaml
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
- signoz
|
||||
schema-migrator:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:v0.129.7
|
||||
image: signoz/signoz-schema-migrator:v0.129.8
|
||||
deploy:
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
connectors:
|
||||
signozmeter:
|
||||
metrics_flush_interval: 1h
|
||||
dimensions:
|
||||
- name: service.name
|
||||
- name: deployment.environment
|
||||
- name: host.name
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
@@ -21,6 +28,10 @@ processors:
|
||||
send_batch_size: 10000
|
||||
send_batch_max_size: 11000
|
||||
timeout: 10s
|
||||
batch/meter:
|
||||
send_batch_max_size: 25000
|
||||
send_batch_size: 20000
|
||||
timeout: 1s
|
||||
resourcedetection:
|
||||
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
|
||||
detectors: [env, system]
|
||||
@@ -66,6 +77,11 @@ exporters:
|
||||
dsn: tcp://clickhouse:9000/signoz_logs
|
||||
timeout: 10s
|
||||
use_new_schema: true
|
||||
signozclickhousemeter:
|
||||
dsn: tcp://clickhouse:9000/signoz_meter
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -77,16 +93,20 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
exporters: [signozclickhousemeter]
|
||||
|
||||
@@ -179,7 +179,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
image: signoz/signoz:${VERSION:-v0.100.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -213,7 +213,7 @@ services:
|
||||
# TODO: support otel-collector multiple replicas. Nginx/Traefik for loadbalancing?
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -239,7 +239,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -250,7 +250,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -111,7 +111,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
image: signoz/signoz:${VERSION:-v0.100.1}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
@@ -144,7 +144,7 @@ services:
|
||||
retries: 3
|
||||
otel-collector:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-otel-collector:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: signoz-otel-collector
|
||||
command:
|
||||
- --config=/etc/otel-collector-config.yaml
|
||||
@@ -166,7 +166,7 @@ services:
|
||||
condition: service_healthy
|
||||
schema-migrator-sync:
|
||||
!!merge <<: *common
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-sync
|
||||
command:
|
||||
- sync
|
||||
@@ -178,7 +178,7 @@ services:
|
||||
restart: on-failure
|
||||
schema-migrator-async:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.7}
|
||||
image: signoz/signoz-schema-migrator:${OTELCOL_TAG:-v0.129.8}
|
||||
container_name: schema-migrator-async
|
||||
command:
|
||||
- async
|
||||
|
||||
@@ -1,3 +1,10 @@
|
||||
connectors:
|
||||
signozmeter:
|
||||
metrics_flush_interval: 1h
|
||||
dimensions:
|
||||
- name: service.name
|
||||
- name: deployment.environment
|
||||
- name: host.name
|
||||
receivers:
|
||||
otlp:
|
||||
protocols:
|
||||
@@ -21,6 +28,10 @@ processors:
|
||||
send_batch_size: 10000
|
||||
send_batch_max_size: 11000
|
||||
timeout: 10s
|
||||
batch/meter:
|
||||
send_batch_max_size: 25000
|
||||
send_batch_size: 20000
|
||||
timeout: 1s
|
||||
resourcedetection:
|
||||
# Using OTEL_RESOURCE_ATTRIBUTES envvar, env detector adds custom labels.
|
||||
detectors: [env, system]
|
||||
@@ -66,6 +77,11 @@ exporters:
|
||||
dsn: tcp://clickhouse:9000/signoz_logs
|
||||
timeout: 10s
|
||||
use_new_schema: true
|
||||
signozclickhousemeter:
|
||||
dsn: tcp://clickhouse:9000/signoz_meter
|
||||
timeout: 45s
|
||||
sending_queue:
|
||||
enabled: false
|
||||
service:
|
||||
telemetry:
|
||||
logs:
|
||||
@@ -77,16 +93,20 @@ service:
|
||||
traces:
|
||||
receivers: [otlp]
|
||||
processors: [signozspanmetrics/delta, batch]
|
||||
exporters: [clickhousetraces]
|
||||
exporters: [clickhousetraces, signozmeter]
|
||||
metrics:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
metrics/prometheus:
|
||||
receivers: [prometheus]
|
||||
processors: [batch]
|
||||
exporters: [signozclickhousemetrics]
|
||||
exporters: [signozclickhousemetrics, signozmeter]
|
||||
logs:
|
||||
receivers: [otlp]
|
||||
processors: [batch]
|
||||
exporters: [clickhouselogsexporter]
|
||||
exporters: [clickhouselogsexporter, signozmeter]
|
||||
metrics/meter:
|
||||
receivers: [signozmeter]
|
||||
processors: [batch/meter]
|
||||
exporters: [signozclickhousemeter]
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
@@ -77,7 +76,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
return
|
||||
}
|
||||
|
||||
ingestionUrl, signozApiUrl, apiErr := getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't deduce ingestion url and signoz api url",
|
||||
@@ -186,48 +185,37 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
return cloudIntegrationUser, nil
|
||||
}
|
||||
|
||||
func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
string, string, *basemodel.ApiError,
|
||||
) {
|
||||
url := fmt.Sprintf(
|
||||
"%s%s",
|
||||
strings.TrimSuffix(constants.ZeusURL, "/"),
|
||||
"/v2/deployments/me",
|
||||
)
|
||||
|
||||
// TODO: remove this struct from here
|
||||
type deploymentResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
ClusterInfo struct {
|
||||
Region struct {
|
||||
DNS string `json:"dns"`
|
||||
} `json:"region"`
|
||||
} `json:"cluster"`
|
||||
} `json:"data"`
|
||||
Name string `json:"name"`
|
||||
ClusterInfo struct {
|
||||
Region struct {
|
||||
DNS string `json:"dns"`
|
||||
} `json:"region"`
|
||||
} `json:"cluster"`
|
||||
}
|
||||
|
||||
resp, apiErr := requestAndParseResponse[deploymentResponse](
|
||||
ctx, url, map[string]string{"X-Signoz-Cloud-Api-Key": licenseKey}, nil,
|
||||
)
|
||||
|
||||
if apiErr != nil {
|
||||
return "", "", basemodel.WrapApiError(
|
||||
apiErr, "couldn't query for deployment info",
|
||||
)
|
||||
}
|
||||
|
||||
if resp.Status != "success" {
|
||||
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
|
||||
if err != nil {
|
||||
return "", "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't query for deployment info: status: %s, error: %s",
|
||||
resp.Status, resp.Error,
|
||||
"couldn't query for deployment info: error: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
regionDns := resp.Data.ClusterInfo.Region.DNS
|
||||
deploymentName := resp.Data.Name
|
||||
resp := new(deploymentResponse)
|
||||
|
||||
err = json.Unmarshal(respBytes, resp)
|
||||
if err != nil {
|
||||
return "", "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't unmarshal deployment info response: error: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
regionDns := resp.ClusterInfo.Region.DNS
|
||||
deploymentName := resp.Name
|
||||
|
||||
if len(regionDns) < 1 || len(deploymentName) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
|
||||
@@ -10,9 +10,6 @@ var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
|
||||
var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false")
|
||||
var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL")
|
||||
|
||||
// this is set via build time variable
|
||||
var ZeusURL = "https://api.signoz.cloud"
|
||||
|
||||
func GetOrDefaultEnv(key string, fallback string) string {
|
||||
v := os.Getenv(key)
|
||||
if len(v) == 0 {
|
||||
|
||||
153
ee/sqlstore/postgressqlstore/formatter.go
Normal file
153
ee/sqlstore/postgressqlstore/formatter.go
Normal file
@@ -0,0 +1,153 @@
|
||||
package postgressqlstore
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/sqlstore"
|
||||
"github.com/uptrace/bun/schema"
|
||||
)
|
||||
|
||||
type formatter struct {
|
||||
bunf schema.Formatter
|
||||
}
|
||||
|
||||
func newFormatter(dialect schema.Dialect) sqlstore.SQLFormatter {
|
||||
return &formatter{bunf: schema.NewFormatter(dialect)}
|
||||
}
|
||||
|
||||
func (f *formatter) JSONExtractString(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgres(path)...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONType(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_typeof("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONIsArray(column, path string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, f.JSONType(column, path)...)
|
||||
sql = append(sql, " = "...)
|
||||
sql = schema.Append(f.bunf, sql, "array")
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayElements(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_array_elements("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, []byte(alias)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayOfStrings(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_array_elements_text("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), "::text"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONKeys(column, path, alias string) ([]byte, []byte) {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_each("...)
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, f.convertJSONPathToPostgresWithMode(path, false)...)
|
||||
sql = append(sql, ") AS "...)
|
||||
sql = f.bunf.AppendIdent(sql, alias)
|
||||
|
||||
return sql, append([]byte(alias), ".key"...)
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayAgg(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_agg("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) JSONArrayLiteral(values ...string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "jsonb_build_array("...)
|
||||
for idx, value := range values {
|
||||
if idx > 0 {
|
||||
sql = append(sql, ", "...)
|
||||
}
|
||||
sql = schema.Append(f.bunf, sql, value)
|
||||
}
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) TextToJsonColumn(column string) []byte {
|
||||
var sql []byte
|
||||
sql = f.bunf.AppendIdent(sql, column)
|
||||
sql = append(sql, "::jsonb"...)
|
||||
return sql
|
||||
}
|
||||
|
||||
func (f *formatter) convertJSONPathToPostgres(jsonPath string) []byte {
|
||||
return f.convertJSONPathToPostgresWithMode(jsonPath, true)
|
||||
}
|
||||
|
||||
func (f *formatter) convertJSONPathToPostgresWithMode(jsonPath string, asText bool) []byte {
|
||||
path := strings.TrimPrefix(strings.TrimPrefix(jsonPath, "$"), ".")
|
||||
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
parts := strings.Split(path, ".")
|
||||
|
||||
var validParts []string
|
||||
for _, part := range parts {
|
||||
if part != "" {
|
||||
validParts = append(validParts, part)
|
||||
}
|
||||
}
|
||||
|
||||
if len(validParts) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
var result []byte
|
||||
|
||||
for idx, part := range validParts {
|
||||
if idx == len(validParts)-1 {
|
||||
if asText {
|
||||
result = append(result, "->>"...)
|
||||
} else {
|
||||
result = append(result, "->"...)
|
||||
}
|
||||
result = schema.Append(f.bunf, result, part)
|
||||
return result
|
||||
}
|
||||
|
||||
result = append(result, "->"...)
|
||||
result = schema.Append(f.bunf, result, part)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func (f *formatter) LowerExpression(expression string) []byte {
|
||||
var sql []byte
|
||||
sql = append(sql, "lower("...)
|
||||
sql = append(sql, expression...)
|
||||
sql = append(sql, ')')
|
||||
return sql
|
||||
}
|
||||
500
ee/sqlstore/postgressqlstore/formatter_test.go
Normal file
500
ee/sqlstore/postgressqlstore/formatter_test.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package postgressqlstore
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/uptrace/bun/dialect/pgdialect"
|
||||
)
|
||||
|
||||
func TestJSONExtractString(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.field",
|
||||
expected: `"data"->>'field'`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.name",
|
||||
expected: `"metadata"->'user'->>'name'`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested path",
|
||||
column: "json_col",
|
||||
path: "$.level1.level2.level3",
|
||||
expected: `"json_col"->'level1'->'level2'->>'level3'`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `"json_col"`,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
column: "data",
|
||||
path: "",
|
||||
expected: `"data"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONExtractString(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.field",
|
||||
expected: `jsonb_typeof("data"->'field')`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.age",
|
||||
expected: `jsonb_typeof("metadata"->'user'->'age')`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `jsonb_typeof("json_col")`,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
column: "data",
|
||||
path: "",
|
||||
expected: `jsonb_typeof("data")`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONType(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONIsArray(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path",
|
||||
column: "data",
|
||||
path: "$.items",
|
||||
expected: `jsonb_typeof("data"->'items') = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.user.tags",
|
||||
expected: `jsonb_typeof("metadata"->'user'->'tags') = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
column: "json_col",
|
||||
path: "$",
|
||||
expected: `jsonb_typeof("json_col") = 'array'`,
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
column: "data",
|
||||
path: "",
|
||||
expected: `jsonb_typeof("data") = 'array'`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONIsArray(tt.column, tt.path))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayElements(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "elem",
|
||||
expected: `jsonb_array_elements("data") AS "elem"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "elem",
|
||||
expected: `jsonb_array_elements("data") AS "elem"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.items",
|
||||
alias: "item",
|
||||
expected: `jsonb_array_elements("metadata"->'items') AS "item"`,
|
||||
},
|
||||
{
|
||||
name: "deeply nested path",
|
||||
column: "json_col",
|
||||
path: "$.user.tags",
|
||||
alias: "tag",
|
||||
expected: `jsonb_array_elements("json_col"->'user'->'tags') AS "tag"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got, _ := f.JSONArrayElements(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayOfStrings(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "str",
|
||||
expected: `jsonb_array_elements_text("data") AS "str"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "str",
|
||||
expected: `jsonb_array_elements_text("data") AS "str"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.strings",
|
||||
alias: "s",
|
||||
expected: `jsonb_array_elements_text("metadata"->'strings') AS "s"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got, _ := f.JSONArrayOfStrings(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONKeys(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
path string
|
||||
alias string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "root path with dollar sign",
|
||||
column: "data",
|
||||
path: "$",
|
||||
alias: "k",
|
||||
expected: `jsonb_each("data") AS "k"`,
|
||||
},
|
||||
{
|
||||
name: "root path empty",
|
||||
column: "data",
|
||||
path: "",
|
||||
alias: "k",
|
||||
expected: `jsonb_each("data") AS "k"`,
|
||||
},
|
||||
{
|
||||
name: "nested path",
|
||||
column: "metadata",
|
||||
path: "$.object",
|
||||
alias: "key",
|
||||
expected: `jsonb_each("metadata"->'object') AS "key"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got, _ := f.JSONKeys(tt.column, tt.path, tt.alias)
|
||||
assert.Equal(t, tt.expected, string(got))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayAgg(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expression string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column",
|
||||
expression: "id",
|
||||
expected: "jsonb_agg(id)",
|
||||
},
|
||||
{
|
||||
name: "expression with function",
|
||||
expression: "DISTINCT name",
|
||||
expected: "jsonb_agg(DISTINCT name)",
|
||||
},
|
||||
{
|
||||
name: "complex expression",
|
||||
expression: "data->>'field'",
|
||||
expected: "jsonb_agg(data->>'field')",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONArrayAgg(tt.expression))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestJSONArrayLiteral(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
values []string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "empty array",
|
||||
values: []string{},
|
||||
expected: "jsonb_build_array()",
|
||||
},
|
||||
{
|
||||
name: "single value",
|
||||
values: []string{"value1"},
|
||||
expected: "jsonb_build_array('value1')",
|
||||
},
|
||||
{
|
||||
name: "multiple values",
|
||||
values: []string{"value1", "value2", "value3"},
|
||||
expected: "jsonb_build_array('value1', 'value2', 'value3')",
|
||||
},
|
||||
{
|
||||
name: "values with special characters",
|
||||
values: []string{"test", "with space", "with-dash"},
|
||||
expected: "jsonb_build_array('test', 'with space', 'with-dash')",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.JSONArrayLiteral(tt.values...))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertJSONPathToPostgresWithMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
jsonPath string
|
||||
asText bool
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple path as text",
|
||||
jsonPath: "$.field",
|
||||
asText: true,
|
||||
expected: "->>'field'",
|
||||
},
|
||||
{
|
||||
name: "simple path as json",
|
||||
jsonPath: "$.field",
|
||||
asText: false,
|
||||
expected: "->'field'",
|
||||
},
|
||||
{
|
||||
name: "nested path as text",
|
||||
jsonPath: "$.user.name",
|
||||
asText: true,
|
||||
expected: "->'user'->>'name'",
|
||||
},
|
||||
{
|
||||
name: "nested path as json",
|
||||
jsonPath: "$.user.name",
|
||||
asText: false,
|
||||
expected: "->'user'->'name'",
|
||||
},
|
||||
{
|
||||
name: "deeply nested as text",
|
||||
jsonPath: "$.a.b.c.d",
|
||||
asText: true,
|
||||
expected: "->'a'->'b'->'c'->>'d'",
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
jsonPath: "$",
|
||||
asText: true,
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
jsonPath: "",
|
||||
asText: true,
|
||||
expected: "",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New()).(*formatter)
|
||||
got := string(f.convertJSONPathToPostgresWithMode(tt.jsonPath, tt.asText))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTextToJsonColumn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
column string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column name",
|
||||
column: "data",
|
||||
expected: `"data"::jsonb`,
|
||||
},
|
||||
{
|
||||
name: "column with underscore",
|
||||
column: "user_data",
|
||||
expected: `"user_data"::jsonb`,
|
||||
},
|
||||
{
|
||||
name: "column with special characters",
|
||||
column: "json-col",
|
||||
expected: `"json-col"::jsonb`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.TextToJsonColumn(tt.column))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
expr string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "simple column name",
|
||||
expr: "name",
|
||||
expected: "lower(name)",
|
||||
},
|
||||
{
|
||||
name: "quoted column identifier",
|
||||
expr: `"column_name"`,
|
||||
expected: `lower("column_name")`,
|
||||
},
|
||||
{
|
||||
name: "jsonb text extraction",
|
||||
expr: "data->>'field'",
|
||||
expected: "lower(data->>'field')",
|
||||
},
|
||||
{
|
||||
name: "nested jsonb extraction",
|
||||
expr: "metadata->'user'->>'name'",
|
||||
expected: "lower(metadata->'user'->>'name')",
|
||||
},
|
||||
{
|
||||
name: "jsonb_typeof expression",
|
||||
expr: "jsonb_typeof(data->'field')",
|
||||
expected: "lower(jsonb_typeof(data->'field'))",
|
||||
},
|
||||
{
|
||||
name: "string concatenation",
|
||||
expr: "first_name || ' ' || last_name",
|
||||
expected: "lower(first_name || ' ' || last_name)",
|
||||
},
|
||||
{
|
||||
name: "CAST expression",
|
||||
expr: "CAST(value AS TEXT)",
|
||||
expected: "lower(CAST(value AS TEXT))",
|
||||
},
|
||||
{
|
||||
name: "COALESCE expression",
|
||||
expr: "COALESCE(name, 'default')",
|
||||
expected: "lower(COALESCE(name, 'default'))",
|
||||
},
|
||||
{
|
||||
name: "subquery column",
|
||||
expr: "users.email",
|
||||
expected: "lower(users.email)",
|
||||
},
|
||||
{
|
||||
name: "quoted identifier with special chars",
|
||||
expr: `"user-name"`,
|
||||
expected: `lower("user-name")`,
|
||||
},
|
||||
{
|
||||
name: "jsonb to text cast",
|
||||
expr: "data::text",
|
||||
expected: "lower(data::text)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := newFormatter(pgdialect.New())
|
||||
got := string(f.LowerExpression(tt.expr))
|
||||
assert.Equal(t, tt.expected, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -15,10 +15,11 @@ import (
|
||||
)
|
||||
|
||||
type provider struct {
|
||||
settings factory.ScopedProviderSettings
|
||||
sqldb *sql.DB
|
||||
bundb *sqlstore.BunDB
|
||||
dialect *dialect
|
||||
settings factory.ScopedProviderSettings
|
||||
sqldb *sql.DB
|
||||
bundb *sqlstore.BunDB
|
||||
dialect *dialect
|
||||
formatter sqlstore.SQLFormatter
|
||||
}
|
||||
|
||||
func NewFactory(hookFactories ...factory.ProviderFactory[sqlstore.SQLStoreHook, sqlstore.Config]) factory.ProviderFactory[sqlstore.SQLStore, sqlstore.Config] {
|
||||
@@ -55,11 +56,14 @@ func New(ctx context.Context, providerSettings factory.ProviderSettings, config
|
||||
|
||||
sqldb := stdlib.OpenDBFromPool(pool)
|
||||
|
||||
pgDialect := pgdialect.New()
|
||||
bunDB := sqlstore.NewBunDB(settings, sqldb, pgDialect, hooks)
|
||||
return &provider{
|
||||
settings: settings,
|
||||
sqldb: sqldb,
|
||||
bundb: sqlstore.NewBunDB(settings, sqldb, pgdialect.New(), hooks),
|
||||
dialect: new(dialect),
|
||||
settings: settings,
|
||||
sqldb: sqldb,
|
||||
bundb: bunDB,
|
||||
dialect: new(dialect),
|
||||
formatter: newFormatter(bunDB.Dialect()),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -75,6 +79,10 @@ func (provider *provider) Dialect() sqlstore.SQLDialect {
|
||||
return provider.dialect
|
||||
}
|
||||
|
||||
func (provider *provider) Formatter() sqlstore.SQLFormatter {
|
||||
return provider.formatter
|
||||
}
|
||||
|
||||
func (provider *provider) BunDBCtx(ctx context.Context) bun.IDB {
|
||||
return provider.bundb.BunDBCtx(ctx)
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
"antlr4": "4.13.2",
|
||||
"axios": "1.8.2",
|
||||
"axios": "1.12.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^29.6.4",
|
||||
"babel-loader": "9.1.3",
|
||||
|
||||
@@ -274,7 +274,7 @@ function App(): JSX.Element {
|
||||
chat_settings: {
|
||||
app_id: process.env.PYLON_APP_ID,
|
||||
email: user.email,
|
||||
name: user.displayName,
|
||||
name: user.displayName || user.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
28
frontend/src/api/trace/getSpanPercentiles.ts
Normal file
28
frontend/src/api/trace/getSpanPercentiles.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
GetSpanPercentilesProps,
|
||||
GetSpanPercentilesResponseDataProps,
|
||||
} from 'types/api/trace/getSpanPercentiles';
|
||||
|
||||
const getSpanPercentiles = async (
|
||||
props: GetSpanPercentilesProps,
|
||||
): Promise<SuccessResponseV2<GetSpanPercentilesResponseDataProps>> => {
|
||||
try {
|
||||
const response = await ApiBaseInstance.post('/span_percentile', {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default getSpanPercentiles;
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
export const getQueryRangeV5 = async (
|
||||
props: QueryRangePayloadV5,
|
||||
version: string,
|
||||
signal: AbortSignal,
|
||||
signal?: AbortSignal,
|
||||
headers?: Record<string, string>,
|
||||
): Promise<SuccessResponseV2<MetricRangePayloadV5>> => {
|
||||
try {
|
||||
|
||||
371
frontend/src/components/Graph/__tests__/yAxisConfig.test.ts
Normal file
371
frontend/src/components/Graph/__tests__/yAxisConfig.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { getYAxisFormattedValue, PrecisionOptionsEnum } from '../yAxisConfig';
|
||||
|
||||
const testFullPrecisionGetYAxisFormattedValue = (
|
||||
value: string,
|
||||
format: string,
|
||||
): string => getYAxisFormattedValue(value, format, PrecisionOptionsEnum.FULL);
|
||||
|
||||
describe('getYAxisFormattedValue - none (full precision legacy assertions)', () => {
|
||||
test('large integers and decimals', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('250034', 'none')).toBe(
|
||||
'250034',
|
||||
);
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('250034897.12345', 'none'),
|
||||
).toBe('250034897.12345');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('250034897.02354', 'none'),
|
||||
).toBe('250034897.02354');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('9999999.9999', 'none')).toBe(
|
||||
'9999999.9999',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves leading zeros after decimal until first non-zero', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.0000234', 'none')).toBe(
|
||||
'1.0000234',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.00003', 'none')).toBe(
|
||||
'0.00003',
|
||||
);
|
||||
});
|
||||
|
||||
test('trims to three significant decimals and removes trailing zeros', () => {
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'none'),
|
||||
).toBe('0.000000250034');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'none')).toBe(
|
||||
'0.00000025',
|
||||
);
|
||||
|
||||
// Big precision, limiting the javascript precision (~16 digits)
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'none'),
|
||||
).toBe('1');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'none'),
|
||||
).toBe('1.005555555595958');
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
|
||||
'0.000000001',
|
||||
);
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000250000', 'none'),
|
||||
).toBe('0.00000025');
|
||||
});
|
||||
|
||||
test('whole numbers normalize', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'none')).toBe('1000');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('99.5458', 'none')).toBe(
|
||||
'99.5458',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.234567', 'none')).toBe(
|
||||
'1.234567',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('99.998', 'none')).toBe(
|
||||
'99.998',
|
||||
);
|
||||
});
|
||||
|
||||
test('strip redundant decimal zeros', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000.000', 'none')).toBe(
|
||||
'1000',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('99.500', 'none')).toBe(
|
||||
'99.5',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.000', 'none')).toBe('1');
|
||||
});
|
||||
|
||||
test('edge values', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'none')).toBe('0');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-0', 'none')).toBe('0');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'none')).toBe('∞');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-Infinity', 'none')).toBe(
|
||||
'-∞',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('invalid', 'none')).toBe(
|
||||
'NaN',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('', 'none')).toBe('NaN');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('abc123', 'none')).toBe('NaN');
|
||||
});
|
||||
|
||||
test('small decimals keep precision as-is', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'none')).toBe(
|
||||
'0.0001',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-0.0001', 'none')).toBe(
|
||||
'-0.0001',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
|
||||
'0.000000001',
|
||||
);
|
||||
});
|
||||
|
||||
test('simple decimals preserved', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.1', 'none')).toBe('0.1');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.2', 'none')).toBe('0.2');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.3', 'none')).toBe('0.3');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.0000000001', 'none')).toBe(
|
||||
'1.0000000001',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getYAxisFormattedValue - units (full precision legacy assertions)', () => {
|
||||
test('ms', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'ms')).toBe('1.5 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('500', 'ms')).toBe('500 ms');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('60000', 'ms')).toBe('1 min');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('295.429', 'ms')).toBe(
|
||||
'295.429 ms',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('4353.81', 'ms')).toBe(
|
||||
'4.35381 s',
|
||||
);
|
||||
});
|
||||
|
||||
test('s', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('90', 's')).toBe('1.5 mins');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('30', 's')).toBe('30 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('3600', 's')).toBe('1 hour');
|
||||
});
|
||||
|
||||
test('m', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('90', 'm')).toBe('1.5 hours');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('30', 'm')).toBe('30 min');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1440', 'm')).toBe('1 day');
|
||||
});
|
||||
|
||||
test('bytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'bytes')).toBe(
|
||||
'1 KiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('512', 'bytes')).toBe('512 B');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'bytes')).toBe(
|
||||
'1.5 KiB',
|
||||
);
|
||||
});
|
||||
|
||||
test('mbytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'mbytes')).toBe(
|
||||
'1 GiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('512', 'mbytes')).toBe(
|
||||
'512 MiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'mbytes')).toBe(
|
||||
'1.5 GiB',
|
||||
);
|
||||
});
|
||||
|
||||
test('kbytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'kbytes')).toBe(
|
||||
'1 MiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('512', 'kbytes')).toBe(
|
||||
'512 KiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'kbytes')).toBe(
|
||||
'1.5 MiB',
|
||||
);
|
||||
});
|
||||
|
||||
test('short', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'short')).toBe('1 K');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'short')).toBe(
|
||||
'1.5 K',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('999', 'short')).toBe('999');
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000000', 'short')).toBe(
|
||||
'1 Mil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1555600', 'short')).toBe(
|
||||
'1.5556 Mil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('999999', 'short')).toBe(
|
||||
'999.999 K',
|
||||
);
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000000000', 'short')).toBe(
|
||||
'1 Bil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500000000', 'short')).toBe(
|
||||
'1.5 Bil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('999999999', 'short')).toBe(
|
||||
'999.999999 Mil',
|
||||
);
|
||||
});
|
||||
|
||||
test('percent', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.15', 'percent')).toBe(
|
||||
'0.15%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.1234', 'percent')).toBe(
|
||||
'0.1234%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.123499', 'percent')).toBe(
|
||||
'0.123499%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.5', 'percent')).toBe(
|
||||
'1.5%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'percent')).toBe(
|
||||
'0.0001%',
|
||||
);
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000001', 'percent'),
|
||||
).toBe('1e-9%');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'percent'),
|
||||
).toBe('0.000000250034%');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'percent')).toBe(
|
||||
'0.00000025%',
|
||||
);
|
||||
// Big precision, limiting the javascript precision (~16 digits)
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'percent'),
|
||||
).toBe('1%');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'percent'),
|
||||
).toBe('1.005555555595958%');
|
||||
});
|
||||
|
||||
test('ratio', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.5', 'ratio')).toBe(
|
||||
'0.5 ratio',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.25', 'ratio')).toBe(
|
||||
'1.25 ratio',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('2.0', 'ratio')).toBe(
|
||||
'2 ratio',
|
||||
);
|
||||
});
|
||||
|
||||
test('temperature units', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('25', 'celsius')).toBe(
|
||||
'25 °C',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'celsius')).toBe('0 °C');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-10', 'celsius')).toBe(
|
||||
'-10 °C',
|
||||
);
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('77', 'fahrenheit')).toBe(
|
||||
'77 °F',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('32', 'fahrenheit')).toBe(
|
||||
'32 °F',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('14', 'fahrenheit')).toBe(
|
||||
'14 °F',
|
||||
);
|
||||
});
|
||||
|
||||
test('ms edge cases', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'ms')).toBe('0 ms');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-1500', 'ms')).toBe('-1.5 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'ms')).toBe('∞');
|
||||
});
|
||||
|
||||
test('bytes edge cases', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'bytes')).toBe('0 B');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-1024', 'bytes')).toBe(
|
||||
'-1 KiB',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getYAxisFormattedValue - precision option tests', () => {
|
||||
test('precision 0 drops decimal part', () => {
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 0)).toBe('1');
|
||||
expect(getYAxisFormattedValue('0.9999', 'none', 0)).toBe('0');
|
||||
expect(getYAxisFormattedValue('12345.6789', 'none', 0)).toBe('12345');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 0)).toBe('0');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 0)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 0)).toBe('0');
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 0)).toBe('1');
|
||||
|
||||
// with unit
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 0)).toBe('4 s');
|
||||
});
|
||||
test('precision 1,2,3,4 decimals', () => {
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 1)).toBe('1.2');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 2)).toBe('1.23');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 3)).toBe('1.234');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 4)).toBe('1.2345');
|
||||
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 1)).toBe('0.00001');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 2)).toBe('0.000012');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 3)).toBe('0.0000123');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 4)).toBe('0.00001234');
|
||||
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 1)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 2)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 3)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 4)).toBe('1000');
|
||||
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 1)).toBe('0.0000002');
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 2)).toBe(
|
||||
'0.00000025',
|
||||
); // leading zeros + 2 significant => same trimmed
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 3)).toBe(
|
||||
'0.00000025',
|
||||
);
|
||||
expect(getYAxisFormattedValue('0.000000250304', 'none', 4)).toBe(
|
||||
'0.0000002503',
|
||||
);
|
||||
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 1)).toBe(
|
||||
'1.005',
|
||||
);
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 2)).toBe(
|
||||
'1.0055',
|
||||
);
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 3)).toBe(
|
||||
'1.00555',
|
||||
);
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 4)).toBe(
|
||||
'1.005555',
|
||||
);
|
||||
|
||||
// with unit
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 1)).toBe('4.4 s');
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 2)).toBe('4.35 s');
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 3)).toBe('4.354 s');
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 4)).toBe('4.3538 s');
|
||||
|
||||
// Percentages
|
||||
expect(getYAxisFormattedValue('0.123456', 'percent', 2)).toBe('0.12%');
|
||||
expect(getYAxisFormattedValue('0.123456', 'percent', 4)).toBe('0.1235%'); // approximation
|
||||
});
|
||||
|
||||
test('precision full uses up to DEFAULT_SIGNIFICANT_DIGITS significant digits', () => {
|
||||
expect(
|
||||
getYAxisFormattedValue(
|
||||
'0.00002625429914148441',
|
||||
'none',
|
||||
PrecisionOptionsEnum.FULL,
|
||||
),
|
||||
).toBe('0.000026254299141');
|
||||
expect(
|
||||
getYAxisFormattedValue(
|
||||
'0.000026254299141484417',
|
||||
's',
|
||||
PrecisionOptionsEnum.FULL,
|
||||
),
|
||||
).toBe('26254299141484417000000 µs');
|
||||
|
||||
expect(
|
||||
getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL),
|
||||
).toBe('4.35381 s');
|
||||
expect(getYAxisFormattedValue('500', 'ms', PrecisionOptionsEnum.FULL)).toBe(
|
||||
'500 ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,158 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { formattedValueToString, getValueFormat } from '@grafana/data';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { isNaN } from 'lodash-es';
|
||||
|
||||
const DEFAULT_SIGNIFICANT_DIGITS = 15;
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
const MAX_DECIMALS = 15;
|
||||
|
||||
export enum PrecisionOptionsEnum {
|
||||
ZERO = 0,
|
||||
ONE = 1,
|
||||
TWO = 2,
|
||||
THREE = 3,
|
||||
FOUR = 4,
|
||||
FULL = 'full',
|
||||
}
|
||||
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;
|
||||
|
||||
/**
|
||||
* Formats a number for display, preserving leading zeros after the decimal point
|
||||
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
|
||||
* It avoids scientific notation and removes unnecessary trailing zeros.
|
||||
*
|
||||
* @example
|
||||
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
|
||||
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
|
||||
* formatDecimalWithLeadingZeros(5.0); // "5"
|
||||
*
|
||||
* @param value The number to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatDecimalWithLeadingZeros = (
|
||||
value: number,
|
||||
precision: PrecisionOption,
|
||||
): string => {
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
// Use toLocaleString to get a full decimal representation without scientific notation.
|
||||
const numStr = value.toLocaleString('en-US', {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 20,
|
||||
});
|
||||
|
||||
const [integerPart, decimalPart = ''] = numStr.split('.');
|
||||
|
||||
// If there's no decimal part, the integer part is the result.
|
||||
if (!decimalPart) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Find the index of the first non-zero digit in the decimal part.
|
||||
const firstNonZeroIndex = decimalPart.search(/[^0]/);
|
||||
|
||||
// If the decimal part consists only of zeros, return just the integer part.
|
||||
if (firstNonZeroIndex === -1) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
|
||||
const significantDigits =
|
||||
precision === PrecisionOptionsEnum.FULL
|
||||
? DEFAULT_SIGNIFICANT_DIGITS
|
||||
: precision;
|
||||
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
|
||||
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
|
||||
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
|
||||
|
||||
// If precision is 0, we drop the decimal part entirely.
|
||||
if (precision === 0) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Remove any trailing zeros from the result to keep it clean.
|
||||
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
|
||||
|
||||
// Return the integer part, or the integer and decimal parts combined.
|
||||
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a Y-axis value based on a given format string.
|
||||
*
|
||||
* @param value The string value from the axis.
|
||||
* @param format The format identifier (e.g. 'none', 'ms', 'bytes', 'short').
|
||||
* @returns A formatted string ready for display.
|
||||
*/
|
||||
export const getYAxisFormattedValue = (
|
||||
value: string,
|
||||
format: string,
|
||||
precision: PrecisionOption = 2, // default precision requested
|
||||
): string => {
|
||||
let decimalPrecision: number | undefined;
|
||||
const parsedValue = getValueFormat(format)(
|
||||
parseFloat(value),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
try {
|
||||
const decimalSplitted = parsedValue.text.split('.');
|
||||
if (decimalSplitted.length === 1) {
|
||||
decimalPrecision = 0;
|
||||
} else {
|
||||
const decimalDigits = decimalSplitted[1].split('');
|
||||
decimalPrecision = decimalDigits.length;
|
||||
let nonZeroCtr = 0;
|
||||
for (let idx = 0; idx < decimalDigits.length; idx += 1) {
|
||||
if (decimalDigits[idx] !== '0') {
|
||||
nonZeroCtr += 1;
|
||||
if (nonZeroCtr >= 2) {
|
||||
decimalPrecision = idx + 1;
|
||||
}
|
||||
} else if (nonZeroCtr) {
|
||||
decimalPrecision = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const numValue = parseFloat(value);
|
||||
|
||||
// Handle non-numeric or special values first.
|
||||
if (isNaN(numValue)) return 'NaN';
|
||||
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.
|
||||
const computeDecimals = (): number | undefined => {
|
||||
if (precision === PrecisionOptionsEnum.FULL) {
|
||||
return decimalPlaces && decimalPlaces >= DEFAULT_SIGNIFICANT_DIGITS
|
||||
? decimalPlaces
|
||||
: DEFAULT_SIGNIFICANT_DIGITS;
|
||||
}
|
||||
return precision;
|
||||
};
|
||||
|
||||
return formattedValueToString(
|
||||
getValueFormat(format)(
|
||||
parseFloat(value),
|
||||
decimalPrecision,
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return `${parseFloat(value)}`;
|
||||
};
|
||||
const fallbackFormat = (): string => {
|
||||
if (precision === PrecisionOptionsEnum.FULL) return numValue.toString();
|
||||
if (precision === 0) return Math.round(numValue).toString();
|
||||
return precision !== undefined
|
||||
? numValue
|
||||
.toFixed(precision)
|
||||
.replace(/(\.[0-9]*[1-9])0+$/, '$1') // trimming zeros
|
||||
.replace(/\.$/, '')
|
||||
: numValue.toString();
|
||||
};
|
||||
|
||||
export const getToolTipValue = (value: string, format?: string): string => {
|
||||
try {
|
||||
return formattedValueToString(
|
||||
getValueFormat(format)(parseFloat(value), undefined, undefined, undefined),
|
||||
);
|
||||
const formatter = getValueFormat(format);
|
||||
const formattedValue = formatter(numValue, computeDecimals(), undefined);
|
||||
if (formattedValue.text && formattedValue.text.includes('.')) {
|
||||
formattedValue.text = formatDecimalWithLeadingZeros(
|
||||
parseFloat(formattedValue.text),
|
||||
precision,
|
||||
);
|
||||
}
|
||||
return formattedValueToString(formattedValue);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Sentry.captureEvent({
|
||||
message: `Error applying formatter: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`,
|
||||
level: 'error',
|
||||
});
|
||||
return fallbackFormat();
|
||||
}
|
||||
return `${value}`;
|
||||
};
|
||||
|
||||
export const getToolTipValue = (
|
||||
value: string | number,
|
||||
format?: string,
|
||||
precision?: PrecisionOption,
|
||||
): string =>
|
||||
getYAxisFormattedValue(value?.toString(), format || 'none', precision);
|
||||
|
||||
@@ -60,6 +60,14 @@ function Metrics({
|
||||
setElement,
|
||||
} = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 });
|
||||
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() =>
|
||||
getHostQueryPayload(
|
||||
@@ -147,6 +155,13 @@ function Metrics({
|
||||
maxTimeScale: graphTimeIntervals[idx].end,
|
||||
onDragSelect: (start, end) => onDragSelect(start, end, idx),
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
|
||||
@@ -132,9 +132,9 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.json-action-btn {
|
||||
.log-detail-drawer__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -319,31 +319,35 @@ function LogDetailInner({
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{selectedView === VIEW_TYPES.JSON && (
|
||||
<div className="json-action-btn">
|
||||
<div className="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Tooltip
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Filter size={16} />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
|
||||
placement="topLeft"
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Copy size={16} />}
|
||||
onClick={handleJSONCopy}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Filter size={16} />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip title="Copy Log Link" placement="left" aria-label="Copy Log Link">
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Copy size={16} />}
|
||||
onClick={onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
<div className="log-detail-drawer-query-container">
|
||||
@@ -383,7 +387,8 @@ function LogDetailInner({
|
||||
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
|
||||
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
|
||||
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
|
||||
logLineTimestamp={log.timestamp.toString()}
|
||||
timestamp={log.timestamp.toString()}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -57,8 +57,8 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
transition: background-color 2s ease-in;`
|
||||
: ''}
|
||||
|
||||
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
|
||||
${({ $isCustomHighlighted }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted)}
|
||||
`;
|
||||
|
||||
export const InfoIconWrapper = styled(Info)`
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
}
|
||||
|
||||
.qb-search-container {
|
||||
.metrics-select-container {
|
||||
.metrics-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
showOnlyWhereClause = false,
|
||||
showTraceOperator = false,
|
||||
version,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
}: QueryBuilderProps): JSX.Element {
|
||||
const {
|
||||
currentQuery,
|
||||
@@ -175,6 +177,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
/>
|
||||
) : (
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
@@ -193,7 +197,9 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={config?.signalSource || ''}
|
||||
signalSource={query.source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
.metrics-select-container {
|
||||
.metrics-source-select-container {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.source-selector {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
width: 100%;
|
||||
@@ -42,7 +51,7 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.metrics-select-container {
|
||||
.metrics-source-select-container {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
@@ -1,21 +1,39 @@
|
||||
import './MetricsSelect.styles.scss';
|
||||
|
||||
import { Select } from 'antd';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
export const SOURCE_OPTIONS: SelectOption<string, string>[] = [
|
||||
{ value: 'metrics', label: 'Metrics' },
|
||||
{ value: 'meter', label: 'Meter' },
|
||||
];
|
||||
|
||||
export const MetricsSelect = memo(function MetricsSelect({
|
||||
query,
|
||||
index,
|
||||
version,
|
||||
signalSource,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
signalSource: 'meter' | '';
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
}): JSX.Element {
|
||||
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
|
||||
@@ -31,8 +49,67 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
},
|
||||
[handleChangeAggregatorAttribute, attributeKeys],
|
||||
);
|
||||
|
||||
const { updateAllQueriesOperators, handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
const source = useMemo(
|
||||
() => (signalSource === 'meter' ? 'meter' : 'metrics'),
|
||||
[signalSource],
|
||||
);
|
||||
|
||||
const defaultMeterQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.METRICS,
|
||||
'meter' as 'meter' | '',
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const defaultMetricsQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.metrics,
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.METRICS,
|
||||
'',
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const handleSignalSourceChange = (value: string): void => {
|
||||
onSignalSourceChange(value);
|
||||
handleSetQueryData(
|
||||
index,
|
||||
value === 'meter'
|
||||
? {
|
||||
...defaultMeterQuery.builder.queryData[0],
|
||||
source: 'meter',
|
||||
queryName: query.queryName,
|
||||
}
|
||||
: {
|
||||
...defaultMetricsQuery.builder.queryData[0],
|
||||
source: '',
|
||||
queryName: query.queryName,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="metrics-select-container">
|
||||
<div className="metrics-source-select-container">
|
||||
{signalSourceChangeEnabled && (
|
||||
<Select
|
||||
className="source-selector"
|
||||
placeholder="Source"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={source}
|
||||
defaultValue="metrics"
|
||||
onChange={handleSignalSourceChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AggregatorFilter
|
||||
onChange={handleAggregatorAttributeChange}
|
||||
query={query}
|
||||
|
||||
@@ -33,7 +33,13 @@ export const QueryV2 = memo(function QueryV2({
|
||||
showOnlyWhereClause = false,
|
||||
signalSource = '',
|
||||
isMultiQueryAllowed = false,
|
||||
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
}: QueryProps & {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
}): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
@@ -207,12 +213,14 @@ export const QueryV2 = memo(function QueryV2({
|
||||
<div className="qb-elements-container">
|
||||
<div className="qb-search-container">
|
||||
{dataSource === DataSource.METRICS && (
|
||||
<div className="metrics-select-container">
|
||||
<div className="metrics-container">
|
||||
<MetricsSelect
|
||||
query={query}
|
||||
index={index}
|
||||
version={ENTITY_VERSION_V5}
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -258,7 +266,7 @@ export const QueryV2 = memo(function QueryV2({
|
||||
panelType={panelType}
|
||||
query={query}
|
||||
index={index}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}`}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
|
||||
version="v4"
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
|
||||
@@ -24,6 +24,7 @@ export const DATE_TIME_FORMATS = {
|
||||
TIME_SECONDS: 'HH:mm:ss',
|
||||
TIME_UTC: 'HH:mm:ss (UTC Z)',
|
||||
TIME_UTC_MS: 'HH:mm:ss.SSS (UTC Z)',
|
||||
TIME_SPAN_PERCENTILE: 'HH:mm:ss MMM DD',
|
||||
|
||||
// Short date formats
|
||||
DATE_SHORT: 'MM/DD',
|
||||
|
||||
@@ -86,7 +86,11 @@ export const REACT_QUERY_KEY = {
|
||||
SPAN_LOGS: 'SPAN_LOGS',
|
||||
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
|
||||
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
|
||||
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
|
||||
|
||||
// Routing Policies Query Keys
|
||||
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
|
||||
|
||||
// Span Percentiles Query Keys
|
||||
GET_SPAN_PERCENTILES: 'GET_SPAN_PERCENTILES',
|
||||
} as const;
|
||||
|
||||
@@ -3,4 +3,5 @@ export const USER_PREFERENCES = {
|
||||
NAV_SHORTCUTS: 'nav_shortcuts',
|
||||
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
|
||||
};
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { LoadingOutlined } from '@ant-design/icons';
|
||||
import { Spin, Switch, Table, Tooltip, Typography } from 'antd';
|
||||
import { getQueryRangeV5 } from 'api/v5/queryRange/getQueryRange';
|
||||
import { MetricRangePayloadV5, ScalarData } from 'api/v5/v5';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { withErrorBoundary } from 'components/ErrorBoundaryHOC';
|
||||
import { DEFAULT_ENTITY_VERSION, ENTITY_VERSION_V4 } from 'constants/app';
|
||||
import { ENTITY_VERSION_V4, ENTITY_VERSION_V5 } from 'constants/app';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY,
|
||||
@@ -11,13 +13,12 @@ import {
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
TopErrorsResponseRow,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { Info } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQueries } from 'react-query';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { QueryFunctionContext, useQueries, useQuery } from 'react-query';
|
||||
import { SuccessResponse, SuccessResponseV2 } from 'types/api';
|
||||
import { MetricRangePayloadProps } from 'types/api/metrics/getQueryRange';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
@@ -46,7 +47,7 @@ function TopErrors({
|
||||
true,
|
||||
);
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
const queryPayload = useMemo(
|
||||
() =>
|
||||
getTopErrorsQueryPayload(
|
||||
domainName,
|
||||
@@ -82,37 +83,34 @@ function TopErrors({
|
||||
],
|
||||
);
|
||||
|
||||
const topErrorsDataQueries = useQueries(
|
||||
queryPayloads.map((payload) => ({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
payload,
|
||||
DEFAULT_ENTITY_VERSION,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
queryFn: (): Promise<SuccessResponse<MetricRangePayloadProps>> =>
|
||||
GetMetricQueryRange(payload, DEFAULT_ENTITY_VERSION),
|
||||
enabled: !!payload,
|
||||
staleTime: 0,
|
||||
cacheTime: 0,
|
||||
})),
|
||||
);
|
||||
|
||||
const topErrorsDataQuery = topErrorsDataQueries[0];
|
||||
const {
|
||||
data: topErrorsData,
|
||||
isLoading,
|
||||
isRefetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = topErrorsDataQuery;
|
||||
} = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
queryPayload,
|
||||
ENTITY_VERSION_V5,
|
||||
showStatusCodeErrors,
|
||||
],
|
||||
queryFn: ({
|
||||
signal,
|
||||
}: QueryFunctionContext): Promise<SuccessResponseV2<MetricRangePayloadV5>> =>
|
||||
getQueryRangeV5(queryPayload, ENTITY_VERSION_V5, signal),
|
||||
enabled: !!queryPayload,
|
||||
staleTime: 0,
|
||||
cacheTime: 0,
|
||||
});
|
||||
|
||||
const topErrorsColumnsConfig = useMemo(() => getTopErrorsColumnsConfig(), []);
|
||||
|
||||
const formattedTopErrorsData = useMemo(
|
||||
() =>
|
||||
formatTopErrorsDataForTable(
|
||||
topErrorsData?.payload?.data?.result as TopErrorsResponseRow[],
|
||||
topErrorsData?.data?.data?.data?.results[0] as ScalarData,
|
||||
),
|
||||
[topErrorsData],
|
||||
);
|
||||
|
||||
@@ -69,6 +69,13 @@ function StatusCodeBarCharts({
|
||||
} = endPointStatusCodeLatencyBarChartsDataQuery;
|
||||
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
@@ -207,6 +214,13 @@ function StatusCodeBarCharts({
|
||||
onDragSelect,
|
||||
colorMapping,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
minTime,
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
endPointStatusCodeColumns,
|
||||
extractPortAndEndpoint,
|
||||
formatDataForTable,
|
||||
formatTopErrorsDataForTable,
|
||||
getAllEndpointsWidgetData,
|
||||
getCustomFiltersForBarChart,
|
||||
getEndPointDetailsQueryPayload,
|
||||
@@ -23,8 +22,6 @@ import {
|
||||
getStatusCodeBarChartWidgetData,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
TopErrorsResponseRow,
|
||||
} from '../utils';
|
||||
import { APIMonitoringColumnsMock } from './mock';
|
||||
|
||||
@@ -344,49 +341,6 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatTopErrorsDataForTable', () => {
|
||||
it('should format top errors data correctly', () => {
|
||||
// Arrange
|
||||
const inputData = [
|
||||
{
|
||||
metric: {
|
||||
[SPAN_ATTRIBUTES.URL_PATH]: '/api/test',
|
||||
[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]: '500',
|
||||
status_message: 'Internal Server Error',
|
||||
},
|
||||
values: [[1000000100, '10']],
|
||||
queryName: 'A',
|
||||
legend: 'Test Legend',
|
||||
},
|
||||
];
|
||||
|
||||
// Act
|
||||
const result = formatTopErrorsDataForTable(
|
||||
inputData as TopErrorsResponseRow[],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBe(1);
|
||||
|
||||
// Check first item is formatted correctly
|
||||
expect(result[0].endpointName).toBe('/api/test');
|
||||
expect(result[0].statusCode).toBe('500');
|
||||
expect(result[0].statusMessage).toBe('Internal Server Error');
|
||||
expect(result[0].count).toBe('10');
|
||||
expect(result[0].key).toBeDefined();
|
||||
});
|
||||
|
||||
it('should handle empty input', () => {
|
||||
// Act
|
||||
const result = formatTopErrorsDataForTable(undefined);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopErrorsColumnsConfig', () => {
|
||||
it('should return column configuration with expected fields', () => {
|
||||
// Act
|
||||
@@ -453,72 +407,6 @@ describe('API Monitoring Utils', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('getTopErrorsQueryPayload', () => {
|
||||
it('should create correct query payload with filters', () => {
|
||||
// Arrange
|
||||
const domainName = 'test-domain';
|
||||
const start = 1000000000;
|
||||
const end = 1000010000;
|
||||
const filters = {
|
||||
items: [
|
||||
{
|
||||
id: 'test-filter',
|
||||
key: {
|
||||
dataType: DataTypes.String,
|
||||
key: 'test-key',
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'test-value',
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
};
|
||||
|
||||
// Act
|
||||
const result = getTopErrorsQueryPayload(
|
||||
domainName,
|
||||
start,
|
||||
end,
|
||||
filters as IBuilderQuery['filters'],
|
||||
);
|
||||
|
||||
// Assert
|
||||
expect(result).toBeDefined();
|
||||
expect(result.length).toBeGreaterThan(0);
|
||||
|
||||
// Verify query params
|
||||
expect(result[0].start).toBe(start);
|
||||
expect(result[0].end).toBe(end);
|
||||
|
||||
// Verify correct structure
|
||||
expect(result[0].graphType).toBeDefined();
|
||||
expect(result[0].query).toBeDefined();
|
||||
expect(result[0].query.builder).toBeDefined();
|
||||
expect(result[0].query.builder.queryData).toBeDefined();
|
||||
|
||||
// Verify domain filter is included
|
||||
const queryData = result[0].query.builder.queryData[0];
|
||||
expect(queryData.filters).toBeDefined();
|
||||
|
||||
// Check for domain filter
|
||||
const domainFilter = queryData.filters?.items?.find(
|
||||
// eslint-disable-next-line sonarjs/no-identical-functions
|
||||
(item) =>
|
||||
item.key &&
|
||||
item.key.key === SPAN_ATTRIBUTES.SERVER_NAME &&
|
||||
item.value === domainName,
|
||||
);
|
||||
expect(domainFilter).toBeDefined();
|
||||
|
||||
// Check that custom filters were included
|
||||
const testFilter = queryData.filters?.items?.find(
|
||||
(item) => item.id === 'test-filter',
|
||||
);
|
||||
expect(testFilter).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// Add new tests for EndPointDetails utility functions
|
||||
describe('extractPortAndEndpoint', () => {
|
||||
it('should extract port and endpoint from a valid URL', () => {
|
||||
|
||||
@@ -1,14 +1,6 @@
|
||||
import { fireEvent, render, screen, within } from '@testing-library/react';
|
||||
import { useNavigateToExplorer } from 'components/CeleryTask/useNavigateToExplorer';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import {
|
||||
formatTopErrorsDataForTable,
|
||||
getEndPointDetailsQueryPayload,
|
||||
getTopErrorsColumnsConfig,
|
||||
getTopErrorsCoRelationQueryFilters,
|
||||
getTopErrorsQueryPayload,
|
||||
} from 'container/ApiMonitoring/utils';
|
||||
import { useQueries } from 'react-query';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { fireEvent, render, screen, waitFor, within } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import TopErrors from '../Explorer/Domains/DomainDetails/TopErrors';
|
||||
@@ -35,28 +27,15 @@ jest.mock(
|
||||
}),
|
||||
);
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueries: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('components/CeleryTask/useNavigateToExplorer', () => ({
|
||||
useNavigateToExplorer: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('container/ApiMonitoring/utils', () => ({
|
||||
END_POINT_DETAILS_QUERY_KEYS_ARRAY: ['key1', 'key2', 'key3', 'key4', 'key5'],
|
||||
formatTopErrorsDataForTable: jest.fn(),
|
||||
getEndPointDetailsQueryPayload: jest.fn(),
|
||||
getTopErrorsColumnsConfig: jest.fn(),
|
||||
getTopErrorsCoRelationQueryFilters: jest.fn(),
|
||||
getTopErrorsQueryPayload: jest.fn(),
|
||||
}));
|
||||
|
||||
describe('TopErrors', () => {
|
||||
const TABLE_BODY_SELECTOR = '.ant-table-tbody';
|
||||
const V5_QUERY_RANGE_API_PATH = '*/api/v5/query_range';
|
||||
|
||||
const mockProps = {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
domainName: 'test-domain',
|
||||
timeRange: {
|
||||
startTime: 1000000000,
|
||||
@@ -68,75 +47,72 @@ describe('TopErrors', () => {
|
||||
},
|
||||
};
|
||||
|
||||
// Setup basic mocks
|
||||
// Helper function to wait for table data to load
|
||||
const waitForTableDataToLoad = async (
|
||||
container: HTMLElement,
|
||||
): Promise<void> => {
|
||||
await waitFor(() => {
|
||||
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
|
||||
expect(tableBody).not.toBeNull();
|
||||
if (tableBody) {
|
||||
expect(
|
||||
within(tableBody as HTMLElement).queryByText('/api/test'),
|
||||
).toBeInTheDocument();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Mock getTopErrorsColumnsConfig
|
||||
(getTopErrorsColumnsConfig as jest.Mock).mockReturnValue([
|
||||
{
|
||||
title: 'Endpoint',
|
||||
dataIndex: 'endpointName',
|
||||
key: 'endpointName',
|
||||
},
|
||||
{
|
||||
title: 'Status Code',
|
||||
dataIndex: 'statusCode',
|
||||
key: 'statusCode',
|
||||
},
|
||||
{
|
||||
title: 'Status Message',
|
||||
dataIndex: 'statusMessage',
|
||||
key: 'statusMessage',
|
||||
},
|
||||
{
|
||||
title: 'Count',
|
||||
dataIndex: 'count',
|
||||
key: 'count',
|
||||
},
|
||||
]);
|
||||
// Mock useNavigateToExplorer
|
||||
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
|
||||
|
||||
// Mock useQueries
|
||||
(useQueries as jest.Mock).mockImplementation((queryConfigs) => {
|
||||
// For topErrorsDataQueries
|
||||
if (
|
||||
queryConfigs.length === 1 &&
|
||||
queryConfigs[0].queryKey &&
|
||||
queryConfigs[0].queryKey[0] === REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN
|
||||
) {
|
||||
return [
|
||||
{
|
||||
// Mock V5 API endpoint for top errors
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: {
|
||||
'http.url': '/api/test',
|
||||
status_code: '500',
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
status_message: 'Internal Server Error',
|
||||
data: {
|
||||
results: [
|
||||
{
|
||||
columns: [
|
||||
{
|
||||
name: 'http.url',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
values: [[1000000100, '10']],
|
||||
queryName: 'A',
|
||||
legend: 'Test Legend',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'status_message',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{ name: 'count()', fieldDataType: 'int64', fieldContext: '' },
|
||||
],
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
data: [['/api/test', '500', 'Internal Server Error', 10]],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
refetch: jest.fn(),
|
||||
},
|
||||
];
|
||||
}
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
// For endPointDropDownDataQueries
|
||||
return [
|
||||
{
|
||||
data: {
|
||||
// Mock V4 API endpoint for dropdown data
|
||||
server.use(
|
||||
rest.post('*/api/v1/query_range', (_req, res, ctx) =>
|
||||
res(
|
||||
ctx.status(200),
|
||||
ctx.json({
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
@@ -153,62 +129,13 @@ describe('TopErrors', () => {
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
isRefetching: false,
|
||||
isError: false,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
// Mock formatTopErrorsDataForTable
|
||||
(formatTopErrorsDataForTable as jest.Mock).mockReturnValue([
|
||||
{
|
||||
key: '1',
|
||||
endpointName: '/api/test',
|
||||
statusCode: '500',
|
||||
statusMessage: 'Internal Server Error',
|
||||
count: 10,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock getTopErrorsQueryPayload
|
||||
(getTopErrorsQueryPayload as jest.Mock).mockReturnValue([
|
||||
{
|
||||
queryName: 'TopErrorsQuery',
|
||||
start: mockProps.timeRange.startTime,
|
||||
end: mockProps.timeRange.endTime,
|
||||
step: 60,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock getEndPointDetailsQueryPayload
|
||||
(getEndPointDetailsQueryPayload as jest.Mock).mockReturnValue([
|
||||
{},
|
||||
{},
|
||||
{
|
||||
queryName: 'EndpointDropdownQuery',
|
||||
start: mockProps.timeRange.startTime,
|
||||
end: mockProps.timeRange.endTime,
|
||||
step: 60,
|
||||
},
|
||||
]);
|
||||
|
||||
// Mock useNavigateToExplorer
|
||||
(useNavigateToExplorer as jest.Mock).mockReturnValue(jest.fn());
|
||||
|
||||
// Mock getTopErrorsCoRelationQueryFilters
|
||||
(getTopErrorsCoRelationQueryFilters as jest.Mock).mockReturnValue({
|
||||
items: [
|
||||
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
|
||||
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
|
||||
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
}),
|
||||
),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders component correctly', () => {
|
||||
it('renders component correctly', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const { container } = render(<TopErrors {...mockProps} />);
|
||||
|
||||
@@ -216,10 +143,11 @@ describe('TopErrors', () => {
|
||||
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
|
||||
expect(screen.getByText('Status Message Exists')).toBeInTheDocument();
|
||||
|
||||
// Find the table row and verify content
|
||||
const tableBody = container.querySelector('.ant-table-tbody');
|
||||
expect(tableBody).not.toBeNull();
|
||||
// Wait for data to load
|
||||
await waitForTableDataToLoad(container);
|
||||
|
||||
// Find the table row and verify content
|
||||
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
|
||||
if (tableBody) {
|
||||
const row = within(tableBody as HTMLElement).getByRole('row');
|
||||
expect(within(row).getByText('/api/test')).toBeInTheDocument();
|
||||
@@ -228,35 +156,40 @@ describe('TopErrors', () => {
|
||||
}
|
||||
});
|
||||
|
||||
it('renders error state when isError is true', () => {
|
||||
// Mock useQueries to return isError: true
|
||||
(useQueries as jest.Mock).mockImplementationOnce(() => [
|
||||
{
|
||||
isError: true,
|
||||
refetch: jest.fn(),
|
||||
},
|
||||
]);
|
||||
it('renders error state when API fails', async () => {
|
||||
// Mock API to return error
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, (_req, res, ctx) =>
|
||||
res(ctx.status(500), ctx.json({ error: 'Internal Server Error' })),
|
||||
),
|
||||
);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Error state should be shown with the actual text displayed in the UI
|
||||
expect(
|
||||
screen.getByText('Uh-oh :/ We ran into an error.'),
|
||||
).toBeInTheDocument();
|
||||
// Wait for error state
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen.getByText('Uh-oh :/ We ran into an error.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByText('Please refresh this panel.')).toBeInTheDocument();
|
||||
expect(screen.getByText('Refresh this panel')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles row click correctly', () => {
|
||||
it('handles row click correctly', async () => {
|
||||
const navigateMock = jest.fn();
|
||||
(useNavigateToExplorer as jest.Mock).mockReturnValue(navigateMock);
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
const { container } = render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for data to load
|
||||
await waitForTableDataToLoad(container);
|
||||
|
||||
// Find and click on the table cell containing the endpoint
|
||||
const tableBody = container.querySelector('.ant-table-tbody');
|
||||
const tableBody = container.querySelector(TABLE_BODY_SELECTOR);
|
||||
expect(tableBody).not.toBeNull();
|
||||
|
||||
if (tableBody) {
|
||||
@@ -267,11 +200,28 @@ describe('TopErrors', () => {
|
||||
|
||||
// Check if navigateToExplorer was called with correct params
|
||||
expect(navigateMock).toHaveBeenCalledWith({
|
||||
filters: [
|
||||
{ id: 'test1', key: { key: 'domain' }, op: '=', value: 'test-domain' },
|
||||
{ id: 'test2', key: { key: 'endpoint' }, op: '=', value: '/api/test' },
|
||||
{ id: 'test3', key: { key: 'status' }, op: '=', value: '500' },
|
||||
],
|
||||
filters: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'http.url' }),
|
||||
op: '=',
|
||||
value: '/api/test',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'has_error' }),
|
||||
op: '=',
|
||||
value: 'true',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'net.peer.name' }),
|
||||
op: '=',
|
||||
value: 'test-domain',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'response_status_code' }),
|
||||
op: '=',
|
||||
value: '500',
|
||||
}),
|
||||
]),
|
||||
dataSource: DataSource.TRACES,
|
||||
startTime: mockProps.timeRange.startTime,
|
||||
endTime: mockProps.timeRange.endTime,
|
||||
@@ -279,24 +229,34 @@ describe('TopErrors', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('updates endpoint filter when dropdown value changes', () => {
|
||||
it('updates endpoint filter when dropdown value changes', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('combobox')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the dropdown
|
||||
const dropdown = screen.getByRole('combobox');
|
||||
|
||||
// Mock the change
|
||||
fireEvent.change(dropdown, { target: { value: '/api/new-endpoint' } });
|
||||
|
||||
// Check if getTopErrorsQueryPayload was called with updated parameters
|
||||
expect(getTopErrorsQueryPayload).toHaveBeenCalled();
|
||||
// Component should re-render with new filter
|
||||
expect(dropdown).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles status message toggle correctly', () => {
|
||||
it('handles status message toggle correctly', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Find the toggle switch
|
||||
const toggle = screen.getByRole('switch');
|
||||
expect(toggle).toBeInTheDocument();
|
||||
@@ -307,69 +267,104 @@ describe('TopErrors', () => {
|
||||
// Click the toggle to turn it off
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=false
|
||||
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
|
||||
mockProps.domainName,
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
expect.any(Object),
|
||||
false,
|
||||
);
|
||||
|
||||
// Title should change
|
||||
expect(screen.getByText('All Errors')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the toggle to turn it back on
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Check if getTopErrorsQueryPayload was called with showStatusCodeErrors=true
|
||||
expect(getTopErrorsQueryPayload).toHaveBeenCalledWith(
|
||||
mockProps.domainName,
|
||||
mockProps.timeRange.startTime,
|
||||
mockProps.timeRange.endTime,
|
||||
expect.any(Object),
|
||||
true,
|
||||
);
|
||||
|
||||
// Title should change back
|
||||
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Errors with Status Message')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('includes toggle state in query key for cache busting', () => {
|
||||
it('includes toggle state in query key for cache busting', async () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
const toggle = screen.getByRole('switch');
|
||||
// Wait for initial load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('switch')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Initial query should include showStatusCodeErrors=true
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
queryKey: expect.arrayContaining([
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
true,
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
);
|
||||
const toggle = screen.getByRole('switch');
|
||||
|
||||
// Click toggle
|
||||
fireEvent.click(toggle);
|
||||
|
||||
// Query should be called with showStatusCodeErrors=false in key
|
||||
expect(useQueries).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
queryKey: expect.arrayContaining([
|
||||
REACT_QUERY_KEY.GET_TOP_ERRORS_BY_DOMAIN,
|
||||
expect.any(Object),
|
||||
expect.any(String),
|
||||
false,
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
// Wait for title to change, indicating query was refetched with new key
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('All Errors')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// The fact that data refetches when toggle changes proves the query key includes the toggle state
|
||||
expect(toggle).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('sends query_range v5 API call with required filters including has_error', async () => {
|
||||
let capturedRequest: any;
|
||||
|
||||
// Override the v5 API mock to capture the request
|
||||
server.use(
|
||||
rest.post(V5_QUERY_RANGE_API_PATH, async (req, res, ctx) => {
|
||||
capturedRequest = await req.json();
|
||||
return res(
|
||||
ctx.status(200),
|
||||
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
|
||||
render(<TopErrors {...mockProps} />);
|
||||
|
||||
// Wait for the API call to be made
|
||||
await waitFor(() => {
|
||||
expect(capturedRequest).toBeDefined();
|
||||
});
|
||||
|
||||
// Extract the filter expression from the captured request
|
||||
const filterExpression =
|
||||
capturedRequest.compositeQuery.queries[0].spec.filter.expression;
|
||||
|
||||
// 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(
|
||||
`(net.peer.name = 'test-domain' OR server.address = 'test-domain')`,
|
||||
);
|
||||
expect(filterExpression).toContain(`has_error = true`);
|
||||
expect(filterExpression).toContain(`status_message EXISTS`); // toggle is on by default
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { Progress, Tag, Tooltip } from 'antd';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { convertFiltersToExpressionWithExistingQuery } from 'components/QueryBuilderV2/utils';
|
||||
import {
|
||||
FiltersType,
|
||||
IQuickFiltersConfig,
|
||||
@@ -27,6 +28,11 @@ import {
|
||||
OrderByPayload,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
ColumnDescriptor,
|
||||
QueryRangePayloadV5,
|
||||
ScalarData,
|
||||
} from 'types/api/v5/queryRange';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -40,6 +46,9 @@ import {
|
||||
EndPointsResponseRow,
|
||||
} from './types';
|
||||
|
||||
export const isEmptyFilterValue = (value: unknown): boolean =>
|
||||
value === '' || value === null || value === undefined || value === 'n/a';
|
||||
|
||||
export const ApiMonitoringQuickFiltersConfig: IQuickFiltersConfig[] = [
|
||||
{
|
||||
type: FiltersType.CHECKBOX,
|
||||
@@ -816,153 +825,100 @@ export const getEndPointsQueryPayload = (
|
||||
];
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function buildFilterExpression(
|
||||
domainName: string,
|
||||
filters: IBuilderQuery['filters'],
|
||||
showStatusCodeErrors: boolean,
|
||||
): string {
|
||||
const baseFilterParts = [
|
||||
`kind_string = 'Client'`,
|
||||
`(http.url EXISTS OR url.full EXISTS)`,
|
||||
`(net.peer.name = '${domainName}' OR server.address = '${domainName}')`,
|
||||
`has_error = true`,
|
||||
];
|
||||
if (showStatusCodeErrors) {
|
||||
baseFilterParts.push('status_message EXISTS');
|
||||
}
|
||||
const filterExpression = baseFilterParts.join(' AND ');
|
||||
if (!filters) {
|
||||
return filterExpression;
|
||||
}
|
||||
const { filter } = convertFiltersToExpressionWithExistingQuery(
|
||||
filters,
|
||||
filterExpression,
|
||||
);
|
||||
return filter.expression;
|
||||
}
|
||||
|
||||
export const getTopErrorsQueryPayload = (
|
||||
domainName: string,
|
||||
start: number,
|
||||
end: number,
|
||||
filters: IBuilderQuery['filters'],
|
||||
showStatusCodeErrors = true,
|
||||
): GetQueryResultsProps[] => [
|
||||
{
|
||||
selectedTime: 'GLOBAL_TIME',
|
||||
graphType: PANEL_TYPES.TABLE,
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.TRACES,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'count',
|
||||
aggregateAttribute: {
|
||||
id: '------false',
|
||||
dataType: DataTypes.String,
|
||||
key: '',
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: 'rate',
|
||||
spaceAggregation: 'sum',
|
||||
functions: [],
|
||||
filters: {
|
||||
op: 'AND',
|
||||
items: [
|
||||
{
|
||||
id: '04da97bd',
|
||||
key: {
|
||||
key: 'kind_string',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: 'Client',
|
||||
},
|
||||
{
|
||||
id: 'b1af6bdb',
|
||||
key: {
|
||||
key: SPAN_ATTRIBUTES.URL_PATH,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: 'exists',
|
||||
value: '',
|
||||
},
|
||||
...(showStatusCodeErrors
|
||||
? [
|
||||
{
|
||||
id: '75d65388',
|
||||
key: {
|
||||
key: 'status_message',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
op: 'exists',
|
||||
value: '',
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
id: '4872bf91',
|
||||
key: {
|
||||
key: SPAN_ATTRIBUTES.SERVER_NAME,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
id: 'ab4c885d',
|
||||
key: {
|
||||
key: 'has_error',
|
||||
dataType: DataTypes.bool,
|
||||
type: '',
|
||||
},
|
||||
op: '=',
|
||||
value: true,
|
||||
},
|
||||
...(filters?.items || []),
|
||||
],
|
||||
},
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
stepInterval: 60,
|
||||
having: [],
|
||||
limit: 10,
|
||||
orderBy: [
|
||||
{
|
||||
columnName: 'timestamp',
|
||||
order: 'desc',
|
||||
},
|
||||
],
|
||||
groupBy: [
|
||||
{
|
||||
key: SPAN_ATTRIBUTES.URL_PATH,
|
||||
dataType: DataTypes.String,
|
||||
type: 'tag',
|
||||
},
|
||||
{
|
||||
dataType: DataTypes.String,
|
||||
key: 'response_status_code',
|
||||
type: '',
|
||||
id: 'response_status_code--string----true',
|
||||
},
|
||||
{
|
||||
key: 'status_message',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
],
|
||||
legend: '',
|
||||
reduceTo: 'avg',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
id: '315b15fa-ff0c-442f-89f8-2bf4fb1af2f2',
|
||||
promql: [
|
||||
{
|
||||
disabled: false,
|
||||
legend: '',
|
||||
name: 'A',
|
||||
query: '',
|
||||
},
|
||||
],
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
variables: {},
|
||||
): QueryRangePayloadV5 => {
|
||||
const filterExpression = buildFilterExpression(
|
||||
domainName,
|
||||
filters,
|
||||
showStatusCodeErrors,
|
||||
);
|
||||
|
||||
return {
|
||||
schemaVersion: 'v1',
|
||||
start,
|
||||
end,
|
||||
step: 240,
|
||||
},
|
||||
];
|
||||
requestType: 'scalar',
|
||||
compositeQuery: {
|
||||
queries: [
|
||||
{
|
||||
type: 'builder_query',
|
||||
spec: {
|
||||
name: 'A',
|
||||
signal: 'traces',
|
||||
stepInterval: 60,
|
||||
disabled: false,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
filter: { expression: filterExpression },
|
||||
groupBy: [
|
||||
{
|
||||
name: 'http.url',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'url.full',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'attribute',
|
||||
},
|
||||
{
|
||||
name: 'response_status_code',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
{
|
||||
name: 'status_message',
|
||||
fieldDataType: 'string',
|
||||
fieldContext: 'span',
|
||||
},
|
||||
],
|
||||
limit: 10,
|
||||
order: [
|
||||
{
|
||||
key: {
|
||||
name: 'count()',
|
||||
},
|
||||
direction: 'desc',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
formatOptions: { formatTableResultForUI: true, fillGaps: false },
|
||||
variables: {},
|
||||
};
|
||||
};
|
||||
|
||||
export interface EndPointsTableRowData {
|
||||
key: string;
|
||||
@@ -1242,63 +1198,55 @@ export const formatEndPointsDataForTable = (
|
||||
return formattedData;
|
||||
};
|
||||
|
||||
export interface TopErrorsResponseRow {
|
||||
metric: {
|
||||
[SPAN_ATTRIBUTES.URL_PATH]: string;
|
||||
[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]: string;
|
||||
status_message: string;
|
||||
};
|
||||
values: [number, string][];
|
||||
queryName: string;
|
||||
legend: string;
|
||||
}
|
||||
export type TopErrorsResponseRow = ScalarData;
|
||||
|
||||
export interface TopErrorsTableRowData {
|
||||
key: string;
|
||||
endpointName: string;
|
||||
statusCode: string;
|
||||
statusMessage: string;
|
||||
count: number | string;
|
||||
count: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns '-' if value is empty, otherwise returns value as string
|
||||
*/
|
||||
export function getDisplayValue(value: unknown): string {
|
||||
return isEmptyFilterValue(value) ? '-' : String(value);
|
||||
}
|
||||
|
||||
export const formatTopErrorsDataForTable = (
|
||||
data: TopErrorsResponseRow[] | undefined,
|
||||
scalarResult: TopErrorsResponseRow | undefined,
|
||||
): TopErrorsTableRowData[] => {
|
||||
if (!data) return [];
|
||||
if (!scalarResult?.data) return [];
|
||||
|
||||
return data.map((row) => ({
|
||||
key: v4(),
|
||||
endpointName:
|
||||
row.metric[SPAN_ATTRIBUTES.URL_PATH] === 'n/a' ||
|
||||
row.metric[SPAN_ATTRIBUTES.URL_PATH] === undefined
|
||||
? '-'
|
||||
: row.metric[SPAN_ATTRIBUTES.URL_PATH],
|
||||
statusCode:
|
||||
row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE] === 'n/a' ||
|
||||
row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE] === undefined
|
||||
? '-'
|
||||
: row.metric[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE],
|
||||
statusMessage:
|
||||
row.metric.status_message === 'n/a' ||
|
||||
row.metric.status_message === undefined
|
||||
? '-'
|
||||
: row.metric.status_message,
|
||||
count:
|
||||
row.values &&
|
||||
row.values[0] &&
|
||||
row.values[0][1] !== undefined &&
|
||||
row.values[0][1] !== 'n/a'
|
||||
? row.values[0][1]
|
||||
: '-',
|
||||
}));
|
||||
const columns = scalarResult.columns || [];
|
||||
const rows = scalarResult.data || [];
|
||||
|
||||
return rows.map((rowData: unknown[]) => {
|
||||
const rowObj: Record<string, unknown> = {};
|
||||
columns.forEach((col: ColumnDescriptor, index: number) => {
|
||||
rowObj[col.name] = rowData[index];
|
||||
});
|
||||
|
||||
return {
|
||||
key: v4(),
|
||||
endpointName: getDisplayValue(
|
||||
rowObj[SPAN_ATTRIBUTES.URL_PATH] || rowObj['url.full'],
|
||||
),
|
||||
statusCode: getDisplayValue(rowObj[SPAN_ATTRIBUTES.RESPONSE_STATUS_CODE]),
|
||||
statusMessage: getDisplayValue(rowObj.status_message),
|
||||
count: getDisplayValue(rowObj.__result_0),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const getTopErrorsCoRelationQueryFilters = (
|
||||
domainName: string,
|
||||
endPointName: string,
|
||||
statusCode: string,
|
||||
): IBuilderQuery['filters'] => ({
|
||||
items: [
|
||||
): IBuilderQuery['filters'] => {
|
||||
const items: TagFilterItem[] = [
|
||||
{
|
||||
id: 'ea16470b',
|
||||
key: {
|
||||
@@ -1330,7 +1278,10 @@ export const getTopErrorsCoRelationQueryFilters = (
|
||||
op: '=',
|
||||
value: domainName,
|
||||
},
|
||||
{
|
||||
];
|
||||
|
||||
if (statusCode !== '-') {
|
||||
items.push({
|
||||
id: 'f6891e27',
|
||||
key: {
|
||||
key: 'response_status_code',
|
||||
@@ -1340,10 +1291,14 @@ export const getTopErrorsCoRelationQueryFilters = (
|
||||
},
|
||||
op: '=',
|
||||
value: statusCode,
|
||||
},
|
||||
],
|
||||
op: 'AND',
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
op: 'AND',
|
||||
};
|
||||
};
|
||||
|
||||
export const getTopErrorsColumnsConfig = (): ColumnType<TopErrorsTableRowData>[] => [
|
||||
{
|
||||
|
||||
@@ -11,12 +11,14 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_INFO_THRESHOLD,
|
||||
INITIAL_RANDOM_THRESHOLD,
|
||||
INITIAL_WARNING_THRESHOLD,
|
||||
THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
THRESHOLD_OPERATOR_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import { AlertThresholdMatchType } from '../context/types';
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
||||
@@ -38,12 +40,12 @@ function AlertThreshold({
|
||||
alertState,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
setEvaluationWindow,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const queryNames = getQueryNames(currentQuery);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,6 +162,54 @@ function AlertThreshold({
|
||||
}),
|
||||
);
|
||||
|
||||
const handleSetEvaluationDetailsForMeter = (): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
});
|
||||
|
||||
setThresholdState({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectedQueryChange = (value: string): void => {
|
||||
// loop through currenttQuery and find the query that matches the selected query
|
||||
const query = currentQuery?.builder?.queryData.find(
|
||||
(query) => query.queryName === value,
|
||||
);
|
||||
|
||||
const currentSelectedQuery = currentQuery?.builder?.queryData.find(
|
||||
(query) => query.queryName === thresholdState.selectedQuery,
|
||||
);
|
||||
|
||||
const newSelectedQuerySource = query?.source || '';
|
||||
const currentSelectedQuerySource = currentSelectedQuery?.source || '';
|
||||
|
||||
if (newSelectedQuerySource === currentSelectedQuerySource) {
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: value,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (newSelectedQuerySource === 'meter') {
|
||||
handleSetEvaluationDetailsForMeter();
|
||||
} else {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
});
|
||||
}
|
||||
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -175,12 +225,7 @@ function AlertThreshold({
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.selectedQuery}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
onChange={handleSelectedQueryChange}
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
data-testid="alert-threshold-query-select"
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
||||
|
||||
function EvaluationSettings(): JSX.Element {
|
||||
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
|
||||
|
||||
const [
|
||||
isEvaluationWindowPopoverOpen,
|
||||
setIsEvaluationWindowPopoverOpen,
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
ICreateAlertContextProps,
|
||||
ICreateAlertProviderProps,
|
||||
} from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
@@ -67,6 +71,7 @@ export function CreateAlertProvider(
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
if (isEditMode) {
|
||||
@@ -122,7 +127,28 @@ export function CreateAlertProvider(
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
});
|
||||
}, [alertType]);
|
||||
|
||||
if (thresholdsFromURL) {
|
||||
try {
|
||||
const thresholds = JSON.parse(thresholdsFromURL);
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing thresholds from URL:', error);
|
||||
}
|
||||
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
});
|
||||
|
||||
setThresholdState({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
});
|
||||
}
|
||||
}, [alertType, thresholdsFromURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
|
||||
@@ -237,6 +237,7 @@ export type EvaluationWindowAction =
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
|
||||
| { type: 'SET_INITIAL_STATE_FOR_METER' }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
alertDefaults,
|
||||
@@ -11,6 +13,7 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { CumulativeWindowTimeframes } from '../EvaluationSettings/types';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
@@ -210,6 +213,18 @@ export const evaluationWindowReducer = (
|
||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
case 'SET_INITIAL_STATE_FOR_METER':
|
||||
return {
|
||||
...state,
|
||||
windowType: 'cumulative',
|
||||
timeframe: CumulativeWindowTimeframes.CURRENT_DAY,
|
||||
startingAt: {
|
||||
time: '00:00:00',
|
||||
number: '0',
|
||||
timezone: UTC_TIMEZONE.value,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -171,3 +171,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.lightMode {
|
||||
.empty-logs-search {
|
||||
&__resources-card {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__resources-description,
|
||||
&__description-list,
|
||||
&__subtitle {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&__clear-filters-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,6 +108,13 @@ function ChartPreview({
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||
@@ -296,6 +303,13 @@ function ChartPreview({
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
enhancedLegend: true,
|
||||
legendPosition,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
yAxisUnit,
|
||||
|
||||
@@ -36,6 +36,7 @@ function QuerySection({
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
const [currentTab, setCurrentTab] = useState(queryCategory);
|
||||
const [signalSource, setSignalSource] = useState<string>('metrics');
|
||||
|
||||
const handleQueryCategoryChange = (queryType: string): void => {
|
||||
setQueryCategory(queryType as EQueryType);
|
||||
@@ -48,12 +49,17 @@ function QuerySection({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleSignalSourceChange = (value: string): void => {
|
||||
setSignalSource(value);
|
||||
};
|
||||
|
||||
const renderMetricUI = (): JSX.Element => (
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
config={{
|
||||
queryVariant: 'static',
|
||||
initialDataSource: ALERTS_DATA_SOURCE_MAP[alertType],
|
||||
signalSource: signalSource === 'meter' ? 'meter' : '',
|
||||
}}
|
||||
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
|
||||
showFunctions={
|
||||
@@ -62,6 +68,8 @@ function QuerySection({
|
||||
alertType === AlertTypes.LOGS_BASED_ALERT
|
||||
}
|
||||
version={alertDef.version || 'v3'}
|
||||
onSignalSourceChange={handleSignalSourceChange}
|
||||
signalSourceChangeEnabled
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -137,8 +137,7 @@ function GeneralSettings({
|
||||
if (logsCurrentTTLValues) {
|
||||
setLogsTotalRetentionPeriod(logsCurrentTTLValues.default_ttl_days * 24);
|
||||
setLogsS3RetentionPeriod(
|
||||
logsCurrentTTLValues.cold_storage_ttl_days &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days > 0
|
||||
logsCurrentTTLValues.cold_storage_ttl_days
|
||||
? logsCurrentTTLValues.cold_storage_ttl_days * 24
|
||||
: null,
|
||||
);
|
||||
|
||||
@@ -94,6 +94,9 @@ const mockDisksWithoutS3: IDiskType[] = [
|
||||
];
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
const BUTTON_SELECTOR = 'button[type="button"]';
|
||||
const PRIMARY_BUTTON_CLASS = 'ant-btn-primary';
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(setRetentionApiV2 as jest.Mock).mockResolvedValue({
|
||||
@@ -155,10 +158,10 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.type(s3Input, '5');
|
||||
|
||||
// Find the save button in the Logs card
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
// The primary button should be the save button
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
@@ -262,9 +265,9 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
@@ -329,4 +332,59 @@ describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
expect(dropdowns?.[1]).toHaveTextContent('Days');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 4: Save Button State with S3 Disabled', () => {
|
||||
it('should disable save button when cold_storage_ttl_days is -1 and no changes made', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithoutS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll(BUTTON_SELECTOR);
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes(PRIMARY_BUTTON_CLASS),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Verify save button is disabled on initial load (no changes, S3 disabled with -1)
|
||||
expect(saveButton).toBeDisabled();
|
||||
|
||||
// Find the total retention input
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
|
||||
// Change total retention value to trigger button enable
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Button should now be enabled after change
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Revert to original value (30 days displays as 1 Month)
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '1');
|
||||
|
||||
// Button should be disabled again (back to original state)
|
||||
await waitFor(() => {
|
||||
expect(saveButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -46,8 +46,7 @@ export const convertHoursValueToRelevantUnit = (
|
||||
availableUnits?: ITimeUnit[],
|
||||
): ITimeUnitConversion => {
|
||||
const unitsToConsider = availableUnits?.length ? availableUnits : TimeUnits;
|
||||
|
||||
if (value) {
|
||||
if (value >= 0) {
|
||||
for (let idx = unitsToConsider.length - 1; idx >= 0; idx -= 1) {
|
||||
const timeUnit = unitsToConsider[idx];
|
||||
const convertedValue = timeUnit.multiplier * value;
|
||||
@@ -62,7 +61,7 @@ export const convertHoursValueToRelevantUnit = (
|
||||
}
|
||||
|
||||
// Fallback to the first available unit
|
||||
return { value, timeUnitValue: unitsToConsider[0].value };
|
||||
return { value: -1, timeUnitValue: unitsToConsider[0].value };
|
||||
};
|
||||
|
||||
export const convertHoursValueToRelevantUnitString = (
|
||||
|
||||
@@ -324,6 +324,7 @@ function FullView({
|
||||
panelType={selectedPanelType}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
// filterConfigs={filterConfigs}
|
||||
// queryComponents={queryComponents}
|
||||
/>
|
||||
|
||||
@@ -17,12 +17,6 @@ export const Card = styled(CardComponent)<CardProps>`
|
||||
overflow: hidden;
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
background: linear-gradient(
|
||||
0deg,
|
||||
rgba(171, 189, 255, 0) 0%,
|
||||
rgba(171, 189, 255, 0) 100%
|
||||
),
|
||||
#0b0c0e;
|
||||
|
||||
${({ isDarkMode }): StyledCSS =>
|
||||
!isDarkMode &&
|
||||
|
||||
@@ -48,6 +48,7 @@ function GridTableComponent({
|
||||
widgetId,
|
||||
panelType,
|
||||
queryRangeRequest,
|
||||
decimalPrecision,
|
||||
...props
|
||||
}: GridTableComponentProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
@@ -87,10 +88,15 @@ function GridTableComponent({
|
||||
const newValue = { ...val };
|
||||
Object.keys(val).forEach((k) => {
|
||||
const unit = getColumnUnit(k, columnUnits);
|
||||
|
||||
if (unit) {
|
||||
// the check below takes care of not adding units for rows that have n/a or null values
|
||||
if (val[k] !== 'n/a' && val[k] !== null) {
|
||||
newValue[k] = getYAxisFormattedValue(String(val[k]), unit);
|
||||
newValue[k] = getYAxisFormattedValue(
|
||||
String(val[k]),
|
||||
unit,
|
||||
decimalPrecision,
|
||||
);
|
||||
} else if (val[k] === null) {
|
||||
newValue[k] = 'n/a';
|
||||
}
|
||||
@@ -103,7 +109,7 @@ function GridTableComponent({
|
||||
|
||||
return mutateDataSource;
|
||||
},
|
||||
[columnUnits],
|
||||
[columnUnits, decimalPrecision],
|
||||
);
|
||||
|
||||
const dataSource = useMemo(() => applyColumnUnits(originalDataSource), [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TableProps } from 'antd';
|
||||
import { PrecisionOption } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ export type GridTableComponentProps = {
|
||||
query: Query;
|
||||
thresholds?: ThresholdProps[];
|
||||
columnUnits?: ColumnUnit;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
tableProcessedDataRef?: React.MutableRefObject<RowData[]>;
|
||||
sticky?: TableProps<RowData>['sticky'];
|
||||
searchTerm?: string;
|
||||
|
||||
@@ -99,7 +99,11 @@ function GridValueComponent({
|
||||
rawValue={value}
|
||||
value={
|
||||
yAxisUnit
|
||||
? getYAxisFormattedValue(String(value), yAxisUnit)
|
||||
? getYAxisFormattedValue(
|
||||
String(value),
|
||||
yAxisUnit,
|
||||
widget?.decimalPrecision,
|
||||
)
|
||||
: value.toString()
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -423,6 +423,7 @@
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
|
||||
.section-icon {
|
||||
display: flex;
|
||||
@@ -461,7 +462,6 @@
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
|
||||
width: 150px;
|
||||
justify-content: flex-end;
|
||||
|
||||
.ant-btn {
|
||||
|
||||
@@ -115,6 +115,13 @@ function EntityMetrics<T>({
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
@@ -184,6 +191,13 @@ function EntityMetrics<T>({
|
||||
maxTimeScale: graphTimeIntervals[idx].end,
|
||||
onDragSelect: (start, end) => onDragSelect(start, end, idx),
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
});
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.set-alert-btn {
|
||||
cursor: pointer;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TablePaginationConfig,
|
||||
TableProps as AntDTableProps,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
@@ -34,15 +35,20 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import Tags from 'components/Tags/Tags';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryMeterWithType } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData';
|
||||
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isNil, isUndefined } from 'lodash-es';
|
||||
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BellPlus,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Copy,
|
||||
@@ -60,6 +66,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import {
|
||||
@@ -71,6 +78,7 @@ import {
|
||||
IngestionKeyProps,
|
||||
PaginationProps,
|
||||
} from 'types/api/ingestionKeys/types';
|
||||
import { MeterAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getDaysUntilExpiry } from 'utils/timeUtils';
|
||||
|
||||
@@ -170,6 +178,8 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const { isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const [
|
||||
hasCreateLimitForIngestionKeyError,
|
||||
setHasCreateLimitForIngestionKeyError,
|
||||
@@ -694,6 +704,68 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleCreateAlert = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
let metricName = '';
|
||||
|
||||
switch (signal.signal) {
|
||||
case 'metrics':
|
||||
metricName = 'signoz.meter.metric.datapoint.count';
|
||||
break;
|
||||
case 'traces':
|
||||
metricName = 'signoz.meter.span.size';
|
||||
break;
|
||||
case 'logs':
|
||||
metricName = 'signoz.meter.log.size';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
signal.signal === 'metrics'
|
||||
? signal.config?.day?.count || 0
|
||||
: signal.config?.day?.size || 0;
|
||||
|
||||
const query = {
|
||||
...initialQueryMeterWithType,
|
||||
builder: {
|
||||
...initialQueryMeterWithType.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryMeterWithType.builder.queryData[0],
|
||||
aggregations: [
|
||||
{
|
||||
...initialQueryMeterWithType.builder.queryData[0].aggregations?.[0],
|
||||
metricName,
|
||||
timeAggregation: MeterAggregateOperator.INCREASE,
|
||||
spaceAggregation: MeterAggregateOperator.SUM,
|
||||
},
|
||||
],
|
||||
filter: {
|
||||
expression: `signoz.workspace.key.id='${APIKey.id}'`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const stringifiedQuery = JSON.stringify(query);
|
||||
|
||||
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
|
||||
thresholds[0].thresholdValue = threshold;
|
||||
|
||||
const URL = `${ROUTES.ALERTS_NEW}?showNewCreateAlertsPage=true&${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(stringifiedQuery)}&${
|
||||
QueryParams.thresholds
|
||||
}=${encodeURIComponent(JSON.stringify(thresholds))}`;
|
||||
|
||||
history.push(URL);
|
||||
};
|
||||
|
||||
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
|
||||
{
|
||||
title: 'Ingestion Key',
|
||||
@@ -1183,6 +1255,27 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{((signalCfg.usesSize &&
|
||||
limit?.config?.day?.size !== undefined) ||
|
||||
(signalCfg.usesCount &&
|
||||
limit?.config?.day?.count !== undefined)) && (
|
||||
<Tooltip
|
||||
title="Set alert on this limit"
|
||||
placement="top"
|
||||
arrow={false}
|
||||
>
|
||||
<Button
|
||||
icon={<BellPlus size={14} color={Color.BG_CHERRY_400} />}
|
||||
className="set-alert-btn periscope-btn ghost"
|
||||
type="text"
|
||||
data-testid={`set-alert-btn-${signalName}`}
|
||||
onClick={(): void =>
|
||||
handleCreateAlert(APIKey, limitsDict[signalName])
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECOND limit usage/limit */}
|
||||
|
||||
@@ -1,10 +1,60 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { LimitProps } from 'types/api/ingestionKeys/limits/types';
|
||||
import {
|
||||
AllIngestionKeyProps,
|
||||
IngestionKeyProps,
|
||||
} from 'types/api/ingestionKeys/types';
|
||||
|
||||
import MultiIngestionSettings from '../MultiIngestionSettings';
|
||||
|
||||
// Extend the existing types to include limits with proper structure
|
||||
interface TestIngestionKeyProps extends Omit<IngestionKeyProps, 'limits'> {
|
||||
limits?: LimitProps[];
|
||||
}
|
||||
|
||||
interface TestAllIngestionKeyProps extends Omit<AllIngestionKeyProps, 'data'> {
|
||||
data: TestIngestionKeyProps[];
|
||||
}
|
||||
|
||||
// Mock useHistory.push to capture navigation URL used by MultiIngestionSettings
|
||||
const mockPush = jest.fn() as jest.MockedFunction<(path: string) => void>;
|
||||
jest.mock('react-router-dom', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useHistory: (): { push: typeof mockPush } => ({ push: mockPush }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock deployments data hook to avoid unrelated network calls in this page
|
||||
jest.mock(
|
||||
'hooks/CustomDomain/useGetDeploymentsData',
|
||||
(): Record<string, unknown> => ({
|
||||
useGetDeploymentsData: (): {
|
||||
data: undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
} => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const TEST_CREATED_UPDATED = '2024-01-01T00:00:00Z';
|
||||
const TEST_EXPIRES_AT = '2030-01-01T00:00:00Z';
|
||||
const TEST_WORKSPACE_ID = 'w1';
|
||||
const INGESTION_SETTINGS_ROUTE = '/ingestion-settings';
|
||||
|
||||
describe('MultiIngestionSettings Page', () => {
|
||||
beforeEach(() => {
|
||||
render(<MultiIngestionSettings />);
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -12,6 +62,10 @@ describe('MultiIngestionSettings Page', () => {
|
||||
});
|
||||
|
||||
it('renders MultiIngestionSettings page without crashing', () => {
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Ingestion Keys')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
@@ -27,4 +81,181 @@ describe('MultiIngestionSettings Page', () => {
|
||||
expect(aboutKeyslink).toHaveClass('learn-more');
|
||||
expect(aboutKeyslink).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
|
||||
it('navigates to create alert with metrics count threshold', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a metrics daily count limit so the alert button is visible
|
||||
const response: TestAllIngestionKeyProps = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
name: 'Key One',
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l1',
|
||||
signal: 'metrics',
|
||||
config: { day: { count: 1000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
|
||||
// Render with initial route to test navigation
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
// Wait for ingestion key to load and expand the row to show limits
|
||||
await screen.findByText('Key One');
|
||||
const expandButton = screen.getByRole('button', { name: /right Key One/i });
|
||||
await user.click(expandButton);
|
||||
|
||||
// Wait for limits section to render and click metrics alert button by test id
|
||||
await screen.findByText('LIMITS');
|
||||
const metricsAlertBtn = (await screen.findByTestId(
|
||||
'set-alert-btn-metrics',
|
||||
)) as HTMLButtonElement;
|
||||
await user.click(metricsAlertBtn);
|
||||
|
||||
// Wait for navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Assert: navigation occurred with correct query parameters
|
||||
const navigationCall = mockPush.mock.calls[0][0] as string;
|
||||
|
||||
// Check URL contains alerts/new route
|
||||
expect(navigationCall).toContain('/alerts/new');
|
||||
expect(navigationCall).toContain('showNewCreateAlertsPage=true');
|
||||
|
||||
// Parse query parameters
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
|
||||
expect(thresholds).toBeDefined();
|
||||
expect(thresholds[0].thresholdValue).toBe(1000);
|
||||
|
||||
// Verify compositeQuery parameter exists and contains correct data
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression for the key
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k1'",
|
||||
);
|
||||
|
||||
// Verify metric name for metrics signal
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.metric.datapoint.count',
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to create alert for logs with size threshold', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a logs daily size limit so the alert button is visible
|
||||
const response: TestAllIngestionKeyProps = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
name: 'Key Two',
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l2',
|
||||
signal: 'logs',
|
||||
config: { day: { size: 2048 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
|
||||
// Wait for ingestion key to load and expand the row to show limits
|
||||
await screen.findByText('Key Two');
|
||||
const expandButton = screen.getByRole('button', { name: /right Key Two/i });
|
||||
await user.click(expandButton);
|
||||
|
||||
// Wait for limits section to render and click logs alert button by test id
|
||||
await screen.findByText('LIMITS');
|
||||
const logsAlertBtn = (await screen.findByTestId(
|
||||
'set-alert-btn-logs',
|
||||
)) as HTMLButtonElement;
|
||||
await user.click(logsAlertBtn);
|
||||
|
||||
// Wait for navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Assert: navigation occurred with correct query parameters
|
||||
const navigationCall = mockPush.mock.calls[0][0] as string;
|
||||
|
||||
// Check URL contains alerts/new route
|
||||
expect(navigationCall).toContain('/alerts/new');
|
||||
expect(navigationCall).toContain('showNewCreateAlertsPage=true');
|
||||
|
||||
// Parse query parameters
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
// Verify thresholds parameter
|
||||
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
|
||||
expect(thresholds).toBeDefined();
|
||||
expect(thresholds[0].thresholdValue).toBe(2048);
|
||||
|
||||
// Verify compositeQuery parameter exists and contains correct data
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression for the key
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k2'",
|
||||
);
|
||||
|
||||
// Verify metric name for logs signal
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import './InfraMetrics.styles.scss';
|
||||
|
||||
import { Empty, Radio } from 'antd';
|
||||
import { Empty } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { History, Table } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { VIEW_TYPES } from './constants';
|
||||
import NodeMetrics from './NodeMetrics';
|
||||
@@ -14,7 +16,8 @@ interface MetricsDataProps {
|
||||
nodeName: string;
|
||||
hostName: string;
|
||||
clusterName: string;
|
||||
logLineTimestamp: string;
|
||||
timestamp: string;
|
||||
dataSource: DataSource.LOGS | DataSource.TRACES;
|
||||
}
|
||||
|
||||
function InfraMetrics({
|
||||
@@ -22,22 +25,56 @@ function InfraMetrics({
|
||||
nodeName,
|
||||
hostName,
|
||||
clusterName,
|
||||
logLineTimestamp,
|
||||
timestamp,
|
||||
dataSource = DataSource.LOGS,
|
||||
}: MetricsDataProps): JSX.Element {
|
||||
const [selectedView, setSelectedView] = useState<string>(() =>
|
||||
podName ? VIEW_TYPES.POD : VIEW_TYPES.NODE,
|
||||
);
|
||||
|
||||
const viewOptions = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Node
|
||||
</div>
|
||||
),
|
||||
value: VIEW_TYPES.NODE,
|
||||
},
|
||||
];
|
||||
|
||||
if (podName) {
|
||||
options.push({
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<History size={14} />
|
||||
Pod
|
||||
</div>
|
||||
),
|
||||
value: VIEW_TYPES.POD,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [podName]);
|
||||
|
||||
const handleModeChange = (e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
};
|
||||
|
||||
if (!podName && !nodeName && !hostName) {
|
||||
const emptyStateDescription =
|
||||
dataSource === DataSource.TRACES
|
||||
? 'No data available. Please select a span containing a pod, node, or host attributes to view metrics.'
|
||||
: 'No data available. Please select a valid log line containing a pod, node, or host attributes to view metrics.';
|
||||
|
||||
return (
|
||||
<div className="empty-container">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="No data available. Please select a valid log line containing a pod, node, or host attributes to view metrics."
|
||||
description={emptyStateDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -45,46 +82,26 @@ function InfraMetrics({
|
||||
|
||||
return (
|
||||
<div className="infra-metrics-container">
|
||||
<Radio.Group
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
<SignozRadioGroup
|
||||
value={selectedView}
|
||||
>
|
||||
<Radio.Button
|
||||
className={selectedView === VIEW_TYPES.NODE ? 'selected_view tab' : 'tab'}
|
||||
value={VIEW_TYPES.NODE}
|
||||
>
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Node
|
||||
</div>
|
||||
</Radio.Button>
|
||||
{podName && (
|
||||
<Radio.Button
|
||||
className={selectedView === VIEW_TYPES.POD ? 'selected_view tab' : 'tab'}
|
||||
value={VIEW_TYPES.POD}
|
||||
>
|
||||
<div className="view-title">
|
||||
<History size={14} />
|
||||
Pod
|
||||
</div>
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
onChange={handleModeChange}
|
||||
className="views-tabs"
|
||||
options={viewOptions}
|
||||
/>
|
||||
{/* TODO(Rahul): Make a common config driven component for this and other infra metrics components */}
|
||||
{selectedView === VIEW_TYPES.NODE && (
|
||||
<NodeMetrics
|
||||
nodeName={nodeName}
|
||||
clusterName={clusterName}
|
||||
hostName={hostName}
|
||||
logLineTimestamp={logLineTimestamp}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.POD && podName && (
|
||||
<PodMetrics
|
||||
podName={podName}
|
||||
clusterName={clusterName}
|
||||
logLineTimestamp={logLineTimestamp}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,15 +29,15 @@ function NodeMetrics({
|
||||
nodeName,
|
||||
clusterName,
|
||||
hostName,
|
||||
logLineTimestamp,
|
||||
timestamp,
|
||||
}: {
|
||||
nodeName: string;
|
||||
clusterName: string;
|
||||
hostName: string;
|
||||
logLineTimestamp: string;
|
||||
timestamp: string;
|
||||
}): JSX.Element {
|
||||
const { start, end, verticalLineTimestamp } = useMemo(() => {
|
||||
const logTimestamp = dayjs(logLineTimestamp);
|
||||
const logTimestamp = dayjs(timestamp);
|
||||
const now = dayjs();
|
||||
const startTime = logTimestamp.subtract(3, 'hour');
|
||||
|
||||
@@ -50,7 +50,7 @@ function NodeMetrics({
|
||||
end: endTime.unix(),
|
||||
verticalLineTimestamp: logTimestamp.unix(),
|
||||
};
|
||||
}, [logLineTimestamp]);
|
||||
}, [timestamp]);
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
@@ -83,6 +83,13 @@ function NodeMetrics({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const chartData = useMemo(
|
||||
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
|
||||
@@ -109,6 +116,13 @@ function NodeMetrics({
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
|
||||
timezone: timezone.value,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
|
||||
@@ -23,14 +23,14 @@ import { getPodQueryPayload, podWidgetInfo } from './constants';
|
||||
function PodMetrics({
|
||||
podName,
|
||||
clusterName,
|
||||
logLineTimestamp,
|
||||
timestamp,
|
||||
}: {
|
||||
podName: string;
|
||||
clusterName: string;
|
||||
logLineTimestamp: string;
|
||||
timestamp: string;
|
||||
}): JSX.Element {
|
||||
const { start, end, verticalLineTimestamp } = useMemo(() => {
|
||||
const logTimestamp = dayjs(logLineTimestamp);
|
||||
const logTimestamp = dayjs(timestamp);
|
||||
const now = dayjs();
|
||||
const startTime = logTimestamp.subtract(3, 'hour');
|
||||
|
||||
@@ -43,7 +43,15 @@ function PodMetrics({
|
||||
end: endTime.unix(),
|
||||
verticalLineTimestamp: logTimestamp.unix(),
|
||||
};
|
||||
}, [logLineTimestamp]);
|
||||
}, [timestamp]);
|
||||
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
@@ -91,6 +99,13 @@ function PodMetrics({
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
|
||||
timezone: timezone.value,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
|
||||
@@ -33,6 +33,7 @@ function Explorer(): JSX.Element {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -50,6 +51,15 @@ function Explorer(): JSX.Element {
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSetQueryData(0, {
|
||||
...initialQueryMeterWithType.builder.queryData[0],
|
||||
source: 'meter',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
|
||||
@@ -290,13 +290,6 @@ function Summary(): JSX.Element {
|
||||
],
|
||||
);
|
||||
|
||||
console.log({
|
||||
isMetricsListDataEmpty,
|
||||
isMetricsTreeMapDataEmpty,
|
||||
treeMapData,
|
||||
sec: treeMapData?.payload?.data[heatmapView],
|
||||
});
|
||||
|
||||
return (
|
||||
<Sentry.ErrorBoundary fallback={<ErrorBoundaryFallback />}>
|
||||
<div className="metrics-explorer-summary-tab">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
/* eslint-disable sonarjs/no-identical-functions */
|
||||
import { getNonIntegrationDashboardById } from 'mocks-server/__mockdata__/dashboards';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { rest } from 'msw';
|
||||
@@ -7,6 +8,16 @@ import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
|
||||
import DashboardDescription from '..';
|
||||
|
||||
interface MockSafeNavigateReturn {
|
||||
safeNavigate: jest.MockedFunction<(url: string) => void>;
|
||||
}
|
||||
|
||||
const DASHBOARD_TEST_ID = 'dashboard-title';
|
||||
const DASHBOARD_TITLE_TEXT = 'thor';
|
||||
const DASHBOARD_PATH = '/dashboard/4';
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
@@ -26,20 +37,24 @@ jest.mock(
|
||||
);
|
||||
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: jest.fn(),
|
||||
useSafeNavigate: (): MockSafeNavigateReturn => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('Dashboard landing page actions header tests', () => {
|
||||
beforeEach(() => {
|
||||
mockSafeNavigate.mockClear();
|
||||
});
|
||||
|
||||
it('unlock dashboard should be disabled for integrations created dashboards', async () => {
|
||||
const mockLocation = {
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`,
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
|
||||
search: '',
|
||||
};
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={['/dashboard/4']}>
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
@@ -54,7 +69,9 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('dashboard-title')).toHaveTextContent('thor'),
|
||||
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
|
||||
DASHBOARD_TITLE_TEXT,
|
||||
),
|
||||
);
|
||||
|
||||
const dashboardSettingsTrigger = getByTestId('options');
|
||||
@@ -65,9 +82,10 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
await waitFor(() => expect(lockUnlockButton).toBeDisabled());
|
||||
});
|
||||
|
||||
it('unlock dashboard should not be disabled for non integration created dashboards', async () => {
|
||||
const mockLocation = {
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}/dashboard/4`,
|
||||
pathname: `${process.env.FRONTEND_API_ENDPOINT}${DASHBOARD_PATH}`,
|
||||
search: '',
|
||||
};
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
@@ -77,7 +95,7 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
),
|
||||
);
|
||||
const { getByTestId } = render(
|
||||
<MemoryRouter initialEntries={['/dashboard/4']}>
|
||||
<MemoryRouter initialEntries={[DASHBOARD_PATH]}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
@@ -92,7 +110,9 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(getByTestId('dashboard-title')).toHaveTextContent('thor'),
|
||||
expect(getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
|
||||
DASHBOARD_TITLE_TEXT,
|
||||
),
|
||||
);
|
||||
|
||||
const dashboardSettingsTrigger = getByTestId('options');
|
||||
@@ -103,4 +123,58 @@ describe('Dashboard landing page actions header tests', () => {
|
||||
|
||||
await waitFor(() => expect(lockUnlockButton).not.toBeDisabled());
|
||||
});
|
||||
|
||||
it('should navigate to dashboard list with correct params and exclude variables', async () => {
|
||||
const dashboardUrlWithVariables = `${DASHBOARD_PATH}?variables=%7B%22var1%22%3A%22value1%22%7D&otherParam=test`;
|
||||
const mockLocation = {
|
||||
pathname: DASHBOARD_PATH,
|
||||
search: '?variables=%7B%22var1%22%3A%22value1%22%7D&otherParam=test',
|
||||
};
|
||||
|
||||
(useLocation as jest.Mock).mockReturnValue(mockLocation);
|
||||
|
||||
const { getByText } = render(
|
||||
<MemoryRouter initialEntries={[dashboardUrlWithVariables]}>
|
||||
<DashboardProvider>
|
||||
<DashboardDescription
|
||||
handle={{
|
||||
active: false,
|
||||
enter: (): Promise<void> => Promise.resolve(),
|
||||
exit: (): Promise<void> => Promise.resolve(),
|
||||
node: { current: null },
|
||||
}}
|
||||
/>
|
||||
</DashboardProvider>
|
||||
</MemoryRouter>,
|
||||
);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByTestId(DASHBOARD_TEST_ID)).toHaveTextContent(
|
||||
DASHBOARD_TITLE_TEXT,
|
||||
),
|
||||
);
|
||||
|
||||
// Click the dashboard breadcrumb to navigate back to list
|
||||
const dashboardButton = getByText('Dashboard /');
|
||||
fireEvent.click(dashboardButton);
|
||||
|
||||
// Verify navigation was called with correct URL
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
'/dashboard?columnKey=updatedAt&order=descend&page=1&search=',
|
||||
);
|
||||
|
||||
// Ensure the URL contains only essential dashboard list params
|
||||
const calledUrl = mockSafeNavigate.mock.calls[0][0] as string;
|
||||
const urlParams = new URLSearchParams(calledUrl.split('?')[1]);
|
||||
|
||||
// Should have essential dashboard list params
|
||||
expect(urlParams.get('columnKey')).toBe('updatedAt');
|
||||
expect(urlParams.get('order')).toBe('descend');
|
||||
expect(urlParams.get('page')).toBe('1');
|
||||
expect(urlParams.get('search')).toBe('');
|
||||
|
||||
// Should NOT have variables or other dashboard-specific params
|
||||
expect(urlParams.has('variables')).toBeFalsy();
|
||||
expect(urlParams.has('relativeTime')).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
} from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import HeaderRightSection from 'components/HeaderRightSection/HeaderRightSection';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { PANEL_GROUP_TYPES, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { DeleteButton } from 'container/ListOfDashboard/TableComponents/DeleteButton';
|
||||
@@ -22,7 +21,6 @@ import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import useComponentPermission from 'hooks/useComponentPermission';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { useSafeNavigate } from 'hooks/useSafeNavigate';
|
||||
import useUrlQuery from 'hooks/useUrlQuery';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import {
|
||||
Check,
|
||||
@@ -116,8 +114,6 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
|
||||
const updateDashboardMutation = useUpdateDashboard();
|
||||
|
||||
const urlQuery = useUrlQuery();
|
||||
|
||||
const { user } = useAppContext();
|
||||
const [editDashboard] = useComponentPermission(['edit_dashboard'], user.role);
|
||||
const [isDashboardSettingsOpen, setIsDashbordSettingsOpen] = useState<boolean>(
|
||||
@@ -291,13 +287,13 @@ function DashboardDescription(props: DashboardDescriptionProps): JSX.Element {
|
||||
}
|
||||
|
||||
function goToListPage(): void {
|
||||
urlQuery.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlQuery.set('order', listSortOrder.order as string);
|
||||
urlQuery.set('page', listSortOrder.pagination as string);
|
||||
urlQuery.set('search', listSortOrder.search as string);
|
||||
urlQuery.delete(QueryParams.relativeTime);
|
||||
const urlParams = new URLSearchParams();
|
||||
urlParams.set('columnKey', listSortOrder.columnKey as string);
|
||||
urlParams.set('order', listSortOrder.order as string);
|
||||
urlParams.set('page', listSortOrder.pagination as string);
|
||||
urlParams.set('search', listSortOrder.search as string);
|
||||
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlQuery.toString()}`;
|
||||
const generatedUrl = `${ROUTES.ALL_DASHBOARD}?${urlParams.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
}
|
||||
|
||||
|
||||
@@ -168,6 +168,7 @@ function QuerySection({
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
isListViewPanel={selectedGraph === PANEL_TYPES.LIST}
|
||||
queryComponents={queryComponents}
|
||||
signalSourceChangeEnabled
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
|
||||
@@ -158,7 +158,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
.log-scale {
|
||||
.log-scale,
|
||||
.decimal-precision-selector {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
||||
@@ -192,3 +192,17 @@ export const panelTypeVsContextLinks: {
|
||||
[PANEL_TYPES.TRACE]: false,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
||||
} as const;
|
||||
|
||||
export const panelTypeVsDecimalPrecision: {
|
||||
[key in PANEL_TYPES]: boolean;
|
||||
} = {
|
||||
[PANEL_TYPES.TIME_SERIES]: true,
|
||||
[PANEL_TYPES.VALUE]: true,
|
||||
[PANEL_TYPES.TABLE]: true,
|
||||
[PANEL_TYPES.LIST]: false,
|
||||
[PANEL_TYPES.PIE]: true,
|
||||
[PANEL_TYPES.BAR]: true,
|
||||
[PANEL_TYPES.HISTOGRAM]: false,
|
||||
[PANEL_TYPES.TRACE]: false,
|
||||
[PANEL_TYPES.EMPTY_WIDGET]: false,
|
||||
} as const;
|
||||
|
||||
@@ -12,6 +12,10 @@ import {
|
||||
Switch,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import {
|
||||
PrecisionOption,
|
||||
PrecisionOptionsEnum,
|
||||
} from 'components/Graph/yAxisConfig';
|
||||
import TimePreference from 'components/TimePreferenceDropDown';
|
||||
import { PANEL_TYPES, PanelDisplay } from 'constants/queryBuilder';
|
||||
import GraphTypes, {
|
||||
@@ -48,6 +52,7 @@ import {
|
||||
panelTypeVsColumnUnitPreferences,
|
||||
panelTypeVsContextLinks,
|
||||
panelTypeVsCreateAlert,
|
||||
panelTypeVsDecimalPrecision,
|
||||
panelTypeVsFillSpan,
|
||||
panelTypeVsLegendColors,
|
||||
panelTypeVsLegendPosition,
|
||||
@@ -95,6 +100,8 @@ function RightContainer({
|
||||
selectedTime,
|
||||
yAxisUnit,
|
||||
setYAxisUnit,
|
||||
decimalPrecision,
|
||||
setDecimalPrecision,
|
||||
setGraphHandler,
|
||||
thresholds,
|
||||
combineHistogram,
|
||||
@@ -160,6 +167,7 @@ function RightContainer({
|
||||
panelTypeVsColumnUnitPreferences[selectedGraph];
|
||||
const allowContextLinks =
|
||||
panelTypeVsContextLinks[selectedGraph] && enableDrillDown;
|
||||
const allowDecimalPrecision = panelTypeVsDecimalPrecision[selectedGraph];
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
@@ -356,6 +364,30 @@ function RightContainer({
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{allowDecimalPrecision && (
|
||||
<section className="decimal-precision-selector">
|
||||
<Typography.Text className="typography">
|
||||
Decimal Precision
|
||||
</Typography.Text>
|
||||
<Select
|
||||
options={[
|
||||
{ label: '0 decimals', value: PrecisionOptionsEnum.ZERO },
|
||||
{ label: '1 decimal', value: PrecisionOptionsEnum.ONE },
|
||||
{ label: '2 decimals', value: PrecisionOptionsEnum.TWO },
|
||||
{ label: '3 decimals', value: PrecisionOptionsEnum.THREE },
|
||||
{ label: '4 decimals', value: PrecisionOptionsEnum.FOUR },
|
||||
{ label: 'Full Precision', value: PrecisionOptionsEnum.FULL },
|
||||
]}
|
||||
value={decimalPrecision}
|
||||
style={{ width: '100%' }}
|
||||
className="panel-type-select"
|
||||
defaultValue={PrecisionOptionsEnum.TWO}
|
||||
onChange={(val: PrecisionOption): void => setDecimalPrecision(val)}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{allowSoftMinMax && (
|
||||
<section className="soft-min-max">
|
||||
<section className="container">
|
||||
@@ -553,6 +585,8 @@ interface RightContainerProps {
|
||||
setBucketWidth: Dispatch<SetStateAction<number>>;
|
||||
setBucketCount: Dispatch<SetStateAction<number>>;
|
||||
setYAxisUnit: Dispatch<SetStateAction<string>>;
|
||||
decimalPrecision: PrecisionOption;
|
||||
setDecimalPrecision: Dispatch<SetStateAction<PrecisionOption>>;
|
||||
setGraphHandler: (type: PANEL_TYPES) => void;
|
||||
thresholds: ThresholdProps[];
|
||||
setThresholds: Dispatch<SetStateAction<ThresholdProps[]>>;
|
||||
|
||||
@@ -4,6 +4,10 @@ import './NewWidget.styles.scss';
|
||||
import { WarningOutlined } from '@ant-design/icons';
|
||||
import { Button, Flex, Modal, Space, Typography } from 'antd';
|
||||
import logEvent from 'api/common/logEvent';
|
||||
import {
|
||||
PrecisionOption,
|
||||
PrecisionOptionsEnum,
|
||||
} from 'components/Graph/yAxisConfig';
|
||||
import OverlayScrollbar from 'components/OverlayScrollbar/OverlayScrollbar';
|
||||
import { adjustQueryForV5 } from 'components/QueryBuilderV2/utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
@@ -178,6 +182,10 @@ function NewWidget({
|
||||
selectedWidget?.yAxisUnit || 'none',
|
||||
);
|
||||
|
||||
const [decimalPrecision, setDecimalPrecision] = useState<PrecisionOption>(
|
||||
selectedWidget?.decimalPrecision ?? PrecisionOptionsEnum.TWO,
|
||||
);
|
||||
|
||||
const [stackedBarChart, setStackedBarChart] = useState<boolean>(
|
||||
selectedWidget?.stackedBarChart || false,
|
||||
);
|
||||
@@ -257,6 +265,7 @@ function NewWidget({
|
||||
opacity,
|
||||
nullZeroValues: selectedNullZeroValue,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
thresholds,
|
||||
softMin,
|
||||
softMax,
|
||||
@@ -290,6 +299,7 @@ function NewWidget({
|
||||
thresholds,
|
||||
title,
|
||||
yAxisUnit,
|
||||
decimalPrecision,
|
||||
bucketWidth,
|
||||
bucketCount,
|
||||
combineHistogram,
|
||||
@@ -493,6 +503,8 @@ function NewWidget({
|
||||
title: selectedWidget?.title,
|
||||
stackedBarChart: selectedWidget?.stackedBarChart || false,
|
||||
yAxisUnit: selectedWidget?.yAxisUnit,
|
||||
decimalPrecision:
|
||||
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
|
||||
panelTypes: graphType,
|
||||
query: adjustedQueryForV5,
|
||||
thresholds: selectedWidget?.thresholds,
|
||||
@@ -522,6 +534,8 @@ function NewWidget({
|
||||
title: selectedWidget?.title,
|
||||
stackedBarChart: selectedWidget?.stackedBarChart || false,
|
||||
yAxisUnit: selectedWidget?.yAxisUnit,
|
||||
decimalPrecision:
|
||||
selectedWidget?.decimalPrecision || PrecisionOptionsEnum.TWO,
|
||||
panelTypes: graphType,
|
||||
query: adjustedQueryForV5,
|
||||
thresholds: selectedWidget?.thresholds,
|
||||
@@ -836,6 +850,8 @@ function NewWidget({
|
||||
setSelectedTime={setSelectedTime}
|
||||
selectedTime={selectedTime}
|
||||
setYAxisUnit={setYAxisUnit}
|
||||
decimalPrecision={decimalPrecision}
|
||||
setDecimalPrecision={setDecimalPrecision}
|
||||
thresholds={thresholds}
|
||||
setThresholds={setThresholds}
|
||||
selectedWidget={selectedWidget}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DefaultOptionType } from 'antd/es/select';
|
||||
import { omitIdFromQuery } from 'components/ExplorerCard/utils';
|
||||
import { PrecisionOptionsEnum } from 'components/Graph/yAxisConfig';
|
||||
import {
|
||||
initialQueryBuilderFormValuesMap,
|
||||
PANEL_TYPES,
|
||||
@@ -554,6 +555,7 @@ export const getDefaultWidgetData = (
|
||||
softMax: null,
|
||||
softMin: null,
|
||||
stackedBarChart: name === PANEL_TYPES.BAR,
|
||||
decimalPrecision: PrecisionOptionsEnum.TWO, // default decimal precision
|
||||
selectedLogFields: defaultLogsSelectedColumns.map((field) => ({
|
||||
...field,
|
||||
type: field.fieldContext ?? '',
|
||||
|
||||
@@ -347,6 +347,7 @@ function OnboardingAddDataSource(): JSX.Element {
|
||||
`${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.BASE}: ${ONBOARDING_V3_ANALYTICS_EVENTS_MAP?.DATA_SOURCE_SEARCHED}`,
|
||||
{
|
||||
searchedDataSource: query,
|
||||
resultCount: filteredDataSources.length,
|
||||
},
|
||||
);
|
||||
}, 300);
|
||||
|
||||
@@ -26,6 +26,7 @@ function HistogramPanelWrapper({
|
||||
enableDrillDown = false,
|
||||
}: PanelWrapperProps): JSX.Element {
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const legendScrollPositionRef = useRef<number>(0);
|
||||
const { toScrollWidgetId, setToScrollWidgetId } = useDashboard();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const containerDimensions = useResizeObserver(graphRef);
|
||||
@@ -129,6 +130,10 @@ function HistogramPanelWrapper({
|
||||
onClickHandler: enableDrillDown
|
||||
? clickHandlerWithContextMenu
|
||||
: onClickHandler ?? _noop,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: number) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
containerDimensions,
|
||||
|
||||
@@ -104,6 +104,7 @@ function PiePanelWrapper({
|
||||
const formattedTotal = getYAxisFormattedValue(
|
||||
totalValue.toString(),
|
||||
widget?.yAxisUnit || 'none',
|
||||
widget?.decimalPrecision,
|
||||
);
|
||||
|
||||
// Extract numeric part and unit separately for styling
|
||||
@@ -219,6 +220,7 @@ function PiePanelWrapper({
|
||||
const displayValue = getYAxisFormattedValue(
|
||||
arc.data.value,
|
||||
widget?.yAxisUnit || 'none',
|
||||
widget?.decimalPrecision,
|
||||
);
|
||||
|
||||
// Determine text anchor based on position in the circle
|
||||
|
||||
@@ -40,6 +40,7 @@ function TablePanelWrapper({
|
||||
enableDrillDown={enableDrillDown}
|
||||
panelType={widget.panelTypes}
|
||||
queryRangeRequest={queryRangeRequest}
|
||||
decimalPrecision={widget.decimalPrecision}
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...GRID_TABLE_CONFIG}
|
||||
/>
|
||||
|
||||
@@ -249,6 +249,7 @@ function UplotPanelWrapper({
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
decimalPrecision: widget.decimalPrecision,
|
||||
}),
|
||||
[
|
||||
queryResponse.data?.payload,
|
||||
|
||||
@@ -27,7 +27,7 @@ describe('Value panel wrappper tests', () => {
|
||||
);
|
||||
|
||||
// selected y axis unit as miliseconds (ms)
|
||||
expect(getByText('295')).toBeInTheDocument();
|
||||
expect(getByText('295.43')).toBeInTheDocument();
|
||||
expect(getByText('ms')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
|
||||
@@ -330,7 +330,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
431.25 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -368,7 +368,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
431 ms
|
||||
431.25 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -406,7 +406,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
287 ms
|
||||
287.11 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -444,7 +444,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
230 ms
|
||||
230.02 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -482,7 +482,7 @@ exports[`Table panel wrappper tests table should render fine with the query resp
|
||||
<div
|
||||
class="line-clamped-wrapper__text"
|
||||
>
|
||||
66.4 ms
|
||||
66.37 ms
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -51,7 +51,7 @@ exports[`Value panel wrappper tests should render tooltip when there are conflic
|
||||
class="ant-typography value-graph-text css-dev-only-do-not-override-2i2tap"
|
||||
style="color: Blue; font-size: 16px;"
|
||||
>
|
||||
295
|
||||
295.43
|
||||
</span>
|
||||
<span
|
||||
class="ant-typography value-graph-unit css-dev-only-do-not-override-2i2tap"
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
import { getLegend } from 'lib/dashboard/getQueryResults';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getMockQuery, getMockQueryData } from './testUtils';
|
||||
|
||||
const mockQueryData = getMockQueryData();
|
||||
const mockQuery = getMockQuery();
|
||||
const MOCK_LABEL_NAME = 'mock-label-name';
|
||||
|
||||
describe('getLegend', () => {
|
||||
it('should directly return the label name for clickhouse query', () => {
|
||||
const legendsData = getLegend(
|
||||
mockQueryData,
|
||||
getMockQuery({
|
||||
queryType: EQueryType.CLICKHOUSE,
|
||||
}),
|
||||
MOCK_LABEL_NAME,
|
||||
);
|
||||
expect(legendsData).toBeDefined();
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it('should directly return the label name for promql query', () => {
|
||||
const legendsData = getLegend(
|
||||
mockQueryData,
|
||||
getMockQuery({
|
||||
queryType: EQueryType.PROM,
|
||||
}),
|
||||
MOCK_LABEL_NAME,
|
||||
);
|
||||
expect(legendsData).toBeDefined();
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it('should return alias when single builder query with single aggregation and alias (logs)', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: "sum(bytes) as 'alias_sum'" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe('alias_sum');
|
||||
});
|
||||
|
||||
it('should return legend when single builder query with no alias but legend set (builder)', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
legend: 'custom-legend',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe('custom-legend');
|
||||
});
|
||||
|
||||
it('should return label when grouped by with single aggregation (builder)', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it("should return '<alias>-<label>' when grouped by with multiple aggregations (builder)", () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'sum_b'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should fallback to label or query name when no alias/expression', () => {
|
||||
const legendsData = getLegend(mockQueryData, mockQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(MOCK_LABEL_NAME);
|
||||
});
|
||||
|
||||
it('should return alias when single query with multiple aggregations and no group by', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'total'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe('total');
|
||||
});
|
||||
|
||||
it("should return '<alias>-<label>' when multiple queries with group by", () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'sum_b'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: 'B',
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`sum_b-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should return label according to the index of the query', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [
|
||||
{ expression: "sum(bytes) as 'sum_a'" },
|
||||
{ expression: 'count()' },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'serviceName', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
},
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: 'B',
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(
|
||||
{
|
||||
...mockQueryData,
|
||||
metaData: {
|
||||
...mockQueryData.metaData,
|
||||
index: 1,
|
||||
},
|
||||
} as QueryData,
|
||||
payloadQuery,
|
||||
MOCK_LABEL_NAME,
|
||||
);
|
||||
expect(legendsData).toBe(`count()-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should handle trace operator with multiple queries and group by', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: 'A',
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregations: [{ expression: 'count()' }],
|
||||
},
|
||||
],
|
||||
queryTraceOperator: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregations: [
|
||||
{ expression: "count() as 'total_count' avg(duration_nano)" },
|
||||
],
|
||||
groupBy: [
|
||||
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
expression: 'A',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`total_count-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
|
||||
it('should handle single trace operator query with group by', () => {
|
||||
const payloadQuery = getMockQuery({
|
||||
...mockQuery,
|
||||
builder: {
|
||||
...mockQuery.builder,
|
||||
queryData: [],
|
||||
queryTraceOperator: [
|
||||
{
|
||||
...mockQuery.builder.queryData[0],
|
||||
queryName: mockQueryData.queryName,
|
||||
dataSource: DataSource.TRACES,
|
||||
aggregations: [{ expression: "count() as 'total' avg(duration_nano)" }],
|
||||
groupBy: [
|
||||
{ key: 'service.name', dataType: DataTypes.String, type: 'resource' },
|
||||
],
|
||||
expression: 'A && B',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const legendsData = getLegend(mockQueryData, payloadQuery, MOCK_LABEL_NAME);
|
||||
expect(legendsData).toBe(`total-${MOCK_LABEL_NAME}`);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,118 @@
|
||||
import { getUplotHistogramChartOptions } from 'lib/uPlotLib/getUplotHistogramChartOptions';
|
||||
import uPlot from 'uplot';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('lib/uPlotLib/plugins/tooltipPlugin', () => jest.fn(() => ({})));
|
||||
jest.mock('lib/uPlotLib/plugins/onClickPlugin', () => jest.fn(() => ({})));
|
||||
|
||||
const mockApiResponse = {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { __name__: 'test_metric' },
|
||||
queryName: 'test_query',
|
||||
values: [[1640995200, '10'] as [number, string]],
|
||||
},
|
||||
],
|
||||
resultType: 'time_series',
|
||||
newResult: {
|
||||
data: {
|
||||
result: [],
|
||||
resultType: 'time_series',
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const mockDimensions = { width: 800, height: 400 };
|
||||
const mockHistogramData: uPlot.AlignedData = [[1640995200], [10]];
|
||||
const TEST_HISTOGRAM_ID = 'test-histogram';
|
||||
|
||||
describe('Histogram Chart Options Legend Scroll Position', () => {
|
||||
let originalRequestAnimationFrame: typeof global.requestAnimationFrame;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
originalRequestAnimationFrame = global.requestAnimationFrame;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
global.requestAnimationFrame = originalRequestAnimationFrame;
|
||||
});
|
||||
|
||||
it('should set up scroll position tracking in histogram chart ready hook', () => {
|
||||
const mockSetScrollPosition = jest.fn();
|
||||
const options = getUplotHistogramChartOptions({
|
||||
id: TEST_HISTOGRAM_ID,
|
||||
dimensions: mockDimensions,
|
||||
isDarkMode: false,
|
||||
apiResponse: mockApiResponse,
|
||||
histogramData: mockHistogramData,
|
||||
legendScrollPosition: 0,
|
||||
setLegendScrollPosition: mockSetScrollPosition,
|
||||
});
|
||||
|
||||
// Create mock chart with legend element
|
||||
const mockChart = ({
|
||||
root: document.createElement('div'),
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'u-legend';
|
||||
mockChart.root.appendChild(legend);
|
||||
|
||||
const addEventListenerSpy = jest.spyOn(legend, 'addEventListener');
|
||||
|
||||
// Execute ready hook
|
||||
if (options.hooks?.ready) {
|
||||
options.hooks.ready.forEach((hook) => hook?.(mockChart));
|
||||
}
|
||||
|
||||
// Verify that scroll event listener was added and cleanup function was stored
|
||||
expect(addEventListenerSpy).toHaveBeenCalledWith(
|
||||
'scroll',
|
||||
expect.any(Function),
|
||||
);
|
||||
expect(
|
||||
(mockChart as uPlot & { _legendScrollCleanup?: () => void })
|
||||
._legendScrollCleanup,
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
it('should restore histogram chart scroll position when provided', () => {
|
||||
const mockScrollPosition = 50;
|
||||
const mockSetScrollPosition = jest.fn();
|
||||
const options = getUplotHistogramChartOptions({
|
||||
id: TEST_HISTOGRAM_ID,
|
||||
dimensions: mockDimensions,
|
||||
isDarkMode: false,
|
||||
apiResponse: mockApiResponse,
|
||||
histogramData: mockHistogramData,
|
||||
legendScrollPosition: mockScrollPosition,
|
||||
setLegendScrollPosition: mockSetScrollPosition,
|
||||
});
|
||||
|
||||
// Create mock chart with legend element
|
||||
const mockChart = ({
|
||||
root: document.createElement('div'),
|
||||
} as unknown) as uPlot;
|
||||
|
||||
const legend = document.createElement('div');
|
||||
legend.className = 'u-legend';
|
||||
legend.scrollTop = 0;
|
||||
mockChart.root.appendChild(legend);
|
||||
|
||||
// Mock requestAnimationFrame
|
||||
const mockRequestAnimationFrame = jest.fn((callback) => callback());
|
||||
global.requestAnimationFrame = mockRequestAnimationFrame;
|
||||
|
||||
// Execute ready hook
|
||||
if (options.hooks?.ready) {
|
||||
options.hooks.ready.forEach((hook) => hook?.(mockChart));
|
||||
}
|
||||
|
||||
// Verify that requestAnimationFrame was called and scroll position was restored
|
||||
expect(mockRequestAnimationFrame).toHaveBeenCalledWith(expect.any(Function));
|
||||
expect(legend.scrollTop).toBe(mockScrollPosition);
|
||||
});
|
||||
});
|
||||
@@ -121,6 +121,7 @@ export const tablePanelWidgetQuery = {
|
||||
stackedBarChart: false,
|
||||
bucketWidth: 0,
|
||||
mergeAllActiveQueries: false,
|
||||
decimalPrecision: 2,
|
||||
};
|
||||
|
||||
export const tablePanelQueryResponse = {
|
||||
|
||||
36
frontend/src/container/PanelWrapper/__tests__/testUtils.ts
Normal file
36
frontend/src/container/PanelWrapper/__tests__/testUtils.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
import { initialQueryState } from 'constants/queryBuilder';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { QueryData } from 'types/api/widgets/getQuery';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
export function getMockQueryData(): QueryData {
|
||||
return {
|
||||
lowerBoundSeries: [],
|
||||
upperBoundSeries: [],
|
||||
predictedSeries: [],
|
||||
anomalyScores: [],
|
||||
metric: {},
|
||||
queryName: 'test-query-name',
|
||||
legend: 'test-legend',
|
||||
values: [],
|
||||
quantity: [],
|
||||
unit: 'test-unit',
|
||||
table: {
|
||||
rows: [],
|
||||
columns: [],
|
||||
},
|
||||
metaData: {
|
||||
alias: 'test-alias',
|
||||
index: 0,
|
||||
queryName: 'test-query-name',
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function getMockQuery(overrides?: Partial<Query>): Query {
|
||||
return {
|
||||
...initialQueryState,
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
@@ -35,6 +35,8 @@ export type QueryBuilderProps = {
|
||||
showTraceOperator?: boolean;
|
||||
version: string;
|
||||
onChangeTraceView?: (view: TraceView) => void;
|
||||
onSignalSourceChange?: (value: string) => void;
|
||||
signalSourceChangeEnabled?: boolean;
|
||||
};
|
||||
|
||||
export enum TraceView {
|
||||
|
||||
@@ -52,6 +52,10 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
(query.aggregations?.[0] as MetricAggregation)?.metricName || '',
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setSearchText('');
|
||||
}, [signalSource]);
|
||||
|
||||
const debouncedSearchText = useMemo(() => {
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars
|
||||
const [_, value] = getAutocompleteValueAndType(searchText);
|
||||
@@ -67,6 +71,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
index,
|
||||
signalSource,
|
||||
],
|
||||
async () =>
|
||||
getAggregateAttribute({
|
||||
@@ -100,6 +105,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
setOptionsData(options);
|
||||
setAttributeKeys?.(data?.payload?.attributeKeys || []);
|
||||
},
|
||||
keepPreviousData: false,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -164,8 +170,11 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
index,
|
||||
signalSource,
|
||||
])?.payload?.attributeKeys || [];
|
||||
|
||||
setAttributeKeys?.(attributeKeys);
|
||||
|
||||
return attributeKeys;
|
||||
}, [
|
||||
debouncedValue,
|
||||
@@ -173,6 +182,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
query.dataSource,
|
||||
queryClient,
|
||||
index,
|
||||
signalSource,
|
||||
setAttributeKeys,
|
||||
]);
|
||||
|
||||
|
||||
@@ -90,8 +90,9 @@ export function QueryTable({
|
||||
column: any,
|
||||
tableColumns: any,
|
||||
): void => {
|
||||
e.stopPropagation();
|
||||
if (isQueryTypeBuilder && enableDrillDown) {
|
||||
e.stopPropagation();
|
||||
|
||||
onClick({ x: e.clientX, y: e.clientY }, { record, column, tableColumns });
|
||||
}
|
||||
},
|
||||
|
||||
@@ -271,7 +271,7 @@ export const defaultMoreMenuItems: SidebarItem[] = [
|
||||
icon: <ChartArea size={16} />,
|
||||
isNew: false,
|
||||
isEnabled: true,
|
||||
isBeta: true,
|
||||
isBeta: false,
|
||||
itemKey: 'meter-explorer',
|
||||
},
|
||||
{
|
||||
|
||||
@@ -55,6 +55,353 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.span-name-wrapper {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.loading-spinner-container {
|
||||
padding: 4px 8px;
|
||||
line-height: 18px; /* 128.571% */
|
||||
letter-spacing: -0.07px;
|
||||
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.span-percentile-value-container {
|
||||
.span-percentile-value {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
|
||||
border-radius: 0 50px 50px 0;
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 14px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
min-width: 48px;
|
||||
padding-left: 8px;
|
||||
padding-right: 8px;
|
||||
|
||||
border-left: 1px solid var(--bg-slate-400);
|
||||
|
||||
cursor: pointer;
|
||||
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
word-break: normal;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
&.span-percentile-value-container-open {
|
||||
.span-percentile-value {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
background: var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentiles-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
position: relative;
|
||||
|
||||
fill: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.32) 0%,
|
||||
rgba(18, 19, 23, 0.36) 98.68%
|
||||
);
|
||||
|
||||
stroke-width: 1px;
|
||||
stroke: var(--bg-slate-500, #161922);
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--bg-slate-500);
|
||||
border-radius: 4px;
|
||||
|
||||
.span-percentiles-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 12px;
|
||||
border-bottom: 1px solid var(--bg-slate-500);
|
||||
|
||||
.span-percentiles-header-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
|
||||
.span-percentile-content-title {
|
||||
.span-percentile-value {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
}
|
||||
|
||||
.span-percentile-value-loader {
|
||||
display: inline-flex;
|
||||
align-items: flex-end;
|
||||
justify-content: flex-end;
|
||||
margin-right: 4px;
|
||||
margin-left: 4px;
|
||||
line-height: 18px;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-timerange {
|
||||
width: 100%;
|
||||
|
||||
.span-percentile-timerange-select {
|
||||
width: 100%;
|
||||
margin-top: 8px;
|
||||
margin-bottom: 16px;
|
||||
|
||||
.ant-select-selector {
|
||||
border-radius: 50px;
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
background: var(--bg-slate-500, #161922);
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: 0.28px;
|
||||
|
||||
height: 32px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table {
|
||||
.span-percentile-values-table-header-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
|
||||
.span-percentile-values-table-header {
|
||||
color: var(--text-vanilla-400);
|
||||
text-align: right;
|
||||
font-family: Inter;
|
||||
font-size: 11px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 181.818% */
|
||||
text-transform: uppercase;
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-rows {
|
||||
margin-top: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.span-percentile-values-table-data-rows-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
.ant-skeleton-title {
|
||||
width: 100% !important;
|
||||
margin-top: 0px !important;
|
||||
}
|
||||
|
||||
.ant-skeleton-paragraph {
|
||||
margin-top: 8px;
|
||||
|
||||
& > li + li {
|
||||
margin-top: 10px;
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0px 4px;
|
||||
|
||||
.span-percentile-values-table-data-row-key {
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-vanilla-100);
|
||||
text-align: right;
|
||||
font-variant-numeric: lining-nums tabular-nums slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on, 'ss02' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
flex: 1;
|
||||
height: 0; /* line only */
|
||||
margin: 0 8px;
|
||||
border-top: 1px dashed var(--bg-slate-300);
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#1d212d 0,
|
||||
#1d212d 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.current-span-percentile-row {
|
||||
border-radius: 2px;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
|
||||
.span-percentile-values-table-data-row-key {
|
||||
color: var(--text-robin-300);
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
flex: 1;
|
||||
height: 0; /* line only */
|
||||
margin: 0 8px;
|
||||
border-top: 1px dashed #abbdff;
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#abbdff 0,
|
||||
#abbdff 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--text-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-container {
|
||||
overflow: hidden;
|
||||
width: calc(100% + 16px);
|
||||
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
left: -8px;
|
||||
z-index: 1000;
|
||||
|
||||
.resource-attributes-select-container-header {
|
||||
.resource-attributes-select-container-input {
|
||||
border-radius: 0px;
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 36px;
|
||||
|
||||
border-bottom: 1px solid var(--bg-slate-400) !important;
|
||||
}
|
||||
}
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-slate-400, #1d212d);
|
||||
background: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 1) 0%,
|
||||
rgba(18, 19, 23, 1) 98.68%
|
||||
);
|
||||
box-shadow: 4px 10px 16px 2px rgba(0, 0, 0, 0.2);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.ant-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.resource-attributes-items {
|
||||
height: 200px;
|
||||
overflow-y: auto;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 0.3rem;
|
||||
height: 0.3rem;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 12px 8px 12px;
|
||||
|
||||
.resource-attributes-select-item-checkbox {
|
||||
.ant-checkbox-disabled {
|
||||
background-color: var(--bg-robin-500);
|
||||
color: var(--bg-vanilla-100);
|
||||
}
|
||||
|
||||
.resource-attributes-select-item-value {
|
||||
color: var(--bg-vanilla-100);
|
||||
|
||||
font-family: Inter;
|
||||
font-size: 13px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 142.857% */
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-key {
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: Inter;
|
||||
@@ -68,7 +415,6 @@
|
||||
|
||||
.value-wrapper {
|
||||
display: flex;
|
||||
padding: 2px 8px;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
max-width: 100%;
|
||||
@@ -77,6 +423,7 @@
|
||||
background: var(--bg-slate-500);
|
||||
|
||||
.attribute-value {
|
||||
padding: 2px 8px;
|
||||
color: var(--bg-vanilla-400);
|
||||
font-family: 'Inter';
|
||||
font-size: 14px;
|
||||
@@ -200,6 +547,44 @@
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-tooltip {
|
||||
.ant-tooltip-content {
|
||||
width: 300px;
|
||||
max-width: 300px;
|
||||
}
|
||||
|
||||
.span-percentile-tooltip-text {
|
||||
color: var(--text-vanilla-400);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions ordinal
|
||||
slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 400;
|
||||
line-height: 20px; /* 166.667% */
|
||||
letter-spacing: -0.06px;
|
||||
|
||||
.span-percentile-tooltip-text-percentile {
|
||||
color: var(--text-sakura-500);
|
||||
font-variant-numeric: lining-nums tabular-nums stacked-fractions slashed-zero;
|
||||
font-feature-settings: 'dlig' on, 'salt' on;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.span-percentile-tooltip-text-link {
|
||||
color: var(--text-vanilla-400);
|
||||
text-align: right;
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
font-style: normal;
|
||||
font-weight: 500;
|
||||
line-height: 20px; /* 166.667% */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-details-drawer-docked {
|
||||
width: 48px;
|
||||
flex: 0 48px !important;
|
||||
@@ -208,6 +593,7 @@
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
.resizable-handle {
|
||||
box-sizing: border-box;
|
||||
border: 2px solid transparent;
|
||||
@@ -234,6 +620,173 @@
|
||||
|
||||
.description {
|
||||
.item {
|
||||
.span-name-wrapper {
|
||||
.span-percentile-value-container {
|
||||
&.span-percentile-value-container-open {
|
||||
.span-percentile-value {
|
||||
border: 1px solid var(--bg-vanilla-200);
|
||||
background: var(--bg-vanilla-200);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-value {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
border-left: 1px solid var(--bg-slate-300);
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentiles-container {
|
||||
fill: linear-gradient(
|
||||
139deg,
|
||||
rgba(18, 19, 23, 0.32) 0%,
|
||||
rgba(18, 19, 23, 0.36) 98.68%
|
||||
);
|
||||
|
||||
stroke-width: 1px;
|
||||
stroke: var(--bg-slate-500);
|
||||
filter: drop-shadow(2px 4px 16px rgba(0, 0, 0, 0.2));
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
border-radius: 4px;
|
||||
|
||||
.span-percentiles-header {
|
||||
border-bottom: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
.span-percentile-content {
|
||||
.span-percentile-content-title {
|
||||
.span-percentile-value {
|
||||
color: var(--text-sakura-400, #f56c87);
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-timerange {
|
||||
.span-percentile-timerange-select {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--text-slate-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table {
|
||||
.span-percentile-values-table-header-row {
|
||||
.span-percentile-values-table-header {
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row {
|
||||
.span-percentile-values-table-data-row-key {
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
flex: 1;
|
||||
height: 0; /* line only */
|
||||
margin: 0 8px;
|
||||
border-top: 1px dashed var(--bg-slate-300);
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
var(--bg-slate-300) 0,
|
||||
var(--bg-slate-300) 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
}
|
||||
|
||||
.current-span-percentile-row {
|
||||
border-radius: 2px;
|
||||
background: rgba(78, 116, 248, 0.2);
|
||||
|
||||
.span-percentile-values-table-data-row-key {
|
||||
color: var(--text-robin-300, #95acfb);
|
||||
}
|
||||
|
||||
.dashed-line {
|
||||
border-top: 1px dashed #abbdff;
|
||||
|
||||
/* Use border image to control dash length & spacing */
|
||||
border-top-width: 1px;
|
||||
border-top-style: solid; /* temporary solid for image */
|
||||
border-image: repeating-linear-gradient(
|
||||
to right,
|
||||
#abbdff 0,
|
||||
#abbdff 10px,
|
||||
transparent 10px,
|
||||
transparent 20px
|
||||
)
|
||||
1 stretch;
|
||||
}
|
||||
|
||||
.span-percentile-values-table-data-row-value {
|
||||
color: var(--text-robin-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-container {
|
||||
.resource-attributes-select-container-header {
|
||||
.resource-attributes-select-container-input {
|
||||
border: none !important;
|
||||
box-shadow: none !important;
|
||||
height: 36px;
|
||||
|
||||
border-bottom: 1px solid var(--bg-vanilla-400) !important;
|
||||
background: var(--bg-vanilla-300);
|
||||
color: var(--text-ink-400);
|
||||
}
|
||||
}
|
||||
|
||||
border-radius: 4px;
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
background: var(--bg-vanilla-300);
|
||||
box-shadow: 0 3px 6px -4px rgba(0, 0, 0, 0.12),
|
||||
0 6px 16px 0 rgba(0, 0, 0, 0.08), 0 9px 28px 8px rgba(0, 0, 0, 0.05);
|
||||
backdrop-filter: blur(20px);
|
||||
|
||||
.resource-attributes-items {
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: var(--bg-slate-300);
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--bg-slate-200);
|
||||
}
|
||||
}
|
||||
|
||||
.resource-attributes-select-item {
|
||||
.resource-attributes-select-item-checkbox {
|
||||
.ant-checkbox-disabled {
|
||||
background-color: var(--bg-robin-500);
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
|
||||
.resource-attributes-select-item-value {
|
||||
color: var(--text-ink-100);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.attribute-key {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
@@ -1,14 +1,54 @@
|
||||
import './SpanDetailsDrawer.styles.scss';
|
||||
|
||||
import { Button, Tabs, TabsProps, Tooltip, Typography } from 'antd';
|
||||
import {
|
||||
Button,
|
||||
Checkbox,
|
||||
Input,
|
||||
Select,
|
||||
Skeleton,
|
||||
Tabs,
|
||||
TabsProps,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import getSpanPercentiles from 'api/trace/getSpanPercentiles';
|
||||
import getUserPreference from 'api/v1/user/preferences/name/get';
|
||||
import updateUserPreference from 'api/v1/user/preferences/name/update';
|
||||
import LogsIcon from 'assets/AlertHistory/LogsIcon';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { REACT_QUERY_KEY } from 'constants/reactQueryKeys';
|
||||
import { themeColors } from 'constants/theme';
|
||||
import { USER_PREFERENCES } from 'constants/userPreferences';
|
||||
import dayjs from 'dayjs';
|
||||
import useClickOutside from 'hooks/useClickOutside';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { Anvil, Bookmark, Link2, PanelRight, Search } from 'lucide-react';
|
||||
import { Dispatch, SetStateAction, useCallback, useState } from 'react';
|
||||
import {
|
||||
Anvil,
|
||||
BarChart2,
|
||||
Bookmark,
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Link2,
|
||||
Loader2,
|
||||
PanelRight,
|
||||
PlusIcon,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { AnimatePresence, motion } from 'motion/react';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useMutation, useQuery } from 'react-query';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { formatEpochTimestamp } from 'utils/timeUtils';
|
||||
|
||||
@@ -17,6 +57,7 @@ import { RelatedSignalsViews } from './constants';
|
||||
import Events from './Events/Events';
|
||||
import LinkedSpans from './LinkedSpans/LinkedSpans';
|
||||
import SpanRelatedSignals from './SpanRelatedSignals/SpanRelatedSignals';
|
||||
import { hasInfraMetadata } from './utils';
|
||||
|
||||
interface ISpanDetailsDrawerProps {
|
||||
isSpanDetailsDocked: boolean;
|
||||
@@ -26,6 +67,45 @@ interface ISpanDetailsDrawerProps {
|
||||
traceEndTime: number;
|
||||
}
|
||||
|
||||
const timerangeOptions = [
|
||||
{
|
||||
label: '1 hour',
|
||||
value: 1,
|
||||
},
|
||||
{
|
||||
label: '2 hours',
|
||||
value: 2,
|
||||
},
|
||||
{
|
||||
label: '3 hours',
|
||||
value: 3,
|
||||
},
|
||||
{
|
||||
label: '6 hours',
|
||||
value: 6,
|
||||
},
|
||||
{
|
||||
label: '12 hours',
|
||||
value: 12,
|
||||
},
|
||||
{
|
||||
label: '24 hours',
|
||||
value: 24,
|
||||
},
|
||||
];
|
||||
|
||||
interface IResourceAttribute {
|
||||
key: string;
|
||||
value: string;
|
||||
isSelected: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_RESOURCE_ATTRIBUTES = {
|
||||
serviceName: 'service.name',
|
||||
name: 'name',
|
||||
};
|
||||
|
||||
// eslint-disable-next-line sonarjs/cognitive-complexity
|
||||
function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
const {
|
||||
isSpanDetailsDocked,
|
||||
@@ -39,12 +119,60 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
const [shouldAutoFocusSearch, setShouldAutoFocusSearch] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [isSpanPercentilesOpen, setIsSpanPercentilesOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [isRelatedSignalsOpen, setIsRelatedSignalsOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [activeDrawerView, setActiveDrawerView] = useState<RelatedSignalsViews>(
|
||||
RelatedSignalsViews.LOGS,
|
||||
);
|
||||
|
||||
const [selectedTimeRange, setSelectedTimeRange] = useState<number>(1);
|
||||
const [
|
||||
resourceAttributesSearchQuery,
|
||||
setResourceAttributesSearchQuery,
|
||||
] = useState<string>('');
|
||||
|
||||
const [spanPercentileData, setSpanPercentileData] = useState<{
|
||||
percentile: number;
|
||||
description: string;
|
||||
percentiles: Record<string, number>;
|
||||
} | null>(null);
|
||||
|
||||
const [
|
||||
showResourceAttributesSelector,
|
||||
setShowResourceAttributesSelector,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const [selectedResourceAttributes, setSelectedResourceAttributes] = useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
const [spanResourceAttributes, updateSpanResourceAttributes] = useState<
|
||||
IResourceAttribute[]
|
||||
>([] as IResourceAttribute[]);
|
||||
|
||||
const [initialWaitCompleted, setInitialWaitCompleted] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
|
||||
const [
|
||||
shouldFetchSpanPercentilesData,
|
||||
setShouldFetchSpanPercentilesData,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const [
|
||||
shouldUpdateUserPreference,
|
||||
setShouldUpdateUserPreference,
|
||||
] = useState<boolean>(false);
|
||||
|
||||
const handleTimeRangeChange = useCallback((value: number): void => {
|
||||
setShouldFetchSpanPercentilesData(true);
|
||||
setSelectedTimeRange(value);
|
||||
}, []);
|
||||
|
||||
const color = generateColor(
|
||||
selectedSpan?.serviceName || '',
|
||||
themeColors.traceDetailColors,
|
||||
@@ -60,6 +188,35 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
setIsRelatedSignalsOpen(false);
|
||||
}, []);
|
||||
|
||||
const relatedSignalsOptions = useMemo(() => {
|
||||
const baseOptions = [
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
];
|
||||
|
||||
// Only show Infra option if span has infrastructure metadata
|
||||
if (hasInfraMetadata(selectedSpan)) {
|
||||
baseOptions.push({
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart2 size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.INFRA,
|
||||
});
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [selectedSpan]);
|
||||
|
||||
function getItems(span: Span, startTime: number): TabsProps['items'] {
|
||||
return [
|
||||
{
|
||||
@@ -123,6 +280,277 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
];
|
||||
}
|
||||
|
||||
const resourceAttributesSelectorRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
useClickOutside({
|
||||
ref: resourceAttributesSelectorRef,
|
||||
onClickOutside: () => {
|
||||
if (resourceAttributesSelectorRef.current) {
|
||||
setShowResourceAttributesSelector(false);
|
||||
}
|
||||
},
|
||||
eventType: 'mousedown',
|
||||
});
|
||||
|
||||
const spanPercentileTooltipText = useMemo(
|
||||
() => (
|
||||
<div className="span-percentile-tooltip-text">
|
||||
<Typography.Text>
|
||||
This span duration is{' '}
|
||||
<span className="span-percentile-tooltip-text-percentile">
|
||||
p{Math.floor(spanPercentileData?.percentile || 0)}
|
||||
</span>{' '}
|
||||
out of the distribution for this resource evaluated for {selectedTimeRange}{' '}
|
||||
hour(s) since the span start time.
|
||||
</Typography.Text>
|
||||
<br />
|
||||
<br />
|
||||
<Typography.Text className="span-percentile-tooltip-text-link">
|
||||
Click to learn more
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
[spanPercentileData?.percentile, selectedTimeRange],
|
||||
);
|
||||
|
||||
const endTime = useMemo(
|
||||
() => Math.floor(Number(selectedSpan?.timestamp) / 1000) * 1000,
|
||||
[selectedSpan?.timestamp],
|
||||
);
|
||||
|
||||
const startTime = useMemo(
|
||||
() =>
|
||||
dayjs(selectedSpan?.timestamp)
|
||||
.subtract(Number(selectedTimeRange), 'hour')
|
||||
.unix() * 1000,
|
||||
[selectedSpan?.timestamp, selectedTimeRange],
|
||||
);
|
||||
|
||||
const { mutate: updateUserPreferenceMutation } = useMutation(
|
||||
updateUserPreference,
|
||||
);
|
||||
|
||||
// TODO: Span percentile should be eventually moved to context and not fetched on every span change
|
||||
const {
|
||||
data: userSelectedResourceAttributes,
|
||||
isError: isErrorUserSelectedResourceAttributes,
|
||||
} = useQuery({
|
||||
queryFn: () =>
|
||||
getUserPreference({
|
||||
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
}),
|
||||
queryKey: [
|
||||
'getUserPreferenceByPreferenceName',
|
||||
USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
selectedSpan?.spanId,
|
||||
],
|
||||
enabled: selectedSpan !== null && selectedSpan?.tagMap !== undefined,
|
||||
});
|
||||
|
||||
const {
|
||||
isLoading: isLoadingSpanPercentilesData,
|
||||
isFetching: isFetchingSpanPercentilesData,
|
||||
data,
|
||||
refetch: refetchSpanPercentilesData,
|
||||
isError: isErrorSpanPercentilesData,
|
||||
} = useQuery({
|
||||
queryFn: () =>
|
||||
getSpanPercentiles({
|
||||
start: startTime || 0,
|
||||
end: endTime || 0,
|
||||
spanDuration: selectedSpan?.durationNano || 0,
|
||||
serviceName: selectedSpan?.serviceName || '',
|
||||
name: selectedSpan?.name || '',
|
||||
resourceAttributes: selectedResourceAttributes,
|
||||
}),
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_SPAN_PERCENTILES,
|
||||
selectedSpan?.spanId,
|
||||
startTime,
|
||||
endTime,
|
||||
],
|
||||
enabled:
|
||||
selectedSpan !== null &&
|
||||
shouldFetchSpanPercentilesData &&
|
||||
!showResourceAttributesSelector &&
|
||||
initialWaitCompleted,
|
||||
onSuccess: (response) => {
|
||||
if (response.httpStatusCode !== 200) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldUpdateUserPreference) {
|
||||
updateUserPreferenceMutation({
|
||||
name: USER_PREFERENCES.SPAN_PERCENTILE_RESOURCE_ATTRIBUTES,
|
||||
value: [...Object.keys(selectedResourceAttributes)],
|
||||
});
|
||||
|
||||
setShouldUpdateUserPreference(false);
|
||||
}
|
||||
},
|
||||
keepPreviousData: false,
|
||||
cacheTime: 0, // no cache
|
||||
});
|
||||
|
||||
// Prod Req - Wait for 2 seconds before fetching span percentile data on initial load
|
||||
useEffect(() => {
|
||||
setSpanPercentileData(null);
|
||||
setIsSpanPercentilesOpen(false);
|
||||
setInitialWaitCompleted(false);
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
setInitialWaitCompleted(true);
|
||||
}, 2000); // 2-second delay
|
||||
|
||||
return (): void => {
|
||||
// clean the old state around span percentile data
|
||||
clearTimeout(timer); // Cleanup on re-run or unmount
|
||||
};
|
||||
}, [selectedSpan?.spanId]);
|
||||
|
||||
useEffect(() => {
|
||||
if (data?.httpStatusCode !== 200) {
|
||||
setSpanPercentileData(null);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (data) {
|
||||
const percentileData = {
|
||||
percentile: data?.data?.position?.percentile || 0,
|
||||
description: data?.data?.position?.description || '',
|
||||
percentiles: data?.data?.percentiles || {},
|
||||
};
|
||||
|
||||
setSpanPercentileData(percentileData);
|
||||
}
|
||||
}, [data]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userSelectedResourceAttributes) {
|
||||
const userSelectedResourceAttributesList = (userSelectedResourceAttributes
|
||||
?.data?.value as string[]).map((attribute: string) => attribute);
|
||||
|
||||
let selectedResourceAttributesMap: Record<string, string> = {};
|
||||
|
||||
userSelectedResourceAttributesList.forEach((attribute: string) => {
|
||||
selectedResourceAttributesMap[attribute] =
|
||||
selectedSpan?.tagMap?.[attribute] || '';
|
||||
});
|
||||
|
||||
// filter out the attributes that are not in the selectedSpan?.tagMap
|
||||
selectedResourceAttributesMap = Object.fromEntries(
|
||||
Object.entries(selectedResourceAttributesMap).filter(
|
||||
([key]) => selectedSpan?.tagMap?.[key] !== undefined,
|
||||
),
|
||||
);
|
||||
|
||||
const resourceAttributes = Object.entries(selectedSpan?.tagMap || {}).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
isSelected:
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.name ||
|
||||
(key in selectedResourceAttributesMap &&
|
||||
selectedResourceAttributesMap[key] !== '' &&
|
||||
selectedResourceAttributesMap[key] !== undefined),
|
||||
}),
|
||||
);
|
||||
|
||||
// selected resources should be at the top of the list
|
||||
const selectedResourceAttributes = resourceAttributes.filter(
|
||||
(resourceAttribute) => resourceAttribute.isSelected,
|
||||
);
|
||||
|
||||
const unselectedResourceAttributes = resourceAttributes.filter(
|
||||
(resourceAttribute) => !resourceAttribute.isSelected,
|
||||
);
|
||||
|
||||
const sortedResourceAttributes = [
|
||||
...selectedResourceAttributes,
|
||||
...unselectedResourceAttributes,
|
||||
];
|
||||
|
||||
updateSpanResourceAttributes(sortedResourceAttributes);
|
||||
|
||||
setSelectedResourceAttributes(
|
||||
selectedResourceAttributesMap as Record<string, string>,
|
||||
);
|
||||
|
||||
setShouldFetchSpanPercentilesData(true);
|
||||
}
|
||||
|
||||
if (isErrorUserSelectedResourceAttributes) {
|
||||
const resourceAttributes = Object.entries(selectedSpan?.tagMap || {}).map(
|
||||
([key, value]) => ({
|
||||
key,
|
||||
value,
|
||||
isSelected:
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.serviceName ||
|
||||
key === DEFAULT_RESOURCE_ATTRIBUTES.name,
|
||||
}),
|
||||
);
|
||||
|
||||
updateSpanResourceAttributes(resourceAttributes);
|
||||
|
||||
setShouldFetchSpanPercentilesData(true);
|
||||
}
|
||||
}, [
|
||||
userSelectedResourceAttributes,
|
||||
isErrorUserSelectedResourceAttributes,
|
||||
selectedSpan?.tagMap,
|
||||
]);
|
||||
|
||||
const handleResourceAttributeChange = useCallback(
|
||||
(key: string, value: string, isSelected: boolean): void => {
|
||||
updateSpanResourceAttributes((prev) =>
|
||||
prev.map((resourceAttribute) =>
|
||||
resourceAttribute.key === key
|
||||
? { ...resourceAttribute, isSelected }
|
||||
: resourceAttribute,
|
||||
),
|
||||
);
|
||||
|
||||
const newSelectedResourceAttributes = { ...selectedResourceAttributes };
|
||||
|
||||
if (isSelected) {
|
||||
newSelectedResourceAttributes[key] = value;
|
||||
} else {
|
||||
delete newSelectedResourceAttributes[key];
|
||||
}
|
||||
|
||||
setSelectedResourceAttributes(newSelectedResourceAttributes);
|
||||
|
||||
setShouldFetchSpanPercentilesData(true);
|
||||
|
||||
setShouldUpdateUserPreference(true);
|
||||
},
|
||||
[selectedResourceAttributes],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
shouldFetchSpanPercentilesData &&
|
||||
!showResourceAttributesSelector &&
|
||||
initialWaitCompleted
|
||||
) {
|
||||
refetchSpanPercentilesData();
|
||||
|
||||
setShouldFetchSpanPercentilesData(false);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [
|
||||
shouldFetchSpanPercentilesData,
|
||||
showResourceAttributesSelector,
|
||||
initialWaitCompleted,
|
||||
]);
|
||||
|
||||
const loadingSpanPercentilesData =
|
||||
isLoadingSpanPercentilesData || isFetchingSpanPercentilesData;
|
||||
|
||||
const spanPercentileValue = Math.floor(spanPercentileData?.percentile || 0);
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="header">
|
||||
@@ -143,13 +571,244 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
<section className="description">
|
||||
<div className="item">
|
||||
<Typography.Text className="attribute-key">span name</Typography.Text>
|
||||
<Tooltip title={selectedSpan.name}>
|
||||
<div className="value-wrapper">
|
||||
|
||||
<div className="value-wrapper span-name-wrapper">
|
||||
<Tooltip title={selectedSpan.name}>
|
||||
<Typography.Text className="attribute-value" ellipsis>
|
||||
{selectedSpan.name}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
|
||||
{loadingSpanPercentilesData && (
|
||||
<div className="loading-spinner-container">
|
||||
<Loader2 size={16} className="animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loadingSpanPercentilesData && spanPercentileData && (
|
||||
<Tooltip
|
||||
title={isSpanPercentilesOpen ? '' : spanPercentileTooltipText}
|
||||
placement="bottomRight"
|
||||
overlayClassName="span-percentile-tooltip"
|
||||
arrow={false}
|
||||
>
|
||||
<div
|
||||
className={`span-percentile-value-container ${
|
||||
isSpanPercentilesOpen
|
||||
? 'span-percentile-value-container-open'
|
||||
: 'span-percentile-value-container-closed'
|
||||
}`}
|
||||
>
|
||||
<Typography.Text
|
||||
className="span-percentile-value"
|
||||
onClick={(): void => setIsSpanPercentilesOpen((prev) => !prev)}
|
||||
disabled={loadingSpanPercentilesData}
|
||||
>
|
||||
<span className="span-percentile-value-text">
|
||||
p{spanPercentileValue}
|
||||
</span>
|
||||
|
||||
{!isSpanPercentilesOpen && (
|
||||
<ChevronDown size={16} className="span-percentile-value-icon" />
|
||||
)}
|
||||
{isSpanPercentilesOpen && (
|
||||
<ChevronUp size={16} className="span-percentile-value-icon" />
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AnimatePresence initial={false}>
|
||||
{isSpanPercentilesOpen && !isErrorSpanPercentilesData && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
key="box"
|
||||
>
|
||||
<div className="span-percentiles-container">
|
||||
<div className="span-percentiles-header">
|
||||
<Typography.Text
|
||||
className="span-percentiles-header-text"
|
||||
onClick={(): void => setIsSpanPercentilesOpen((prev) => !prev)}
|
||||
>
|
||||
<ChevronDown size={16} /> Span Percentile
|
||||
</Typography.Text>
|
||||
|
||||
{showResourceAttributesSelector ? (
|
||||
<Check
|
||||
data-testid="check-icon"
|
||||
size={16}
|
||||
className="cursor-pointer span-percentiles-header-icon"
|
||||
onClick={(): void => setShowResourceAttributesSelector(false)}
|
||||
/>
|
||||
) : (
|
||||
<PlusIcon
|
||||
data-testid="plus-icon"
|
||||
size={16}
|
||||
className="cursor-pointer span-percentiles-header-icon"
|
||||
onClick={(): void => setShowResourceAttributesSelector(true)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showResourceAttributesSelector && (
|
||||
<div
|
||||
className="resource-attributes-select-container"
|
||||
ref={resourceAttributesSelectorRef}
|
||||
>
|
||||
<div className="resource-attributes-select-container-header">
|
||||
<Input
|
||||
placeholder="Search resource attributes"
|
||||
className="resource-attributes-select-container-input"
|
||||
value={resourceAttributesSearchQuery}
|
||||
onChange={(e): void =>
|
||||
setResourceAttributesSearchQuery(e.target.value as string)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="resource-attributes-items">
|
||||
{spanResourceAttributes
|
||||
.filter((resourceAttribute) =>
|
||||
resourceAttribute.key
|
||||
.toLowerCase()
|
||||
.includes(resourceAttributesSearchQuery.toLowerCase()),
|
||||
)
|
||||
.map((resourceAttribute) => (
|
||||
<div
|
||||
className="resource-attributes-select-item"
|
||||
key={resourceAttribute.key}
|
||||
>
|
||||
<div className="resource-attributes-select-item-checkbox">
|
||||
<Checkbox
|
||||
checked={resourceAttribute.isSelected}
|
||||
onChange={(e): void => {
|
||||
handleResourceAttributeChange(
|
||||
resourceAttribute.key,
|
||||
resourceAttribute.value,
|
||||
e.target.checked,
|
||||
);
|
||||
}}
|
||||
disabled={
|
||||
resourceAttribute.key === 'service.name' ||
|
||||
resourceAttribute.key === 'name'
|
||||
}
|
||||
>
|
||||
<div className="resource-attributes-select-item-value">
|
||||
{resourceAttribute.key}
|
||||
</div>
|
||||
</Checkbox>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="span-percentile-content">
|
||||
<Typography.Text className="span-percentile-content-title">
|
||||
This span duration is{' '}
|
||||
{!isLoadingSpanPercentilesData &&
|
||||
!isFetchingSpanPercentilesData &&
|
||||
spanPercentileData ? (
|
||||
<span className="span-percentile-value">
|
||||
p{Math.floor(spanPercentileData?.percentile || 0)}
|
||||
</span>
|
||||
) : (
|
||||
<span className="span-percentile-value-loader">
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
</span>
|
||||
)}{' '}
|
||||
out of the distribution for this resource evaluated for{' '}
|
||||
{selectedTimeRange} hour(s) since the span start time.
|
||||
</Typography.Text>
|
||||
|
||||
<div className="span-percentile-timerange">
|
||||
<Select
|
||||
labelInValue
|
||||
placeholder="Select timerange"
|
||||
className="span-percentile-timerange-select"
|
||||
value={{
|
||||
label: `${selectedTimeRange}h : ${dayjs(selectedSpan?.timestamp)
|
||||
.subtract(selectedTimeRange, 'hour')
|
||||
.format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)} - ${dayjs(
|
||||
selectedSpan?.timestamp,
|
||||
).format(DATE_TIME_FORMATS.TIME_SPAN_PERCENTILE)}`,
|
||||
value: selectedTimeRange,
|
||||
}}
|
||||
onChange={(value): void => {
|
||||
handleTimeRangeChange(Number(value.value));
|
||||
}}
|
||||
options={timerangeOptions}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="span-percentile-values-table">
|
||||
<div className="span-percentile-values-table-header-row">
|
||||
<Typography.Text className="span-percentile-values-table-header">
|
||||
Percentile
|
||||
</Typography.Text>
|
||||
|
||||
<Typography.Text className="span-percentile-values-table-header">
|
||||
Duration
|
||||
</Typography.Text>
|
||||
</div>
|
||||
|
||||
<div className="span-percentile-values-table-data-rows">
|
||||
{isLoadingSpanPercentilesData || isFetchingSpanPercentilesData ? (
|
||||
<Skeleton
|
||||
active
|
||||
paragraph={{ rows: 3 }}
|
||||
className="span-percentile-values-table-data-rows-skeleton"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
{Object.entries(spanPercentileData?.percentiles || {}).map(
|
||||
([percentile, duration]) => (
|
||||
<div
|
||||
className="span-percentile-values-table-data-row"
|
||||
key={percentile}
|
||||
>
|
||||
<Typography.Text className="span-percentile-values-table-data-row-key">
|
||||
{percentile}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="dashed-line" />
|
||||
|
||||
<Typography.Text className="span-percentile-values-table-data-row-value">
|
||||
{getYAxisFormattedValue(`${duration / 1000000}`, 'ms')}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
|
||||
<div className="span-percentile-values-table-data-row current-span-percentile-row">
|
||||
<Typography.Text className="span-percentile-values-table-data-row-key">
|
||||
p{Math.floor(spanPercentileData?.percentile || 0)}
|
||||
</Typography.Text>
|
||||
|
||||
<div className="dashed-line" />
|
||||
|
||||
<Typography.Text className="span-percentile-values-table-data-row-value">
|
||||
(this span){' '}
|
||||
{getYAxisFormattedValue(
|
||||
`${selectedSpan.durationNano / 1000000}`,
|
||||
'ms',
|
||||
)}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
<div className="item">
|
||||
<Typography.Text className="attribute-key">span id</Typography.Text>
|
||||
@@ -226,17 +885,7 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
<div className="related-signals-section">
|
||||
<SignozRadioGroup
|
||||
value=""
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
]}
|
||||
options={relatedSignalsOptions}
|
||||
onChange={handleRelatedSignalsChange}
|
||||
className="related-signals-radio"
|
||||
/>
|
||||
|
||||
@@ -12,7 +12,9 @@ import {
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import EmptyLogsSearch from 'container/EmptyLogsSearch/EmptyLogsSearch';
|
||||
import LogsError from 'container/LogsError/LogsError';
|
||||
import { EmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { LogsLoading } from 'container/LogsLoading/LogsLoading';
|
||||
import { FontSize } from 'container/OptionsMenu/types';
|
||||
import { getOperatorValue } from 'container/QueryBuilder/filters/QueryBuilderSearch/utils';
|
||||
@@ -30,8 +32,6 @@ import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { useSpanContextLogs } from './useSpanContextLogs';
|
||||
|
||||
interface SpanLogsProps {
|
||||
traceId: string;
|
||||
spanId: string;
|
||||
@@ -39,29 +39,29 @@ interface SpanLogsProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
logs: ILog[];
|
||||
isLoading: boolean;
|
||||
isError: boolean;
|
||||
isFetching: boolean;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
handleExplorerPageRedirect: () => void;
|
||||
emptyStateConfig?: EmptyLogsListConfig;
|
||||
}
|
||||
|
||||
function SpanLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
handleExplorerPageRedirect,
|
||||
emptyStateConfig,
|
||||
}: SpanLogsProps): JSX.Element {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
} = useSpanContextLogs({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
});
|
||||
|
||||
// Create trace_id and span_id filters for logs explorer navigation
|
||||
const createLogsFilter = useCallback(
|
||||
(targetSpanId: string): TagFilter => {
|
||||
@@ -236,9 +236,7 @@ function SpanLogs({
|
||||
<img src="/Icons/no-data.svg" alt="no-data" className="no-data-img" />
|
||||
<Typography.Text className="no-data-text-1">
|
||||
No logs found for selected span.
|
||||
<span className="no-data-text-2">
|
||||
Try viewing logs for the current trace.
|
||||
</span>
|
||||
<span className="no-data-text-2">View logs for the current trace.</span>
|
||||
</Typography.Text>
|
||||
</section>
|
||||
<section className="action-section">
|
||||
@@ -249,24 +247,45 @@ function SpanLogs({
|
||||
onClick={handleExplorerPageRedirect}
|
||||
size="md"
|
||||
>
|
||||
Log Explorer
|
||||
View Logs
|
||||
</Button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
const renderSpanLogsContent = (): JSX.Element | null => {
|
||||
if (isLoading || isFetching) {
|
||||
return <LogsLoading />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <LogsError />;
|
||||
}
|
||||
|
||||
if (logs.length === 0) {
|
||||
if (emptyStateConfig) {
|
||||
return (
|
||||
<EmptyLogsSearch
|
||||
dataSource={DataSource.LOGS}
|
||||
panelType="LIST"
|
||||
customMessage={emptyStateConfig}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return renderNoLogsFound();
|
||||
}
|
||||
|
||||
return renderContent;
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={cx('span-logs', { 'span-logs-empty': logs.length === 0 })}>
|
||||
{(isLoading || isFetching) && <LogsLoading />}
|
||||
{!isLoading &&
|
||||
!isFetching &&
|
||||
!isError &&
|
||||
logs.length === 0 &&
|
||||
renderNoLogsFound()}
|
||||
{isError && !isLoading && !isFetching && <LogsError />}
|
||||
{!isLoading && !isFetching && !isError && logs.length > 0 && renderContent}
|
||||
{renderSpanLogsContent()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
SpanLogs.defaultProps = {
|
||||
emptyStateConfig: undefined,
|
||||
};
|
||||
|
||||
export default SpanLogs;
|
||||
|
||||
@@ -0,0 +1,214 @@
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import SpanLogs from '../SpanLogs';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filter: { expression: "trace_id = 'test-trace-id'" },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
}),
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock window.open
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock Virtuoso to avoid complex virtualization
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }: any) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock RawLogView component
|
||||
jest.mock(
|
||||
'components/Logs/RawLogView',
|
||||
() =>
|
||||
function MockRawLogView({
|
||||
data,
|
||||
onLogClick,
|
||||
isHighlighted,
|
||||
helpTooltip,
|
||||
}: any): JSX.Element {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
data-testid={`raw-log-${data.id}`}
|
||||
className={isHighlighted ? 'log-highlighted' : 'log-context'}
|
||||
title={helpTooltip}
|
||||
onClick={(e): void => onLogClick?.(data, e)}
|
||||
>
|
||||
<div>{data.body}</div>
|
||||
<div>{data.timestamp}</div>
|
||||
</button>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock('components/OverlayScrollbar/OverlayScrollbar', () => ({
|
||||
default: ({ children }: any): JSX.Element => (
|
||||
<div data-testid="overlay-scrollbar">{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock LogsLoading component
|
||||
jest.mock('container/LogsLoading/LogsLoading', () => ({
|
||||
LogsLoading: function MockLogsLoading(): JSX.Element {
|
||||
return <div data-testid="logs-loading">Loading logs...</div>;
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock LogsError component
|
||||
jest.mock(
|
||||
'container/LogsError/LogsError',
|
||||
() =>
|
||||
function MockLogsError(): JSX.Element {
|
||||
return <div data-testid="logs-error">Error loading logs</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Don't mock EmptyLogsSearch - test the actual component behavior
|
||||
|
||||
const TEST_TRACE_ID = 'test-trace-id';
|
||||
const TEST_SPAN_ID = 'test-span-id';
|
||||
|
||||
const defaultProps = {
|
||||
traceId: TEST_TRACE_ID,
|
||||
spanId: TEST_SPAN_ID,
|
||||
timeRange: {
|
||||
startTime: 1640995200000,
|
||||
endTime: 1640995260000,
|
||||
},
|
||||
logs: [],
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isFetching: false,
|
||||
isLogSpanRelated: jest.fn().mockReturnValue(false),
|
||||
handleExplorerPageRedirect: jest.fn(),
|
||||
};
|
||||
|
||||
describe('SpanLogs', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockWindowOpen.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
it('should show simple empty state when emptyStateConfig is not provided', () => {
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<SpanLogs {...defaultProps} />);
|
||||
|
||||
// Should show simple empty state (no emptyStateConfig provided)
|
||||
expect(
|
||||
screen.getByText('No logs found for selected span.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('View logs for the current trace.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
}),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should NOT show enhanced empty state
|
||||
expect(screen.queryByTestId('empty-logs-search')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('documentation-links')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show enhanced empty state when entire trace has no logs', () => {
|
||||
render(
|
||||
<SpanLogs
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...defaultProps}
|
||||
emptyStateConfig={getEmptyLogsListConfig(jest.fn())}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Should show enhanced empty state with custom message
|
||||
expect(screen.getByText('No logs found for this trace.')).toBeInTheDocument();
|
||||
expect(screen.getByText('This could be because :')).toBeInTheDocument();
|
||||
|
||||
// Should show description list
|
||||
expect(
|
||||
screen.getByText('Logs are not linked to Traces.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Logs are not being sent to SigNoz.'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('No logs are associated with this particular trace/span.'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
// Should show documentation links
|
||||
expect(screen.getByText('RESOURCES')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sending logs to SigNoz')).toBeInTheDocument();
|
||||
expect(screen.getByText('Correlate traces and logs')).toBeInTheDocument();
|
||||
|
||||
// Should NOT show simple empty state
|
||||
expect(
|
||||
screen.queryByText('No logs found for selected span.'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call handleExplorerPageRedirect when Log Explorer button is clicked', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const mockHandleExplorerPageRedirect = jest.fn();
|
||||
|
||||
render(
|
||||
<SpanLogs
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...defaultProps}
|
||||
handleExplorerPageRedirect={mockHandleExplorerPageRedirect}
|
||||
/>,
|
||||
);
|
||||
|
||||
const logExplorerButton = screen.getByRole('button', {
|
||||
name: /view logs/i,
|
||||
});
|
||||
await user.click(logExplorerButton);
|
||||
|
||||
expect(mockHandleExplorerPageRedirect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -85,7 +85,7 @@ export const getTraceOnlyFilters = (traceId: string): TagFilter => ({
|
||||
type: '',
|
||||
key: 'trace_id',
|
||||
},
|
||||
op: 'in',
|
||||
op: '=',
|
||||
value: traceId,
|
||||
},
|
||||
],
|
||||
|
||||
@@ -11,7 +11,7 @@ import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Filter } from 'types/api/v5/queryRange';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
import { getSpanLogsQueryPayload } from './constants';
|
||||
import { getSpanLogsQueryPayload, getTraceOnlyFilters } from './constants';
|
||||
|
||||
interface UseSpanContextLogsProps {
|
||||
traceId: string;
|
||||
@@ -20,6 +20,7 @@ interface UseSpanContextLogsProps {
|
||||
startTime: number;
|
||||
endTime: number;
|
||||
};
|
||||
isDrawerOpen?: boolean;
|
||||
}
|
||||
|
||||
interface UseSpanContextLogsReturn {
|
||||
@@ -29,6 +30,7 @@ interface UseSpanContextLogsReturn {
|
||||
isFetching: boolean;
|
||||
spanLogIds: Set<string>;
|
||||
isLogSpanRelated: (logId: string) => boolean;
|
||||
hasTraceIdLogs: boolean;
|
||||
}
|
||||
|
||||
const traceIdKey = {
|
||||
@@ -110,6 +112,7 @@ export const useSpanContextLogs = ({
|
||||
traceId,
|
||||
spanId,
|
||||
timeRange,
|
||||
isDrawerOpen = true,
|
||||
}: UseSpanContextLogsProps): UseSpanContextLogsReturn => {
|
||||
const [allLogs, setAllLogs] = useState<ILog[]>([]);
|
||||
const [spanLogIds, setSpanLogIds] = useState<Set<string>>(new Set());
|
||||
@@ -264,6 +267,43 @@ export const useSpanContextLogs = ({
|
||||
setAllLogs(combined);
|
||||
}, [beforeLogs, spanLogs, afterLogs]);
|
||||
|
||||
// Phase 4: Check for trace_id-only logs when span has no logs
|
||||
// This helps differentiate between "no logs for span" vs "no logs for trace"
|
||||
const traceOnlyFilter = useMemo(() => {
|
||||
if (spanLogs.length > 0) return null;
|
||||
const filters = getTraceOnlyFilters(traceId);
|
||||
return convertFiltersToExpression(filters);
|
||||
}, [traceId, spanLogs.length]);
|
||||
|
||||
const traceOnlyQueryPayload = useMemo(() => {
|
||||
if (!traceOnlyFilter) return null;
|
||||
return getSpanLogsQueryPayload(
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
traceOnlyFilter,
|
||||
);
|
||||
}, [timeRange.startTime, timeRange.endTime, traceOnlyFilter]);
|
||||
|
||||
const { data: traceOnlyData } = useQuery({
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.TRACE_ONLY_LOGS,
|
||||
traceId,
|
||||
timeRange.startTime,
|
||||
timeRange.endTime,
|
||||
],
|
||||
queryFn: () =>
|
||||
GetMetricQueryRange(traceOnlyQueryPayload as any, ENTITY_VERSION_V5),
|
||||
enabled: isDrawerOpen && !!traceOnlyQueryPayload && spanLogs.length === 0,
|
||||
staleTime: FIVE_MINUTES_IN_MS,
|
||||
});
|
||||
|
||||
const hasTraceIdLogs = useMemo(() => {
|
||||
if (spanLogs.length > 0) return true;
|
||||
return !!(
|
||||
traceOnlyData?.payload?.data?.newResult?.data?.result?.[0]?.list?.length || 0
|
||||
);
|
||||
}, [spanLogs.length, traceOnlyData]);
|
||||
|
||||
// Helper function to check if a log belongs to the span
|
||||
const isLogSpanRelated = useCallback(
|
||||
(logId: string): boolean => spanLogIds.has(logId),
|
||||
@@ -277,5 +317,6 @@ export const useSpanContextLogs = ({
|
||||
isFetching: isSpanFetching || isBeforeFetching || isAfterFetching,
|
||||
spanLogIds,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -30,6 +30,11 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.view-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.views-tabs-container {
|
||||
padding: 16px 15px;
|
||||
@@ -37,7 +42,8 @@
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
.open-in-explorer {
|
||||
width: 30px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 30px;
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
@@ -87,28 +93,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
.infra-placeholder {
|
||||
height: 50vh;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 2rem;
|
||||
box-sizing: border-box;
|
||||
|
||||
.infra-placeholder-content {
|
||||
text-align: center;
|
||||
color: var(--bg-slate-400);
|
||||
|
||||
svg {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
|
||||
.ant-typography {
|
||||
font-size: 16px;
|
||||
color: var(--bg-slate-400);
|
||||
}
|
||||
.infra-metrics-container {
|
||||
padding-inline: 16px;
|
||||
.infra-metrics-card {
|
||||
border: 1px solid var(--bg-slate-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,39 +11,23 @@ import {
|
||||
initialQueryState,
|
||||
} from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import InfraMetrics from 'container/LogDetailedView/InfraMetrics/InfraMetrics';
|
||||
import { getEmptyLogsListConfig } from 'container/LogsExplorerList/utils';
|
||||
import dayjs from 'dayjs';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { Compass, X } from 'lucide-react';
|
||||
import { BarChart2, Compass, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
import { LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
import { DataSource, LogsAggregatorOperator } from 'types/common/queryBuilder';
|
||||
|
||||
import { RelatedSignalsViews } from '../constants';
|
||||
import SpanLogs from '../SpanLogs/SpanLogs';
|
||||
import { useSpanContextLogs } from '../SpanLogs/useSpanContextLogs';
|
||||
import { hasInfraMetadata } from '../utils';
|
||||
|
||||
const FIVE_MINUTES_IN_MS = 5 * 60 * 1000;
|
||||
|
||||
interface AppliedFiltersProps {
|
||||
filters: TagFilterItem[];
|
||||
}
|
||||
|
||||
function AppliedFilters({ filters }: AppliedFiltersProps): JSX.Element {
|
||||
return (
|
||||
<div className="span-related-signals-drawer__applied-filters">
|
||||
<div className="span-related-signals-drawer__filters-list">
|
||||
{filters.map((filter) => (
|
||||
<div key={filter.id} className="span-related-signals-drawer__filter-tag">
|
||||
<Typography.Text>
|
||||
{filter.key?.key}={filter.value}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface SpanRelatedSignalsProps {
|
||||
selectedSpan: Span;
|
||||
traceStartTime: number;
|
||||
@@ -66,33 +50,70 @@ function SpanRelatedSignals({
|
||||
);
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
// Extract infrastructure metadata from span attributes
|
||||
const infraMetadata = useMemo(() => {
|
||||
// Only return metadata if span has infrastructure metadata
|
||||
if (!hasInfraMetadata(selectedSpan)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
clusterName: selectedSpan.tagMap['k8s.cluster.name'] || '',
|
||||
podName: selectedSpan.tagMap['k8s.pod.name'] || '',
|
||||
nodeName: selectedSpan.tagMap['k8s.node.name'] || '',
|
||||
hostName: selectedSpan.tagMap['host.name'] || '',
|
||||
spanTimestamp: dayjs(selectedSpan.timestamp).format(),
|
||||
};
|
||||
}, [selectedSpan]);
|
||||
const {
|
||||
logs,
|
||||
isLoading,
|
||||
isError,
|
||||
isFetching,
|
||||
isLogSpanRelated,
|
||||
hasTraceIdLogs,
|
||||
} = useSpanContextLogs({
|
||||
traceId: selectedSpan.traceId,
|
||||
spanId: selectedSpan.spanId,
|
||||
timeRange: {
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
},
|
||||
isDrawerOpen: isOpen,
|
||||
});
|
||||
|
||||
const handleTabChange = useCallback((e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
}, []);
|
||||
|
||||
const handleClose = useCallback((): void => {
|
||||
setSelectedView(RelatedSignalsViews.LOGS);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
const appliedFilters = useMemo(
|
||||
(): TagFilterItem[] => [
|
||||
const tabOptions = useMemo(() => {
|
||||
const baseOptions = [
|
||||
{
|
||||
id: 'trace-id-filter',
|
||||
key: {
|
||||
key: 'trace_id',
|
||||
id: 'trace-id-key',
|
||||
dataType: 'string' as const,
|
||||
isColumn: true,
|
||||
type: '',
|
||||
isJSON: false,
|
||||
} as BaseAutocompleteData,
|
||||
op: '=',
|
||||
value: selectedSpan.traceId,
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
],
|
||||
[selectedSpan.traceId],
|
||||
);
|
||||
];
|
||||
|
||||
// Add Infra option if infrastructure metadata is available
|
||||
if (infraMetadata) {
|
||||
baseOptions.push({
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<BarChart2 size={14} />
|
||||
Metrics
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.INFRA,
|
||||
});
|
||||
}
|
||||
|
||||
return baseOptions;
|
||||
}, [infraMetadata]);
|
||||
|
||||
const handleExplorerPageRedirect = useCallback((): void => {
|
||||
const startTimeMs = traceStartTime - FIVE_MINUTES_IN_MS;
|
||||
@@ -146,6 +167,14 @@ function SpanRelatedSignals({
|
||||
);
|
||||
}, [selectedSpan.traceId, traceStartTime, traceEndTime]);
|
||||
|
||||
const emptyStateConfig = useMemo(
|
||||
() => ({
|
||||
...getEmptyLogsListConfig(() => {}),
|
||||
showClearFiltersButton: false,
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
width="50%"
|
||||
@@ -158,7 +187,7 @@ function SpanRelatedSignals({
|
||||
</>
|
||||
}
|
||||
placement="right"
|
||||
onClose={handleClose}
|
||||
onClose={onClose}
|
||||
open={isOpen}
|
||||
style={{
|
||||
overscrollBehavior: 'contain',
|
||||
@@ -173,35 +202,7 @@ function SpanRelatedSignals({
|
||||
<div className="views-tabs-container">
|
||||
<SignozRadioGroup
|
||||
value={selectedView}
|
||||
options={[
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<LogsIcon width={14} height={14} />
|
||||
Logs
|
||||
</div>
|
||||
),
|
||||
value: RelatedSignalsViews.LOGS,
|
||||
},
|
||||
// {
|
||||
// label: (
|
||||
// <div className="view-title">
|
||||
// <LogsIcon width={14} height={14} />
|
||||
// Metrics
|
||||
// </div>
|
||||
// ),
|
||||
// value: RelatedSignalsViews.METRICS,
|
||||
// },
|
||||
// {
|
||||
// label: (
|
||||
// <div className="view-title">
|
||||
// <Server size={14} />
|
||||
// Infra
|
||||
// </div>
|
||||
// ),
|
||||
// value: RelatedSignalsViews.INFRA,
|
||||
// },
|
||||
]}
|
||||
options={tabOptions}
|
||||
onChange={handleTabChange}
|
||||
className="related-signals-radio"
|
||||
/>
|
||||
@@ -210,23 +211,40 @@ function SpanRelatedSignals({
|
||||
icon={<Compass size={18} />}
|
||||
className="open-in-explorer"
|
||||
onClick={handleExplorerPageRedirect}
|
||||
/>
|
||||
data-testid="open-in-explorer-button"
|
||||
>
|
||||
Open in Logs Explorer
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedView === RelatedSignalsViews.LOGS && (
|
||||
<>
|
||||
<AppliedFilters filters={appliedFilters} />
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
/>
|
||||
</>
|
||||
<SpanLogs
|
||||
traceId={selectedSpan.traceId}
|
||||
spanId={selectedSpan.spanId}
|
||||
timeRange={{
|
||||
startTime: traceStartTime - FIVE_MINUTES_IN_MS,
|
||||
endTime: traceEndTime + FIVE_MINUTES_IN_MS,
|
||||
}}
|
||||
logs={logs}
|
||||
isLoading={isLoading}
|
||||
isError={isError}
|
||||
isFetching={isFetching}
|
||||
isLogSpanRelated={isLogSpanRelated}
|
||||
handleExplorerPageRedirect={handleExplorerPageRedirect}
|
||||
emptyStateConfig={!hasTraceIdLogs ? emptyStateConfig : undefined}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedView === RelatedSignalsViews.INFRA && infraMetadata && (
|
||||
<InfraMetrics
|
||||
clusterName={infraMetadata.clusterName}
|
||||
podName={infraMetadata.podName}
|
||||
nodeName={infraMetadata.nodeName}
|
||||
hostName={infraMetadata.hostName}
|
||||
timestamp={infraMetadata.spanTimestamp}
|
||||
dataSource={DataSource.TRACES}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,502 @@
|
||||
import ROUTES from 'constants/routes';
|
||||
import { GetMetricQueryRange } from 'lib/dashboard/getQueryResults';
|
||||
import { server } from 'mocks-server/server';
|
||||
import { QueryBuilderContext } from 'providers/QueryBuilder';
|
||||
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import SpanDetailsDrawer from '../SpanDetailsDrawer';
|
||||
import {
|
||||
expectedHostOnlyMetadata,
|
||||
expectedInfraMetadata,
|
||||
expectedNodeOnlyMetadata,
|
||||
expectedPodOnlyMetadata,
|
||||
mockEmptyMetricsResponse,
|
||||
mockNodeMetricsResponse,
|
||||
mockPodMetricsResponse,
|
||||
mockSpanWithHostOnly,
|
||||
mockSpanWithInfraMetadata,
|
||||
mockSpanWithNodeOnly,
|
||||
mockSpanWithoutInfraMetadata,
|
||||
mockSpanWithPodOnly,
|
||||
} from './infraMetricsTestData';
|
||||
|
||||
// Mock external dependencies
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: (): { pathname: string } => ({
|
||||
pathname: `${ROUTES.TRACE_DETAIL}`,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUpdateAllQueriesOperators = jest.fn().mockReturnValue({
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'noop',
|
||||
filters: { items: [], op: 'AND' },
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
orderBy: [{ columnName: 'timestamp', order: 'desc' }],
|
||||
groupBy: [],
|
||||
limit: null,
|
||||
having: [],
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
},
|
||||
queryType: 'builder',
|
||||
});
|
||||
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: (): any => ({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockWindowOpen = jest.fn();
|
||||
Object.defineProperty(window, 'open', {
|
||||
writable: true,
|
||||
value: mockWindowOpen,
|
||||
});
|
||||
|
||||
// Mock uplot to avoid rendering issues
|
||||
jest.mock('uplot', () => {
|
||||
const paths = {
|
||||
spline: jest.fn(),
|
||||
bars: jest.fn(),
|
||||
};
|
||||
const uplotMock = jest.fn(() => ({
|
||||
paths,
|
||||
}));
|
||||
return {
|
||||
paths,
|
||||
default: uplotMock,
|
||||
};
|
||||
});
|
||||
|
||||
// Mock GetMetricQueryRange to track API calls
|
||||
jest.mock('lib/dashboard/getQueryResults', () => ({
|
||||
GetMetricQueryRange: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock generateColor
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: jest.fn().mockReturnValue('#1f77b4'),
|
||||
}));
|
||||
|
||||
// Mock OverlayScrollbar
|
||||
jest.mock(
|
||||
'components/OverlayScrollbar/OverlayScrollbar',
|
||||
() =>
|
||||
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
|
||||
function ({ children }: any) {
|
||||
return <div data-testid="overlay-scrollbar">{children}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
// Mock Virtuoso
|
||||
jest.mock('react-virtuoso', () => ({
|
||||
Virtuoso: jest.fn(({ data, itemContent }) => (
|
||||
<div data-testid="virtuoso">
|
||||
{data?.map((item: any, index: number) => (
|
||||
<div key={item.id || index} data-testid={`log-item-${item.id}`}>
|
||||
{itemContent(index, item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)),
|
||||
}));
|
||||
|
||||
// Mock InfraMetrics component for focused testing
|
||||
jest.mock(
|
||||
'container/LogDetailedView/InfraMetrics/InfraMetrics',
|
||||
() =>
|
||||
// eslint-disable-next-line func-names, @typescript-eslint/explicit-function-return-type, react/display-name
|
||||
function MockInfraMetrics({
|
||||
podName,
|
||||
nodeName,
|
||||
hostName,
|
||||
clusterName,
|
||||
timestamp,
|
||||
dataSource,
|
||||
}: any) {
|
||||
return (
|
||||
<div data-testid="infra-metrics">
|
||||
<div data-testid="infra-pod-name">{podName}</div>
|
||||
<div data-testid="infra-node-name">{nodeName}</div>
|
||||
<div data-testid="infra-host-name">{hostName}</div>
|
||||
<div data-testid="infra-cluster-name">{clusterName}</div>
|
||||
<div data-testid="infra-timestamp">{timestamp}</div>
|
||||
<div data-testid="infra-data-source">{dataSource}</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
// Mock PreferenceContextProvider
|
||||
jest.mock('providers/preferences/context/PreferenceContextProvider', () => ({
|
||||
PreferenceContextProvider: ({ children }: any): JSX.Element => (
|
||||
<div>{children}</div>
|
||||
),
|
||||
}));
|
||||
|
||||
describe('SpanDetailsDrawer - Infra Metrics', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any, sonarjs/no-unused-collection
|
||||
let apiCallHistory: any[] = [];
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
apiCallHistory = [];
|
||||
mockSafeNavigate.mockClear();
|
||||
mockWindowOpen.mockClear();
|
||||
mockUpdateAllQueriesOperators.mockClear();
|
||||
|
||||
// Setup API call tracking for infra metrics
|
||||
(GetMetricQueryRange as jest.Mock).mockImplementation((query) => {
|
||||
apiCallHistory.push(query);
|
||||
|
||||
// Return mock responses for different query types
|
||||
if (
|
||||
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
|
||||
(item: any) => item.key?.key === 'k8s_pod_name',
|
||||
)
|
||||
) {
|
||||
return Promise.resolve(mockPodMetricsResponse);
|
||||
}
|
||||
|
||||
if (
|
||||
query?.query?.builder?.queryData?.[0]?.filters?.items?.some(
|
||||
(item: any) => item.key?.key === 'k8s_node_name',
|
||||
)
|
||||
) {
|
||||
return Promise.resolve(mockNodeMetricsResponse);
|
||||
}
|
||||
|
||||
return Promise.resolve(mockEmptyMetricsResponse);
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
server.resetHandlers();
|
||||
});
|
||||
|
||||
// Mock QueryBuilder context value
|
||||
const mockQueryBuilderContextValue = {
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
stagedQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: 'logs',
|
||||
queryName: 'A',
|
||||
filters: { items: [], op: 'AND' },
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
panelType: 'list',
|
||||
redirectWithQuery: jest.fn(),
|
||||
handleRunQuery: jest.fn(),
|
||||
handleStageQuery: jest.fn(),
|
||||
resetQuery: jest.fn(),
|
||||
};
|
||||
|
||||
const renderSpanDetailsDrawer = (props = {}): void => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithInfraMetadata}
|
||||
traceStartTime={1640995200000} // 2022-01-01 00:00:00
|
||||
traceEndTime={1640995260000} // 2022-01-01 00:01:00
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
{...props}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
};
|
||||
|
||||
it('should detect infra metadata from span attributes', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Click on metrics tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
// Wait for infra metrics to load
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify metadata extraction
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.podName,
|
||||
);
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.nodeName,
|
||||
);
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.hostName,
|
||||
);
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedInfraMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
|
||||
DataSource.TRACES,
|
||||
);
|
||||
});
|
||||
|
||||
it('should not show infra tab when span lacks infra metadata', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithoutInfraMetadata}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Should NOT show infra tab, only logs tab
|
||||
expect(
|
||||
screen.queryByRole('radio', { name: /metrics/i }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show infra tab when span has infra metadata', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show both logs and infra tabs
|
||||
expect(screen.getByRole('radio', { name: /metrics/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('radio', { name: /logs/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle pod-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithPodOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify pod-only metadata
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedPodOnlyMetadata.hostName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle node-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithNodeOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify node-only metadata
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.clusterName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedNodeOnlyMetadata.hostName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle host-only metadata correctly', async () => {
|
||||
render(
|
||||
<QueryBuilderContext.Provider value={mockQueryBuilderContextValue as any}>
|
||||
<SpanDetailsDrawer
|
||||
isSpanDetailsDocked={false}
|
||||
setIsSpanDetailsDocked={jest.fn()}
|
||||
selectedSpan={mockSpanWithHostOnly}
|
||||
traceStartTime={1640995200000}
|
||||
traceEndTime={1640995260000}
|
||||
/>
|
||||
</QueryBuilderContext.Provider>,
|
||||
);
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify host-only metadata
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.hostName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.podName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.nodeName,
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
expectedHostOnlyMetadata.clusterName,
|
||||
);
|
||||
});
|
||||
|
||||
it('should switch between logs and infra tabs correctly', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Initially should show logs tab content
|
||||
const logsButton = screen.getByRole('radio', { name: /logs/i });
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
|
||||
expect(logsButton).toBeInTheDocument();
|
||||
expect(infraMetricsButton).toBeInTheDocument();
|
||||
|
||||
// Ensure logs tab is active and wait for content to load
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on infra tab
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Should not show logs content anymore
|
||||
expect(
|
||||
screen.queryByTestId('open-in-explorer-button'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// Switch back to logs tab
|
||||
fireEvent.click(logsButton);
|
||||
|
||||
// Should not show infra metrics anymore
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByTestId('infra-metrics')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify logs content is shown again
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('open-in-explorer-button')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should pass correct data source and handle multiple infra identifiers', async () => {
|
||||
renderSpanDetailsDrawer();
|
||||
|
||||
// Should show infra tab when span has any of: clusterName, podName, nodeName, hostName
|
||||
expect(screen.getByRole('radio', { name: /metrics/i })).toBeInTheDocument();
|
||||
|
||||
// Click on infra tab
|
||||
const infraMetricsButton = screen.getByRole('radio', { name: /metrics/i });
|
||||
fireEvent.click(infraMetricsButton);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByTestId('infra-metrics')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Verify TRACES data source is passed
|
||||
expect(screen.getByTestId('infra-data-source')).toHaveTextContent(
|
||||
DataSource.TRACES,
|
||||
);
|
||||
|
||||
// All infra identifiers should be passed through
|
||||
expect(screen.getByTestId('infra-pod-name')).toHaveTextContent(
|
||||
'test-pod-abc123',
|
||||
);
|
||||
expect(screen.getByTestId('infra-node-name')).toHaveTextContent(
|
||||
'test-node-456',
|
||||
);
|
||||
expect(screen.getByTestId('infra-host-name')).toHaveTextContent(
|
||||
'test-host.example.com',
|
||||
);
|
||||
expect(screen.getByTestId('infra-cluster-name')).toHaveTextContent(
|
||||
'test-cluster',
|
||||
);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user