Compare commits
58 Commits
tvats-cust
...
enhancemen
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aea42ba5ca | ||
|
|
01e0b36d62 | ||
|
|
e90bb016f7 | ||
|
|
bdecbfb7f5 | ||
|
|
3dced2b082 | ||
|
|
1285666087 | ||
|
|
1655397eaa | ||
|
|
718360a966 | ||
|
|
2f5995b071 | ||
|
|
a061c9de0f | ||
|
|
7b1ca9a1a6 | ||
|
|
0d1131e99f | ||
|
|
44d1d0f994 | ||
|
|
bdce97a727 | ||
|
|
5f8cfbe474 | ||
|
|
55c2f98768 | ||
|
|
624bb5cc62 | ||
|
|
95f8fa1566 | ||
|
|
fa97e63912 | ||
|
|
c8419c1f82 | ||
|
|
e05ede3978 | ||
|
|
437d0d1345 | ||
|
|
64e379c413 | ||
|
|
d05d394f57 | ||
|
|
b4e5085a5a | ||
|
|
88f7502a15 | ||
|
|
b0442761ac | ||
|
|
455ba0549f | ||
|
|
d539ca9bab | ||
|
|
c8194e9abb | ||
|
|
c919102fee | ||
|
|
765370752c | ||
|
|
db5c102f14 | ||
|
|
8a0c5bc3c8 | ||
|
|
a28ccffd01 | ||
|
|
84adb3e163 | ||
|
|
5f2c302551 | ||
|
|
15c2dc700a | ||
|
|
02fa0dbc32 | ||
|
|
e0948033c8 | ||
|
|
a1115ac65b | ||
|
|
9bcb88c747 | ||
|
|
367bf7b4b5 | ||
|
|
59b68057b8 | ||
|
|
fa1b2ddf7c | ||
|
|
642a0e5656 | ||
|
|
cb99ee1ac1 | ||
|
|
7616cb89e4 | ||
|
|
bf780c7445 | ||
|
|
61062dfd8d | ||
|
|
5b7af9651c | ||
|
|
b9012f6150 | ||
|
|
7ab81780b3 | ||
|
|
a16f51457f | ||
|
|
38a38b5645 | ||
|
|
bb04bc5044 | ||
|
|
58736f40dc | ||
|
|
91154249d6 |
16
.github/CODEOWNERS
vendored
16
.github/CODEOWNERS
vendored
@@ -48,13 +48,13 @@
|
||||
.github @SigNoz/devops
|
||||
|
||||
# Scaffold Owners
|
||||
/pkg/config/ @grandwizard28
|
||||
/pkg/errors/ @grandwizard28
|
||||
/pkg/factory/ @grandwizard28
|
||||
/pkg/types/ @grandwizard28
|
||||
/pkg/valuer/ @grandwizard28
|
||||
/cmd/ @grandwizard28
|
||||
.golangci.yml @grandwizard28
|
||||
/pkg/config/ @therealpandey
|
||||
/pkg/errors/ @therealpandey
|
||||
/pkg/factory/ @therealpandey
|
||||
/pkg/types/ @therealpandey
|
||||
/pkg/valuer/ @therealpandey
|
||||
/cmd/ @therealpandey
|
||||
.golangci.yml @therealpandey
|
||||
|
||||
# Zeus Owners
|
||||
/pkg/zeus/ @vikrantgupta25
|
||||
@@ -84,4 +84,4 @@
|
||||
|
||||
# AuthN / AuthZ Owners
|
||||
|
||||
/pkg/authz/ @vikrantgupta25 @grandwizard28
|
||||
/pkg/authz/ @vikrantgupta25 @therealpandey
|
||||
|
||||
7
.github/workflows/build-community.yaml
vendored
7
.github/workflows/build-community.yaml
vendored
@@ -3,8 +3,8 @@ name: build-community
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+'
|
||||
- "v[0-9]+.[0-9]+.[0-9]+"
|
||||
- "v[0-9]+.[0-9]+.[0-9]+-rc.[0-9]+"
|
||||
|
||||
defaults:
|
||||
run:
|
||||
@@ -69,14 +69,13 @@ jobs:
|
||||
GO_BUILD_CONTEXT: ./cmd/community
|
||||
GO_BUILD_FLAGS: >-
|
||||
-tags timetzdata
|
||||
-ldflags='-linkmode external -extldflags \"-static\" -s -w
|
||||
-ldflags='-s -w
|
||||
-X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }}
|
||||
-X github.com/SigNoz/signoz/pkg/version.variant=community
|
||||
-X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }}
|
||||
-X github.com/SigNoz/signoz/pkg/version.time=${{ needs.prepare.outputs.time }}
|
||||
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
GO_CGO_ENABLED: 1
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
DOCKER_DOCKERFILE_PATH: ./cmd/community/Dockerfile.multi-arch
|
||||
DOCKER_MANIFEST: true
|
||||
|
||||
5
.github/workflows/build-enterprise.yaml
vendored
5
.github/workflows/build-enterprise.yaml
vendored
@@ -84,7 +84,7 @@ jobs:
|
||||
JS_INPUT_ARTIFACT_CACHE_KEY: enterprise-dotenv-${{ github.sha }}
|
||||
JS_INPUT_ARTIFACT_PATH: frontend/.env
|
||||
JS_OUTPUT_ARTIFACT_CACHE_KEY: enterprise-jsbuild-${{ github.sha }}
|
||||
JS_OUTPUT_ARTIFACT_PATH: frontend/build
|
||||
JS_OUTPUT_ARTIFACT_PATH: frontend/build
|
||||
DOCKER_BUILD: false
|
||||
DOCKER_MANIFEST: false
|
||||
go-build:
|
||||
@@ -99,7 +99,7 @@ jobs:
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
GO_BUILD_FLAGS: >-
|
||||
-tags timetzdata
|
||||
-ldflags='-linkmode external -extldflags \"-static\" -s -w
|
||||
-ldflags='-s -w
|
||||
-X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }}
|
||||
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise
|
||||
-X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }}
|
||||
@@ -110,7 +110,6 @@ jobs:
|
||||
-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'
|
||||
GO_CGO_ENABLED: 1
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
|
||||
DOCKER_MANIFEST: true
|
||||
|
||||
5
.github/workflows/build-staging.yaml
vendored
5
.github/workflows/build-staging.yaml
vendored
@@ -98,7 +98,7 @@ jobs:
|
||||
GO_BUILD_CONTEXT: ./cmd/enterprise
|
||||
GO_BUILD_FLAGS: >-
|
||||
-tags timetzdata
|
||||
-ldflags='-linkmode external -extldflags \"-static\" -s -w
|
||||
-ldflags='-s -w
|
||||
-X github.com/SigNoz/signoz/pkg/version.version=${{ needs.prepare.outputs.version }}
|
||||
-X github.com/SigNoz/signoz/pkg/version.variant=enterprise
|
||||
-X github.com/SigNoz/signoz/pkg/version.hash=${{ needs.prepare.outputs.hash }}
|
||||
@@ -109,7 +109,6 @@ jobs:
|
||||
-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'
|
||||
GO_CGO_ENABLED: 1
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
|
||||
DOCKER_MANIFEST: true
|
||||
@@ -125,4 +124,4 @@ jobs:
|
||||
GITHUB_SILENT: true
|
||||
GITHUB_REPOSITORY_NAME: charts-saas-v3-staging
|
||||
GITHUB_EVENT_NAME: releaser
|
||||
GITHUB_EVENT_PAYLOAD: "{\"deployment\": \"${{ needs.prepare.outputs.deployment }}\", \"signoz_version\": \"${{ needs.prepare.outputs.version }}\"}"
|
||||
GITHUB_EVENT_PAYLOAD: '{"deployment": "${{ needs.prepare.outputs.deployment }}", "signoz_version": "${{ needs.prepare.outputs.version }}"}'
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -106,6 +106,7 @@ downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
!frontend/src/lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
|
||||
@@ -32,6 +32,10 @@ linters-settings:
|
||||
iface:
|
||||
enable:
|
||||
- identical
|
||||
forbidigo:
|
||||
forbid:
|
||||
- fmt.Errorf
|
||||
- ^(fmt\.Print.*|print|println)$
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- "pkg/query-service"
|
||||
|
||||
12
Makefile
12
Makefile
@@ -114,9 +114,9 @@ $(GO_BUILD_ARCHS_COMMUNITY): go-build-community-%: $(TARGET_DIR)
|
||||
@mkdir -p $(TARGET_DIR)/$(OS)-$*
|
||||
@echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)-community"
|
||||
@if [ $* = "arm64" ]; then \
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
|
||||
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
|
||||
else \
|
||||
CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
|
||||
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_COMMUNITY) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME)-community -ldflags "-s -w $(GO_BUILD_LDFLAGS_COMMUNITY)"; \
|
||||
fi
|
||||
|
||||
|
||||
@@ -127,9 +127,9 @@ $(GO_BUILD_ARCHS_ENTERPRISE): go-build-enterprise-%: $(TARGET_DIR)
|
||||
@mkdir -p $(TARGET_DIR)/$(OS)-$*
|
||||
@echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)"
|
||||
@if [ $* = "arm64" ]; then \
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
else \
|
||||
CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
fi
|
||||
|
||||
.PHONY: go-build-enterprise-race $(GO_BUILD_ARCHS_ENTERPRISE_RACE)
|
||||
@@ -139,9 +139,9 @@ $(GO_BUILD_ARCHS_ENTERPRISE_RACE): go-build-enterprise-race-%: $(TARGET_DIR)
|
||||
@mkdir -p $(TARGET_DIR)/$(OS)-$*
|
||||
@echo ">> building binary $(TARGET_DIR)/$(OS)-$*/$(NAME)"
|
||||
@if [ $* = "arm64" ]; then \
|
||||
CC=aarch64-linux-gnu-gcc CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
else \
|
||||
CGO_ENABLED=1 GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-linkmode external -extldflags '-static' -s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
GOARCH=$* GOOS=$(OS) go build -C $(GO_BUILD_CONTEXT_ENTERPRISE) -race -tags timetzdata -o $(TARGET_DIR)/$(OS)-$*/$(NAME) -ldflags "-s -w $(GO_BUILD_LDFLAGS_ENTERPRISE)"; \
|
||||
fi
|
||||
|
||||
##############################################################
|
||||
|
||||
@@ -236,7 +236,7 @@ Not sure how to get started? Just ping us on `#contributing` in our [slack commu
|
||||
#### DevOps
|
||||
|
||||
- [Prashant Shahi](https://github.com/prashant-shahi)
|
||||
- [Vibhu Pandey](https://github.com/grandwizard28)
|
||||
- [Vibhu Pandey](https://github.com/therealpandey)
|
||||
|
||||
<br /><br />
|
||||
|
||||
|
||||
@@ -12,12 +12,6 @@ builds:
|
||||
- id: signoz
|
||||
binary: bin/signoz
|
||||
main: ./cmd/community
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- >-
|
||||
{{- if eq .Os "linux" }}
|
||||
{{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{- end }}
|
||||
{{- end }}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
@@ -36,8 +30,6 @@ builds:
|
||||
- -X github.com/SigNoz/signoz/pkg/version.time={{ .CommitTimestamp }}
|
||||
- -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }}
|
||||
- -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr
|
||||
- >-
|
||||
{{- if eq .Os "linux" }}-linkmode external -extldflags '-static'{{- end }}
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- timetzdata
|
||||
|
||||
@@ -12,12 +12,6 @@ builds:
|
||||
- id: signoz
|
||||
binary: bin/signoz
|
||||
main: ./cmd/enterprise
|
||||
env:
|
||||
- CGO_ENABLED=1
|
||||
- >-
|
||||
{{- if eq .Os "linux" }}
|
||||
{{- if eq .Arch "arm64" }}CC=aarch64-linux-gnu-gcc{{- end }}
|
||||
{{- end }}
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
@@ -40,8 +34,6 @@ builds:
|
||||
- -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
|
||||
- >-
|
||||
{{- if eq .Os "linux" }}-linkmode external -extldflags '-static'{{- end }}
|
||||
mod_timestamp: "{{ .CommitTimestamp }}"
|
||||
tags:
|
||||
- timetzdata
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
##################### SigNoz Configuration Example #####################
|
||||
#
|
||||
#
|
||||
# Do not modify this file
|
||||
#
|
||||
|
||||
@@ -58,7 +58,7 @@ cache:
|
||||
# The port on which the Redis server is running. Default is usually 6379.
|
||||
port: 6379
|
||||
# The password for authenticating with the Redis server, if required.
|
||||
password:
|
||||
password:
|
||||
# The Redis database number to use
|
||||
db: 0
|
||||
|
||||
@@ -71,6 +71,10 @@ sqlstore:
|
||||
sqlite:
|
||||
# The path to the SQLite database file.
|
||||
path: /var/lib/signoz/signoz.db
|
||||
# Mode is the mode to use for the sqlite database.
|
||||
mode: delete
|
||||
# BusyTimeout is the timeout for the sqlite database to wait for a lock.
|
||||
busy_timeout: 10s
|
||||
|
||||
##################### APIServer #####################
|
||||
apiserver:
|
||||
@@ -238,7 +242,6 @@ statsreporter:
|
||||
# Whether to collect identities and traits (emails).
|
||||
identities: true
|
||||
|
||||
|
||||
##################### Gateway (License only) #####################
|
||||
gateway:
|
||||
# The URL of the gateway's api.
|
||||
|
||||
@@ -176,7 +176,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.97.0
|
||||
image: signoz/signoz:v0.99.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -117,7 +117,7 @@ services:
|
||||
# - ../common/clickhouse/storage.xml:/etc/clickhouse-server/config.d/storage.xml
|
||||
signoz:
|
||||
!!merge <<: *db-depend
|
||||
image: signoz/signoz:v0.97.0
|
||||
image: signoz/signoz:v0.99.0
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
ports:
|
||||
|
||||
@@ -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.97.0}
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -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.97.0}
|
||||
image: signoz/signoz:${VERSION:-v0.99.0}
|
||||
container_name: signoz
|
||||
command:
|
||||
- --config=/root/config/prometheus.yml
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -13,8 +13,6 @@ Before diving in, make sure you have these tools installed:
|
||||
- Download from [go.dev/dl](https://go.dev/dl/)
|
||||
- Check [go.mod](../../go.mod#L3) for the minimum version
|
||||
|
||||
- **GCC** - Required for CGO dependencies
|
||||
- Download from [gcc.gnu.org](https://gcc.gnu.org/)
|
||||
|
||||
- **Node** - Powers our frontend
|
||||
- Download from [nodejs.org](https://nodejs.org)
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package licensing
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/licensing"
|
||||
)
|
||||
|
||||
@@ -18,7 +18,7 @@ func Config(pollInterval time.Duration, failureThreshold int) licensing.Config {
|
||||
once.Do(func() {
|
||||
config = licensing.Config{PollInterval: pollInterval, FailureThreshold: failureThreshold}
|
||||
if err := config.Validate(); err != nil {
|
||||
panic(fmt.Errorf("invalid licensing config: %w", err))
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid licensing config"))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package postgressqlschema
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/factory"
|
||||
"github.com/SigNoz/signoz/pkg/sqlschema"
|
||||
@@ -47,50 +48,45 @@ func (provider *provider) Operator() sqlschema.SQLOperator {
|
||||
}
|
||||
|
||||
func (provider *provider) GetTable(ctx context.Context, tableName sqlschema.TableName) (*sqlschema.Table, []*sqlschema.UniqueConstraint, error) {
|
||||
rows, err := provider.
|
||||
columns := []struct {
|
||||
ColumnName string `bun:"column_name"`
|
||||
Nullable bool `bun:"nullable"`
|
||||
SQLDataType string `bun:"udt_name"`
|
||||
DefaultVal *string `bun:"column_default"`
|
||||
}{}
|
||||
|
||||
err := provider.
|
||||
sqlstore.
|
||||
BunDB().
|
||||
QueryContext(ctx, `
|
||||
NewRaw(`
|
||||
SELECT
|
||||
c.column_name,
|
||||
c.is_nullable = 'YES',
|
||||
c.is_nullable = 'YES' as nullable,
|
||||
c.udt_name,
|
||||
c.column_default
|
||||
FROM
|
||||
information_schema.columns AS c
|
||||
WHERE
|
||||
c.table_name = ?`, string(tableName))
|
||||
c.table_name = ?`, string(tableName)).
|
||||
Scan(ctx, &columns)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if len(columns) == 0 {
|
||||
return nil, nil, sql.ErrNoRows
|
||||
}
|
||||
|
||||
defer func() {
|
||||
if err := rows.Close(); err != nil {
|
||||
provider.settings.Logger().ErrorContext(ctx, "error closing rows", "error", err)
|
||||
}
|
||||
}()
|
||||
|
||||
columns := make([]*sqlschema.Column, 0)
|
||||
for rows.Next() {
|
||||
var (
|
||||
name string
|
||||
sqlDataType string
|
||||
nullable bool
|
||||
defaultVal *string
|
||||
)
|
||||
if err := rows.Scan(&name, &nullable, &sqlDataType, &defaultVal); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
|
||||
sqlschemaColumns := make([]*sqlschema.Column, 0)
|
||||
for _, column := range columns {
|
||||
columnDefault := ""
|
||||
if defaultVal != nil {
|
||||
columnDefault = *defaultVal
|
||||
if column.DefaultVal != nil {
|
||||
columnDefault = *column.DefaultVal
|
||||
}
|
||||
|
||||
columns = append(columns, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName(name),
|
||||
Nullable: nullable,
|
||||
DataType: provider.fmter.DataTypeOf(sqlDataType),
|
||||
sqlschemaColumns = append(sqlschemaColumns, &sqlschema.Column{
|
||||
Name: sqlschema.ColumnName(column.ColumnName),
|
||||
Nullable: column.Nullable,
|
||||
DataType: provider.fmter.DataTypeOf(column.SQLDataType),
|
||||
Default: columnDefault,
|
||||
})
|
||||
}
|
||||
@@ -208,7 +204,7 @@ WHERE
|
||||
|
||||
return &sqlschema.Table{
|
||||
Name: tableName,
|
||||
Columns: columns,
|
||||
Columns: sqlschemaColumns,
|
||||
PrimaryKeyConstraint: primaryKeyConstraint,
|
||||
ForeignKeyConstraints: foreignKeyConstraints,
|
||||
}, uniqueConstraints, nil
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
package zeus
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
neturl "net/url"
|
||||
"sync"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/zeus"
|
||||
)
|
||||
|
||||
@@ -24,17 +24,17 @@ func Config() zeus.Config {
|
||||
once.Do(func() {
|
||||
parsedURL, err := neturl.Parse(url)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("invalid zeus URL: %w", err))
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus URL"))
|
||||
}
|
||||
|
||||
deprecatedParsedURL, err := neturl.Parse(deprecatedURL)
|
||||
if err != nil {
|
||||
panic(fmt.Errorf("invalid zeus deprecated URL: %w", err))
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus deprecated URL"))
|
||||
}
|
||||
|
||||
config = zeus.Config{URL: parsedURL, DeprecatedURL: deprecatedParsedURL}
|
||||
if err := config.Validate(); err != nil {
|
||||
panic(fmt.Errorf("invalid zeus config: %w", err))
|
||||
panic(errors.WrapInternalf(err, errors.CodeInternal, "invalid zeus config"))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
// Mock for useSafeNavigate hook to avoid React Router version conflicts in tests
|
||||
export { isEventObject } from '../src/utils/isEventObject';
|
||||
|
||||
interface SafeNavigateOptions {
|
||||
replace?: boolean;
|
||||
state?: unknown;
|
||||
|
||||
@@ -279,6 +279,7 @@
|
||||
"prismjs": "1.30.0",
|
||||
"got": "11.8.5",
|
||||
"form-data": "4.0.4",
|
||||
"brace-expansion": "^2.0.2"
|
||||
"brace-expansion": "^2.0.2",
|
||||
"on-headers": "^1.1.0"
|
||||
}
|
||||
}
|
||||
|
||||
14
frontend/public/Logos/mastra.svg
Normal file
14
frontend/public/Logos/mastra.svg
Normal file
@@ -0,0 +1,14 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="512" height="512">
|
||||
<path d="M0 0 C1.01790619 0.00215515 2.03581238 0.0043103 3.08456421 0.00653076 C17.69363523 0.05620146 32.02575853 0.30709809 46.375 3.3125 C47.34985352 3.51117676 48.32470703 3.70985352 49.32910156 3.91455078 C97.7109774 14.03517194 141.69928269 35.9516877 177.375 70.3125 C178.3640332 71.26068604 178.3640332 71.26068604 179.37304688 72.22802734 C188.86130305 81.37247479 197.6012044 90.66984009 205.375 101.3125 C206.23306152 102.46355017 207.09113451 103.61459177 207.94921875 104.765625 C213.98736829 112.95680614 219.34595521 121.47054105 224.375 130.3125 C224.73948242 130.94430176 225.10396484 131.57610352 225.47949219 132.22705078 C247.08534998 169.97532572 256.85198095 212.74189997 256.6875 255.9375 C256.68534485 256.95540619 256.6831897 257.97331238 256.68096924 259.02206421 C256.63129854 273.63113523 256.38040191 287.96325853 253.375 302.3125 C253.07698486 303.77478027 253.07698486 303.77478027 252.77294922 305.26660156 C242.65232806 353.6484774 220.7358123 397.63678269 186.375 433.3125 C185.74287598 433.97185547 185.11075195 434.63121094 184.45947266 435.31054688 C175.31502521 444.79880305 166.01765991 453.5387044 155.375 461.3125 C154.22394983 462.17056152 153.07290823 463.02863451 151.921875 463.88671875 C143.73224019 469.92427396 135.22961724 475.30707302 126.375 480.3125 C125.66778809 480.7146875 124.96057617 481.116875 124.23193359 481.53125 C101.09654164 494.5566741 75.92537591 503.30551461 49.9375 508.625 C49.21860596 508.77235596 48.49971191 508.91971191 47.7590332 509.0715332 C33.08339502 511.86090125 18.55831162 512.66142619 3.64770508 512.62817383 C0.70811513 512.6250221 -2.2304558 512.64854926 -5.16992188 512.67382812 C-18.54306192 512.71848737 -31.43916711 511.51301542 -44.625 509.3125 C-46.33055054 509.03031616 -46.33055054 509.03031616 -48.07055664 508.74243164 C-91.33802759 500.97044532 -132.38793216 480.98345309 -165.625 452.3125 C-166.37007813 451.67828125 -167.11515625 451.0440625 -167.8828125 450.390625 C-181.38779833 438.57948485 -194.05918743 425.82889772 -204.625 411.3125 C-205.48149066 410.16255169 -206.33826433 409.01281411 -207.1953125 407.86328125 C-213.23534209 399.67315742 -218.61867614 391.16870382 -223.625 382.3125 C-224.0271875 381.60528809 -224.429375 380.89807617 -224.84375 380.16943359 C-237.8691741 357.03404164 -246.61801461 331.86287591 -251.9375 305.875 C-252.08485596 305.15610596 -252.23221191 304.43721191 -252.3840332 303.6965332 C-255.35789088 288.05024391 -255.99800362 272.57681954 -255.9375 256.6875 C-255.93534485 255.66959381 -255.9331897 254.65168762 -255.93096924 253.60293579 C-255.88129854 238.99386477 -255.63040191 224.66174147 -252.625 210.3125 C-252.42632324 209.33764648 -252.22764648 208.36279297 -252.02294922 207.35839844 C-241.90232806 158.9765226 -219.9858123 114.98821731 -185.625 79.3125 C-184.99287598 78.65314453 -184.36075195 77.99378906 -183.70947266 77.31445312 C-174.56502521 67.82619695 -165.26765991 59.0862956 -154.625 51.3125 C-153.47394983 50.45443848 -152.32290823 49.59636549 -151.171875 48.73828125 C-142.98224019 42.70072604 -134.47961724 37.31792698 -125.625 32.3125 C-124.91778809 31.9103125 -124.21057617 31.508125 -123.48193359 31.09375 C-100.34654164 18.0683259 -75.17537591 9.31948539 -49.1875 4 C-48.46860596 3.85264404 -47.74971191 3.70528809 -47.0090332 3.5534668 C-31.36274391 0.57960912 -15.88931954 -0.06050362 0 0 Z " fill="#FAFAFA" transform="translate(255.625,-0.3125)"/>
|
||||
<path d="M0 0 C1.01790619 0.00215515 2.03581238 0.0043103 3.08456421 0.00653076 C17.69363523 0.05620146 32.02575853 0.30709809 46.375 3.3125 C47.34985352 3.51117676 48.32470703 3.70985352 49.32910156 3.91455078 C97.7109774 14.03517194 141.69928269 35.9516877 177.375 70.3125 C178.3640332 71.26068604 178.3640332 71.26068604 179.37304688 72.22802734 C188.86130305 81.37247479 197.6012044 90.66984009 205.375 101.3125 C206.23306152 102.46355017 207.09113451 103.61459177 207.94921875 104.765625 C213.98736829 112.95680614 219.34595521 121.47054105 224.375 130.3125 C224.73948242 130.94430176 225.10396484 131.57610352 225.47949219 132.22705078 C247.08534998 169.97532572 256.85198095 212.74189997 256.6875 255.9375 C256.68534485 256.95540619 256.6831897 257.97331238 256.68096924 259.02206421 C256.63129854 273.63113523 256.38040191 287.96325853 253.375 302.3125 C253.07698486 303.77478027 253.07698486 303.77478027 252.77294922 305.26660156 C242.65232806 353.6484774 220.7358123 397.63678269 186.375 433.3125 C185.74287598 433.97185547 185.11075195 434.63121094 184.45947266 435.31054688 C175.31502521 444.79880305 166.01765991 453.5387044 155.375 461.3125 C154.22394983 462.17056152 153.07290823 463.02863451 151.921875 463.88671875 C143.73224019 469.92427396 135.22961724 475.30707302 126.375 480.3125 C125.66778809 480.7146875 124.96057617 481.116875 124.23193359 481.53125 C101.09654164 494.5566741 75.92537591 503.30551461 49.9375 508.625 C49.21860596 508.77235596 48.49971191 508.91971191 47.7590332 509.0715332 C33.08339502 511.86090125 18.55831162 512.66142619 3.64770508 512.62817383 C0.70811513 512.6250221 -2.2304558 512.64854926 -5.16992188 512.67382812 C-18.54306192 512.71848737 -31.43916711 511.51301542 -44.625 509.3125 C-46.33055054 509.03031616 -46.33055054 509.03031616 -48.07055664 508.74243164 C-91.33802759 500.97044532 -132.38793216 480.98345309 -165.625 452.3125 C-166.37007813 451.67828125 -167.11515625 451.0440625 -167.8828125 450.390625 C-181.38779833 438.57948485 -194.05918743 425.82889772 -204.625 411.3125 C-205.48149066 410.16255169 -206.33826433 409.01281411 -207.1953125 407.86328125 C-213.23534209 399.67315742 -218.61867614 391.16870382 -223.625 382.3125 C-224.0271875 381.60528809 -224.429375 380.89807617 -224.84375 380.16943359 C-237.8691741 357.03404164 -246.61801461 331.86287591 -251.9375 305.875 C-252.08485596 305.15610596 -252.23221191 304.43721191 -252.3840332 303.6965332 C-255.35789088 288.05024391 -255.99800362 272.57681954 -255.9375 256.6875 C-255.93534485 255.66959381 -255.9331897 254.65168762 -255.93096924 253.60293579 C-255.88129854 238.99386477 -255.63040191 224.66174147 -252.625 210.3125 C-252.42632324 209.33764648 -252.22764648 208.36279297 -252.02294922 207.35839844 C-241.90232806 158.9765226 -219.9858123 114.98821731 -185.625 79.3125 C-184.99287598 78.65314453 -184.36075195 77.99378906 -183.70947266 77.31445312 C-174.56502521 67.82619695 -165.26765991 59.0862956 -154.625 51.3125 C-153.47394983 50.45443848 -152.32290823 49.59636549 -151.171875 48.73828125 C-142.98224019 42.70072604 -134.47961724 37.31792698 -125.625 32.3125 C-124.91778809 31.9103125 -124.21057617 31.508125 -123.48193359 31.09375 C-100.34654164 18.0683259 -75.17537591 9.31948539 -49.1875 4 C-48.46860596 3.85264404 -47.74971191 3.70528809 -47.0090332 3.5534668 C-31.36274391 0.57960912 -15.88931954 -0.06050362 0 0 Z M-132.625 100.3125 C-133.86636719 101.38757813 -133.86636719 101.38757813 -135.1328125 102.484375 C-165.63578284 129.44938326 -187.86448496 164.37287727 -198.01196289 203.92382812 C-198.4561255 205.654488 -198.91264545 207.38199177 -199.37670898 209.10742188 C-206.39011482 235.81273152 -206.57174532 264.26106754 -201.625 291.3125 C-201.5022168 292.01439453 -201.37943359 292.71628906 -201.25292969 293.43945312 C-191.91867325 346.46214396 -160.92344717 393.42130233 -117.23217773 424.5078125 C-89.1520157 444.03658082 -57.46503366 455.81316968 -23.625 460.3125 C-22.6246875 460.4465625 -21.624375 460.580625 -20.59375 460.71875 C33.68154914 466.46554638 87.79464708 449.50556098 130.05859375 415.375 C172.73532881 380.04170204 197.99757236 331.33192114 204.83984375 276.59375 C205.14411827 273.15919925 205.29014212 269.75985138 205.375 266.3125 C205.40078125 265.47734863 205.4265625 264.64219727 205.453125 263.78173828 C207.08252124 205.19475973 185.21292028 153.59303729 145.375 111.3125 C144.28251953 110.13880859 144.28251953 110.13880859 143.16796875 108.94140625 C136.81651093 102.33070526 129.68927931 96.80369409 122.375 91.3125 C121.54226563 90.68472656 120.70953125 90.05695313 119.8515625 89.41015625 C44.39149597 34.6822923 -62.3071717 39.41419387 -132.625 100.3125 Z " fill="#000000" transform="translate(255.625,-0.3125)"/>
|
||||
<path d="M0 0 C0.71623535 0.07524902 1.4324707 0.15049805 2.17041016 0.22802734 C11.41582457 1.31558193 17.53905875 5.69555803 24.37939453 11.60302734 C25.9717539 12.97565167 27.58779464 14.31330925 29.21484375 15.64453125 C39.45353655 24.07849539 49.89302189 32.86357336 58.390625 43.0859375 C59.52771819 44.43830561 60.70999313 45.7532854 61.921875 47.0390625 C78.86561041 65.03405157 92.54732646 86.67485183 103 109 C103.45761719 109.94875 103.91523438 110.8975 104.38671875 111.875 C105.65363083 114.56069375 106.84247298 117.26538924 108 120 C108.70576172 121.65902344 108.70576172 121.65902344 109.42578125 123.3515625 C115.29775985 137.75748334 119.44802749 153.51162791 121 169 C121.1393396 170.13598633 121.1393396 170.13598633 121.28149414 171.29492188 C123.04224228 187.01734848 123.04224228 187.01734848 118.6640625 192.58203125 C115.46027156 195.86702306 111.77976561 198.42626256 108 201 C106.70961638 201.94695663 105.4218661 202.89751165 104.13671875 203.8515625 C54.19558639 240.19218217 -10.15836211 250.82403206 -70.52783203 244.24707031 C-72.70701376 244.02928126 -74.87191381 243.88318722 -77.05859375 243.7734375 C-87.15317474 242.98567756 -93.49567628 238.26267641 -101 232 C-101.5775 231.5270752 -102.155 231.05415039 -102.75 230.56689453 C-113.80888326 221.47972622 -125.20747742 211.94987788 -134.3828125 200.92578125 C-136.24364652 198.70986054 -138.21467717 196.61674763 -140.1875 194.5 C-161.40351648 170.90663702 -177.09305866 142.66632254 -188 113 C-188.40734375 111.93007812 -188.8146875 110.86015625 -189.234375 109.7578125 C-194.48827127 95.16814672 -202.71297728 67.59681209 -195.89453125 52.5625 C-194.07223789 50.69071225 -192.19999029 49.41220499 -190 48 C-188.52096885 46.81314803 -187.04236305 45.62573122 -185.57055664 44.42993164 C-163.09739012 26.47222869 -136.79512656 14.12849151 -109 7 C-107.47898826 6.58659334 -105.95816766 6.17248301 -104.4375 5.7578125 C-70.37699256 -3.34276097 -34.91038892 -3.88896444 0 0 Z " fill="#010101" transform="translate(294,134)"/>
|
||||
<path d="M0 0 C0.86409988 -0.0033284 1.72819977 -0.0066568 2.6184845 -0.01008606 C4.43616205 -0.0151155 6.2538487 -0.01749563 8.0715332 -0.01733398 C10.80761482 -0.01951182 13.54328676 -0.03768671 16.27929688 -0.05664062 C26.27760684 -0.08904371 35.78646484 0.35879187 45.64453125 2.16796875 C46.42820068 2.2980835 47.21187012 2.42819824 48.01928711 2.56225586 C67.48091134 5.91708002 86.4354731 13.58908312 103.64453125 23.16796875 C104.27649414 23.51827148 104.90845703 23.86857422 105.55957031 24.22949219 C119.56180482 32.08853758 131.62289424 41.70235966 143.20019531 52.78662109 C144.48254743 54.01304914 145.77750786 55.22626362 147.07421875 56.4375 C160.46125565 69.27666884 171.07943638 85.21359379 179.83203125 101.48046875 C180.23115723 102.21628174 180.6302832 102.95209473 181.04150391 103.71020508 C181.97584067 105.49613991 182.81943883 107.32900956 183.64453125 109.16796875 C183.31453125 109.82796875 182.98453125 110.48796875 182.64453125 111.16796875 C181.77828125 110.23984375 180.91203125 109.31171875 180.01953125 108.35546875 C151.77719444 80.22655498 111.24847041 62.18954649 72.55981445 54.76806641 C66.66500207 53.55440703 62.13035714 50.73600667 57.08203125 47.54296875 C55.29699984 46.44819083 53.51053116 45.35575303 51.72265625 44.265625 C50.85318359 43.73340332 49.98371094 43.20118164 49.08789062 42.65283203 C16.63913044 22.93327173 -19.64835388 13.71677996 -57.35546875 13.16796875 C-51.06422023 6.87672023 -37.42242063 5.35193024 -28.98046875 3.41796875 C-28.06620117 3.20744873 -27.15193359 2.99692871 -26.20996094 2.7800293 C-17.46378901 0.84110933 -8.95226967 0.0123532 0 0 Z " fill="#030303" transform="translate(244.35546875,67.83203125)"/>
|
||||
<path d="M0 0 C1.3921875 1.2065625 1.3921875 1.2065625 2.8125 2.4375 C17.70081221 15.15211863 32.63519217 25.94043322 50 35 C50.65210449 35.34772461 51.30420898 35.69544922 51.97607422 36.05371094 C68.61826436 44.84435504 87.93410811 51.6622374 106.5 54.8125 C117.0495917 56.69262525 125.60341083 62.82215102 134.53027344 68.45361328 C166.82331519 88.67347552 203.35131846 97.02260308 241 98 C234.72437292 104.27562708 221.11199545 105.81088155 212.6875 107.75 C211.78161133 107.96052002 210.87572266 108.17104004 209.94238281 108.38793945 C198.42674064 110.9501174 187.15547289 111.18641307 175.40698242 111.18530273 C172.83560399 111.18748043 170.2646618 111.20566475 167.69335938 111.22460938 C157.60706378 111.25933256 147.96699734 110.68684053 138 109 C137.17717529 108.86456787 136.35435059 108.72913574 135.5065918 108.58959961 C99.40933725 102.18890888 66.71597085 83.54843429 40.46044922 58.39746094 C39.17158965 57.16418909 37.86994193 55.94431172 36.56640625 54.7265625 C21.17388217 39.96510447 9.14707063 21.15582183 0 2 C0 1.34 0 0.68 0 0 Z " fill="#030303" transform="translate(84,333)"/>
|
||||
<path d="M0 0 C2.99943905 1.34572887 5.08076179 2.83537101 7.39160156 5.16455078 C8.0319635 5.80506378 8.67232544 6.44557678 9.33209229 7.10549927 C10.01140808 7.79513763 10.69072388 8.484776 11.390625 9.1953125 C12.09523865 9.90275604 12.79985229 10.61019958 13.52581787 11.33908081 C15.7719539 13.59639862 18.01103009 15.86057633 20.25 18.125 C21.77297717 19.65800953 23.29640939 21.19056715 24.8203125 22.72265625 C28.55216815 26.47654573 32.27825351 30.23608914 36 34 C40.8277897 29.9845347 45.3437641 25.77173317 49.73828125 21.2890625 C50.98229394 20.03242542 52.22643824 18.77591862 53.47070312 17.51953125 C55.40594863 15.55930686 57.33953935 13.59749882 59.27075195 11.63330078 C61.15145313 9.72257458 63.03818159 7.81797036 64.92578125 5.9140625 C65.50596512 5.32041901 66.08614899 4.72677551 66.68391418 4.11514282 C68.08510986 2.70514466 69.53810801 1.34696546 71 0 C73 0 73 0 74.62109375 1.609375 C75.22050781 2.31578125 75.81992187 3.0221875 76.4375 3.75 C77.03433594 4.44609375 77.63117188 5.1421875 78.24609375 5.859375 C79.88667532 7.86168836 81.44684965 9.92913287 83 12 C74.42 20.58 65.84 29.16 57 38 C86.7 38.495 86.7 38.495 117 39 C117 44.28 117 49.56 117 55 C97.53 55.33 78.06 55.66 58 56 C66.25 64.25 74.5 72.5 83 81 C81.16614077 84.66771845 79.74937171 86.46834996 76.875 89.25 C76.15054688 89.95640625 75.42609375 90.6628125 74.6796875 91.390625 C74.12539063 91.92171875 73.57109375 92.4528125 73 93 C70.00056095 91.65427113 67.91923821 90.16462899 65.60839844 87.83544922 C64.64785553 86.87467972 64.64785553 86.87467972 63.66790771 85.89450073 C62.98859192 85.20486237 62.30927612 84.515224 61.609375 83.8046875 C60.90476135 83.09724396 60.20014771 82.38980042 59.47418213 81.66091919 C57.2280461 79.40360138 54.98896991 77.13942367 52.75 74.875 C51.22702283 73.34199047 49.70359061 71.80943285 48.1796875 70.27734375 C44.44783185 66.52345427 40.72174649 62.76391086 37 59 C32.16701194 63.02748053 27.61889996 67.24379413 23.19140625 71.7109375 C22.57982162 72.32393707 21.968237 72.93693665 21.33811951 73.56851196 C19.41052441 75.50164564 17.48646072 77.43824987 15.5625 79.375 C14.24567596 80.69684699 12.92862074 82.01846372 11.61132812 83.33984375 C8.40487627 86.55724299 5.20158371 89.77775681 2 93 C-1.41059403 91.39464713 -3.61792484 89.59481099 -6.25 86.875 C-6.95640625 86.15054688 -7.6628125 85.42609375 -8.390625 84.6796875 C-8.92171875 84.12539063 -9.4528125 83.57109375 -10 83 C-8.41570736 79.09154201 -5.73402218 76.47642804 -2.7734375 73.578125 C-1.84450684 72.65418945 -0.91557617 71.73025391 0.04150391 70.77832031 C1.22405762 69.61397461 2.40661133 68.44962891 3.625 67.25 C7.37875 63.5375 11.1325 59.825 15 56 C-14.205 55.505 -14.205 55.505 -44 55 C-44 49.72 -44 44.44 -44 39 C-24.2 38.67 -4.4 38.34 16 38 C7.42 29.42 -1.16 20.84 -10 12 C-8.02 9.69 -6.04 7.38 -4 5 C-2.65807132 3.34024611 -1.32090525 1.67653359 0 0 Z " fill="#FBFBFB" transform="translate(220,210)"/>
|
||||
<path d="M0 0 C0.66 0 1.32 0 2 0 C2.10320557 0.85489014 2.10320557 0.85489014 2.20849609 1.72705078 C7.84197412 45.87779718 28.1939551 86.6195367 56 121 C56.42941895 121.53383301 56.85883789 122.06766602 57.30126953 122.61767578 C66.40690991 133.83981661 76.68709229 143.89775823 87 154 C85 155 85 155 83.08325195 154.41430664 C82.30941162 154.08293701 81.53557129 153.75156738 80.73828125 153.41015625 C79.44216919 152.85835693 79.44216919 152.85835693 78.11987305 152.29541016 C77.19343994 151.8885498 76.26700684 151.48168945 75.3125 151.0625 C74.36221924 150.64790527 73.41193848 150.23331055 72.43286133 149.80615234 C39.14146569 135.09423719 7.08477782 112.00144189 -6.8203125 77.16015625 C-7.20960937 76.11730469 -7.59890625 75.07445312 -8 74 C-8.32742187 73.13246094 -8.65484375 72.26492188 -8.9921875 71.37109375 C-16.36268399 50.62841076 -12.86150198 26.97666813 -3.75 7.5625 C-2.52169606 5.02980009 -1.26750366 2.51347207 0 0 Z " fill="#040404" transform="translate(80,210)"/>
|
||||
<path d="M0 0 C1.35764345 4.07293036 -0.06910614 6.67744348 -1.5625 10.5 C-1.97749756 11.57531982 -1.97749756 11.57531982 -2.40087891 12.67236328 C-6.47155806 22.9772079 -11.68698526 31.65423127 -19 40 C-19.59296875 40.68835938 -20.1859375 41.37671875 -20.796875 42.0859375 C-35.31857853 57.78675371 -58.56153437 66.83592642 -79.57324219 68.23852539 C-83.59327999 68.37727956 -87.60297433 68.43877365 -91.625 68.4375 C-92.70432297 68.43910126 -92.70432297 68.43910126 -93.80545044 68.44073486 C-120.19044633 68.3846361 -144.22754501 60.94698881 -168 50 C-168 49.67 -168 49.34 -168 49 C-167.23671387 48.98018066 -166.47342773 48.96036133 -165.68701172 48.93994141 C-135.41473945 48.1185831 -105.01412697 46.60137763 -75.89160156 37.58056641 C-74.17234947 37.05289714 -72.4420738 36.56148633 -70.7109375 36.07421875 C-45.39043035 28.79857935 -20.43492118 16.73681044 0 0 Z " fill="#040404" transform="translate(411,347)"/>
|
||||
<path d="M0 0 C36.3336157 11.02984762 71.78280942 35.12814008 90.01855469 69.06640625 C96.50040978 81.83662823 99.34867561 93.65420512 99.25 107.9375 C99.26160156 109.61102539 99.26160156 109.61102539 99.2734375 111.31835938 C99.25018103 121.24305766 97.70881388 129.9522873 94.125 139.1875 C93.88136719 139.84685547 93.63773437 140.50621094 93.38671875 141.18554688 C91.65503117 145.7390777 89.50393006 149.82136348 87 154 C86.34 153.67 85.68 153.34 85 153 C84.50097656 151.18432617 84.50097656 151.18432617 84.140625 148.79296875 C83.99786133 147.90649658 83.85509766 147.02002441 83.70800781 146.10668945 C83.55686523 145.14335693 83.40572266 144.18002441 83.25 143.1875 C79.16249798 119.24447032 70.31517306 97.3963538 59 76 C58.48832275 75.02450195 58.48832275 75.02450195 57.96630859 74.02929688 C47.301887 53.8627614 33.39273041 35.47640491 17.7265625 18.9375 C15.86858655 16.97059803 14.11773128 14.95405655 12.41015625 12.85546875 C10.3273051 10.38777963 8.28787438 8.36653406 5.8125 6.3125 C3.31942756 4.23891203 1.82972289 2.74458434 0 0 Z " fill="#040404" transform="translate(345,147)"/>
|
||||
<path d="M0 0 C1.26231445 0.00491455 2.52462891 0.0098291 3.82519531 0.01489258 C24.5153613 0.27226578 42.86797287 5.22314233 62.125 12.4375 C63.02718262 12.77418701 63.92936523 13.11087402 64.85888672 13.45776367 C66.12949951 13.94660034 66.12949951 13.94660034 67.42578125 14.4453125 C68.55173706 14.87674561 68.55173706 14.87674561 69.70043945 15.31689453 C71.99402845 16.3980708 73.83827422 17.73010316 75.8125 19.3125 C74.90935059 19.3218457 74.00620117 19.33119141 73.07568359 19.34082031 C46.66373358 19.68118909 20.60587941 21.22322233 -5.11425781 27.79150391 C-7.27539495 28.33458759 -9.44421442 28.83079049 -11.6171875 29.32421875 C-39.00925704 35.7635841 -65.52353508 48.25154961 -88.08203125 65.01171875 C-91.1875 67.3125 -91.1875 67.3125 -93.1875 68.3125 C-92.18113049 53.55241391 -82.67422534 39.13895373 -73.1875 28.3125 C-72.59453125 27.62414062 -72.0015625 26.93578125 -71.390625 26.2265625 C-53.12979089 6.48301109 -25.93856094 -0.12102791 0 0 Z " fill="#030303" transform="translate(193.1875,96.6875)"/>
|
||||
<path d="M0 0 C0.99 0.33 1.98 0.66 3 1 C2.34 1.66 1.68 2.32 1 3 C0.67 2.01 0.34 1.02 0 0 Z M-9 10 C-9 13 -9 13 -9 13 Z " fill="#E8E8E8" transform="translate(220,210)"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 20 KiB |
@@ -9,6 +9,7 @@ export interface UpdateMetricMetadataProps {
|
||||
metricType: MetricType;
|
||||
temporality?: Temporality;
|
||||
isMonotonic?: boolean;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface UpdateMetricMetadataResponse {
|
||||
|
||||
@@ -8,7 +8,7 @@ const setRetentionV2 = async ({
|
||||
type,
|
||||
defaultTTLDays,
|
||||
coldStorageVolume,
|
||||
coldStorageDuration,
|
||||
coldStorageDurationDays,
|
||||
ttlConditions,
|
||||
}: PropsV2): Promise<SuccessResponseV2<PayloadPropsV2>> => {
|
||||
try {
|
||||
@@ -16,7 +16,7 @@ const setRetentionV2 = async ({
|
||||
type,
|
||||
defaultTTLDays,
|
||||
coldStorageVolume,
|
||||
coldStorageDuration,
|
||||
coldStorageDurationDays,
|
||||
ttlConditions,
|
||||
});
|
||||
|
||||
|
||||
@@ -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)`
|
||||
|
||||
@@ -153,7 +153,9 @@ export const useTableView = (props: UseTableViewProps): UseTableViewResult => {
|
||||
children: (
|
||||
<TableBodyContent
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: getSanitizedLogBody(field as string),
|
||||
__html: getSanitizedLogBody(field as string, {
|
||||
shouldEscapeHtml: true,
|
||||
}),
|
||||
}}
|
||||
fontSize={fontSize}
|
||||
linesPerRow={linesPerRow}
|
||||
|
||||
@@ -32,6 +32,7 @@ import { popupContainer } from 'utils/selectPopupContainer';
|
||||
|
||||
import { CustomMultiSelectProps, CustomTagProps, OptionData } from './types';
|
||||
import {
|
||||
ALL_SELECTED_VALUE,
|
||||
filterOptionsBySearch,
|
||||
handleScrollToBottom,
|
||||
prioritizeOrAddOptionForMultiSelect,
|
||||
@@ -43,8 +44,6 @@ enum ToggleTagValue {
|
||||
All = 'All',
|
||||
}
|
||||
|
||||
const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
|
||||
|
||||
const CustomMultiSelect: React.FC<CustomMultiSelectProps> = ({
|
||||
placeholder = 'Search...',
|
||||
className,
|
||||
|
||||
@@ -5,6 +5,8 @@ import { OptionData } from './types';
|
||||
|
||||
export const SPACEKEY = ' ';
|
||||
|
||||
export const ALL_SELECTED_VALUE = '__ALL__'; // Constant for the special value
|
||||
|
||||
export const prioritizeOrAddOptionForSingleSelect = (
|
||||
options: OptionData[],
|
||||
value: string,
|
||||
|
||||
@@ -2,7 +2,8 @@ import './MetricsSelect.styles.scss';
|
||||
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { memo } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
export const MetricsSelect = memo(function MetricsSelect({
|
||||
@@ -16,19 +17,28 @@ export const MetricsSelect = memo(function MetricsSelect({
|
||||
version: string;
|
||||
signalSource: 'meter' | '';
|
||||
}): JSX.Element {
|
||||
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
|
||||
const { handleChangeAggregatorAttribute } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
const handleAggregatorAttributeChange = useCallback(
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean) => {
|
||||
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
|
||||
},
|
||||
[handleChangeAggregatorAttribute, attributeKeys],
|
||||
);
|
||||
return (
|
||||
<div className="metrics-select-container">
|
||||
<AggregatorFilter
|
||||
onChange={handleChangeAggregatorAttribute}
|
||||
onChange={handleAggregatorAttributeChange}
|
||||
query={query}
|
||||
index={index}
|
||||
signalSource={signalSource || ''}
|
||||
setAttributeKeys={setAttributeKeys}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -500,7 +500,10 @@ function QueryAddOns({
|
||||
}
|
||||
value={addOn}
|
||||
>
|
||||
<div className="add-on-tab-title">
|
||||
<div
|
||||
className="add-on-tab-title"
|
||||
data-testid={`query-add-on-${addOn.key}`}
|
||||
>
|
||||
{addOn.icon}
|
||||
{addOn.label}
|
||||
</div>
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
.filter-separator {
|
||||
height: 1px;
|
||||
background-color: var(--bg-slate-400);
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -177,6 +183,12 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.values {
|
||||
.filter-separator {
|
||||
background-color: var(--bg-vanilla-300);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { FiltersType, QuickFiltersSource } from 'components/QuickFilters/types';
|
||||
import { useGetAggregateValues } from 'hooks/queryBuilder/useGetAggregateValues';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQueryKeyValueSuggestions';
|
||||
import { quickFiltersAttributeValuesResponse } from 'mocks-server/__mockdata__/customQuickFilters';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { UseQueryResult } from 'react-query';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { SuccessResponse } from 'types/api';
|
||||
import { IAttributeValuesResponse } from 'types/api/queryBuilder/getAttributesValues';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import CheckboxFilter from './Checkbox';
|
||||
|
||||
// Mock the query builder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder');
|
||||
const mockUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
|
||||
// Mock the aggregate values hook
|
||||
jest.mock('hooks/queryBuilder/useGetAggregateValues');
|
||||
|
||||
const mockUseGetAggregateValues = jest.mocked(useGetAggregateValues);
|
||||
|
||||
// Mock the key value suggestions hook
|
||||
jest.mock('hooks/querySuggestions/useGetQueryKeyValueSuggestions');
|
||||
|
||||
const mockUseGetQueryKeyValueSuggestions = jest.mocked(
|
||||
useGetQueryKeyValueSuggestions,
|
||||
);
|
||||
|
||||
interface MockFilterConfig {
|
||||
title: string;
|
||||
attributeKey: {
|
||||
key: string;
|
||||
dataType: DataTypes;
|
||||
type: string;
|
||||
};
|
||||
dataSource: DataSource;
|
||||
defaultOpen: boolean;
|
||||
type: FiltersType;
|
||||
}
|
||||
|
||||
const createMockFilter = (
|
||||
overrides: Partial<MockFilterConfig> = {},
|
||||
): MockFilterConfig => ({
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
title: 'Service Name',
|
||||
attributeKey: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
dataSource: DataSource.LOGS,
|
||||
defaultOpen: false,
|
||||
type: FiltersType.CHECKBOX,
|
||||
...overrides,
|
||||
});
|
||||
|
||||
const createMockQueryBuilderData = (hasActiveFilters = false): any => ({
|
||||
lastUsedQuery: 0,
|
||||
currentQuery: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
filters: {
|
||||
items: hasActiveFilters
|
||||
? [
|
||||
{
|
||||
key: {
|
||||
key: 'service.name',
|
||||
dataType: DataTypes.String,
|
||||
type: 'resource',
|
||||
},
|
||||
op: 'in',
|
||||
value: ['otel-demo', 'sample-flask'],
|
||||
},
|
||||
]
|
||||
: [],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
redirectWithQueryBuilderData: jest.fn(),
|
||||
});
|
||||
|
||||
describe('CheckboxFilter - User Flows', () => {
|
||||
beforeEach(() => {
|
||||
// Reset all mocks
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Default mock implementations using the same structure as existing tests
|
||||
mockUseGetAggregateValues.mockReturnValue({
|
||||
data: {
|
||||
payload: {
|
||||
stringAttributeValues: [
|
||||
'mq-kafka',
|
||||
'otel-demo',
|
||||
'otlp-python',
|
||||
'sample-flask',
|
||||
],
|
||||
},
|
||||
},
|
||||
isLoading: false,
|
||||
} as UseQueryResult<SuccessResponse<IAttributeValuesResponse>>);
|
||||
|
||||
mockUseGetQueryKeyValueSuggestions.mockReturnValue({
|
||||
data: null,
|
||||
isLoading: false,
|
||||
} as any);
|
||||
|
||||
// Setup MSW server for API calls
|
||||
server.use(
|
||||
rest.get('*/api/v3/autocomplete/attribute_values', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(quickFiltersAttributeValuesResponse)),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
it('should auto-open filter and prioritize checked items with visual separator when user opens page with active filters', async () => {
|
||||
// Mock query builder with active filters
|
||||
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: false });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// User should see the filter is automatically opened (not collapsed)
|
||||
expect(screen.getByText('Service Name')).toBeInTheDocument();
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User should see visual separator between checked and unchecked items
|
||||
expect(screen.getByTestId('filter-separator')).toBeInTheDocument();
|
||||
|
||||
// User should see checked items at the top
|
||||
await waitFor(() => {
|
||||
const checkboxes = screen.getAllByRole('checkbox');
|
||||
expect(checkboxes).toHaveLength(4); // Ensure we have exactly 4 checkboxes
|
||||
expect(checkboxes[0]).toBeChecked(); // otel-demo should be first and checked
|
||||
expect(checkboxes[1]).toBeChecked(); // sample-flask should be second and checked
|
||||
expect(checkboxes[2]).not.toBeChecked(); // mq-kafka should be unchecked
|
||||
expect(checkboxes[3]).not.toBeChecked(); // otlp-python should be unchecked
|
||||
});
|
||||
});
|
||||
|
||||
it('should respect user preference when user manually toggles filter over auto-open behavior', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Mock query builder with active filters
|
||||
mockUseQueryBuilder.mockReturnValue(createMockQueryBuilderData(true) as any);
|
||||
|
||||
const mockFilter = createMockFilter({ defaultOpen: false });
|
||||
|
||||
render(
|
||||
<CheckboxFilter
|
||||
filter={mockFilter}
|
||||
source={QuickFiltersSource.LOGS_EXPLORER}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Initially auto-opened due to active filters
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// User manually closes the filter
|
||||
await user.click(screen.getByText('Service Name'));
|
||||
|
||||
// User should see filter is now closed (respecting user preference)
|
||||
expect(
|
||||
screen.queryByPlaceholderText('Filter values'),
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
// User manually opens the filter again
|
||||
await user.click(screen.getByText('Service Name'));
|
||||
|
||||
// User should see filter is now open (respecting user preference)
|
||||
await waitFor(() => {
|
||||
expect(screen.getByPlaceholderText('Filter values')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -21,7 +21,7 @@ import { useGetQueryKeyValueSuggestions } from 'hooks/querySuggestions/useGetQue
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { cloneDeep, isArray, isEqual, isFunction } from 'lodash-es';
|
||||
import { ChevronDown, ChevronRight } from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { Fragment, useMemo, useState } from 'react';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
@@ -54,7 +54,8 @@ interface ICheckboxProps {
|
||||
export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const { source, filter, onFilterChange } = props;
|
||||
const [searchText, setSearchText] = useState<string>('');
|
||||
const [isOpen, setIsOpen] = useState<boolean>(filter.defaultOpen);
|
||||
// null = no user action, true = user opened, false = user closed
|
||||
const [userToggleState, setUserToggleState] = useState<boolean | null>(null);
|
||||
const [visibleItemsCount, setVisibleItemsCount] = useState<number>(10);
|
||||
|
||||
const {
|
||||
@@ -63,6 +64,33 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
redirectWithQueryBuilderData,
|
||||
} = useQueryBuilder();
|
||||
|
||||
// Check if this filter has active filters in the query
|
||||
const isSomeFilterPresentForCurrentAttribute = useMemo(
|
||||
() =>
|
||||
currentQuery.builder.queryData?.[
|
||||
lastUsedQuery || 0
|
||||
]?.filters?.items?.some((item) =>
|
||||
isEqual(item.key?.key, filter.attributeKey.key),
|
||||
),
|
||||
[currentQuery.builder.queryData, lastUsedQuery, filter.attributeKey.key],
|
||||
);
|
||||
|
||||
// Derive isOpen from filter state + user action
|
||||
const isOpen = useMemo(() => {
|
||||
// If user explicitly toggled, respect that
|
||||
if (userToggleState !== null) return userToggleState;
|
||||
|
||||
// Auto-open if this filter has active filters in the query
|
||||
if (isSomeFilterPresentForCurrentAttribute) return true;
|
||||
|
||||
// Otherwise use default behavior (first 2 filters open)
|
||||
return filter.defaultOpen;
|
||||
}, [
|
||||
userToggleState,
|
||||
isSomeFilterPresentForCurrentAttribute,
|
||||
filter.defaultOpen,
|
||||
]);
|
||||
|
||||
const { data, isLoading } = useGetAggregateValues(
|
||||
{
|
||||
aggregateOperator: filter.aggregateOperator || 'noop',
|
||||
@@ -128,8 +156,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
);
|
||||
}, [data?.payload, filter.attributeKey.dataType, keyValueSuggestions, source]);
|
||||
|
||||
const currentAttributeKeys = attributeValues.slice(0, visibleItemsCount);
|
||||
|
||||
const setSearchTextDebounced = useDebouncedFn((...args) => {
|
||||
setSearchText(args[0] as string);
|
||||
}, DEBOUNCE_DELAY);
|
||||
@@ -202,6 +228,23 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
const isMultipleValuesTrueForTheKey =
|
||||
Object.values(currentFilterState).filter((val) => val).length > 1;
|
||||
|
||||
// Sort checked items to the top, then unchecked items
|
||||
const currentAttributeKeys = useMemo(() => {
|
||||
const checkedValues = attributeValues.filter(
|
||||
(val) => currentFilterState[val],
|
||||
);
|
||||
const uncheckedValues = attributeValues.filter(
|
||||
(val) => !currentFilterState[val],
|
||||
);
|
||||
return [...checkedValues, ...uncheckedValues].slice(0, visibleItemsCount);
|
||||
}, [attributeValues, currentFilterState, visibleItemsCount]);
|
||||
|
||||
// Count of checked values in the currently visible items
|
||||
const checkedValuesCount = useMemo(
|
||||
() => currentAttributeKeys.filter((val) => currentFilterState[val]).length,
|
||||
[currentAttributeKeys, currentFilterState],
|
||||
);
|
||||
|
||||
const handleClearFilterAttribute = (): void => {
|
||||
const preparedQuery: Query = {
|
||||
...currentQuery,
|
||||
@@ -235,12 +278,6 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
}
|
||||
};
|
||||
|
||||
const isSomeFilterPresentForCurrentAttribute = currentQuery.builder.queryData?.[
|
||||
lastUsedQuery || 0
|
||||
]?.filters?.items?.some((item) =>
|
||||
isEqual(item.key?.key, filter.attributeKey.key),
|
||||
);
|
||||
|
||||
const onChange = (
|
||||
value: string,
|
||||
checked: boolean,
|
||||
@@ -490,10 +527,10 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
className="filter-header-checkbox"
|
||||
onClick={(): void => {
|
||||
if (isOpen) {
|
||||
setIsOpen(false);
|
||||
setUserToggleState(false);
|
||||
setVisibleItemsCount(10);
|
||||
} else {
|
||||
setIsOpen(true);
|
||||
setUserToggleState(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
@@ -540,50 +577,59 @@ export default function CheckboxFilter(props: ICheckboxProps): JSX.Element {
|
||||
)}
|
||||
{attributeValues.length > 0 ? (
|
||||
<section className="values">
|
||||
{currentAttributeKeys.map((value: string) => (
|
||||
<div key={value} className="value">
|
||||
<Checkbox
|
||||
onChange={(e): void => onChange(value, e.target.checked, false)}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
rootClassName="check-box"
|
||||
/>
|
||||
{currentAttributeKeys.map((value: string, index: number) => (
|
||||
<Fragment key={value}>
|
||||
{index === checkedValuesCount && checkedValuesCount > 0 && (
|
||||
<div
|
||||
key="separator"
|
||||
className="filter-separator"
|
||||
data-testid="filter-separator"
|
||||
/>
|
||||
)}
|
||||
<div className="value">
|
||||
<Checkbox
|
||||
onChange={(e): void => onChange(value, e.target.checked, false)}
|
||||
checked={currentFilterState[value]}
|
||||
disabled={isFilterDisabled}
|
||||
rootClassName="check-box"
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="value-string"
|
||||
ellipsis={{ tooltip: { placement: 'right' } }}
|
||||
>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
<div
|
||||
className={cx(
|
||||
'checkbox-value-section',
|
||||
isFilterDisabled ? 'filter-disabled' : '',
|
||||
)}
|
||||
onClick={(): void => {
|
||||
if (isFilterDisabled) {
|
||||
return;
|
||||
}
|
||||
onChange(value, currentFilterState[value], true);
|
||||
}}
|
||||
>
|
||||
<div className={`${filter.title} label-${value}`} />
|
||||
{filter.customRendererForValue ? (
|
||||
filter.customRendererForValue(value)
|
||||
) : (
|
||||
<Typography.Text
|
||||
className="value-string"
|
||||
ellipsis={{ tooltip: { placement: 'right' } }}
|
||||
>
|
||||
{String(value)}
|
||||
</Typography.Text>
|
||||
)}
|
||||
<Button type="text" className="only-btn">
|
||||
{isSomeFilterPresentForCurrentAttribute
|
||||
? currentFilterState[value] && !isMultipleValuesTrueForTheKey
|
||||
? 'All'
|
||||
: 'Only'
|
||||
: 'Only'}
|
||||
</Button>
|
||||
<Button type="text" className="toggle-btn">
|
||||
Toggle
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Fragment>
|
||||
))}
|
||||
</section>
|
||||
) : isEmptyStateWithDocsEnabled ? (
|
||||
|
||||
@@ -87,6 +87,7 @@ function SpanHoverCard({
|
||||
</Typography.Text>
|
||||
</div>
|
||||
}
|
||||
mouseEnterDelay={0.5}
|
||||
content={getContent()}
|
||||
trigger="hover"
|
||||
rootClassName="span-hover-card"
|
||||
|
||||
@@ -0,0 +1,258 @@
|
||||
import { act, fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Span } from 'types/api/trace/getTraceV2';
|
||||
|
||||
import SpanHoverCard from '../SpanHoverCard';
|
||||
|
||||
// Mock dayjs completely for testing
|
||||
jest.mock('dayjs', () => {
|
||||
const mockDayjs = jest.fn(() => ({
|
||||
format: jest.fn((formatString: string) => {
|
||||
if (formatString === 'D/M/YY - HH:mm:ss') {
|
||||
return '15/3/24 - 14:23:45';
|
||||
}
|
||||
return 'mock-date';
|
||||
}),
|
||||
}));
|
||||
Object.assign(mockDayjs, {
|
||||
extend: jest.fn(),
|
||||
tz: { guess: jest.fn(() => 'UTC') },
|
||||
});
|
||||
return mockDayjs;
|
||||
});
|
||||
|
||||
const HOVER_ELEMENT_ID = 'hover-element';
|
||||
|
||||
const mockSpan: Span = {
|
||||
spanId: 'test-span-id',
|
||||
traceId: 'test-trace-id',
|
||||
rootSpanId: 'root-span-id',
|
||||
parentSpanId: 'parent-span-id',
|
||||
name: 'GET /api/users',
|
||||
timestamp: 1679748225000000,
|
||||
durationNano: 150000000,
|
||||
serviceName: 'user-service',
|
||||
kind: 1,
|
||||
hasError: false,
|
||||
level: 1,
|
||||
references: [],
|
||||
tagMap: {},
|
||||
event: [
|
||||
{
|
||||
name: 'event1',
|
||||
timeUnixNano: 1679748225100000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
{
|
||||
name: 'event2',
|
||||
timeUnixNano: 1679748225200000,
|
||||
attributeMap: {},
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
rootName: 'root-span',
|
||||
statusMessage: '',
|
||||
statusCodeString: 'OK',
|
||||
spanKind: 'server',
|
||||
hasChildren: false,
|
||||
hasSibling: false,
|
||||
subTreeNodeCount: 1,
|
||||
};
|
||||
|
||||
const mockTraceMetadata = {
|
||||
startTime: 1679748225000000,
|
||||
endTime: 1679748226000000,
|
||||
};
|
||||
|
||||
describe('SpanHoverCard', () => {
|
||||
beforeEach(() => {
|
||||
jest.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('renders child element correctly', () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid="child-element">Hover me</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('child-element')).toBeInTheDocument();
|
||||
expect(screen.getByText('Hover me')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows popover after 0.5 second delay on hover', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Hover for details</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover over the element
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Popover should NOT appear immediately
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Advance time by 0.5 seconds
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Now popover should appear
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show popover if hover is too brief', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Quick hover test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Quick hover and unhover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(200); // Only 0.2 seconds
|
||||
});
|
||||
fireEvent.mouseLeave(hoverElement);
|
||||
|
||||
// Advance past the full delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(400);
|
||||
});
|
||||
|
||||
// Popover should not appear
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays span information in popover content after delay', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Test span</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check that popover shows span operation name in title
|
||||
expect(screen.getByText('GET /api/users')).toBeInTheDocument();
|
||||
|
||||
// Check duration information
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
expect(screen.getByText('150ms')).toBeInTheDocument();
|
||||
|
||||
// Check events count
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
|
||||
// Check start time label
|
||||
expect(screen.getByText('Start time:')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays new date format with seconds', async () => {
|
||||
render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Date format test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Verify the new date format is displayed
|
||||
expect(screen.getByText('15/3/24 - 14:23:45')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('displays relative time information', async () => {
|
||||
const spanWithRelativeTime: Span = {
|
||||
...mockSpan,
|
||||
timestamp: mockTraceMetadata.startTime + 1000000, // 1 second later
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithRelativeTime} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Relative time test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
// Check relative time display
|
||||
expect(screen.getByText(/after trace start/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('handles spans with no events correctly', async () => {
|
||||
const spanWithoutEvents: Span = {
|
||||
...mockSpan,
|
||||
event: [],
|
||||
};
|
||||
|
||||
render(
|
||||
<SpanHoverCard span={spanWithoutEvents} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>No events test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
|
||||
// Hover and wait for popover
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
|
||||
expect(screen.getByText('Events:')).toBeInTheDocument();
|
||||
expect(screen.getByText('0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('verifies mouseEnterDelay prop is set to 0.5', () => {
|
||||
const { container } = render(
|
||||
<SpanHoverCard span={mockSpan} traceMetadata={mockTraceMetadata}>
|
||||
<div data-testid={HOVER_ELEMENT_ID}>Delay test</div>
|
||||
</SpanHoverCard>,
|
||||
);
|
||||
|
||||
// The mouseEnterDelay prop should be set on the Popover component
|
||||
// This test verifies the implementation includes the delay
|
||||
const popover = container.querySelector('.ant-popover');
|
||||
expect(popover).not.toBeInTheDocument(); // Initially not visible
|
||||
|
||||
// Hover to trigger delay mechanism
|
||||
const hoverElement = screen.getByTestId(HOVER_ELEMENT_ID);
|
||||
fireEvent.mouseEnter(hoverElement);
|
||||
|
||||
// Should not appear before delay
|
||||
expect(screen.queryByText('Duration:')).not.toBeInTheDocument();
|
||||
|
||||
// Should appear after delay
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(500);
|
||||
});
|
||||
expect(screen.getByText('Duration:')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -18,11 +18,6 @@ import UPlot from 'uplot';
|
||||
|
||||
import { dataMatch, optionsUpdateState } from './utils';
|
||||
|
||||
// Extended uPlot interface with custom properties
|
||||
interface ExtendedUPlot extends uPlot {
|
||||
_legendScrollCleanup?: () => void;
|
||||
}
|
||||
|
||||
export interface UplotProps {
|
||||
options: uPlot.Options;
|
||||
data: uPlot.AlignedData;
|
||||
@@ -71,12 +66,6 @@ const Uplot = forwardRef<ToggleGraphProps | undefined, UplotProps>(
|
||||
|
||||
const destroy = useCallback((chart: uPlot | null) => {
|
||||
if (chart) {
|
||||
// Clean up legend scroll event listener
|
||||
const extendedChart = chart as ExtendedUPlot;
|
||||
if (extendedChart._legendScrollCleanup) {
|
||||
extendedChart._legendScrollCleanup();
|
||||
}
|
||||
|
||||
onDeleteRef.current?.(chart);
|
||||
chart.destroy();
|
||||
chartRef.current = null;
|
||||
|
||||
@@ -12,6 +12,7 @@ function YAxisUnitSelector({
|
||||
onChange,
|
||||
placeholder = 'Please select a unit',
|
||||
loading = false,
|
||||
'data-testid': dataTestId,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
const universalUnit = mapMetricUnitToUniversalUnit(value);
|
||||
|
||||
@@ -45,6 +46,7 @@ function YAxisUnitSelector({
|
||||
placeholder={placeholder}
|
||||
filterOption={(input, option): boolean => handleSearch(input, option)}
|
||||
loading={loading}
|
||||
data-testid={dataTestId}
|
||||
>
|
||||
{Y_AXIS_CATEGORIES.map((category) => (
|
||||
<Select.OptGroup key={category.name} label={category.name}>
|
||||
|
||||
@@ -4,6 +4,7 @@ export interface YAxisUnitSelectorProps {
|
||||
placeholder?: string;
|
||||
loading?: boolean;
|
||||
disabled?: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export enum UniversalYAxisUnit {
|
||||
|
||||
@@ -29,7 +29,7 @@ export const DATE_TIME_FORMATS = {
|
||||
DATE_SHORT: 'MM/DD',
|
||||
YEAR_SHORT: 'YY',
|
||||
YEAR_MONTH: 'YY-MM',
|
||||
SPAN_POPOVER_DATE: 'M/D/YY - HH:mm',
|
||||
SPAN_POPOVER_DATE: 'D/M/YY - HH:mm:ss',
|
||||
|
||||
// Month name formats
|
||||
MONTH_DATE_FULL: 'MMMM DD, YYYY',
|
||||
|
||||
@@ -50,4 +50,5 @@ export enum QueryParams {
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
variables = 'variables',
|
||||
}
|
||||
|
||||
@@ -86,6 +86,7 @@ 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',
|
||||
|
||||
@@ -183,6 +183,7 @@ function AlertThreshold({
|
||||
}}
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
data-testid="alert-threshold-query-select"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">is</Typography.Text>
|
||||
<Select
|
||||
@@ -195,6 +196,7 @@ function AlertThreshold({
|
||||
}}
|
||||
style={{ width: 180 }}
|
||||
options={THRESHOLD_OPERATOR_OPTIONS}
|
||||
data-testid="alert-threshold-operator-select"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
the threshold(s)
|
||||
@@ -209,6 +211,7 @@ function AlertThreshold({
|
||||
}}
|
||||
style={{ width: 180 }}
|
||||
options={matchTypeOptionsWithTooltips}
|
||||
data-testid="alert-threshold-match-type-select"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">
|
||||
during the <EvaluationSettings />
|
||||
@@ -236,6 +239,7 @@ function AlertThreshold({
|
||||
icon={<Plus size={16} />}
|
||||
onClick={addThreshold}
|
||||
className="add-threshold-btn"
|
||||
data-testid="add-threshold-button"
|
||||
>
|
||||
Add Threshold
|
||||
</Button>
|
||||
|
||||
@@ -32,6 +32,7 @@ function ThresholdItem({
|
||||
style={{ width: 150 }}
|
||||
options={units}
|
||||
disabled={units.length === 0}
|
||||
data-testid="threshold-unit-select"
|
||||
/>
|
||||
);
|
||||
if (units.length === 0) {
|
||||
@@ -47,6 +48,7 @@ function ThresholdItem({
|
||||
style={{ width: 150 }}
|
||||
options={units}
|
||||
disabled={units.length === 0}
|
||||
data-testid="threshold-unit-select"
|
||||
/>
|
||||
</Tooltip>
|
||||
);
|
||||
@@ -96,6 +98,7 @@ function ThresholdItem({
|
||||
updateThreshold(threshold.id, 'label', e.target.value)
|
||||
}
|
||||
style={{ width: 200 }}
|
||||
data-testid="threshold-name-input"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">on value</Typography.Text>
|
||||
<Typography.Text className="sentence-text highlighted-text">
|
||||
@@ -109,6 +112,7 @@ function ThresholdItem({
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
data-testid="threshold-value-input"
|
||||
/>
|
||||
{yAxisUnitSelect}
|
||||
{!notificationSettings.routingPolicies && (
|
||||
@@ -119,10 +123,12 @@ function ThresholdItem({
|
||||
onChange={(value): void =>
|
||||
updateThreshold(threshold.id, 'channels', value)
|
||||
}
|
||||
data-testid="threshold-notification-channel-select"
|
||||
style={{ width: 350 }}
|
||||
options={channels.map((channel) => ({
|
||||
value: channel.name,
|
||||
label: channel.name,
|
||||
'data-testid': `threshold-notification-channel-option-${threshold.label}`,
|
||||
}))}
|
||||
mode="multiple"
|
||||
placeholder="Select notification channels"
|
||||
@@ -157,6 +163,7 @@ function ThresholdItem({
|
||||
}
|
||||
style={{ width: 100 }}
|
||||
type="number"
|
||||
data-testid="recovery-threshold-value-input"
|
||||
/>
|
||||
<Tooltip title="Remove recovery threshold">
|
||||
<Button
|
||||
@@ -164,6 +171,7 @@ function ThresholdItem({
|
||||
icon={<Trash size={16} />}
|
||||
onClick={removeRecoveryThreshold}
|
||||
className="icon-btn"
|
||||
data-testid="remove-recovery-threshold-button"
|
||||
/>
|
||||
</Tooltip>
|
||||
</>
|
||||
@@ -187,6 +195,7 @@ function ThresholdItem({
|
||||
icon={<CircleX size={16} />}
|
||||
onClick={(): void => removeThreshold(threshold.id)}
|
||||
className="icon-btn"
|
||||
data-testid="remove-threshold-button"
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
@@ -50,6 +50,7 @@ export function getCategorySelectOptionByName(
|
||||
(unit) => ({
|
||||
label: unit.name,
|
||||
value: unit.id,
|
||||
'data-testid': `threshold-unit-select-option-${unit.id}`,
|
||||
}),
|
||||
) || []
|
||||
);
|
||||
@@ -401,6 +402,7 @@ export function RoutingPolicyBanner({
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
checked={notificationSettings.routingPolicies}
|
||||
data-testid="routing-policies-switch"
|
||||
onChange={(value): void => {
|
||||
setNotificationSettings({
|
||||
type: 'SET_ROUTING_POLICIES',
|
||||
|
||||
@@ -52,6 +52,7 @@ function CreateAlertHeader(): JSX.Element {
|
||||
}
|
||||
className="alert-header__input title"
|
||||
placeholder="Enter alert rule name"
|
||||
data-testid="alert-name-input"
|
||||
/>
|
||||
<LabelsInput
|
||||
labels={alertState.labels}
|
||||
|
||||
@@ -124,7 +124,11 @@ function LabelsInput({
|
||||
{Object.keys(labels).length > 0 && (
|
||||
<div className="labels-input__existing-labels">
|
||||
{Object.entries(labels).map(([key, value]) => (
|
||||
<span key={key} className="labels-input__label-pill">
|
||||
<span
|
||||
key={key}
|
||||
className="labels-input__label-pill"
|
||||
data-testid={`label-pill-${key}-${value}`}
|
||||
>
|
||||
{key}: {value}
|
||||
<button
|
||||
type="button"
|
||||
@@ -143,6 +147,7 @@ function LabelsInput({
|
||||
className="labels-input__add-button"
|
||||
type="button"
|
||||
onClick={handleAddLabelsClick}
|
||||
data-testid="alert-add-label-button"
|
||||
>
|
||||
+ Add labels
|
||||
</button>
|
||||
@@ -158,6 +163,7 @@ function LabelsInput({
|
||||
placeholder={inputState.isKeyInput ? 'Enter key' : 'Enter value'}
|
||||
// eslint-disable-next-line jsx-a11y/no-autofocus
|
||||
autoFocus
|
||||
data-testid="alert-add-label-input"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,7 @@ function AdvancedOptionItem({
|
||||
tooltipText,
|
||||
onToggle,
|
||||
defaultShowInput,
|
||||
'data-testid': dataTestId,
|
||||
}: IAdvancedOptionItemProps): JSX.Element {
|
||||
const [showInput, setShowInput] = useState<boolean>(false);
|
||||
|
||||
@@ -26,7 +27,7 @@ function AdvancedOptionItem({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="advanced-option-item">
|
||||
<div className="advanced-option-item" data-testid={dataTestId}>
|
||||
<div className="advanced-option-item-left-content">
|
||||
<Typography.Text className="advanced-option-item-title">
|
||||
{title}
|
||||
|
||||
@@ -43,6 +43,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
defaultShowInput={advancedOptions.sendNotificationIfDataIsMissing.enabled}
|
||||
data-testid="send-notification-if-data-is-missing-container"
|
||||
/>
|
||||
<AdvancedOptionItem
|
||||
title="Minimum data required"
|
||||
@@ -74,6 +75,7 @@ function AdvancedOptions(): JSX.Element {
|
||||
})
|
||||
}
|
||||
defaultShowInput={advancedOptions.enforceMinimumDatapoints.enabled}
|
||||
data-testid="enforce-minimum-datapoints-container"
|
||||
/>
|
||||
{/* TODO: Add back when the functionality is implemented */}
|
||||
{/* <AdvancedOptionItem
|
||||
|
||||
@@ -78,6 +78,7 @@ function EvaluationCadence(): JSX.Element {
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="evaluation-cadence-duration-input"
|
||||
/>
|
||||
<Select
|
||||
options={ADVANCED_OPTIONS_TIME_UNIT_OPTIONS}
|
||||
@@ -96,6 +97,7 @@ function EvaluationCadence(): JSX.Element {
|
||||
},
|
||||
})
|
||||
}
|
||||
data-testid="evaluation-cadence-unit-select"
|
||||
/>
|
||||
</Input.Group>
|
||||
{/* TODO: Add custom schedule back once the functionality is implemented */}
|
||||
|
||||
@@ -30,7 +30,7 @@ function EvaluationSettings(): JSX.Element {
|
||||
trigger="click"
|
||||
showArrow={false}
|
||||
>
|
||||
<Button>
|
||||
<Button data-testid="evaluation-settings-button">
|
||||
<div className="evaluate-alert-conditions-button-left">
|
||||
{getTimeframeText(evaluationWindow)}
|
||||
</div>
|
||||
|
||||
@@ -127,6 +127,7 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
data-testid="evaluation-window-details-starting-at-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -154,6 +155,7 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
data-testid="evaluation-window-details-timezone-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -174,6 +176,7 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.number || null}
|
||||
onChange={handleNumberChange}
|
||||
placeholder="Select starting at"
|
||||
data-testid="evaluation-window-details-starting-at-select"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
@@ -190,6 +193,7 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.timezone || null}
|
||||
onChange={handleTimezoneChange}
|
||||
placeholder="Select timezone"
|
||||
data-testid="evaluation-window-details-timezone-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,6 +215,7 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.number}
|
||||
onChange={(e): void => handleNumberChange(e.target.value)}
|
||||
placeholder="Enter value"
|
||||
data-testid="evaluation-window-details-custom-rolling-window-duration-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="select-group time-select-group">
|
||||
@@ -220,6 +225,7 @@ function EvaluationWindowDetails({
|
||||
value={evaluationWindow.startingAt.unit || null}
|
||||
onChange={handleUnitChange}
|
||||
placeholder="Select unit"
|
||||
data-testid="evaluation-window-details-custom-rolling-window-unit-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -145,7 +145,7 @@ function TimeInput({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={`time-input-container ${className}`}>
|
||||
<div data-testid="time-input" className={`time-input-container ${className}`}>
|
||||
<Input
|
||||
data-field="hours"
|
||||
value={hours}
|
||||
@@ -156,6 +156,7 @@ function TimeInput({
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
data-testid="time-input-hours"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
@@ -168,6 +169,7 @@ function TimeInput({
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
data-testid="time-input-minutes"
|
||||
/>
|
||||
<span className="time-input-separator">:</span>
|
||||
<Input
|
||||
@@ -180,6 +182,7 @@ function TimeInput({
|
||||
maxLength={2}
|
||||
className="time-input-field"
|
||||
placeholder="00"
|
||||
data-testid="time-input-seconds"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -12,6 +12,7 @@ export interface IAdvancedOptionItemProps {
|
||||
tooltipText?: string;
|
||||
onToggle?: () => void;
|
||||
defaultShowInput: boolean;
|
||||
'data-testid'?: string;
|
||||
}
|
||||
|
||||
export enum RollingWindowTimeframes {
|
||||
|
||||
@@ -24,6 +24,7 @@ function MultipleNotifications(): JSX.Element {
|
||||
return uniqueGroupBys.map((key) => ({
|
||||
label: key,
|
||||
value: key,
|
||||
'data-testid': 'multiple-notifications-select-option',
|
||||
}));
|
||||
}, [currentQuery.builder.queryData]);
|
||||
|
||||
@@ -49,6 +50,7 @@ function MultipleNotifications(): JSX.Element {
|
||||
disabled={!isMultipleNotificationsEnabled}
|
||||
aria-disabled={!isMultipleNotificationsEnabled}
|
||||
maxTagCount={3}
|
||||
data-testid="multiple-notifications-select"
|
||||
/>
|
||||
{isMultipleNotificationsEnabled && (
|
||||
<Typography.Paragraph className="multiple-notifications-select-description">
|
||||
|
||||
@@ -37,6 +37,7 @@ function NotificationSettings(): JSX.Element {
|
||||
},
|
||||
});
|
||||
}}
|
||||
data-testid="repeat-notifications-time-input"
|
||||
/>
|
||||
<Select
|
||||
value={notificationSettings.reNotification.unit || null}
|
||||
@@ -54,6 +55,7 @@ function NotificationSettings(): JSX.Element {
|
||||
},
|
||||
});
|
||||
}}
|
||||
data-testid="repeat-notifications-unit-select"
|
||||
/>
|
||||
<Typography.Text>while</Typography.Text>
|
||||
<Select
|
||||
@@ -73,6 +75,7 @@ function NotificationSettings(): JSX.Element {
|
||||
},
|
||||
});
|
||||
}}
|
||||
data-testid="repeat-notifications-conditions-select"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -98,6 +101,7 @@ function NotificationSettings(): JSX.Element {
|
||||
});
|
||||
}}
|
||||
defaultShowInput={notificationSettings.reNotification.enabled}
|
||||
data-testid="repeat-notifications-container"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { MOCK_QUERY } from 'container/QueryTable/Drilldown/__tests__/mockTableData';
|
||||
import { useUpdateDashboard } from 'hooks/dashboard/useUpdateDashboard';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { Dashboard } from 'types/api/dashboard/getAll';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { generateExportToDashboardLink } from 'utils/dashboard/generateExportToDashboardLink';
|
||||
import { v4 } from 'uuid';
|
||||
|
||||
import ExplorerOptionWrapper from '../ExplorerOptionWrapper';
|
||||
import { getExplorerToolBarVisibility } from '../utils';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('hooks/dashboard/useUpdateDashboard');
|
||||
|
||||
jest.mock('../utils', () => ({
|
||||
getExplorerToolBarVisibility: jest.fn(),
|
||||
generateRGBAFromHex: jest.fn(() => 'rgba(0, 0, 0, 0.08)'),
|
||||
getRandomColor: jest.fn(() => '#000000'),
|
||||
saveNewViewHandler: jest.fn(),
|
||||
setExplorerToolBarVisibility: jest.fn(),
|
||||
DATASOURCE_VS_ROUTES: {},
|
||||
}));
|
||||
|
||||
const mockGetExplorerToolBarVisibility = jest.mocked(
|
||||
getExplorerToolBarVisibility,
|
||||
);
|
||||
|
||||
const mockUseUpdateDashboard = jest.mocked(useUpdateDashboard);
|
||||
|
||||
// Mock data
|
||||
const TEST_QUERY_ID = 'test-query-id';
|
||||
const TEST_DASHBOARD_ID = 'test-dashboard-id';
|
||||
const TEST_DASHBOARD_TITLE = 'Test Dashboard';
|
||||
const TEST_DASHBOARD_DESCRIPTION = 'Test Description';
|
||||
const TEST_TIMESTAMP = '2023-01-01T00:00:00Z';
|
||||
const TEST_DASHBOARD_TITLE_2 = 'Test Dashboard for Export';
|
||||
const NEW_DASHBOARD_ID = 'new-dashboard-id';
|
||||
const DASHBOARDS_API_ENDPOINT = '*/api/v1/dashboards';
|
||||
|
||||
// Use the existing mock query from the codebase
|
||||
const mockQuery: Query = {
|
||||
...MOCK_QUERY,
|
||||
id: TEST_QUERY_ID, // Override with our test ID
|
||||
} as Query;
|
||||
|
||||
const createMockDashboard = (id: string = TEST_DASHBOARD_ID): Dashboard => ({
|
||||
id,
|
||||
data: {
|
||||
title: TEST_DASHBOARD_TITLE,
|
||||
description: TEST_DASHBOARD_DESCRIPTION,
|
||||
tags: [],
|
||||
layout: [],
|
||||
variables: {},
|
||||
},
|
||||
createdAt: TEST_TIMESTAMP,
|
||||
updatedAt: TEST_TIMESTAMP,
|
||||
createdBy: 'test-user',
|
||||
updatedBy: 'test-user',
|
||||
});
|
||||
|
||||
const ADD_TO_DASHBOARD_BUTTON_NAME = /add to dashboard/i;
|
||||
|
||||
// Helper function to render component with props
|
||||
const renderExplorerOptionWrapper = (
|
||||
overrides = {},
|
||||
): ReturnType<typeof render> => {
|
||||
const props = {
|
||||
disabled: false,
|
||||
query: mockQuery,
|
||||
isLoading: false,
|
||||
onExport: jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>,
|
||||
sourcepage: DataSource.LOGS,
|
||||
isOneChartPerQuery: false,
|
||||
splitedQueries: [],
|
||||
signalSource: 'test-signal',
|
||||
...overrides,
|
||||
};
|
||||
|
||||
return render(
|
||||
<ExplorerOptionWrapper
|
||||
disabled={props.disabled}
|
||||
query={props.query}
|
||||
isLoading={props.isLoading}
|
||||
onExport={props.onExport}
|
||||
sourcepage={props.sourcepage}
|
||||
isOneChartPerQuery={props.isOneChartPerQuery}
|
||||
splitedQueries={props.splitedQueries}
|
||||
signalSource={props.signalSource}
|
||||
/>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('ExplorerOptionWrapper', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockGetExplorerToolBarVisibility.mockReturnValue(true);
|
||||
|
||||
// Mock useUpdateDashboard to return a mutation object
|
||||
mockUseUpdateDashboard.mockReturnValue(({
|
||||
mutate: jest.fn(),
|
||||
mutateAsync: jest.fn(),
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
isSuccess: false,
|
||||
data: undefined,
|
||||
error: null,
|
||||
reset: jest.fn(),
|
||||
} as unknown) as ReturnType<typeof useUpdateDashboard>);
|
||||
});
|
||||
|
||||
describe('onExport functionality', () => {
|
||||
it('should call onExport when New Dashboard button is clicked in export modal', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>;
|
||||
|
||||
// Mock the dashboard creation API
|
||||
const mockNewDashboard = createMockDashboard(NEW_DASHBOARD_ID);
|
||||
server.use(
|
||||
rest.post(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockNewDashboard })),
|
||||
),
|
||||
);
|
||||
|
||||
renderExplorerOptionWrapper({
|
||||
onExport: testOnExport,
|
||||
});
|
||||
|
||||
// Find and click the "Add to Dashboard" button
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
await user.click(addToDashboardButton);
|
||||
|
||||
// Wait for the export modal to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click the "New Dashboard" button
|
||||
const newDashboardButton = screen.getByRole('button', {
|
||||
name: /new dashboard/i,
|
||||
});
|
||||
await user.click(newDashboardButton);
|
||||
|
||||
// Wait for the API call to complete and onExport to be called
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockNewDashboard, true);
|
||||
});
|
||||
});
|
||||
|
||||
it('should call onExport when selecting existing dashboard and clicking Export button', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>;
|
||||
|
||||
// Mock existing dashboards with unique titles
|
||||
const mockDashboard1 = createMockDashboard('dashboard-1');
|
||||
mockDashboard1.data.title = 'Dashboard 1';
|
||||
const mockDashboard2 = createMockDashboard('dashboard-2');
|
||||
mockDashboard2.data.title = 'Dashboard 2';
|
||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||
|
||||
server.use(
|
||||
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: mockDashboards })),
|
||||
),
|
||||
);
|
||||
|
||||
renderExplorerOptionWrapper({
|
||||
onExport: testOnExport,
|
||||
});
|
||||
|
||||
// Find and click the "Add to Dashboard" button
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
await user.click(addToDashboardButton);
|
||||
|
||||
// Wait for the export modal to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
expect(dashboardSelect).toBeInTheDocument();
|
||||
await user.click(dashboardSelect);
|
||||
|
||||
// Wait for the dropdown options to appear and select the first dashboard
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockDashboard1.data.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the first dashboard option
|
||||
const dashboardOption = screen.getByText(mockDashboard1.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for onExport to be called with the selected dashboard
|
||||
await waitFor(() => {
|
||||
expect(testOnExport).toHaveBeenCalledWith(mockDashboard1, false);
|
||||
});
|
||||
});
|
||||
|
||||
it('should test actual handleExport function with generateExportToDashboardLink and verify useUpdateDashboard is NOT called', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Mock the safeNavigate function
|
||||
const mockSafeNavigate = jest.fn();
|
||||
|
||||
// Get the mock mutate function to track calls
|
||||
const mockMutate = mockUseUpdateDashboard().mutate as jest.MockedFunction<
|
||||
(...args: unknown[]) => void
|
||||
>;
|
||||
|
||||
const panelTypeParam = PANEL_TYPES.TIME_SERIES;
|
||||
const widgetId = v4();
|
||||
const query = mockQuery;
|
||||
|
||||
// Create a real handleExport function similar to LogsExplorerViews
|
||||
// This should NOT call useUpdateDashboard (as per PR #8029)
|
||||
const handleExport = (dashboard: Dashboard | null): void => {
|
||||
if (!dashboard) return;
|
||||
|
||||
// Call the actual generateExportToDashboardLink function (not mocked)
|
||||
const dashboardEditView = generateExportToDashboardLink({
|
||||
query,
|
||||
panelType: panelTypeParam,
|
||||
dashboardId: dashboard.id,
|
||||
widgetId,
|
||||
});
|
||||
|
||||
// Simulate navigation
|
||||
mockSafeNavigate(dashboardEditView);
|
||||
};
|
||||
|
||||
// Mock existing dashboards
|
||||
const mockDashboard = createMockDashboard('test-dashboard-id');
|
||||
mockDashboard.data.title = TEST_DASHBOARD_TITLE_2;
|
||||
|
||||
server.use(
|
||||
rest.get(DASHBOARDS_API_ENDPOINT, (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json({ data: [mockDashboard] })),
|
||||
),
|
||||
);
|
||||
|
||||
renderExplorerOptionWrapper({
|
||||
onExport: handleExport,
|
||||
});
|
||||
|
||||
// Find and click the "Add to Dashboard" button
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
await user.click(addToDashboardButton);
|
||||
|
||||
// Wait for the export modal to appear
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Wait for dashboards to load and then click on the dashboard select dropdown
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Select Dashboard')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Get the modal and find the dashboard select dropdown within it
|
||||
const modal = screen.getByRole('dialog');
|
||||
const dashboardSelect = modal.querySelector(
|
||||
'[role="combobox"]',
|
||||
) as HTMLElement;
|
||||
expect(dashboardSelect).toBeInTheDocument();
|
||||
await user.click(dashboardSelect);
|
||||
|
||||
// Wait for the dropdown options to appear and select the dashboard
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(mockDashboard.data.title)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Click on the dashboard option
|
||||
const dashboardOption = screen.getByText(mockDashboard.data.title);
|
||||
await user.click(dashboardOption);
|
||||
|
||||
// Wait for the selection to be made and the Export button to be enabled
|
||||
await waitFor(() => {
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
expect(exportButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click the Export button
|
||||
const exportButton = screen.getByRole('button', { name: /export/i });
|
||||
await user.click(exportButton);
|
||||
|
||||
// Wait for the handleExport function to be called and navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockSafeNavigate).toHaveBeenCalledTimes(1);
|
||||
expect(mockSafeNavigate).toHaveBeenCalledWith(
|
||||
`/dashboard/test-dashboard-id/new?graphType=${panelTypeParam}&widgetId=${widgetId}&compositeQuery=${encodeURIComponent(
|
||||
JSON.stringify(query),
|
||||
)}`,
|
||||
);
|
||||
});
|
||||
|
||||
// Assert that useUpdateDashboard was NOT called (as per PR #8029)
|
||||
expect(mockMutate).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not show export buttons when component is disabled', () => {
|
||||
const testOnExport = jest.fn() as jest.MockedFunction<
|
||||
(
|
||||
dashboard: Dashboard | null,
|
||||
isNewDashboard?: boolean,
|
||||
queryToExport?: Query,
|
||||
) => void
|
||||
>;
|
||||
|
||||
renderExplorerOptionWrapper({ disabled: true, onExport: testOnExport });
|
||||
|
||||
// The "Add to Dashboard" button should be disabled
|
||||
const addToDashboardButton = screen.getByRole('button', {
|
||||
name: ADD_TO_DASHBOARD_BUTTON_NAME,
|
||||
});
|
||||
expect(addToDashboardButton).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -137,8 +137,9 @@ function GeneralSettings({
|
||||
if (logsCurrentTTLValues) {
|
||||
setLogsTotalRetentionPeriod(logsCurrentTTLValues.default_ttl_days * 24);
|
||||
setLogsS3RetentionPeriod(
|
||||
logsCurrentTTLValues.logs_move_ttl_duration_hrs
|
||||
? logsCurrentTTLValues.logs_move_ttl_duration_hrs
|
||||
logsCurrentTTLValues.cold_storage_ttl_days &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days > 0
|
||||
? logsCurrentTTLValues.cold_storage_ttl_days * 24
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@@ -198,7 +199,12 @@ function GeneralSettings({
|
||||
);
|
||||
|
||||
const s3Enabled = useMemo(
|
||||
() => !!find(availableDisks, (disks: IDiskType) => disks?.type === 's3'),
|
||||
() =>
|
||||
!!find(
|
||||
availableDisks,
|
||||
(disks: IDiskType) =>
|
||||
disks?.type === 's3' || disks?.type === 'ObjectStorage',
|
||||
),
|
||||
[availableDisks],
|
||||
);
|
||||
|
||||
@@ -289,8 +295,9 @@ function GeneralSettings({
|
||||
isTracesSaveDisabled = true;
|
||||
|
||||
if (
|
||||
logsCurrentTTLValues.logs_ttl_duration_hrs === logsTotalRetentionPeriod &&
|
||||
logsCurrentTTLValues.logs_move_ttl_duration_hrs === logsS3RetentionPeriod
|
||||
logsCurrentTTLValues.default_ttl_days * 24 === logsTotalRetentionPeriod &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days &&
|
||||
logsCurrentTTLValues.cold_storage_ttl_days * 24 === logsS3RetentionPeriod
|
||||
)
|
||||
isLogsSaveDisabled = true;
|
||||
|
||||
@@ -301,8 +308,8 @@ function GeneralSettings({
|
||||
errorText,
|
||||
];
|
||||
}, [
|
||||
logsCurrentTTLValues.logs_move_ttl_duration_hrs,
|
||||
logsCurrentTTLValues.logs_ttl_duration_hrs,
|
||||
logsCurrentTTLValues.cold_storage_ttl_days,
|
||||
logsCurrentTTLValues.default_ttl_days,
|
||||
logsS3RetentionPeriod,
|
||||
logsTotalRetentionPeriod,
|
||||
metricsCurrentTTLValues.metrics_move_ttl_duration_hrs,
|
||||
@@ -348,11 +355,17 @@ function GeneralSettings({
|
||||
|
||||
try {
|
||||
if (type === 'logs') {
|
||||
// Only send S3 values if user has specified a duration
|
||||
const s3RetentionDays =
|
||||
apiCallS3Retention && apiCallS3Retention > 0
|
||||
? apiCallS3Retention / 24
|
||||
: 0;
|
||||
|
||||
await setRetentionApiV2({
|
||||
type,
|
||||
defaultTTLDays: apiCallTotalRetention ? apiCallTotalRetention / 24 : -1, // convert Hours to days
|
||||
coldStorageVolume: '',
|
||||
coldStorageDuration: 0,
|
||||
coldStorageVolume: s3RetentionDays > 0 ? 's3' : '',
|
||||
coldStorageDurationDays: s3RetentionDays,
|
||||
ttlConditions: [],
|
||||
});
|
||||
} else {
|
||||
@@ -406,8 +419,9 @@ function GeneralSettings({
|
||||
// Updates the currentTTL Values in order to avoid pushing the same values.
|
||||
setLogsCurrentTTLValues((prev) => ({
|
||||
...prev,
|
||||
logs_ttl_duration_hrs: logsTotalRetentionPeriod || -1,
|
||||
logs_move_ttl_duration_hrs: logsS3RetentionPeriod || -1,
|
||||
cold_storage_ttl_days: logsS3RetentionPeriod
|
||||
? logsS3RetentionPeriod / 24
|
||||
: -1,
|
||||
default_ttl_days: logsTotalRetentionPeriod
|
||||
? logsTotalRetentionPeriod / 24 // convert Hours to days
|
||||
: -1,
|
||||
@@ -524,6 +538,7 @@ function GeneralSettings({
|
||||
value: logsS3RetentionPeriod,
|
||||
setValue: setLogsS3RetentionPeriod,
|
||||
hide: !s3Enabled,
|
||||
isS3Field: true,
|
||||
},
|
||||
],
|
||||
save: {
|
||||
@@ -577,6 +592,7 @@ function GeneralSettings({
|
||||
retentionValue={retentionField.value}
|
||||
setRetentionValue={retentionField.setValue}
|
||||
hide={!!retentionField.hide}
|
||||
isS3Field={'isS3Field' in retentionField && retentionField.isS3Field}
|
||||
/>
|
||||
))}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from 'react';
|
||||
@@ -32,11 +33,31 @@ function Retention({
|
||||
setRetentionValue,
|
||||
text,
|
||||
hide,
|
||||
isS3Field = false,
|
||||
}: RetentionProps): JSX.Element | null {
|
||||
// Filter available units based on type and field
|
||||
const availableUnits = useMemo(
|
||||
() =>
|
||||
TimeUnits.filter((option) => {
|
||||
if (type === 'logs') {
|
||||
// For S3 cold storage fields: only allow Days
|
||||
if (isS3Field) {
|
||||
return option.value === TimeUnitsValues.day;
|
||||
}
|
||||
// For total retention: allow Days and Months (not Hours)
|
||||
return option.value !== TimeUnitsValues.hr;
|
||||
}
|
||||
return true;
|
||||
}),
|
||||
[type, isS3Field],
|
||||
);
|
||||
|
||||
// Convert the hours value using only the available units
|
||||
const {
|
||||
value: initialValue,
|
||||
timeUnitValue: initialTimeUnitValue,
|
||||
} = convertHoursValueToRelevantUnit(Number(retentionValue));
|
||||
} = convertHoursValueToRelevantUnit(Number(retentionValue), availableUnits);
|
||||
|
||||
const [selectedTimeUnit, setSelectTimeUnit] = useState(initialTimeUnitValue);
|
||||
const [selectedValue, setSelectedValue] = useState<number | null>(
|
||||
initialValue,
|
||||
@@ -53,29 +74,27 @@ function Retention({
|
||||
if (!interacted.current) setSelectTimeUnit(initialTimeUnitValue);
|
||||
}, [initialTimeUnitValue]);
|
||||
|
||||
const menuItems = TimeUnits.filter((option) =>
|
||||
type === 'logs' ? option.value !== TimeUnitsValues.hr : true,
|
||||
).map((option) => (
|
||||
const menuItems = availableUnits.map((option) => (
|
||||
<Option key={option.value} value={option.value}>
|
||||
{option.key}
|
||||
</Option>
|
||||
));
|
||||
|
||||
const currentSelectedOption = (option: SettingPeriod): void => {
|
||||
const selectedValue = find(TimeUnits, (e) => e.value === option)?.value;
|
||||
const selectedValue = find(availableUnits, (e) => e.value === option)?.value;
|
||||
if (selectedValue) setSelectTimeUnit(selectedValue);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const inverseMultiplier = find(
|
||||
TimeUnits,
|
||||
availableUnits,
|
||||
(timeUnit) => timeUnit.value === selectedTimeUnit,
|
||||
)?.multiplier;
|
||||
if (!selectedValue) setRetentionValue(null);
|
||||
if (selectedValue && inverseMultiplier) {
|
||||
setRetentionValue(selectedValue * (1 / inverseMultiplier));
|
||||
}
|
||||
}, [selectedTimeUnit, selectedValue, setRetentionValue]);
|
||||
}, [selectedTimeUnit, selectedValue, setRetentionValue, availableUnits]);
|
||||
|
||||
const onChangeHandler = (
|
||||
e: ChangeEvent<HTMLInputElement>,
|
||||
@@ -134,6 +153,10 @@ interface RetentionProps {
|
||||
text: string;
|
||||
setRetentionValue: Dispatch<SetStateAction<number | null>>;
|
||||
hide: boolean;
|
||||
isS3Field?: boolean;
|
||||
}
|
||||
|
||||
Retention.defaultProps = {
|
||||
isS3Field: false,
|
||||
};
|
||||
export default Retention;
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import setRetentionApiV2 from 'api/settings/setRetentionV2';
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
userEvent,
|
||||
waitFor,
|
||||
} from 'tests/test-utils';
|
||||
import { IDiskType } from 'types/api/disks/getDisks';
|
||||
import {
|
||||
PayloadPropsLogs,
|
||||
PayloadPropsMetrics,
|
||||
PayloadPropsTraces,
|
||||
} from 'types/api/settings/getRetention';
|
||||
|
||||
import GeneralSettings from '../GeneralSettings';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('api/settings/setRetentionV2');
|
||||
|
||||
const mockNotifications = {
|
||||
error: jest.fn(),
|
||||
success: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock('hooks/useNotifications', () => ({
|
||||
useNotifications: (): { notifications: typeof mockNotifications } => ({
|
||||
notifications: mockNotifications,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useComponentPermission', () => ({
|
||||
__esModule: true,
|
||||
default: jest.fn(() => [true]),
|
||||
}));
|
||||
|
||||
jest.mock('hooks/useGetTenantLicense', () => ({
|
||||
useGetTenantLicense: (): { isCloudUser: boolean } => ({
|
||||
isCloudUser: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
jest.mock('container/GeneralSettingsCloud', () => ({
|
||||
__esModule: true,
|
||||
default: (): null => null,
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockMetricsRetention: PayloadPropsMetrics = {
|
||||
metrics_ttl_duration_hrs: 168,
|
||||
metrics_move_ttl_duration_hrs: -1,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockTracesRetention: PayloadPropsTraces = {
|
||||
traces_ttl_duration_hrs: 168,
|
||||
traces_move_ttl_duration_hrs: -1,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockLogsRetentionWithS3: PayloadPropsLogs = {
|
||||
version: 'v2',
|
||||
default_ttl_days: 30,
|
||||
cold_storage_ttl_days: 24,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockLogsRetentionWithoutS3: PayloadPropsLogs = {
|
||||
version: 'v2',
|
||||
default_ttl_days: 30,
|
||||
cold_storage_ttl_days: -1,
|
||||
status: '',
|
||||
};
|
||||
|
||||
const mockDisksWithS3: IDiskType[] = [
|
||||
{
|
||||
name: 'default',
|
||||
type: 's3',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisksWithObjectStorage: IDiskType[] = [
|
||||
{
|
||||
name: 'default',
|
||||
type: 'ObjectStorage',
|
||||
},
|
||||
];
|
||||
|
||||
const mockDisksWithoutS3: IDiskType[] = [
|
||||
{
|
||||
name: 'default',
|
||||
type: 'local',
|
||||
},
|
||||
];
|
||||
|
||||
describe('GeneralSettings - S3 Logs Retention', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
(setRetentionApiV2 as jest.Mock).mockResolvedValue({
|
||||
httpStatusCode: 200,
|
||||
data: { message: 'success' },
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 1: S3 Enabled - Only Days in Dropdown', () => {
|
||||
it('should show only Days option for S3 retention and send correct API payload', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
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 all inputs in the Logs card - there should be 2 (total retention + S3)
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(2);
|
||||
|
||||
// The second input is the S3 retention field
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
|
||||
// Find the S3 dropdown (next sibling of the S3 input)
|
||||
const s3Dropdown = s3Input?.nextElementSibling?.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
expect(s3Dropdown).toBeInTheDocument();
|
||||
|
||||
// Click the S3 dropdown to open it
|
||||
fireEvent.mouseDown(s3Dropdown);
|
||||
|
||||
// Wait for dropdown options to appear and verify only "Days" is available
|
||||
await waitFor(() => {
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
const dropdownOptions = document.querySelectorAll('.ant-select-item');
|
||||
expect(dropdownOptions).toHaveLength(1);
|
||||
expect(dropdownOptions[0]).toHaveTextContent('Days');
|
||||
});
|
||||
|
||||
// Close dropdown
|
||||
fireEvent.click(document.body);
|
||||
|
||||
// Change S3 retention value to 5 days
|
||||
await user.clear(s3Input);
|
||||
await user.type(s3Input, '5');
|
||||
|
||||
// Find the save button in the Logs card
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
// The primary button should be the save button
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (it should enable after value changes)
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
// Wait for modal to appear
|
||||
const modal = await screen.findByRole('dialog');
|
||||
expect(modal).toBeInTheDocument();
|
||||
|
||||
// Click OK button
|
||||
const okButton = await screen.findByRole('button', { name: /ok/i });
|
||||
fireEvent.click(okButton);
|
||||
|
||||
// Verify API was called with correct payload
|
||||
await waitFor(() => {
|
||||
expect(setRetentionApiV2).toHaveBeenCalledWith({
|
||||
type: 'logs',
|
||||
defaultTTLDays: 30,
|
||||
coldStorageVolume: 's3',
|
||||
coldStorageDurationDays: 5,
|
||||
ttlConditions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('should recognize ObjectStorage disk type as S3 enabled', async () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithObjectStorage}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify S3 field is visible
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(2); // Total + S3
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 2: S3 Disabled - Field Hidden', () => {
|
||||
it('should hide S3 retention field and send empty S3 values to API', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithoutS3}
|
||||
getAvailableDiskPayload={mockDisksWithoutS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
expect(logsCard).toBeInTheDocument();
|
||||
|
||||
// Only 1 input should be visible (total retention, no S3)
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
expect(inputs).toHaveLength(1);
|
||||
|
||||
// Change total retention value
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
|
||||
// First, change the dropdown to Days (it defaults to Months)
|
||||
const totalDropdown = totalInput?.nextElementSibling?.querySelector(
|
||||
'.ant-select-selector',
|
||||
) as HTMLElement;
|
||||
await user.click(totalDropdown);
|
||||
|
||||
// Wait for dropdown options to appear
|
||||
await waitFor(() => {
|
||||
const options = document.querySelectorAll('.ant-select-item');
|
||||
expect(options.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
// Find and click the Days option
|
||||
const options = document.querySelectorAll('.ant-select-item');
|
||||
const daysOption = Array.from(options).find((opt) =>
|
||||
opt.textContent?.includes('Days'),
|
||||
);
|
||||
expect(daysOption).toBeInTheDocument();
|
||||
await user.click(daysOption as HTMLElement);
|
||||
|
||||
// Now change the value
|
||||
await user.clear(totalInput);
|
||||
await user.type(totalInput, '60');
|
||||
|
||||
// Find the save button
|
||||
const buttons = logsCard?.querySelectorAll('button[type="button"]');
|
||||
const saveButton = Array.from(buttons || []).find((btn) =>
|
||||
btn.className.includes('ant-btn-primary'),
|
||||
) as HTMLButtonElement;
|
||||
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
|
||||
// Wait for button to be enabled (ensures all state updates have settled)
|
||||
await waitFor(() => {
|
||||
expect(saveButton).not.toBeDisabled();
|
||||
});
|
||||
|
||||
// Click save button
|
||||
await user.click(saveButton);
|
||||
|
||||
// Wait for modal to appear
|
||||
const okButton = await screen.findByRole('button', { name: /ok/i });
|
||||
expect(okButton).toBeInTheDocument();
|
||||
|
||||
// Click OK button
|
||||
await user.click(okButton);
|
||||
|
||||
// Verify API was called with empty S3 values (60 days)
|
||||
await waitFor(() => {
|
||||
expect(setRetentionApiV2).toHaveBeenCalledWith({
|
||||
type: 'logs',
|
||||
defaultTTLDays: 60,
|
||||
coldStorageVolume: '',
|
||||
coldStorageDurationDays: 0,
|
||||
ttlConditions: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Test 3: Save & Reload - Correct Display', () => {
|
||||
it('should display retention values correctly after converting from hours', () => {
|
||||
render(
|
||||
<GeneralSettings
|
||||
metricsTtlValuesPayload={mockMetricsRetention}
|
||||
tracesTtlValuesPayload={mockTracesRetention}
|
||||
logsTtlValuesPayload={mockLogsRetentionWithS3}
|
||||
getAvailableDiskPayload={mockDisksWithS3}
|
||||
metricsTtlValuesRefetch={jest.fn()}
|
||||
tracesTtlValuesRefetch={jest.fn()}
|
||||
logsTtlValuesRefetch={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Find the Logs card
|
||||
const logsCard = screen.getByText('Logs').closest('.ant-card');
|
||||
const inputs = logsCard?.querySelectorAll('input[type="text"]');
|
||||
|
||||
// Total retention: 720 hours = 30 days = 1 month (displays as 1 Month)
|
||||
const totalInput = inputs?.[0] as HTMLInputElement;
|
||||
expect(totalInput.value).toBe('1');
|
||||
|
||||
// S3 retention: 24 day
|
||||
const s3Input = inputs?.[1] as HTMLInputElement;
|
||||
expect(s3Input.value).toBe('24');
|
||||
|
||||
// Verify dropdowns: total shows Months, S3 shows Days
|
||||
const dropdowns = logsCard?.querySelectorAll('.ant-select-selection-item');
|
||||
expect(dropdowns?.[0]).toHaveTextContent('Months');
|
||||
expect(dropdowns?.[1]).toHaveTextContent('Days');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -34,12 +34,22 @@ interface ITimeUnitConversion {
|
||||
value: number;
|
||||
timeUnitValue: SettingPeriod;
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts hours value to the most relevant unit from the available units.
|
||||
* @param value - The value in hours
|
||||
* @param availableUnits - Optional array of available time units to consider. If not provided, all units are considered.
|
||||
* @returns The converted value and the selected time unit
|
||||
*/
|
||||
export const convertHoursValueToRelevantUnit = (
|
||||
value: number,
|
||||
availableUnits?: ITimeUnit[],
|
||||
): ITimeUnitConversion => {
|
||||
if (value)
|
||||
for (let idx = TimeUnits.length - 1; idx >= 0; idx -= 1) {
|
||||
const timeUnit = TimeUnits[idx];
|
||||
const unitsToConsider = availableUnits?.length ? availableUnits : TimeUnits;
|
||||
|
||||
if (value) {
|
||||
for (let idx = unitsToConsider.length - 1; idx >= 0; idx -= 1) {
|
||||
const timeUnit = unitsToConsider[idx];
|
||||
const convertedValue = timeUnit.multiplier * value;
|
||||
|
||||
if (
|
||||
@@ -49,7 +59,10 @@ export const convertHoursValueToRelevantUnit = (
|
||||
return { value: convertedValue, timeUnitValue: timeUnit.value };
|
||||
}
|
||||
}
|
||||
return { value, timeUnitValue: TimeUnits[0].value };
|
||||
}
|
||||
|
||||
// Fallback to the first available unit
|
||||
return { value, timeUnitValue: unitsToConsider[0].value };
|
||||
};
|
||||
|
||||
export const convertHoursValueToRelevantUnitString = (
|
||||
|
||||
@@ -0,0 +1,207 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import PanelWrapper from 'container/PanelWrapper/PanelWrapper';
|
||||
import { RowData } from 'lib/query/createTableColumnsFromQuery';
|
||||
import { MutableRefObject } from 'react';
|
||||
import { render, screen, waitFor } from 'tests/test-utils';
|
||||
import { Widgets } from 'types/api/dashboard/getAll';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock('container/PanelWrapper/constants', () => ({
|
||||
PanelTypeVsPanelWrapper: {
|
||||
[PANEL_TYPES.TIME_SERIES]: ({
|
||||
onDragSelect,
|
||||
}: {
|
||||
onDragSelect: (start: number, end: number) => void;
|
||||
}): JSX.Element => {
|
||||
const handleCanvasMouseDown = (): void => {
|
||||
// Simulate drag start
|
||||
const handleMouseMove = (): void => {
|
||||
// Simulate drag progress
|
||||
};
|
||||
|
||||
const handleMouseUp = (): void => {
|
||||
// Simulate drag end and call onDragSelect
|
||||
onDragSelect(1634325650, 1634325750);
|
||||
document.removeEventListener('mousemove', handleMouseMove);
|
||||
document.removeEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
document.addEventListener('mousemove', handleMouseMove);
|
||||
document.addEventListener('mouseup', handleMouseUp);
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid="mock-time-series-panel">
|
||||
<canvas
|
||||
data-testid="uplot-canvas"
|
||||
width={400}
|
||||
height={300}
|
||||
onMouseDown={handleCanvasMouseDown}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="drag-select-trigger"
|
||||
onClick={(): void => onDragSelect(1634325650, 1634325750)}
|
||||
>
|
||||
Trigger Drag Select
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock data
|
||||
const mockWidget: Widgets = {
|
||||
id: 'test-widget-id',
|
||||
query: {
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.METRICS,
|
||||
queryName: 'A',
|
||||
aggregateOperator: 'sum',
|
||||
aggregateAttribute: {
|
||||
key: 'test',
|
||||
dataType: DataTypes.Float64,
|
||||
type: '',
|
||||
},
|
||||
functions: [],
|
||||
groupBy: [],
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
having: [],
|
||||
limit: null,
|
||||
orderBy: [],
|
||||
stepInterval: 60,
|
||||
legend: '',
|
||||
spaceAggregation: 'sum',
|
||||
timeAggregation: 'sum',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
promql: [],
|
||||
clickhouse_sql: [],
|
||||
id: 'test-query-id',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
},
|
||||
panelTypes: PANEL_TYPES.TIME_SERIES,
|
||||
title: 'Test Widget',
|
||||
description: '',
|
||||
opacity: '',
|
||||
timePreferance: 'GLOBAL_TIME',
|
||||
nullZeroValues: '',
|
||||
yAxisUnit: '',
|
||||
fillSpans: false,
|
||||
softMin: null,
|
||||
softMax: null,
|
||||
selectedLogFields: [],
|
||||
selectedTracesFields: [],
|
||||
};
|
||||
|
||||
// Mock response data
|
||||
const mockQueryResponse: any = {
|
||||
data: {
|
||||
payload: {
|
||||
data: {
|
||||
result: [
|
||||
{
|
||||
metric: { __name__: 'test_metric' },
|
||||
values: [[1634325600, '42']],
|
||||
queryName: 'A',
|
||||
},
|
||||
],
|
||||
resultType: '',
|
||||
newResult: {
|
||||
data: {
|
||||
resultType: '',
|
||||
result: [
|
||||
{
|
||||
queryName: 'A',
|
||||
series: null,
|
||||
list: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
statusCode: 200,
|
||||
message: 'success',
|
||||
error: null,
|
||||
},
|
||||
isLoading: false,
|
||||
isError: false,
|
||||
error: null,
|
||||
isFetching: false,
|
||||
refetch: jest.fn(),
|
||||
};
|
||||
|
||||
describe('PanelWrapper with DragSelect', () => {
|
||||
const tableProcessedDataRef = { current: [] } as MutableRefObject<RowData[]>;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('simulates drag select on uPlot canvas', async () => {
|
||||
const mockOnDragSelect = jest.fn();
|
||||
|
||||
render(
|
||||
<PanelWrapper
|
||||
widget={mockWidget}
|
||||
queryResponse={mockQueryResponse}
|
||||
onDragSelect={mockOnDragSelect}
|
||||
selectedGraph={PANEL_TYPES.TIME_SERIES}
|
||||
tableProcessedDataRef={tableProcessedDataRef}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Verify the panel renders
|
||||
expect(screen.getByTestId('mock-time-series-panel')).toBeInTheDocument();
|
||||
|
||||
// Find the canvas element
|
||||
const canvas = screen.getByTestId('uplot-canvas');
|
||||
expect(canvas).toBeInTheDocument();
|
||||
|
||||
// Simulate drag events on the canvas
|
||||
// Start drag by dispatching mousedown
|
||||
canvas.dispatchEvent(
|
||||
new MouseEvent('mousedown', {
|
||||
clientX: 10,
|
||||
clientY: 10,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Simulate mouse move during drag
|
||||
canvas.dispatchEvent(
|
||||
new MouseEvent('mousemove', {
|
||||
clientX: 60,
|
||||
clientY: 60,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// End drag by dispatching mouseup
|
||||
canvas.dispatchEvent(
|
||||
new MouseEvent('mouseup', {
|
||||
clientX: 80,
|
||||
clientY: 80,
|
||||
bubbles: true,
|
||||
}),
|
||||
);
|
||||
|
||||
// Wait for the onDragSelect to be called
|
||||
await waitFor(() => {
|
||||
expect(mockOnDragSelect).toHaveBeenCalledWith(1634325650, 1634325750);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -38,9 +38,7 @@ import { isEmpty } from 'lodash-es';
|
||||
import { useAppContext } from 'providers/App/App';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { UpdateTimeInterval } from 'store/actions';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
import { Warning } from 'types/api';
|
||||
import { GlobalReducer } from 'types/reducer/globalTime';
|
||||
@@ -67,13 +65,11 @@ function FullView({
|
||||
enableDrillDown = false,
|
||||
}: FullViewProps): JSX.Element {
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
const { selectedTime: globalSelectedTime } = useSelector<
|
||||
const { selectedTime: globalSelectedTime, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const dispatch = useDispatch();
|
||||
const urlQuery = useUrlQuery();
|
||||
const location = useLocation();
|
||||
|
||||
const fullViewRef = useRef<HTMLDivElement>(null);
|
||||
const { handleRunQuery } = useQueryBuilder();
|
||||
@@ -154,11 +150,16 @@ function FullView({
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
const timeRange =
|
||||
selectedTime.enum !== 'GLOBAL_TIME'
|
||||
? { start: undefined, end: undefined }
|
||||
: { start: Math.floor(minTime / 1e9), end: Math.floor(maxTime / 1e9) };
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
selectedTime: selectedTime.enum,
|
||||
...timeRange,
|
||||
}));
|
||||
}, [selectedTime]);
|
||||
}, [selectedTime, minTime, maxTime]);
|
||||
|
||||
// Update requestData when panel type changes
|
||||
useEffect(() => {
|
||||
@@ -181,38 +182,34 @@ function FullView({
|
||||
});
|
||||
}, [selectedPanelType]);
|
||||
|
||||
const response = useGetQueryRange(
|
||||
requestData,
|
||||
// selectedDashboard?.data?.version || version || DEFAULT_ENTITY_VERSION,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
queryKey: [widget?.query, selectedPanelType, requestData, version],
|
||||
enabled: !isDependedDataLoaded,
|
||||
keepPreviousData: true,
|
||||
},
|
||||
);
|
||||
const response = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
queryKey: [
|
||||
widget?.query,
|
||||
selectedPanelType,
|
||||
requestData,
|
||||
version,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
enabled: !isDependedDataLoaded,
|
||||
keepPreviousData: true,
|
||||
});
|
||||
|
||||
const onDragSelect = useCallback(
|
||||
(start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
const onDragSelect = useCallback((start: number, end: number): void => {
|
||||
const startTimestamp = Math.trunc(start);
|
||||
const endTimestamp = Math.trunc(end);
|
||||
|
||||
if (startTimestamp !== endTimestamp) {
|
||||
dispatch(UpdateTimeInterval('custom', [startTimestamp, endTimestamp]));
|
||||
}
|
||||
const { maxTime, minTime } = GetMinMax('custom', [
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
]);
|
||||
|
||||
const { maxTime, minTime } = GetMinMax('custom', [
|
||||
startTimestamp,
|
||||
endTimestamp,
|
||||
]);
|
||||
|
||||
urlQuery.set(QueryParams.startTime, minTime.toString());
|
||||
urlQuery.set(QueryParams.endTime, maxTime.toString());
|
||||
const generatedUrl = `${location.pathname}?${urlQuery.toString()}`;
|
||||
safeNavigate(generatedUrl);
|
||||
},
|
||||
[dispatch, location.pathname, safeNavigate, urlQuery],
|
||||
);
|
||||
setRequestData((prev) => ({
|
||||
...prev,
|
||||
start: Math.floor(minTime / 1e9),
|
||||
end: Math.floor(maxTime / 1e9),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const [graphsVisibilityStates, setGraphsVisibilityStates] = useState<
|
||||
boolean[]
|
||||
|
||||
@@ -350,47 +350,51 @@ function ListAlert({ allAlertRules, refetch }: ListAlertProps): JSX.Element {
|
||||
key: 'action',
|
||||
width: 10,
|
||||
render: (id: GettableAlert['id'], record): JSX.Element => (
|
||||
<DropDown
|
||||
onDropDownItemClick={(item): void => alertActionLogEvent(item.key, record)}
|
||||
element={[
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(): void => onEditHandler(record, false)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={(): void => onEditHandler(record, true)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
<div data-testid="alert-actions">
|
||||
<DropDown
|
||||
onDropDownItemClick={(item): void =>
|
||||
alertActionLogEvent(item.key, record)
|
||||
}
|
||||
element={[
|
||||
<ToggleAlertState
|
||||
key="1"
|
||||
disabled={record.disabled}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
<ColumnButton
|
||||
key="2"
|
||||
onClick={(): void => onEditHandler(record, false)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={(): void => onEditHandler(record, true)}
|
||||
type="link"
|
||||
loading={editLoader}
|
||||
>
|
||||
Edit in New Tab
|
||||
</ColumnButton>,
|
||||
<ColumnButton
|
||||
key="3"
|
||||
onClick={onCloneHandler(record)}
|
||||
type="link"
|
||||
loading={cloneLoader}
|
||||
>
|
||||
Clone
|
||||
</ColumnButton>,
|
||||
<DeleteAlert
|
||||
key="4"
|
||||
notifications={notificationsApi}
|
||||
setData={setData}
|
||||
id={id}
|
||||
/>,
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@ import dompurify from 'dompurify';
|
||||
import { uniqueId } from 'lodash-es';
|
||||
import { ILog, ILogAggregateAttributesResources } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
import { FORBID_DOM_PURIFY_ATTR, FORBID_DOM_PURIFY_TAGS } from 'utils/app';
|
||||
|
||||
import BodyTitleRenderer from './BodyTitleRenderer';
|
||||
import { typeToArrayTypeMapper } from './config';
|
||||
@@ -352,6 +352,7 @@ export const getSanitizedLogBody = (
|
||||
return convertInstance.toHtml(
|
||||
dompurify.sanitize(unescapeString(escapedText), {
|
||||
FORBID_TAGS: [...FORBID_DOM_PURIFY_TAGS],
|
||||
FORBID_ATTR: [...FORBID_DOM_PURIFY_ATTR],
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
|
||||
@@ -0,0 +1,379 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { Query, TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
import {
|
||||
DataSource,
|
||||
QueryBuilderContextType,
|
||||
ReduceOperators,
|
||||
} from 'types/common/queryBuilder';
|
||||
|
||||
import useInitialQuery from '../useInitialQuery';
|
||||
|
||||
// Mock the queryBuilder hook
|
||||
jest.mock('hooks/queryBuilder/useQueryBuilder', () => ({
|
||||
useQueryBuilder: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock the convertFiltersToExpression utility
|
||||
jest.mock('components/QueryBuilderV2/utils', () => ({
|
||||
convertFiltersToExpression: jest.fn(),
|
||||
}));
|
||||
|
||||
// Mock uuid for consistent testing
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() => 'test-uuid'),
|
||||
}));
|
||||
|
||||
// Type the mocked functions
|
||||
const mockedUseQueryBuilder = jest.mocked(useQueryBuilder);
|
||||
const mockedConvertFiltersToExpression = jest.mocked(
|
||||
convertFiltersToExpression,
|
||||
);
|
||||
|
||||
describe('useInitialQuery - Priority-Based Resource Filtering', () => {
|
||||
const mockUpdateAllQueriesOperators = jest.fn();
|
||||
const mockBaseQuery: Query = {
|
||||
id: 'test-query',
|
||||
queryType: EQueryType.QUERY_BUILDER,
|
||||
builder: {
|
||||
queryData: [
|
||||
{
|
||||
dataSource: DataSource.LOGS,
|
||||
aggregateOperator: '',
|
||||
aggregateAttribute: {
|
||||
key: '',
|
||||
dataType: DataTypes.String,
|
||||
type: '',
|
||||
},
|
||||
timeAggregation: '',
|
||||
spaceAggregation: '',
|
||||
functions: [],
|
||||
filters: {
|
||||
items: [],
|
||||
op: 'AND',
|
||||
},
|
||||
groupBy: [],
|
||||
having: [],
|
||||
orderBy: [],
|
||||
limit: null,
|
||||
offset: 0,
|
||||
pageSize: 0,
|
||||
stepInterval: 60,
|
||||
queryName: 'A',
|
||||
expression: 'A',
|
||||
disabled: false,
|
||||
reduceTo: 'avg' as ReduceOperators,
|
||||
legend: '',
|
||||
},
|
||||
],
|
||||
queryFormulas: [],
|
||||
queryTraceOperator: [],
|
||||
},
|
||||
clickhouse_sql: [],
|
||||
promql: [],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Setup useQueryBuilder mock - only mock what we need
|
||||
mockedUseQueryBuilder.mockReturnValue(({
|
||||
updateAllQueriesOperators: mockUpdateAllQueriesOperators,
|
||||
} as Partial<QueryBuilderContextType>) as QueryBuilderContextType);
|
||||
|
||||
// Setup the mock to return base query
|
||||
mockUpdateAllQueriesOperators.mockReturnValue(mockBaseQuery);
|
||||
|
||||
// Setup convertFiltersToExpression mock
|
||||
mockedConvertFiltersToExpression.mockReturnValue({
|
||||
expression: 'test-expression',
|
||||
});
|
||||
});
|
||||
|
||||
// Helper function to create test log with resources
|
||||
const createTestLog = (resources: Record<string, string>): ILog => ({
|
||||
date: '2023-10-20',
|
||||
timestamp: 1697788800000,
|
||||
id: 'test-log-id',
|
||||
traceId: 'test-trace-id',
|
||||
spanID: 'test-span-id',
|
||||
span_id: 'test-span-id',
|
||||
traceFlags: 0,
|
||||
severityText: 'INFO',
|
||||
severityNumber: 9,
|
||||
body: 'Test log message',
|
||||
resources_string: resources as Record<string, never>,
|
||||
scope_string: {},
|
||||
attributesString: {},
|
||||
attributes_string: {},
|
||||
attributesInt: {},
|
||||
attributesFloat: {},
|
||||
severity_text: 'INFO',
|
||||
severity_number: 9,
|
||||
});
|
||||
|
||||
// Helper function to assert that specific keys are NOT present in filter items
|
||||
const assertKeysNotPresent = (
|
||||
items: TagFilterItem[],
|
||||
excludedKeys: string[],
|
||||
): void => {
|
||||
excludedKeys.forEach((key) => {
|
||||
const found = items.find((item) => item.key?.key === key);
|
||||
expect(found).toBeUndefined();
|
||||
});
|
||||
};
|
||||
|
||||
describe('K8s Environment Context Flow', () => {
|
||||
it('should include service.name and k8s.pod.name when user opens log context from Kubernetes pod', () => {
|
||||
// Log from k8s pod with multiple resource attributes
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'frontend-service',
|
||||
'deployment.environment': 'production',
|
||||
'k8s.pod.name': 'frontend-pod-abc123',
|
||||
'k8s.pod.uid': 'pod-uid-xyz789',
|
||||
'k8s.deployment.name': 'frontend-deployment',
|
||||
'host.name': 'worker-node-1',
|
||||
'container.id': 'container-abc123',
|
||||
'random.attribute': 'should-be-filtered-out',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes only service.name + first k8s priority item
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
// Verify that updateAllQueriesOperators was called with correct params
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledWith(
|
||||
expect.any(Object), // initialQueriesMap.logs
|
||||
'list', // PANEL_TYPES.LIST
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
// Verify convertFiltersToExpression was called
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
// eslint-disable-next-line sonarjs/no-duplicate-string
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'frontend-service',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'deployment.environment' }),
|
||||
value: 'production',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'k8s.pod.uid' }), // First priority k8s item
|
||||
value: 'pod-uid-xyz789',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 3)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(3);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, [
|
||||
'k8s.pod.name', // Other k8s attributes should be excluded
|
||||
'k8s.deployment.name',
|
||||
'host.name', // Lower priority attributes should be excluded
|
||||
'container.id',
|
||||
'random.attribute', // Non-matching attributes should be excluded
|
||||
]);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Cloud Environment Flow', () => {
|
||||
it('should include service.name and cloud.resource_id when user opens log context from cloud service without k8s', () => {
|
||||
// Log from cloud service (no k8s attributes)
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'api-gateway',
|
||||
env: 'staging',
|
||||
'cloud.resource_id': 'i-0abcdef1234567890',
|
||||
'cloud.provider': 'aws',
|
||||
'cloud.region': 'us-east-1',
|
||||
'host.name': 'ip-10-0-1-100',
|
||||
'host.id': 'host-xyz123',
|
||||
'unnecessary.tag': 'filtered-out',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes service + env + first cloud priority item (skips host due to priority)
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'api-gateway',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'env' }),
|
||||
value: 'staging',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'cloud.resource_id' }), // First priority cloud item
|
||||
value: 'i-0abcdef1234567890',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 3)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(3);
|
||||
|
||||
// Verify host attributes are NOT included due to lower priority
|
||||
const hostItems = calledWith.items.filter((item: TagFilterItem) =>
|
||||
item.key?.key?.startsWith('host.'),
|
||||
);
|
||||
expect(hostItems).toHaveLength(0);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, [
|
||||
'cloud.provider',
|
||||
'cloud.region',
|
||||
'host.name',
|
||||
'host.id',
|
||||
'unnecessary.tag',
|
||||
]);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Fallback Environment Flow', () => {
|
||||
it('should include service.name and deployment.name when user opens log context from basic deployment without priority attributes', () => {
|
||||
// Log from basic deployment (no k8s, cloud, host, or container)
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'legacy-app',
|
||||
'deployment.environment': 'production',
|
||||
'deployment.name': 'legacy-deployment',
|
||||
'file.path': '/var/log/app.log',
|
||||
'random.key': 'ignored',
|
||||
'another.attribute': 'also-ignored',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes service + environment + fallback regex matches
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'legacy-app',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'deployment.environment' }),
|
||||
value: 'production',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'deployment.name' }), // Fallback regex match
|
||||
value: 'legacy-deployment',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'file.path' }), // Fallback regex match
|
||||
value: '/var/log/app.log',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 4)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(4);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, ['random.key', 'another.attribute']);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Service-Only Minimal Flow', () => {
|
||||
it('should include at least service.name when user opens log context with minimal attributes', () => {
|
||||
// Log with only service and unmatched attributes
|
||||
const testLog = createTestLog({
|
||||
'service.name': 'minimal-service',
|
||||
'custom.tag': 'business-value',
|
||||
'user.id': 'user-123',
|
||||
'request.id': 'req-abc',
|
||||
});
|
||||
|
||||
// User opens log context (hook executes)
|
||||
const { result } = renderHook(() => useInitialQuery(testLog));
|
||||
|
||||
// Query includes at least service.name (essential for filtering)
|
||||
const generatedQuery = result.current;
|
||||
expect(generatedQuery).toBeDefined();
|
||||
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
items: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
key: expect.objectContaining({ key: 'service.name' }),
|
||||
value: 'minimal-service',
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
);
|
||||
|
||||
// Verify exact count of filter items (should be exactly 1)
|
||||
const calledWith = mockedConvertFiltersToExpression.mock.calls[0][0];
|
||||
expect(calledWith.items).toHaveLength(1);
|
||||
|
||||
// Verify that service.name is included
|
||||
const serviceItems = calledWith.items.filter(
|
||||
(item: TagFilterItem) => item.key?.key === 'service.name',
|
||||
);
|
||||
expect(serviceItems.length).toBe(1);
|
||||
|
||||
// Verify no priority items (k8s, cloud, host, container) are included
|
||||
const priorityItems = calledWith.items.filter(
|
||||
(item: TagFilterItem) =>
|
||||
item.key?.key &&
|
||||
(item.key.key.startsWith('k8s.') ||
|
||||
item.key.key.startsWith('cloud.') ||
|
||||
item.key.key.startsWith('host.') ||
|
||||
item.key.key.startsWith('container.')),
|
||||
);
|
||||
expect(priorityItems).toHaveLength(0);
|
||||
|
||||
// Verify specific unwanted keys are excluded
|
||||
assertKeysNotPresent(calledWith.items, [
|
||||
'custom.tag', // Non-matching attributes should be excluded
|
||||
'user.id',
|
||||
'request.id',
|
||||
]);
|
||||
|
||||
// Verify exact call counts to catch unintended multiple invocations
|
||||
expect(mockedConvertFiltersToExpression).toHaveBeenCalledTimes(1);
|
||||
expect(mockUpdateAllQueriesOperators).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -2,13 +2,10 @@ import { convertFiltersToExpression } from 'components/QueryBuilderV2/utils';
|
||||
import { initialQueriesMap, PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { Query, TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { getFiltersFromResources } from './utils';
|
||||
|
||||
const RESOURCE_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
|
||||
const RESOURCE_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the spefied keywords
|
||||
import { getFiltersFromResources, updateFilters } from './utils';
|
||||
|
||||
const useInitialQuery = (log: ILog): Query => {
|
||||
const { updateAllQueriesOperators } = useQueryBuilder();
|
||||
@@ -20,16 +17,6 @@ const useInitialQuery = (log: ILog): Query => {
|
||||
DataSource.LOGS,
|
||||
);
|
||||
|
||||
const updateFilters = (filters: TagFilter): TagFilter => ({
|
||||
...filters,
|
||||
items: filters.items.filter(
|
||||
(filterItem) =>
|
||||
filterItem.key?.key &&
|
||||
(RESOURCE_STARTS_WITH_REGEX.test(filterItem.key.key) ||
|
||||
RESOURCE_CONTAINS_REGEX.test(filterItem.key.key)),
|
||||
),
|
||||
});
|
||||
|
||||
const data: Query = {
|
||||
...updatedAllQueriesOperator,
|
||||
builder: {
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
import { OPERATORS } from 'constants/queryBuilder';
|
||||
import { ILog } from 'types/api/logs/log';
|
||||
import { DataTypes } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { TagFilterItem } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import {
|
||||
TagFilter,
|
||||
TagFilterItem,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
|
||||
const FALLBACK_STARTS_WITH_REGEX = /^(k8s|cloud|host|deployment)/; // regex to filter out resources that start with the specified keywords
|
||||
const FALLBACK_CONTAINS_REGEX = /(env|service|file|container|tenant)/; // regex to filter out resources that contains the specified keywords
|
||||
|
||||
// Priority categories for filter selection
|
||||
// Strategy:
|
||||
// - Always include: service.name, deployment.environment, env, environment
|
||||
// - Select ONE category only: stops at the first category with a matching attribute
|
||||
// - Within category: picks the first available attribute by order
|
||||
// - Order (highest to lowest priority): Kubernetes > Cloud > Host > Container
|
||||
// - Fallback: If no priority match, uses regex-based filtering (excludes the above attributes)
|
||||
const PRIORITY_CATEGORIES = [
|
||||
['k8s.pod.uid', 'k8s.pod.name', 'k8s.deployment.name'],
|
||||
['cloud.resource_id', 'cloud.provider', 'cloud.region'],
|
||||
['host.id', 'host.name'],
|
||||
['container.id', 'container.name'],
|
||||
];
|
||||
|
||||
const SERVICE_AND_ENVIRONMENT_KEYS = [
|
||||
'service.name',
|
||||
'deployment.environment',
|
||||
'env',
|
||||
'environment',
|
||||
];
|
||||
|
||||
export const getFiltersFromResources = (
|
||||
resources: ILog['resources_string'],
|
||||
): TagFilterItem[] =>
|
||||
@@ -20,3 +47,59 @@ export const getFiltersFromResources = (
|
||||
value: resourceValue,
|
||||
};
|
||||
});
|
||||
|
||||
export const isServiceOrEnvironmentAttribute = (key: string): boolean =>
|
||||
SERVICE_AND_ENVIRONMENT_KEYS.includes(key);
|
||||
|
||||
export const getServiceAndEnvironmentFilterItems = (
|
||||
items: TagFilterItem[],
|
||||
): TagFilterItem[] =>
|
||||
items.filter(
|
||||
(item) => item.key?.key && isServiceOrEnvironmentAttribute(item.key.key),
|
||||
);
|
||||
|
||||
export const findFirstPriorityItem = (
|
||||
items: TagFilterItem[],
|
||||
): TagFilterItem | undefined =>
|
||||
PRIORITY_CATEGORIES.flat()
|
||||
.map((priorityKey) => items.find((item) => item.key?.key === priorityKey))
|
||||
.find(Boolean);
|
||||
|
||||
export const getFallbackItems = (items: TagFilterItem[]): TagFilterItem[] =>
|
||||
items.filter((item) => {
|
||||
if (!item.key?.key) return false;
|
||||
|
||||
const { key } = item.key;
|
||||
|
||||
return (
|
||||
FALLBACK_STARTS_WITH_REGEX.test(key) || FALLBACK_CONTAINS_REGEX.test(key)
|
||||
);
|
||||
});
|
||||
|
||||
export const updateFilters = (filters: TagFilter): TagFilter => {
|
||||
const availableItems = filters.items;
|
||||
const selectedItems: TagFilterItem[] = [];
|
||||
|
||||
// Step 1: Always include service.name and environment attributes
|
||||
selectedItems.push(...getServiceAndEnvironmentFilterItems(availableItems));
|
||||
|
||||
// Step 2: Find first category with attributes and pick first available
|
||||
const priorityItem = findFirstPriorityItem(availableItems);
|
||||
if (priorityItem) {
|
||||
selectedItems.push(priorityItem);
|
||||
} else {
|
||||
// Step 3: Fallback to current regex logic (only if no priority items found)
|
||||
const fallbackItems = getFallbackItems(availableItems);
|
||||
if (fallbackItems.length > 0) {
|
||||
selectedItems.push(...fallbackItems);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...filters,
|
||||
// deduplication
|
||||
items: Array.from(
|
||||
new Map(selectedItems.map((item) => [item.key?.key || '', item])).values(),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -5,12 +5,15 @@ import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { UpdateMetricMetadataProps } from 'api/metricsExplorer/updateMetricMetadata';
|
||||
import { ResizeTable } from 'components/ResizeTable';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import FieldRenderer from 'container/LogDetailedView/FieldRenderer';
|
||||
import { DataType } from 'container/LogDetailedView/TableView';
|
||||
import { useUpdateMetricMetadata } from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { Edit2, Save, X } from 'lucide-react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useQueryClient } from 'react-query';
|
||||
|
||||
import { MetricsExplorerEventKeys, MetricsExplorerEvents } from '../events';
|
||||
import {
|
||||
@@ -35,6 +38,7 @@ function Metadata({
|
||||
metricType: metadata?.metric_type || MetricType.SUM,
|
||||
description: metadata?.description || '',
|
||||
temporality: metadata?.temporality,
|
||||
unit: metadata?.unit,
|
||||
});
|
||||
const { notifications } = useNotifications();
|
||||
const {
|
||||
@@ -44,6 +48,7 @@ function Metadata({
|
||||
const [activeKey, setActiveKey] = useState<string | string[]>(
|
||||
'metric-metadata',
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const tableData = useMemo(
|
||||
() =>
|
||||
@@ -65,6 +70,101 @@ function Metadata({
|
||||
[metadata],
|
||||
);
|
||||
|
||||
// Render un-editable field value
|
||||
const renderUneditableField = useCallback((key: string, value: string) => {
|
||||
if (key === 'metric_type') {
|
||||
return <MetricTypeRenderer type={value as MetricType} />;
|
||||
}
|
||||
let fieldValue = value;
|
||||
if (key === 'unit') {
|
||||
fieldValue = getUniversalNameFromMetricUnit(value);
|
||||
}
|
||||
return <FieldRenderer field={fieldValue || '-'} />;
|
||||
}, []);
|
||||
|
||||
const renderColumnValue = useCallback(
|
||||
(field: { value: string; key: string }): JSX.Element => {
|
||||
if (!isEditing) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
// Don't allow editing of unit if it's already set
|
||||
const metricUnitAlreadySet = field.key === 'unit' && Boolean(metadata?.unit);
|
||||
if (metricUnitAlreadySet) {
|
||||
return renderUneditableField(field.key, field.value);
|
||||
}
|
||||
|
||||
if (field.key === 'metric_type') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="metric-type-select"
|
||||
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
|
||||
value: key,
|
||||
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
}))}
|
||||
value={metricMetadata.metricType}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
metricType: value as MetricType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'unit') {
|
||||
return (
|
||||
<YAxisUnitSelector
|
||||
value={metricMetadata.unit}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({ ...prev, unit: value }));
|
||||
}}
|
||||
data-testid="unit-select"
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'temporality') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="temporality-select"
|
||||
options={Object.values(Temporality).map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}))}
|
||||
value={metricMetadata.temporality}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
temporality: value as Temporality,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'description') {
|
||||
return (
|
||||
<Input
|
||||
data-testid="description-input"
|
||||
name={field.key}
|
||||
defaultValue={
|
||||
metricMetadata[
|
||||
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
|
||||
]
|
||||
}
|
||||
onChange={(e): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return <FieldRenderer field="-" />;
|
||||
},
|
||||
[isEditing, metadata?.unit, metricMetadata, renderUneditableField],
|
||||
);
|
||||
|
||||
const columns: ColumnsType<DataType> = useMemo(
|
||||
() => [
|
||||
{
|
||||
@@ -90,74 +190,10 @@ function Metadata({
|
||||
align: 'left',
|
||||
ellipsis: true,
|
||||
className: 'metric-metadata-value',
|
||||
render: (field: { value: string; key: string }): JSX.Element => {
|
||||
if (!isEditing || field.key === 'unit') {
|
||||
if (field.key === 'metric_type') {
|
||||
return (
|
||||
<div>
|
||||
<MetricTypeRenderer type={field.value as MetricType} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <FieldRenderer field={field.value || '-'} />;
|
||||
}
|
||||
if (field.key === 'metric_type') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="metric-type-select"
|
||||
options={Object.entries(METRIC_TYPE_VALUES_MAP).map(([key]) => ({
|
||||
value: key,
|
||||
label: METRIC_TYPE_LABEL_MAP[key as MetricType],
|
||||
}))}
|
||||
defaultValue={metricMetadata.metricType}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
metricType: value as MetricType,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (field.key === 'temporality') {
|
||||
return (
|
||||
<Select
|
||||
data-testid="temporality-select"
|
||||
options={Object.values(Temporality).map((key) => ({
|
||||
value: key,
|
||||
label: key,
|
||||
}))}
|
||||
defaultValue={metricMetadata.temporality}
|
||||
onChange={(value): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
temporality: value as Temporality,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Input
|
||||
data-testid="description-input"
|
||||
name={field.key}
|
||||
defaultValue={
|
||||
metricMetadata[
|
||||
field.key as Exclude<keyof UpdateMetricMetadataProps, 'isMonotonic'>
|
||||
]
|
||||
}
|
||||
onChange={(e): void => {
|
||||
setMetricMetadata((prev) => ({
|
||||
...prev,
|
||||
[field.key]: e.target.value,
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
);
|
||||
},
|
||||
render: renderColumnValue,
|
||||
},
|
||||
],
|
||||
[isEditing, metricMetadata, setMetricMetadata],
|
||||
[renderColumnValue],
|
||||
);
|
||||
|
||||
const handleSave = useCallback(() => {
|
||||
@@ -185,6 +221,7 @@ function Metadata({
|
||||
});
|
||||
refetchMetricDetails();
|
||||
setIsEditing(false);
|
||||
queryClient.invalidateQueries(['metricsList']);
|
||||
} else {
|
||||
notifications.error({
|
||||
message:
|
||||
@@ -205,6 +242,7 @@ function Metadata({
|
||||
metricMetadata,
|
||||
notifications,
|
||||
refetchMetricDetails,
|
||||
queryClient,
|
||||
]);
|
||||
|
||||
const actionButton = useMemo(() => {
|
||||
|
||||
@@ -224,10 +224,6 @@
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.metric-type-renderer {
|
||||
max-height: 12px;
|
||||
}
|
||||
|
||||
.metric-metadata-key {
|
||||
cursor: pointer;
|
||||
padding-left: 10px;
|
||||
@@ -391,3 +387,11 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.metric-metadata-value {
|
||||
.y-axis-unit-selector-component {
|
||||
.ant-select {
|
||||
width: auto !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,11 +23,15 @@ const mockAlerts = [mockAlert1, mockAlert2];
|
||||
const mockDashboards = [mockDashboard1, mockDashboard2];
|
||||
|
||||
const mockSafeNavigate = jest.fn();
|
||||
jest.mock('hooks/useSafeNavigate', () => ({
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
}));
|
||||
jest.mock('hooks/useSafeNavigate', () => {
|
||||
const actual = jest.requireActual('hooks/useSafeNavigate');
|
||||
return {
|
||||
...actual,
|
||||
useSafeNavigate: (): any => ({
|
||||
safeNavigate: mockSafeNavigate,
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const mockSetQuery = jest.fn();
|
||||
const mockUrlQuery = {
|
||||
|
||||
@@ -2,11 +2,76 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { Temporality } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import {
|
||||
UniversalYAxisUnit,
|
||||
YAxisUnitSelectorProps,
|
||||
} from 'components/YAxisUnitSelector/types';
|
||||
import * as useUpdateMetricMetadataHooks from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
import * as useNotificationsHooks from 'hooks/useNotifications';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
import Metadata from '../Metadata';
|
||||
|
||||
// Mock antd select for testing
|
||||
jest.mock('antd', () => ({
|
||||
...jest.requireActual('antd'),
|
||||
Select: ({
|
||||
children,
|
||||
onChange,
|
||||
value,
|
||||
'data-testid': dataTestId,
|
||||
options,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
'data-testid': string;
|
||||
options: SelectOption<string, string>[];
|
||||
}): JSX.Element => (
|
||||
<select
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={(e): void => onChange?.(e.target.value)}
|
||||
>
|
||||
{options?.map((option: SelectOption<string, string>) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
{children}
|
||||
</select>
|
||||
),
|
||||
}));
|
||||
jest.mock(
|
||||
'components/YAxisUnitSelector',
|
||||
() =>
|
||||
function MockYAxisUnitSelector({
|
||||
onChange,
|
||||
value,
|
||||
'data-testid': dataTestId,
|
||||
}: YAxisUnitSelectorProps): JSX.Element {
|
||||
return (
|
||||
<select
|
||||
data-testid={dataTestId}
|
||||
value={value}
|
||||
onChange={(e): void => onChange?.(e.target.value as UniversalYAxisUnit)}
|
||||
>
|
||||
<option value="">Please select a unit</option>
|
||||
<option value="By">Bytes (B)</option>
|
||||
<option value="s">Seconds (s)</option>
|
||||
<option value="ms">Milliseconds (ms)</option>
|
||||
</select>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): { invalidateQueries: () => void } => ({
|
||||
invalidateQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockUseUpdateMetricMetadata = jest.fn();
|
||||
jest
|
||||
.spyOn(useUpdateMetricMetadataHooks, 'useUpdateMetricMetadata')
|
||||
@@ -75,7 +140,10 @@ describe('Metadata', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
metadata={{
|
||||
...mockMetricMetadata,
|
||||
unit: '',
|
||||
}}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
@@ -90,6 +158,24 @@ describe('Metadata', () => {
|
||||
target: { value: 'Updated description' },
|
||||
});
|
||||
|
||||
const metricTypeSelect = screen.getByTestId('metric-type-select');
|
||||
expect(metricTypeSelect).toBeInTheDocument();
|
||||
fireEvent.change(metricTypeSelect, {
|
||||
target: { value: MetricType.SUM },
|
||||
});
|
||||
|
||||
const temporalitySelect = screen.getByTestId('temporality-select');
|
||||
expect(temporalitySelect).toBeInTheDocument();
|
||||
fireEvent.change(temporalitySelect, {
|
||||
target: { value: Temporality.CUMULATIVE },
|
||||
});
|
||||
|
||||
const unitSelect = screen.getByTestId('unit-select');
|
||||
expect(unitSelect).toBeInTheDocument();
|
||||
fireEvent.change(unitSelect, {
|
||||
target: { value: 'By' },
|
||||
});
|
||||
|
||||
const saveButton = screen.getByText('Save');
|
||||
expect(saveButton).toBeInTheDocument();
|
||||
fireEvent.click(saveButton);
|
||||
@@ -99,6 +185,10 @@ describe('Metadata', () => {
|
||||
metricName: mockMetricName,
|
||||
payload: expect.objectContaining({
|
||||
description: 'Updated description',
|
||||
metricType: MetricType.SUM,
|
||||
temporality: Temporality.CUMULATIVE,
|
||||
unit: 'By',
|
||||
isMonotonic: true,
|
||||
}),
|
||||
}),
|
||||
expect.objectContaining({
|
||||
@@ -219,4 +309,21 @@ describe('Metadata', () => {
|
||||
const editButton2 = screen.getByText('Edit');
|
||||
expect(editButton2).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not allow editing of unit if it is already set', () => {
|
||||
render(
|
||||
<Metadata
|
||||
metricName={mockMetricName}
|
||||
metadata={mockMetricMetadata}
|
||||
refetchMetricDetails={mockRefetchMetricDetails}
|
||||
/>,
|
||||
);
|
||||
|
||||
const editButton = screen.getByText('Edit');
|
||||
expect(editButton).toBeInTheDocument();
|
||||
fireEvent.click(editButton);
|
||||
|
||||
const unitSelect = screen.queryByTestId('unit-select');
|
||||
expect(unitSelect).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { MetricDetails as MetricDetailsType } from 'api/metricsExplorer/getMetricDetails';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import ROUTES from 'constants/routes';
|
||||
import * as useGetMetricDetails from 'hooks/metricsExplorer/useGetMetricDetails';
|
||||
import * as useUpdateMetricMetadata from 'hooks/metricsExplorer/useUpdateMetricMetadata';
|
||||
@@ -80,6 +81,12 @@ jest.mock('hooks/useSafeNavigate', () => ({
|
||||
safeNavigate: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
jest.mock('react-query', () => ({
|
||||
...jest.requireActual('react-query'),
|
||||
useQueryClient: (): { invalidateQueries: () => void } => ({
|
||||
invalidateQueries: jest.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('MetricDetails', () => {
|
||||
it('renders metric details correctly', () => {
|
||||
@@ -95,7 +102,9 @@ describe('MetricDetails', () => {
|
||||
|
||||
expect(screen.getByText(mockMetricName)).toBeInTheDocument();
|
||||
expect(screen.getByText(mockMetricDescription)).toBeInTheDocument();
|
||||
expect(screen.getByText(`${mockMetricData.unit}`)).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText(getUniversalNameFromMetricUnit(mockMetricData.unit)),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the "open in explorer" and "inspect" buttons', () => {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Color } from '@signozhq/design-tokens';
|
||||
import { render } from '@testing-library/react';
|
||||
import { MetricType } from 'api/metricsExplorer/getMetricsList';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import { TagFilter } from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { TreemapViewType } from '../types';
|
||||
@@ -144,7 +145,7 @@ describe('formatDataForMetricsTable', () => {
|
||||
// Verify unit rendering
|
||||
const unitElement = result[0].unit as JSX.Element;
|
||||
const { container: unitWrapper } = render(unitElement);
|
||||
expect(unitWrapper.textContent).toBe('bytes');
|
||||
expect(unitWrapper.textContent).toBe(getUniversalNameFromMetricUnit('bytes'));
|
||||
|
||||
// Verify samples rendering
|
||||
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;
|
||||
@@ -162,10 +163,10 @@ describe('formatDataForMetricsTable', () => {
|
||||
it('should handle empty/null values', () => {
|
||||
const mockData = [
|
||||
{
|
||||
metric_name: 'test-metric',
|
||||
description: 'test-description',
|
||||
metric_name: '',
|
||||
description: '',
|
||||
type: MetricType.GAUGE,
|
||||
unit: 'ms',
|
||||
unit: '',
|
||||
[TreemapViewType.SAMPLES]: 0,
|
||||
[TreemapViewType.TIMESERIES]: 0,
|
||||
lastReceived: '2023-01-01T00:00:00Z',
|
||||
@@ -177,17 +178,17 @@ describe('formatDataForMetricsTable', () => {
|
||||
// Verify empty metric name rendering
|
||||
const metricNameElement = result[0].metric_name as JSX.Element;
|
||||
const { container: metricNameWrapper } = render(metricNameElement);
|
||||
expect(metricNameWrapper.textContent).toBe('test-metric');
|
||||
expect(metricNameWrapper.textContent).toBe('-');
|
||||
|
||||
// Verify null description rendering
|
||||
const descriptionElement = result[0].description as JSX.Element;
|
||||
const { container: descriptionWrapper } = render(descriptionElement);
|
||||
expect(descriptionWrapper.textContent).toBe('test-description');
|
||||
expect(descriptionWrapper.textContent).toBe('-');
|
||||
|
||||
// Verify null unit rendering
|
||||
const unitElement = result[0].unit as JSX.Element;
|
||||
const { container: unitWrapper } = render(unitElement);
|
||||
expect(unitWrapper.textContent).toBe('ms');
|
||||
expect(unitWrapper.textContent).toBe('-');
|
||||
|
||||
// Verify zero samples rendering
|
||||
const samplesElement = result[0][TreemapViewType.SAMPLES] as JSX.Element;
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
SamplesData,
|
||||
TimeseriesData,
|
||||
} from 'api/metricsExplorer/getMetricsTreeMap';
|
||||
import { getUniversalNameFromMetricUnit } from 'components/YAxisUnitSelector/utils';
|
||||
import {
|
||||
BarChart,
|
||||
BarChart2,
|
||||
@@ -199,8 +200,8 @@ export const formatDataForMetricsTable = (
|
||||
),
|
||||
metric_type: <MetricTypeRenderer type={metric.type} />,
|
||||
unit: (
|
||||
<ValidateRowValueWrapper value={metric.unit}>
|
||||
{metric.unit}
|
||||
<ValidateRowValueWrapper value={getUniversalNameFromMetricUnit(metric.unit)}>
|
||||
{getUniversalNameFromMetricUnit(metric.unit)}
|
||||
</ValidateRowValueWrapper>
|
||||
),
|
||||
[TreemapViewType.SAMPLES]: (
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import './DashboardVariableSelection.styles.scss';
|
||||
|
||||
import { Row } from 'antd';
|
||||
import { ALL_SELECTED_VALUE } from 'components/NewSelect/utils';
|
||||
import useVariablesFromUrl from 'hooks/dashboard/useVariablesFromUrl';
|
||||
import { isEmpty } from 'lodash-es';
|
||||
import { useDashboard } from 'providers/Dashboard/Dashboard';
|
||||
import { initializeDefaultVariables } from 'providers/Dashboard/initializeDefaultVariables';
|
||||
import { memo, useEffect, useState } from 'react';
|
||||
import { useSelector } from 'react-redux';
|
||||
import { AppState } from 'store/reducers';
|
||||
@@ -28,6 +31,8 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
setVariablesToGetUpdated,
|
||||
} = useDashboard();
|
||||
|
||||
const { updateUrlVariable, getUrlVariables } = useVariablesFromUrl();
|
||||
|
||||
const { data } = selectedDashboard || {};
|
||||
|
||||
const { variables } = data || {};
|
||||
@@ -61,8 +66,11 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
tableRowData.sort((a, b) => a.order - b.order);
|
||||
|
||||
setVariablesTableData(tableRowData);
|
||||
|
||||
// Initialize variables with default values if not in URL
|
||||
initializeDefaultVariables(variables, getUrlVariables, updateUrlVariable);
|
||||
}
|
||||
}, [variables]);
|
||||
}, [getUrlVariables, updateUrlVariable, variables]);
|
||||
|
||||
useEffect(() => {
|
||||
if (variablesTableData.length > 0) {
|
||||
@@ -118,6 +126,12 @@ function DashboardVariableSelection(): JSX.Element | null {
|
||||
const isDynamic = variable?.type === 'DYNAMIC';
|
||||
updateLocalStorageDashboardVariables(name, value, allSelected, isDynamic);
|
||||
|
||||
if (allSelected) {
|
||||
updateUrlVariable(name || id, ALL_SELECTED_VALUE);
|
||||
} else {
|
||||
updateUrlVariable(name || id, value);
|
||||
}
|
||||
|
||||
if (selectedDashboard) {
|
||||
setSelectedDashboard((prev) => {
|
||||
if (prev) {
|
||||
|
||||
@@ -66,6 +66,7 @@ function PromQLQueryBuilder({
|
||||
defaultValue={queryData?.query}
|
||||
addonBefore="PromQL Query"
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
data-testid="promql-query-input"
|
||||
/>
|
||||
|
||||
<Input
|
||||
@@ -75,6 +76,7 @@ function PromQLQueryBuilder({
|
||||
defaultValue={queryData?.legend}
|
||||
addonBefore="Legend Format"
|
||||
style={{ marginBottom: '0.5rem' }}
|
||||
data-testid="promql-legend-input"
|
||||
/>
|
||||
</QueryHeader>
|
||||
);
|
||||
|
||||
@@ -30,25 +30,21 @@ function LeftContainer({
|
||||
enableDrillDown = false,
|
||||
}: WidgetGraphProps): JSX.Element {
|
||||
const { stagedQuery } = useQueryBuilder();
|
||||
// const { selectedDashboard } = useDashboard();
|
||||
|
||||
const { selectedTime: globalSelectedInterval } = useSelector<
|
||||
const { selectedTime: globalSelectedInterval, minTime, maxTime } = useSelector<
|
||||
AppState,
|
||||
GlobalReducer
|
||||
>((state) => state.globalTime);
|
||||
const queryResponse = useGetQueryRange(
|
||||
requestData,
|
||||
// selectedDashboard?.data?.version || DEFAULT_ENTITY_VERSION,
|
||||
ENTITY_VERSION_V5,
|
||||
{
|
||||
enabled: !!stagedQuery,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedInterval,
|
||||
requestData,
|
||||
],
|
||||
},
|
||||
);
|
||||
const queryResponse = useGetQueryRange(requestData, ENTITY_VERSION_V5, {
|
||||
enabled: !!stagedQuery,
|
||||
queryKey: [
|
||||
REACT_QUERY_KEY.GET_QUERY_RANGE,
|
||||
globalSelectedInterval,
|
||||
requestData,
|
||||
minTime,
|
||||
maxTime,
|
||||
],
|
||||
});
|
||||
|
||||
// Update parent component with query response for legend colors
|
||||
useEffect(() => {
|
||||
|
||||
@@ -43,6 +43,7 @@ function Threshold({
|
||||
tableOptions,
|
||||
thresholdTableOptions = '',
|
||||
columnUnits,
|
||||
yAxisUnit,
|
||||
}: ThresholdProps): JSX.Element {
|
||||
const [isEditMode, setIsEditMode] = useState<boolean>(isEditEnabled);
|
||||
const [operator, setOperator] = useState<string | number>(
|
||||
@@ -195,16 +196,13 @@ function Threshold({
|
||||
|
||||
const allowDragAndDrop = panelTypeVsDragAndDrop[selectedGraph];
|
||||
|
||||
const isInvalidUnitComparison = useMemo(
|
||||
() =>
|
||||
unit !== 'none' &&
|
||||
convertUnit(
|
||||
value,
|
||||
unit,
|
||||
getColumnUnit(tableSelectedOption, columnUnits || {}),
|
||||
) === null,
|
||||
[unit, value, columnUnits, tableSelectedOption],
|
||||
);
|
||||
const isInvalidUnitComparison = useMemo(() => {
|
||||
const toUnitId =
|
||||
selectedGraph === PANEL_TYPES.TABLE
|
||||
? getColumnUnit(tableSelectedOption, columnUnits || {})
|
||||
: yAxisUnit;
|
||||
return unit !== 'none' && convertUnit(value, unit, toUnitId) === null;
|
||||
}, [selectedGraph, yAxisUnit, tableSelectedOption, columnUnits, unit, value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -318,7 +316,9 @@ function Threshold({
|
||||
<Select
|
||||
defaultValue={unit}
|
||||
options={unitOptions(
|
||||
getColumnUnit(tableSelectedOption, columnUnits || {}) || '',
|
||||
selectedGraph === PANEL_TYPES.TABLE
|
||||
? getColumnUnit(tableSelectedOption, columnUnits || {}) || ''
|
||||
: yAxisUnit || '',
|
||||
)}
|
||||
onChange={handleUnitChange}
|
||||
showSearch
|
||||
@@ -357,8 +357,12 @@ function Threshold({
|
||||
</div>
|
||||
{isInvalidUnitComparison && (
|
||||
<Typography.Text className="invalid-unit">
|
||||
Threshold unit ({unit}) is not valid in comparison with the column unit (
|
||||
{getColumnUnit(tableSelectedOption, columnUnits || {}) || 'none'})
|
||||
Threshold unit ({unit}) is not valid in comparison with the{' '}
|
||||
{selectedGraph === PANEL_TYPES.TABLE ? 'column' : 'y-axis'} unit (
|
||||
{selectedGraph === PANEL_TYPES.TABLE
|
||||
? getColumnUnit(tableSelectedOption, columnUnits || {}) || 'none'
|
||||
: yAxisUnit || 'none'}
|
||||
)
|
||||
</Typography.Text>
|
||||
)}
|
||||
{isEditMode && (
|
||||
|
||||
@@ -95,6 +95,7 @@ function ThresholdSelector({
|
||||
tableOptions={aggregationQueries}
|
||||
thresholdTableOptions={threshold.thresholdTableOptions}
|
||||
columnUnits={columnUnits}
|
||||
yAxisUnit={yAxisUnit}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { DndProvider } from 'react-dnd';
|
||||
import { HTML5Backend } from 'react-dnd-html5-backend';
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
|
||||
import Threshold from '../Threshold';
|
||||
|
||||
// Mock the getColumnUnit function
|
||||
jest.mock('lib/query/createTableColumnsFromQuery', () => ({
|
||||
getColumnUnit: jest.fn(
|
||||
(option: string, columnUnits: Record<string, string>) =>
|
||||
columnUnits[option] || 'percent',
|
||||
),
|
||||
}));
|
||||
|
||||
// Mock the unitOptions function
|
||||
jest.mock('container/NewWidget/utils', () => ({
|
||||
unitOptions: jest.fn(() => [
|
||||
{ value: 'none', label: 'None' },
|
||||
{ value: 'percent', label: 'Percent' },
|
||||
{ value: 'ms', label: 'Milliseconds' },
|
||||
]),
|
||||
}));
|
||||
|
||||
const defaultProps = {
|
||||
index: 'test-threshold-1',
|
||||
keyIndex: 0,
|
||||
thresholdOperator: '>' as const,
|
||||
thresholdValue: 50,
|
||||
thresholdUnit: 'none',
|
||||
thresholdColor: 'Red',
|
||||
thresholdFormat: 'Text' as const,
|
||||
isEditEnabled: true,
|
||||
selectedGraph: PANEL_TYPES.TABLE,
|
||||
tableOptions: [
|
||||
{ value: 'cpu_usage', label: 'CPU Usage' },
|
||||
{ value: 'memory_usage', label: 'Memory Usage' },
|
||||
],
|
||||
thresholdTableOptions: 'cpu_usage',
|
||||
columnUnits: { cpu_usage: 'percent', memory_usage: 'bytes' },
|
||||
yAxisUnit: 'percent',
|
||||
moveThreshold: jest.fn(),
|
||||
};
|
||||
|
||||
const renderThreshold = (props = {}): void => {
|
||||
render(
|
||||
<DndProvider backend={HTML5Backend}>
|
||||
<Threshold {...{ ...defaultProps, ...props }} />
|
||||
</DndProvider>,
|
||||
);
|
||||
};
|
||||
|
||||
describe('Threshold Component Unit Validation', () => {
|
||||
it('should not show validation error when threshold unit is "none" regardless of column unit', () => {
|
||||
// Act - Render component with "none" threshold unit
|
||||
renderThreshold({
|
||||
thresholdUnit: 'none',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error when threshold unit is not "none" and units are incompatible', () => {
|
||||
// Act - Render component with incompatible units (ms vs percent)
|
||||
renderThreshold({
|
||||
thresholdUnit: 'ms',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
// Assert - Validation error should be displayed
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(ms\) is not valid in comparison with the column unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error when threshold unit matches column unit', () => {
|
||||
// Act - Render component with matching units
|
||||
renderThreshold({
|
||||
thresholdUnit: 'percent',
|
||||
thresholdValue: 50,
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error for time series graph when units are incompatible', () => {
|
||||
// Act - Render component for time series with incompatible units
|
||||
renderThreshold({
|
||||
selectedGraph: PANEL_TYPES.TIME_SERIES,
|
||||
thresholdUnit: 'ms',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
// Assert - Validation error should be displayed
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(ms\) is not valid in comparison with the y-axis unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error for time series graph when threshold unit is "none"', () => {
|
||||
// Act - Render component for time series with "none" threshold unit
|
||||
renderThreshold({
|
||||
selectedGraph: PANEL_TYPES.TIME_SERIES,
|
||||
thresholdUnit: 'none',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not show validation error when threshold unit is compatible with column unit', () => {
|
||||
// Act - Render component with compatible units (both in same category - Time)
|
||||
renderThreshold({
|
||||
thresholdUnit: 's',
|
||||
thresholdValue: 100,
|
||||
columnUnits: { cpu_usage: 'ms' },
|
||||
thresholdTableOptions: 'cpu_usage',
|
||||
});
|
||||
|
||||
// Assert - No validation error should be displayed
|
||||
expect(
|
||||
screen.queryByText(/Threshold unit.*is not valid in comparison/i),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show validation error when threshold unit is in different category than column unit', () => {
|
||||
// Act - Render component with units from different categories
|
||||
renderThreshold({
|
||||
thresholdUnit: 'bytes',
|
||||
thresholdValue: 100,
|
||||
yAxisUnit: 'percent',
|
||||
});
|
||||
|
||||
// Assert - Validation error should be displayed
|
||||
expect(
|
||||
screen.getByText(
|
||||
/Threshold unit \(bytes\) is not valid in comparison with the column unit \(percent\)/i,
|
||||
),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -21,6 +21,7 @@ export type ThresholdProps = {
|
||||
selectedGraph: PANEL_TYPES;
|
||||
tableOptions?: Array<{ value: string; label: string }>;
|
||||
columnUnits?: ColumnUnit;
|
||||
yAxisUnit?: string;
|
||||
};
|
||||
|
||||
export type ShowCaseValueProps = {
|
||||
|
||||
@@ -97,7 +97,10 @@
|
||||
"opentelemetry",
|
||||
"migration guide",
|
||||
"migrate",
|
||||
"migration"
|
||||
"migration",
|
||||
"otel collector",
|
||||
"opentelemetry collector",
|
||||
"otlp"
|
||||
],
|
||||
"imgUrl": "/Logos/opentelemetry.svg",
|
||||
"link": "https://signoz.io/docs/migration/migrate-from-opentelemetry-to-signoz/"
|
||||
@@ -108,7 +111,7 @@
|
||||
"label": "Java",
|
||||
"id": "java",
|
||||
"imgUrl": "/Logos/java.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -300,7 +303,7 @@
|
||||
"dataSource": "python",
|
||||
"label": "Python",
|
||||
"imgUrl": "/Logos/python.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -465,7 +468,7 @@
|
||||
{
|
||||
"dataSource": "javascript",
|
||||
"label": "JavaScript",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -712,7 +715,7 @@
|
||||
"dataSource": "golang",
|
||||
"label": "Golang",
|
||||
"imgUrl": "/Logos/go.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -776,7 +779,7 @@
|
||||
"dataSource": "php",
|
||||
"label": "PHP",
|
||||
"imgUrl": "/Logos/php.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -799,7 +802,6 @@
|
||||
"php zipkin tracing",
|
||||
"otel php exporter",
|
||||
"php http instrumentation",
|
||||
"php prometheus exporter",
|
||||
"php-fpm monitoring",
|
||||
"php on kubernetes",
|
||||
"php in containers",
|
||||
@@ -844,7 +846,7 @@
|
||||
"dataSource": "dotnet",
|
||||
"label": ".NET",
|
||||
"imgUrl": "/Logos/dotnet.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -876,7 +878,6 @@
|
||||
".net kubernetes",
|
||||
"dotnet in containers",
|
||||
"opentelemetry exporter .net",
|
||||
".net prometheus exporter",
|
||||
"c# opentelemetry",
|
||||
"monitor c# app",
|
||||
"entity framework telemetry",
|
||||
@@ -917,7 +918,7 @@
|
||||
"dataSource": "ruby-on-rails",
|
||||
"label": "Ruby on Rails",
|
||||
"imgUrl": "/Logos/ruby-on-rails.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -982,7 +983,7 @@
|
||||
"dataSource": "elixir",
|
||||
"label": "Elixir",
|
||||
"imgUrl": "/Logos/elixir.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -1041,7 +1042,7 @@
|
||||
"dataSource": "rust",
|
||||
"label": "Rust",
|
||||
"imgUrl": "/Logos/rust.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -1094,7 +1095,7 @@
|
||||
"dataSource": "swift",
|
||||
"label": "Swift",
|
||||
"imgUrl": "/Logos/swift.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -1125,7 +1126,6 @@
|
||||
"urlsession instrumentation",
|
||||
"ios sdk opentelemetry",
|
||||
"apple ecosystem observability",
|
||||
"swift prometheus exporter",
|
||||
"ios otlp exporter",
|
||||
"ios telemetry sdk"
|
||||
],
|
||||
@@ -1161,7 +1161,7 @@
|
||||
"dataSource": "opentelemetry-cpp",
|
||||
"label": "C++",
|
||||
"imgUrl": "/Logos/cpp.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -1203,7 +1203,7 @@
|
||||
"dataSource": "nginx-tracing",
|
||||
"label": "Nginx - Tracing",
|
||||
"imgUrl": "/Logos/nginx.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": ["tracing", "nginx server", "nginx proxy", "nginx"],
|
||||
"id": "nginx-tracing",
|
||||
@@ -1213,7 +1213,7 @@
|
||||
"dataSource": "opentelemetry-wordpress",
|
||||
"label": "WordPress",
|
||||
"imgUrl": "/Logos/wordpress.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -1239,7 +1239,7 @@
|
||||
"dataSource": "opentelemetry-cloudflare",
|
||||
"label": "Cloudflare",
|
||||
"imgUrl": "/Logos/cloudflare.svg",
|
||||
"tags": ["apm"],
|
||||
"tags": ["apm/traces", "logs"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"apm",
|
||||
@@ -1556,7 +1556,7 @@
|
||||
},
|
||||
{
|
||||
"dataSource": "from-log-file",
|
||||
"label": "From Log File",
|
||||
"label": "Collect Logs From Log File",
|
||||
"imgUrl": "/Logos/from-log-file.svg",
|
||||
"tags": ["logs"],
|
||||
"module": "logs",
|
||||
@@ -2821,6 +2821,20 @@
|
||||
],
|
||||
"link": "https://signoz.io/docs/vercel-ai-sdk-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "mastra-monitoring",
|
||||
"label": "Mastra",
|
||||
"imgUrl": "/Logos/mastra.svg",
|
||||
"tags": ["LLM Monitoring"],
|
||||
"module": "apm",
|
||||
"relatedSearchKeywords": [
|
||||
"mastra monitoring",
|
||||
"mastra observability",
|
||||
"mastra agent",
|
||||
"traces"
|
||||
],
|
||||
"link": "https://signoz.io/docs/mastra-monitoring/"
|
||||
},
|
||||
{
|
||||
"dataSource": "http-endpoints-monitoring",
|
||||
"label": "HTTP Endpoints Monitoring",
|
||||
|
||||
@@ -28,7 +28,15 @@ function ConfigureGoogleAuthAuthnProvider({
|
||||
</Typography.Paragraph>
|
||||
</section>
|
||||
|
||||
<Form.Item label="Domain" name="name" className="field">
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
className="field"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -16,7 +16,14 @@ function ConfigureOIDCAuthnProvider({
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
<Form.Item label="Domain" name="name">
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
|
||||
|
||||
@@ -16,7 +16,14 @@ function ConfigureSAMLAuthnProvider({
|
||||
</Typography.Text>
|
||||
</section>
|
||||
|
||||
<Form.Item label="Domain" name="name">
|
||||
<Form.Item
|
||||
label="Domain"
|
||||
name="name"
|
||||
tooltip={{
|
||||
title:
|
||||
'The email domain for users who should use SSO (e.g., `example.com` for users with `@example.com` emails)',
|
||||
}}
|
||||
>
|
||||
<Input disabled={!isCreate} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -24,7 +31,7 @@ function ConfigureSAMLAuthnProvider({
|
||||
label="SAML ACS URL"
|
||||
name={['samlConfig', 'samlIdp']}
|
||||
tooltip={{
|
||||
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
|
||||
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
@@ -34,7 +41,7 @@ function ConfigureSAMLAuthnProvider({
|
||||
label="SAML Entity ID"
|
||||
name={['samlConfig', 'samlEntity']}
|
||||
tooltip={{
|
||||
title: `The SSO endpoint of the SAML identity provider. It can typically be found in the SingleSignOnService element in the SAML metadata of the identity provider. Example: <md:SingleSignOnService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" Location="{samlIdp}"/>`,
|
||||
title: `The entityID of the SAML identity provider. It can typically be found in the EntityID attribute of the EntityDescriptor element in the SAML metadata of the identity provider. Example: <md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="{samlEntity}">`,
|
||||
}}
|
||||
>
|
||||
<Input />
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -9,4 +9,5 @@ export type AgregatorFilterProps = Pick<AutoCompleteProps, 'disabled'> & {
|
||||
onSelect?: (value: BaseAutocompleteData) => void;
|
||||
index?: number;
|
||||
signalSource?: 'meter' | '';
|
||||
setAttributeKeys?: (keys: BaseAutocompleteData[]) => void;
|
||||
};
|
||||
|
||||
@@ -37,6 +37,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
onSelect,
|
||||
index,
|
||||
signalSource,
|
||||
setAttributeKeys,
|
||||
}: AgregatorFilterProps): JSX.Element {
|
||||
const queryClient = useQueryClient();
|
||||
const [optionsData, setOptionsData] = useState<ExtendedSelectOption[]>([]);
|
||||
@@ -97,6 +98,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
})) || [];
|
||||
|
||||
setOptionsData(options);
|
||||
setAttributeKeys?.(data?.payload?.attributeKeys || []);
|
||||
},
|
||||
},
|
||||
);
|
||||
@@ -135,6 +137,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
onChange,
|
||||
index,
|
||||
query,
|
||||
setAttributeKeys,
|
||||
]);
|
||||
|
||||
const handleSearchText = useCallback((text: string): void => {
|
||||
@@ -153,23 +156,25 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
return 'Aggregate attribute';
|
||||
}, [signalSource, query.dataSource]);
|
||||
|
||||
const getAttributesData = useCallback(
|
||||
(): BaseAutocompleteData[] =>
|
||||
const getAttributesData = useCallback((): BaseAutocompleteData[] => {
|
||||
const attributeKeys =
|
||||
queryClient.getQueryData<SuccessResponse<IQueryAutocompleteResponse>>([
|
||||
QueryBuilderKeys.GET_AGGREGATE_ATTRIBUTE,
|
||||
debouncedValue,
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
index,
|
||||
])?.payload?.attributeKeys || [],
|
||||
[
|
||||
debouncedValue,
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
queryClient,
|
||||
index,
|
||||
],
|
||||
);
|
||||
])?.payload?.attributeKeys || [];
|
||||
setAttributeKeys?.(attributeKeys);
|
||||
return attributeKeys;
|
||||
}, [
|
||||
debouncedValue,
|
||||
queryAggregation.timeAggregation,
|
||||
query.dataSource,
|
||||
queryClient,
|
||||
index,
|
||||
setAttributeKeys,
|
||||
]);
|
||||
|
||||
const getResponseAttributes = useCallback(async () => {
|
||||
const response = await queryClient.fetchQuery(
|
||||
@@ -188,6 +193,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
}),
|
||||
);
|
||||
|
||||
setAttributeKeys?.(response.payload?.attributeKeys || []);
|
||||
return response.payload?.attributeKeys || [];
|
||||
}, [
|
||||
queryAggregation.timeAggregation,
|
||||
@@ -195,6 +201,7 @@ export const AggregatorFilter = memo(function AggregatorFilter({
|
||||
queryClient,
|
||||
searchText,
|
||||
index,
|
||||
setAttributeKeys,
|
||||
]);
|
||||
|
||||
const handleChangeCustomValue = useCallback(
|
||||
|
||||
@@ -194,7 +194,7 @@ describe('TableDrilldown', () => {
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
|
||||
);
|
||||
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
@@ -270,7 +270,7 @@ describe('TableDrilldown', () => {
|
||||
expect(urlObj.searchParams.has('compositeQuery')).toBe(true);
|
||||
|
||||
const compositeQuery = JSON.parse(
|
||||
urlObj.searchParams.get('compositeQuery') || '{}',
|
||||
decodeURIComponent(urlObj.searchParams.get('compositeQuery') || '{}'),
|
||||
);
|
||||
// Verify the query structure includes the filters from clicked data
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
|
||||
@@ -137,7 +137,7 @@ const useBaseAggregateOptions = ({
|
||||
);
|
||||
|
||||
let queryParams = {
|
||||
[QueryParams.compositeQuery]: JSON.stringify(viewQuery),
|
||||
[QueryParams.compositeQuery]: encodeURIComponent(JSON.stringify(viewQuery)),
|
||||
...(timeRange && {
|
||||
[QueryParams.startTime]: timeRange?.startTime.toString(),
|
||||
[QueryParams.endTime]: timeRange?.endTime.toString(),
|
||||
|
||||
@@ -20,10 +20,11 @@ interface AttributeRecord {
|
||||
interface IAttributesProps {
|
||||
span: Span;
|
||||
isSearchVisible: boolean;
|
||||
shouldFocusOnToggle?: boolean;
|
||||
}
|
||||
|
||||
function Attributes(props: IAttributesProps): JSX.Element {
|
||||
const { span, isSearchVisible } = props;
|
||||
const { span, isSearchVisible, shouldFocusOnToggle } = props;
|
||||
const [fieldSearchInput, setFieldSearchInput] = useState<string>('');
|
||||
|
||||
const flattenSpanData: Record<string, string> = useMemo(
|
||||
@@ -69,7 +70,7 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
||||
{isSearchVisible &&
|
||||
(datasource.length > 0 || fieldSearchInput.length > 0) && (
|
||||
<Input
|
||||
autoFocus
|
||||
autoFocus={shouldFocusOnToggle}
|
||||
placeholder="Search for attribute..."
|
||||
className="search-input"
|
||||
value={fieldSearchInput}
|
||||
@@ -118,4 +119,8 @@ function Attributes(props: IAttributesProps): JSX.Element {
|
||||
);
|
||||
}
|
||||
|
||||
Attributes.defaultProps = {
|
||||
shouldFocusOnToggle: false,
|
||||
};
|
||||
|
||||
export default Attributes;
|
||||
|
||||
@@ -41,7 +41,7 @@ function EventsTable(props: IEventsTableProps): JSX.Element {
|
||||
</div>
|
||||
)}
|
||||
<div className="events-container">
|
||||
{isSearchVisible && (
|
||||
{isSearchVisible && events.length > 0 && (
|
||||
<Input
|
||||
autoFocus
|
||||
placeholder="Search for events..."
|
||||
|
||||
@@ -35,7 +35,10 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
traceEndTime,
|
||||
} = props;
|
||||
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(false);
|
||||
const [isSearchVisible, setIsSearchVisible] = useState<boolean>(true);
|
||||
const [shouldAutoFocusSearch, setShouldAutoFocusSearch] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
const [isRelatedSignalsOpen, setIsRelatedSignalsOpen] = useState<boolean>(
|
||||
false,
|
||||
);
|
||||
@@ -73,7 +76,13 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
</Button>
|
||||
),
|
||||
key: 'attributes',
|
||||
children: <Attributes span={span} isSearchVisible={isSearchVisible} />,
|
||||
children: (
|
||||
<Attributes
|
||||
span={span}
|
||||
isSearchVisible={isSearchVisible}
|
||||
shouldFocusOnToggle={shouldAutoFocusSearch}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
label: (
|
||||
@@ -246,7 +255,16 @@ function SpanDetailsDrawer(props: ISpanDetailsDrawerProps): JSX.Element {
|
||||
size={14}
|
||||
className="search-icon"
|
||||
cursor="pointer"
|
||||
onClick={(): void => setIsSearchVisible((prev) => !prev)}
|
||||
onClick={(): void => {
|
||||
setIsSearchVisible((prev) => {
|
||||
const newValue = !prev;
|
||||
// Only set toggle flag when search becomes visible
|
||||
if (newValue) {
|
||||
setShouldAutoFocusSearch(true);
|
||||
}
|
||||
return newValue;
|
||||
});
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user