mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-20 10:20:48 +01:00
Compare commits
55 Commits
feat/enabl
...
SIG-5270
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
eb199179b8 | ||
|
|
f2b2f0e550 | ||
|
|
cdd58e8e20 | ||
|
|
e7772d93af | ||
|
|
bbf987ebd7 | ||
|
|
105c3a3b8c | ||
|
|
c1a4a5b8db | ||
|
|
c9591f4341 | ||
|
|
fd216fdee1 | ||
|
|
f5bf4293a1 | ||
|
|
155a44a25d | ||
|
|
4b21c9d5f9 | ||
|
|
5ef0a18867 | ||
|
|
c8266d1aec | ||
|
|
adfd16ce1b | ||
|
|
6db74a5585 | ||
|
|
f8e0db0085 | ||
|
|
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 | ||
|
|
d539ca9bab | ||
|
|
c8194e9abb | ||
|
|
0458b059c3 | ||
|
|
c919102fee | ||
|
|
633f719a8b | ||
|
|
2945b10c04 | ||
|
|
763eeeab0b | ||
|
|
d0aaf8fd2f | ||
|
|
894215cb00 | ||
|
|
c5646765fd | ||
|
|
1e47968afe | ||
|
|
dfd4e2e0fe |
2
.github/CODEOWNERS
vendored
2
.github/CODEOWNERS
vendored
@@ -2,7 +2,7 @@
|
||||
# Owners are automatically requested for review for PRs that changes code
|
||||
# that they own.
|
||||
|
||||
/frontend/ @SigNoz/frontend @YounixM
|
||||
/frontend/ @YounixM @aks07
|
||||
/frontend/src/container/MetricsApplication @srikanthccv
|
||||
/frontend/src/container/NewWidget/RightContainer/types.ts @srikanthccv
|
||||
|
||||
|
||||
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
|
||||
|
||||
6
.github/workflows/build-enterprise.yaml
vendored
6
.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 }}
|
||||
@@ -107,10 +107,8 @@ jobs:
|
||||
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
GO_CGO_ENABLED: 1
|
||||
DOCKER_BASE_IMAGES: '{"alpine": "alpine:3.20.3"}'
|
||||
DOCKER_DOCKERFILE_PATH: ./cmd/enterprise/Dockerfile.multi-arch
|
||||
DOCKER_MANIFEST: true
|
||||
|
||||
6
.github/workflows/build-staging.yaml
vendored
6
.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 }}
|
||||
@@ -106,10 +106,8 @@ jobs:
|
||||
-X github.com/SigNoz/signoz/pkg/version.branch=${{ needs.prepare.outputs.branch }}
|
||||
-X github.com/SigNoz/signoz/ee/zeus.url=https://api.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.staging.signoz.cloud
|
||||
-X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.staging.signoz.cloud/api/v1
|
||||
-X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr'
|
||||
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 +123,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
.github/workflows/integrationci.yaml
vendored
1
.github/workflows/integrationci.yaml
vendored
@@ -17,6 +17,7 @@ jobs:
|
||||
- bootstrap
|
||||
- passwordauthn
|
||||
- callbackauthn
|
||||
- cloudintegrations
|
||||
- querier
|
||||
- ttl
|
||||
sqlstore-provider:
|
||||
|
||||
@@ -1,39 +1,63 @@
|
||||
version: "2"
|
||||
linters:
|
||||
default: standard
|
||||
default: none
|
||||
enable:
|
||||
- bodyclose
|
||||
- depguard
|
||||
- errcheck
|
||||
- forbidigo
|
||||
- govet
|
||||
- iface
|
||||
- ineffassign
|
||||
- misspell
|
||||
- nilnil
|
||||
- sloglint
|
||||
- depguard
|
||||
- iface
|
||||
- unparam
|
||||
- forbidigo
|
||||
|
||||
linters-settings:
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
kv-only: true
|
||||
no-global: all
|
||||
context: all
|
||||
static-msg: true
|
||||
msg-style: lowercased
|
||||
key-naming-case: snake
|
||||
depguard:
|
||||
rules:
|
||||
nozap:
|
||||
deny:
|
||||
- pkg: "go.uber.org/zap"
|
||||
desc: "Do not use zap logger. Use slog instead."
|
||||
noerrors:
|
||||
deny:
|
||||
- pkg: "errors"
|
||||
desc: "Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead."
|
||||
iface:
|
||||
enable:
|
||||
- identical
|
||||
issues:
|
||||
exclude-dirs:
|
||||
- "pkg/query-service"
|
||||
- "ee/query-service"
|
||||
- "scripts/"
|
||||
- unused
|
||||
settings:
|
||||
depguard:
|
||||
rules:
|
||||
noerrors:
|
||||
deny:
|
||||
- pkg: errors
|
||||
desc: Do not use errors package. Use github.com/SigNoz/signoz/pkg/errors instead.
|
||||
nozap:
|
||||
deny:
|
||||
- pkg: go.uber.org/zap
|
||||
desc: Do not use zap logger. Use slog instead.
|
||||
forbidigo:
|
||||
forbid:
|
||||
- pattern: fmt.Errorf
|
||||
- pattern: ^(fmt\.Print.*|print|println)$
|
||||
iface:
|
||||
enable:
|
||||
- identical
|
||||
sloglint:
|
||||
no-mixed-args: true
|
||||
kv-only: true
|
||||
no-global: all
|
||||
context: all
|
||||
static-msg: true
|
||||
key-naming-case: snake
|
||||
exclusions:
|
||||
generated: lax
|
||||
presets:
|
||||
- comments
|
||||
- common-false-positives
|
||||
- legacy
|
||||
- std-error-handling
|
||||
paths:
|
||||
- pkg/query-service
|
||||
- ee/query-service
|
||||
- scripts/
|
||||
- tmp/
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
formatters:
|
||||
exclusions:
|
||||
generated: lax
|
||||
paths:
|
||||
- third_party$
|
||||
- builtin$
|
||||
- examples$
|
||||
|
||||
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
|
||||
|
||||
##############################################################
|
||||
|
||||
@@ -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
|
||||
@@ -37,11 +31,8 @@ builds:
|
||||
- -X github.com/SigNoz/signoz/pkg/version.branch={{ .Branch }}
|
||||
- -X github.com/SigNoz/signoz/ee/zeus.url=https://api.signoz.cloud
|
||||
- -X github.com/SigNoz/signoz/ee/zeus.deprecatedURL=https://license.signoz.io
|
||||
- -X github.com/SigNoz/signoz/ee/query-service/constants.ZeusURL=https://api.signoz.cloud
|
||||
- -X github.com/SigNoz/signoz/ee/query-service/constants.LicenseSignozIo=https://license.signoz.io/api/v1
|
||||
- -X github.com/SigNoz/signoz/pkg/analytics.key=9kRrJ7oPCGPEJLF6QjMPLt5bljFhRQBr
|
||||
- >-
|
||||
{{- 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"))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -10,7 +10,6 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/SigNoz/signoz/ee/query-service/constants"
|
||||
"github.com/SigNoz/signoz/pkg/errors"
|
||||
"github.com/SigNoz/signoz/pkg/http/render"
|
||||
"github.com/SigNoz/signoz/pkg/modules/user"
|
||||
@@ -77,7 +76,7 @@ func (ah *APIHandler) CloudIntegrationsGenerateConnectionParams(w http.ResponseW
|
||||
return
|
||||
}
|
||||
|
||||
ingestionUrl, signozApiUrl, apiErr := getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
ingestionUrl, signozApiUrl, apiErr := ah.getIngestionUrlAndSigNozAPIUrl(r.Context(), license.Key)
|
||||
if apiErr != nil {
|
||||
RespondError(w, basemodel.WrapApiError(
|
||||
apiErr, "couldn't deduce ingestion url and signoz api url",
|
||||
@@ -186,48 +185,37 @@ func (ah *APIHandler) getOrCreateCloudIntegrationUser(
|
||||
return cloudIntegrationUser, nil
|
||||
}
|
||||
|
||||
func getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
func (ah *APIHandler) getIngestionUrlAndSigNozAPIUrl(ctx context.Context, licenseKey string) (
|
||||
string, string, *basemodel.ApiError,
|
||||
) {
|
||||
url := fmt.Sprintf(
|
||||
"%s%s",
|
||||
strings.TrimSuffix(constants.ZeusURL, "/"),
|
||||
"/v2/deployments/me",
|
||||
)
|
||||
|
||||
// TODO: remove this struct from here
|
||||
type deploymentResponse struct {
|
||||
Status string `json:"status"`
|
||||
Error string `json:"error"`
|
||||
Data struct {
|
||||
Name string `json:"name"`
|
||||
|
||||
ClusterInfo struct {
|
||||
Region struct {
|
||||
DNS string `json:"dns"`
|
||||
} `json:"region"`
|
||||
} `json:"cluster"`
|
||||
} `json:"data"`
|
||||
Name string `json:"name"`
|
||||
ClusterInfo struct {
|
||||
Region struct {
|
||||
DNS string `json:"dns"`
|
||||
} `json:"region"`
|
||||
} `json:"cluster"`
|
||||
}
|
||||
|
||||
resp, apiErr := requestAndParseResponse[deploymentResponse](
|
||||
ctx, url, map[string]string{"X-Signoz-Cloud-Api-Key": licenseKey}, nil,
|
||||
)
|
||||
|
||||
if apiErr != nil {
|
||||
return "", "", basemodel.WrapApiError(
|
||||
apiErr, "couldn't query for deployment info",
|
||||
)
|
||||
}
|
||||
|
||||
if resp.Status != "success" {
|
||||
respBytes, err := ah.Signoz.Zeus.GetDeployment(ctx, licenseKey)
|
||||
if err != nil {
|
||||
return "", "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't query for deployment info: status: %s, error: %s",
|
||||
resp.Status, resp.Error,
|
||||
"couldn't query for deployment info: error: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
regionDns := resp.Data.ClusterInfo.Region.DNS
|
||||
deploymentName := resp.Data.Name
|
||||
resp := new(deploymentResponse)
|
||||
|
||||
err = json.Unmarshal(respBytes, resp)
|
||||
if err != nil {
|
||||
return "", "", basemodel.InternalError(fmt.Errorf(
|
||||
"couldn't unmarshal deployment info response: error: %w", err,
|
||||
))
|
||||
}
|
||||
|
||||
regionDns := resp.ClusterInfo.Region.DNS
|
||||
deploymentName := resp.Name
|
||||
|
||||
if len(regionDns) < 1 || len(deploymentName) < 1 {
|
||||
// Fail early if actual response structure and expectation here ever diverge
|
||||
|
||||
@@ -10,9 +10,6 @@ var SaasSegmentKey = GetOrDefaultEnv("SIGNOZ_SAAS_SEGMENT_KEY", "")
|
||||
var FetchFeatures = GetOrDefaultEnv("FETCH_FEATURES", "false")
|
||||
var ZeusFeaturesURL = GetOrDefaultEnv("ZEUS_FEATURES_URL", "ZeusFeaturesURL")
|
||||
|
||||
// this is set via build time variable
|
||||
var ZeusURL = "https://api.signoz.cloud"
|
||||
|
||||
func GetOrDefaultEnv(key string, fallback string) string {
|
||||
v := os.Getenv(key)
|
||||
if len(v) == 0 {
|
||||
|
||||
@@ -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"))
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
"antd": "5.11.0",
|
||||
"antd-table-saveas-excel": "2.2.1",
|
||||
"antlr4": "4.13.2",
|
||||
"axios": "1.8.2",
|
||||
"axios": "1.12.0",
|
||||
"babel-eslint": "^10.1.0",
|
||||
"babel-jest": "^29.6.4",
|
||||
"babel-loader": "9.1.3",
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
28
frontend/src/api/trace/getSpanPercentiles.ts
Normal file
28
frontend/src/api/trace/getSpanPercentiles.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { ApiBaseInstance } from 'api';
|
||||
import { ErrorResponseHandlerV2 } from 'api/ErrorResponseHandlerV2';
|
||||
import { AxiosError } from 'axios';
|
||||
import { ErrorV2Resp, SuccessResponseV2 } from 'types/api';
|
||||
import {
|
||||
GetSpanPercentilesProps,
|
||||
GetSpanPercentilesResponseDataProps,
|
||||
} from 'types/api/trace/getSpanPercentiles';
|
||||
|
||||
const getSpanPercentiles = async (
|
||||
props: GetSpanPercentilesProps,
|
||||
): Promise<SuccessResponseV2<GetSpanPercentilesResponseDataProps>> => {
|
||||
try {
|
||||
const response = await ApiBaseInstance.post('/span_percentile', {
|
||||
...props,
|
||||
});
|
||||
|
||||
return {
|
||||
httpStatusCode: response.status,
|
||||
data: response.data.data,
|
||||
};
|
||||
} catch (error) {
|
||||
ErrorResponseHandlerV2(error as AxiosError<ErrorV2Resp>);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
export default getSpanPercentiles;
|
||||
371
frontend/src/components/Graph/__tests__/yAxisConfig.test.ts
Normal file
371
frontend/src/components/Graph/__tests__/yAxisConfig.test.ts
Normal file
@@ -0,0 +1,371 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
import { getYAxisFormattedValue, PrecisionOptionsEnum } from '../yAxisConfig';
|
||||
|
||||
const testFullPrecisionGetYAxisFormattedValue = (
|
||||
value: string,
|
||||
format: string,
|
||||
): string => getYAxisFormattedValue(value, format, PrecisionOptionsEnum.FULL);
|
||||
|
||||
describe('getYAxisFormattedValue - none (full precision legacy assertions)', () => {
|
||||
test('large integers and decimals', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('250034', 'none')).toBe(
|
||||
'250034',
|
||||
);
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('250034897.12345', 'none'),
|
||||
).toBe('250034897.12345');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('250034897.02354', 'none'),
|
||||
).toBe('250034897.02354');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('9999999.9999', 'none')).toBe(
|
||||
'9999999.9999',
|
||||
);
|
||||
});
|
||||
|
||||
test('preserves leading zeros after decimal until first non-zero', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.0000234', 'none')).toBe(
|
||||
'1.0000234',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.00003', 'none')).toBe(
|
||||
'0.00003',
|
||||
);
|
||||
});
|
||||
|
||||
test('trims to three significant decimals and removes trailing zeros', () => {
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'none'),
|
||||
).toBe('0.000000250034');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'none')).toBe(
|
||||
'0.00000025',
|
||||
);
|
||||
|
||||
// Big precision, limiting the javascript precision (~16 digits)
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'none'),
|
||||
).toBe('1');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'none'),
|
||||
).toBe('1.005555555595958');
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
|
||||
'0.000000001',
|
||||
);
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000250000', 'none'),
|
||||
).toBe('0.00000025');
|
||||
});
|
||||
|
||||
test('whole numbers normalize', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'none')).toBe('1000');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('99.5458', 'none')).toBe(
|
||||
'99.5458',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.234567', 'none')).toBe(
|
||||
'1.234567',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('99.998', 'none')).toBe(
|
||||
'99.998',
|
||||
);
|
||||
});
|
||||
|
||||
test('strip redundant decimal zeros', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000.000', 'none')).toBe(
|
||||
'1000',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('99.500', 'none')).toBe(
|
||||
'99.5',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.000', 'none')).toBe('1');
|
||||
});
|
||||
|
||||
test('edge values', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'none')).toBe('0');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-0', 'none')).toBe('0');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'none')).toBe('∞');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-Infinity', 'none')).toBe(
|
||||
'-∞',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('invalid', 'none')).toBe(
|
||||
'NaN',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('', 'none')).toBe('NaN');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('abc123', 'none')).toBe('NaN');
|
||||
});
|
||||
|
||||
test('small decimals keep precision as-is', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'none')).toBe(
|
||||
'0.0001',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-0.0001', 'none')).toBe(
|
||||
'-0.0001',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.000000001', 'none')).toBe(
|
||||
'0.000000001',
|
||||
);
|
||||
});
|
||||
|
||||
test('simple decimals preserved', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.1', 'none')).toBe('0.1');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.2', 'none')).toBe('0.2');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.3', 'none')).toBe('0.3');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.0000000001', 'none')).toBe(
|
||||
'1.0000000001',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getYAxisFormattedValue - units (full precision legacy assertions)', () => {
|
||||
test('ms', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'ms')).toBe('1.5 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('500', 'ms')).toBe('500 ms');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('60000', 'ms')).toBe('1 min');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('295.429', 'ms')).toBe(
|
||||
'295.429 ms',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('4353.81', 'ms')).toBe(
|
||||
'4.35381 s',
|
||||
);
|
||||
});
|
||||
|
||||
test('s', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('90', 's')).toBe('1.5 mins');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('30', 's')).toBe('30 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('3600', 's')).toBe('1 hour');
|
||||
});
|
||||
|
||||
test('m', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('90', 'm')).toBe('1.5 hours');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('30', 'm')).toBe('30 min');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1440', 'm')).toBe('1 day');
|
||||
});
|
||||
|
||||
test('bytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'bytes')).toBe(
|
||||
'1 KiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('512', 'bytes')).toBe('512 B');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'bytes')).toBe(
|
||||
'1.5 KiB',
|
||||
);
|
||||
});
|
||||
|
||||
test('mbytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'mbytes')).toBe(
|
||||
'1 GiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('512', 'mbytes')).toBe(
|
||||
'512 MiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'mbytes')).toBe(
|
||||
'1.5 GiB',
|
||||
);
|
||||
});
|
||||
|
||||
test('kbytes', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1024', 'kbytes')).toBe(
|
||||
'1 MiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('512', 'kbytes')).toBe(
|
||||
'512 KiB',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1536', 'kbytes')).toBe(
|
||||
'1.5 MiB',
|
||||
);
|
||||
});
|
||||
|
||||
test('short', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000', 'short')).toBe('1 K');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500', 'short')).toBe(
|
||||
'1.5 K',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('999', 'short')).toBe('999');
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000000', 'short')).toBe(
|
||||
'1 Mil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1555600', 'short')).toBe(
|
||||
'1.5556 Mil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('999999', 'short')).toBe(
|
||||
'999.999 K',
|
||||
);
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1000000000', 'short')).toBe(
|
||||
'1 Bil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1500000000', 'short')).toBe(
|
||||
'1.5 Bil',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('999999999', 'short')).toBe(
|
||||
'999.999999 Mil',
|
||||
);
|
||||
});
|
||||
|
||||
test('percent', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.15', 'percent')).toBe(
|
||||
'0.15%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.1234', 'percent')).toBe(
|
||||
'0.1234%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.123499', 'percent')).toBe(
|
||||
'0.123499%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.5', 'percent')).toBe(
|
||||
'1.5%',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.0001', 'percent')).toBe(
|
||||
'0.0001%',
|
||||
);
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000001', 'percent'),
|
||||
).toBe('1e-9%');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('0.000000250034', 'percent'),
|
||||
).toBe('0.000000250034%');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.00000025', 'percent')).toBe(
|
||||
'0.00000025%',
|
||||
);
|
||||
// Big precision, limiting the javascript precision (~16 digits)
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.0000000000000001', 'percent'),
|
||||
).toBe('1%');
|
||||
expect(
|
||||
testFullPrecisionGetYAxisFormattedValue('1.00555555559595876', 'percent'),
|
||||
).toBe('1.005555555595958%');
|
||||
});
|
||||
|
||||
test('ratio', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0.5', 'ratio')).toBe(
|
||||
'0.5 ratio',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('1.25', 'ratio')).toBe(
|
||||
'1.25 ratio',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('2.0', 'ratio')).toBe(
|
||||
'2 ratio',
|
||||
);
|
||||
});
|
||||
|
||||
test('temperature units', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('25', 'celsius')).toBe(
|
||||
'25 °C',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'celsius')).toBe('0 °C');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-10', 'celsius')).toBe(
|
||||
'-10 °C',
|
||||
);
|
||||
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('77', 'fahrenheit')).toBe(
|
||||
'77 °F',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('32', 'fahrenheit')).toBe(
|
||||
'32 °F',
|
||||
);
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('14', 'fahrenheit')).toBe(
|
||||
'14 °F',
|
||||
);
|
||||
});
|
||||
|
||||
test('ms edge cases', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'ms')).toBe('0 ms');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-1500', 'ms')).toBe('-1.5 s');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('Infinity', 'ms')).toBe('∞');
|
||||
});
|
||||
|
||||
test('bytes edge cases', () => {
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('0', 'bytes')).toBe('0 B');
|
||||
expect(testFullPrecisionGetYAxisFormattedValue('-1024', 'bytes')).toBe(
|
||||
'-1 KiB',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getYAxisFormattedValue - precision option tests', () => {
|
||||
test('precision 0 drops decimal part', () => {
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 0)).toBe('1');
|
||||
expect(getYAxisFormattedValue('0.9999', 'none', 0)).toBe('0');
|
||||
expect(getYAxisFormattedValue('12345.6789', 'none', 0)).toBe('12345');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 0)).toBe('0');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 0)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 0)).toBe('0');
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 0)).toBe('1');
|
||||
|
||||
// with unit
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 0)).toBe('4 s');
|
||||
});
|
||||
test('precision 1,2,3,4 decimals', () => {
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 1)).toBe('1.2');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 2)).toBe('1.23');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 3)).toBe('1.234');
|
||||
expect(getYAxisFormattedValue('1.2345', 'none', 4)).toBe('1.2345');
|
||||
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 1)).toBe('0.00001');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 2)).toBe('0.000012');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 3)).toBe('0.0000123');
|
||||
expect(getYAxisFormattedValue('0.0000123456', 'none', 4)).toBe('0.00001234');
|
||||
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 1)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 2)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 3)).toBe('1000');
|
||||
expect(getYAxisFormattedValue('1000.000', 'none', 4)).toBe('1000');
|
||||
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 1)).toBe('0.0000002');
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 2)).toBe(
|
||||
'0.00000025',
|
||||
); // leading zeros + 2 significant => same trimmed
|
||||
expect(getYAxisFormattedValue('0.000000250034', 'none', 3)).toBe(
|
||||
'0.00000025',
|
||||
);
|
||||
expect(getYAxisFormattedValue('0.000000250304', 'none', 4)).toBe(
|
||||
'0.0000002503',
|
||||
);
|
||||
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 1)).toBe(
|
||||
'1.005',
|
||||
);
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 2)).toBe(
|
||||
'1.0055',
|
||||
);
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 3)).toBe(
|
||||
'1.00555',
|
||||
);
|
||||
expect(getYAxisFormattedValue('1.00555555559595876', 'none', 4)).toBe(
|
||||
'1.005555',
|
||||
);
|
||||
|
||||
// with unit
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 1)).toBe('4.4 s');
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 2)).toBe('4.35 s');
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 3)).toBe('4.354 s');
|
||||
expect(getYAxisFormattedValue('4353.81', 'ms', 4)).toBe('4.3538 s');
|
||||
|
||||
// Percentages
|
||||
expect(getYAxisFormattedValue('0.123456', 'percent', 2)).toBe('0.12%');
|
||||
expect(getYAxisFormattedValue('0.123456', 'percent', 4)).toBe('0.1235%'); // approximation
|
||||
});
|
||||
|
||||
test('precision full uses up to DEFAULT_SIGNIFICANT_DIGITS significant digits', () => {
|
||||
expect(
|
||||
getYAxisFormattedValue(
|
||||
'0.00002625429914148441',
|
||||
'none',
|
||||
PrecisionOptionsEnum.FULL,
|
||||
),
|
||||
).toBe('0.000026254299141');
|
||||
expect(
|
||||
getYAxisFormattedValue(
|
||||
'0.000026254299141484417',
|
||||
's',
|
||||
PrecisionOptionsEnum.FULL,
|
||||
),
|
||||
).toBe('26254299141484417000000 µs');
|
||||
|
||||
expect(
|
||||
getYAxisFormattedValue('4353.81', 'ms', PrecisionOptionsEnum.FULL),
|
||||
).toBe('4.35381 s');
|
||||
expect(getYAxisFormattedValue('500', 'ms', PrecisionOptionsEnum.FULL)).toBe(
|
||||
'500 ms',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +1,158 @@
|
||||
/* eslint-disable sonarjs/cognitive-complexity */
|
||||
import { formattedValueToString, getValueFormat } from '@grafana/data';
|
||||
import * as Sentry from '@sentry/react';
|
||||
import { isNaN } from 'lodash-es';
|
||||
|
||||
const DEFAULT_SIGNIFICANT_DIGITS = 15;
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
const MAX_DECIMALS = 15;
|
||||
|
||||
export enum PrecisionOptionsEnum {
|
||||
ZERO = 0,
|
||||
ONE = 1,
|
||||
TWO = 2,
|
||||
THREE = 3,
|
||||
FOUR = 4,
|
||||
FULL = 'full',
|
||||
}
|
||||
export type PrecisionOption = 0 | 1 | 2 | 3 | 4 | PrecisionOptionsEnum.FULL;
|
||||
|
||||
/**
|
||||
* Formats a number for display, preserving leading zeros after the decimal point
|
||||
* and showing up to DEFAULT_SIGNIFICANT_DIGITS digits after the first non-zero decimal digit.
|
||||
* It avoids scientific notation and removes unnecessary trailing zeros.
|
||||
*
|
||||
* @example
|
||||
* formatDecimalWithLeadingZeros(1.2345); // "1.2345"
|
||||
* formatDecimalWithLeadingZeros(0.0012345); // "0.0012345"
|
||||
* formatDecimalWithLeadingZeros(5.0); // "5"
|
||||
*
|
||||
* @param value The number to format.
|
||||
* @returns The formatted string.
|
||||
*/
|
||||
const formatDecimalWithLeadingZeros = (
|
||||
value: number,
|
||||
precision: PrecisionOption,
|
||||
): string => {
|
||||
if (value === 0) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
// Use toLocaleString to get a full decimal representation without scientific notation.
|
||||
const numStr = value.toLocaleString('en-US', {
|
||||
useGrouping: false,
|
||||
maximumFractionDigits: 20,
|
||||
});
|
||||
|
||||
const [integerPart, decimalPart = ''] = numStr.split('.');
|
||||
|
||||
// If there's no decimal part, the integer part is the result.
|
||||
if (!decimalPart) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Find the index of the first non-zero digit in the decimal part.
|
||||
const firstNonZeroIndex = decimalPart.search(/[^0]/);
|
||||
|
||||
// If the decimal part consists only of zeros, return just the integer part.
|
||||
if (firstNonZeroIndex === -1) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Determine the number of decimals to keep: leading zeros + up to N significant digits.
|
||||
const significantDigits =
|
||||
precision === PrecisionOptionsEnum.FULL
|
||||
? DEFAULT_SIGNIFICANT_DIGITS
|
||||
: precision;
|
||||
const decimalsToKeep = firstNonZeroIndex + (significantDigits || 0);
|
||||
|
||||
// max decimals to keep should not exceed 15 decimal places to avoid floating point precision issues
|
||||
const finalDecimalsToKeep = Math.min(decimalsToKeep, MAX_DECIMALS);
|
||||
const trimmedDecimalPart = decimalPart.substring(0, finalDecimalsToKeep);
|
||||
|
||||
// If precision is 0, we drop the decimal part entirely.
|
||||
if (precision === 0) {
|
||||
return integerPart;
|
||||
}
|
||||
|
||||
// Remove any trailing zeros from the result to keep it clean.
|
||||
const finalDecimalPart = trimmedDecimalPart.replace(/0+$/, '');
|
||||
|
||||
// Return the integer part, or the integer and decimal parts combined.
|
||||
return finalDecimalPart ? `${integerPart}.${finalDecimalPart}` : integerPart;
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats a Y-axis value based on a given format string.
|
||||
*
|
||||
* @param value The string value from the axis.
|
||||
* @param format The format identifier (e.g. 'none', 'ms', 'bytes', 'short').
|
||||
* @returns A formatted string ready for display.
|
||||
*/
|
||||
export const getYAxisFormattedValue = (
|
||||
value: string,
|
||||
format: string,
|
||||
precision: PrecisionOption = 2, // default precision requested
|
||||
): string => {
|
||||
let decimalPrecision: number | undefined;
|
||||
const parsedValue = getValueFormat(format)(
|
||||
parseFloat(value),
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
try {
|
||||
const decimalSplitted = parsedValue.text.split('.');
|
||||
if (decimalSplitted.length === 1) {
|
||||
decimalPrecision = 0;
|
||||
} else {
|
||||
const decimalDigits = decimalSplitted[1].split('');
|
||||
decimalPrecision = decimalDigits.length;
|
||||
let nonZeroCtr = 0;
|
||||
for (let idx = 0; idx < decimalDigits.length; idx += 1) {
|
||||
if (decimalDigits[idx] !== '0') {
|
||||
nonZeroCtr += 1;
|
||||
if (nonZeroCtr >= 2) {
|
||||
decimalPrecision = idx + 1;
|
||||
}
|
||||
} else if (nonZeroCtr) {
|
||||
decimalPrecision = idx;
|
||||
break;
|
||||
}
|
||||
}
|
||||
const numValue = parseFloat(value);
|
||||
|
||||
// Handle non-numeric or special values first.
|
||||
if (isNaN(numValue)) return 'NaN';
|
||||
if (numValue === Infinity) return '∞';
|
||||
if (numValue === -Infinity) return '-∞';
|
||||
|
||||
const decimalPlaces = value.split('.')[1]?.length || undefined;
|
||||
|
||||
// Use custom formatter for the 'none' format honoring precision
|
||||
if (format === 'none') {
|
||||
return formatDecimalWithLeadingZeros(numValue, precision);
|
||||
}
|
||||
|
||||
// For all other standard formats, delegate to grafana/data's built-in formatter.
|
||||
const computeDecimals = (): number | undefined => {
|
||||
if (precision === PrecisionOptionsEnum.FULL) {
|
||||
return decimalPlaces && decimalPlaces >= DEFAULT_SIGNIFICANT_DIGITS
|
||||
? decimalPlaces
|
||||
: DEFAULT_SIGNIFICANT_DIGITS;
|
||||
}
|
||||
return precision;
|
||||
};
|
||||
|
||||
return formattedValueToString(
|
||||
getValueFormat(format)(
|
||||
parseFloat(value),
|
||||
decimalPrecision,
|
||||
undefined,
|
||||
undefined,
|
||||
),
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
}
|
||||
return `${parseFloat(value)}`;
|
||||
};
|
||||
const fallbackFormat = (): string => {
|
||||
if (precision === PrecisionOptionsEnum.FULL) return numValue.toString();
|
||||
if (precision === 0) return Math.round(numValue).toString();
|
||||
return precision !== undefined
|
||||
? numValue
|
||||
.toFixed(precision)
|
||||
.replace(/(\.[0-9]*[1-9])0+$/, '$1') // trimming zeros
|
||||
.replace(/\.$/, '')
|
||||
: numValue.toString();
|
||||
};
|
||||
|
||||
export const getToolTipValue = (value: string, format?: string): string => {
|
||||
try {
|
||||
return formattedValueToString(
|
||||
getValueFormat(format)(parseFloat(value), undefined, undefined, undefined),
|
||||
);
|
||||
const formatter = getValueFormat(format);
|
||||
const formattedValue = formatter(numValue, computeDecimals(), undefined);
|
||||
if (formattedValue.text && formattedValue.text.includes('.')) {
|
||||
formattedValue.text = formatDecimalWithLeadingZeros(
|
||||
parseFloat(formattedValue.text),
|
||||
precision,
|
||||
);
|
||||
}
|
||||
return formattedValueToString(formattedValue);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
Sentry.captureEvent({
|
||||
message: `Error applying formatter: ${
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
}`,
|
||||
level: 'error',
|
||||
});
|
||||
return fallbackFormat();
|
||||
}
|
||||
return `${value}`;
|
||||
};
|
||||
|
||||
export const getToolTipValue = (
|
||||
value: string | number,
|
||||
format?: string,
|
||||
precision?: PrecisionOption,
|
||||
): string =>
|
||||
getYAxisFormattedValue(value?.toString(), format || 'none', precision);
|
||||
|
||||
@@ -60,6 +60,14 @@ function Metrics({
|
||||
setElement,
|
||||
} = useMultiIntersectionObserver(hostWidgetInfo.length, { threshold: 0.1 });
|
||||
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const queryPayloads = useMemo(
|
||||
() =>
|
||||
getHostQueryPayload(
|
||||
@@ -147,6 +155,13 @@ function Metrics({
|
||||
maxTimeScale: graphTimeIntervals[idx].end,
|
||||
onDragSelect: (start, end) => onDragSelect(start, end, idx),
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
|
||||
@@ -132,9 +132,9 @@
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.json-action-btn {
|
||||
.log-detail-drawer__actions {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
gap: 4px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -319,31 +319,35 @@ function LogDetailInner({
|
||||
</Radio.Button>
|
||||
</Radio.Group>
|
||||
|
||||
{selectedView === VIEW_TYPES.JSON && (
|
||||
<div className="json-action-btn">
|
||||
<div className="log-detail-drawer__actions">
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Tooltip
|
||||
title="Show Filters"
|
||||
placement="topLeft"
|
||||
aria-label="Show Filters"
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Filter size={16} />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<Tooltip
|
||||
title={selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'}
|
||||
placement="topLeft"
|
||||
aria-label={
|
||||
selectedView === VIEW_TYPES.JSON ? 'Copy JSON' : 'Copy Log Link'
|
||||
}
|
||||
>
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Copy size={16} />}
|
||||
onClick={handleJSONCopy}
|
||||
onClick={selectedView === VIEW_TYPES.JSON ? handleJSONCopy : onLogCopy}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{selectedView === VIEW_TYPES.CONTEXT && (
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Filter size={16} />}
|
||||
onClick={handleFilterVisible}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip title="Copy Log Link" placement="left" aria-label="Copy Log Link">
|
||||
<Button
|
||||
className="action-btn"
|
||||
icon={<Copy size={16} />}
|
||||
onClick={onLogCopy}
|
||||
/>
|
||||
</Tooltip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
{isFilterVisible && contextQuery?.builder.queryData[0] && (
|
||||
<div className="log-detail-drawer-query-container">
|
||||
@@ -383,7 +387,8 @@ function LogDetailInner({
|
||||
podName={log.resources_string?.[RESOURCE_KEYS.POD_NAME] || ''}
|
||||
nodeName={log.resources_string?.[RESOURCE_KEYS.NODE_NAME] || ''}
|
||||
hostName={log.resources_string?.[RESOURCE_KEYS.HOST_NAME] || ''}
|
||||
logLineTimestamp={log.timestamp.toString()}
|
||||
timestamp={log.timestamp.toString()}
|
||||
dataSource={DataSource.LOGS}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -57,8 +57,8 @@ export const RawLogViewContainer = styled(Row)<{
|
||||
transition: background-color 2s ease-in;`
|
||||
: ''}
|
||||
|
||||
${({ $isCustomHighlighted, $isDarkMode, $logType }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted, $isDarkMode, $logType)}
|
||||
${({ $isCustomHighlighted }): string =>
|
||||
getCustomHighlightBackground($isCustomHighlighted)}
|
||||
`;
|
||||
|
||||
export const InfoIconWrapper = styled(Info)`
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -398,7 +398,7 @@
|
||||
}
|
||||
|
||||
.qb-search-container {
|
||||
.metrics-select-container {
|
||||
.metrics-container {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
showOnlyWhereClause = false,
|
||||
showTraceOperator = false,
|
||||
version,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
}: QueryBuilderProps): JSX.Element {
|
||||
const {
|
||||
currentQuery,
|
||||
@@ -175,6 +177,8 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
/>
|
||||
) : (
|
||||
currentQuery.builder.queryData.map((query, index) => (
|
||||
@@ -193,7 +197,9 @@ export const QueryBuilderV2 = memo(function QueryBuilderV2({
|
||||
queryVariant={config?.queryVariant || 'dropdown'}
|
||||
showOnlyWhereClause={showOnlyWhereClause}
|
||||
isListViewPanel={isListViewPanel}
|
||||
signalSource={config?.signalSource || ''}
|
||||
signalSource={query.source as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange || ((): void => {})}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
.metrics-select-container {
|
||||
.metrics-source-select-container {
|
||||
margin-bottom: 8px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
|
||||
.source-selector {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.ant-select-selector {
|
||||
width: 100%;
|
||||
@@ -42,7 +51,7 @@
|
||||
}
|
||||
|
||||
.lightMode {
|
||||
.metrics-select-container {
|
||||
.metrics-source-select-container {
|
||||
.ant-select-selector {
|
||||
border: 1px solid var(--bg-vanilla-300) !important;
|
||||
background: var(--bg-vanilla-100);
|
||||
|
||||
@@ -1,34 +1,121 @@
|
||||
import './MetricsSelect.styles.scss';
|
||||
|
||||
import { Select } from 'antd';
|
||||
import {
|
||||
initialQueriesMap,
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES,
|
||||
} from 'constants/queryBuilder';
|
||||
import { AggregatorFilter } from 'container/QueryBuilder/filters';
|
||||
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useQueryOperations } from 'hooks/queryBuilder/useQueryBuilderOperations';
|
||||
import { memo } from 'react';
|
||||
import { memo, useCallback, useMemo, useState } from 'react';
|
||||
import { BaseAutocompleteData } from 'types/api/queryBuilder/queryAutocompleteResponse';
|
||||
import { IBuilderQuery } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
import { SelectOption } from 'types/common/select';
|
||||
|
||||
export const SOURCE_OPTIONS: SelectOption<string, string>[] = [
|
||||
{ value: 'metrics', label: 'Metrics' },
|
||||
{ value: 'meter', label: 'Meter' },
|
||||
];
|
||||
|
||||
export const MetricsSelect = memo(function MetricsSelect({
|
||||
query,
|
||||
index,
|
||||
version,
|
||||
signalSource,
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
}: {
|
||||
query: IBuilderQuery;
|
||||
index: number;
|
||||
version: string;
|
||||
signalSource: 'meter' | '';
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
}): JSX.Element {
|
||||
const [attributeKeys, setAttributeKeys] = useState<BaseAutocompleteData[]>([]);
|
||||
|
||||
const { handleChangeAggregatorAttribute } = useQueryOperations({
|
||||
index,
|
||||
query,
|
||||
entityVersion: version,
|
||||
});
|
||||
|
||||
const handleAggregatorAttributeChange = useCallback(
|
||||
(value: BaseAutocompleteData, isEditMode?: boolean) => {
|
||||
handleChangeAggregatorAttribute(value, isEditMode, attributeKeys || []);
|
||||
},
|
||||
[handleChangeAggregatorAttribute, attributeKeys],
|
||||
);
|
||||
|
||||
const { updateAllQueriesOperators, handleSetQueryData } = useQueryBuilder();
|
||||
|
||||
const source = useMemo(
|
||||
() => (signalSource === 'meter' ? 'meter' : 'metrics'),
|
||||
[signalSource],
|
||||
);
|
||||
|
||||
const defaultMeterQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueryMeterWithType,
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.METRICS,
|
||||
'meter' as 'meter' | '',
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const defaultMetricsQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
initialQueriesMap.metrics,
|
||||
PANEL_TYPES.BAR,
|
||||
DataSource.METRICS,
|
||||
'',
|
||||
),
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
const handleSignalSourceChange = (value: string): void => {
|
||||
onSignalSourceChange(value);
|
||||
handleSetQueryData(
|
||||
index,
|
||||
value === 'meter'
|
||||
? {
|
||||
...defaultMeterQuery.builder.queryData[0],
|
||||
source: 'meter',
|
||||
queryName: query.queryName,
|
||||
}
|
||||
: {
|
||||
...defaultMetricsQuery.builder.queryData[0],
|
||||
source: '',
|
||||
queryName: query.queryName,
|
||||
},
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="metrics-select-container">
|
||||
<div className="metrics-source-select-container">
|
||||
{signalSourceChangeEnabled && (
|
||||
<Select
|
||||
className="source-selector"
|
||||
placeholder="Source"
|
||||
options={SOURCE_OPTIONS}
|
||||
value={source}
|
||||
defaultValue="metrics"
|
||||
onChange={handleSignalSourceChange}
|
||||
/>
|
||||
)}
|
||||
|
||||
<AggregatorFilter
|
||||
onChange={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>
|
||||
|
||||
@@ -33,7 +33,13 @@ export const QueryV2 = memo(function QueryV2({
|
||||
showOnlyWhereClause = false,
|
||||
signalSource = '',
|
||||
isMultiQueryAllowed = false,
|
||||
}: QueryProps & { ref: React.RefObject<HTMLDivElement> }): JSX.Element {
|
||||
onSignalSourceChange,
|
||||
signalSourceChangeEnabled = false,
|
||||
}: QueryProps & {
|
||||
ref: React.RefObject<HTMLDivElement>;
|
||||
onSignalSourceChange: (value: string) => void;
|
||||
signalSourceChangeEnabled: boolean;
|
||||
}): JSX.Element {
|
||||
const { cloneQuery, panelType } = useQueryBuilder();
|
||||
|
||||
const showFunctions = query?.functions?.length > 0;
|
||||
@@ -207,12 +213,14 @@ export const QueryV2 = memo(function QueryV2({
|
||||
<div className="qb-elements-container">
|
||||
<div className="qb-search-container">
|
||||
{dataSource === DataSource.METRICS && (
|
||||
<div className="metrics-select-container">
|
||||
<div className="metrics-container">
|
||||
<MetricsSelect
|
||||
query={query}
|
||||
index={index}
|
||||
version={ENTITY_VERSION_V5}
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
onSignalSourceChange={onSignalSourceChange}
|
||||
signalSourceChangeEnabled={signalSourceChangeEnabled}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
@@ -258,7 +266,7 @@ export const QueryV2 = memo(function QueryV2({
|
||||
panelType={panelType}
|
||||
query={query}
|
||||
index={index}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}`}
|
||||
key={`metrics-aggregate-section-${query.queryName}-${query.dataSource}-${signalSource}`}
|
||||
version="v4"
|
||||
signalSource={signalSource as 'meter' | ''}
|
||||
/>
|
||||
|
||||
@@ -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 ? (
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -24,6 +24,7 @@ export const DATE_TIME_FORMATS = {
|
||||
TIME_SECONDS: 'HH:mm:ss',
|
||||
TIME_UTC: 'HH:mm:ss (UTC Z)',
|
||||
TIME_UTC_MS: 'HH:mm:ss.SSS (UTC Z)',
|
||||
TIME_SPAN_PERCENTILE: 'HH:mm:ss MMM DD',
|
||||
|
||||
// Short date formats
|
||||
DATE_SHORT: 'MM/DD',
|
||||
|
||||
@@ -50,4 +50,5 @@ export enum QueryParams {
|
||||
tab = 'tab',
|
||||
thresholds = 'thresholds',
|
||||
selectedExplorerView = 'selectedExplorerView',
|
||||
variables = 'variables',
|
||||
}
|
||||
|
||||
@@ -86,7 +86,11 @@ export const REACT_QUERY_KEY = {
|
||||
SPAN_LOGS: 'SPAN_LOGS',
|
||||
SPAN_BEFORE_LOGS: 'SPAN_BEFORE_LOGS',
|
||||
SPAN_AFTER_LOGS: 'SPAN_AFTER_LOGS',
|
||||
TRACE_ONLY_LOGS: 'TRACE_ONLY_LOGS',
|
||||
|
||||
// Routing Policies Query Keys
|
||||
GET_ROUTING_POLICIES: 'GET_ROUTING_POLICIES',
|
||||
|
||||
// Span Percentiles Query Keys
|
||||
GET_SPAN_PERCENTILES: 'GET_SPAN_PERCENTILES',
|
||||
} as const;
|
||||
|
||||
@@ -3,4 +3,5 @@ export const USER_PREFERENCES = {
|
||||
NAV_SHORTCUTS: 'nav_shortcuts',
|
||||
LAST_SEEN_CHANGELOG_VERSION: 'last_seen_changelog_version',
|
||||
SPAN_DETAILS_PINNED_ATTRIBUTES: 'span_details_pinned_attributes',
|
||||
SPAN_PERCENTILE_RESOURCE_ATTRIBUTES: 'span_percentile_resource_attributes',
|
||||
};
|
||||
|
||||
@@ -69,6 +69,13 @@ function StatusCodeBarCharts({
|
||||
} = endPointStatusCodeLatencyBarChartsDataQuery;
|
||||
|
||||
const { startTime: minTime, endTime: maxTime } = timeRange;
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
@@ -207,6 +214,13 @@ function StatusCodeBarCharts({
|
||||
onDragSelect,
|
||||
colorMapping,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
minTime,
|
||||
|
||||
@@ -11,12 +11,14 @@ import { v4 } from 'uuid';
|
||||
|
||||
import { useCreateAlertState } from '../context';
|
||||
import {
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_INFO_THRESHOLD,
|
||||
INITIAL_RANDOM_THRESHOLD,
|
||||
INITIAL_WARNING_THRESHOLD,
|
||||
THRESHOLD_MATCH_TYPE_OPTIONS,
|
||||
THRESHOLD_OPERATOR_OPTIONS,
|
||||
} from '../context/constants';
|
||||
import { AlertThresholdMatchType } from '../context/types';
|
||||
import EvaluationSettings from '../EvaluationSettings/EvaluationSettings';
|
||||
import ThresholdItem from './ThresholdItem';
|
||||
import { AnomalyAndThresholdProps, UpdateThreshold } from './types';
|
||||
@@ -38,12 +40,12 @@ function AlertThreshold({
|
||||
alertState,
|
||||
thresholdState,
|
||||
setThresholdState,
|
||||
setEvaluationWindow,
|
||||
notificationSettings,
|
||||
setNotificationSettings,
|
||||
} = useCreateAlertState();
|
||||
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const queryNames = getQueryNames(currentQuery);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -160,6 +162,54 @@ function AlertThreshold({
|
||||
}),
|
||||
);
|
||||
|
||||
const handleSetEvaluationDetailsForMeter = (): void => {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
});
|
||||
|
||||
setThresholdState({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSelectedQueryChange = (value: string): void => {
|
||||
// loop through currenttQuery and find the query that matches the selected query
|
||||
const query = currentQuery?.builder?.queryData.find(
|
||||
(query) => query.queryName === value,
|
||||
);
|
||||
|
||||
const currentSelectedQuery = currentQuery?.builder?.queryData.find(
|
||||
(query) => query.queryName === thresholdState.selectedQuery,
|
||||
);
|
||||
|
||||
const newSelectedQuerySource = query?.source || '';
|
||||
const currentSelectedQuerySource = currentSelectedQuery?.source || '';
|
||||
|
||||
if (newSelectedQuerySource === currentSelectedQuerySource) {
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: value,
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (newSelectedQuerySource === 'meter') {
|
||||
handleSetEvaluationDetailsForMeter();
|
||||
} else {
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE',
|
||||
payload: INITIAL_EVALUATION_WINDOW_STATE,
|
||||
});
|
||||
}
|
||||
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: value,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
@@ -175,14 +225,10 @@ function AlertThreshold({
|
||||
</Typography.Text>
|
||||
<Select
|
||||
value={thresholdState.selectedQuery}
|
||||
onChange={(value): void => {
|
||||
setThresholdState({
|
||||
type: 'SET_SELECTED_QUERY',
|
||||
payload: value,
|
||||
});
|
||||
}}
|
||||
onChange={handleSelectedQueryChange}
|
||||
style={{ width: 80 }}
|
||||
options={queryNames}
|
||||
data-testid="alert-threshold-query-select"
|
||||
/>
|
||||
<Typography.Text className="sentence-text">is</Typography.Text>
|
||||
<Select
|
||||
@@ -195,6 +241,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 +256,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 +284,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 */}
|
||||
|
||||
@@ -10,6 +10,7 @@ import { getEvaluationWindowTypeText, getTimeframeText } from './utils';
|
||||
|
||||
function EvaluationSettings(): JSX.Element {
|
||||
const { evaluationWindow, setEvaluationWindow } = useCreateAlertState();
|
||||
|
||||
const [
|
||||
isEvaluationWindowPopoverOpen,
|
||||
setIsEvaluationWindowPopoverOpen,
|
||||
@@ -30,7 +31,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>
|
||||
|
||||
@@ -24,7 +24,11 @@ import {
|
||||
INITIAL_EVALUATION_WINDOW_STATE,
|
||||
INITIAL_NOTIFICATION_SETTINGS_STATE,
|
||||
} from './constants';
|
||||
import { ICreateAlertContextProps, ICreateAlertProviderProps } from './types';
|
||||
import {
|
||||
AlertThresholdMatchType,
|
||||
ICreateAlertContextProps,
|
||||
ICreateAlertProviderProps,
|
||||
} from './types';
|
||||
import {
|
||||
advancedOptionsReducer,
|
||||
alertCreationReducer,
|
||||
@@ -67,6 +71,7 @@ export function CreateAlertProvider(
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const location = useLocation();
|
||||
const queryParams = new URLSearchParams(location.search);
|
||||
const thresholdsFromURL = queryParams.get(QueryParams.thresholds);
|
||||
|
||||
const [alertType, setAlertType] = useState<AlertTypes>(() => {
|
||||
if (isEditMode) {
|
||||
@@ -122,7 +127,28 @@ export function CreateAlertProvider(
|
||||
setThresholdState({
|
||||
type: 'RESET',
|
||||
});
|
||||
}, [alertType]);
|
||||
|
||||
if (thresholdsFromURL) {
|
||||
try {
|
||||
const thresholds = JSON.parse(thresholdsFromURL);
|
||||
setThresholdState({
|
||||
type: 'SET_THRESHOLDS',
|
||||
payload: thresholds,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error parsing thresholds from URL:', error);
|
||||
}
|
||||
|
||||
setEvaluationWindow({
|
||||
type: 'SET_INITIAL_STATE_FOR_METER',
|
||||
});
|
||||
|
||||
setThresholdState({
|
||||
type: 'SET_MATCH_TYPE',
|
||||
payload: AlertThresholdMatchType.IN_TOTAL,
|
||||
});
|
||||
}
|
||||
}, [alertType, thresholdsFromURL]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditMode && initialAlertState) {
|
||||
|
||||
@@ -237,6 +237,7 @@ export type EvaluationWindowAction =
|
||||
}
|
||||
| { type: 'SET_EVALUATION_CADENCE_MODE'; payload: EvaluationCadenceMode }
|
||||
| { type: 'SET_INITIAL_STATE'; payload: EvaluationWindowState }
|
||||
| { type: 'SET_INITIAL_STATE_FOR_METER' }
|
||||
| { type: 'RESET' };
|
||||
|
||||
export type EvaluationCadenceMode = 'default' | 'custom' | 'rrule';
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { UTC_TIMEZONE } from 'components/CustomTimePicker/timezoneUtils';
|
||||
import { UniversalYAxisUnit } from 'components/YAxisUnitSelector/types';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import {
|
||||
alertDefaults,
|
||||
@@ -11,6 +13,7 @@ import { AlertDef } from 'types/api/alerts/def';
|
||||
import { Query } from 'types/api/queryBuilder/queryBuilderData';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { CumulativeWindowTimeframes } from '../EvaluationSettings/types';
|
||||
import {
|
||||
INITIAL_ADVANCED_OPTIONS_STATE,
|
||||
INITIAL_ALERT_STATE,
|
||||
@@ -210,6 +213,18 @@ export const evaluationWindowReducer = (
|
||||
return INITIAL_EVALUATION_WINDOW_STATE;
|
||||
case 'SET_INITIAL_STATE':
|
||||
return action.payload;
|
||||
case 'SET_INITIAL_STATE_FOR_METER':
|
||||
return {
|
||||
...state,
|
||||
windowType: 'cumulative',
|
||||
timeframe: CumulativeWindowTimeframes.CURRENT_DAY,
|
||||
startingAt: {
|
||||
time: '00:00:00',
|
||||
number: '0',
|
||||
timezone: UTC_TIMEZONE.value,
|
||||
unit: UniversalYAxisUnit.MINUTES,
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -171,3 +171,30 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
.lightMode {
|
||||
.empty-logs-search {
|
||||
&__resources-card {
|
||||
background: var(--bg-vanilla-100);
|
||||
border: 1px solid var(--bg-vanilla-300);
|
||||
}
|
||||
|
||||
&__resources-title {
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
|
||||
&__resources-description,
|
||||
&__description-list,
|
||||
&__subtitle {
|
||||
color: var(--bg-ink-300);
|
||||
}
|
||||
|
||||
&__title {
|
||||
color: var(--bg-ink-500);
|
||||
}
|
||||
|
||||
&__clear-filters-btn {
|
||||
border: 1px dashed var(--bg-vanilla-300);
|
||||
color: var(--bg-ink-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -108,6 +108,13 @@ function ChartPreview({
|
||||
const [minTimeScale, setMinTimeScale] = useState<number>();
|
||||
const [maxTimeScale, setMaxTimeScale] = useState<number>();
|
||||
const [graphVisibility, setGraphVisibility] = useState<boolean[]>([]);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
|
||||
const { minTime, maxTime, selectedTime: globalSelectedInterval } = useSelector<
|
||||
@@ -296,6 +303,13 @@ function ChartPreview({
|
||||
setGraphsVisibilityStates: setGraphVisibility,
|
||||
enhancedLegend: true,
|
||||
legendPosition,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
[
|
||||
yAxisUnit,
|
||||
|
||||
@@ -36,6 +36,7 @@ function QuerySection({
|
||||
// init namespace for translations
|
||||
const { t } = useTranslation('alerts');
|
||||
const [currentTab, setCurrentTab] = useState(queryCategory);
|
||||
const [signalSource, setSignalSource] = useState<string>('metrics');
|
||||
|
||||
const handleQueryCategoryChange = (queryType: string): void => {
|
||||
setQueryCategory(queryType as EQueryType);
|
||||
@@ -48,12 +49,17 @@ function QuerySection({
|
||||
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
const handleSignalSourceChange = (value: string): void => {
|
||||
setSignalSource(value);
|
||||
};
|
||||
|
||||
const renderMetricUI = (): JSX.Element => (
|
||||
<QueryBuilderV2
|
||||
panelType={panelType}
|
||||
config={{
|
||||
queryVariant: 'static',
|
||||
initialDataSource: ALERTS_DATA_SOURCE_MAP[alertType],
|
||||
signalSource: signalSource === 'meter' ? 'meter' : '',
|
||||
}}
|
||||
showTraceOperator={alertType === AlertTypes.TRACES_BASED_ALERT}
|
||||
showFunctions={
|
||||
@@ -62,6 +68,8 @@ function QuerySection({
|
||||
alertType === AlertTypes.LOGS_BASED_ALERT
|
||||
}
|
||||
version={alertDef.version || 'v3'}
|
||||
onSignalSourceChange={handleSignalSourceChange}
|
||||
signalSourceChangeEnabled
|
||||
/>
|
||||
);
|
||||
|
||||
|
||||
@@ -137,8 +137,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 = (
|
||||
|
||||
@@ -54,16 +54,34 @@ function GraphManager({
|
||||
|
||||
const labelClickedHandler = useCallback(
|
||||
(labelIndex: number): void => {
|
||||
const newGraphVisibilityStates = Array<boolean>(data.length).fill(false);
|
||||
newGraphVisibilityStates[labelIndex] = true;
|
||||
if (labelIndex < 0 || labelIndex >= graphsVisibilityStates.length) return;
|
||||
|
||||
const newGraphVisibilityStates = [...graphsVisibilityStates];
|
||||
const isCurrentlyVisible = newGraphVisibilityStates[labelIndex];
|
||||
const visibleCount = newGraphVisibilityStates.filter(Boolean).length;
|
||||
|
||||
if (isCurrentlyVisible && visibleCount === 1) {
|
||||
newGraphVisibilityStates.fill(true);
|
||||
} else if (isCurrentlyVisible) {
|
||||
newGraphVisibilityStates.fill(false);
|
||||
newGraphVisibilityStates[labelIndex] = true;
|
||||
} else {
|
||||
newGraphVisibilityStates[labelIndex] = true;
|
||||
}
|
||||
|
||||
// Update all graphs based on new state
|
||||
newGraphVisibilityStates.forEach((state, index) => {
|
||||
lineChartRef?.current?.toggleGraph(index, state);
|
||||
parentChartRef?.current?.toggleGraph(index, state);
|
||||
});
|
||||
setGraphsVisibilityStates(newGraphVisibilityStates);
|
||||
},
|
||||
[data.length, lineChartRef, parentChartRef, setGraphsVisibilityStates],
|
||||
[
|
||||
graphsVisibilityStates,
|
||||
lineChartRef,
|
||||
parentChartRef,
|
||||
setGraphsVisibilityStates,
|
||||
],
|
||||
);
|
||||
|
||||
const columns = getGraphManagerTableColumns({
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { CheckboxChangeEvent } from 'antd/es/checkbox';
|
||||
import { ColumnType } from 'antd/es/table';
|
||||
import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
|
||||
import { ColumnsKeyAndDataIndex, ColumnsTitle } from '../contants';
|
||||
import { DataSetProps, ExtendedChartDataset } from '../types';
|
||||
@@ -7,6 +8,20 @@ import { getGraphManagerTableHeaderTitle } from '../utils';
|
||||
import CustomCheckBox from './CustomCheckBox';
|
||||
import { getLabel } from './GetLabel';
|
||||
|
||||
// Helper function to format numeric values based on yAxisUnit
|
||||
const formatMetricValue = (
|
||||
value: number | null | undefined,
|
||||
yAxisUnit?: string,
|
||||
): string => {
|
||||
if (value == null || value === undefined || Number.isNaN(value)) {
|
||||
return '';
|
||||
}
|
||||
if (yAxisUnit) {
|
||||
return getYAxisFormattedValue(value.toString(), yAxisUnit);
|
||||
}
|
||||
return value.toString();
|
||||
};
|
||||
|
||||
export const getGraphManagerTableColumns = ({
|
||||
tableDataSet,
|
||||
checkBoxOnChangeHandler,
|
||||
@@ -45,6 +60,7 @@ export const getGraphManagerTableColumns = ({
|
||||
width: 90,
|
||||
dataIndex: ColumnsKeyAndDataIndex.Avg,
|
||||
key: ColumnsKeyAndDataIndex.Avg,
|
||||
render: (value: number): string => formatMetricValue(value, yAxisUnit),
|
||||
},
|
||||
{
|
||||
title: getGraphManagerTableHeaderTitle(
|
||||
@@ -54,6 +70,7 @@ export const getGraphManagerTableColumns = ({
|
||||
width: 90,
|
||||
dataIndex: ColumnsKeyAndDataIndex.Sum,
|
||||
key: ColumnsKeyAndDataIndex.Sum,
|
||||
render: (value: number): string => formatMetricValue(value, yAxisUnit),
|
||||
},
|
||||
{
|
||||
title: getGraphManagerTableHeaderTitle(
|
||||
@@ -63,6 +80,7 @@ export const getGraphManagerTableColumns = ({
|
||||
width: 90,
|
||||
dataIndex: ColumnsKeyAndDataIndex.Max,
|
||||
key: ColumnsKeyAndDataIndex.Max,
|
||||
render: (value: number): string => formatMetricValue(value, yAxisUnit),
|
||||
},
|
||||
{
|
||||
title: getGraphManagerTableHeaderTitle(
|
||||
@@ -72,10 +90,11 @@ export const getGraphManagerTableColumns = ({
|
||||
width: 90,
|
||||
dataIndex: ColumnsKeyAndDataIndex.Min,
|
||||
key: ColumnsKeyAndDataIndex.Min,
|
||||
render: (value: number): string => formatMetricValue(value, yAxisUnit),
|
||||
},
|
||||
];
|
||||
|
||||
interface GetGraphManagerTableColumnsProps {
|
||||
export interface GetGraphManagerTableColumnsProps {
|
||||
tableDataSet: ExtendedChartDataset[];
|
||||
checkBoxOnChangeHandler: (e: CheckboxChangeEvent, index: number) => void;
|
||||
labelClickedHandler: (labelIndex: number) => void;
|
||||
|
||||
@@ -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[]
|
||||
@@ -327,6 +324,7 @@ function FullView({
|
||||
panelType={selectedPanelType}
|
||||
version={selectedDashboard?.data?.version || 'v3'}
|
||||
isListViewPanel={selectedPanelType === PANEL_TYPES.LIST}
|
||||
signalSourceChangeEnabled
|
||||
// filterConfigs={filterConfigs}
|
||||
// queryComponents={queryComponents}
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
/* eslint-disable sonarjs/no-duplicate-string */
|
||||
/* eslint-disable react/jsx-props-no-spreading */
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import GraphManager from '../GridCard/FullView/GraphManager';
|
||||
import {
|
||||
getGraphManagerTableColumns,
|
||||
GetGraphManagerTableColumnsProps,
|
||||
} from '../GridCard/FullView/TableRender/GraphManagerColumns';
|
||||
import { GraphManagerProps } from '../GridCard/FullView/types';
|
||||
|
||||
// Props
|
||||
const props = {
|
||||
tableDataSet: [
|
||||
{
|
||||
label: 'Timestamp',
|
||||
stroke: 'purple',
|
||||
index: 0,
|
||||
show: true,
|
||||
sum: 52791867900,
|
||||
avg: 1759728930,
|
||||
max: 1759729800,
|
||||
min: 1759728060,
|
||||
},
|
||||
{
|
||||
drawStyle: 'line',
|
||||
lineInterpolation: 'spline',
|
||||
show: true,
|
||||
label: '{service.name=""}',
|
||||
stroke: '#B33300',
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
size: 5,
|
||||
show: false,
|
||||
stroke: '#B33300',
|
||||
},
|
||||
index: 1,
|
||||
sum: 2274.96,
|
||||
avg: 75.83,
|
||||
max: 115.76,
|
||||
min: 55.64,
|
||||
},
|
||||
{
|
||||
drawStyle: 'line',
|
||||
lineInterpolation: 'spline',
|
||||
show: true,
|
||||
label: '{service.name="recommendationservice"}',
|
||||
stroke: '#BB6BD9',
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
size: 5,
|
||||
show: false,
|
||||
stroke: '#BB6BD9',
|
||||
},
|
||||
index: 2,
|
||||
sum: 1770.84,
|
||||
avg: 59.028,
|
||||
max: 112.16,
|
||||
min: 0,
|
||||
},
|
||||
{
|
||||
drawStyle: 'line',
|
||||
lineInterpolation: 'spline',
|
||||
show: true,
|
||||
label: '{service.name="loadgenerator"}',
|
||||
stroke: '#E9967A',
|
||||
width: 2,
|
||||
spanGaps: true,
|
||||
points: {
|
||||
size: 5,
|
||||
show: false,
|
||||
stroke: '#E9967A',
|
||||
},
|
||||
index: 3,
|
||||
sum: 1801.25,
|
||||
avg: 60.041,
|
||||
max: 94.46,
|
||||
min: 39.86,
|
||||
},
|
||||
],
|
||||
graphVisibilityState: [true, true, true, true],
|
||||
yAxisUnit: 'ops',
|
||||
isGraphDisabled: false,
|
||||
} as GetGraphManagerTableColumnsProps;
|
||||
|
||||
describe('GraphManager', () => {
|
||||
it('should render the columns', () => {
|
||||
const columns = getGraphManagerTableColumns({
|
||||
...props,
|
||||
});
|
||||
expect(columns).toStrictEqual([
|
||||
{
|
||||
dataIndex: 'index',
|
||||
key: 'index',
|
||||
render: expect.any(Function),
|
||||
title: '',
|
||||
width: 50,
|
||||
},
|
||||
{
|
||||
dataIndex: 'label',
|
||||
key: 'label',
|
||||
render: expect.any(Function),
|
||||
title: 'Label',
|
||||
width: 300,
|
||||
},
|
||||
{
|
||||
dataIndex: 'avg',
|
||||
key: 'avg',
|
||||
render: expect.any(Function),
|
||||
title: 'Avg (in ops)',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
dataIndex: 'sum',
|
||||
key: 'sum',
|
||||
render: expect.any(Function),
|
||||
title: 'Sum (in ops)',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
dataIndex: 'max',
|
||||
key: 'max',
|
||||
render: expect.any(Function),
|
||||
title: 'Max (in ops)',
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
dataIndex: 'min',
|
||||
key: 'min',
|
||||
render: expect.any(Function),
|
||||
title: 'Min (in ops)',
|
||||
width: 90,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('should render graphmanager with correct formatting using y-axis', () => {
|
||||
const testProps: GraphManagerProps = {
|
||||
data: [
|
||||
[1759729380, 1759729440, 1759729500], // timestamps
|
||||
[66.167, 76.833, 83.767], // series 1
|
||||
[46.6, 52.7, 70.867], // series 2
|
||||
[45.967, 52.967, 69.933], // series 3
|
||||
],
|
||||
name: 'test-graph',
|
||||
yAxisUnit: 'ops',
|
||||
onToggleModelHandler: jest.fn(),
|
||||
setGraphsVisibilityStates: jest.fn(),
|
||||
graphsVisibilityStates: [true, true, true, true],
|
||||
lineChartRef: { current: { toggleGraph: jest.fn() } },
|
||||
parentChartRef: { current: { toggleGraph: jest.fn() } },
|
||||
options: {
|
||||
series: [
|
||||
{ label: 'Timestamp' },
|
||||
{ label: '{service.name=""}' },
|
||||
{ label: '{service.name="recommendationservice"}' },
|
||||
{ label: '{service.name="loadgenerator"}' },
|
||||
],
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
};
|
||||
|
||||
// eslint-disable-next-line react/jsx-props-no-spreading
|
||||
render(<GraphManager {...testProps} />);
|
||||
|
||||
// Assert that column headers include y-axis unit formatting
|
||||
expect(screen.getByText('Avg (in ops)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Sum (in ops)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Max (in ops)')).toBeInTheDocument();
|
||||
expect(screen.getByText('Min (in ops)')).toBeInTheDocument();
|
||||
|
||||
// Assert formatting
|
||||
expect(screen.getByText('75.6 ops/s')).toBeInTheDocument();
|
||||
expect(screen.getByText('227 ops/s')).toBeInTheDocument();
|
||||
expect(screen.getByText('83.8 ops/s')).toBeInTheDocument();
|
||||
expect(screen.getByText('66.2 ops/s')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should handle checkbox click correctly', async () => {
|
||||
const mockToggleGraph = jest.fn();
|
||||
const mockSetGraphsVisibilityStates = jest.fn();
|
||||
|
||||
const testProps: GraphManagerProps = {
|
||||
data: [
|
||||
[1759729380, 1759729440, 1759729500],
|
||||
[66.167, 76.833, 83.767],
|
||||
[46.6, 52.7, 70.867],
|
||||
],
|
||||
name: 'test-graph',
|
||||
yAxisUnit: 'ops',
|
||||
onToggleModelHandler: jest.fn(),
|
||||
setGraphsVisibilityStates: mockSetGraphsVisibilityStates,
|
||||
graphsVisibilityStates: [true, true, true],
|
||||
lineChartRef: { current: { toggleGraph: mockToggleGraph } },
|
||||
parentChartRef: { current: { toggleGraph: mockToggleGraph } },
|
||||
options: {
|
||||
series: [
|
||||
{ label: 'Timestamp' },
|
||||
{ label: '{service.name=""}' },
|
||||
{ label: '{service.name="recommendationservice"}' },
|
||||
],
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
};
|
||||
|
||||
render(<GraphManager {...testProps} />);
|
||||
|
||||
// Find the first checkbox input (index 1, since index 0 is timestamp)
|
||||
const checkbox = screen.getAllByRole('checkbox')[0];
|
||||
expect(checkbox).toBeInTheDocument();
|
||||
|
||||
// Simulate checkbox click
|
||||
await userEvent.click(checkbox);
|
||||
|
||||
// Verify toggleGraph was called on both chart refs
|
||||
expect(mockToggleGraph).toHaveBeenCalledWith(1, false);
|
||||
expect(mockToggleGraph).toHaveBeenCalledTimes(2); // lineChartRef and parentChartRef
|
||||
|
||||
// Verify state update function was called
|
||||
expect(mockSetGraphsVisibilityStates).toHaveBeenCalledWith([
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
]);
|
||||
});
|
||||
|
||||
it('should handle label click correctly for visibility toggle', async () => {
|
||||
const mockToggleGraph = jest.fn();
|
||||
const mockSetGraphsVisibilityStates = jest.fn();
|
||||
|
||||
const testProps: GraphManagerProps = {
|
||||
data: [
|
||||
[1759729380, 1759729440, 1759729500],
|
||||
[66.167, 76.833, 83.767],
|
||||
[46.6, 52.7, 70.867],
|
||||
],
|
||||
name: 'test-graph',
|
||||
yAxisUnit: 'ops',
|
||||
onToggleModelHandler: jest.fn(),
|
||||
setGraphsVisibilityStates: mockSetGraphsVisibilityStates,
|
||||
graphsVisibilityStates: [true, true, true],
|
||||
lineChartRef: { current: { toggleGraph: mockToggleGraph } },
|
||||
parentChartRef: { current: { toggleGraph: mockToggleGraph } },
|
||||
options: {
|
||||
series: [
|
||||
{ label: 'Timestamp' },
|
||||
{ label: '{service.name="loadgenerator"}' },
|
||||
{ label: '{service.name="recommendationservice"}' },
|
||||
],
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
};
|
||||
|
||||
render(<GraphManager {...testProps} />);
|
||||
|
||||
// Find the first label button (skip Cancel and Save buttons)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const label = buttons.find((button) =>
|
||||
button.textContent?.includes('{service.name="loadgenerator"}'),
|
||||
) as HTMLElement;
|
||||
expect(label).toBeInTheDocument();
|
||||
|
||||
// Simulate label click
|
||||
await userEvent.click(label);
|
||||
|
||||
// Verify setGraphsVisibilityStates was called with show-only behavior
|
||||
expect(mockSetGraphsVisibilityStates).toHaveBeenCalledWith([
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
]);
|
||||
|
||||
// Check if toggleGraph was called for each series
|
||||
expect(mockToggleGraph).toHaveBeenCalledWith(0, false); // timestamp
|
||||
expect(mockToggleGraph).toHaveBeenCalledWith(1, true); // selected series
|
||||
expect(mockToggleGraph).toHaveBeenCalledWith(2, false); // other series
|
||||
expect(mockToggleGraph).toHaveBeenCalledTimes(6); // 3 series × 2 chart refs
|
||||
});
|
||||
|
||||
it('should handle label click to show all when only one is visible', async () => {
|
||||
const mockToggleGraph = jest.fn();
|
||||
const mockSetGraphsVisibilityStates = jest.fn();
|
||||
|
||||
const testProps: GraphManagerProps = {
|
||||
data: [
|
||||
[1759729380, 1759729440, 1759729500],
|
||||
[66.167, 76.833, 83.767],
|
||||
[46.6, 52.7, 70.867],
|
||||
],
|
||||
name: 'test-graph',
|
||||
yAxisUnit: 'ops',
|
||||
onToggleModelHandler: jest.fn(),
|
||||
setGraphsVisibilityStates: mockSetGraphsVisibilityStates,
|
||||
graphsVisibilityStates: [false, true, false], // Only one series visible
|
||||
lineChartRef: { current: { toggleGraph: mockToggleGraph } },
|
||||
parentChartRef: { current: { toggleGraph: mockToggleGraph } },
|
||||
options: {
|
||||
series: [
|
||||
{ label: 'Timestamp' },
|
||||
{ label: '{service.name=""}' },
|
||||
{ label: '{service.name="recommendationservice"}' },
|
||||
],
|
||||
width: 100,
|
||||
height: 100,
|
||||
},
|
||||
};
|
||||
|
||||
render(<GraphManager {...testProps} />);
|
||||
|
||||
// Find the visible label button (skip Cancel and Save buttons)
|
||||
const buttons = screen.getAllByRole('button');
|
||||
const label = buttons.find((button) =>
|
||||
button.textContent?.includes('{service.name=""}'),
|
||||
) as HTMLElement;
|
||||
expect(label).toBeInTheDocument();
|
||||
|
||||
// Simulate label click (should show all since only this one is visible)
|
||||
await userEvent.click(label);
|
||||
|
||||
// Verify setGraphsVisibilityStates was called with show-all behavior
|
||||
expect(mockSetGraphsVisibilityStates).toHaveBeenCalledWith([
|
||||
true,
|
||||
true,
|
||||
true,
|
||||
]);
|
||||
|
||||
// Check if toggleGraph was called to show all series
|
||||
expect(mockToggleGraph).toHaveBeenCalledWith(0, true); // timestamp
|
||||
expect(mockToggleGraph).toHaveBeenCalledWith(1, true); // current series
|
||||
expect(mockToggleGraph).toHaveBeenCalledWith(2, true); // other series
|
||||
expect(mockToggleGraph).toHaveBeenCalledTimes(6); // 3 series × 2 chart refs
|
||||
});
|
||||
});
|
||||
@@ -48,6 +48,7 @@ function GridTableComponent({
|
||||
widgetId,
|
||||
panelType,
|
||||
queryRangeRequest,
|
||||
decimalPrecision,
|
||||
...props
|
||||
}: GridTableComponentProps): JSX.Element {
|
||||
const { t } = useTranslation(['valueGraph']);
|
||||
@@ -87,10 +88,19 @@ function GridTableComponent({
|
||||
const newValue = { ...val };
|
||||
Object.keys(val).forEach((k) => {
|
||||
const unit = getColumnUnit(k, columnUnits);
|
||||
if (unit) {
|
||||
// Apply formatting if:
|
||||
// 1. Column has a unit defined, OR
|
||||
// 2. decimalPrecision is specified (format all values)
|
||||
const shouldFormat = unit || decimalPrecision !== undefined;
|
||||
|
||||
if (shouldFormat) {
|
||||
// the check below takes care of not adding units for rows that have n/a or null values
|
||||
if (val[k] !== 'n/a' && val[k] !== null) {
|
||||
newValue[k] = getYAxisFormattedValue(String(val[k]), unit);
|
||||
newValue[k] = getYAxisFormattedValue(
|
||||
String(val[k]),
|
||||
unit || 'none',
|
||||
decimalPrecision,
|
||||
);
|
||||
} else if (val[k] === null) {
|
||||
newValue[k] = 'n/a';
|
||||
}
|
||||
@@ -103,7 +113,7 @@ function GridTableComponent({
|
||||
|
||||
return mutateDataSource;
|
||||
},
|
||||
[columnUnits],
|
||||
[columnUnits, decimalPrecision],
|
||||
);
|
||||
|
||||
const dataSource = useMemo(() => applyColumnUnits(originalDataSource), [
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TableProps } from 'antd';
|
||||
import { PrecisionOption } from 'components/Graph/yAxisConfig';
|
||||
import { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
import { LogsExplorerTableProps } from 'container/LogsExplorerTable/LogsExplorerTable.interfaces';
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ export type GridTableComponentProps = {
|
||||
query: Query;
|
||||
thresholds?: ThresholdProps[];
|
||||
columnUnits?: ColumnUnit;
|
||||
decimalPrecision?: PrecisionOption;
|
||||
tableProcessedDataRef?: React.MutableRefObject<RowData[]>;
|
||||
sticky?: TableProps<RowData>['sticky'];
|
||||
searchTerm?: string;
|
||||
|
||||
@@ -99,7 +99,11 @@ function GridValueComponent({
|
||||
rawValue={value}
|
||||
value={
|
||||
yAxisUnit
|
||||
? getYAxisFormattedValue(String(value), yAxisUnit)
|
||||
? getYAxisFormattedValue(
|
||||
String(value),
|
||||
yAxisUnit,
|
||||
widget?.decimalPrecision,
|
||||
)
|
||||
: value.toString()
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -115,6 +115,13 @@ function EntityMetrics<T>({
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const { currentQuery } = useQueryBuilder();
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const chartData = useMemo(
|
||||
() =>
|
||||
@@ -184,6 +191,13 @@ function EntityMetrics<T>({
|
||||
maxTimeScale: graphTimeIntervals[idx].end,
|
||||
onDragSelect: (start, end) => onDragSelect(start, end, idx),
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
});
|
||||
}),
|
||||
[
|
||||
|
||||
@@ -418,6 +418,11 @@
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.set-alert-btn {
|
||||
cursor: pointer;
|
||||
margin-left: 24px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
TablePaginationConfig,
|
||||
TableProps as AntDTableProps,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Typography,
|
||||
} from 'antd';
|
||||
import { NotificationInstance } from 'antd/es/notification/interface';
|
||||
@@ -34,15 +35,20 @@ import { getYAxisFormattedValue } from 'components/Graph/yAxisConfig';
|
||||
import Tags from 'components/Tags/Tags';
|
||||
import { SOMETHING_WENT_WRONG } from 'constants/api';
|
||||
import { DATE_TIME_FORMATS } from 'constants/dateTimeFormats';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { initialQueryMeterWithType } from 'constants/queryBuilder';
|
||||
import ROUTES from 'constants/routes';
|
||||
import { INITIAL_ALERT_THRESHOLD_STATE } from 'container/CreateAlertV2/context/constants';
|
||||
import dayjs from 'dayjs';
|
||||
import { useGetDeploymentsData } from 'hooks/CustomDomain/useGetDeploymentsData';
|
||||
import { useGetAllIngestionsKeys } from 'hooks/IngestionKeys/useGetAllIngestionKeys';
|
||||
import useDebouncedFn from 'hooks/useDebouncedFunction';
|
||||
import { useGetTenantLicense } from 'hooks/useGetTenantLicense';
|
||||
import { useNotifications } from 'hooks/useNotifications';
|
||||
import { isNil, isUndefined } from 'lodash-es';
|
||||
import { cloneDeep, isNil, isUndefined } from 'lodash-es';
|
||||
import {
|
||||
ArrowUpRight,
|
||||
BellPlus,
|
||||
CalendarClock,
|
||||
Check,
|
||||
Copy,
|
||||
@@ -60,6 +66,7 @@ import { useTimezone } from 'providers/Timezone';
|
||||
import { ChangeEvent, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useMutation } from 'react-query';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useCopyToClipboard } from 'react-use';
|
||||
import { ErrorResponse } from 'types/api';
|
||||
import {
|
||||
@@ -71,6 +78,7 @@ import {
|
||||
IngestionKeyProps,
|
||||
PaginationProps,
|
||||
} from 'types/api/ingestionKeys/types';
|
||||
import { MeterAggregateOperator } from 'types/common/queryBuilder';
|
||||
import { USER_ROLES } from 'types/roles';
|
||||
import { getDaysUntilExpiry } from 'utils/timeUtils';
|
||||
|
||||
@@ -170,6 +178,8 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const { isEnterpriseSelfHostedUser } = useGetTenantLicense();
|
||||
|
||||
const history = useHistory();
|
||||
|
||||
const [
|
||||
hasCreateLimitForIngestionKeyError,
|
||||
setHasCreateLimitForIngestionKeyError,
|
||||
@@ -694,6 +704,68 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
|
||||
const { formatTimezoneAdjustedTimestamp } = useTimezone();
|
||||
|
||||
const handleCreateAlert = (
|
||||
APIKey: IngestionKeyProps,
|
||||
signal: LimitProps,
|
||||
): void => {
|
||||
let metricName = '';
|
||||
|
||||
switch (signal.signal) {
|
||||
case 'metrics':
|
||||
metricName = 'signoz.meter.metric.datapoint.count';
|
||||
break;
|
||||
case 'traces':
|
||||
metricName = 'signoz.meter.span.size';
|
||||
break;
|
||||
case 'logs':
|
||||
metricName = 'signoz.meter.log.size';
|
||||
break;
|
||||
default:
|
||||
return;
|
||||
}
|
||||
|
||||
const threshold =
|
||||
signal.signal === 'metrics'
|
||||
? signal.config?.day?.count || 0
|
||||
: signal.config?.day?.size || 0;
|
||||
|
||||
const query = {
|
||||
...initialQueryMeterWithType,
|
||||
builder: {
|
||||
...initialQueryMeterWithType.builder,
|
||||
queryData: [
|
||||
{
|
||||
...initialQueryMeterWithType.builder.queryData[0],
|
||||
aggregations: [
|
||||
{
|
||||
...initialQueryMeterWithType.builder.queryData[0].aggregations?.[0],
|
||||
metricName,
|
||||
timeAggregation: MeterAggregateOperator.INCREASE,
|
||||
spaceAggregation: MeterAggregateOperator.SUM,
|
||||
},
|
||||
],
|
||||
filter: {
|
||||
expression: `signoz.workspace.key.id='${APIKey.id}'`,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const stringifiedQuery = JSON.stringify(query);
|
||||
|
||||
const thresholds = cloneDeep(INITIAL_ALERT_THRESHOLD_STATE.thresholds);
|
||||
thresholds[0].thresholdValue = threshold;
|
||||
|
||||
const URL = `${ROUTES.ALERTS_NEW}?showNewCreateAlertsPage=true&${
|
||||
QueryParams.compositeQuery
|
||||
}=${encodeURIComponent(stringifiedQuery)}&${
|
||||
QueryParams.thresholds
|
||||
}=${encodeURIComponent(JSON.stringify(thresholds))}`;
|
||||
|
||||
history.push(URL);
|
||||
};
|
||||
|
||||
const columns: AntDTableProps<IngestionKeyProps>['columns'] = [
|
||||
{
|
||||
title: 'Ingestion Key',
|
||||
@@ -1183,6 +1255,27 @@ function MultiIngestionSettings(): JSX.Element {
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{((signalCfg.usesSize &&
|
||||
limit?.config?.day?.size !== undefined) ||
|
||||
(signalCfg.usesCount &&
|
||||
limit?.config?.day?.count !== undefined)) && (
|
||||
<Tooltip
|
||||
title="Set alert on this limit"
|
||||
placement="top"
|
||||
arrow={false}
|
||||
>
|
||||
<Button
|
||||
icon={<BellPlus size={14} color={Color.BG_CHERRY_400} />}
|
||||
className="set-alert-btn periscope-btn ghost"
|
||||
type="text"
|
||||
data-testid={`set-alert-btn-${signalName}`}
|
||||
onClick={(): void =>
|
||||
handleCreateAlert(APIKey, limitsDict[signalName])
|
||||
}
|
||||
/>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* SECOND limit usage/limit */}
|
||||
|
||||
@@ -1,10 +1,60 @@
|
||||
import { render, screen } from 'tests/test-utils';
|
||||
import { QueryParams } from 'constants/query';
|
||||
import { rest, server } from 'mocks-server/server';
|
||||
import { render, screen, userEvent, waitFor } from 'tests/test-utils';
|
||||
import { LimitProps } from 'types/api/ingestionKeys/limits/types';
|
||||
import {
|
||||
AllIngestionKeyProps,
|
||||
IngestionKeyProps,
|
||||
} from 'types/api/ingestionKeys/types';
|
||||
|
||||
import MultiIngestionSettings from '../MultiIngestionSettings';
|
||||
|
||||
// Extend the existing types to include limits with proper structure
|
||||
interface TestIngestionKeyProps extends Omit<IngestionKeyProps, 'limits'> {
|
||||
limits?: LimitProps[];
|
||||
}
|
||||
|
||||
interface TestAllIngestionKeyProps extends Omit<AllIngestionKeyProps, 'data'> {
|
||||
data: TestIngestionKeyProps[];
|
||||
}
|
||||
|
||||
// Mock useHistory.push to capture navigation URL used by MultiIngestionSettings
|
||||
const mockPush = jest.fn() as jest.MockedFunction<(path: string) => void>;
|
||||
jest.mock('react-router-dom', () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const actual = jest.requireActual('react-router-dom');
|
||||
return {
|
||||
...actual,
|
||||
useHistory: (): { push: typeof mockPush } => ({ push: mockPush }),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock deployments data hook to avoid unrelated network calls in this page
|
||||
jest.mock(
|
||||
'hooks/CustomDomain/useGetDeploymentsData',
|
||||
(): Record<string, unknown> => ({
|
||||
useGetDeploymentsData: (): {
|
||||
data: undefined;
|
||||
isLoading: boolean;
|
||||
isFetching: boolean;
|
||||
isError: boolean;
|
||||
} => ({
|
||||
data: undefined,
|
||||
isLoading: false,
|
||||
isFetching: false,
|
||||
isError: false,
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
const TEST_CREATED_UPDATED = '2024-01-01T00:00:00Z';
|
||||
const TEST_EXPIRES_AT = '2030-01-01T00:00:00Z';
|
||||
const TEST_WORKSPACE_ID = 'w1';
|
||||
const INGESTION_SETTINGS_ROUTE = '/ingestion-settings';
|
||||
|
||||
describe('MultiIngestionSettings Page', () => {
|
||||
beforeEach(() => {
|
||||
render(<MultiIngestionSettings />);
|
||||
mockPush.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -12,6 +62,10 @@ describe('MultiIngestionSettings Page', () => {
|
||||
});
|
||||
|
||||
it('renders MultiIngestionSettings page without crashing', () => {
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
|
||||
expect(screen.getByText('Ingestion Keys')).toBeInTheDocument();
|
||||
|
||||
expect(
|
||||
@@ -27,4 +81,181 @@ describe('MultiIngestionSettings Page', () => {
|
||||
expect(aboutKeyslink).toHaveClass('learn-more');
|
||||
expect(aboutKeyslink).toHaveAttribute('rel', 'noreferrer');
|
||||
});
|
||||
|
||||
it('navigates to create alert with metrics count threshold', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a metrics daily count limit so the alert button is visible
|
||||
const response: TestAllIngestionKeyProps = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
name: 'Key One',
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k1',
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l1',
|
||||
signal: 'metrics',
|
||||
config: { day: { count: 1000 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
|
||||
// Render with initial route to test navigation
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
// Wait for ingestion key to load and expand the row to show limits
|
||||
await screen.findByText('Key One');
|
||||
const expandButton = screen.getByRole('button', { name: /right Key One/i });
|
||||
await user.click(expandButton);
|
||||
|
||||
// Wait for limits section to render and click metrics alert button by test id
|
||||
await screen.findByText('LIMITS');
|
||||
const metricsAlertBtn = (await screen.findByTestId(
|
||||
'set-alert-btn-metrics',
|
||||
)) as HTMLButtonElement;
|
||||
await user.click(metricsAlertBtn);
|
||||
|
||||
// Wait for navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Assert: navigation occurred with correct query parameters
|
||||
const navigationCall = mockPush.mock.calls[0][0] as string;
|
||||
|
||||
// Check URL contains alerts/new route
|
||||
expect(navigationCall).toContain('/alerts/new');
|
||||
expect(navigationCall).toContain('showNewCreateAlertsPage=true');
|
||||
|
||||
// Parse query parameters
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
|
||||
expect(thresholds).toBeDefined();
|
||||
expect(thresholds[0].thresholdValue).toBe(1000);
|
||||
|
||||
// Verify compositeQuery parameter exists and contains correct data
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression for the key
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k1'",
|
||||
);
|
||||
|
||||
// Verify metric name for metrics signal
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.metric.datapoint.count',
|
||||
);
|
||||
});
|
||||
|
||||
it('navigates to create alert for logs with size threshold', async () => {
|
||||
const user = userEvent.setup({ pointerEventsCheck: 0 });
|
||||
|
||||
// Arrange API response with a logs daily size limit so the alert button is visible
|
||||
const response: TestAllIngestionKeyProps = {
|
||||
status: 'success',
|
||||
data: [
|
||||
{
|
||||
name: 'Key Two',
|
||||
expires_at: TEST_EXPIRES_AT,
|
||||
value: 'secret',
|
||||
workspace_id: TEST_WORKSPACE_ID,
|
||||
id: 'k2',
|
||||
created_at: TEST_CREATED_UPDATED,
|
||||
updated_at: TEST_CREATED_UPDATED,
|
||||
tags: [],
|
||||
limits: [
|
||||
{
|
||||
id: 'l2',
|
||||
signal: 'logs',
|
||||
config: { day: { size: 2048 } },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
_pagination: { page: 1, per_page: 10, pages: 1, total: 1 },
|
||||
};
|
||||
|
||||
server.use(
|
||||
rest.get('*/workspaces/me/keys*', (_req, res, ctx) =>
|
||||
res(ctx.status(200), ctx.json(response)),
|
||||
),
|
||||
);
|
||||
|
||||
render(<MultiIngestionSettings />, undefined, {
|
||||
initialRoute: INGESTION_SETTINGS_ROUTE,
|
||||
});
|
||||
|
||||
// Wait for ingestion key to load and expand the row to show limits
|
||||
await screen.findByText('Key Two');
|
||||
const expandButton = screen.getByRole('button', { name: /right Key Two/i });
|
||||
await user.click(expandButton);
|
||||
|
||||
// Wait for limits section to render and click logs alert button by test id
|
||||
await screen.findByText('LIMITS');
|
||||
const logsAlertBtn = (await screen.findByTestId(
|
||||
'set-alert-btn-logs',
|
||||
)) as HTMLButtonElement;
|
||||
await user.click(logsAlertBtn);
|
||||
|
||||
// Wait for navigation to occur
|
||||
await waitFor(() => {
|
||||
expect(mockPush).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Assert: navigation occurred with correct query parameters
|
||||
const navigationCall = mockPush.mock.calls[0][0] as string;
|
||||
|
||||
// Check URL contains alerts/new route
|
||||
expect(navigationCall).toContain('/alerts/new');
|
||||
expect(navigationCall).toContain('showNewCreateAlertsPage=true');
|
||||
|
||||
// Parse query parameters
|
||||
const urlParams = new URLSearchParams(navigationCall.split('?')[1]);
|
||||
|
||||
// Verify thresholds parameter
|
||||
const thresholds = JSON.parse(urlParams.get(QueryParams.thresholds) || '{}');
|
||||
expect(thresholds).toBeDefined();
|
||||
expect(thresholds[0].thresholdValue).toBe(2048);
|
||||
|
||||
// Verify compositeQuery parameter exists and contains correct data
|
||||
const compositeQuery = JSON.parse(
|
||||
urlParams.get(QueryParams.compositeQuery) || '{}',
|
||||
);
|
||||
expect(compositeQuery.builder).toBeDefined();
|
||||
expect(compositeQuery.builder.queryData).toBeDefined();
|
||||
|
||||
// Check that the query contains the correct filter expression for the key
|
||||
const firstQueryData = compositeQuery.builder.queryData[0];
|
||||
expect(firstQueryData.filter.expression).toContain(
|
||||
"signoz.workspace.key.id='k2'",
|
||||
);
|
||||
|
||||
// Verify metric name for logs signal
|
||||
expect(firstQueryData.aggregations[0].metricName).toBe(
|
||||
'signoz.meter.log.size',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import './InfraMetrics.styles.scss';
|
||||
|
||||
import { Empty, Radio } from 'antd';
|
||||
import { Empty } from 'antd';
|
||||
import { RadioChangeEvent } from 'antd/lib';
|
||||
import SignozRadioGroup from 'components/SignozRadioGroup/SignozRadioGroup';
|
||||
import { History, Table } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { DataSource } from 'types/common/queryBuilder';
|
||||
|
||||
import { VIEW_TYPES } from './constants';
|
||||
import NodeMetrics from './NodeMetrics';
|
||||
@@ -14,7 +16,8 @@ interface MetricsDataProps {
|
||||
nodeName: string;
|
||||
hostName: string;
|
||||
clusterName: string;
|
||||
logLineTimestamp: string;
|
||||
timestamp: string;
|
||||
dataSource: DataSource.LOGS | DataSource.TRACES;
|
||||
}
|
||||
|
||||
function InfraMetrics({
|
||||
@@ -22,22 +25,56 @@ function InfraMetrics({
|
||||
nodeName,
|
||||
hostName,
|
||||
clusterName,
|
||||
logLineTimestamp,
|
||||
timestamp,
|
||||
dataSource = DataSource.LOGS,
|
||||
}: MetricsDataProps): JSX.Element {
|
||||
const [selectedView, setSelectedView] = useState<string>(() =>
|
||||
podName ? VIEW_TYPES.POD : VIEW_TYPES.NODE,
|
||||
);
|
||||
|
||||
const viewOptions = useMemo(() => {
|
||||
const options = [
|
||||
{
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Node
|
||||
</div>
|
||||
),
|
||||
value: VIEW_TYPES.NODE,
|
||||
},
|
||||
];
|
||||
|
||||
if (podName) {
|
||||
options.push({
|
||||
label: (
|
||||
<div className="view-title">
|
||||
<History size={14} />
|
||||
Pod
|
||||
</div>
|
||||
),
|
||||
value: VIEW_TYPES.POD,
|
||||
});
|
||||
}
|
||||
|
||||
return options;
|
||||
}, [podName]);
|
||||
|
||||
const handleModeChange = (e: RadioChangeEvent): void => {
|
||||
setSelectedView(e.target.value);
|
||||
};
|
||||
|
||||
if (!podName && !nodeName && !hostName) {
|
||||
const emptyStateDescription =
|
||||
dataSource === DataSource.TRACES
|
||||
? 'No data available. Please select a span containing a pod, node, or host attributes to view metrics.'
|
||||
: 'No data available. Please select a valid log line containing a pod, node, or host attributes to view metrics.';
|
||||
|
||||
return (
|
||||
<div className="empty-container">
|
||||
<Empty
|
||||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||||
description="No data available. Please select a valid log line containing a pod, node, or host attributes to view metrics."
|
||||
description={emptyStateDescription}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -45,46 +82,26 @@ function InfraMetrics({
|
||||
|
||||
return (
|
||||
<div className="infra-metrics-container">
|
||||
<Radio.Group
|
||||
className="views-tabs"
|
||||
onChange={handleModeChange}
|
||||
<SignozRadioGroup
|
||||
value={selectedView}
|
||||
>
|
||||
<Radio.Button
|
||||
className={selectedView === VIEW_TYPES.NODE ? 'selected_view tab' : 'tab'}
|
||||
value={VIEW_TYPES.NODE}
|
||||
>
|
||||
<div className="view-title">
|
||||
<Table size={14} />
|
||||
Node
|
||||
</div>
|
||||
</Radio.Button>
|
||||
{podName && (
|
||||
<Radio.Button
|
||||
className={selectedView === VIEW_TYPES.POD ? 'selected_view tab' : 'tab'}
|
||||
value={VIEW_TYPES.POD}
|
||||
>
|
||||
<div className="view-title">
|
||||
<History size={14} />
|
||||
Pod
|
||||
</div>
|
||||
</Radio.Button>
|
||||
)}
|
||||
</Radio.Group>
|
||||
onChange={handleModeChange}
|
||||
className="views-tabs"
|
||||
options={viewOptions}
|
||||
/>
|
||||
{/* TODO(Rahul): Make a common config driven component for this and other infra metrics components */}
|
||||
{selectedView === VIEW_TYPES.NODE && (
|
||||
<NodeMetrics
|
||||
nodeName={nodeName}
|
||||
clusterName={clusterName}
|
||||
hostName={hostName}
|
||||
logLineTimestamp={logLineTimestamp}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
{selectedView === VIEW_TYPES.POD && podName && (
|
||||
<PodMetrics
|
||||
podName={podName}
|
||||
clusterName={clusterName}
|
||||
logLineTimestamp={logLineTimestamp}
|
||||
timestamp={timestamp}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -29,15 +29,15 @@ function NodeMetrics({
|
||||
nodeName,
|
||||
clusterName,
|
||||
hostName,
|
||||
logLineTimestamp,
|
||||
timestamp,
|
||||
}: {
|
||||
nodeName: string;
|
||||
clusterName: string;
|
||||
hostName: string;
|
||||
logLineTimestamp: string;
|
||||
timestamp: string;
|
||||
}): JSX.Element {
|
||||
const { start, end, verticalLineTimestamp } = useMemo(() => {
|
||||
const logTimestamp = dayjs(logLineTimestamp);
|
||||
const logTimestamp = dayjs(timestamp);
|
||||
const now = dayjs();
|
||||
const startTime = logTimestamp.subtract(3, 'hour');
|
||||
|
||||
@@ -50,7 +50,7 @@ function NodeMetrics({
|
||||
end: endTime.unix(),
|
||||
verticalLineTimestamp: logTimestamp.unix(),
|
||||
};
|
||||
}, [logLineTimestamp]);
|
||||
}, [timestamp]);
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
@@ -83,6 +83,13 @@ function NodeMetrics({
|
||||
const isDarkMode = useIsDarkMode();
|
||||
const graphRef = useRef<HTMLDivElement>(null);
|
||||
const dimensions = useResizeObserver(graphRef);
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const chartData = useMemo(
|
||||
() => queries.map(({ data }) => getUPlotChartData(data?.payload)),
|
||||
@@ -109,6 +116,13 @@ function NodeMetrics({
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
|
||||
timezone: timezone.value,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
|
||||
@@ -23,14 +23,14 @@ import { getPodQueryPayload, podWidgetInfo } from './constants';
|
||||
function PodMetrics({
|
||||
podName,
|
||||
clusterName,
|
||||
logLineTimestamp,
|
||||
timestamp,
|
||||
}: {
|
||||
podName: string;
|
||||
clusterName: string;
|
||||
logLineTimestamp: string;
|
||||
timestamp: string;
|
||||
}): JSX.Element {
|
||||
const { start, end, verticalLineTimestamp } = useMemo(() => {
|
||||
const logTimestamp = dayjs(logLineTimestamp);
|
||||
const logTimestamp = dayjs(timestamp);
|
||||
const now = dayjs();
|
||||
const startTime = logTimestamp.subtract(3, 'hour');
|
||||
|
||||
@@ -43,7 +43,15 @@ function PodMetrics({
|
||||
end: endTime.unix(),
|
||||
verticalLineTimestamp: logTimestamp.unix(),
|
||||
};
|
||||
}, [logLineTimestamp]);
|
||||
}, [timestamp]);
|
||||
|
||||
const legendScrollPositionRef = useRef<{
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}>({
|
||||
scrollTop: 0,
|
||||
scrollLeft: 0,
|
||||
});
|
||||
|
||||
const { featureFlags } = useAppContext();
|
||||
const dotMetricsEnabled =
|
||||
@@ -91,6 +99,13 @@ function PodMetrics({
|
||||
uPlot.tzDate(new Date(timestamp * 1e3), timezone.value),
|
||||
timezone: timezone.value,
|
||||
query: currentQuery,
|
||||
legendScrollPosition: legendScrollPositionRef.current,
|
||||
setLegendScrollPosition: (position: {
|
||||
scrollTop: number;
|
||||
scrollLeft: number;
|
||||
}) => {
|
||||
legendScrollPositionRef.current = position;
|
||||
},
|
||||
}),
|
||||
),
|
||||
[
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
@@ -33,6 +33,7 @@ function Explorer(): JSX.Element {
|
||||
handleRunQuery,
|
||||
stagedQuery,
|
||||
updateAllQueriesOperators,
|
||||
handleSetQueryData,
|
||||
currentQuery,
|
||||
} = useQueryBuilder();
|
||||
const { safeNavigate } = useSafeNavigate();
|
||||
@@ -50,6 +51,15 @@ function Explorer(): JSX.Element {
|
||||
[updateAllQueriesOperators],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
handleSetQueryData(0, {
|
||||
...initialQueryMeterWithType.builder.queryData[0],
|
||||
source: 'meter',
|
||||
});
|
||||
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const exportDefaultQuery = useMemo(
|
||||
() =>
|
||||
updateAllQueriesOperators(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user